Compare commits
14 Commits
34135209d9
...
feat/ldap-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c0e75637 | ||
|
|
1c977f9266 | ||
|
|
02fa631f02 | ||
|
|
55e78875ae | ||
|
|
e6f3bc799e | ||
|
|
4dcff48792 | ||
|
|
4cafcaeedc | ||
|
|
93e66d0122 | ||
|
|
a065c1800f | ||
|
|
3b059f1897 | ||
|
|
9a5f0a602a | ||
|
|
a9674c81ab | ||
|
|
1f4ac0ae7b | ||
|
|
fe8983e212 |
84
CLAUDE.md
Normal file
84
CLAUDE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# CLAUDE.md — Agent Instructions
|
||||
|
||||
Behavioral guidelines for AI coding agents. Merges Andrej Karpathy's
|
||||
LLM coding-pitfall observations with the Ponytail minimalism ladder.
|
||||
|
||||
**Tradeoff:** bias toward caution *and* minimalism. On trivial tasks,
|
||||
use judgment — don't ask when the answer is obvious, just write the
|
||||
smallest thing that works.
|
||||
|
||||
---
|
||||
|
||||
## 1. Think before coding
|
||||
|
||||
Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||
|
||||
- State assumptions explicitly.
|
||||
- If interpretations genuinely diverge, ask ONE clarifying question.
|
||||
Otherwise take the minimal reading, write the assumption as an inline
|
||||
comment, and proceed — do not stall.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
|
||||
## 2. Write the least code that works (the ladder)
|
||||
|
||||
Before writing any code, stop at the FIRST rung that solves the task:
|
||||
|
||||
1. **Skip it** — is the feature even needed? (YAGNI)
|
||||
2. **Stdlib** — does the standard library already do this?
|
||||
3. **Native** — does the platform/runtime have it built in?
|
||||
4. **Existing dependency** — can something already installed do the job?
|
||||
5. **One line** — can it be one line?
|
||||
6. **Minimum** — only now, write the least code that works.
|
||||
|
||||
No features beyond what was asked. No abstractions for single-use code.
|
||||
No speculative flexibility or configurability. No error handling for
|
||||
impossible scenarios. If you wrote 200 lines and it could be 50, rewrite.
|
||||
|
||||
Test: "Would a senior engineer call this overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Where laziness stops
|
||||
|
||||
Section 2 does NOT apply to these — here, do it properly:
|
||||
|
||||
- Input validation at trust boundaries.
|
||||
- Error handling that prevents data loss.
|
||||
- Security.
|
||||
- Accessibility.
|
||||
- Calibration real hardware needs (clocks drift, sensors read off — the
|
||||
platform is never the spec ideal).
|
||||
- Anything the user explicitly requested.
|
||||
|
||||
## 4. Mark and verify your shortcuts
|
||||
|
||||
Lazy code without a check is unfinished.
|
||||
|
||||
- Mark every intentional simplification with a `ponytail:` comment. If the
|
||||
shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic),
|
||||
the comment names the ceiling and the upgrade path.
|
||||
- Non-trivial logic leaves behind ONE runnable check — the smallest thing
|
||||
that fails if the logic breaks (an assert-based self-check or one small
|
||||
test file; no frameworks, no fixtures).
|
||||
- Turn the task into a verifiable goal and loop until it passes:
|
||||
- "Add validation" → write tests for invalid inputs, then make them pass.
|
||||
- "Fix the bug" → write a test that reproduces it, then make it pass.
|
||||
- "Refactor X" → ensure tests pass before and after.
|
||||
|
||||
For multi-step tasks, state a brief plan: `1. step → verify: check`.
|
||||
|
||||
## 5. Surgical changes
|
||||
|
||||
Touch only what you must. Clean up only your own mess.
|
||||
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor what isn't broken. Match existing style.
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Pre-existing dead code: mention it, don't delete it (unless asked).
|
||||
|
||||
Test: every changed line traces directly to the user's request.
|
||||
|
||||
---
|
||||
|
||||
**Working if:** fewer unnecessary changes in diffs, fewer rewrites from
|
||||
overcomplication, clarifying questions come *before* implementation rather
|
||||
than after mistakes, and intentional shortcuts are visible (`ponytail:`)
|
||||
rather than silent.
|
||||
@@ -87,6 +87,7 @@ UnitTCMS currently supports the following languages:
|
||||
- English (en)
|
||||
- Portuguese (pt-BR)
|
||||
- Chinese (zh-CN)
|
||||
- Traditional Chinese (zh-TW)
|
||||
- Japanese (ja)
|
||||
|
||||
If you would like to add support for another language, feel free to submit a pull request. For reference, you can see how Portuguese was added in [PR #260](https://github.com/kimatata/unittcms/pull/260).
|
||||
|
||||
1
backend/config/locale.js
Normal file
1
backend/config/locale.js
Normal file
@@ -0,0 +1 @@
|
||||
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'];
|
||||
@@ -5,6 +5,7 @@ import defineProject from '../models/projects.js';
|
||||
import defineFolder from '../models/folders.js';
|
||||
import defineCase from '../models/cases.js';
|
||||
import defineRun from '../models/runs.js';
|
||||
import defineRunCase from '../models/runCases.js';
|
||||
|
||||
export default function verifyEditableMiddleware(sequelize) {
|
||||
/**
|
||||
@@ -243,6 +244,55 @@ export default function verifyEditableMiddleware(sequelize) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is reporter of the project by CommentableId
|
||||
* (have to be called after verifySignedIn() middleware)
|
||||
*/
|
||||
async function verifyProjectReporterFromCommentableId(req, res, next) {
|
||||
const commentableType = req.params.commentableType || req.query.commentableType;
|
||||
const commentableId = req.params.commentableId || req.query.commentableId;
|
||||
if (!commentableType || !commentableId) {
|
||||
return res.status(400).json({ error: 'commentableType and commentableId are required' });
|
||||
}
|
||||
|
||||
if (commentableType === 'Run') {
|
||||
// not implemented yet
|
||||
next();
|
||||
return;
|
||||
} else if (commentableType === 'Case') {
|
||||
// not implemented yet
|
||||
next();
|
||||
return;
|
||||
} else if (commentableType === 'RunCase') {
|
||||
const RunCase = defineRunCase(sequelize, DataTypes);
|
||||
const runCaseId = req.params.commentableId || req.query.commentableId;
|
||||
if (!runCaseId) {
|
||||
return res.status(400).json({ error: 'runCaseId is required' });
|
||||
}
|
||||
|
||||
const runCase = await RunCase.findByPk(runCaseId);
|
||||
const runId = runCase && runCase.runId;
|
||||
if (!runId) {
|
||||
return res.status(404).send('failed to find runId');
|
||||
}
|
||||
|
||||
const Run = defineRun(sequelize, DataTypes);
|
||||
const run = await Run.findByPk(runId);
|
||||
const projectId = run && run.projectId;
|
||||
if (!projectId) {
|
||||
return res.status(404).send('failed to find projectId');
|
||||
}
|
||||
|
||||
const isReporterRet = await isReporter(projectId, req.userId);
|
||||
if (isReporterRet) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ error: 'unsupported commentableType' });
|
||||
}
|
||||
}
|
||||
|
||||
async function isReporter(projectId, userId) {
|
||||
const Project = defineProject(sequelize, DataTypes);
|
||||
const Member = defineMember(sequelize, DataTypes);
|
||||
@@ -289,5 +339,6 @@ export default function verifyEditableMiddleware(sequelize) {
|
||||
verifyProjectDeveloperFromCaseId,
|
||||
verifyProjectReporterFromProjectId,
|
||||
verifyProjectReporterFromRunId,
|
||||
verifyProjectReporterFromCommentableId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import defineProject from '../models/projects.js';
|
||||
import defineFolder from '../models/folders.js';
|
||||
import defineCase from '../models/cases.js';
|
||||
import defineRun from '../models/runs.js';
|
||||
import defineRunCase from '../models/runCases.js';
|
||||
|
||||
export default function verifyVisibleMiddleware(sequelize) {
|
||||
/**
|
||||
@@ -16,8 +17,8 @@ export default function verifyVisibleMiddleware(sequelize) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
const isVisble = await isVisible(projectId, req.userId);
|
||||
if (isVisble) {
|
||||
const visible = await isVisible(projectId, req.userId);
|
||||
if (visible) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -44,8 +45,8 @@ export default function verifyVisibleMiddleware(sequelize) {
|
||||
return res.status(404).send('failed to find projectId');
|
||||
}
|
||||
|
||||
const isVisble = await isVisible(projectId, req.userId);
|
||||
if (isVisble) {
|
||||
const visible = await isVisible(projectId, req.userId);
|
||||
if (visible) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -80,8 +81,8 @@ export default function verifyVisibleMiddleware(sequelize) {
|
||||
return res.status(404).send('failed to find projectId');
|
||||
}
|
||||
|
||||
const isVisble = await isVisible(projectId, req.userId);
|
||||
if (isVisble) {
|
||||
const visible = await isVisible(projectId, req.userId);
|
||||
if (visible) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -108,8 +109,8 @@ export default function verifyVisibleMiddleware(sequelize) {
|
||||
return res.status(404).send('failed to find projectId');
|
||||
}
|
||||
|
||||
const isVisble = await isVisible(projectId, req.userId);
|
||||
if (isVisble) {
|
||||
const visible = await isVisible(projectId, req.userId);
|
||||
if (visible) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -117,6 +118,51 @@ export default function verifyVisibleMiddleware(sequelize) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
async function verifyProjectVisibleFromCommentableId(req, res, next) {
|
||||
const commentableType = req.params.commentableType || req.query.commentableType;
|
||||
const commentableId = req.params.commentableId || req.query.commentableId;
|
||||
if (!commentableType || !commentableId) {
|
||||
return res.status(400).json({ error: 'commentableType and commentableId are required' });
|
||||
}
|
||||
|
||||
if (commentableType === 'Run') {
|
||||
// not implemented yet
|
||||
next();
|
||||
return;
|
||||
} else if (commentableType === 'Case') {
|
||||
// not implemented yet
|
||||
next();
|
||||
return;
|
||||
} else if (commentableType === 'RunCase') {
|
||||
const RunCase = defineRunCase(sequelize, DataTypes);
|
||||
const runCaseId = req.params.commentableId || req.query.commentableId;
|
||||
if (!runCaseId) {
|
||||
return res.status(400).json({ error: 'runCaseId is required' });
|
||||
}
|
||||
|
||||
const runCase = await RunCase.findByPk(runCaseId);
|
||||
const runId = runCase && runCase.runId;
|
||||
if (!runId) {
|
||||
return res.status(404).send('failed to find runId');
|
||||
}
|
||||
|
||||
const Run = defineRun(sequelize, DataTypes);
|
||||
const run = await Run.findByPk(runId);
|
||||
const projectId = run && run.projectId;
|
||||
if (!projectId) {
|
||||
return res.status(404).send('failed to find projectId');
|
||||
}
|
||||
|
||||
const visible = await isVisible(projectId, req.userId);
|
||||
if (visible) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ error: 'unsupported commentableType' });
|
||||
}
|
||||
}
|
||||
|
||||
async function isVisible(projectId, userId) {
|
||||
const Project = defineProject(sequelize, DataTypes);
|
||||
const Member = defineMember(sequelize, DataTypes);
|
||||
@@ -158,5 +204,6 @@ export default function verifyVisibleMiddleware(sequelize) {
|
||||
verifyProjectVisibleFromFolderId,
|
||||
verifyProjectVisibleFromCaseId,
|
||||
verifyProjectVisibleFromRunId,
|
||||
verifyProjectVisibleFromCommentableId,
|
||||
};
|
||||
}
|
||||
|
||||
47
backend/migrations/20260131000000-create-comments.js
Normal file
47
backend/migrations/20260131000000-create-comments.js
Normal file
@@ -0,0 +1,47 @@
|
||||
export async function up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('comments', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
commentableType: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
commentableId: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
userId: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Add composite index for efficient polymorphic queries
|
||||
await queryInterface.addIndex('comments', ['commentableType', 'commentableId'], {
|
||||
name: 'comments_commentable_index',
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface) {
|
||||
await queryInterface.dropTable('comments');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export async function up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'locale', {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface) {
|
||||
await queryInterface.removeColumn('users', 'locale');
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export async function up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('runCaseAttachments', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
runCaseId: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'runCases',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
attachmentId: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'attachments',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('runCaseAttachments', ['runCaseId', 'attachmentId'], {
|
||||
unique: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface) {
|
||||
await queryInterface.dropTable('runCaseAttachments');
|
||||
}
|
||||
61
backend/migrations/20260625000000-create-ldap-settings.js
Normal file
61
backend/migrations/20260625000000-create-ldap-settings.js
Normal file
@@ -0,0 +1,61 @@
|
||||
export async function up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('ldapSettings', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
enabled: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
url: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindDn: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindCredentials: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
searchBase: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
searchFilter: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '(mail={{username}})',
|
||||
},
|
||||
emailAttribute: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'mail',
|
||||
},
|
||||
usernameAttribute: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'cn',
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface) {
|
||||
await queryInterface.dropTable('ldapSettings');
|
||||
}
|
||||
47
backend/models/comments.js
Normal file
47
backend/models/comments.js
Normal file
@@ -0,0 +1,47 @@
|
||||
function defineComment(sequelize, DataTypes) {
|
||||
const Comment = sequelize.define('Comment', {
|
||||
commentableType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
commentableId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
|
||||
Comment.associate = (models) => {
|
||||
// Polymorphic associations
|
||||
Comment.belongsTo(models.RunCase, {
|
||||
foreignKey: 'commentableId',
|
||||
constraints: false,
|
||||
as: 'runCase',
|
||||
});
|
||||
Comment.belongsTo(models.Run, {
|
||||
foreignKey: 'commentableId',
|
||||
constraints: false,
|
||||
as: 'run',
|
||||
});
|
||||
Comment.belongsTo(models.Case, {
|
||||
foreignKey: 'commentableId',
|
||||
constraints: false,
|
||||
as: 'case',
|
||||
});
|
||||
Comment.belongsTo(models.User, {
|
||||
foreignKey: 'userId',
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
};
|
||||
|
||||
return Comment;
|
||||
}
|
||||
|
||||
export default defineComment;
|
||||
49
backend/models/ldapSettings.js
Normal file
49
backend/models/ldapSettings.js
Normal file
@@ -0,0 +1,49 @@
|
||||
function defineLdapSetting(sequelize, DataTypes) {
|
||||
const LdapSetting = sequelize.define('LdapSetting', {
|
||||
enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
url: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindDn: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindCredentials: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
searchBase: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
// {{username}} is replaced with the value typed in the sign-in form.
|
||||
searchFilter: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '(mail={{username}})',
|
||||
},
|
||||
emailAttribute: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'mail',
|
||||
},
|
||||
usernameAttribute: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'cn',
|
||||
},
|
||||
});
|
||||
|
||||
return LdapSetting;
|
||||
}
|
||||
|
||||
export default defineLdapSetting;
|
||||
27
backend/models/runCaseAttachments.js
Normal file
27
backend/models/runCaseAttachments.js
Normal file
@@ -0,0 +1,27 @@
|
||||
function defineRunCaseAttachment(sequelize, DataTypes) {
|
||||
const RunCaseAttachment = sequelize.define('RunCaseAttachment', {
|
||||
runCaseId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
attachmentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
|
||||
RunCaseAttachment.associate = (models) => {
|
||||
RunCaseAttachment.belongsTo(models.RunCase, {
|
||||
foreignKey: 'runCaseId',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
RunCaseAttachment.belongsTo(models.Attachment, {
|
||||
foreignKey: 'attachmentId',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
};
|
||||
|
||||
return RunCaseAttachment;
|
||||
}
|
||||
|
||||
export default defineRunCaseAttachment;
|
||||
@@ -23,6 +23,10 @@ function defineRunCase(sequelize, DataTypes) {
|
||||
foreignKey: 'caseId',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
RunCase.hasMany(models.Comment, {
|
||||
foreignKey: 'commentableId',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
};
|
||||
|
||||
return RunCase;
|
||||
|
||||
@@ -22,6 +22,10 @@ function defineUser(sequelize, DataTypes) {
|
||||
avatarPath: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
locale: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{ underscored: true }
|
||||
);
|
||||
|
||||
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"sequelize": "^6.37.7",
|
||||
@@ -1010,6 +1011,29 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
@@ -1099,7 +1123,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -1840,6 +1863,18 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ldapts": {
|
||||
"version": "8.1.8",
|
||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
|
||||
"integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strict-event-emitter-types": "2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -3068,6 +3103,12 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter-types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -4266,6 +4307,26 @@
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
|
||||
},
|
||||
"encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
@@ -4328,7 +4389,6 @@
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -4863,6 +4923,14 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"ldapts": {
|
||||
"version": "8.1.8",
|
||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
|
||||
"integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
|
||||
"requires": {
|
||||
"strict-event-emitter-types": "2.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -5707,6 +5775,11 @@
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
|
||||
},
|
||||
"strict-event-emitter-types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"sequelize": "^6.37.7",
|
||||
|
||||
@@ -12,18 +12,15 @@ import editableMiddleware from '../../middleware/verifyEditable.js';
|
||||
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedFileTypes = ['.xlsx', '.xls'];
|
||||
const allowedMimeTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
];
|
||||
// Accept Excel and JSON by extension. JSON mime types vary across browsers
|
||||
// (application/json, text/plain, application/octet-stream), so rely on the extension.
|
||||
const allowedFileTypes = ['.xlsx', '.xls', '.json'];
|
||||
const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedMimeTypes.includes(file.mimetype);
|
||||
|
||||
if (extname && mimetype) {
|
||||
if (extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only Excel files (.xlsx, .xls) are allowed!'));
|
||||
cb(new Error('Only Excel (.xlsx, .xls) or JSON (.json) files are allowed!'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,9 +63,63 @@ export default function (sequelize) {
|
||||
return res.status(400).json({ error: 'folderId is required' });
|
||||
}
|
||||
|
||||
// Parse and validate the file before opening a transaction.
|
||||
// Both parsers return the same { casesToCreate, stepsToCreate } shape,
|
||||
// or throw an Error with a user-facing validation message.
|
||||
let casesToCreate;
|
||||
let stepsToCreate;
|
||||
try {
|
||||
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||
const parsed =
|
||||
ext === '.json' ? _parseJsonBuffer(req.file.buffer, folderId) : _parseExcelBuffer(req.file.buffer, folderId);
|
||||
casesToCreate = parsed.casesToCreate;
|
||||
stepsToCreate = parsed.stepsToCreate;
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
||||
// '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);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Parse an Excel (.xlsx, .xls) buffer into { casesToCreate, stepsToCreate }.
|
||||
// Rows sharing the same title are grouped into a single case with multiple steps.
|
||||
function _parseExcelBuffer(buffer, folderId) {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
@@ -81,7 +132,7 @@ export default function (sequelize) {
|
||||
for (const [index, row] of jsonData.entries()) {
|
||||
const errorMessage = _getRowValidationError(row, index);
|
||||
if (errorMessage) {
|
||||
return res.status(400).json({ error: errorMessage });
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Add step to the same case if the current row title is equal to previous row title
|
||||
@@ -121,37 +172,62 @@ export default function (sequelize) {
|
||||
}
|
||||
}
|
||||
|
||||
// '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 }
|
||||
);
|
||||
return { casesToCreate, stepsToCreate };
|
||||
}
|
||||
|
||||
// Parse a JSON buffer into { casesToCreate, stepsToCreate }.
|
||||
// Accepts the same shape produced by the JSON export (`/cases/download?type=json`):
|
||||
// either an array of case objects, or an object map keyed by id.
|
||||
// `id`, `folderId`, `folder` and `state` are ignored; cases are created in the
|
||||
// target folder with the default `new` state.
|
||||
function _parseJsonBuffer(buffer, folderId) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(buffer.toString('utf-8'));
|
||||
} catch {
|
||||
throw new Error('Invalid JSON file');
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
res.json(createdCases);
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error(error);
|
||||
const rawCases = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed) : null;
|
||||
if (!rawCases || rawCases.length === 0) {
|
||||
throw new Error('No cases found in JSON file');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
const casesToCreate = [];
|
||||
const stepsToCreate = [];
|
||||
for (const [index, c] of rawCases.entries()) {
|
||||
const errorMessage = _getJsonCaseValidationError(c, index);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
casesToCreate.push({
|
||||
folderId: folderId,
|
||||
title: String(c.title).trim(),
|
||||
description: c.description || '',
|
||||
state: 0, // default state
|
||||
priority: priorities.indexOf(c.priority),
|
||||
type: testTypes.indexOf(c.type),
|
||||
preConditions: c.preConditions || '',
|
||||
expectedResults: c.expectedResults || '',
|
||||
automationStatus: c.automationStatus
|
||||
? automationStatus.indexOf(c.automationStatus)
|
||||
: automationStatus.indexOf('automation-not-required'),
|
||||
template: templates.indexOf(c.template),
|
||||
});
|
||||
|
||||
const steps = Array.isArray(c.Steps) ? c.Steps : [];
|
||||
steps.forEach((s, stepIndex) => {
|
||||
stepsToCreate.push({
|
||||
caseIndex: casesToCreate.length - 1,
|
||||
stepNo: stepIndex + 1,
|
||||
step: s?.step || '',
|
||||
result: s?.expectedStepResult || s?.result || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { casesToCreate, stepsToCreate };
|
||||
}
|
||||
|
||||
function _getRowValidationError(row, index) {
|
||||
@@ -198,3 +274,37 @@ function _getRowValidationError(row, index) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function _getJsonCaseValidationError(c, index) {
|
||||
const caseNumber = index + 1;
|
||||
|
||||
if (!c || typeof c !== 'object') {
|
||||
return `Case ${caseNumber} is not a valid object`;
|
||||
}
|
||||
|
||||
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||
for (const field of requiredFields) {
|
||||
if (!c[field]) {
|
||||
return `Case ${caseNumber} is missing required field: ${field}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Values are human-readable labels, matching the JSON export format.
|
||||
if (priorities.indexOf(c.priority) === -1) {
|
||||
return `Case ${caseNumber} has invalid priority: ${c.priority}`;
|
||||
}
|
||||
|
||||
if (testTypes.indexOf(c.type) === -1) {
|
||||
return `Case ${caseNumber} has invalid type: ${c.type}`;
|
||||
}
|
||||
|
||||
if (templates.indexOf(c.template) === -1) {
|
||||
return `Case ${caseNumber} has invalid template: ${c.template}`;
|
||||
}
|
||||
|
||||
if (c.automationStatus && automationStatus.indexOf(c.automationStatus) === -1) {
|
||||
return `Case ${caseNumber} has invalid automationStatus: ${c.automationStatus}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
163
backend/routes/cases/import.test.js
Normal file
163
backend/routes/cases/import.test.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import casesImportRoute from './import.js';
|
||||
|
||||
// mock of authentication / permission middleware
|
||||
vi.mock('../../middleware/auth.js', () => ({
|
||||
default: () => ({
|
||||
verifySignedIn: vi.fn((req, res, next) => {
|
||||
req.userId = 1;
|
||||
next();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../middleware/verifyEditable.js', () => ({
|
||||
default: () => ({
|
||||
verifyProjectDeveloperFromFolderId: vi.fn((req, res, next) => {
|
||||
next();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCase = {
|
||||
bulkCreate: vi.fn(),
|
||||
belongsToMany: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/cases.js', () => ({
|
||||
default: () => mockCase,
|
||||
}));
|
||||
|
||||
const mockStep = {
|
||||
create: vi.fn(),
|
||||
belongsToMany: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/steps.js', () => ({
|
||||
default: () => mockStep,
|
||||
}));
|
||||
|
||||
const mockCaseStep = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/caseSteps.js', () => ({
|
||||
default: () => mockCaseStep,
|
||||
}));
|
||||
|
||||
describe('POST /import with JSON file', () => {
|
||||
let app;
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', logging: false });
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', casesImportRoute(sequelize));
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(sequelize, 'transaction').mockResolvedValue({
|
||||
commit: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const attachJson = (data) =>
|
||||
request(app)
|
||||
.post('/import?folderId=1')
|
||||
.attach('file', Buffer.from(JSON.stringify(data)), 'cases.json');
|
||||
|
||||
it('creates cases from a JSON array, mapping labels to enum indexes', async () => {
|
||||
mockCase.bulkCreate.mockResolvedValue([{ id: 101 }, { id: 102 }]);
|
||||
mockStep.create.mockResolvedValue({ id: 201 });
|
||||
mockCaseStep.create.mockResolvedValue({});
|
||||
|
||||
const response = await attachJson([
|
||||
{
|
||||
title: 'Text case',
|
||||
priority: 'high',
|
||||
type: 'functional',
|
||||
template: 'text',
|
||||
description: 'desc',
|
||||
Steps: [],
|
||||
},
|
||||
{
|
||||
title: 'Step case',
|
||||
priority: 'medium',
|
||||
type: 'acceptance',
|
||||
template: 'step',
|
||||
automationStatus: 'cannot-be-automated',
|
||||
Steps: [
|
||||
{ step: 'do A', expectedStepResult: 'A done' },
|
||||
{ step: 'do B', expectedStepResult: 'B done' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const created = mockCase.bulkCreate.mock.calls[0][0];
|
||||
expect(created).toHaveLength(2);
|
||||
expect(created[0]).toMatchObject({
|
||||
folderId: '1',
|
||||
title: 'Text case',
|
||||
state: 0,
|
||||
priority: 1, // high
|
||||
type: 4, // functional
|
||||
template: 0, // text
|
||||
automationStatus: 1, // default automation-not-required
|
||||
});
|
||||
expect(created[1]).toMatchObject({
|
||||
title: 'Step case',
|
||||
priority: 2, // medium
|
||||
type: 5, // acceptance
|
||||
template: 1, // step
|
||||
automationStatus: 2, // cannot-be-automated
|
||||
});
|
||||
|
||||
// Only the second case has steps -> 2 steps created
|
||||
expect(mockStep.create).toHaveBeenCalledTimes(2);
|
||||
expect(mockCaseStep.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts the object-map shape produced by the JSON export', async () => {
|
||||
mockCase.bulkCreate.mockResolvedValue([{ id: 101 }]);
|
||||
|
||||
const response = await attachJson({
|
||||
1: { title: 'Mapped case', priority: 'low', type: 'other', template: 'text', Steps: [] },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCase.bulkCreate.mock.calls[0][0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 400 when a required field is missing', async () => {
|
||||
const response = await attachJson([{ priority: 'high', type: 'functional', template: 'text' }]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('missing required field: title');
|
||||
expect(mockCase.bulkCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid priority label', async () => {
|
||||
const response = await attachJson([
|
||||
{ title: 'Bad priority', priority: 'urgent', type: 'functional', template: 'text' },
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('invalid priority');
|
||||
});
|
||||
|
||||
it('returns 400 for malformed JSON', async () => {
|
||||
const response = await request(app)
|
||||
.post('/import?folderId=1')
|
||||
.attach('file', Buffer.from('{ not json'), 'cases.json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid JSON file');
|
||||
});
|
||||
|
||||
it('rejects unsupported file extensions', async () => {
|
||||
const response = await request(app).post('/import?folderId=1').attach('file', Buffer.from('hello'), 'cases.txt');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('Only Excel');
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,19 @@ export default function (sequelize) {
|
||||
},
|
||||
{
|
||||
model: RunCase,
|
||||
attributes: ['id', 'runId', 'status'],
|
||||
attributes: [
|
||||
'id',
|
||||
'runId',
|
||||
'status',
|
||||
[
|
||||
sequelize.literal(
|
||||
'(SELECT COUNT(*) FROM `comments` WHERE `comments`.`commentableType` = ' +
|
||||
sequelize.escape('RunCase') +
|
||||
' AND `comments`.`commentableId` = `RunCases`.`id`)'
|
||||
),
|
||||
'commentCount',
|
||||
],
|
||||
],
|
||||
// Must be 'true' when filtering by status, otherwise all cases are returned.
|
||||
required: runCaseRequired,
|
||||
where: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import defineTag from '../../models/tags.js';
|
||||
import defineAttachment from '../../models/attachments.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||
import defineRunCase from '../../models/runCases.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const Case = defineCase(sequelize, DataTypes);
|
||||
@@ -19,6 +20,10 @@ export default function (sequelize) {
|
||||
Attachment.belongsToMany(Case, { through: 'caseAttachments' });
|
||||
Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' });
|
||||
Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' });
|
||||
const RunCase = defineRunCase(sequelize, DataTypes);
|
||||
RunCase.belongsTo(Case, { foreignKey: 'caseId' });
|
||||
Case.hasMany(RunCase, { foreignKey: 'caseId' });
|
||||
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const { verifyProjectVisibleFromCaseId } = visibilityMiddleware(sequelize);
|
||||
|
||||
@@ -44,6 +49,9 @@ export default function (sequelize) {
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: RunCase,
|
||||
},
|
||||
],
|
||||
});
|
||||
return res.json(testcase);
|
||||
|
||||
38
backend/routes/comments/delete.js
Normal file
38
backend/routes/comments/delete.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineComment from '../../models/comments.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const Comment = defineComment(sequelize, DataTypes);
|
||||
|
||||
router.delete('/:commentId', verifySignedIn, async (req, res) => {
|
||||
const commentId = req.params.commentId;
|
||||
|
||||
if (!commentId) {
|
||||
return res.status(400).json({ error: 'commentId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await Comment.findByPk(commentId);
|
||||
if (!comment) {
|
||||
return res.status(404).json({ error: 'Comment not found' });
|
||||
}
|
||||
|
||||
// Verify the user owns the comment
|
||||
if (comment.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await comment.destroy();
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
53
backend/routes/comments/edit.js
Normal file
53
backend/routes/comments/edit.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineComment from '../../models/comments.js';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const Comment = defineComment(sequelize, DataTypes);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
Comment.belongsTo(User, { foreignKey: 'userId' });
|
||||
|
||||
router.put('/:commentId', verifySignedIn, async (req, res) => {
|
||||
const commentId = req.params.commentId;
|
||||
const { content } = req.body;
|
||||
|
||||
if (!commentId || !content) {
|
||||
return res.status(400).json({ error: 'id and content are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await Comment.findByPk(commentId);
|
||||
if (!comment) {
|
||||
return res.status(404).json({ error: 'Comment not found' });
|
||||
}
|
||||
|
||||
// Verify the user owns the comment
|
||||
if (comment.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await comment.update({ content });
|
||||
|
||||
// Fetch the comment with user data
|
||||
const commentWithUser = await Comment.findByPk(commentId, {
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.User,
|
||||
attributes: ['id', 'username', 'email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.json(commentWithUser);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
46
backend/routes/comments/index.js
Normal file
46
backend/routes/comments/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineComment from '../../models/comments.js';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const { verifyProjectVisibleFromCommentableId } = visibilityMiddleware(sequelize);
|
||||
const Comment = defineComment(sequelize, DataTypes);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
Comment.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
|
||||
User.hasMany(Comment, { foreignKey: 'userId', onDelete: 'CASCADE' });
|
||||
|
||||
router.get('/', verifySignedIn, verifyProjectVisibleFromCommentableId, async (req, res) => {
|
||||
const { commentableType, commentableId } = req.query;
|
||||
|
||||
if (!commentableType || !commentableId) {
|
||||
return res.status(400).json({ error: 'commentableType and commentableId are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const comments = await Comment.findAll({
|
||||
where: {
|
||||
commentableType: commentableType,
|
||||
commentableId: commentableId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
attributes: ['id', 'username', 'email'],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
res.json(comments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
50
backend/routes/comments/new.js
Normal file
50
backend/routes/comments/new.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineComment from '../../models/comments.js';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import editableMiddleware from '../../middleware/verifyEditable.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const { verifyProjectReporterFromCommentableId } = editableMiddleware(sequelize);
|
||||
const Comment = defineComment(sequelize, DataTypes);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
Comment.belongsTo(User, { foreignKey: 'userId' });
|
||||
|
||||
router.post('/', verifySignedIn, verifyProjectReporterFromCommentableId, async (req, res) => {
|
||||
const { commentableType, commentableId } = req.query;
|
||||
const { content } = req.body;
|
||||
|
||||
if (!commentableType || !commentableId || !content) {
|
||||
return res.status(400).json({ error: 'commentableType, commentableId, and content are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const newComment = await Comment.create({
|
||||
commentableType: commentableType,
|
||||
commentableId: commentableId,
|
||||
userId: req.userId,
|
||||
content: content,
|
||||
});
|
||||
|
||||
// Fetch the comment with user data
|
||||
const commentWithUser = await Comment.findByPk(newComment.id, {
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.User,
|
||||
attributes: ['id', 'username', 'email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.json(commentWithUser);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
48
backend/routes/ldap/edit.js
Normal file
48
backend/routes/ldap/edit.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineLdapSetting from '../../models/ldapSettings.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import { getLdapSettings } from './ldapClient.js';
|
||||
import { serialize } from './index.js';
|
||||
const router = express.Router();
|
||||
|
||||
const editableFields = [
|
||||
'enabled',
|
||||
'url',
|
||||
'bindDn',
|
||||
'searchBase',
|
||||
'searchFilter',
|
||||
'emailAttribute',
|
||||
'usernameAttribute',
|
||||
];
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
|
||||
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
|
||||
|
||||
router.put('/', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||
try {
|
||||
const settings = await getLdapSettings(LdapSetting);
|
||||
|
||||
const updates = {};
|
||||
for (const field of editableFields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates[field] = req.body[field];
|
||||
}
|
||||
}
|
||||
// Only overwrite the bind password when a non-empty value is supplied,
|
||||
// so the masked GET value can be saved back without wiping it.
|
||||
if (req.body.bindCredentials) {
|
||||
updates.bindCredentials = req.body.bindCredentials;
|
||||
}
|
||||
|
||||
await settings.update(updates);
|
||||
res.json(serialize(settings));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
33
backend/routes/ldap/index.js
Normal file
33
backend/routes/ldap/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineLdapSetting from '../../models/ldapSettings.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import { getLdapSettings } from './ldapClient.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Never expose the stored bind password. Report only whether one is set.
|
||||
function serialize(settings) {
|
||||
const json = settings.toJSON();
|
||||
json.bindCredentialsSet = Boolean(json.bindCredentials);
|
||||
json.bindCredentials = '';
|
||||
return json;
|
||||
}
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
|
||||
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
|
||||
|
||||
router.get('/', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||
try {
|
||||
const settings = await getLdapSettings(LdapSetting);
|
||||
res.json(serialize(settings));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export { serialize };
|
||||
97
backend/routes/ldap/ldapClient.js
Normal file
97
backend/routes/ldap/ldapClient.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Client } from 'ldapts';
|
||||
|
||||
/**
|
||||
* Escape a value before putting it into an LDAP search filter (RFC 4515).
|
||||
* Prevents LDAP filter injection through the typed username.
|
||||
*/
|
||||
function escapeFilterValue(value) {
|
||||
return String(value).replace(/[\\*()]/g, (char) => {
|
||||
switch (char) {
|
||||
case '\\':
|
||||
return '\\5c';
|
||||
case '*':
|
||||
return '\\2a';
|
||||
case '(':
|
||||
return '\\28';
|
||||
case ')':
|
||||
return '\\29';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildFilter(template, username) {
|
||||
return template.replace(/\{\{username\}\}/g, escapeFilterValue(username));
|
||||
}
|
||||
|
||||
function firstValue(attributeValue) {
|
||||
if (Array.isArray(attributeValue)) {
|
||||
return attributeValue.length > 0 ? attributeValue[0].toString() : '';
|
||||
}
|
||||
return attributeValue !== undefined && attributeValue !== null ? attributeValue.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the single-row LDAP settings, creating defaults on first access.
|
||||
*/
|
||||
async function getLdapSettings(LdapSetting) {
|
||||
let settings = await LdapSetting.findOne({ order: [['id', 'ASC']] });
|
||||
if (!settings) {
|
||||
settings = await LdapSetting.create({});
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user against LDAP.
|
||||
* Binds with the service account, searches for the user, then binds as
|
||||
* the user to verify the password. Returns { email, username } on success.
|
||||
* Throws an Error with a human-readable message on any failure.
|
||||
*/
|
||||
async function authenticateLdap(settings, username, password) {
|
||||
// Reject empty password: many directories treat an empty password as an
|
||||
// anonymous (unauthenticated) bind, which would be an auth bypass.
|
||||
if (!password) {
|
||||
throw new Error('Password is required');
|
||||
}
|
||||
if (!settings.url || !settings.searchBase) {
|
||||
throw new Error('LDAP is not configured');
|
||||
}
|
||||
|
||||
const client = new Client({ url: settings.url });
|
||||
try {
|
||||
await client.bind(settings.bindDn, settings.bindCredentials);
|
||||
|
||||
const filter = buildFilter(settings.searchFilter, username);
|
||||
const { searchEntries } = await client.search(settings.searchBase, {
|
||||
scope: 'sub',
|
||||
filter,
|
||||
attributes: [settings.emailAttribute, settings.usernameAttribute],
|
||||
});
|
||||
|
||||
if (searchEntries.length === 0) {
|
||||
throw new Error('User not found in directory');
|
||||
}
|
||||
const entry = searchEntries[0];
|
||||
|
||||
// Verify the password by binding as the found user.
|
||||
await client.bind(entry.dn, password);
|
||||
|
||||
const email = firstValue(entry[settings.emailAttribute]);
|
||||
if (!email) {
|
||||
throw new Error(`LDAP entry has no "${settings.emailAttribute}" attribute`);
|
||||
}
|
||||
const displayName = firstValue(entry[settings.usernameAttribute]) || email;
|
||||
|
||||
return { email, username: displayName };
|
||||
} finally {
|
||||
try {
|
||||
await client.unbind();
|
||||
} catch {
|
||||
// ignore unbind errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { authenticateLdap, getLdapSettings, buildFilter, escapeFilterValue };
|
||||
14
backend/routes/ldap/ldapClient.test.js
Normal file
14
backend/routes/ldap/ldapClient.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildFilter, escapeFilterValue } from './ldapClient.js';
|
||||
|
||||
describe('LDAP filter building', () => {
|
||||
it('substitutes {{username}} into the filter template', () => {
|
||||
expect(buildFilter('(mail={{username}})', 'alice@example.com')).toBe('(mail=alice@example.com)');
|
||||
});
|
||||
|
||||
it('escapes RFC 4515 special characters to prevent filter injection', () => {
|
||||
// An attacker-supplied "*" or parentheses must not alter the filter shape.
|
||||
expect(escapeFilterValue('*)(uid=*')).toBe('\\2a\\29\\28uid=\\2a');
|
||||
expect(buildFilter('(uid={{username}})', 'a*b')).toBe('(uid=a\\2ab)');
|
||||
});
|
||||
});
|
||||
57
backend/routes/ldap/test.js
Normal file
57
backend/routes/ldap/test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { Client } from 'ldapts';
|
||||
import defineLdapSetting from '../../models/ldapSettings.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import { authenticateLdap, getLdapSettings } from './ldapClient.js';
|
||||
const router = express.Router();
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
|
||||
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
|
||||
|
||||
router.post('/test', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||
try {
|
||||
const stored = await getLdapSettings(LdapSetting);
|
||||
|
||||
// Use the values from the form, falling back to the stored bind
|
||||
// password when the (masked) field is left empty.
|
||||
const settings = {
|
||||
url: req.body.url ?? stored.url,
|
||||
bindDn: req.body.bindDn ?? stored.bindDn,
|
||||
bindCredentials: req.body.bindCredentials || stored.bindCredentials,
|
||||
searchBase: req.body.searchBase ?? stored.searchBase,
|
||||
searchFilter: req.body.searchFilter ?? stored.searchFilter,
|
||||
emailAttribute: req.body.emailAttribute ?? stored.emailAttribute,
|
||||
usernameAttribute: req.body.usernameAttribute ?? stored.usernameAttribute,
|
||||
};
|
||||
|
||||
if (!settings.url) {
|
||||
return res.status(400).json({ ok: false, message: 'Server URL is required' });
|
||||
}
|
||||
|
||||
const { testUsername, testPassword } = req.body;
|
||||
if (testUsername && testPassword) {
|
||||
const user = await authenticateLdap(settings, testUsername, testPassword);
|
||||
return res.json({ ok: true, user });
|
||||
}
|
||||
|
||||
// No test user supplied: just verify connectivity and the service bind.
|
||||
const client = new Client({ url: settings.url });
|
||||
try {
|
||||
await client.bind(settings.bindDn, settings.bindCredentials);
|
||||
} finally {
|
||||
try {
|
||||
await client.unbind();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (error) {
|
||||
return res.status(200).json({ ok: false, message: error.message || 'LDAP test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
35
backend/routes/runcaseattachments/index.js
Normal file
35
backend/routes/runcaseattachments/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineAttachment from '../../models/attachments.js';
|
||||
import defineRunCase from '../../models/runCases.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const Attachment = defineAttachment(sequelize, DataTypes);
|
||||
const RunCase = defineRunCase(sequelize, DataTypes);
|
||||
RunCase.belongsToMany(Attachment, { through: 'runCaseAttachments', foreignKey: 'runCaseId', otherKey: 'attachmentId' });
|
||||
Attachment.belongsToMany(RunCase, { through: 'runCaseAttachments', foreignKey: 'attachmentId', otherKey: 'runCaseId' });
|
||||
|
||||
// ponytail: no auth middleware, mirroring the sibling /attachments routes (TODO there).
|
||||
router.get('/', async (req, res) => {
|
||||
const { runCaseId } = req.query;
|
||||
if (!runCaseId) {
|
||||
return res.status(400).json({ error: 'runCaseId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const runCase = await RunCase.findByPk(runCaseId, {
|
||||
include: [{ model: Attachment }],
|
||||
});
|
||||
if (!runCase) {
|
||||
return res.status(404).send('RunCase not found');
|
||||
}
|
||||
res.json(runCase.Attachments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
91
backend/routes/runcaseattachments/new.js
Normal file
91
backend/routes/runcaseattachments/new.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import multer from 'multer';
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineAttachment from '../../models/attachments.js';
|
||||
import defineRunCaseAttachment from '../../models/runCaseAttachments.js';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default function (sequelize) {
|
||||
const Attachment = defineAttachment(sequelize, DataTypes);
|
||||
const RunCaseAttachment = defineRunCaseAttachment(sequelize, DataTypes);
|
||||
|
||||
// Create uploads folder if it does not exist
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: async (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const baseName = path.basename(file.originalname, ext);
|
||||
let fileName = `${baseName}${ext}`;
|
||||
|
||||
// Check if a file with the same name already exists
|
||||
let fileExists = true;
|
||||
let fileIndex = 1;
|
||||
while (fileExists) {
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
// If a file with the same name exists, add an index and rename the file
|
||||
fileName = `${baseName}_${fileIndex}${ext}`;
|
||||
fileIndex++;
|
||||
} else {
|
||||
fileExists = false;
|
||||
}
|
||||
}
|
||||
|
||||
cb(null, fileName);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({ storage });
|
||||
|
||||
// ponytail: no auth middleware, mirroring the sibling /attachments upload route
|
||||
// (see routes/attachments/new.js TODO). Upgrade path: add verifyProjectReporterFromRunCaseId.
|
||||
router.post('/', upload.array('files', 10), async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const runCaseId = req.query.runCaseId;
|
||||
if (!runCaseId) {
|
||||
return res.status(400).json({ error: 'runCaseId is required' });
|
||||
}
|
||||
|
||||
const files = req.files;
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const attachmentsData = files.map((file) => ({
|
||||
title: file.originalname,
|
||||
filename: file.filename,
|
||||
}));
|
||||
|
||||
const newAttachments = await Attachment.bulkCreate(attachmentsData, {
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
const runCaseAttachmentsData = newAttachments.map((attachment) => ({
|
||||
runCaseId: runCaseId,
|
||||
attachmentId: attachment.id,
|
||||
}));
|
||||
await RunCaseAttachment.bulkCreate(runCaseAttachmentsData, { transaction: t });
|
||||
await t.commit();
|
||||
res.json(newAttachments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await t.rollback();
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -4,37 +4,65 @@ import { DataTypes } from 'sequelize';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import defineUser from '../../models/users.js';
|
||||
import { defaultDangerKey } from './authSettings.js';
|
||||
import defineLdapSetting from '../../models/ldapSettings.js';
|
||||
import { authenticateLdap, getLdapSettings } from '../ldap/ldapClient.js';
|
||||
import { roles, defaultDangerKey } from './authSettings.js';
|
||||
|
||||
export default function (sequelize) {
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
|
||||
const secretKey = process.env.SECRET_KEY || defaultDangerKey;
|
||||
|
||||
router.post('/signin', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
function issueToken(res, user) {
|
||||
const accessToken = jwt.sign({ userId: user.id }, secretKey, {
|
||||
expiresIn: '24h',
|
||||
});
|
||||
const expiresAt = Date.now() + 3600 * 1000 * 24; // expire date(ms)
|
||||
|
||||
res.status(200).json({ access_token: accessToken, expires_at: expiresAt, user });
|
||||
}
|
||||
|
||||
router.post('/signin', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Local authentication first. This keeps existing accounts (including
|
||||
// the bootstrap administrator) usable even when LDAP is enabled.
|
||||
const user = await User.findOne({ where: { email } });
|
||||
if (user) {
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
if (passwordMatch) {
|
||||
return issueToken(res, user);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LDAP when enabled. The email field carries the LDAP login
|
||||
// identifier (substituted into the configured search filter).
|
||||
const ldapSettings = await getLdapSettings(LdapSetting);
|
||||
if (ldapSettings.enabled) {
|
||||
try {
|
||||
const ldapUser = await authenticateLdap(ldapSettings, email, password);
|
||||
// Find-or-create a local user keyed by the directory email.
|
||||
let localUser = user || (await User.findOne({ where: { email: ldapUser.email } }));
|
||||
if (!localUser) {
|
||||
const userRoleIndex = roles.findIndex((entry) => entry.uid === 'user');
|
||||
const randomPassword = await bcrypt.hash(jwt.sign({ t: Date.now() }, secretKey), 10);
|
||||
localUser = await User.create({
|
||||
email: ldapUser.email,
|
||||
password: randomPassword,
|
||||
username: ldapUser.username,
|
||||
role: userRoleIndex,
|
||||
});
|
||||
}
|
||||
return issueToken(res, localUser);
|
||||
} catch (ldapError) {
|
||||
console.error('LDAP authentication failed:', ldapError.message);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Authentication failed' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Sign up failed');
|
||||
res.status(500).send('Sign in failed');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function (sequelize) {
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
|
||||
46
backend/routes/users/updateLocale.js
Normal file
46
backend/routes/users/updateLocale.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||
const router = express.Router();
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
|
||||
router.put('/locale', verifySignedIn, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found');
|
||||
}
|
||||
|
||||
const { locale } = req.body;
|
||||
|
||||
const normalizedLocale = typeof locale === 'string' ? locale.trim() : '';
|
||||
if (!normalizedLocale || normalizedLocale.length === 0) {
|
||||
return res.status(400).send('Locale is required');
|
||||
}
|
||||
|
||||
if (!SUPPORTED_LOCALES.includes(normalizedLocale)) {
|
||||
return res.status(400).send('Invalid locale');
|
||||
}
|
||||
|
||||
await user.update({ locale: normalizedLocale });
|
||||
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
92
backend/routes/users/updateLocale.test.js
Normal file
92
backend/routes/users/updateLocale.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||
import updateLocaleRoute from './updateLocale.js';
|
||||
|
||||
// mock of authentication middleware
|
||||
let mockUserId = 1;
|
||||
vi.mock('../../middleware/auth.js', () => ({
|
||||
default: () => ({
|
||||
verifySignedIn: vi.fn((req, res, next) => {
|
||||
req.userId = mockUserId; // Mock user ID
|
||||
next();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock defineUser
|
||||
const mockUsers = new Map();
|
||||
const mockUser = {
|
||||
findByPk: vi.fn((id) => {
|
||||
const user = mockUsers.get(id);
|
||||
if (!user) return null;
|
||||
return {
|
||||
...user,
|
||||
update: vi.fn(async (data) => {
|
||||
Object.assign(user, data);
|
||||
return user;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('../../models/users.js', () => ({
|
||||
default: () => mockUser,
|
||||
}));
|
||||
|
||||
describe('User Locale Routes', () => {
|
||||
let app;
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
logging: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/users', updateLocaleRoute(sequelize));
|
||||
|
||||
// Reset mock users
|
||||
mockUsers.clear();
|
||||
mockUserId = 1;
|
||||
|
||||
// Create a test user
|
||||
mockUsers.set(1, {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: '',
|
||||
role: 1,
|
||||
avatarPath: null,
|
||||
locale: null,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should update locale', async () => {
|
||||
const newLocale = SUPPORTED_LOCALES[0];
|
||||
const response = await request(app).put('/users/locale').send({ locale: newLocale });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.locale).toBe(newLocale);
|
||||
});
|
||||
|
||||
it('should replace existing locale', async () => {
|
||||
mockUsers.set(1, {
|
||||
locale: SUPPORTED_LOCALES[0],
|
||||
});
|
||||
const response = await request(app).put('/users/locale').send({ locale: SUPPORTED_LOCALES[1] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.locale).toBe(SUPPORTED_LOCALES[1]);
|
||||
});
|
||||
|
||||
it.each([' ', 'chinese', 'english'])('should reject not supported locale: %s', async (locale) => {
|
||||
const response = await request(app).put('/users/locale').send({ locale });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export default function (sequelize) {
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
|
||||
@@ -53,6 +53,7 @@ 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 usersUpdateLocaleRoute from './routes/users/updateLocale.js';
|
||||
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
||||
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
||||
import signUpRoute from './routes/users/signup.js';
|
||||
@@ -63,11 +64,20 @@ app.use('/users', usersSearchRoute(sequelize));
|
||||
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
||||
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
||||
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
||||
app.use('/users', usersUpdateLocaleRoute(sequelize));
|
||||
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
||||
app.use('/users', usersUpdateRoleRoute(sequelize));
|
||||
app.use('/users', signUpRoute(sequelize));
|
||||
app.use('/users', signInRoute(sequelize));
|
||||
|
||||
// "/ldap"
|
||||
import ldapIndexRoute from './routes/ldap/index.js';
|
||||
import ldapEditRoute from './routes/ldap/edit.js';
|
||||
import ldapTestRoute from './routes/ldap/test.js';
|
||||
app.use('/ldap', ldapIndexRoute(sequelize));
|
||||
app.use('/ldap', ldapEditRoute(sequelize));
|
||||
app.use('/ldap', ldapTestRoute(sequelize));
|
||||
|
||||
// "/projects"
|
||||
import projectsIndexRoute from './routes/projects/index.js';
|
||||
import projectsShowRoute from './routes/projects/show.js';
|
||||
@@ -147,6 +157,12 @@ import runCaseEditRoute from './routes/runcases/edit.js';
|
||||
app.use('/runcases', runCaseIndexRoute(sequelize));
|
||||
app.use('/runcases', runCaseEditRoute(sequelize));
|
||||
|
||||
// "/runcaseattachments"
|
||||
import runCaseAttachmentsNewRoute from './routes/runcaseattachments/new.js';
|
||||
import runCaseAttachmentsIndexRoute from './routes/runcaseattachments/index.js';
|
||||
app.use('/runcaseattachments', runCaseAttachmentsNewRoute(sequelize));
|
||||
app.use('/runcaseattachments', runCaseAttachmentsIndexRoute(sequelize));
|
||||
|
||||
// "/members"
|
||||
import membersIndexRoute from './routes/members/index.js';
|
||||
import membersNewRoute from './routes/members/new.js';
|
||||
@@ -175,6 +191,16 @@ app.use('/tags', tagsEditRoute(sequelize));
|
||||
import caseTagsEditRoute from './routes/casetags/edit.js';
|
||||
app.use('/casetags', caseTagsEditRoute(sequelize));
|
||||
|
||||
// "/comments"
|
||||
import commentsIndexRoute from './routes/comments/index.js';
|
||||
import commentsNewRoute from './routes/comments/new.js';
|
||||
import commentsEditRoute from './routes/comments/edit.js';
|
||||
import commentsDeleteRoute from './routes/comments/delete.js';
|
||||
app.use('/comments', commentsIndexRoute(sequelize));
|
||||
app.use('/comments', commentsNewRoute(sequelize));
|
||||
app.use('/comments', commentsEditRoute(sequelize));
|
||||
app.use('/comments', commentsDeleteRoute(sequelize));
|
||||
|
||||
// "/home"
|
||||
import homeIndexRoute from './routes/home/index.js';
|
||||
app.use('/home', homeIndexRoute(sequelize));
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
- SECRET_KEY=your_secret_key_here
|
||||
- IS_DEMO=false # set to true to seed the database
|
||||
volumes:
|
||||
- db-data:/app/backend/database
|
||||
- ~/tcms-db-data:/app/backend/database
|
||||
- ./backend/public/uploads:/app/backend/public/uploads
|
||||
|
||||
volumes:
|
||||
|
||||
95
frontend/components/CommentItem.tsx
Normal file
95
frontend/components/CommentItem.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Button, Textarea, Card, CardBody } from '@heroui/react';
|
||||
import { Trash2, Edit2 } from 'lucide-react';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import { CommentMessages, CommentType } from '@/types/comment';
|
||||
|
||||
type Props = {
|
||||
comment: CommentType;
|
||||
isEditing: boolean;
|
||||
canEdit: boolean;
|
||||
editContent: string;
|
||||
isSubmitting: boolean;
|
||||
messages: CommentMessages;
|
||||
onEditContentChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export default function CommentItem({
|
||||
comment,
|
||||
isEditing,
|
||||
canEdit,
|
||||
editContent,
|
||||
isSubmitting,
|
||||
messages,
|
||||
onEditContentChange,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card shadow="sm">
|
||||
<CardBody>
|
||||
<div className="flex items-start gap-3">
|
||||
<UserAvatar username={comment.User.username} size={24} />
|
||||
<div className="flex-grow min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-semibold text-sm">{comment.User.username}</span>
|
||||
<span className="text-xs text-default-400 ml-2">{new Date(comment.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
aria-label="Edit Comment"
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={onStartEdit}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete Comment"
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="danger"
|
||||
onPress={onDelete}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onValueChange={onEditContentChange}
|
||||
minRows={3}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button size="sm" color="primary" onPress={onSave} isLoading={isSubmitting}>
|
||||
{messages.save}
|
||||
</Button>
|
||||
<Button size="sm" variant="bordered" onPress={onCancelEdit} isDisabled={isSubmitting}>
|
||||
{messages.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,201 @@
|
||||
'use client';
|
||||
import { useEffect, useState, useContext } from 'react';
|
||||
import { Button, Textarea, Spinner, addToast } from '@heroui/react';
|
||||
import CommentItem from './CommentItem';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { fetchComments, createComment, updateComment, deleteComment } from '@/utils/commentControl';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import type { CommentMessages, CommentType } from '@/types/comment';
|
||||
|
||||
import { Alert } from '@heroui/react';
|
||||
type Props = {
|
||||
projectId: string;
|
||||
commentableType: 'RunCase' | 'Run' | 'Case';
|
||||
commentableId?: number;
|
||||
messages: CommentMessages;
|
||||
};
|
||||
|
||||
export default function Comments() {
|
||||
export default function Comments({ projectId, commentableType, commentableId, messages }: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
const [comments, setComments] = useState<CommentType[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadComments() {
|
||||
if (!commentableType || !commentableId || !context.isSignedIn()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchComments(context.token.access_token, commentableType, commentableId);
|
||||
setComments(data);
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching comments', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadComments();
|
||||
}, [commentableType, commentableId, context]);
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim() || !commentableType || !commentableId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const comment = await createComment(context.token.access_token, commentableType, commentableId, newComment);
|
||||
if (!comment) {
|
||||
throw new Error('Failed to create comment');
|
||||
}
|
||||
const updatedComments = [...comments, comment];
|
||||
setComments(updatedComments);
|
||||
setNewComment('');
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.commentAdded,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error adding comment', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.failedToAddComment,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (id: number, content: string) => {
|
||||
setEditingId(id);
|
||||
setEditContent(content);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (id: number) => {
|
||||
if (!editContent.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updated = await updateComment(context.token.access_token, id, editContent);
|
||||
if (!updated) {
|
||||
throw new Error('Failed to update comment');
|
||||
}
|
||||
setComments(comments.map((c) => (c.id === id ? { ...c, content: editContent } : c)));
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.commentUpdated,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error updating comment', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.failedToUpdateComment,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (id: number) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await deleteComment(context.token.access_token, id);
|
||||
const updatedComments = comments.filter((c) => c.id !== id);
|
||||
setComments(updatedComments);
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.commentDeleted,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error deleting comment', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.failedToDeleteComment,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!commentableType || !commentableId) {
|
||||
return (
|
||||
<div className="h-full text-default-500">
|
||||
<div className="mb-4">
|
||||
<Alert color="secondary" title="Sorry" description={'Comments function will be implemented'} />
|
||||
<div className="text-default-500 text-sm">
|
||||
{commentableType === 'RunCase' && !commentableId ? <p>{messages.notIncludedInRun}</p> : <p>Unknown state</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canComment = projectId && context.isProjectReporter(Number(projectId));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-center text-default-400 py-8">
|
||||
<p>{messages.noComments}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isEditing={editingId === comment.id}
|
||||
canEdit={comment.userId === context.token.user?.id}
|
||||
editContent={editContent}
|
||||
isSubmitting={isSubmitting}
|
||||
messages={messages}
|
||||
onEditContentChange={setEditContent}
|
||||
onStartEdit={() => handleStartEdit(comment.id, comment.content)}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onSave={() => handleSaveEdit(comment.id)}
|
||||
onDelete={() => handleDeleteComment(comment.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12">
|
||||
<Textarea
|
||||
placeholder={messages.placeholder}
|
||||
value={newComment}
|
||||
onValueChange={setNewComment}
|
||||
minRows={3}
|
||||
variant="bordered"
|
||||
isDisabled={!canComment || isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onPress={handleAddComment}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!newComment.trim() || !canComment}
|
||||
>
|
||||
{messages.addComment}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,16 +4,22 @@ import { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
type Props = {
|
||||
leftPane: ReactNode;
|
||||
rightPane: ReactNode;
|
||||
minLeftWidth?: number;
|
||||
minRightWidth?: number;
|
||||
defaultLeftWidth?: number;
|
||||
};
|
||||
|
||||
export default function ResizablePanes({ leftPane, rightPane }: Props) {
|
||||
const [leftWidth, setLeftWidth] = useState(70); // default 70%
|
||||
export default function ResizablePanes({
|
||||
leftPane,
|
||||
rightPane,
|
||||
minLeftWidth = 40,
|
||||
minRightWidth = 15,
|
||||
defaultLeftWidth = 70,
|
||||
}: Props) {
|
||||
const [leftWidth, setLeftWidth] = useState(defaultLeftWidth); // default 70%
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const minLeftWidth = 40; // left panel min width 40%
|
||||
const minRightWidth = 15; // right panel min width 15%
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
@@ -45,7 +51,7 @@ export default function ResizablePanes({ leftPane, rightPane }: Props) {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
}, [isDragging, minLeftWidth, minRightWidth]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}>
|
||||
|
||||
@@ -19,6 +19,7 @@ const locales: LocaleType[] = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'pt-BR', name: 'Português' },
|
||||
{ code: 'zh-CN', name: '简体中文' },
|
||||
{ code: 'zh-TW', name: '繁體中文' },
|
||||
{ code: 'ja', name: '日本語' },
|
||||
];
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "Passwort (Bestätigung)",
|
||||
"invalid_email": "Ungültige E-Mail",
|
||||
"invalid_password": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"invalid_locale": "Ungültige Sprache",
|
||||
"username_empty": "Benutzername ist leer",
|
||||
"password_empty": "Passwort ist leer",
|
||||
"password_not_match": "Passwörter stimmen nicht überein",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
"update_password": "Passwort aktualisieren",
|
||||
"password_updated": "Passwort erfolgreich geändert",
|
||||
"change_locale": "Sprache ändern",
|
||||
"update_locale": "Sprache aktualisieren",
|
||||
"locale_updated": "Sprache erfolgreich geändert",
|
||||
"change_avatar": "Avatar ändern",
|
||||
"upload_avatar": "Avatar hochladen",
|
||||
"remove_avatar": "Avatar entfernen",
|
||||
@@ -145,6 +149,7 @@
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "Benutzerverwaltung",
|
||||
"ldap": "LDAP",
|
||||
"avatar": "Avatar",
|
||||
"id": "ID",
|
||||
"email": "E-Mail",
|
||||
@@ -247,8 +252,9 @@
|
||||
"select_tags": "Tags auswählen",
|
||||
"import": "Importieren",
|
||||
"import_cases": "Testfälle importieren",
|
||||
"import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) importieren. Die Datei muss dem vorgegebenen Format entsprechen.",
|
||||
"import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) oder einer JSON-Datei (json) importieren. Die Datei muss dem vorgegebenen Format entsprechen.",
|
||||
"download_template": "Vorlage herunterladen",
|
||||
"download_json_sample": "JSON-Beispiel herunterladen",
|
||||
"click_to_upload": "Zum Hochladen klicken",
|
||||
"or_drag_and_drop": " oder per Drag & Drop",
|
||||
"max_file_size": "Max. Dateigröße",
|
||||
@@ -354,7 +360,31 @@
|
||||
"selected": "Ausgewählt",
|
||||
"tags": "Tags",
|
||||
"select_tags": "Tags auswählen",
|
||||
"no_case_selected": "Kein Testfall ausgewählt"
|
||||
"no_case_selected": "Kein Testfall ausgewählt",
|
||||
"case_detail": "Testfall-Details",
|
||||
"comments": "Kommentare",
|
||||
"history": "Verlauf",
|
||||
"test_record": "Testnachweis",
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"click_to_upload": "Zum Hochladen klicken",
|
||||
"or_drag_and_drop": " oder per Drag & Drop",
|
||||
"max_file_size": "Max. Dateigröße"
|
||||
},
|
||||
"Comments": {
|
||||
"comments": "Kommentare",
|
||||
"no_comments": "Keine Kommentare",
|
||||
"add_comment": "Kommentar hinzufügen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"placeholder": "Kommentar eingeben...",
|
||||
"not_included_in_run": "Kann keine Kommentare zu Testfällen abgeben, die nicht im Testlauf enthalten sind",
|
||||
"comment_added": "Kommentar hinzugefügt",
|
||||
"failed_to_add_comment": "Fehler beim Hinzufügen des Kommentars",
|
||||
"comment_updated": "Kommentar aktualisiert",
|
||||
"failed_to_update_comment": "Fehler beim Aktualisieren des Kommentars",
|
||||
"comment_deleted": "Kommentar gelöscht",
|
||||
"failed_to_delete_comment": "Fehler beim Löschen des Kommentars"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "Mitgliederverwaltung",
|
||||
@@ -405,5 +435,27 @@
|
||||
"tag_error_create": "Tag konnte nicht erstellt werden. Bitte versuche es erneut.",
|
||||
"tag_error_update": "Tag konnte nicht aktualisiert werden. Bitte versuche es erneut.",
|
||||
"tag_error_delete": "Tag konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||
},
|
||||
"Ldap": {
|
||||
"ldap_settings": "LDAP-Einstellungen",
|
||||
"ldap_description": "Benutzer über ein LDAP-Verzeichnis authentifizieren.",
|
||||
"enable_ldap": "LDAP aktivieren",
|
||||
"enable_ldap_desc": "Benutzern erlauben, sich mit ihren Verzeichnis-Anmeldedaten anzumelden.",
|
||||
"server_url": "Server-URL",
|
||||
"bind_dn": "Bind-DN",
|
||||
"bind_credentials": "Bind-Passwort",
|
||||
"bind_credentials_placeholder": "Leer lassen, um das aktuelle Passwort beizubehalten",
|
||||
"search_base": "Suchbasis",
|
||||
"search_filter": "Suchfilter",
|
||||
"email_attribute": "E-Mail-Attribut",
|
||||
"username_attribute": "Benutzername-Attribut",
|
||||
"save": "Speichern",
|
||||
"saved": "LDAP-Einstellungen gespeichert",
|
||||
"save_error": "LDAP-Einstellungen konnten nicht gespeichert werden",
|
||||
"test_connection": "Verbindung testen",
|
||||
"test_username": "Test-Benutzername",
|
||||
"test_password": "Test-Passwort",
|
||||
"test_success": "Verbindung erfolgreich",
|
||||
"test_failed": "Verbindung fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "Password (confirm)",
|
||||
"invalid_email": "Invalid email",
|
||||
"invalid_password": "Password must be at least 8 characters",
|
||||
"invalid_locale": "Invalid language",
|
||||
"username_empty": "username is empty",
|
||||
"password_empty": "password is empty",
|
||||
"password_not_match": "Pasword does not match",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
"update_password": "Update Password",
|
||||
"password_updated": "Password updated successfully",
|
||||
"change_locale": "Change Language",
|
||||
"update_locale": "Update Language",
|
||||
"locale_updated": "Language updated successfully",
|
||||
"change_avatar": "Change Avatar",
|
||||
"upload_avatar": "Upload Avatar",
|
||||
"remove_avatar": "Remove Avatar",
|
||||
@@ -145,6 +149,7 @@
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "User Management",
|
||||
"ldap": "LDAP",
|
||||
"avatar": "Avatar",
|
||||
"id": "ID",
|
||||
"email": "Email",
|
||||
@@ -247,8 +252,9 @@
|
||||
"select_tags": "Select tags",
|
||||
"import": "Import",
|
||||
"import_cases": "Import Test Cases",
|
||||
"import_available": "You can import test cases from Excel file (xlsx, xls). The Excel file to be imported must follow the specified format.",
|
||||
"import_available": "You can import test cases from an Excel file (xlsx, xls) or a JSON file (json). The file to be imported must follow the specified format.",
|
||||
"download_template": "Download Template",
|
||||
"download_json_sample": "Download JSON Sample",
|
||||
"click_to_upload": "Click to upload",
|
||||
"or_drag_and_drop": " or drag and drop",
|
||||
"max_file_size": "Max. file size",
|
||||
@@ -354,7 +360,31 @@
|
||||
"selected": "Selected",
|
||||
"tags": "Tags",
|
||||
"select_tags": "Select tags",
|
||||
"no_case_selected": "No test case selected"
|
||||
"no_case_selected": "No test case selected",
|
||||
"case_detail": "Test case detail",
|
||||
"comments": "Comments",
|
||||
"history": "History",
|
||||
"test_record": "Test record",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"click_to_upload": "Click to upload",
|
||||
"or_drag_and_drop": " or drag and drop",
|
||||
"max_file_size": "Max. file size"
|
||||
},
|
||||
"Comments": {
|
||||
"comments": "Comments",
|
||||
"no_comments": "No comments",
|
||||
"add_comment": "Add comment",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"placeholder": "Enter a comment...",
|
||||
"not_included_in_run": "Cannot comment on test cases not included in the test run",
|
||||
"comment_added": "Comment added",
|
||||
"failed_to_add_comment": "Failed to add comment",
|
||||
"comment_updated": "Comment updated",
|
||||
"failed_to_update_comment": "Failed to update comment",
|
||||
"comment_deleted": "Comment deleted",
|
||||
"failed_to_delete_comment": "Failed to delete comment"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "Member Management",
|
||||
@@ -405,5 +435,27 @@
|
||||
"tag_error_create": "Failed to create tag. Please try again.",
|
||||
"tag_error_update": "Failed to update tag. Please try again.",
|
||||
"tag_error_delete": "Failed to delete tag. Please try again."
|
||||
},
|
||||
"Ldap": {
|
||||
"ldap_settings": "LDAP Settings",
|
||||
"ldap_description": "Authenticate users against an LDAP directory.",
|
||||
"enable_ldap": "Enable LDAP",
|
||||
"enable_ldap_desc": "Allow users to sign in with their directory credentials.",
|
||||
"server_url": "Server URL",
|
||||
"bind_dn": "Bind DN",
|
||||
"bind_credentials": "Bind Password",
|
||||
"bind_credentials_placeholder": "Leave blank to keep current password",
|
||||
"search_base": "Search Base",
|
||||
"search_filter": "Search Filter",
|
||||
"email_attribute": "Email Attribute",
|
||||
"username_attribute": "Username Attribute",
|
||||
"save": "Save",
|
||||
"saved": "LDAP settings saved",
|
||||
"save_error": "Failed to save LDAP settings",
|
||||
"test_connection": "Test Connection",
|
||||
"test_username": "Test Username",
|
||||
"test_password": "Test Password",
|
||||
"test_success": "Connection successful",
|
||||
"test_failed": "Connection failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "パスワード(確認)",
|
||||
"invalid_email": "無効なメールアドレスです",
|
||||
"invalid_password": "パスワードは8文字以上である必要があります",
|
||||
"invalid_locale": "無効な言語です",
|
||||
"username_empty": "ユーザー名が入力されていません",
|
||||
"password_empty": "パスワードが入力されていません",
|
||||
"password_not_match": "パスワードが一致しません",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "新しいパスワード(確認)",
|
||||
"update_password": "パスワードを更新",
|
||||
"password_updated": "パスワードが正常に更新されました",
|
||||
"change_locale": "言語の変更",
|
||||
"update_locale": "言語を更新",
|
||||
"locale_updated": "言語が正常に更新されました",
|
||||
"change_avatar": "アバターの変更",
|
||||
"upload_avatar": "アバターをアップロード",
|
||||
"remove_avatar": "アバターを削除",
|
||||
@@ -145,6 +149,7 @@
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "ユーザー管理",
|
||||
"ldap": "LDAP",
|
||||
"avatar": "アバター",
|
||||
"id": "ID",
|
||||
"email": "メールアドレス",
|
||||
@@ -247,8 +252,9 @@
|
||||
"select_tags": "タグを選択",
|
||||
"import": "インポート",
|
||||
"import_cases": "テストケースのインポート",
|
||||
"import_available": "Excelファイル(xlsx, xls)からテストケースをインポートできます。インポートするExcelファイルは既定のフォーマットに従う必要があります。",
|
||||
"import_available": "Excelファイル(xlsx, xls)またはJSONファイル(json)からテストケースをインポートできます。インポートするファイルは既定のフォーマットに従う必要があります。",
|
||||
"download_template": "テンプレートをダウンロード",
|
||||
"download_json_sample": "JSONサンプルをダウンロード",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||
"max_file_size": "最大ファイルサイズ",
|
||||
@@ -354,7 +360,31 @@
|
||||
"selected": "選択済み",
|
||||
"tags": "タグ",
|
||||
"select_tags": "タグを選択",
|
||||
"no_case_selected": "テストケースが選択されていません"
|
||||
"no_case_selected": "テストケースが選択されていません",
|
||||
"case_detail": "テストケース詳細",
|
||||
"comments": "コメント",
|
||||
"history": "履歴",
|
||||
"test_record": "テスト記録",
|
||||
"download": "ダウンロード",
|
||||
"delete": "削除",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||
"max_file_size": "最大ファイルサイズ"
|
||||
},
|
||||
"Comments": {
|
||||
"comments": "コメント",
|
||||
"no_comments": "コメントがありません",
|
||||
"add_comment": "コメントを追加",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"placeholder": "コメントを入力...",
|
||||
"not_included_in_run": "テストランに含まれていないテストケースにはコメントできません",
|
||||
"comment_added": "コメントが追加されました",
|
||||
"failed_to_add_comment": "コメントの追加に失敗しました",
|
||||
"comment_updated": "コメントが更新されました",
|
||||
"failed_to_update_comment": "コメントの更新に失敗しました",
|
||||
"comment_deleted": "コメントが削除されました",
|
||||
"failed_to_delete_comment": "コメントの削除に失敗しました"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "メンバー管理",
|
||||
@@ -405,5 +435,27 @@
|
||||
"tag_error_create": "タグの作成に失敗しました。もう一度お試しください。",
|
||||
"tag_error_update": "タグの更新に失敗しました。もう一度お試しください。",
|
||||
"tag_error_delete": "タグの削除に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"Ldap": {
|
||||
"ldap_settings": "LDAP 設定",
|
||||
"ldap_description": "LDAP ディレクトリでユーザーを認証します。",
|
||||
"enable_ldap": "LDAP を有効化",
|
||||
"enable_ldap_desc": "ユーザーがディレクトリの資格情報でサインインできるようにします。",
|
||||
"server_url": "サーバー URL",
|
||||
"bind_dn": "バインド DN",
|
||||
"bind_credentials": "バインドパスワード",
|
||||
"bind_credentials_placeholder": "現在のパスワードを保持する場合は空欄",
|
||||
"search_base": "検索ベース",
|
||||
"search_filter": "検索フィルター",
|
||||
"email_attribute": "メール属性",
|
||||
"username_attribute": "ユーザー名属性",
|
||||
"save": "保存",
|
||||
"saved": "LDAP 設定を保存しました",
|
||||
"save_error": "LDAP 設定の保存に失敗しました",
|
||||
"test_connection": "接続テスト",
|
||||
"test_username": "テストユーザー名",
|
||||
"test_password": "テストパスワード",
|
||||
"test_success": "接続に成功しました",
|
||||
"test_failed": "接続に失敗しました"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "Senha (confirmação)",
|
||||
"invalid_email": "E-mail inválido",
|
||||
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
||||
"invalid_locale": "Idioma inválido",
|
||||
"username_empty": "O nome de usuário está vazio",
|
||||
"password_empty": "A senha está vazia",
|
||||
"password_not_match": "A senha não corresponde",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "Confirmar Nova Senha",
|
||||
"update_password": "Atualizar Senha",
|
||||
"password_updated": "Senha atualizada com sucesso",
|
||||
"change_locale": "Alterar Idioma",
|
||||
"update_locale": "Atualizar Idioma",
|
||||
"locale_updated": "Idioma atualizado com sucesso",
|
||||
"change_avatar": "Alterar Avatar",
|
||||
"upload_avatar": "Enviar Avatar",
|
||||
"remove_avatar": "Remover Avatar",
|
||||
@@ -145,6 +149,7 @@
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "Gerenciamento de Usuários",
|
||||
"ldap": "LDAP",
|
||||
"avatar": "Avatar",
|
||||
"id": "ID",
|
||||
"email": "E-mail",
|
||||
@@ -247,8 +252,9 @@
|
||||
"select_tags": "Selecionar tags",
|
||||
"import": "Importar",
|
||||
"import_cases": "Importar Casos de Teste",
|
||||
"import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls). O arquivo Excel a ser importado deve seguir o formato especificado.",
|
||||
"import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls) ou um arquivo JSON (json). O arquivo a ser importado deve seguir o formato especificado.",
|
||||
"download_template": "Baixar Modelo",
|
||||
"download_json_sample": "Baixar Exemplo JSON",
|
||||
"click_to_upload": "Clique para enviar",
|
||||
"or_drag_and_drop": " ou arraste e solte",
|
||||
"max_file_size": "Tamanho máx. do arquivo",
|
||||
@@ -354,7 +360,31 @@
|
||||
"selected": "Selecionado",
|
||||
"tags": "Tags",
|
||||
"select_tags": "Selecionar tags",
|
||||
"no_case_selected": "Nenhum caso de teste selecionado"
|
||||
"no_case_selected": "Nenhum caso de teste selecionado",
|
||||
"case_detail": "Detalhe do caso de teste",
|
||||
"comments": "Comentários",
|
||||
"history": "Histórico",
|
||||
"test_record": "Registro de teste",
|
||||
"download": "Baixar",
|
||||
"delete": "Excluir",
|
||||
"click_to_upload": "Clique para enviar",
|
||||
"or_drag_and_drop": " ou arraste e solte",
|
||||
"max_file_size": "Tamanho máx. do arquivo"
|
||||
},
|
||||
"Comments": {
|
||||
"comments": "Comentários",
|
||||
"no_comments": "Nenhum comentário",
|
||||
"add_comment": "Adicionar comentário",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"placeholder": "Digite um comentário...",
|
||||
"not_included_in_run": "Não é possível comentar em casos de teste que não estão incluídos na execução de teste",
|
||||
"comment_added": "Comentário adicionado",
|
||||
"failed_to_add_comment": "Falha ao adicionar comentário",
|
||||
"comment_updated": "Comentário atualizado",
|
||||
"failed_to_update_comment": "Falha ao atualizar comentário",
|
||||
"comment_deleted": "Comentário excluído",
|
||||
"failed_to_delete_comment": "Falha ao excluir comentário"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "Gerenciamento de Membros",
|
||||
@@ -405,5 +435,27 @@
|
||||
"tag_error_create": "Falha ao criar tag. Por favor, tente novamente.",
|
||||
"tag_error_update": "Falha ao atualizar tag. Por favor, tente novamente.",
|
||||
"tag_error_delete": "Falha ao excluir tag. Por favor, tente novamente."
|
||||
},
|
||||
"Ldap": {
|
||||
"ldap_settings": "Configurações de LDAP",
|
||||
"ldap_description": "Autenticar usuários em um diretório LDAP.",
|
||||
"enable_ldap": "Ativar LDAP",
|
||||
"enable_ldap_desc": "Permitir que os usuários entrem com suas credenciais do diretório.",
|
||||
"server_url": "URL do servidor",
|
||||
"bind_dn": "Bind DN",
|
||||
"bind_credentials": "Senha de Bind",
|
||||
"bind_credentials_placeholder": "Deixe em branco para manter a senha atual",
|
||||
"search_base": "Base de busca",
|
||||
"search_filter": "Filtro de busca",
|
||||
"email_attribute": "Atributo de e-mail",
|
||||
"username_attribute": "Atributo de nome de usuário",
|
||||
"save": "Salvar",
|
||||
"saved": "Configurações de LDAP salvas",
|
||||
"save_error": "Falha ao salvar as configurações de LDAP",
|
||||
"test_connection": "Testar conexão",
|
||||
"test_username": "Usuário de teste",
|
||||
"test_password": "Senha de teste",
|
||||
"test_success": "Conexão bem-sucedida",
|
||||
"test_failed": "Falha na conexão"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "确认密码",
|
||||
"invalid_email": "无效的邮箱地址",
|
||||
"invalid_password": "密码必须至少包含 8 个字符",
|
||||
"invalid_locale": "无效的语言",
|
||||
"username_empty": "用户名不能为空",
|
||||
"password_empty": "密码不能为空",
|
||||
"password_not_match": "密码不匹配",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "确认新密码",
|
||||
"update_password": "更新密码",
|
||||
"password_updated": "密码更新成功",
|
||||
"change_locale": "修改语言",
|
||||
"update_locale": "更新语言",
|
||||
"locale_updated": "语言更新成功",
|
||||
"change_avatar": "修改头像",
|
||||
"upload_avatar": "上传头像",
|
||||
"remove_avatar": "移除头像",
|
||||
@@ -145,6 +149,7 @@
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "用户管理",
|
||||
"ldap": "LDAP",
|
||||
"avatar": "头像",
|
||||
"id": "ID",
|
||||
"email": "邮箱",
|
||||
@@ -247,8 +252,9 @@
|
||||
"select_tags": "选择标签",
|
||||
"import": "导入",
|
||||
"import_cases": "导入测试用例",
|
||||
"import_available": "您可以从 Excel 文件 (xlsx, xls) 导入测试用例。导入的 Excel 文件必须遵循指定格式。",
|
||||
"import_available": "您可以从 Excel 文件 (xlsx, xls) 或 JSON 文件 (json) 导入测试用例。导入的文件必须遵循指定格式。",
|
||||
"download_template": "下载模板",
|
||||
"download_json_sample": "下载 JSON 示例",
|
||||
"click_to_upload": "点击上传",
|
||||
"or_drag_and_drop": " 或拖拽文件到此处",
|
||||
"max_file_size": "最大文件大小",
|
||||
@@ -354,7 +360,31 @@
|
||||
"selected": "已选择",
|
||||
"tags": "标签",
|
||||
"select_tags": "选择标签",
|
||||
"no_case_selected": "未选择测试用例"
|
||||
"no_case_selected": "未选择测试用例",
|
||||
"case_detail": "测试用例详情",
|
||||
"comments": "评论",
|
||||
"history": "历史",
|
||||
"test_record": "测试记录",
|
||||
"download": "下载",
|
||||
"delete": "删除",
|
||||
"click_to_upload": "点击上传",
|
||||
"or_drag_and_drop": " 或拖拽文件到此处",
|
||||
"max_file_size": "最大文件大小"
|
||||
},
|
||||
"Comments": {
|
||||
"comments": "评论",
|
||||
"no_comments": "暂无评论",
|
||||
"add_comment": "添加评论",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"placeholder": "输入评论...",
|
||||
"not_included_in_run": "无法对未包含在测试运行中的测试用例进行评论",
|
||||
"comment_added": "评论已添加",
|
||||
"failed_to_add_comment": "添加评论失败",
|
||||
"comment_updated": "评论已更新",
|
||||
"failed_to_update_comment": "更新评论失败",
|
||||
"comment_deleted": "评论已删除",
|
||||
"failed_to_delete_comment": "删除评论失败"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "成员管理",
|
||||
@@ -405,5 +435,27 @@
|
||||
"tag_error_create": "标签创建失败,请重试。",
|
||||
"tag_error_update": "标签更新失败,请重试。",
|
||||
"tag_error_delete": "标签删除失败,请重试。"
|
||||
},
|
||||
"Ldap": {
|
||||
"ldap_settings": "LDAP 设置",
|
||||
"ldap_description": "通过 LDAP 目录验证用户身份。",
|
||||
"enable_ldap": "启用 LDAP",
|
||||
"enable_ldap_desc": "允许用户使用目录凭据登录。",
|
||||
"server_url": "服务器地址",
|
||||
"bind_dn": "绑定 DN",
|
||||
"bind_credentials": "绑定密码",
|
||||
"bind_credentials_placeholder": "留空以保留当前密码",
|
||||
"search_base": "搜索基准",
|
||||
"search_filter": "搜索过滤器",
|
||||
"email_attribute": "邮箱属性",
|
||||
"username_attribute": "用户名属性",
|
||||
"save": "保存",
|
||||
"saved": "LDAP 设置已保存",
|
||||
"save_error": "保存 LDAP 设置失败",
|
||||
"test_connection": "测试连接",
|
||||
"test_username": "测试用户名",
|
||||
"test_password": "测试密码",
|
||||
"test_success": "连接成功",
|
||||
"test_failed": "连接失败"
|
||||
}
|
||||
}
|
||||
|
||||
461
frontend/messages/zh-TW.json
Normal file
461
frontend/messages/zh-TW.json
Normal file
@@ -0,0 +1,461 @@
|
||||
{
|
||||
"RunStatus": {
|
||||
"new": "新建",
|
||||
"inProgress": "進行中",
|
||||
"underReview": "審核中",
|
||||
"rejected": "已拒絕",
|
||||
"done": "完成",
|
||||
"closed": "已關閉"
|
||||
},
|
||||
"RunCaseStatus": {
|
||||
"untested": "未測試",
|
||||
"passed": "通過",
|
||||
"failed": "失敗",
|
||||
"retest": "重測",
|
||||
"skipped": "略過"
|
||||
},
|
||||
"Priority": {
|
||||
"critical": "嚴重",
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低"
|
||||
},
|
||||
"Type": {
|
||||
"other": "其他",
|
||||
"security": "安全",
|
||||
"performance": "效能",
|
||||
"accessibility": "無障礙",
|
||||
"functional": "功能",
|
||||
"acceptance": "驗收",
|
||||
"usability": "易用性",
|
||||
"smoke_sanity": "冒煙與健全性",
|
||||
"compatibility": "相容性",
|
||||
"destructive": "破壞性",
|
||||
"regression": "迴歸",
|
||||
"automated": "自動化",
|
||||
"manual": "手動"
|
||||
},
|
||||
"ProjectDialog": {
|
||||
"project": "專案",
|
||||
"project_name": "專案名稱",
|
||||
"project_detail": "專案詳情",
|
||||
"public": "公開",
|
||||
"private": "私有",
|
||||
"if_you_make_public": "將專案設為公開將使其對非專案成員的使用者可見。",
|
||||
"close": "關閉",
|
||||
"create": "建立",
|
||||
"update": "更新",
|
||||
"please_enter": "請輸入專案名稱"
|
||||
},
|
||||
"Index": {
|
||||
"get_started": "開始使用",
|
||||
"demo": "示範",
|
||||
"oss_tcms": "開源測試案例管理系統",
|
||||
"integrate_and_manage": "整合並管理您所有的軟體測試。",
|
||||
"oss_title": "開源",
|
||||
"oss_detail": "UnitTCMS 是免費且開源的。應用程式支援自我託管,可部署於具有嚴格安全要求的環境中。",
|
||||
"organize_title": "組織測試案例",
|
||||
"organize_detail": "可以透過專案與資料夾來組織測試案例。現代化的 UI 框架使其運行迅速。",
|
||||
"usability_title": "易用性",
|
||||
"usability_detail": "定義好的測試案例可以在測試執行中反覆使用。測試執行與專案的狀態可以透過圖表直覺地檢視。",
|
||||
"universal_title": "通用性",
|
||||
"universal_detail": "多語言支援與深色模式讓任何人都能無障礙地使用此系統。",
|
||||
"project_title": "以專案為基礎",
|
||||
"project_subtitle": "以專案為單位管理測試案例與測試執行。我們的儀表板提供了每個專案中測試案例類型及其進度的概覽。這使您能夠即時監控專案狀態並進行高效率的管理。",
|
||||
"case_management_title": "測試案例管理",
|
||||
"case_management_subtitle": "在專案中建立資料夾,利用我們現代化且直覺的 UI 輕鬆定義測試案例。支援附件上傳,可對測試案例進行詳細說明,便於在整個團隊中共享資訊。",
|
||||
"run_management_title": "測試執行管理",
|
||||
"run_management_subtitle": "定義好的測試案例可以在測試執行中多次重複使用,實現高效率的測試循環。此外,您還可以視覺化地監控測試執行與專案的狀態。",
|
||||
"member_management_title": "成員管理",
|
||||
"member_management_subtitle": "透過在專案中新增或移除成員來支援團隊開發。您可以為每個成員指派角色並詳細設定權限。我們提供三種主要角色:「管理者」管理整個專案,「開發者」設計測試,「報告者」執行測試。"
|
||||
},
|
||||
"Header": {
|
||||
"title": "開源測試案例管理系統",
|
||||
"description": "整合並管理您所有的軟體測試",
|
||||
"docs": "文件",
|
||||
"roadmap": "路線圖",
|
||||
"projects": "專案",
|
||||
"admin": "管理後台",
|
||||
"account": "帳戶",
|
||||
"profile_settings": "個人設定",
|
||||
"signup": "註冊",
|
||||
"signin": "登入",
|
||||
"signout": "登出",
|
||||
"links": "連結",
|
||||
"languages": "語言"
|
||||
},
|
||||
"Toast": {
|
||||
"need_signed_in": "您需要先登入",
|
||||
"session_expired": "工作階段已過期"
|
||||
},
|
||||
"Auth": {
|
||||
"account": "帳戶",
|
||||
"signup": "註冊",
|
||||
"signin": "登入",
|
||||
"or_signup": "還沒有帳號?前往註冊",
|
||||
"or_signin": "已有帳號?前往登入",
|
||||
"signin_as_guest": "或以訪客身分登入",
|
||||
"email": "電子郵件",
|
||||
"username": "使用者名稱",
|
||||
"password": "密碼",
|
||||
"confirm_password": "確認密碼",
|
||||
"invalid_email": "無效的電子郵件地址",
|
||||
"invalid_password": "密碼必須至少包含 8 個字元",
|
||||
"invalid_locale": "無效的語言",
|
||||
"username_empty": "使用者名稱不能為空",
|
||||
"password_empty": "密碼不能為空",
|
||||
"password_not_match": "密碼不一致",
|
||||
"email_already_exist": "電子郵件已被註冊",
|
||||
"email_not_exist": "找不到該電子郵件地址",
|
||||
"signup_error": "註冊失敗",
|
||||
"signin_error": "登入失敗",
|
||||
"demo_page_warning": "這是一個示範環境,您輸入的任何資料都將被定期重置。",
|
||||
"your_projects": "您的專案",
|
||||
"public": "公開",
|
||||
"private": "私有",
|
||||
"not_own_any_projects": "您沒有任何專案。",
|
||||
"find_projects": "探索專案",
|
||||
"profile_settings": "個人設定",
|
||||
"change_username": "變更使用者名稱",
|
||||
"new_username": "新使用者名稱",
|
||||
"update_username": "更新使用者名稱",
|
||||
"username_updated": "使用者名稱更新成功",
|
||||
"change_password": "變更密碼",
|
||||
"current_password": "目前密碼",
|
||||
"new_password": "新密碼",
|
||||
"confirm_new_password": "確認新密碼",
|
||||
"update_password": "更新密碼",
|
||||
"password_updated": "密碼更新成功",
|
||||
"change_locale": "變更語言",
|
||||
"update_locale": "更新語言",
|
||||
"locale_updated": "語言更新成功",
|
||||
"change_avatar": "變更頭像",
|
||||
"upload_avatar": "上傳頭像",
|
||||
"remove_avatar": "移除頭像",
|
||||
"avatar_updated": "頭像更新成功",
|
||||
"avatar_removed": "頭像移除成功",
|
||||
"max_file_size_5mb": "檔案大小上限:5MB",
|
||||
"only_images_allowed": "僅允許上傳圖片檔案",
|
||||
"current_password_incorrect": "目前密碼不正確",
|
||||
"update_error": "更新失敗"
|
||||
},
|
||||
"Health": {
|
||||
"health_check": "健康檢查",
|
||||
"status": "狀態",
|
||||
"ok": "正常",
|
||||
"error": "錯誤",
|
||||
"api_server": "API 伺服器",
|
||||
"unittcms_version": "UnitTCMS 版本"
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "使用者管理",
|
||||
"ldap": "LDAP",
|
||||
"avatar": "頭像",
|
||||
"id": "ID",
|
||||
"email": "電子郵件",
|
||||
"username": "使用者名稱",
|
||||
"role": "角色",
|
||||
"administrator": "管理員",
|
||||
"user": "使用者",
|
||||
"no_users_found": "找不到使用者",
|
||||
"quit_admin": "放棄管理員權限",
|
||||
"close": "關閉",
|
||||
"quit": "放棄",
|
||||
"quit_confirm": "如果您放棄管理員權限,將無法再存取管理頁面。",
|
||||
"role_changed": "角色已變更",
|
||||
"lost_admin_auth": "已失去管理員權限",
|
||||
"at_least": "至少需要保留一名管理員",
|
||||
"reset_password": "重設密碼",
|
||||
"reset": "重設",
|
||||
"invalid_password": "密碼必須至少包含 8 個字元",
|
||||
"password_not_match": "密碼不一致"
|
||||
},
|
||||
"Projects": {
|
||||
"project_list": "專案列表",
|
||||
"new_project": "新增專案",
|
||||
"id": "ID",
|
||||
"publicity": "公開性",
|
||||
"public": "公開",
|
||||
"private": "私有",
|
||||
"name": "名稱",
|
||||
"detail": "詳情",
|
||||
"last_update": "最後更新",
|
||||
"no_projects_found": "找不到專案"
|
||||
},
|
||||
"Project": {
|
||||
"project": "專案",
|
||||
"toggle_sidebar": "切換側邊欄",
|
||||
"home": "首頁",
|
||||
"test_cases": "測試案例",
|
||||
"test_runs": "測試執行",
|
||||
"members": "成員",
|
||||
"settings": "設定"
|
||||
},
|
||||
"Home": {
|
||||
"home": "首頁",
|
||||
"Folders": "資料夾",
|
||||
"test_cases": "測試案例",
|
||||
"test_runs": "測試執行",
|
||||
"progress": "進度",
|
||||
"test_classification": "測試分類",
|
||||
"by_type": "依測試類型",
|
||||
"by_priority": "依測試優先級"
|
||||
},
|
||||
"Folders": {
|
||||
"folder": "資料夾",
|
||||
"new_folder": "新增資料夾",
|
||||
"edit_folder": "編輯資料夾",
|
||||
"delete_folder": "刪除資料夾",
|
||||
"folder_name": "資料夾名稱",
|
||||
"folder_detail": "資料夾詳情",
|
||||
"close": "關閉",
|
||||
"create": "建立",
|
||||
"update": "更新",
|
||||
"please_enter": "請輸入資料夾名稱",
|
||||
"delete": "刪除",
|
||||
"are_you_sure": "您確定要刪除此資料夾嗎?",
|
||||
"no_folders_found": "找不到資料夾"
|
||||
},
|
||||
"Cases": {
|
||||
"test_case_list": "測試案例列表",
|
||||
"id": "ID",
|
||||
"title": "標題",
|
||||
"priority": "優先級",
|
||||
"actions": "操作",
|
||||
"delete_case": "刪除測試案例",
|
||||
"delete": "刪除",
|
||||
"close": "關閉",
|
||||
"are_you_sure": "您確定要刪除測試案例嗎?",
|
||||
"new_test_case": "新增",
|
||||
"export": "匯出",
|
||||
"status": "狀態",
|
||||
"no_cases_found": "找不到測試案例",
|
||||
"case_title": "測試案例標題",
|
||||
"case_description": "測試案例描述",
|
||||
"case_title_or_description": "測試案例標題或描述",
|
||||
"create": "建立",
|
||||
"please_enter": "請輸入測試案例標題",
|
||||
"filter": "篩選",
|
||||
"clear_all": "清除全部",
|
||||
"apply": "套用",
|
||||
"select_priorities": "選擇優先級",
|
||||
"selected": "已選擇",
|
||||
"type": "類型",
|
||||
"select_types": "選擇類型",
|
||||
"cases_selected": "個案例已選擇",
|
||||
"select_action": "選擇操作",
|
||||
"move": "移動",
|
||||
"clone": "複製",
|
||||
"cases_moved": "測試案例已移動",
|
||||
"cases_cloned": "測試案例已複製",
|
||||
"tags": "標籤",
|
||||
"select_tags": "選擇標籤",
|
||||
"import": "匯入",
|
||||
"import_cases": "匯入測試案例",
|
||||
"import_available": "您可以從 Excel 檔案 (xlsx, xls) 或 JSON 檔案 (json) 匯入測試案例。匯入的檔案必須遵循指定格式。",
|
||||
"download_template": "下載範本",
|
||||
"download_json_sample": "下載 JSON 範例",
|
||||
"click_to_upload": "點擊上傳",
|
||||
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||
"max_file_size": "檔案大小上限",
|
||||
"cases_imported": "測試案例已匯入",
|
||||
"create_more": "繼續建立"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "返回測試案例列表",
|
||||
"updating": "更新中...",
|
||||
"update": "更新",
|
||||
"updated_test_case": "測試案例已更新",
|
||||
"basic": "基本資訊",
|
||||
"title": "標題",
|
||||
"please_enter_title": "請輸入標題",
|
||||
"description": "描述",
|
||||
"test_case_description": "測試案例描述",
|
||||
"priority": "優先級",
|
||||
"type": "類型",
|
||||
"template": "範本",
|
||||
"test_detail": "測試詳情",
|
||||
"preconditions": "前置條件",
|
||||
"expected_result": "預期結果",
|
||||
"step": "步驟",
|
||||
"text": "文字",
|
||||
"steps": "步驟",
|
||||
"new_step": "新增步驟",
|
||||
"details_of_the_step": "步驟詳情",
|
||||
"delete_this_step": "刪除此步驟",
|
||||
"insert_step": "插入步驟",
|
||||
"attachments": "附件",
|
||||
"delete": "刪除",
|
||||
"download": "下載",
|
||||
"delete_file": "刪除檔案",
|
||||
"click_to_upload": "點擊上傳",
|
||||
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||
"max_file_size": "檔案大小上限",
|
||||
"are_you_sure_leave": "您確定要離開此頁面嗎?",
|
||||
"tags": "標籤",
|
||||
"create_tag": "建立標籤",
|
||||
"max_tags_limit": "標籤數量上限",
|
||||
"tag_already_exists": "標籤已存在",
|
||||
"tag_created_and_added": "標籤已建立並新增",
|
||||
"error_creating_tag": "建立標籤失敗",
|
||||
"error_updating_test_case": "更新測試案例失敗",
|
||||
"search_or_create_tag": "搜尋或建立標籤",
|
||||
"no_tags_selected": "未選擇標籤"
|
||||
},
|
||||
"Runs": {
|
||||
"run_list": "測試執行列表",
|
||||
"run": "執行",
|
||||
"new_run": "新增執行",
|
||||
"edit_run": "編輯執行",
|
||||
"delete_run": "刪除執行",
|
||||
"id": "ID",
|
||||
"name": "名稱",
|
||||
"description": "描述",
|
||||
"last_update": "最後更新",
|
||||
"actions": "操作",
|
||||
"run_name": "執行名稱",
|
||||
"run_description": "執行描述",
|
||||
"no_runs_found": "找不到測試執行",
|
||||
"close": "關閉",
|
||||
"create": "建立",
|
||||
"update": "更新",
|
||||
"please_enter": "請輸入執行名稱",
|
||||
"are_you_sure": "您確定要刪除此測試執行嗎?",
|
||||
"delete": "刪除"
|
||||
},
|
||||
"Run": {
|
||||
"back_to_runs": "返回測試執行列表",
|
||||
"updating": "更新中...",
|
||||
"update": "更新",
|
||||
"updated_test_run": "測試執行已更新",
|
||||
"export": "匯出",
|
||||
"progress": "進度",
|
||||
"refresh": "重新整理",
|
||||
"id": "ID",
|
||||
"title": "標題",
|
||||
"please_enter": "請輸入執行名稱",
|
||||
"description": "描述",
|
||||
"priority": "優先級",
|
||||
"status": "狀態",
|
||||
"actions": "操作",
|
||||
"select_test_case": "選擇測試案例",
|
||||
"test_case_selection": "測試案例選擇",
|
||||
"include_in_run": "納入執行",
|
||||
"exclude_from_run": "從執行中排除",
|
||||
"no_cases_found": "找不到案例",
|
||||
"are_you_sure_leave": "您確定要離開此頁面嗎?",
|
||||
"type": "類型",
|
||||
"test_detail": "測試詳情",
|
||||
"steps": "步驟",
|
||||
"preconditions": "前置條件",
|
||||
"expected_result": "預期結果",
|
||||
"details_of_the_step": "步驟詳情",
|
||||
"close": "關閉",
|
||||
"filter": "篩選",
|
||||
"clear_all": "清除全部",
|
||||
"apply": "套用",
|
||||
"select_status": "選擇狀態",
|
||||
"please_save": "請儲存變更",
|
||||
"case_title_or_description": "測試案例標題或描述",
|
||||
"selected": "已選擇",
|
||||
"tags": "標籤",
|
||||
"select_tags": "選擇標籤",
|
||||
"no_case_selected": "未選擇測試案例",
|
||||
"case_detail": "測試案例詳情",
|
||||
"comments": "留言",
|
||||
"history": "歷史",
|
||||
"test_record": "測試紀錄",
|
||||
"download": "下載",
|
||||
"delete": "刪除",
|
||||
"click_to_upload": "點擊上傳",
|
||||
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||
"max_file_size": "檔案大小上限"
|
||||
},
|
||||
"Comments": {
|
||||
"comments": "留言",
|
||||
"no_comments": "尚無留言",
|
||||
"add_comment": "新增留言",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"placeholder": "輸入留言...",
|
||||
"not_included_in_run": "無法對未納入測試執行的測試案例進行留言",
|
||||
"comment_added": "留言已新增",
|
||||
"failed_to_add_comment": "新增留言失敗",
|
||||
"comment_updated": "留言已更新",
|
||||
"failed_to_update_comment": "更新留言失敗",
|
||||
"comment_deleted": "留言已刪除",
|
||||
"failed_to_delete_comment": "刪除留言失敗"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "成員管理",
|
||||
"avatar": "頭像",
|
||||
"email": "電子郵件",
|
||||
"username": "使用者名稱",
|
||||
"role": "角色",
|
||||
"manager": "管理者",
|
||||
"developer": "開發者",
|
||||
"reporter": "報告者",
|
||||
"delete": "刪除",
|
||||
"deleteMember": "刪除成員",
|
||||
"no_members_found": "找不到成員",
|
||||
"add_member": "新增成員",
|
||||
"user_name_or_email": "使用者名稱或電子郵件",
|
||||
"close": "關閉",
|
||||
"add": "新增",
|
||||
"are_you_sure": "您確定要刪除此成員嗎?",
|
||||
"member_added": "成員已新增",
|
||||
"role_changed": "成員角色已變更",
|
||||
"member_deleted": "成員已刪除"
|
||||
},
|
||||
"Settings": {
|
||||
"project_management": "專案管理",
|
||||
"project_name": "專案名稱",
|
||||
"project_detail": "專案詳情",
|
||||
"project_owner": "專案擁有者",
|
||||
"edit_project": "編輯專案",
|
||||
"publicity": "公開性",
|
||||
"public": "公開",
|
||||
"private": "私有",
|
||||
"delete_project": "刪除專案",
|
||||
"delete": "刪除",
|
||||
"close": "關閉",
|
||||
"are_you_sure": "您確定要刪除此專案嗎?",
|
||||
"tag_management": "標籤管理",
|
||||
"tag_name": "標籤名稱",
|
||||
"add_tag": "新增標籤",
|
||||
"no_tags_available": "尚無標籤",
|
||||
"delete_tag": "刪除標籤",
|
||||
"are_you_sure_delete_tag": "您確定要刪除此標籤嗎?",
|
||||
"tag_created": "標籤建立成功。",
|
||||
"tag_updated": "標籤更新成功。",
|
||||
"tag_deleted": "標籤刪除成功。",
|
||||
"tag_error_empty": "標籤名稱不能為空。",
|
||||
"tag_error_min_length": "標籤名稱長度至少為 3 個字元。",
|
||||
"tag_error_max_length": "標籤名稱不能超過 20 個字元。",
|
||||
"tag_error_create": "標籤建立失敗,請重試。",
|
||||
"tag_error_update": "標籤更新失敗,請重試。",
|
||||
"tag_error_delete": "標籤刪除失敗,請重試。"
|
||||
},
|
||||
"Ldap": {
|
||||
"ldap_settings": "LDAP 設定",
|
||||
"ldap_description": "透過 LDAP 目錄驗證使用者身分。",
|
||||
"enable_ldap": "啟用 LDAP",
|
||||
"enable_ldap_desc": "允許使用者使用目錄憑證登入。",
|
||||
"server_url": "伺服器位址",
|
||||
"bind_dn": "繫結 DN",
|
||||
"bind_credentials": "繫結密碼",
|
||||
"bind_credentials_placeholder": "留空以保留目前密碼",
|
||||
"search_base": "搜尋基準",
|
||||
"search_filter": "搜尋過濾器",
|
||||
"email_attribute": "電子郵件屬性",
|
||||
"username_attribute": "使用者名稱屬性",
|
||||
"save": "儲存",
|
||||
"saved": "已儲存 LDAP 設定",
|
||||
"save_error": "儲存 LDAP 設定失敗",
|
||||
"test_connection": "測試連線",
|
||||
"test_username": "測試使用者名稱",
|
||||
"test_password": "測試密碼",
|
||||
"test_success": "連線成功",
|
||||
"test_failed": "連線失敗"
|
||||
}
|
||||
}
|
||||
33
frontend/public/template/unittcms-import-sample.json
Normal file
33
frontend/public/template/unittcms-import-sample.json
Normal file
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{
|
||||
"title": "User can sign in with valid credentials",
|
||||
"priority": "high",
|
||||
"type": "functional",
|
||||
"template": "text",
|
||||
"automationStatus": "automation-not-required",
|
||||
"description": "Verify that a registered user can sign in.",
|
||||
"preConditions": "A user account already exists.",
|
||||
"expectedResults": "The user is redirected to the projects page after signing in.",
|
||||
"Steps": []
|
||||
},
|
||||
{
|
||||
"title": "Create a new project",
|
||||
"priority": "medium",
|
||||
"type": "acceptance",
|
||||
"template": "step",
|
||||
"automationStatus": "cannot-be-automated",
|
||||
"description": "Verify that a signed-in user can create a project.",
|
||||
"preConditions": "The user is signed in.",
|
||||
"expectedResults": "The new project appears in the project list.",
|
||||
"Steps": [
|
||||
{
|
||||
"step": "Click the \"New project\" button.",
|
||||
"expectedStepResult": "The project creation dialog opens."
|
||||
},
|
||||
{
|
||||
"step": "Enter a project name and submit.",
|
||||
"expectedStepResult": "The dialog closes and the project is created."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -16,7 +16,7 @@ export default function DropdownLanguage({ locale, onChangeLocale }: Props) {
|
||||
{locales.find((entry) => entry.code === locale)?.name || locale}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="lacales">
|
||||
<DropdownMenu aria-label="locales">
|
||||
{locales.map((entry) => (
|
||||
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
|
||||
{entry.name}
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
<ThemeSwitch />
|
||||
<div className="hidden md:block">
|
||||
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
|
||||
<DropdownLanguage locale={locale} onChangeLocale={changeLocale} />
|
||||
{!context.isSignedIn() && <DropdownLanguage locale={locale} onChangeLocale={changeLocale} />}
|
||||
</div>
|
||||
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
|
||||
</NavbarContent>
|
||||
@@ -224,6 +224,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
/>
|
||||
</Listbox>
|
||||
) : (
|
||||
<>
|
||||
<Listbox
|
||||
aria-label="Account links"
|
||||
itemClasses={{
|
||||
@@ -249,7 +250,6 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
}}
|
||||
/>
|
||||
</Listbox>
|
||||
)}
|
||||
<p className="font-bold">{messages.languages}</p>
|
||||
<Listbox
|
||||
aria-label="Language links"
|
||||
@@ -269,6 +269,8 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
/>
|
||||
))}
|
||||
</Listbox>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</NavbarMenu>
|
||||
</Navbar>
|
||||
|
||||
@@ -60,6 +60,7 @@ async function signInAsGuest() {
|
||||
username: 'Guest',
|
||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||
avatarPath: '',
|
||||
locale: null,
|
||||
};
|
||||
const token = await signUp(guestUser);
|
||||
return token;
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
||||
username: '',
|
||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||
avatarPath: '',
|
||||
locale: null,
|
||||
});
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -83,14 +84,14 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
||||
|
||||
context.setToken(token);
|
||||
context.storeTokenToLocalStorage(token);
|
||||
router.push('/account', { locale: locale });
|
||||
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||
};
|
||||
|
||||
const handleSignInAsGuest = async () => {
|
||||
const token = await signInAsGuest();
|
||||
context.setToken(token);
|
||||
context.storeTokenToLocalStorage(token);
|
||||
router.push('/account', { locale: locale });
|
||||
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
import { useState, useContext, useRef } from 'react';
|
||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react';
|
||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter, Select, SelectItem } from '@heroui/react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl';
|
||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar, updateLocale } from '@/utils/usersControl';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { useRouter, usePathname } from '@/src/i18n/routing';
|
||||
import { locales } from '@/config/selection';
|
||||
import { LocaleType } from '@/types/locale';
|
||||
|
||||
type ProfileSettingsPageMessages = {
|
||||
profileSettings: string;
|
||||
@@ -19,6 +23,9 @@ type ProfileSettingsPageMessages = {
|
||||
confirmNewPassword: string;
|
||||
updatePassword: string;
|
||||
passwordUpdated: string;
|
||||
changeLocale: string;
|
||||
updateLocale: string;
|
||||
localeUpdated: string;
|
||||
changeAvatar: string;
|
||||
uploadAvatar: string;
|
||||
removeAvatar: string;
|
||||
@@ -31,6 +38,7 @@ type ProfileSettingsPageMessages = {
|
||||
invalidPassword: string;
|
||||
passwordNotMatch: string;
|
||||
usernameEmpty: string;
|
||||
invalidLocale: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -38,15 +46,21 @@ type Props = {
|
||||
locale: LocaleCodeType;
|
||||
};
|
||||
|
||||
export default function ProfileSettingsPage({ messages }: Props) {
|
||||
export default function ProfileSettingsPage({ messages, locale: defaultLocale }: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [locale, setLocale] = useState<LocaleCodeType>(context.token?.user?.locale ?? defaultLocale);
|
||||
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
||||
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
||||
const [isUpdatingLocale, setIsUpdatingLocale] = useState(false);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
|
||||
const handleUsernameUpdate = async () => {
|
||||
@@ -150,6 +164,53 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocaleUpdate = async () => {
|
||||
if (!locales.some((l) => l.code === locale)) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.invalidLocale,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingLocale(true);
|
||||
try {
|
||||
const result = await updateLocale(context.token.access_token, locale);
|
||||
if (result && result.user) {
|
||||
// refresh locale
|
||||
const newToken = { ...context.token };
|
||||
if (newToken.user) {
|
||||
newToken.user.locale = result.user.locale;
|
||||
}
|
||||
context.setToken(newToken);
|
||||
context.storeTokenToLocalStorage(newToken);
|
||||
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.localeUpdated,
|
||||
});
|
||||
const nextLocale = result.user.locale ?? locale;
|
||||
setLocale(nextLocale);
|
||||
changeLocale(nextLocale);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error updating locale:', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.updateError,
|
||||
});
|
||||
} finally {
|
||||
setIsUpdatingLocale(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function changeLocale(nextLocale: LocaleCodeType) {
|
||||
router.push(pathname, { locale: nextLocale });
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -333,6 +394,46 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Locale */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<Globe size={16} />
|
||||
<h2 className="text-large font-semibold ml-2">{messages.changeLocale}</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form>
|
||||
<div className="space-y-4">
|
||||
<Select<LocaleType>
|
||||
fullWidth
|
||||
aria-label="change locale"
|
||||
selectedKeys={[locale]}
|
||||
disabledKeys={[locale]}
|
||||
onSelectionChange={(value) => {
|
||||
const selectedLocale = locales.find((locale) => locale.code === value.currentKey);
|
||||
if (!selectedLocale) return;
|
||||
setLocale(selectedLocale.code);
|
||||
}}
|
||||
>
|
||||
{locales.map((locale) => (
|
||||
<SelectItem key={locale.code}>{locale.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleLocaleUpdate}
|
||||
isLoading={isUpdatingLocale}
|
||||
isDisabled={locale === context.token?.user?.locale}
|
||||
size="sm"
|
||||
>
|
||||
{messages.updateLocale}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Avatar */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
|
||||
@@ -17,6 +17,9 @@ export default function Page({ params }: PageType) {
|
||||
confirmNewPassword: t('confirm_new_password'),
|
||||
updatePassword: t('update_password'),
|
||||
passwordUpdated: t('password_updated'),
|
||||
changeLocale: t('change_locale'),
|
||||
updateLocale: t('update_locale'),
|
||||
localeUpdated: t('locale_updated'),
|
||||
changeAvatar: t('change_avatar'),
|
||||
uploadAvatar: t('upload_avatar'),
|
||||
removeAvatar: t('remove_avatar'),
|
||||
@@ -29,6 +32,7 @@ export default function Page({ params }: PageType) {
|
||||
invalidPassword: t('invalid_password'),
|
||||
passwordNotMatch: t('password_not_match'),
|
||||
usernameEmpty: t('username_empty'),
|
||||
invalidLocale: t('invalid_locale'),
|
||||
};
|
||||
|
||||
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
||||
|
||||
30
frontend/src/app/[locale]/admin/AdminNav.tsx
Normal file
30
frontend/src/app/[locale]/admin/AdminNav.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
import { Tabs, Tab } from '@heroui/react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
type Props = {
|
||||
locale: LocaleCodeType;
|
||||
userManagementLabel: string;
|
||||
ldapLabel: string;
|
||||
};
|
||||
|
||||
export default function AdminNav({ locale, userManagementLabel, ldapLabel }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const selected = pathname.endsWith('/admin/ldap') ? 'ldap' : 'users';
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
aria-label="Admin sections"
|
||||
selectedKey={selected}
|
||||
onSelectionChange={(key) => {
|
||||
router.push(key === 'ldap' ? '/admin/ldap' : '/admin', { locale });
|
||||
}}
|
||||
>
|
||||
<Tab key="users" title={userManagementLabel} />
|
||||
<Tab key="ldap" title={ldapLabel} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useContext } from 'react';
|
||||
import { Button, addToast } from '@heroui/react';
|
||||
import UsersTable from './UsersTable';
|
||||
import PasswordResetDialog from './PasswordResetDialog';
|
||||
import AdminNav from './AdminNav';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { UserType, AdminMessages } from '@/types/user';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
@@ -149,6 +150,7 @@ export default function AdminPage({ messages, locale }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
|
||||
<AdminNav locale={locale} userManagementLabel={messages.userManagement} ldapLabel={messages.ldap} />
|
||||
<div className="w-full p-3 flex items-center justify-between">
|
||||
<h3 className="font-bold">{messages.userManagement}</h3>
|
||||
</div>
|
||||
|
||||
190
frontend/src/app/[locale]/admin/ldap/LdapSettingsPage.tsx
Normal file
190
frontend/src/app/[locale]/admin/ldap/LdapSettingsPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { Button, Input, Switch, Card, CardHeader, CardBody, CardFooter, addToast } from '@heroui/react';
|
||||
import AdminNav from '../AdminNav';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { LdapSettingsType, LdapMessages } from '@/types/ldap';
|
||||
import { fetchLdapSettings, updateLdapSettings, testLdapConnection } from '@/utils/ldapControl';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
|
||||
type Props = {
|
||||
messages: LdapMessages;
|
||||
locale: LocaleCodeType;
|
||||
};
|
||||
|
||||
const emptySettings: LdapSettingsType = {
|
||||
enabled: false,
|
||||
url: '',
|
||||
bindDn: '',
|
||||
bindCredentials: '',
|
||||
bindCredentialsSet: false,
|
||||
searchBase: '',
|
||||
searchFilter: '(mail={{username}})',
|
||||
emailAttribute: 'mail',
|
||||
usernameAttribute: 'cn',
|
||||
};
|
||||
|
||||
export default function LdapSettingsPage({ messages, locale }: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
const [settings, setSettings] = useState<LdapSettingsType>(emptySettings);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testUsername, setTestUsername] = useState('');
|
||||
const [testPassword, setTestPassword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!context.isAdmin()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await fetchLdapSettings(context.token.access_token);
|
||||
setSettings(data);
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching LDAP settings:', error);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [context]);
|
||||
|
||||
const update = (patch: Partial<LdapSettingsType>) => setSettings((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = await updateLdapSettings(context.token.access_token, settings);
|
||||
setSettings(saved);
|
||||
addToast({ title: 'Success', color: 'success', description: messages.saved });
|
||||
} catch (error: unknown) {
|
||||
logError('Error saving LDAP settings:', error);
|
||||
addToast({ title: 'Error', color: 'danger', description: messages.saveError });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testLdapConnection(context.token.access_token, settings, testUsername, testPassword);
|
||||
if (result.ok) {
|
||||
addToast({ title: 'Success', color: 'success', description: messages.testSuccess });
|
||||
} else {
|
||||
addToast({ title: 'Error', color: 'danger', description: `${messages.testFailed}: ${result.message ?? ''}` });
|
||||
}
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!context.isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
|
||||
<AdminNav locale={locale} userManagementLabel={messages.userManagement} ldapLabel={messages.ldap} />
|
||||
|
||||
<Card className="my-6">
|
||||
<CardHeader className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-large font-semibold">{messages.ldapSettings}</h2>
|
||||
<p className="text-small text-default-500">{messages.ldapDescription}</p>
|
||||
</div>
|
||||
<Switch isSelected={settings.enabled} onValueChange={(value) => update({ enabled: value })}>
|
||||
{messages.enableLdap}
|
||||
</Switch>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.serverUrl}
|
||||
placeholder="ldap://localhost:389"
|
||||
value={settings.url}
|
||||
onChange={(e) => update({ url: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.bindDn}
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
value={settings.bindDn}
|
||||
onChange={(e) => update({ bindDn: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
label={messages.bindCredentials}
|
||||
placeholder={settings.bindCredentialsSet ? messages.bindCredentialsPlaceholder : ''}
|
||||
value={settings.bindCredentials}
|
||||
onChange={(e) => update({ bindCredentials: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.searchBase}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
value={settings.searchBase}
|
||||
onChange={(e) => update({ searchBase: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.searchFilter}
|
||||
placeholder="(mail={{username}})"
|
||||
value={settings.searchFilter}
|
||||
onChange={(e) => update({ searchFilter: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.emailAttribute}
|
||||
value={settings.emailAttribute}
|
||||
onChange={(e) => update({ emailAttribute: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.usernameAttribute}
|
||||
value={settings.usernameAttribute}
|
||||
onChange={(e) => update({ usernameAttribute: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button size="sm" color="primary" onPress={handleSave} isLoading={isSaving}>
|
||||
{messages.save}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h2 className="text-large font-semibold">{messages.testConnection}</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
size="sm"
|
||||
label={messages.testUsername}
|
||||
autoComplete="off"
|
||||
value={testUsername}
|
||||
onChange={(e) => setTestUsername(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
label={messages.testPassword}
|
||||
autoComplete="off"
|
||||
value={testPassword}
|
||||
onChange={(e) => setTestPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button size="sm" color="secondary" onPress={handleTest} isLoading={isTesting}>
|
||||
{messages.testConnection}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/[locale]/admin/ldap/page.tsx
Normal file
49
frontend/src/app/[locale]/admin/ldap/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import LdapSettingsPage from './LdapSettingsPage';
|
||||
import { PageType } from '@/types/base';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { LdapMessages } from '@/types/ldap';
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Ldap' });
|
||||
return {
|
||||
title: `${t('ldap_settings')} | UnitTCMS`,
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({ params }: PageType) {
|
||||
const t = useTranslations('Ldap');
|
||||
const tAdmin = useTranslations('Admin');
|
||||
const messages: LdapMessages = {
|
||||
userManagement: tAdmin('user_management'),
|
||||
ldap: tAdmin('ldap'),
|
||||
ldapSettings: t('ldap_settings'),
|
||||
ldapDescription: t('ldap_description'),
|
||||
enableLdap: t('enable_ldap'),
|
||||
enableLdapDesc: t('enable_ldap_desc'),
|
||||
serverUrl: t('server_url'),
|
||||
bindDn: t('bind_dn'),
|
||||
bindCredentials: t('bind_credentials'),
|
||||
bindCredentialsPlaceholder: t('bind_credentials_placeholder'),
|
||||
searchBase: t('search_base'),
|
||||
searchFilter: t('search_filter'),
|
||||
emailAttribute: t('email_attribute'),
|
||||
usernameAttribute: t('username_attribute'),
|
||||
save: t('save'),
|
||||
saved: t('saved'),
|
||||
saveError: t('save_error'),
|
||||
testConnection: t('test_connection'),
|
||||
testUsername: t('test_username'),
|
||||
testPassword: t('test_password'),
|
||||
testSuccess: t('test_success'),
|
||||
testFailed: t('test_failed'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<LdapSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default function Page({ params }: PageType) {
|
||||
const t = useTranslations('Admin');
|
||||
const messages: AdminMessages = {
|
||||
userManagement: t('user_management'),
|
||||
ldap: t('ldap'),
|
||||
avatar: t('avatar'),
|
||||
id: t('id'),
|
||||
email: t('email'),
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
|
||||
<TableBody>
|
||||
<TableRow key="1">
|
||||
<TableCell>{messages.unittcms_version}</TableCell>
|
||||
<TableCell>1.0.0-beta.26</TableCell>
|
||||
<TableCell>1.0.0-beta.28</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="2">
|
||||
<TableCell>{messages.api_server}</TableCell>
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-80 min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700">
|
||||
<div className="min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700">
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
|
||||
@@ -73,6 +73,10 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
||||
<a href="/template/unittcms-import-template-v1.1.xlsx" download className="text-tiny underline">
|
||||
{messages.downloadTemplate}
|
||||
</a>
|
||||
<span className="mx-2 text-tiny">|</span>
|
||||
<a href="/template/unittcms-import-sample.json" download className="text-tiny underline">
|
||||
{messages.downloadJsonSample}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{importError && <Alert color="danger" className="mt-1" title="error" description={importError} />}
|
||||
@@ -101,6 +105,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.json"
|
||||
className="hidden"
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => handleInput(e)}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function CaseEditor({
|
||||
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
||||
const [isTitleInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [plusCount, setPlusCount] = useState<number>(0);
|
||||
const [idCounter, setIdCounter] = useState<number>(0);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
@@ -68,9 +68,14 @@ export default function CaseEditor({
|
||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||
|
||||
const onPlusClick = async (newStepNo: number) => {
|
||||
if (!testCase.Steps) {
|
||||
return;
|
||||
}
|
||||
setIsDirty(true);
|
||||
const nextId = idCounter + 1;
|
||||
const newStep: StepType = {
|
||||
id: plusCount,
|
||||
// hypothetical ID
|
||||
id: nextId,
|
||||
step: '',
|
||||
result: '',
|
||||
createdAt: new Date(),
|
||||
@@ -78,12 +83,10 @@ export default function CaseEditor({
|
||||
caseSteps: {
|
||||
stepNo: newStepNo,
|
||||
},
|
||||
uid: `uid${plusCount}`,
|
||||
uid: `uid${nextId}`,
|
||||
editState: 'new',
|
||||
};
|
||||
setPlusCount(plusCount + 1);
|
||||
|
||||
if (testCase.Steps) {
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo >= newStepNo) {
|
||||
return {
|
||||
@@ -104,14 +107,16 @@ export default function CaseEditor({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
}
|
||||
setIdCounter(nextId);
|
||||
};
|
||||
|
||||
const onDeleteClick = async (stepId: number) => {
|
||||
setIsDirty(true);
|
||||
|
||||
if (!testCase.Steps) {
|
||||
return;
|
||||
}
|
||||
// find deletedStep's stepNo
|
||||
if (testCase.Steps) {
|
||||
|
||||
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
|
||||
if (!deletedStep) {
|
||||
return;
|
||||
@@ -137,7 +142,6 @@ export default function CaseEditor({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLElement>) => {
|
||||
@@ -201,7 +205,10 @@ export default function CaseEditor({
|
||||
changeStep.editState = 'changed';
|
||||
}
|
||||
|
||||
if (testCase.Steps) {
|
||||
if (!testCase.Steps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: testCase.Steps.map((step) => {
|
||||
@@ -212,7 +219,6 @@ export default function CaseEditor({
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -223,6 +229,11 @@ export default function CaseEditor({
|
||||
data.Steps.forEach((step: StepType) => {
|
||||
step.editState = 'notChanged';
|
||||
});
|
||||
|
||||
// set idCounter to the max step id to avoid id conflict for new steps
|
||||
// id is not reflected on database
|
||||
const maxStepId = data.Steps.reduce((maxId: number, step: StepType) => Math.max(maxId, step.id), 0);
|
||||
setIdCounter(maxStepId);
|
||||
setTestCase(data);
|
||||
if (data.Tags) {
|
||||
setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []);
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
||||
importCases: t('import_cases'),
|
||||
importAvailable: t('import_available'),
|
||||
downloadTemplate: t('download_template'),
|
||||
downloadJsonSample: t('download_json_sample'),
|
||||
clickToUpload: t('click_to_upload'),
|
||||
orDragAndDrop: t('or_drag_and_drop'),
|
||||
maxFileSize: t('max_file_size'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import FoldersPane from './FoldersPane';
|
||||
import ResizablePanes from '@/components/ResizablePane';
|
||||
|
||||
export default function FoldersLayout({
|
||||
children,
|
||||
@@ -25,9 +26,12 @@ export default function FoldersLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full">
|
||||
<FoldersPane projectId={params.projectId} messages={messages} locale={params.locale} />
|
||||
<div className="flex-grow w-full">{children}</div>
|
||||
</div>
|
||||
<ResizablePanes
|
||||
minLeftWidth={15}
|
||||
minRightWidth={40}
|
||||
defaultLeftWidth={20}
|
||||
leftPane={<FoldersPane projectId={params.projectId} messages={messages} locale={params.locale} />}
|
||||
rightPane={children}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,7 +505,7 @@ export default function RunEditor({
|
||||
)}
|
||||
</Tree>
|
||||
</div>
|
||||
<div className="w-9/12">
|
||||
<div className="w-9/12 overflow-x-auto">
|
||||
<TestCaseSelector
|
||||
projectId={projectId}
|
||||
runId={runId}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SortDescriptor,
|
||||
Chip,
|
||||
} from '@heroui/react';
|
||||
import { ChevronDown, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
|
||||
import { ChevronDown, MoreVertical, CopyPlus, CopyMinus, MessageCircle } from 'lucide-react';
|
||||
import RunCaseStatus from './RunCaseStatus';
|
||||
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
||||
import { testRunCaseStatus } from '@/config/selection';
|
||||
@@ -64,6 +64,7 @@ export default function TestCaseSelector({
|
||||
{ name: messages.priority, uid: 'priority', sortable: true },
|
||||
{ name: messages.tags, uid: 'tags', sortable: false },
|
||||
{ name: messages.status, uid: 'runStatus', sortable: true },
|
||||
{ name: messages.comments, uid: 'comments', sortable: false },
|
||||
{ name: messages.actions, uid: 'actions' },
|
||||
];
|
||||
|
||||
@@ -115,6 +116,7 @@ export default function TestCaseSelector({
|
||||
const cellValue = testCase[columnKey as keyof CaseType];
|
||||
const isIncluded = isCaseIncluded(testCase);
|
||||
const runStatus = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].status : 0;
|
||||
const commentCount = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].commentCount || 0 : 0;
|
||||
|
||||
switch (columnKey) {
|
||||
case 'title':
|
||||
@@ -179,6 +181,24 @@ export default function TestCaseSelector({
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
case 'comments':
|
||||
return (
|
||||
<div className={isIncluded ? '' : notIncludedCaseClass}>
|
||||
{isIncluded && commentCount > 0 ? (
|
||||
<Link
|
||||
href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}?tab=comments`}
|
||||
locale={locale}
|
||||
className="flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
<span>{commentCount}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'actions':
|
||||
return (
|
||||
<Dropdown>
|
||||
|
||||
@@ -1,38 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useContext } from 'react';
|
||||
import { Tabs, Tab, Chip } from '@heroui/react';
|
||||
import { useEffect, useState, useContext, ChangeEvent, DragEvent } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, Tab } from '@heroui/react';
|
||||
import CaseDetail from './CaseDetail';
|
||||
import TestRecordEditor, { TestRecordType } from './TestRecordEditor';
|
||||
import {
|
||||
fetchRunCaseAttachments,
|
||||
fetchCreateRunCaseAttachments,
|
||||
fetchDownloadAttachment,
|
||||
fetchDeleteAttachment,
|
||||
} from './attachmentControl';
|
||||
import Comments from '@/components/Comments';
|
||||
import History from '@/components/History';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { fetchCase } from '@/utils/caseControl';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import type { CaseType, StepType } from '@/types/case';
|
||||
import type { RunDetailMessages } from '@/types/run';
|
||||
import type { RunCaseType, RunDetailMessages } from '@/types/run';
|
||||
import type { PriorityMessages } from '@/types/priority';
|
||||
import type { TestTypeMessages } from '@/types/testType';
|
||||
import type { CommentMessages } from '@/types/comment';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
runId: string;
|
||||
locale: string;
|
||||
caseId: string;
|
||||
messages: RunDetailMessages;
|
||||
testTypeMessages: TestTypeMessages;
|
||||
priorityMessages: PriorityMessages;
|
||||
commentMessages: CommentMessages;
|
||||
};
|
||||
|
||||
export default function TestCaseDetailPane({
|
||||
projectId,
|
||||
runId,
|
||||
locale,
|
||||
caseId,
|
||||
messages,
|
||||
testTypeMessages,
|
||||
priorityMessages,
|
||||
commentMessages,
|
||||
}: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedTab, setSelectedTab] = useState('caseDetail');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
||||
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
|
||||
const [testRecords, setTestRecords] = useState<TestRecordType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// if the url has ?tab=comments, then select the comments tab
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab === 'comments') {
|
||||
setSelectedTab('comments');
|
||||
} else if (tab === 'history') {
|
||||
setSelectedTab('history');
|
||||
} else if (tab === 'testRecord') {
|
||||
setSelectedTab('testRecord');
|
||||
} else {
|
||||
setSelectedTab('caseDetail');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
@@ -46,6 +76,14 @@ export default function TestCaseDetailPane({
|
||||
data.Steps.sort((a: StepType, b: StepType) => a.caseSteps.stepNo - b.caseSteps.stepNo);
|
||||
}
|
||||
setTestCase(data);
|
||||
|
||||
// Find the runCase for this case in this run
|
||||
if (data.RunCases && data.RunCases.length > 0) {
|
||||
const runCase = data.RunCases.find((rc: RunCaseType) => rc.runId === Number(runId));
|
||||
if (runCase) {
|
||||
setRunCaseId(runCase.id);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching case data', error);
|
||||
} finally {
|
||||
@@ -54,15 +92,61 @@ export default function TestCaseDetailPane({
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, [context, caseId]);
|
||||
}, [context, caseId, runId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRecordsEffect() {
|
||||
if (!context.isSignedIn() || !runCaseId) return;
|
||||
try {
|
||||
const records = await fetchRunCaseAttachments(runCaseId);
|
||||
setTestRecords(records);
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching test records', error);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRecordsEffect();
|
||||
}, [context, runCaseId]);
|
||||
|
||||
const handleFilesDrop = (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files) {
|
||||
handleCreateRecords(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilesInput = (event: ChangeEvent) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
handleCreateRecords(Array.from(input.files));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRecords = async (files: File[]) => {
|
||||
if (!runCaseId) return;
|
||||
const newRecords = await fetchCreateRunCaseAttachments(runCaseId, files);
|
||||
if (newRecords) {
|
||||
setTestRecords((prev) => [...prev, ...newRecords]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecord = async (attachmentId: number) => {
|
||||
await fetchDeleteAttachment(attachmentId);
|
||||
setTestRecords((prev) => prev.filter((record) => record.id !== attachmentId));
|
||||
};
|
||||
|
||||
if (isFetching || !testCase) {
|
||||
return <div>loading...</div>;
|
||||
} else {
|
||||
return (
|
||||
<div className="flex w-full flex-col p-3">
|
||||
<Tabs aria-label="Options" size="sm">
|
||||
<Tab key="caseDetail" title="Case Detail">
|
||||
<div className="flex h-full w-full flex-col p-3">
|
||||
<Tabs
|
||||
aria-label="Options"
|
||||
size="sm"
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(String(key))}
|
||||
>
|
||||
<Tab key="caseDetail" title={messages.caseDetail}>
|
||||
<CaseDetail
|
||||
projectId={projectId}
|
||||
testCase={testCase}
|
||||
@@ -72,20 +156,26 @@ export default function TestCaseDetailPane({
|
||||
priorityMessages={priorityMessages}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
key="comments"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Comments</span>
|
||||
<Chip size="sm" variant="faded">
|
||||
3
|
||||
</Chip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Comments />
|
||||
<Tab key="testRecord" title={messages.testRecord}>
|
||||
<TestRecordEditor
|
||||
isDisabled={!runCaseId || !context.isProjectReporter(Number(projectId))}
|
||||
attachments={testRecords}
|
||||
onAttachmentDownload={fetchDownloadAttachment}
|
||||
onAttachmentDelete={handleDeleteRecord}
|
||||
onFilesDrop={handleFilesDrop}
|
||||
onFilesInput={handleFilesInput}
|
||||
messages={messages}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="history" title="History">
|
||||
<Tab key="comments" title={messages.comments}>
|
||||
<Comments
|
||||
projectId={projectId}
|
||||
commentableType="RunCase"
|
||||
commentableId={runCaseId}
|
||||
messages={commentMessages}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="history" title={messages.history}>
|
||||
<History />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Image, Button, Tooltip, Card, CardBody } from '@heroui/react';
|
||||
import { Trash, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||
import { ChangeEvent, DragEvent } from 'react';
|
||||
import Config from '@/config/config';
|
||||
import type { RunDetailMessages } from '@/types/run';
|
||||
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
export type TestRecordType = {
|
||||
id: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
filename: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg'];
|
||||
function isImage(filename: string) {
|
||||
const extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||
return imageExtensions.includes(extension);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isDisabled: boolean;
|
||||
attachments: TestRecordType[];
|
||||
onAttachmentDownload: (attachmentId: number, downloadFileName: string) => void;
|
||||
onAttachmentDelete: (attachmentId: number) => void;
|
||||
onFilesDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
onFilesInput: (event: ChangeEvent) => void;
|
||||
messages: RunDetailMessages;
|
||||
};
|
||||
|
||||
export default function TestRecordEditor({
|
||||
isDisabled = false,
|
||||
attachments = [],
|
||||
onAttachmentDownload,
|
||||
onAttachmentDelete,
|
||||
onFilesDrop,
|
||||
onFilesInput,
|
||||
messages,
|
||||
}: Props) {
|
||||
const images: TestRecordType[] = [];
|
||||
const others: TestRecordType[] = [];
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
if (isImage(attachment.filename)) {
|
||||
images.push(attachment);
|
||||
} else {
|
||||
others.push(attachment);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap mt-3">
|
||||
{images.map((image, index) => (
|
||||
<Card key={index} radius="sm" className="mt-2 me-2 max-w-md">
|
||||
<CardBody>
|
||||
<Image
|
||||
alt={image.title}
|
||||
src={`${apiServer}/uploads/${image.filename}`}
|
||||
className="object-cover h-40 w-40"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{image.title}</p>
|
||||
<div>
|
||||
<Tooltip content={messages.download}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDownload(image.id, image.title)}
|
||||
>
|
||||
<ArrowDownToLine size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={messages.delete}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
isDisabled={isDisabled}
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDelete(image.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{others.map((file, index) => (
|
||||
<Card key={index} radius="sm" className="mt-2 max-w-md">
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{file.title}</p>
|
||||
<div>
|
||||
<Tooltip content={messages.download}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDownload(file.id, file.title)}
|
||||
>
|
||||
<ArrowDownToLine size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={messages.delete}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
isDisabled={isDisabled}
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDelete(file.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center w-96 mt-3"
|
||||
onDrop={(event) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
onFilesDrop(event);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
>
|
||||
<label
|
||||
htmlFor="testrecord-dropzone-file"
|
||||
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-neutral-200 border-dashed rounded-lg bg-neutral-50 dark:hover:bg-bray-800 dark:bg-neutral-700 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-600 ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<ArrowUpFromLine />
|
||||
<p className="mb-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<span className="font-semibold">{messages.clickToUpload}</span>
|
||||
<span>{messages.orDragAndDrop}</span>
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{messages.maxFileSize}: 50 MB</p>
|
||||
</div>
|
||||
<input
|
||||
id="testrecord-dropzone-file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => onFilesInput(e)}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Config from '@/config/config';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
async function fetchRunCaseAttachments(runCaseId: number) {
|
||||
const url = `${apiServer}/runcaseattachments?runCaseId=${runCaseId}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching test records', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCreateRunCaseAttachments(runCaseId: number, files: File[]) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', files[i]);
|
||||
}
|
||||
|
||||
const url = `${apiServer}/runcaseattachments?runCaseId=${runCaseId}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: unknown) {
|
||||
logError('Error uploading test records', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloadAttachment(attachmentId: number, downloadFileName: string) {
|
||||
const url = `${apiServer}/attachments/download/${attachmentId}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = downloadFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error: unknown) {
|
||||
logError('Error downloading attachment', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeleteAttachment(attachmentId: number) {
|
||||
const url = `${apiServer}/attachments/${attachmentId}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logError('Error deleting file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchRunCaseAttachments, fetchCreateRunCaseAttachments, fetchDownloadAttachment, fetchDeleteAttachment };
|
||||
@@ -21,6 +21,15 @@ export default function Page({
|
||||
preconditions: t('preconditions'),
|
||||
expectedResult: t('expected_result'),
|
||||
detailsOfTheStep: t('details_of_the_step'),
|
||||
caseDetail: t('case_detail'),
|
||||
comments: t('comments'),
|
||||
history: t('history'),
|
||||
testRecord: t('test_record'),
|
||||
download: t('download'),
|
||||
delete: t('delete'),
|
||||
clickToUpload: t('click_to_upload'),
|
||||
orDragAndDrop: t('or_drag_and_drop'),
|
||||
maxFileSize: t('max_file_size'),
|
||||
};
|
||||
|
||||
const pt = useTranslations('Priority');
|
||||
@@ -48,14 +57,33 @@ export default function Page({
|
||||
manual: tt('manual'),
|
||||
};
|
||||
|
||||
const ct = useTranslations('Comments');
|
||||
const commentMessages = {
|
||||
comments: ct('comments'),
|
||||
noComments: ct('no_comments'),
|
||||
addComment: ct('add_comment'),
|
||||
save: ct('save'),
|
||||
cancel: ct('cancel'),
|
||||
placeholder: ct('placeholder'),
|
||||
notIncludedInRun: ct('not_included_in_run'),
|
||||
commentAdded: ct('comment_added'),
|
||||
failedToAddComment: ct('failed_to_add_comment'),
|
||||
commentUpdated: ct('comment_updated'),
|
||||
failedToUpdateComment: ct('failed_to_update_comment'),
|
||||
commentDeleted: ct('comment_deleted'),
|
||||
failedToDeleteComment: ct('failed_to_delete_comment'),
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailPane
|
||||
projectId={params.projectId}
|
||||
runId={params.runId}
|
||||
caseId={params.caseId}
|
||||
locale={params.locale}
|
||||
messages={messages}
|
||||
priorityMessages={priorityMessages}
|
||||
testTypeMessages={testTypeMessages}
|
||||
commentMessages={commentMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function RunLayout({
|
||||
selected: t('selected'),
|
||||
tags: t('tags'),
|
||||
selectTags: t('select_tags'),
|
||||
comments: t('comments'),
|
||||
};
|
||||
|
||||
const rst = useTranslations('RunStatus');
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
||||
avatarPath: '',
|
||||
role: -1,
|
||||
username: '',
|
||||
locale: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createNavigation } from 'next-intl/navigation';
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['de', 'en', 'pt-BR', 'zh-CN', 'ja'],
|
||||
locales: ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: {
|
||||
mode: 'always',
|
||||
|
||||
@@ -4,5 +4,5 @@ import { routing } from './i18n/routing';
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ['/', '/(de|en|pt-BR|zh-CN|ja)/:path*'],
|
||||
matcher: ['/', '/(de|en|pt-BR|zh-CN|zh-TW|ja)/:path*'],
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ type RunCaseType = {
|
||||
caseId: number;
|
||||
status: number;
|
||||
editState: 'notChanged' | 'changed' | 'new' | 'deleted';
|
||||
commentCount?: number;
|
||||
};
|
||||
|
||||
type CaseAttachmentType = {
|
||||
@@ -101,6 +102,7 @@ type CasesMessages = {
|
||||
importCases: string;
|
||||
importAvailable: string;
|
||||
downloadTemplate: string;
|
||||
downloadJsonSample: string;
|
||||
clickToUpload: string;
|
||||
orDragAndDrop: string;
|
||||
maxFileSize: string;
|
||||
|
||||
32
frontend/types/comment.ts
Normal file
32
frontend/types/comment.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
type CommentType = {
|
||||
id: number;
|
||||
commentableType: 'RunCase' | 'Run' | 'Case';
|
||||
commentableId: number;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
User: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CommentMessages = {
|
||||
comments: string;
|
||||
noComments: string;
|
||||
addComment: string;
|
||||
save: string;
|
||||
cancel: string;
|
||||
placeholder: string;
|
||||
notIncludedInRun: string;
|
||||
commentAdded: string;
|
||||
failedToAddComment: string;
|
||||
commentUpdated: string;
|
||||
failedToUpdateComment: string;
|
||||
commentDeleted: string;
|
||||
failedToDeleteComment: string;
|
||||
};
|
||||
|
||||
export type { CommentType, CommentMessages };
|
||||
36
frontend/types/ldap.ts
Normal file
36
frontend/types/ldap.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type LdapSettingsType = {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
bindDn: string;
|
||||
bindCredentials: string;
|
||||
bindCredentialsSet?: boolean;
|
||||
searchBase: string;
|
||||
searchFilter: string;
|
||||
emailAttribute: string;
|
||||
usernameAttribute: string;
|
||||
};
|
||||
|
||||
export type LdapMessages = {
|
||||
userManagement: string;
|
||||
ldap: string;
|
||||
ldapSettings: string;
|
||||
ldapDescription: string;
|
||||
enableLdap: string;
|
||||
enableLdapDesc: string;
|
||||
serverUrl: string;
|
||||
bindDn: string;
|
||||
bindCredentials: string;
|
||||
bindCredentialsPlaceholder: string;
|
||||
searchBase: string;
|
||||
searchFilter: string;
|
||||
emailAttribute: string;
|
||||
usernameAttribute: string;
|
||||
save: string;
|
||||
saved: string;
|
||||
saveError: string;
|
||||
testConnection: string;
|
||||
testUsername: string;
|
||||
testPassword: string;
|
||||
testSuccess: string;
|
||||
testFailed: string;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'ja';
|
||||
export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'zh-TW' | 'ja';
|
||||
|
||||
export type LocaleType = {
|
||||
code: LocaleCodeType;
|
||||
|
||||
@@ -89,6 +89,7 @@ type RunMessages = {
|
||||
selected: string;
|
||||
tags: string;
|
||||
selectTags: string;
|
||||
comments: string;
|
||||
};
|
||||
|
||||
type RunDetailMessages = {
|
||||
@@ -102,6 +103,15 @@ type RunDetailMessages = {
|
||||
preconditions: string;
|
||||
expectedResult: string;
|
||||
detailsOfTheStep: string;
|
||||
caseDetail: string;
|
||||
comments: string;
|
||||
history: string;
|
||||
testRecord: string;
|
||||
download: string;
|
||||
delete: string;
|
||||
clickToUpload: string;
|
||||
orDragAndDrop: string;
|
||||
maxFileSize: string;
|
||||
};
|
||||
|
||||
export type {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type UserType = {
|
||||
username: string;
|
||||
role: number;
|
||||
avatarPath: string | null;
|
||||
locale: LocaleCodeType | null;
|
||||
};
|
||||
|
||||
export type TokenProps = {
|
||||
@@ -69,6 +70,7 @@ export type AuthMessages = {
|
||||
|
||||
export type AdminMessages = {
|
||||
userManagement: string;
|
||||
ldap: string;
|
||||
avatar: string;
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
110
frontend/utils/commentControl.ts
Normal file
110
frontend/utils/commentControl.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { logError } from './errorHandler';
|
||||
import { CommentType } from '@/types/comment';
|
||||
import Config from '@/config/config';
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
export async function fetchComments(
|
||||
jwt: string,
|
||||
commentableType: 'RunCase' | 'Run' | 'Case',
|
||||
commentableId: number
|
||||
): Promise<CommentType[]> {
|
||||
const fetchOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/comments?commentableType=${commentableType}&commentableId=${commentableId}`;
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data || [];
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createComment(
|
||||
jwt: string,
|
||||
commentableType: 'RunCase' | 'Run' | 'Case',
|
||||
commentableId: number,
|
||||
content: string
|
||||
): Promise<CommentType | null> {
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/comments/?commentableType=${commentableType}&commentableId=${commentableId}`;
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data || null;
|
||||
} catch (error: unknown) {
|
||||
logError('Error creating comments:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateComment(jwt: string, commentId: number, content: string): Promise<CommentType | null> {
|
||||
const fetchOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/comments/${commentId}`;
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data || null;
|
||||
} catch (error: unknown) {
|
||||
logError('Error updating comments:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteComment(jwt: string, commentId: number): Promise<void> {
|
||||
const fetchOptions = {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/comments/${commentId}`;
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
return;
|
||||
} catch (error: unknown) {
|
||||
logError('Error deleting comments:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
103
frontend/utils/formGuard.test.ts
Normal file
103
frontend/utils/formGuard.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// mock cleanup
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
vi.mock('react', () => ({
|
||||
useEffect: (fn: () => void) => {
|
||||
cleanupFn = fn() as (() => void) | undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
import { useFormGuard } from './formGuard';
|
||||
|
||||
describe('useFormGuard', () => {
|
||||
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - we will mock confirm
|
||||
confirmSpy = vi.spyOn(window, 'confirm');
|
||||
cleanupFn = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupFn?.();
|
||||
cleanupFn = undefined;
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
// helper: simulate clicking an anchor
|
||||
const clickAnchor = (href: string, target?: string): MouseEvent => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.setAttribute('href', href);
|
||||
if (target) anchor.setAttribute('target', target);
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
anchor.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('does not show confirm when form is clean', () => {
|
||||
useFormGuard(false, 'Leave?');
|
||||
clickAnchor('/some-path');
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows confirm with the given text when dirty', () => {
|
||||
confirmSpy.mockReturnValue(true);
|
||||
useFormGuard(true, 'Are you sure?');
|
||||
clickAnchor('/other-page');
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
|
||||
});
|
||||
|
||||
it('prevents navigation when dirty and user cancels confirm', () => {
|
||||
confirmSpy.mockReturnValue(false);
|
||||
useFormGuard(true, 'Leave?');
|
||||
|
||||
const event = clickAnchor('/other-page');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('allows navigation when dirty and user confirms', () => {
|
||||
confirmSpy.mockReturnValue(true);
|
||||
useFormGuard(true, 'Leave?');
|
||||
|
||||
const event = clickAnchor('/other-page');
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external links', () => {
|
||||
it('does not show confirm for anchor with target="_blank"', () => {
|
||||
useFormGuard(true, 'Leave?');
|
||||
clickAnchor('https://example.com', '_blank');
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnitTCMS use case', () => {
|
||||
it('does not show confirm when navigating to case detail page of test run', () => {
|
||||
const projectId = '1';
|
||||
const runId = '2';
|
||||
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||
clickAnchor(`/projects/${projectId}/runs/${runId}/cases/123`);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows confirm when navigating to test cases page', () => {
|
||||
const projectId = '1';
|
||||
const runId = '2';
|
||||
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||
clickAnchor(`/projects/${projectId}/runs/${runId}/cases`);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => {
|
||||
const isIgnoredPath = (href: string, compiledPatterns: RegExp[]): boolean => {
|
||||
return compiledPatterns.some((regex) => regex.test(href));
|
||||
};
|
||||
|
||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePathPatterns?: string[]) => {
|
||||
useEffect(() => {
|
||||
const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => {
|
||||
try {
|
||||
return [new RegExp(pattern)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty) return;
|
||||
|
||||
@@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
||||
if (!href) return;
|
||||
|
||||
// do not show confirm for ignored paths
|
||||
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) {
|
||||
if (isIgnoredPath(href, compiledPatterns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [confirmText, isDirty, ignorePaths]);
|
||||
}, [confirmText, isDirty, ignorePathPatterns]);
|
||||
};
|
||||
|
||||
57
frontend/utils/ldapControl.ts
Normal file
57
frontend/utils/ldapControl.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { LdapSettingsType } from '@/types/ldap';
|
||||
import Config from '@/config/config';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
async function fetchLdapSettings(jwt: string): Promise<LdapSettingsType> {
|
||||
const response = await fetch(`${apiServer}/ldap`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function updateLdapSettings(jwt: string, settings: LdapSettingsType): Promise<LdapSettingsType> {
|
||||
const response = await fetch(`${apiServer}/ldap`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function testLdapConnection(
|
||||
jwt: string,
|
||||
settings: LdapSettingsType,
|
||||
testUsername: string,
|
||||
testPassword: string
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${apiServer}/ldap/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify({ ...settings, testUsername, testPassword }),
|
||||
});
|
||||
return response.json();
|
||||
} catch (error: unknown) {
|
||||
logError('Error testing LDAP connection:', error);
|
||||
return { ok: false, message: 'Request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchLdapSettings, updateLdapSettings, testLdapConnection };
|
||||
@@ -219,6 +219,36 @@ async function adminResetPassword(jwt: string, userId: number, newPassword: stri
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLocale(jwt: string, locale: string) {
|
||||
const updateData = {
|
||||
locale,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/locale`;
|
||||
|
||||
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 updating locale:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
findUser,
|
||||
searchUsers,
|
||||
@@ -228,4 +258,5 @@ export {
|
||||
uploadAvatar,
|
||||
deleteAvatar,
|
||||
adminResetPassword,
|
||||
updateLocale,
|
||||
};
|
||||
|
||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -19,7 +19,9 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"express": "^4.21.0",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.8.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react": "^19.2.4",
|
||||
"sequelize": "^6.37.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"supertest": "^7.1.4",
|
||||
@@ -50,10 +52,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -1255,6 +1258,23 @@
|
||||
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz",
|
||||
@@ -1289,6 +1309,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
|
||||
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.29.1",
|
||||
"@typescript-eslint/types": "8.29.1",
|
||||
@@ -1838,6 +1859,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2938,6 +2960,19 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -3182,6 +3217,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3361,6 +3397,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
|
||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
@@ -4220,6 +4257,25 @@
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.8.3",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz",
|
||||
"integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -6349,6 +6405,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -7498,6 +7564,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7974,6 +8041,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
|
||||
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "1.6.1",
|
||||
"@vitest/runner": "1.6.1",
|
||||
@@ -8034,6 +8102,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -8184,6 +8262,28 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"express": "^4.21.0",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.8.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react": "^19.2.4",
|
||||
"sequelize": "^6.37.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"supertest": "^7.1.4",
|
||||
|
||||
Reference in New Issue
Block a user