diff --git a/backend/migrations/20260625000000-create-ldap-settings.js b/backend/migrations/20260625000000-create-ldap-settings.js
new file mode 100644
index 0000000..64b5f02
--- /dev/null
+++ b/backend/migrations/20260625000000-create-ldap-settings.js
@@ -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');
+}
diff --git a/backend/models/ldapSettings.js b/backend/models/ldapSettings.js
new file mode 100644
index 0000000..238b093
--- /dev/null
+++ b/backend/models/ldapSettings.js
@@ -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;
diff --git a/backend/package-lock.json b/backend/package-lock.json
index e5ae6ac..e8cae09 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -14,6 +14,7 @@
"express": "^4.21.0",
"express-rate-limit": "^7.4.1",
"jsonwebtoken": "^9.0.2",
+ "ldapts": "^8.1.8",
"multer": "^1.4.5-lts.1",
"papaparse": "^5.5.2",
"sequelize": "^6.37.7",
@@ -1010,6 +1011,29 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -1099,7 +1123,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -1840,6 +1863,18 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/ldapts": {
+ "version": "8.1.8",
+ "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
+ "integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "strict-event-emitter-types": "2.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -3068,6 +3103,12 @@
"node": ">=10.0.0"
}
},
+ "node_modules/strict-event-emitter-types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
+ "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
+ "license": "ISC"
+ },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -4266,6 +4307,26 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
},
+ "encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "optional": true,
+ "requires": {
+ "iconv-lite": "^0.6.2"
+ },
+ "dependencies": {
+ "iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "optional": true,
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ }
+ }
+ }
+ },
"end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -4328,7 +4389,6 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
- "peer": true,
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -4863,6 +4923,14 @@
"safe-buffer": "^5.0.1"
}
},
+ "ldapts": {
+ "version": "8.1.8",
+ "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
+ "integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
+ "requires": {
+ "strict-event-emitter-types": "2.0.0"
+ }
+ },
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -5707,6 +5775,11 @@
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
},
+ "strict-event-emitter-types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
+ "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="
+ },
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index 07f13f7..d6f1aab 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -18,6 +18,7 @@
"express": "^4.21.0",
"express-rate-limit": "^7.4.1",
"jsonwebtoken": "^9.0.2",
+ "ldapts": "^8.1.8",
"multer": "^1.4.5-lts.1",
"papaparse": "^5.5.2",
"sequelize": "^6.37.7",
diff --git a/backend/routes/ldap/edit.js b/backend/routes/ldap/edit.js
new file mode 100644
index 0000000..689b658
--- /dev/null
+++ b/backend/routes/ldap/edit.js
@@ -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;
+}
diff --git a/backend/routes/ldap/index.js b/backend/routes/ldap/index.js
new file mode 100644
index 0000000..f8e880e
--- /dev/null
+++ b/backend/routes/ldap/index.js
@@ -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 };
diff --git a/backend/routes/ldap/ldapClient.js b/backend/routes/ldap/ldapClient.js
new file mode 100644
index 0000000..7fd0ba6
--- /dev/null
+++ b/backend/routes/ldap/ldapClient.js
@@ -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 };
diff --git a/backend/routes/ldap/ldapClient.test.js b/backend/routes/ldap/ldapClient.test.js
new file mode 100644
index 0000000..67aa926
--- /dev/null
+++ b/backend/routes/ldap/ldapClient.test.js
@@ -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)');
+ });
+});
diff --git a/backend/routes/ldap/test.js b/backend/routes/ldap/test.js
new file mode 100644
index 0000000..66cb474
--- /dev/null
+++ b/backend/routes/ldap/test.js
@@ -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;
+}
diff --git a/backend/routes/users/signin.js b/backend/routes/users/signin.js
index ae5f3f3..7fc098f 100644
--- a/backend/routes/users/signin.js
+++ b/backend/routes/users/signin.js
@@ -4,37 +4,65 @@ import { DataTypes } from 'sequelize';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import defineUser from '../../models/users.js';
-import { defaultDangerKey } from './authSettings.js';
+import defineLdapSetting from '../../models/ldapSettings.js';
+import { authenticateLdap, getLdapSettings } from '../ldap/ldapClient.js';
+import { roles, defaultDangerKey } from './authSettings.js';
export default function (sequelize) {
const User = defineUser(sequelize, DataTypes);
+ const LdapSetting = defineLdapSetting(sequelize, DataTypes);
const secretKey = process.env.SECRET_KEY || defaultDangerKey;
+ function issueToken(res, user) {
+ const accessToken = jwt.sign({ userId: user.id }, secretKey, {
+ expiresIn: '24h',
+ });
+ const expiresAt = Date.now() + 3600 * 1000 * 24; // expire date(ms)
+ res.status(200).json({ access_token: accessToken, expires_at: expiresAt, user });
+ }
+
router.post('/signin', async (req, res) => {
try {
const { email, password } = req.body;
- const user = await User.findOne({
- where: {
- email: email,
- },
- });
- if (!user) {
- return res.status(401).json({ error: 'Authentication failed' });
+
+ // 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);
+ }
}
- const passwordMatch = await bcrypt.compare(password, user.password);
- if (!passwordMatch) {
- return res.status(401).json({ error: 'Authentication failed' });
+ // 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);
+ }
}
- 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) {
console.error(error);
- res.status(500).send('Sign up failed');
+ res.status(500).send('Sign in failed');
}
});
diff --git a/backend/server.js b/backend/server.js
index 4f0a894..8492ea0 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -70,6 +70,14 @@ app.use('/users', usersUpdateRoleRoute(sequelize));
app.use('/users', signUpRoute(sequelize));
app.use('/users', signInRoute(sequelize));
+// "/ldap"
+import ldapIndexRoute from './routes/ldap/index.js';
+import ldapEditRoute from './routes/ldap/edit.js';
+import ldapTestRoute from './routes/ldap/test.js';
+app.use('/ldap', ldapIndexRoute(sequelize));
+app.use('/ldap', ldapEditRoute(sequelize));
+app.use('/ldap', ldapTestRoute(sequelize));
+
// "/projects"
import projectsIndexRoute from './routes/projects/index.js';
import projectsShowRoute from './routes/projects/show.js';
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 97680f4..876621f 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -149,6 +149,7 @@
},
"Admin": {
"user_management": "Benutzerverwaltung",
+ "ldap": "LDAP",
"avatar": "Avatar",
"id": "ID",
"email": "E-Mail",
@@ -434,5 +435,27 @@
"tag_error_create": "Tag konnte nicht erstellt werden. Bitte versuche es erneut.",
"tag_error_update": "Tag konnte nicht aktualisiert werden. Bitte versuche es erneut.",
"tag_error_delete": "Tag konnte nicht gelöscht werden. Bitte versuche es erneut."
+ },
+ "Ldap": {
+ "ldap_settings": "LDAP-Einstellungen",
+ "ldap_description": "Benutzer über ein LDAP-Verzeichnis authentifizieren.",
+ "enable_ldap": "LDAP aktivieren",
+ "enable_ldap_desc": "Benutzern erlauben, sich mit ihren Verzeichnis-Anmeldedaten anzumelden.",
+ "server_url": "Server-URL",
+ "bind_dn": "Bind-DN",
+ "bind_credentials": "Bind-Passwort",
+ "bind_credentials_placeholder": "Leer lassen, um das aktuelle Passwort beizubehalten",
+ "search_base": "Suchbasis",
+ "search_filter": "Suchfilter",
+ "email_attribute": "E-Mail-Attribut",
+ "username_attribute": "Benutzername-Attribut",
+ "save": "Speichern",
+ "saved": "LDAP-Einstellungen gespeichert",
+ "save_error": "LDAP-Einstellungen konnten nicht gespeichert werden",
+ "test_connection": "Verbindung testen",
+ "test_username": "Test-Benutzername",
+ "test_password": "Test-Passwort",
+ "test_success": "Verbindung erfolgreich",
+ "test_failed": "Verbindung fehlgeschlagen"
}
}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index c2cebf4..6dd0e79 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -149,6 +149,7 @@
},
"Admin": {
"user_management": "User Management",
+ "ldap": "LDAP",
"avatar": "Avatar",
"id": "ID",
"email": "Email",
@@ -434,5 +435,27 @@
"tag_error_create": "Failed to create tag. Please try again.",
"tag_error_update": "Failed to update tag. Please try again.",
"tag_error_delete": "Failed to delete tag. Please try again."
+ },
+ "Ldap": {
+ "ldap_settings": "LDAP Settings",
+ "ldap_description": "Authenticate users against an LDAP directory.",
+ "enable_ldap": "Enable LDAP",
+ "enable_ldap_desc": "Allow users to sign in with their directory credentials.",
+ "server_url": "Server URL",
+ "bind_dn": "Bind DN",
+ "bind_credentials": "Bind Password",
+ "bind_credentials_placeholder": "Leave blank to keep current password",
+ "search_base": "Search Base",
+ "search_filter": "Search Filter",
+ "email_attribute": "Email Attribute",
+ "username_attribute": "Username Attribute",
+ "save": "Save",
+ "saved": "LDAP settings saved",
+ "save_error": "Failed to save LDAP settings",
+ "test_connection": "Test Connection",
+ "test_username": "Test Username",
+ "test_password": "Test Password",
+ "test_success": "Connection successful",
+ "test_failed": "Connection failed"
}
}
diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json
index 6ea13a8..c772c02 100644
--- a/frontend/messages/ja.json
+++ b/frontend/messages/ja.json
@@ -149,6 +149,7 @@
},
"Admin": {
"user_management": "ユーザー管理",
+ "ldap": "LDAP",
"avatar": "アバター",
"id": "ID",
"email": "メールアドレス",
@@ -434,5 +435,27 @@
"tag_error_create": "タグの作成に失敗しました。もう一度お試しください。",
"tag_error_update": "タグの更新に失敗しました。もう一度お試しください。",
"tag_error_delete": "タグの削除に失敗しました。もう一度お試しください。"
+ },
+ "Ldap": {
+ "ldap_settings": "LDAP 設定",
+ "ldap_description": "LDAP ディレクトリでユーザーを認証します。",
+ "enable_ldap": "LDAP を有効化",
+ "enable_ldap_desc": "ユーザーがディレクトリの資格情報でサインインできるようにします。",
+ "server_url": "サーバー URL",
+ "bind_dn": "バインド DN",
+ "bind_credentials": "バインドパスワード",
+ "bind_credentials_placeholder": "現在のパスワードを保持する場合は空欄",
+ "search_base": "検索ベース",
+ "search_filter": "検索フィルター",
+ "email_attribute": "メール属性",
+ "username_attribute": "ユーザー名属性",
+ "save": "保存",
+ "saved": "LDAP 設定を保存しました",
+ "save_error": "LDAP 設定の保存に失敗しました",
+ "test_connection": "接続テスト",
+ "test_username": "テストユーザー名",
+ "test_password": "テストパスワード",
+ "test_success": "接続に成功しました",
+ "test_failed": "接続に失敗しました"
}
}
diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json
index ce0ca77..33b46b8 100644
--- a/frontend/messages/pt-BR.json
+++ b/frontend/messages/pt-BR.json
@@ -149,6 +149,7 @@
},
"Admin": {
"user_management": "Gerenciamento de Usuários",
+ "ldap": "LDAP",
"avatar": "Avatar",
"id": "ID",
"email": "E-mail",
@@ -434,5 +435,27 @@
"tag_error_create": "Falha ao criar tag. Por favor, tente novamente.",
"tag_error_update": "Falha ao atualizar tag. Por favor, tente novamente.",
"tag_error_delete": "Falha ao excluir tag. Por favor, tente novamente."
+ },
+ "Ldap": {
+ "ldap_settings": "Configurações de LDAP",
+ "ldap_description": "Autenticar usuários em um diretório LDAP.",
+ "enable_ldap": "Ativar LDAP",
+ "enable_ldap_desc": "Permitir que os usuários entrem com suas credenciais do diretório.",
+ "server_url": "URL do servidor",
+ "bind_dn": "Bind DN",
+ "bind_credentials": "Senha de Bind",
+ "bind_credentials_placeholder": "Deixe em branco para manter a senha atual",
+ "search_base": "Base de busca",
+ "search_filter": "Filtro de busca",
+ "email_attribute": "Atributo de e-mail",
+ "username_attribute": "Atributo de nome de usuário",
+ "save": "Salvar",
+ "saved": "Configurações de LDAP salvas",
+ "save_error": "Falha ao salvar as configurações de LDAP",
+ "test_connection": "Testar conexão",
+ "test_username": "Usuário de teste",
+ "test_password": "Senha de teste",
+ "test_success": "Conexão bem-sucedida",
+ "test_failed": "Falha na conexão"
}
}
diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json
index 911a55b..61656bc 100644
--- a/frontend/messages/zh-CN.json
+++ b/frontend/messages/zh-CN.json
@@ -149,6 +149,7 @@
},
"Admin": {
"user_management": "用户管理",
+ "ldap": "LDAP",
"avatar": "头像",
"id": "ID",
"email": "邮箱",
@@ -434,5 +435,27 @@
"tag_error_create": "标签创建失败,请重试。",
"tag_error_update": "标签更新失败,请重试。",
"tag_error_delete": "标签删除失败,请重试。"
+ },
+ "Ldap": {
+ "ldap_settings": "LDAP 设置",
+ "ldap_description": "通过 LDAP 目录验证用户身份。",
+ "enable_ldap": "启用 LDAP",
+ "enable_ldap_desc": "允许用户使用目录凭据登录。",
+ "server_url": "服务器地址",
+ "bind_dn": "绑定 DN",
+ "bind_credentials": "绑定密码",
+ "bind_credentials_placeholder": "留空以保留当前密码",
+ "search_base": "搜索基准",
+ "search_filter": "搜索过滤器",
+ "email_attribute": "邮箱属性",
+ "username_attribute": "用户名属性",
+ "save": "保存",
+ "saved": "LDAP 设置已保存",
+ "save_error": "保存 LDAP 设置失败",
+ "test_connection": "测试连接",
+ "test_username": "测试用户名",
+ "test_password": "测试密码",
+ "test_success": "连接成功",
+ "test_failed": "连接失败"
}
}
diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json
index a997497..7ce6472 100644
--- a/frontend/messages/zh-TW.json
+++ b/frontend/messages/zh-TW.json
@@ -149,6 +149,7 @@
},
"Admin": {
"user_management": "使用者管理",
+ "ldap": "LDAP",
"avatar": "頭像",
"id": "ID",
"email": "電子郵件",
@@ -434,5 +435,27 @@
"tag_error_create": "標籤建立失敗,請重試。",
"tag_error_update": "標籤更新失敗,請重試。",
"tag_error_delete": "標籤刪除失敗,請重試。"
+ },
+ "Ldap": {
+ "ldap_settings": "LDAP 設定",
+ "ldap_description": "透過 LDAP 目錄驗證使用者身分。",
+ "enable_ldap": "啟用 LDAP",
+ "enable_ldap_desc": "允許使用者使用目錄憑證登入。",
+ "server_url": "伺服器位址",
+ "bind_dn": "繫結 DN",
+ "bind_credentials": "繫結密碼",
+ "bind_credentials_placeholder": "留空以保留目前密碼",
+ "search_base": "搜尋基準",
+ "search_filter": "搜尋過濾器",
+ "email_attribute": "電子郵件屬性",
+ "username_attribute": "使用者名稱屬性",
+ "save": "儲存",
+ "saved": "已儲存 LDAP 設定",
+ "save_error": "儲存 LDAP 設定失敗",
+ "test_connection": "測試連線",
+ "test_username": "測試使用者名稱",
+ "test_password": "測試密碼",
+ "test_success": "連線成功",
+ "test_failed": "連線失敗"
}
}
diff --git a/frontend/src/app/[locale]/admin/AdminNav.tsx b/frontend/src/app/[locale]/admin/AdminNav.tsx
new file mode 100644
index 0000000..ed0a04c
--- /dev/null
+++ b/frontend/src/app/[locale]/admin/AdminNav.tsx
@@ -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 (
+
{messages.ldapDescription}
+