feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { providers } from "./tables.js";
import { createProviderStore } from "./providers.js";
import { createLdapLogin, METHOD_ID } from "./ldap.js";
import { registerLdapRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(providers);
const store = createProviderStore(ctx, table);
// One form per enabled directory.
ctx.auth.registerLoginMethod({
id: METHOD_ID,
labelKey: "loginWithLdap",
icon: "server",
kind: "form",
external: true,
describe: async () =>
(await store.listRows()).map((row) => ({
id: String(row.id),
label: row.name,
enabled: !!row.enabled,
type: "ldap",
})),
verify: createLdapLogin(ctx, store),
});
registerLdapRoutes(ctx.http.router<Router>(), ctx, store);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+205
View File
@@ -0,0 +1,205 @@
/**
* LDAP sign-in as a form login method: a service bind to find the user, a
* bind as the user to check the password, then an optional admin group
* lookup. Core finds or provisions the user and issues the session.
*/
import ldap from "ldapjs";
import {
LoginMethodError,
type PluginContext,
type PluginLoginRequest,
type PluginVerifiedIdentity,
} from "@termix/plugin-sdk/backend";
import type { ProviderStore } from "./providers.js";
import type { LdapConfig } from "./types.js";
export const METHOD_ID = "ldap";
export function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
/** The provider id identities from this directory are stored under. */
export function identityProvider(providerId: number): string {
return `ldap:${providerId}`;
}
function createClient(config: LdapConfig): ldap.Client {
const useTLS = !!config.useTLS;
const url = `${useTLS ? "ldaps" : "ldap"}://${config.host}:${config.port || 389}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function bind(client: ldap.Client, dn: string, password: string) {
return new Promise<void>((resolve, reject) => {
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
});
}
function search(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function unbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
// best effort
}
}
function firstValue(entry: ldap.SearchEntry, key: string): string {
const attr = entry.attributes.find((a) => a.type === key);
if (!attr) return "";
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
}
export function createLdapLogin(ctx: PluginContext, store: ProviderStore) {
return async function verify(
request: PluginLoginRequest,
instanceId: string | null,
): Promise<PluginVerifiedIdentity> {
const body = request.body ?? {};
const providerId = Number(instanceId ?? body.providerId);
const username = typeof body.username === "string" ? body.username : "";
const password = typeof body.password === "string" ? body.password : "";
if (!Number.isInteger(providerId) || !username || !password) {
throw new LoginMethodError(
"providerId, username, and password are required",
400,
);
}
// Same limits as password login: an LDAP bind is a password guess.
const clientIp = request.ip || "unknown";
const rateLimitKey = `${providerId}:${username}`;
const lock = await ctx.auth.loginRateLimit.isLocked(clientIp, rateLimitKey);
if (lock.locked) {
const error = new LoginMethodError(
"Too many login attempts. Please try again later.",
429,
);
Object.assign(error, { remainingTime: lock.remainingTime });
throw error;
}
const provider = await store.find(providerId);
if (!provider || !provider.enabled) {
throw new LoginMethodError("LDAP provider not found", 404);
}
const config = provider.config;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
throw new LoginMethodError("LDAP provider is misconfigured", 500);
}
const refuse = async (reason: string) => {
await ctx.auth.loginRateLimit.recordFailure(clientIp, rateLimitKey);
ctx.log.warn(`LDAP login refused for ${username}: ${reason}`);
return new LoginMethodError("Invalid username or password", 401);
};
const serviceClient = createClient(config);
try {
await bind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const entries = await search(
serviceClient,
config.userSearchBase,
filter,
[
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
],
);
if (entries.length === 0) throw await refuse("user not found");
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const uid =
firstValue(userEntry, config.usernameAttribute || "uid") || username;
const displayName =
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
const email =
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
const userClient = createClient(config);
try {
await bind(userClient, userDN, password);
} catch {
throw await refuse("wrong password");
} finally {
unbind(userClient);
}
let isAdmin: boolean | undefined;
if (config.adminGroup && config.groupSearchBase) {
isAdmin = false;
try {
const groups = await search(
serviceClient,
config.groupSearchBase,
`(member=${ldapEscapeFilter(userDN)})`,
["cn", "dn"],
);
isAdmin = groups.some(
(group) =>
firstValue(group, "cn") === config.adminGroup ||
group.dn.toString() === config.adminGroup,
);
} catch (error) {
ctx.log.warn(`LDAP group check failed: ${String(error)}`);
}
}
return {
kind: "external",
provider: identityProvider(providerId),
subject: uid,
email: email || null,
name: displayName,
isAdmin,
allowedUsers: config.allowedUsers ?? null,
legacy: {
identifier: `ldap:${providerId}:${uid}`,
providerRowId: providerId,
},
rememberMe: !!body.rememberMe,
rateLimitKey,
};
} finally {
unbind(serviceClient);
}
};
}
+141
View File
@@ -0,0 +1,141 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { LdapConfig, ProviderRow } from "./types.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
const LEGACY_PREFIXES = ["encoded:", "encrypted:"];
export type ProviderStore = ReturnType<typeof createProviderStore>;
export function createProviderStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
/** Opens the bind password: sealed, or the 2.8 base64 forms. */
async function openSecret(value: string): Promise<string> {
const prefix = LEGACY_PREFIXES.find((p) => value.startsWith(p));
if (prefix) {
return Buffer.from(value.slice(prefix.length), "base64").toString("utf8");
}
return (await ctx.secrets.unseal(value)) ?? value;
}
async function parseConfig(raw: string): Promise<LdapConfig> {
let config: Record<string, unknown>;
try {
config = JSON.parse(raw);
} catch {
config = {};
}
if (typeof config.bindPassword === "string" && config.bindPassword) {
config.bindPassword = await openSecret(config.bindPassword);
}
return config as unknown as LdapConfig;
}
async function sealConfig(config: Record<string, unknown>): Promise<string> {
const out = { ...config };
if (typeof out.bindPassword === "string" && out.bindPassword) {
out.bindPassword = await ctx.secrets.seal(out.bindPassword);
}
return JSON.stringify(out);
}
async function findRow(id: number): Promise<ProviderRow | null> {
const drizzle = await client();
const rows = (await drizzle
.select()
.from(table)
.where(eq(table.id, id))
.limit(1)) as ProviderRow[];
return rows[0] ?? null;
}
return {
parseConfig,
findRow,
async listRows(): Promise<ProviderRow[]> {
const drizzle = await client();
return (await drizzle
.select()
.from(table)
.orderBy(asc(table.displayOrder), asc(table.id))) as ProviderRow[];
},
async find(id: number): Promise<{
row: ProviderRow;
enabled: boolean;
config: LdapConfig;
} | null> {
const row = await findRow(id);
if (!row) return null;
return {
row,
enabled: !!row.enabled,
config: await parseConfig(row.config),
};
},
async create(input: {
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
}): Promise<ProviderRow> {
const drizzle = await client();
const now = new Date().toISOString();
await drizzle.insert(table).values({
name: input.name,
enabled: input.enabled,
displayOrder: input.displayOrder,
config: await sealConfig(input.config),
createdAt: now,
updatedAt: now,
});
await ctx.db.persist();
// No returning() on every engine: find the row by what was written.
const rows = (await drizzle
.select()
.from(table)
.where(and(eq(table.name, input.name), eq(table.createdAt, now)))
.orderBy(desc(table.id))
.limit(1)) as ProviderRow[];
return rows[0];
},
async update(
id: number,
values: Partial<{
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
}>,
): Promise<ProviderRow | null> {
const drizzle = await client();
const { config, ...rest } = values;
await drizzle
.update(table)
.set({
...rest,
...(config ? { config: await sealConfig(config) } : {}),
updatedAt: new Date().toISOString(),
})
.where(eq(table.id, id));
await ctx.db.persist();
return findRow(id);
},
async remove(id: number): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.id, id));
await ctx.db.persist();
},
};
}
+265
View File
@@ -0,0 +1,265 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { identityProvider } from "./ldap.js";
import type { ProviderStore } from "./providers.js";
import { REQUIRED_FIELDS, type ProviderRow } from "./types.js";
function fail(
ctx: PluginContext,
res: Response,
message: string,
error: unknown,
) {
ctx.log.error(
message,
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: message });
}
function missingFields(config: Record<string, unknown>): string[] {
return REQUIRED_FIELDS.filter((field) => !config[field]);
}
export function registerLdapRoutes(
router: Router,
ctx: PluginContext,
store: ProviderStore,
): void {
router.use(ctx.rbac.require("manage") as never);
async function present(row: ProviderRow) {
const { bindPassword, ...config } = (await store.parseConfig(
row.config,
)) as unknown as Record<string, unknown>;
return {
id: row.id,
name: row.name,
enabled: !!row.enabled,
displayOrder: row.displayOrder,
config,
hasBindPassword: typeof bindPassword === "string" && !!bindPassword,
};
}
/**
* @openapi
* /plugin-api/ldap/providers:
* get:
* summary: List LDAP directories
* description: Every configured LDAP directory. The bind password is never sent, only whether one is set. Needs ldap.manage.
* tags:
* - LDAP
* responses:
* 200:
* description: Directories.
* 403:
* description: Missing permission.
*/
router.get("/providers", async (_req: Request, res: Response) => {
try {
const rows = await store.listRows();
res.json({ providers: await Promise.all(rows.map(present)) });
} catch (error) {
fail(ctx, res, "Failed to list LDAP providers", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers:
* post:
* summary: Add an LDAP directory
* description: Needs ldap.manage.
* tags:
* - LDAP
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* enabled:
* type: boolean
* config:
* type: object
* responses:
* 201:
* description: Directory added.
* 400:
* description: Missing fields.
* 403:
* description: Missing permission.
*/
router.post("/providers", async (req: Request, res: Response) => {
try {
const {
name,
enabled = true,
displayOrder = 0,
config = {},
} = req.body ?? {};
if (typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "Provider name is required" });
return;
}
const missing = missingFields(config);
if (missing.length > 0) {
res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
return;
}
const row = await store.create({
name: name.trim(),
enabled: !!enabled,
displayOrder: Number(displayOrder) || 0,
config: { ...(config as Record<string, unknown>) },
});
await ctx.audit.record({
action: "ldap_provider_create",
resourceType: "ldap_provider",
resourceId: String(row.id),
resourceName: row.name,
success: true,
});
res.status(201).json(await present(row));
} catch (error) {
fail(ctx, res, "Failed to create LDAP provider", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers/{id}:
* put:
* summary: Update an LDAP directory
* description: Config fields are merged over the stored ones; an empty bind password keeps the stored one. Needs ldap.manage.
* tags:
* - LDAP
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Directory updated.
* 400:
* description: Missing fields.
* 403:
* description: Missing permission.
* 404:
* description: Directory not found.
*/
router.put("/providers/:id", async (req: Request, res: Response) => {
try {
const id = Number.parseInt(String(req.params.id), 10);
const row = Number.isInteger(id) ? await store.findRow(id) : null;
if (!row) {
res.status(404).json({ error: "Provider not found" });
return;
}
const { name, enabled, displayOrder, config } = req.body ?? {};
const values: Parameters<ProviderStore["update"]>[1] = {};
if (name !== undefined) {
if (typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "Provider name is required" });
return;
}
values.name = name.trim();
}
if (enabled !== undefined) values.enabled = !!enabled;
if (displayOrder !== undefined) {
values.displayOrder = Number(displayOrder) || 0;
}
if (config && typeof config === "object") {
const incoming = { ...(config as Record<string, unknown>) };
if (!incoming.bindPassword) delete incoming.bindPassword;
const merged = {
...((await store.parseConfig(row.config)) as unknown as Record<
string,
unknown
>),
...incoming,
};
const missing = missingFields(merged);
if (missing.length > 0) {
res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
return;
}
values.config = merged;
}
const updated = await store.update(id, values);
await ctx.audit.record({
action: "ldap_provider_update",
resourceType: "ldap_provider",
resourceId: String(id),
resourceName: updated?.name ?? row.name,
success: true,
});
res.json(await present(updated ?? row));
} catch (error) {
fail(ctx, res, "Failed to update LDAP provider", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers/{id}:
* delete:
* summary: Delete an LDAP directory
* description: Refused while users still sign in with it. Needs ldap.manage.
* tags:
* - LDAP
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Directory deleted.
* 403:
* description: Missing permission.
* 404:
* description: Directory not found.
* 409:
* description: Users are still linked to it.
*/
router.delete("/providers/:id", async (req: Request, res: Response) => {
try {
const id = Number.parseInt(String(req.params.id), 10);
const row = Number.isInteger(id) ? await store.findRow(id) : null;
if (!row) {
res.status(404).json({ error: "Provider not found" });
return;
}
if ((await ctx.auth.countLinkedUsers(identityProvider(id))) > 0) {
res.status(409).json({
error:
"Users still sign in with this directory. Disable it instead, or remove those users first.",
});
return;
}
await store.remove(id);
await ctx.audit.record({
action: "ldap_provider_delete",
resourceType: "ldap_provider",
resourceId: String(id),
resourceName: row.name,
success: true,
});
res.json({ message: "LDAP provider deleted" });
} catch (error) {
fail(ctx, res, "Failed to delete LDAP provider", error);
}
});
}
+24
View File
@@ -0,0 +1,24 @@
import {
boolean,
defineTable,
id,
integer,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* LDAP directories. Rows from 2.8 are copied in from sso_providers by core's
* boot migration, keeping their ids, because identities point at them.
*/
export const providers = defineTable("providers", {
id: id(),
name: text().notNull(),
enabled: boolean().notNull().default(true),
displayOrder: integer().notNull().default(0),
config: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
});
export const tables = [providers];
+34
View File
@@ -0,0 +1,34 @@
export interface LdapConfig {
host: string;
port: number;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase?: string;
adminGroup?: string;
allowedUsers?: string;
}
export interface ProviderRow {
id: number;
name: string;
enabled: boolean;
displayOrder: number;
config: string;
createdAt: string;
updatedAt: string;
}
export const REQUIRED_FIELDS = [
"host",
"port",
"bindDN",
"bindPassword",
"userSearchBase",
"userSearchFilter",
"usernameAttribute",
] as const;
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "sonner";
import { Button, Input } from "@termix/plugin-sdk/ui";
import {
useTranslation,
type LoginMethodUIProps,
} from "@termix/plugin-sdk/frontend";
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
/** A collapsible username and password form per enabled directory. */
export function LdapLoginForms({
instances,
disabled,
submit,
}: LoginMethodUIProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
setUsername("");
setPassword("");
}, [expandedId]);
return (
<>
{instances.map((instance) => {
const expanded = expandedId === instance.id;
return (
<div key={instance.id} className="flex flex-col border border-border">
<button
type="button"
onClick={() => setExpandedId(expanded ? null : instance.id)}
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<span>{t("loginWithProvider", { name: instance.label })}</span>
{expanded ? (
<ChevronUp className="size-3.5" />
) : (
<ChevronDown className="size-3.5" />
)}
</button>
{expanded && (
<form
onSubmit={async (event) => {
event.preventDefault();
if (!username.trim() || !password) {
toast.error(t("requiredField"));
return;
}
setLoading(true);
try {
await submit(
{ username: username.trim(), password },
instance.id,
);
} finally {
setLoading(false);
}
}}
className="flex flex-col gap-3 p-3 border-t border-border"
>
<label className="flex flex-col gap-1.5">
<span className={labelClass}>{t("username")}</span>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={disabled || loading}
autoFocus
/>
</label>
<label className="flex flex-col gap-1.5">
<span className={labelClass}>{t("password")}</span>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={disabled || loading}
className="font-mono"
/>
</label>
<Button
type="submit"
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
disabled={disabled || loading}
>
{loading ? t("loading") : t("signIn")}
</Button>
</form>
)}
</div>
);
})}
</>
);
}
@@ -0,0 +1,439 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Input,
Switch,
} from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { createLdapApi, type LdapProvider } from "./ldap-api";
type Fields = {
host: string;
port: string;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase: string;
adminGroup: string;
allowedUsers: string;
};
const EMPTY_FIELDS: Fields = {
host: "",
port: "389",
useTLS: false,
bindDN: "",
bindPassword: "",
userSearchBase: "",
userSearchFilter: "(uid={{username}})",
usernameAttribute: "uid",
displayNameAttribute: "cn",
groupSearchBase: "",
adminGroup: "",
allowedUsers: "",
};
function errorMessage(error: unknown, fallback: string): string {
const err = error as {
response?: { data?: { error?: string } };
message?: string;
};
return err.response?.data?.error || err.message || fallback;
}
const labelClass =
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className={labelClass}>
{label}
{required && <span className="text-accent-brand ml-1">*</span>}
</label>
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
{children}
</div>
);
}
function ProviderDialog({
open,
onOpenChange,
provider,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: LdapProvider | null;
onSaved: () => void;
}) {
const { t } = useTranslation();
const api = createLdapApi(usePluginApi());
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [fields, setFields] = useState<Fields>(EMPTY_FIELDS);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName(provider?.name ?? "");
setEnabled(provider?.enabled ?? true);
const config = provider?.config ?? {};
const next = { ...EMPTY_FIELDS };
for (const key of Object.keys(next) as Array<keyof Fields>) {
const value = config[key];
if (key === "useTLS") next.useTLS = value === true;
else if (key === "port" && value !== undefined) next.port = String(value);
else if (typeof value === "string") next[key] = value as never;
}
next.bindPassword = "";
setFields(next);
}, [open, provider]);
const set = (key: Exclude<keyof Fields, "useTLS">) => (value: string) =>
setFields((prev) => ({ ...prev, [key]: value }));
async function save() {
if (!name.trim()) {
toast.error(t("providers.nameRequired"));
return;
}
const config: Record<string, unknown> = {
host: fields.host.trim(),
port: Number.parseInt(fields.port, 10) || 389,
useTLS: fields.useTLS,
bindDN: fields.bindDN.trim(),
userSearchBase: fields.userSearchBase.trim(),
userSearchFilter: fields.userSearchFilter.trim(),
usernameAttribute: fields.usernameAttribute.trim() || "uid",
displayNameAttribute: fields.displayNameAttribute.trim() || "cn",
groupSearchBase: fields.groupSearchBase.trim() || undefined,
adminGroup: fields.adminGroup.trim() || undefined,
allowedUsers: fields.allowedUsers.trim() || undefined,
};
if (fields.bindPassword) config.bindPassword = fields.bindPassword;
setSaving(true);
try {
const input = { name: name.trim(), enabled, config };
if (provider) await api.update(provider.id, input);
else await api.create(input);
toast.success(t("providers.saved"));
onSaved();
onOpenChange(false);
} catch (error) {
toast.error(errorMessage(error, t("providers.saveFailed")));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{provider ? t("providers.edit") : t("providers.add")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Field label={t("providers.name")} required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("providers.namePlaceholder")}
className="text-xs"
/>
</Field>
<div className="flex items-center justify-between">
<label className={labelClass}>{t("providers.enabled")}</label>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-3 border-t border-border pt-3">
<span className="text-[10px] text-muted-foreground">
<a
href="https://docs.termix.site/features/authentication/ldap"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("providers.docsLink")}
</a>
</span>
<Field label={t("fields.host")} required>
<Input
value={fields.host}
onChange={(e) => set("host")(e.target.value)}
placeholder="ldap.example.com"
className="text-xs"
/>
</Field>
<div className="flex gap-2">
<div className="flex-1">
<Field label={t("fields.port")} required>
<Input
value={fields.port}
onChange={(e) => set("port")(e.target.value)}
placeholder="389"
className="text-xs"
/>
</Field>
</div>
<div className="flex items-end pb-1.5 gap-2">
<Switch
checked={fields.useTLS}
onCheckedChange={(useTLS) =>
setFields((prev) => ({ ...prev, useTLS }))
}
aria-label={t("fields.useTls")}
/>
<span className="text-[10px] text-muted-foreground">
{t("fields.useTls")}
</span>
</div>
</div>
<Field label={t("fields.bindDn")} required>
<Input
value={fields.bindDN}
onChange={(e) => set("bindDN")(e.target.value)}
placeholder="cn=admin,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field
label={t("fields.bindPassword")}
required={!provider?.hasBindPassword}
hint={
provider?.hasBindPassword ? t("fields.secretKeep") : undefined
}
>
<Input
type="password"
value={fields.bindPassword}
onChange={(e) => set("bindPassword")(e.target.value)}
className="text-xs"
/>
</Field>
<Field label={t("fields.userSearchBase")} required>
<Input
value={fields.userSearchBase}
onChange={(e) => set("userSearchBase")(e.target.value)}
placeholder="ou=users,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.userSearchFilter")} required>
<Input
value={fields.userSearchFilter}
onChange={(e) => set("userSearchFilter")(e.target.value)}
placeholder="(uid={{username}})"
className="text-xs"
/>
</Field>
<Field label={t("fields.usernameAttr")} required>
<Input
value={fields.usernameAttribute}
onChange={(e) => set("usernameAttribute")(e.target.value)}
placeholder="uid"
className="text-xs"
/>
</Field>
<Field label={t("fields.displayNameAttr")} required>
<Input
value={fields.displayNameAttribute}
onChange={(e) => set("displayNameAttribute")(e.target.value)}
placeholder="cn"
className="text-xs"
/>
</Field>
<div className="border-t border-border pt-3 flex flex-col gap-3">
<Field label={t("fields.groupSearchBase")}>
<Input
value={fields.groupSearchBase}
onChange={(e) => set("groupSearchBase")(e.target.value)}
placeholder="ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.adminGroup")}>
<Input
value={fields.adminGroup}
onChange={(e) => set("adminGroup")(e.target.value)}
placeholder="cn=admins,ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.allowedUsers")}>
<Input
value={fields.allowedUsers}
onChange={(e) => set("allowedUsers")(e.target.value)}
placeholder="user1,user2,@domain.com"
className="text-xs"
/>
</Field>
</div>
</div>
<div className="flex gap-2 justify-end pt-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onOpenChange(false)}
>
{t("providers.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={save}
disabled={saving}
>
{saving ? t("providers.saving") : t("providers.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
/**
* The "providers" custom field on the plugin's admin settings page: the
* directory list with an editor.
*/
export function ProvidersSetting() {
const { t } = useTranslation();
const api = createLdapApi(usePluginApi());
const [providers, setProviders] = useState<LdapProvider[] | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LdapProvider | null>(null);
const load = useCallback(async () => {
try {
setProviders(await api.list());
} catch (error) {
setProviders([]);
toast.error(errorMessage(error, t("providers.loadFailed")));
}
// api is rebuilt each render from the same client.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void load();
}, [load]);
async function toggleEnabled(provider: LdapProvider) {
try {
await api.update(provider.id, { enabled: !provider.enabled });
await load();
} catch (error) {
toast.error(errorMessage(error, t("providers.saveFailed")));
}
}
async function remove(provider: LdapProvider) {
if (!window.confirm(t("providers.deleteConfirm"))) return;
try {
await api.remove(provider.id);
toast.success(t("providers.deleted"));
await load();
} catch (error) {
toast.error(errorMessage(error, t("providers.deleteFailed")));
}
}
if (providers === null) return null;
return (
<div className="flex flex-col gap-3">
{providers.length === 0 ? (
<span className="text-[10px] text-muted-foreground">
{t("providers.none")}
</span>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center gap-2 p-2 border border-border bg-background"
>
<span className="flex-1 min-w-0 text-xs font-medium truncate">
{provider.name}
</span>
<Switch
checked={provider.enabled}
onCheckedChange={() => void toggleEnabled(provider)}
aria-label={t("providers.enabled")}
/>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditing(provider);
setDialogOpen(true);
}}
title={t("providers.edit")}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(provider)}
title={t("providers.delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus className="size-3" />
{t("providers.add")}
</Button>
<ProviderDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
provider={editing}
onSaved={() => void load()}
/>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { LdapLoginForms } from "./LdapLoginForms";
import { ProvidersSetting } from "./ProvidersSetting";
export function activate(app: TermixApp): void {
app.registerLoginMethod({
id: "ldap",
titleKey: "loginWithLdap",
component: LdapLoginForms,
});
app.registerSettingsComponent("providers", ProvidersSetting);
}
+35
View File
@@ -0,0 +1,35 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export interface LdapProvider {
id: number;
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
hasBindPassword: boolean;
}
export interface LdapProviderInput {
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
}
/** Paths are relative to /plugin-api/ldap/. */
export function createLdapApi(api: PluginApiClient) {
return {
async list() {
return (await api.get<{ providers: LdapProvider[] }>("/providers")).data
.providers;
},
async create(input: LdapProviderInput) {
return (await api.post<LdapProvider>("/providers", input)).data;
},
async update(id: number, input: LdapProviderInput) {
return (await api.put<LdapProvider>(`/providers/${id}`, input)).data;
},
async remove(id: number) {
await api.delete(`/providers/${id}`);
},
};
}