From 92d6340ddc96c8a0a061e29a2745165f9cf5bd8e Mon Sep 17 00:00:00 2001 From: Mucahit Bilal GOKER <51519350+bimgeek@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:13:04 +0300 Subject: [PATCH 1/6] feat: bulk test case creation (#346) --- frontend/messages/en.json | 5 ++- frontend/messages/ja.json | 5 ++- frontend/messages/pt-BR.json | 5 ++- .../folders/[folderId]/cases/CaseDialog.tsx | 41 +++++++++++++++---- .../folders/[folderId]/cases/CasesPane.tsx | 6 ++- .../folders/[folderId]/cases/page.tsx | 1 + frontend/types/case.ts | 1 + 7 files changed, 49 insertions(+), 15 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index eee362c..cb599d3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -217,7 +217,7 @@ "delete": "Delete", "close": "Close", "are_you_sure": "Are you sure you want to delete test cases?", - "new_test_case": "New Test Case", + "new_test_case": "New", "export": "Export", "status": "Status", "no_cases_found": "No test cases found", @@ -248,7 +248,8 @@ "click_to_upload": "Click to upload", "or_drag_and_drop": " or drag and drop", "max_file_size": "Max. file size", - "cases_imported": "Test cases imported" + "cases_imported": "Test cases imported", + "create_more": "Create more" }, "Case": { "back_to_cases": "Back to test cases", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 53229ec..8285ad3 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -217,7 +217,7 @@ "delete": "削除", "close": "閉じる", "are_you_sure": "テストケースを削除してもよろしいですか?", - "new_test_case": "新規テストケース", + "new_test_case": "新規", "export": "エクスポート", "status": "ステータス", "no_cases_found": "テストケースがありません", @@ -248,7 +248,8 @@ "click_to_upload": "クリックしてアップロード", "or_drag_and_drop": "またはドラッグアンドドロップ", "max_file_size": "最大ファイルサイズ", - "cases_imported": "テストケースがインポートされました" + "cases_imported": "テストケースがインポートされました", + "create_more": "さらに作成" }, "Case": { "back_to_cases": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 3f3b767..da1dcf8 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -217,7 +217,7 @@ "delete": "Excluir", "close": "Fechar", "are_you_sure": "Tem certeza de que deseja excluir os casos de teste?", - "new_test_case": "Novo Caso de Teste", + "new_test_case": "Novo", "export": "Exportar", "status": "Status", "no_cases_found": "Nenhum caso de teste encontrado", @@ -248,7 +248,8 @@ "click_to_upload": "Clique para enviar", "or_drag_and_drop": " ou arraste e solte", "max_file_size": "Tamanho máx. do arquivo", - "cases_imported": "Casos de teste importados" + "cases_imported": "Casos de teste importados", + "create_more": "Criar mais" }, "Case": { "back_to_cases": "Voltar para os casos de teste", diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx index e718d96..9d261fe 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx @@ -1,12 +1,22 @@ 'use client'; import { useState } from 'react'; -import { Button, Input, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/react'; +import { + Button, + Input, + Textarea, + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Switch, +} from '@heroui/react'; import { CasesMessages } from '@/types/case'; type Props = { isOpen: boolean; onCancel: () => void; - onSubmit: (title: string, description: string) => void; + onSubmit: (title: string, description: string, createMore: boolean) => void; messages: CasesMessages; }; @@ -23,6 +33,8 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro errorMessage: '', }); + const [createMore, setCreateMore] = useState(false); + const clear = () => { setCaseName({ isValid: false, @@ -47,8 +59,23 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro return; } - onSubmit(caseTitle.text, caseDescription.text); - clear(); + onSubmit(caseTitle.text, caseDescription.text, createMore); + + if (!createMore) { + clear(); + } else { + // Reset form fields but keep dialog open + setCaseName({ + isValid: false, + text: 'Untitled Case', + errorMessage: '', + }); + setCaseDescription({ + isValid: false, + text: '', + errorMessage: '', + }); + } }; return ( @@ -88,9 +115,9 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro /> - + + {messages.createMore} + diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx index 5be48ed..e109829 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx @@ -112,10 +112,12 @@ export default function CasesPane({ const closeDialog = () => setIsCaseDialogOpen(false); - const onSubmit = async (title: string, description: string) => { + const onSubmit = async (title: string, description: string, createMore: boolean) => { const newCase = await createCase(context.token.access_token, folderId, title, description); setCases([...cases, newCase]); - closeDialog(); + if (!createMore) { + closeDialog(); + } }; const closeDeleteConfirmDialog = () => { diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx index 11bf0ac..1e275d7 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx @@ -57,6 +57,7 @@ export default function Page({ params }: { params: { projectId: string; folderId orDragAndDrop: t('or_drag_and_drop'), maxFileSize: t('max_file_size'), casesImported: t('cases_imported'), + createMore: t('create_more'), }; const priorityTranslation = useTranslations('Priority'); diff --git a/frontend/types/case.ts b/frontend/types/case.ts index 664439f..49e1ec4 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -105,6 +105,7 @@ type CasesMessages = { orDragAndDrop: string; maxFileSize: string; casesImported: string; + createMore: string; }; type CaseMessages = { From a2fea7a127bf319983c07c92f6d11c5c86882190 Mon Sep 17 00:00:00 2001 From: Matheus Santana <83383321+matheusfsantana@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:00:43 -0300 Subject: [PATCH 2/6] feat: add folder on export and handle steps on import (#352) --- backend/routes/cases/download.js | 9 + backend/routes/cases/download.test.js | 1 + backend/routes/cases/import.js | 175 ++++++++++++------ .../unittcms-import-template-v1.1.xlsx | Bin 0 -> 106490 bytes .../[folderId]/cases/CaseImportDialog.tsx | 2 +- 5 files changed, 126 insertions(+), 61 deletions(-) create mode 100644 frontend/public/template/unittcms-import-template-v1.1.xlsx diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index ab2071d..3714968 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -4,6 +4,7 @@ import { DataTypes } from 'sequelize'; import Papa from 'papaparse'; import defineCase from '../../models/cases.js'; import defineStep from '../../models/steps.js'; +import defineFolder from '../../models/folders.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; @@ -11,6 +12,8 @@ import { testRunStatus, priorities, testTypes, automationStatus, templates } fro export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); const Step = defineStep(sequelize, DataTypes); + const Folder = defineFolder(sequelize, DataTypes); + Case.belongsTo(Folder); Case.belongsToMany(Step, { through: 'caseSteps' }); Step.belongsToMany(Case, { through: 'caseSteps' }); const { verifySignedIn } = authMiddleware(sequelize); @@ -37,6 +40,10 @@ export default function (sequelize) { order: [['stepNo', 'ASC']], attributes: { exclude: ['createdAt', 'updatedAt'] }, }, + { + model: Folder, + attributes: ['name'], + }, ], where: { folderId }, raw: true, @@ -90,6 +97,7 @@ const _formatRawCasesToJson = (cases) => { casesObject[c.id] = { id: c.id, folderId: c.folderId, + folder: c['Folder.name'], title: c.title, state: c.state, priority: c.priority, @@ -119,6 +127,7 @@ const _formatRawCasesToCsv = (cases) => { return cases.map((c) => ({ id: c.id, folderId: c.folderId, + folder: c['Folder.name'], title: c.title, state: c.state, priority: c.priority, diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index 06e60c2..e8a1b56 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -38,6 +38,7 @@ vi.mock('../../middleware/verifyVisible.js', () => ({ const mockCase = { findAll: vi.fn(), belongsToMany: vi.fn(), + belongsTo: vi.fn(), }; vi.mock('../../models/cases.js', () => ({ default: () => mockCase, diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js index 9b07cfd..894b859 100644 --- a/backend/routes/cases/import.js +++ b/backend/routes/cases/import.js @@ -5,6 +5,8 @@ import multer from 'multer'; import XLSX from 'xlsx'; import { DataTypes } from 'sequelize'; import defineCase from '../../models/cases.js'; +import defineStep from '../../models/steps.js'; +import defineCaseStep from '../../models/caseSteps.js'; import authMiddleware from '../../middleware/auth.js'; import editableMiddleware from '../../middleware/verifyEditable.js'; import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; @@ -34,6 +36,10 @@ const upload = multer({ export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); + const Step = defineStep(sequelize, DataTypes); + const CaseStep = defineCaseStep(sequelize, DataTypes); + Case.belongsToMany(Step, { through: CaseStep }); + Step.belongsToMany(Case, { through: CaseStep }); const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize); @@ -60,86 +66,135 @@ export default function (sequelize) { return res.status(400).json({ error: 'folderId is required' }); } + const t = await sequelize.transaction(); try { const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet); - let errorMessage = null; + let currentTitle = null; + let previousTitle = null; + let stepNo = 1; const casesToCreate = []; - const requiredFields = ['title', 'priority', 'type', 'template']; + const stepsToCreate = []; for (const [index, row] of jsonData.entries()) { - const rowNumber = index + 2; - for (const field of requiredFields) { - if (!row[field]) { - errorMessage = `Row ${rowNumber} is missing required field: ${field}`; - console.log(`Error found for field: ${errorMessage}`); - } - } - - // Validate priority if provided - let priorityIndex = priorities.indexOf('medium'); // default to 'medium' - if (row['priority']) { - priorityIndex = priorities.indexOf(row['priority'].toLowerCase()); - if (priorityIndex === -1) { - errorMessage = `Row ${rowNumber} has invalid priority: ${row['priority']}`; - } - } - - // Validate type if provided - let typeIndex = testTypes.indexOf('other'); // default to 'other' - if (row['type']) { - typeIndex = testTypes.indexOf(row['type'].toLowerCase()); - if (typeIndex === -1) { - errorMessage = `Row ${rowNumber} has invalid type: ${row['type']}`; - } - } - - // Validate automationStatus if provided - let automationStatusIndex = automationStatus.indexOf('automation-not-required'); // default to 'automation-not-required' - if (row['automationStatus']) { - automationStatusIndex = automationStatus.indexOf(row['automationStatus'].toLowerCase()); - if (automationStatusIndex === -1) { - errorMessage = `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`; - } - } - - // Validate template if provided - let templateIndex = templates.indexOf('text'); // default to 'text' - if (row['template']) { - templateIndex = templates.indexOf(row['template'].toLowerCase()); - if (templateIndex === -1) { - errorMessage = `Row ${rowNumber} has invalid template: ${row['template']}`; - } - } - + const errorMessage = _getRowValidationError(row, index); if (errorMessage) { return res.status(400).json({ error: errorMessage }); } - casesToCreate.push({ - folderId: folderId, - title: row['title'], - description: row['description'] || '', - state: 0, // default state - priority: priorityIndex, - type: typeIndex, - preConditions: row['preConditions'], - expectedResults: row['expectedResults'], - automationStatus: automationStatusIndex, - template: templateIndex, - }); + // Add step to the same case if the current row title is equal to previous row title + // This handle cases with multiple steps :) + currentTitle = row['title'].trim(); + previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim(); + if (casesToCreate.length > 0 && previousTitle === currentTitle) { + stepNo += 1; + stepsToCreate.push({ + caseIndex: casesToCreate.length - 1, + stepNo: stepNo, + step: row['step'] || '', + result: row['expectedStepResult'] || '', + }); + } else { + stepNo = 1; + casesToCreate.push({ + folderId: folderId, + title: currentTitle, + description: row['description'] || '', + state: 0, // default state + priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'), + type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'), + preConditions: row['preConditions'], + expectedResults: row['expectedResults'], + automationStatus: row['automationStatus'] + ? automationStatus.indexOf(row['automationStatus']) + : automationStatus.indexOf('automation-not-required'), + template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'), + }); + stepsToCreate.push({ + caseIndex: casesToCreate.length - 1, + stepNo: stepNo, + step: row['step'] || '', + result: row['expectedStepResult'] || '', + }); + } } - const createdCases = await Case.bulkCreate(casesToCreate); + // 'Manually' create cases, steps and caseStep association. + const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t }); + for (const stepData of stepsToCreate) { + const createdCase = createdCases[stepData.caseIndex]; + const createdStep = await Step.create( + { + step: stepData.step, + result: stepData.result, + }, + { transaction: t } + ); + await CaseStep.create( + { + caseId: createdCase.id, + stepId: createdStep.id, + stepNo: stepData.stepNo, + }, + { transaction: t } + ); + } + + await t.commit(); res.json(createdCases); } catch (error) { + await t.rollback(); console.error(error); - res.status(500).send('Internal Server Error'); } } ); return router; } + +function _getRowValidationError(row, index) { + const requiredFields = ['title', 'priority', 'type', 'template']; + const rowNumber = index + 2; + + for (const field of requiredFields) { + if (!row[field]) { + return `Row ${rowNumber} is missing required field: ${field}`; + } + } + + // Validate priority if provided + if (row['priority']) { + const priorityIndex = priorities.indexOf(row['priority']?.toLowerCase()); + if (priorityIndex === -1) { + return `Row ${rowNumber} has invalid priority: ${row['priority']}`; + } + } + + // Validate type if provided + if (row['type']) { + const typeIndex = testTypes.indexOf(row['type']?.toLowerCase()); + if (typeIndex === -1) { + return `Row ${rowNumber} has invalid type: ${row['type']}`; + } + } + + // Validate automationStatus if provided + if (row['automationStatus']) { + const automationStatusIndex = automationStatus.indexOf(row['automationStatus']?.toLowerCase()); + if (automationStatusIndex === -1) { + return `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`; + } + } + + // Validate template if provided + if (row['template']) { + const templateIndex = templates.indexOf(row['template']?.toLowerCase()); + if (templateIndex === -1) { + return `Row ${rowNumber} has invalid template: ${row['template']}`; + } + } + + return null; +} diff --git a/frontend/public/template/unittcms-import-template-v1.1.xlsx b/frontend/public/template/unittcms-import-template-v1.1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ff6c70ee6107c7b2729c9fdc0c4cd3d220cb47af GIT binary patch literal 106490 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 literal 0 HcmV?d00001 diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx index 3a1167e..d771130 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx @@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
{messages.importAvailable}
- + {messages.downloadTemplate}
From 3badfb1cadd36c7da788f4d3f80b4e5222629e33 Mon Sep 17 00:00:00 2001 From: Matheus Santana <83383321+matheusfsantana@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:08:14 -0300 Subject: [PATCH 3/6] feat: add filters to run case endpoint (#353) --- backend/routes/cases/indexByProjectId.js | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index c630441..d9badc7 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -1,6 +1,6 @@ import express from 'express'; const router = express.Router(); -import { DataTypes } from 'sequelize'; +import { DataTypes, Op } from 'sequelize'; import defineProject from '../../models/projects.js'; import defineFolder from '../../models/folders.js'; import defineCase from '../../models/cases.js'; @@ -8,6 +8,7 @@ import defineTag from '../../models/tags.js'; import defineRunCase from '../../models/runCases.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { testRunCaseStatus } from '../../config/enums.js'; export default function (sequelize) { const Project = defineProject(sequelize, DataTypes); @@ -33,7 +34,7 @@ export default function (sequelize) { verifyProjectVisibleFromProjectId, verifyProjectVisibleFromRunId, async (req, res) => { - const { projectId, runId } = req.query; + const { projectId, runId, status, tag } = req.query; if (!projectId) { return res.status(400).json({ error: 'projectId is required' }); @@ -43,6 +44,23 @@ export default function (sequelize) { return res.status(400).json({ error: 'runId is required' }); } + let errorMessage = null; + let statusFilter = undefined; + if (status) { + let statusIndex = testRunCaseStatus.indexOf(status.toLowerCase()); + if (statusIndex === -1) { + errorMessage = `Invalid status filter: ${status}`; + } else { + statusFilter = { status: statusIndex }; + } + } + + let tagFilter = tag ? { name: tag } : undefined; + + if (errorMessage) { + return res.status(400).json({ error: errorMessage }); + } + try { const cases = await Case.findAll({ include: [ @@ -56,15 +74,17 @@ export default function (sequelize) { { model: RunCase, attributes: ['id', 'runId', 'status'], - required: false, + // Must be 'true' when filtering by status, otherwise all cases are returned. + required: statusFilter ? true : false, where: { - runId: runId, + [Op.and]: [{ runId: runId }, statusFilter], }, }, { model: Tags, attributes: ['id', 'name'], through: { attributes: [] }, + ...(tagFilter ? { where: tagFilter } : {}), }, ], }); From 805c1f50b6074f63b38d8ad8c567328dbdffd411 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:26:14 +0900 Subject: [PATCH 4/6] feat: Add filter on test run page (#358) --- backend/routes/cases/indexByProjectId.js | 83 +++++--- frontend/messages/en.json | 11 +- frontend/messages/ja.json | 11 +- frontend/messages/pt-BR.json | 11 +- .../runs/[runId]/RunCaseStatus.tsx | 19 ++ .../[projectId]/runs/[runId]/RunEditor.tsx | 77 ++++++- .../runs/[runId]/TestCaseSelector.tsx | 32 +-- .../runs/[runId]/TestRunFilter.tsx | 200 ++++++++++++++++++ .../[projectId]/runs/[runId]/page.tsx | 9 + .../projects/[projectId]/runs/runsControl.ts | 27 ++- frontend/types/run.ts | 9 + 11 files changed, 428 insertions(+), 61 deletions(-) create mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestRunFilter.tsx diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index d9badc7..216c027 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -8,7 +8,6 @@ import defineTag from '../../models/tags.js'; import defineRunCase from '../../models/runCases.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; -import { testRunCaseStatus } from '../../config/enums.js'; export default function (sequelize) { const Project = defineProject(sequelize, DataTypes); @@ -34,7 +33,7 @@ export default function (sequelize) { verifyProjectVisibleFromProjectId, verifyProjectVisibleFromRunId, async (req, res) => { - const { projectId, runId, status, tag } = req.query; + const { projectId, runId, status, tag, search } = req.query; if (!projectId) { return res.status(400).json({ error: 'projectId is required' }); @@ -44,25 +43,62 @@ export default function (sequelize) { return res.status(400).json({ error: 'runId is required' }); } - let errorMessage = null; - let statusFilter = undefined; - if (status) { - let statusIndex = testRunCaseStatus.indexOf(status.toLowerCase()); - if (statusIndex === -1) { - errorMessage = `Invalid status filter: ${status}`; - } else { - statusFilter = { status: statusIndex }; - } - } - - let tagFilter = tag ? { name: tag } : undefined; - - if (errorMessage) { - return res.status(400).json({ error: errorMessage }); - } - try { + // Build where clause for Case model + const caseWhereClause = {}; + + // Handle search parameter + if (search) { + const searchTerm = search.trim(); + + if (searchTerm.length > 100) { + return res.status(400).json({ error: 'too long search param' }); + } + + if (searchTerm.length >= 1) { + caseWhereClause[Op.or] = [ + { title: { [Op.like]: `%${searchTerm}%` } }, + { description: { [Op.like]: `%${searchTerm}%` } }, + ]; + } + } + + // Handle status filter for RunCase + let statusFilter = undefined; + let runCaseRequired = false; + if (status) { + const statusValues = status + .split(',') + .map((t) => parseInt(t.trim(), 10)) + .filter((t) => !isNaN(t)); + + if (statusValues.length > 0) { + statusFilter = { status: { [Op.in]: statusValues } }; + runCaseRequired = true; + } + } + + // Handle tag filter + const tagInclude = { + model: Tags, + attributes: ['id', 'name'], + through: { attributes: [] }, + }; + + if (tag) { + const tagIds = tag + .split(',') + .map((t) => parseInt(t.trim(), 10)) + .filter((t) => !isNaN(t)); + + if (tagIds.length > 0) { + tagInclude.where = { id: { [Op.in]: tagIds } }; + tagInclude.required = true; + } + } + const cases = await Case.findAll({ + where: caseWhereClause, include: [ { model: Folder, @@ -75,17 +111,12 @@ export default function (sequelize) { model: RunCase, attributes: ['id', 'runId', 'status'], // Must be 'true' when filtering by status, otherwise all cases are returned. - required: statusFilter ? true : false, + required: runCaseRequired, where: { [Op.and]: [{ runId: runId }, statusFilter], }, }, - { - model: Tags, - attributes: ['id', 'name'], - through: { attributes: [] }, - ...(tagFilter ? { where: tagFilter } : {}), - }, + tagInclude, ], }); res.json(cases); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cb599d3..0419ca1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -340,7 +340,16 @@ "preconditions": "Preconditions", "expected_result": "Expected result", "details_of_the_step": "Details of the step", - "close": "Close" + "close": "Close", + "filter": "Filter", + "clear_all": "Clear all", + "apply": "Apply", + "select_status": "Select status", + "please_save": "Please save changes", + "case_title_or_description": "Test case title or description", + "selected": "Selected", + "tags": "Tags", + "select_tags": "Select tags" }, "Members": { "member_management": "Member Management", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 8285ad3..d194d95 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -340,7 +340,16 @@ "preconditions": "前提条件", "expected_result": "期待結果", "details_of_the_step": "ステップ詳細", - "close": "閉じる" + "close": "閉じる", + "filter": "フィルター", + "clear_all": "すべてクリア", + "apply": "適用", + "select_status": "ステータスを選択", + "please_save": "変更を保存してください", + "case_title_or_description": "テストケースのタイトルまたは説明", + "selected": "選択済み", + "tags": "タグ", + "select_tags": "タグを選択" }, "Members": { "member_management": "メンバー管理", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index da1dcf8..1d9133f 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -340,7 +340,16 @@ "preconditions": "Pré-condições", "expected_result": "Resultado esperado", "details_of_the_step": "Detalhes do passo", - "close": "Fechar" + "close": "Fechar", + "filter": "Filtrar", + "clear_all": "Limpar tudo", + "apply": "Aplicar", + "select_status": "Selecionar status", + "please_save": "Por favor, salve as alterações", + "case_title_or_description": "Título ou descrição do caso de teste", + "selected": "Selecionado", + "tags": "Tags", + "select_tags": "Selecionar tags" }, "Members": { "member_management": "Gerenciamento de Membros", diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx new file mode 100644 index 0000000..c49c003 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx @@ -0,0 +1,19 @@ +import { Circle, CircleCheck, CircleDashed, CircleX, CircleSlash2 } from 'lucide-react'; + +type Props = { + uid: string; +}; + +export default function RunCaseStatus({ uid }: Props) { + if (uid === 'untested') { + return ; + } else if (uid === 'passed') { + return ; + } else if (uid === 'retest') { + return ; + } else if (uid === 'failed') { + return ; + } else if (uid === 'skipped') { + return ; + } +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx index 1a291a8..09da940 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx @@ -15,6 +15,9 @@ import { DropdownItem, addToast, Badge, + Popover, + PopoverContent, + PopoverTrigger, } from '@heroui/react'; import { Save, @@ -29,6 +32,7 @@ import { FileJson, ChevronRight, Folder, + Filter, } from 'lucide-react'; import { useTheme } from 'next-themes'; import { NodeApi, Tree } from 'react-arborist'; @@ -44,6 +48,7 @@ import { import { fetchFolders } from '../../folders/foldersControl'; import RunProgressChart from './RunPregressDonutChart'; import TestCaseSelector from './TestCaseSelector'; +import TestRunFilter from './TestRunFilter'; import { useRouter } from '@/src/i18n/routing'; import { testRunStatus } from '@/config/selection'; import { RunType, RunStatusCountType, RunMessages } from '@/types/run'; @@ -102,6 +107,9 @@ export default function RunEditor({ const [isNameInvalid] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [isDirty, setIsDirty] = useState(false); + const [searchFilter, setSearchFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); + const [tagFilter, setTagFilter] = useState([]); const router = useRouter(); useFormGuard(isDirty, messages.areYouSureLeave); @@ -111,8 +119,15 @@ export default function RunEditor({ setRunStatusCounts(statusCounts); }; - const initTestCases = async () => { - const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId)); + const initTestCases = async (search?: string, status?: string[], tag?: string[]) => { + const casesData = await fetchProjectCases( + tokenContext.token.access_token, + Number(projectId), + Number(runId), + search, + status, + tag + ); casesData.forEach((testCase: CaseType) => { if (testCase.RunCases && testCase.RunCases.length > 0) { testCase.RunCases[0].editState = 'notChanged'; @@ -200,6 +215,29 @@ export default function RunEditor({ setIsDirty(false); }; + // ************************************************************************** + // Filter + // ************************************************************************** + const [showFilter, setShowFilter] = useState(false); + const [activeFilterNum, setActiveFilterNum] = useState(0); + + const onFilterChange = async (search: string, status: number[], tag: number[]) => { + if (isDirty) { + addToast({ + title: 'Error', + color: 'danger', + description: messages.pleaseSave, + }); + return; + } + + setSearchFilter(search); + setStatusFilter(status); + setTagFilter(tag); + setActiveFilterNum((search ? 1 : 0) + (status.length > 0 ? 1 : 0) + (tag.length > 0 ? 1 : 0)); + await initTestCases(search, status.map(String), tag.map(String)); + }; + return ( <>
@@ -217,6 +255,41 @@ export default function RunEditor({

{testRun.name}

+ setShowFilter(open)}> + + + + + + + { + setShowFilter(false); + onFilterChange(newTitleFilter, newStatusFilters, newTagFilters); + }} + /> + + + + + {testRunCaseStatus.map((status) => ( + } + className="flex items-center" + > + {testRunCaseStatusMessages[status.uid]} + + ))} + + +
+ +
+

{messages.tags}

+ + + + + + {tags.map((tag) => ( + + {tag.name} + + ))} + + +
+
+ +
+ + +
+ + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx index 9bbeee9..f9626a9 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx @@ -35,6 +35,15 @@ export default function Page({ params }: { params: { projectId: string; runId: s expectedResult: t('expected_result'), detailsOfTheStep: t('details_of_the_step'), close: t('close'), + filter: t('filter'), + clearAll: t('clear_all'), + apply: t('apply'), + selectStatus: t('select_status'), + pleaseSave: t('please_save'), + caseTitleOrDescription: t('case_title_or_description'), + selected: t('selected'), + tags: t('tags'), + selectTags: t('select_tags'), }; const rst = useTranslations('RunStatus'); diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts index dd1c2ee..0a30414 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts @@ -315,8 +315,31 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[]) } } -async function fetchProjectCases(jwt: string, projectId: number, runId: number) { - const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`; +async function fetchProjectCases( + jwt: string, + projectId: number, + runId: number, + search?: string, + status?: string[], + tag?: string[] +) { + const queryParams = [`projectId=${projectId}&runId=${runId}`]; + + if (search) { + queryParams.push(`search=${search}`); + } + + if (status && status.length > 0) { + queryParams.push(`status=${status.join(',')}`); + } + + if (tag && tag.length > 0) { + queryParams.push(`tag=${tag.join(',')}`); + } + + const query = `?${queryParams.join('&')}`; + + const url = `${apiServer}/cases/byproject${query}`; try { const response = await fetch(url, { diff --git a/frontend/types/run.ts b/frontend/types/run.ts index c696fe3..2680c75 100644 --- a/frontend/types/run.ts +++ b/frontend/types/run.ts @@ -80,6 +80,15 @@ type RunMessages = { expectedResult: string; detailsOfTheStep: string; close: string; + filter: string; + clearAll: string; + apply: string; + selectStatus: string; + pleaseSave: string; + caseTitleOrDescription: string; + selected: string; + tags: string; + selectTags: string; }; export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages }; From 2adb24dbaabdd25baf38a57228bc923af2aac134 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:37:38 +0900 Subject: [PATCH 5/6] feat: password reset by admin (#360) --- backend/routes/users/adminResetPassword.js | 42 ++++++++++ backend/server.js | 2 + frontend/messages/en.json | 6 +- frontend/messages/ja.json | 6 +- frontend/messages/pt-BR.json | 6 +- frontend/src/app/[locale]/admin/AdminPage.tsx | 42 +++++++++- .../[locale]/admin/PasswordResetDialog.tsx | 76 +++++++++++++++++++ .../src/app/[locale]/admin/UsersTable.tsx | 24 +++++- frontend/src/app/[locale]/admin/page.tsx | 4 + frontend/types/user.ts | 4 + frontend/utils/usersControl.ts | 41 +++++++++- 11 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 backend/routes/users/adminResetPassword.js create mode 100644 frontend/src/app/[locale]/admin/PasswordResetDialog.tsx diff --git a/backend/routes/users/adminResetPassword.js b/backend/routes/users/adminResetPassword.js new file mode 100644 index 0000000..e2d9370 --- /dev/null +++ b/backend/routes/users/adminResetPassword.js @@ -0,0 +1,42 @@ +import express from 'express'; +import bcrypt from 'bcrypt'; +import { DataTypes } from 'sequelize'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; +const router = express.Router(); + +export default function (sequelize) { + const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize); + const User = defineUser(sequelize, DataTypes); + + // Admin resets another user's password + router.put('/:id/password', verifySignedIn, verifyAdmin, async (req, res) => { + try { + const { id } = req.params; + const { newPassword } = req.body; + + if (!newPassword) { + return res.status(400).send('New password is required'); + } + + if (newPassword.length < 8) { + return res.status(400).send('New password must be at least 8 characters'); + } + + const user = await User.findByPk(id); + if (!user) { + return res.status(404).send('User not found'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + await user.update({ password: hashedPassword }); + + return res.json({ user: { id: user.id } }); + } catch (error) { + console.error(error); + return res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index db432b8..52bc492 100644 --- a/backend/server.js +++ b/backend/server.js @@ -52,6 +52,7 @@ import usersFindRoute from './routes/users/find.js'; import usersSearchRoute from './routes/users/search.js'; import usersUpdateUsernameRoute from './routes/users/updateUsername.js'; import usersUpdatePasswordRoute from './routes/users/updatePassword.js'; +import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js'; import usersUpdateAvatarRoute from './routes/users/updateAvatar.js'; import usersUpdateRoleRoute from './routes/users/updateRole.js'; import signUpRoute from './routes/users/signup.js'; @@ -61,6 +62,7 @@ app.use('/users', usersFindRoute(sequelize)); app.use('/users', usersSearchRoute(sequelize)); app.use('/users', usersUpdateUsernameRoute(sequelize)); app.use('/users', usersUpdatePasswordRoute(sequelize)); +app.use('/users', usersAdminResetPasswordRoute(sequelize)); app.use('/users', usersUpdateAvatarRoute(sequelize)); app.use('/users', usersUpdateRoleRoute(sequelize)); app.use('/users', signUpRoute(sequelize)); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0419ca1..83c5ee4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -159,7 +159,11 @@ "quit_confirm": "If you relinquish your administrator privileges, you will no longer be able to access the administration page.", "role_changed": "Role changed", "lost_admin_auth": "Lost admin authority", - "at_least": "At least one administrator is required" + "at_least": "At least one administrator is required", + "reset_password": "Reset password", + "reset": "Reset", + "invalid_password": "Password must be at least 8 characters", + "password_not_match": "Pasword does not match" }, "Projects": { "project_list": "Project List", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index d194d95..9827ae1 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -159,7 +159,11 @@ "quit_confirm": "管理者権限を放棄すると管理ページにアクセスできなくなります。", "role_changed": "ロールを更新しました", "lost_admin_auth": "管理者権限を失いました", - "at_least": "最低1人以上の管理者が必要です。" + "at_least": "最低1人以上の管理者が必要です。", + "reset_password": "パスワードをリセット", + "reset": "リセット", + "invalid_password": "パスワードは8文字以上である必要があります", + "password_not_match": "パスワードが一致しません" }, "Projects": { "project_list": "プロジェクト一覧", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 1d9133f..f372406 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -159,7 +159,11 @@ "quit_confirm": "Se você renunciar aos seus privilégios de administrador, não poderá mais acessar a página de administração.", "role_changed": "Função alterada", "lost_admin_auth": "Perdeu a autoridade de administrador", - "at_least": "É necessário pelo menos um administrador" + "at_least": "É necessário pelo menos um administrador", + "reset_password": "Redefinir senha", + "reset": "Redefinir", + "invalid_password": "A senha deve ter pelo menos 8 caracteres", + "password_not_match": "A senha não corresponde" }, "Projects": { "project_list": "Lista de Projetos", diff --git a/frontend/src/app/[locale]/admin/AdminPage.tsx b/frontend/src/app/[locale]/admin/AdminPage.tsx index 37f8303..1946e84 100644 --- a/frontend/src/app/[locale]/admin/AdminPage.tsx +++ b/frontend/src/app/[locale]/admin/AdminPage.tsx @@ -2,14 +2,15 @@ import { useState, useEffect, useContext } from 'react'; import { Button, addToast } from '@heroui/react'; import UsersTable from './UsersTable'; +import PasswordResetDialog from './PasswordResetDialog'; +import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { UserType, AdminMessages } from '@/types/user'; import { TokenContext } from '@/utils/TokenProvider'; import { useRouter } from '@/src/i18n/routing'; import Config from '@/config/config'; import { LocaleCodeType } from '@/types/locale'; -import { updateUserRole } from '@/utils/usersControl'; +import { updateUserRole, adminResetPassword } from '@/utils/usersControl'; import { roles } from '@/config/selection'; -import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { logError } from '@/utils/errorHandler'; const apiServer = Config.apiServer; @@ -123,6 +124,28 @@ export default function AdminPage({ messages, locale }: Props) { } }; + // Reset password dialog state + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + + const openResetDialog = (user: UserType) => { + setResetTarget(user); + setIsResetDialogOpen(true); + }; + + const onReset = async (newPassword: string) => { + setIsResetDialogOpen(false); + if (!resetTarget || !resetTarget.id) return; + + try { + await adminResetPassword(tokenContext.token.access_token, resetTarget.id, newPassword); + addToast({ title: 'Success', color: 'success', description: 'Password updated' }); + setResetTarget(null); + } catch (error: unknown) { + logError('Failed to reset password', error); + } + }; + return ( <>
@@ -130,7 +153,13 @@ export default function AdminPage({ messages, locale }: Props) {

{messages.userManagement}

- + @@ -144,6 +173,13 @@ export default function AdminPage({ messages, locale }: Props) { confirmText={messages.quitConfirm} deleteText={messages.quit} /> + + setIsResetDialogOpen(false)} + onReset={onReset} + messages={messages} + /> ); } diff --git a/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx b/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx new file mode 100644 index 0000000..c317e2c --- /dev/null +++ b/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx @@ -0,0 +1,76 @@ +import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input } from '@heroui/react'; +import { useState } from 'react'; +import { isValidPassword } from '../account/validate'; +import { AdminMessages } from '@/types/user'; + +type Props = { + isOpen: boolean; + onCancel: () => void; + onReset: (newPassword: string) => void; + messages: AdminMessages; +}; + +export default function PasswordResetDialog({ isOpen, onCancel, onReset, messages }: Props) { + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const validate = async () => { + if (newPassword !== confirmPassword) { + setErrorMessage(messages.passwordNotMatch); + return; + } + + if (!isValidPassword(newPassword)) { + setErrorMessage(messages.invalidPassword); + return; + } + + onReset(newPassword); + }; + + return ( + { + onCancel(); + }} + > + + {messages.resetPassword} + +
+
+ {errorMessage &&
{errorMessage}
} + setNewPassword(e.target.value)} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + autoComplete="new-password" + className="mt-3" + /> +
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/app/[locale]/admin/UsersTable.tsx b/frontend/src/app/[locale]/admin/UsersTable.tsx index ea90486..11f2f88 100644 --- a/frontend/src/app/[locale]/admin/UsersTable.tsx +++ b/frontend/src/app/[locale]/admin/UsersTable.tsx @@ -13,7 +13,7 @@ import { DropdownMenu, DropdownItem, } from '@heroui/react'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, MoreVertical } from 'lucide-react'; import { UserType, AdminMessages } from '@/types/user'; import { roles } from '@/config/selection'; import UserAvatar from '@/components/UserAvatar'; @@ -22,16 +22,18 @@ type Props = { users: UserType[]; myself: UserType | null; onChangeRole: (userEdit: UserType, role: number) => void; + openResetDialog: (user: UserType) => void; messages: AdminMessages; }; -export default function UsersTable({ users, myself, onChangeRole, messages }: Props) { +export default function UsersTable({ users, myself, onChangeRole, openResetDialog, messages }: Props) { const headerColumns = [ { name: messages.avatar, uid: 'avatar', sortable: false }, { name: messages.id, uid: 'id', sortable: true }, { name: messages.email, uid: 'email', sortable: true }, { name: messages.username, uid: 'username', sortable: true }, { name: messages.role, uid: 'role', sortable: true }, + { name: '', uid: 'actions', sortable: false }, ]; const [sortDescriptor, setSortDescriptor] = useState({ @@ -92,6 +94,24 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr ); + case 'actions': + return ( + !isMyself(myself, user) && ( + + + + + + openResetDialog(user)}> + {messages.resetPassword} + + + + ) + ); + default: return cellValue; } diff --git a/frontend/src/app/[locale]/admin/page.tsx b/frontend/src/app/[locale]/admin/page.tsx index b47bf46..f71ac06 100644 --- a/frontend/src/app/[locale]/admin/page.tsx +++ b/frontend/src/app/[locale]/admin/page.tsx @@ -32,6 +32,10 @@ export default function Page({ params }: PageType) { roleChanged: t('role_changed'), lostAdminAuth: t('lost_admin_auth'), atLeast: t('at_least'), + resetPassword: t('reset_password'), + reset: t('reset'), + invalidPassword: t('invalid_password'), + passwordNotMatch: t('password_not_match'), }; return ( diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 993b4b3..cc3e204 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -84,6 +84,10 @@ export type AdminMessages = { roleChanged: string; lostAdminAuth: string; atLeast: string; + resetPassword: string; + reset: string; + invalidPassword: string; + passwordNotMatch: string; }; export type AccountDropDownMessages = { diff --git a/frontend/utils/usersControl.ts b/frontend/utils/usersControl.ts index cc5836c..211e69f 100644 --- a/frontend/utils/usersControl.ts +++ b/frontend/utils/usersControl.ts @@ -189,4 +189,43 @@ async function deleteAvatar(jwt: string) { } } -export { findUser, searchUsers, updateUserRole, updateUsername, updatePassword, uploadAvatar, deleteAvatar }; +async function adminResetPassword(jwt: string, userId: number, newPassword: string) { + const updateData = { + newPassword, + }; + + const fetchOptions = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updateData), + }; + + const url = `${apiServer}/users/${userId}/password`; + + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `HTTP error! Status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error: unknown) { + logError('Error admin resetting password:', error); + throw error; + } +} + +export { + findUser, + searchUsers, + updateUserRole, + updateUsername, + updatePassword, + uploadAvatar, + deleteAvatar, + adminResetPassword, +}; From 8eecbce3c2ff6f752f5c7f6fdbcb3e0fa9e82ce6 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:39:07 +0900 Subject: [PATCH 6/6] chore: update version (#361) --- frontend/src/app/[locale]/health/HealthPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/[locale]/health/HealthPage.tsx b/frontend/src/app/[locale]/health/HealthPage.tsx index cecebd5..2f8aa63 100644 --- a/frontend/src/app/[locale]/health/HealthPage.tsx +++ b/frontend/src/app/[locale]/health/HealthPage.tsx @@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) { {messages.unittcms_version} - 1.0.0-beta.24 + 1.0.0-beta.25 {messages.api_server}