Compare commits
3 Commits
main
...
feat/ldap-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c0e75637 | ||
|
|
1c977f9266 | ||
|
|
02fa631f02 |
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.
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
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;
|
||||||
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ldapts": "^8.1.8",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
@@ -1010,6 +1011,29 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -1840,6 +1863,18 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
@@ -3068,6 +3103,12 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
|
"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": {
|
"end-of-stream": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||||
@@ -4328,7 +4389,6 @@
|
|||||||
"version": "4.21.2",
|
"version": "4.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
"peer": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -4863,6 +4923,14 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
|
"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": {
|
"string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ldapts": "^8.1.8",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
|
|||||||
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 bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import defineUser from '../../models/users.js';
|
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) {
|
export default function (sequelize) {
|
||||||
const User = defineUser(sequelize, DataTypes);
|
const User = defineUser(sequelize, DataTypes);
|
||||||
|
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
|
||||||
const secretKey = process.env.SECRET_KEY || defaultDangerKey;
|
const secretKey = process.env.SECRET_KEY || defaultDangerKey;
|
||||||
|
|
||||||
|
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) => {
|
router.post('/signin', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
const user = await User.findOne({
|
|
||||||
where: {
|
// Local authentication first. This keeps existing accounts (including
|
||||||
email: email,
|
// the bootstrap administrator) usable even when LDAP is enabled.
|
||||||
},
|
const user = await User.findOne({ where: { email } });
|
||||||
});
|
if (user) {
|
||||||
if (!user) {
|
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||||
return res.status(401).json({ error: 'Authentication failed' });
|
if (passwordMatch) {
|
||||||
|
return issueToken(res, user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
// Fall back to LDAP when enabled. The email field carries the LDAP login
|
||||||
if (!passwordMatch) {
|
// identifier (substituted into the configured search filter).
|
||||||
return res.status(401).json({ error: 'Authentication failed' });
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 });
|
return res.status(401).json({ error: 'Authentication failed' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).send('Sign up failed');
|
res.status(500).send('Sign in failed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ app.use('/users', usersUpdateRoleRoute(sequelize));
|
|||||||
app.use('/users', signUpRoute(sequelize));
|
app.use('/users', signUpRoute(sequelize));
|
||||||
app.use('/users', signInRoute(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"
|
// "/projects"
|
||||||
import projectsIndexRoute from './routes/projects/index.js';
|
import projectsIndexRoute from './routes/projects/index.js';
|
||||||
import projectsShowRoute from './routes/projects/show.js';
|
import projectsShowRoute from './routes/projects/show.js';
|
||||||
@@ -149,6 +157,12 @@ import runCaseEditRoute from './routes/runcases/edit.js';
|
|||||||
app.use('/runcases', runCaseIndexRoute(sequelize));
|
app.use('/runcases', runCaseIndexRoute(sequelize));
|
||||||
app.use('/runcases', runCaseEditRoute(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"
|
// "/members"
|
||||||
import membersIndexRoute from './routes/members/index.js';
|
import membersIndexRoute from './routes/members/index.js';
|
||||||
import membersNewRoute from './routes/members/new.js';
|
import membersNewRoute from './routes/members/new.js';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
- SECRET_KEY=your_secret_key_here
|
- SECRET_KEY=your_secret_key_here
|
||||||
- IS_DEMO=false # set to true to seed the database
|
- IS_DEMO=false # set to true to seed the database
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/app/backend/database
|
- ~/tcms-db-data:/app/backend/database
|
||||||
- ./backend/public/uploads:/app/backend/public/uploads
|
- ./backend/public/uploads:/app/backend/public/uploads
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "Benutzerverwaltung",
|
"user_management": "Benutzerverwaltung",
|
||||||
|
"ldap": "LDAP",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
@@ -362,7 +363,13 @@
|
|||||||
"no_case_selected": "Kein Testfall ausgewählt",
|
"no_case_selected": "Kein Testfall ausgewählt",
|
||||||
"case_detail": "Testfall-Details",
|
"case_detail": "Testfall-Details",
|
||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
"history": "Verlauf"
|
"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": {
|
||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
@@ -428,5 +435,27 @@
|
|||||||
"tag_error_create": "Tag konnte nicht erstellt werden. Bitte versuche es erneut.",
|
"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_update": "Tag konnte nicht aktualisiert werden. Bitte versuche es erneut.",
|
||||||
"tag_error_delete": "Tag konnte nicht gelöscht 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "User Management",
|
"user_management": "User Management",
|
||||||
|
"ldap": "LDAP",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -362,7 +363,13 @@
|
|||||||
"no_case_selected": "No test case selected",
|
"no_case_selected": "No test case selected",
|
||||||
"case_detail": "Test case detail",
|
"case_detail": "Test case detail",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"history": "History"
|
"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": "Comments",
|
"comments": "Comments",
|
||||||
@@ -428,5 +435,27 @@
|
|||||||
"tag_error_create": "Failed to create tag. Please try again.",
|
"tag_error_create": "Failed to create tag. Please try again.",
|
||||||
"tag_error_update": "Failed to update tag. Please try again.",
|
"tag_error_update": "Failed to update tag. Please try again.",
|
||||||
"tag_error_delete": "Failed to delete 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "ユーザー管理",
|
"user_management": "ユーザー管理",
|
||||||
|
"ldap": "LDAP",
|
||||||
"avatar": "アバター",
|
"avatar": "アバター",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"email": "メールアドレス",
|
"email": "メールアドレス",
|
||||||
@@ -362,7 +363,13 @@
|
|||||||
"no_case_selected": "テストケースが選択されていません",
|
"no_case_selected": "テストケースが選択されていません",
|
||||||
"case_detail": "テストケース詳細",
|
"case_detail": "テストケース詳細",
|
||||||
"comments": "コメント",
|
"comments": "コメント",
|
||||||
"history": "履歴"
|
"history": "履歴",
|
||||||
|
"test_record": "テスト記録",
|
||||||
|
"download": "ダウンロード",
|
||||||
|
"delete": "削除",
|
||||||
|
"click_to_upload": "クリックしてアップロード",
|
||||||
|
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||||
|
"max_file_size": "最大ファイルサイズ"
|
||||||
},
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"comments": "コメント",
|
"comments": "コメント",
|
||||||
@@ -428,5 +435,27 @@
|
|||||||
"tag_error_create": "タグの作成に失敗しました。もう一度お試しください。",
|
"tag_error_create": "タグの作成に失敗しました。もう一度お試しください。",
|
||||||
"tag_error_update": "タグの更新に失敗しました。もう一度お試しください。",
|
"tag_error_update": "タグの更新に失敗しました。もう一度お試しください。",
|
||||||
"tag_error_delete": "タグの削除に失敗しました。もう一度お試しください。"
|
"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": "接続に失敗しました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "Gerenciamento de Usuários",
|
"user_management": "Gerenciamento de Usuários",
|
||||||
|
"ldap": "LDAP",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
@@ -362,7 +363,13 @@
|
|||||||
"no_case_selected": "Nenhum caso de teste selecionado",
|
"no_case_selected": "Nenhum caso de teste selecionado",
|
||||||
"case_detail": "Detalhe do caso de teste",
|
"case_detail": "Detalhe do caso de teste",
|
||||||
"comments": "Comentários",
|
"comments": "Comentários",
|
||||||
"history": "Histórico"
|
"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": {
|
||||||
"comments": "Comentários",
|
"comments": "Comentários",
|
||||||
@@ -428,5 +435,27 @@
|
|||||||
"tag_error_create": "Falha ao criar tag. Por favor, tente novamente.",
|
"tag_error_create": "Falha ao criar tag. Por favor, tente novamente.",
|
||||||
"tag_error_update": "Falha ao atualizar 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."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "用户管理",
|
"user_management": "用户管理",
|
||||||
|
"ldap": "LDAP",
|
||||||
"avatar": "头像",
|
"avatar": "头像",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
@@ -362,7 +363,13 @@
|
|||||||
"no_case_selected": "未选择测试用例",
|
"no_case_selected": "未选择测试用例",
|
||||||
"case_detail": "测试用例详情",
|
"case_detail": "测试用例详情",
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
"history": "历史"
|
"history": "历史",
|
||||||
|
"test_record": "测试记录",
|
||||||
|
"download": "下载",
|
||||||
|
"delete": "删除",
|
||||||
|
"click_to_upload": "点击上传",
|
||||||
|
"or_drag_and_drop": " 或拖拽文件到此处",
|
||||||
|
"max_file_size": "最大文件大小"
|
||||||
},
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
@@ -428,5 +435,27 @@
|
|||||||
"tag_error_create": "标签创建失败,请重试。",
|
"tag_error_create": "标签创建失败,请重试。",
|
||||||
"tag_error_update": "标签更新失败,请重试。",
|
"tag_error_update": "标签更新失败,请重试。",
|
||||||
"tag_error_delete": "标签删除失败,请重试。"
|
"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": "连接失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "使用者管理",
|
"user_management": "使用者管理",
|
||||||
|
"ldap": "LDAP",
|
||||||
"avatar": "頭像",
|
"avatar": "頭像",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"email": "電子郵件",
|
"email": "電子郵件",
|
||||||
@@ -362,7 +363,13 @@
|
|||||||
"no_case_selected": "未選擇測試案例",
|
"no_case_selected": "未選擇測試案例",
|
||||||
"case_detail": "測試案例詳情",
|
"case_detail": "測試案例詳情",
|
||||||
"comments": "留言",
|
"comments": "留言",
|
||||||
"history": "歷史"
|
"history": "歷史",
|
||||||
|
"test_record": "測試紀錄",
|
||||||
|
"download": "下載",
|
||||||
|
"delete": "刪除",
|
||||||
|
"click_to_upload": "點擊上傳",
|
||||||
|
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||||
|
"max_file_size": "檔案大小上限"
|
||||||
},
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"comments": "留言",
|
"comments": "留言",
|
||||||
@@ -428,5 +435,27 @@
|
|||||||
"tag_error_create": "標籤建立失敗,請重試。",
|
"tag_error_create": "標籤建立失敗,請重試。",
|
||||||
"tag_error_update": "標籤更新失敗,請重試。",
|
"tag_error_update": "標籤更新失敗,請重試。",
|
||||||
"tag_error_delete": "標籤刪除失敗,請重試。"
|
"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": "連線失敗"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Button, addToast } from '@heroui/react';
|
||||||
import UsersTable from './UsersTable';
|
import UsersTable from './UsersTable';
|
||||||
import PasswordResetDialog from './PasswordResetDialog';
|
import PasswordResetDialog from './PasswordResetDialog';
|
||||||
|
import AdminNav from './AdminNav';
|
||||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||||
import { UserType, AdminMessages } from '@/types/user';
|
import { UserType, AdminMessages } from '@/types/user';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
@@ -149,6 +150,7 @@ export default function AdminPage({ messages, locale }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
|
<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">
|
<div className="w-full p-3 flex items-center justify-between">
|
||||||
<h3 className="font-bold">{messages.userManagement}</h3>
|
<h3 className="font-bold">{messages.userManagement}</h3>
|
||||||
</div>
|
</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 t = useTranslations('Admin');
|
||||||
const messages: AdminMessages = {
|
const messages: AdminMessages = {
|
||||||
userManagement: t('user_management'),
|
userManagement: t('user_management'),
|
||||||
|
ldap: t('ldap'),
|
||||||
avatar: t('avatar'),
|
avatar: t('avatar'),
|
||||||
id: t('id'),
|
id: t('id'),
|
||||||
email: t('email'),
|
email: t('email'),
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useState, useContext } from 'react';
|
import { useEffect, useState, useContext, ChangeEvent, DragEvent } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Tabs, Tab } from '@heroui/react';
|
import { Tabs, Tab } from '@heroui/react';
|
||||||
import CaseDetail from './CaseDetail';
|
import CaseDetail from './CaseDetail';
|
||||||
|
import TestRecordEditor, { TestRecordType } from './TestRecordEditor';
|
||||||
|
import {
|
||||||
|
fetchRunCaseAttachments,
|
||||||
|
fetchCreateRunCaseAttachments,
|
||||||
|
fetchDownloadAttachment,
|
||||||
|
fetchDeleteAttachment,
|
||||||
|
} from './attachmentControl';
|
||||||
import Comments from '@/components/Comments';
|
import Comments from '@/components/Comments';
|
||||||
import History from '@/components/History';
|
import History from '@/components/History';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
@@ -41,6 +48,7 @@ export default function TestCaseDetailPane({
|
|||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
||||||
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
|
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
|
||||||
|
const [testRecords, setTestRecords] = useState<TestRecordType[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if the url has ?tab=comments, then select the comments tab
|
// if the url has ?tab=comments, then select the comments tab
|
||||||
@@ -49,6 +57,8 @@ export default function TestCaseDetailPane({
|
|||||||
setSelectedTab('comments');
|
setSelectedTab('comments');
|
||||||
} else if (tab === 'history') {
|
} else if (tab === 'history') {
|
||||||
setSelectedTab('history');
|
setSelectedTab('history');
|
||||||
|
} else if (tab === 'testRecord') {
|
||||||
|
setSelectedTab('testRecord');
|
||||||
} else {
|
} else {
|
||||||
setSelectedTab('caseDetail');
|
setSelectedTab('caseDetail');
|
||||||
}
|
}
|
||||||
@@ -84,6 +94,47 @@ export default function TestCaseDetailPane({
|
|||||||
fetchDataEffect();
|
fetchDataEffect();
|
||||||
}, [context, caseId, runId]);
|
}, [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) {
|
if (isFetching || !testCase) {
|
||||||
return <div>loading...</div>;
|
return <div>loading...</div>;
|
||||||
} else {
|
} else {
|
||||||
@@ -105,6 +156,17 @@ export default function TestCaseDetailPane({
|
|||||||
priorityMessages={priorityMessages}
|
priorityMessages={priorityMessages}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<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="comments" title={messages.comments}>
|
<Tab key="comments" title={messages.comments}>
|
||||||
<Comments
|
<Comments
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -24,6 +24,12 @@ export default function Page({
|
|||||||
caseDetail: t('case_detail'),
|
caseDetail: t('case_detail'),
|
||||||
comments: t('comments'),
|
comments: t('comments'),
|
||||||
history: t('history'),
|
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');
|
const pt = useTranslations('Priority');
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -106,6 +106,12 @@ type RunDetailMessages = {
|
|||||||
caseDetail: string;
|
caseDetail: string;
|
||||||
comments: string;
|
comments: string;
|
||||||
history: string;
|
history: string;
|
||||||
|
testRecord: string;
|
||||||
|
download: string;
|
||||||
|
delete: string;
|
||||||
|
clickToUpload: string;
|
||||||
|
orDragAndDrop: string;
|
||||||
|
maxFileSize: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type AuthMessages = {
|
|||||||
|
|
||||||
export type AdminMessages = {
|
export type AdminMessages = {
|
||||||
userManagement: string;
|
userManagement: string;
|
||||||
|
ldap: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
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 };
|
||||||
Reference in New Issue
Block a user