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>
This commit is contained in:
LittleYellow
2026-06-26 07:20:08 +08:00
parent 02fa631f02
commit 1c977f9266
25 changed files with 992 additions and 19 deletions

View File

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

View File

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

View File

@@ -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": "接続に失敗しました"
}
}

View File

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

View File

@@ -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": "连接失败"
}
}

View File

@@ -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": "連線失敗"
}
}

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 UsersTable from './UsersTable';
import PasswordResetDialog from './PasswordResetDialog';
import AdminNav from './AdminNav';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { UserType, AdminMessages } from '@/types/user';
import { TokenContext } from '@/utils/TokenProvider';
@@ -149,6 +150,7 @@ export default function AdminPage({ messages, locale }: Props) {
return (
<>
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
<AdminNav locale={locale} userManagementLabel={messages.userManagement} ldapLabel={messages.ldap} />
<div className="w-full p-3 flex items-center justify-between">
<h3 className="font-bold">{messages.userManagement}</h3>
</div>

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 messages: AdminMessages = {
userManagement: t('user_management'),
ldap: t('ldap'),
avatar: t('avatar'),
id: t('id'),
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 = {
userManagement: string;
ldap: string;
avatar: string;
id: 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 };