2 Commits

Author SHA1 Message Date
LittleYellow
b0c0e75637 chore: persist db data to host path and add agent instructions
- docker-compose: mount ~/tcms-db-data for the sqlite database
- add CLAUDE.md agent coding guidelines

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:06:16 +08:00
LittleYellow
1c977f9266 feat: add LDAP authentication settings and toggle
Add an admin-only LDAP configuration UI with an enable toggle and full
sign-in integration.

Backend:
- ldapSettings model + migration (single-row config)
- GET/PUT/test routes under /ldap (admin-gated; bind password masked)
- shared ldapClient with RFC 4515 filter escaping and empty-password guard
- signin tries local auth first, then LDAP when enabled (find-or-create
  local user) so the bootstrap admin is never locked out

Frontend:
- LDAP settings page (Switch + form + test connection) under /admin/ldap
- AdminNav tabs between user management and LDAP
- ldapControl util, types, and Ldap i18n namespace for all 6 locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:20:08 +08:00
27 changed files with 1077 additions and 20 deletions

84
CLAUDE.md Normal file
View 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.

View 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');
}

View 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;

View File

@@ -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",

View File

@@ -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",

View 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;
}

View 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 };

View 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 };

View 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)');
});
});

View 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;
}

View File

@@ -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;
router.post('/signin', async (req, res) => { function issueToken(res, user) {
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' });
}
const accessToken = jwt.sign({ userId: user.id }, secretKey, { const accessToken = jwt.sign({ userId: user.id }, secretKey, {
expiresIn: '24h', expiresIn: '24h',
}); });
const expiresAt = Date.now() + 3600 * 1000 * 24; // expire date(ms) const expiresAt = Date.now() + 3600 * 1000 * 24; // expire date(ms)
res.status(200).json({ access_token: accessToken, expires_at: expiresAt, user }); 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) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send('Sign up failed'); res.status(500).send('Sign in failed');
} }
}); });

View File

@@ -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';

View File

@@ -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:

View File

@@ -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",
@@ -434,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"
} }
} }

View File

@@ -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",
@@ -434,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"
} }
} }

View File

@@ -149,6 +149,7 @@
}, },
"Admin": { "Admin": {
"user_management": "ユーザー管理", "user_management": "ユーザー管理",
"ldap": "LDAP",
"avatar": "アバター", "avatar": "アバター",
"id": "ID", "id": "ID",
"email": "メールアドレス", "email": "メールアドレス",
@@ -434,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": "接続に失敗しました"
} }
} }

View File

@@ -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",
@@ -434,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"
} }
} }

View File

@@ -149,6 +149,7 @@
}, },
"Admin": { "Admin": {
"user_management": "用户管理", "user_management": "用户管理",
"ldap": "LDAP",
"avatar": "头像", "avatar": "头像",
"id": "ID", "id": "ID",
"email": "邮箱", "email": "邮箱",
@@ -434,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": "连接失败"
} }
} }

View File

@@ -149,6 +149,7 @@
}, },
"Admin": { "Admin": {
"user_management": "使用者管理", "user_management": "使用者管理",
"ldap": "LDAP",
"avatar": "頭像", "avatar": "頭像",
"id": "ID", "id": "ID",
"email": "電子郵件", "email": "電子郵件",
@@ -434,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": "連線失敗"
} }
} }

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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'),

36
frontend/types/ldap.ts Normal file
View 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;
};

View File

@@ -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;

View 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 };