Add account management and LDAP login
Admins can now create local agent/admin accounts and configure LDAP directly from the UI (Настройки → Аккаунты / LDAP), with login trying LDAP first and falling back to the local password. This is the first place users.role is actually enforced (requireAdminSession). Fixed a bug in the generated 0005 migration: the INSERT into __new_users selected auth_source from the old users table, which doesn't have that column yet — caused drizzle-kit migrate to fail silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
8121d8aa51
commit
cb12711e91
23 files changed
+2379
-13
No files matched your search
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
useTls: boolean;
|
||||
bindDn: string | null;
|
||||
baseDn: string | null;
|
||||
userFilter: string | null;
|
||||
listFilter: string | null;
|
||||
defaultRole: "admin" | "agent";
|
||||
verifiedAt: number | null;
|
||||
}
|
||||
|
||||
export function LdapSettingsForm({ initialStatus }: { initialStatus: Status }) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [host, setHost] = useState(initialStatus.host ?? "");
|
||||
const [port, setPort] = useState(String(initialStatus.port ?? 389));
|
||||
const [useTls, setUseTls] = useState(initialStatus.useTls);
|
||||
const [bindDn, setBindDn] = useState(initialStatus.bindDn ?? "");
|
||||
const [bindPassword, setBindPassword] = useState("");
|
||||
const [baseDn, setBaseDn] = useState(initialStatus.baseDn ?? "");
|
||||
const [userFilter, setUserFilter] = useState(initialStatus.userFilter ?? "(mail={{email}})");
|
||||
const [listFilter, setListFilter] = useState(initialStatus.listFilter ?? "(objectClass=person)");
|
||||
const [defaultRole, setDefaultRole] = useState<"admin" | "agent">(initialStatus.defaultRole);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/ldap/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
port: Number(port),
|
||||
useTls,
|
||||
bindDn,
|
||||
bindPassword,
|
||||
baseDn,
|
||||
userFilter,
|
||||
listFilter,
|
||||
defaultRole,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось подключить LDAP");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
host,
|
||||
port: Number(port),
|
||||
useTls,
|
||||
bindDn,
|
||||
baseDn,
|
||||
userFilter,
|
||||
listFilter,
|
||||
defaultRole,
|
||||
verifiedAt: Date.now(),
|
||||
});
|
||||
setBindPassword("");
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setLoading(true);
|
||||
await fetch("/api/ldap/config", { method: "DELETE" });
|
||||
setLoading(false);
|
||||
setStatus((s) => ({ ...s, enabled: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
{status.enabled ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-success" />
|
||||
<span>Подключено: {status.host}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={16} className="text-text-faint" />
|
||||
<span className="text-text-muted">LDAP не подключён</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect}>
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Хост</span>
|
||||
<input
|
||||
required
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="ldap.example.local"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Порт</span>
|
||||
<input
|
||||
required
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-end gap-2 pb-2 text-sm text-text-muted">
|
||||
<input type="checkbox" checked={useTls} onChange={(e) => setUseTls(e.target.checked)} />
|
||||
Использовать TLS (ldaps://)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Bind DN (служебная учётка)</span>
|
||||
<input
|
||||
required
|
||||
value={bindDn}
|
||||
onChange={(e) => setBindDn(e.target.value)}
|
||||
placeholder="cn=svc-helpdesk,dc=example,dc=local"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Bind пароль</span>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={bindPassword}
|
||||
onChange={(e) => setBindPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Base DN</span>
|
||||
<input
|
||||
required
|
||||
value={baseDn}
|
||||
onChange={(e) => setBaseDn(e.target.value)}
|
||||
placeholder="dc=example,dc=local"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Фильтр поиска пользователя при входе</span>
|
||||
<input
|
||||
required
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-text-faint">{"{{email}}"} заменяется на введённый при входе email</span>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Фильтр для импорта каталога</span>
|
||||
<input
|
||||
required
|
||||
value={listFilter}
|
||||
onChange={(e) => setListFilter(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-4 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Роль по умолчанию для новых LDAP-аккаунтов</span>
|
||||
<select
|
||||
value={defaultRole}
|
||||
onChange={(e) => setDefaultRole(e.target.value as "admin" | "agent")}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="agent">Агент</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{status.configured ? "Обновить" : "Подключить"}
|
||||
</button>
|
||||
{status.enabled && (
|
||||
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||
Отключить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user