diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx
index 384304f..a67ba07 100644
--- a/src/components/layout/MainContent.tsx
+++ b/src/components/layout/MainContent.tsx
@@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
+import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard';
import { SendingSetupCard } from '@/features/sending/SendingSetupCard';
import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard';
import type { Schema } from '@/types/schema';
@@ -76,6 +77,11 @@ const SettingsOverviewPage = lazyFeature(
() => import('@/features/settings/SettingsOverviewPage'),
(m) => m.SettingsOverviewPage,
);
+// inbuxa: guided setup, connect a sign-in directory (settings-reorg spec).
+const DirectorySetupPage = lazyFeature(
+ () => import('@/features/directory/DirectorySetupPage'),
+ (m) => m.DirectorySetupPage,
+);
// inbuxa: Settings › Spam filter › Local AI (ai-spam-classification spec).
const LocalAiPage = lazyFeature(
() => import('@/features/ai/LocalAiPage'),
@@ -141,6 +147,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (wizard === 'dns' && param) {
return ;
}
+ // inbuxa: guided setup, connect a sign-in directory (settings-reorg).
+ if (wizard === 'directory') {
+ return ;
+ }
// inbuxa: guided setup, how this server sends mail (settings-reorg).
if (wizard === 'sending') {
return ;
@@ -216,6 +226,14 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (resolved.objectType.type === 'singleton') {
// INBUXA: the Security settings carry the legacy protocols banner (LP-18).
+ if (resolved.objectName === 'x:Authentication') {
+ return (
+
+
+
+
+ );
+ }
if (resolved.objectName === 'x:Security') {
return (
@@ -290,5 +308,13 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
);
}
+ if (viewName === 'x:Directory') {
+ return (
+
+
+
+
+ );
+ }
return ;
}
diff --git a/src/features/directory/DirectorySetupCard.tsx b/src/features/directory/DirectorySetupCard.tsx
new file mode 100644
index 0000000..2f93823
--- /dev/null
+++ b/src/features/directory/DirectorySetupCard.tsx
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { Fingerprint } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { LaunchChoice } from '@/components/wizard/LaunchChoice';
+import { useSchemaStore } from '@/stores/schemaStore';
+import { cn } from '@/lib/utils';
+
+export const DIRECTORY_WIZARD_VIEW = 'Wizard/directory';
+
+/**
+ * inbuxa: at the top of Directories and Authentication, an invitation to
+ * connect a directory with the guide (settings-reorg, first wave). Manual
+ * stays on the page.
+ */
+export function DirectorySetupCard({ className }: { className?: string }) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const section = useSchemaStore((s) => s.viewToSection['x:Directory']) ?? 'Settings';
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+
{t('dirCard.title', 'Sign in with accounts you already have')}
+
+ {t(
+ 'dirCard.hint',
+ 'A guide connects Active Directory, LDAP or an OpenID Connect provider, tests a real person, then moves the domains you pick.',
+ )}
+
+
+
setOpen(true)}>
+ {t('dirCard.start', 'Set it up')}
+
+
navigate(`/${section}/${DIRECTORY_WIZARD_VIEW}`)}
+ onManual={() => undefined}
+ />
+
+ );
+}
diff --git a/src/features/directory/DirectorySetupPage.tsx b/src/features/directory/DirectorySetupPage.tsx
new file mode 100644
index 0000000..e1ad024
--- /dev/null
+++ b/src/features/directory/DirectorySetupPage.tsx
@@ -0,0 +1,722 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: guided setup, "Connect a sign-in directory" (settings-reorg, first
+ * wave). Pick Active Directory, OpenLDAP, FreeIPA or an OpenID Connect
+ * provider, fill in the connection, save it (a directory nothing points at
+ * changes nothing), test a real person against it with the server's
+ * directory test, then choose which domains sign in through it. The server
+ * default is offered last, behind a warning, because it's how an admin
+ * locks themselves out.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, CheckCircle2, Loader2, XCircle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { LoadingFallback } from '@/components/common/LoadingFallback';
+import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell';
+import { apiFetch } from '@/services/api';
+import { getAccountId, jmapRequest, jmapSet } from '@/services/jmap/client';
+import { useAuthStore } from '@/stores/authStore';
+import { useSchemaStore } from '@/stores/schemaStore';
+import type { JmapSetResponse } from '@/types/jmap';
+import { cn } from '@/lib/utils';
+import { directoryObject, PRESETS, type Connection, type Preset } from './presets';
+
+interface DomainRow {
+ id: string;
+ name: string;
+ directoryId: string | null;
+ accounts: number | null;
+}
+
+interface TestResult {
+ kind?: string;
+ opened: boolean;
+ error?: string;
+ oidc?: { issuer: string };
+ lookup?: {
+ found?: 'account' | 'group' | 'none';
+ email?: string;
+ aliases?: string[];
+ groups?: string[];
+ description?: string | null;
+ error?: string;
+ };
+ signIn?: { ok: boolean; wrongPassword?: boolean; error?: string; email?: string };
+}
+
+function failure(body: JmapSetResponse | undefined, kind: 'notCreated' | 'notUpdated'): string | null {
+ const e = body?.[kind] ? Object.values(body[kind]!)[0] : undefined;
+ if (!e) return null;
+ const details = (e.validationErrors ?? []).map((v) => JSON.stringify(v)).join('; ');
+ return [e.description, details].filter(Boolean).join(' ') || e.type;
+}
+
+async function loadDomains(): Promise<{ domains: DomainRow[]; serverDefault: string | null }> {
+ const res = await jmapRequest([
+ ['x:Domain/get', { accountId: getAccountId('x:Domain'), ids: null, properties: ['name', 'directoryId'] }, 'd'],
+ [
+ 'x:Authentication/get',
+ { accountId: getAccountId('x:Authentication'), ids: ['singleton'], properties: ['directoryId'] },
+ 'a',
+ ],
+ ]);
+ const list = (id: string) =>
+ (res.find(([, , i]) => i === id)?.[1] as { list?: Record[] } | undefined)?.list ?? [];
+ const domains = list('d')
+ .map((d): DomainRow => ({
+ id: String(d.id),
+ name: String(d.name),
+ directoryId: (d.directoryId as string | null) ?? null,
+ accounts: null,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ // How many accounts each domain has: the people a new directory must know.
+ const counts = await jmapRequest(
+ domains.map((d, i) => [
+ 'x:Account/query',
+ { accountId: getAccountId('x:Account'), filter: { domainId: d.id }, calculateTotal: true, limit: 0 },
+ String(i),
+ ]),
+ ).catch(() => []);
+ for (const [, body, i] of counts) {
+ const total = (body as { total?: number }).total;
+ if (typeof total === 'number') domains[Number(i)].accounts = total;
+ }
+ return { domains, serverDefault: (list('a')[0]?.directoryId as string | null) ?? null };
+}
+
+export function DirectorySetupPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const viewToSection = useSchemaStore((s) => s.viewToSection);
+ const section = viewToSection['x:Directory'] ?? 'Settings';
+ const me = useAuthStore((s) => s.username) ?? '';
+
+ const [step, setStep] = useState(0);
+ const [preset, setPreset] = useState(null);
+ const [conn, setConn] = useState({
+ url: '',
+ baseDn: '',
+ bindDn: '',
+ bindPassword: '',
+ allowInvalidCerts: false,
+ issuer: '',
+ usernameDomain: '',
+ });
+ const [savedId, setSavedId] = useState(null);
+ const [testAddress, setTestAddress] = useState('');
+ const [testPassword, setTestPassword] = useState('');
+ const [result, setResult] = useState(null);
+ const [data, setData] = useState<{ domains: DomainRow[]; serverDefault: string | null } | null>(null);
+ const [picked, setPicked] = useState>(new Set());
+ const [asDefault, setAsDefault] = useState(false);
+ const [understood, setUnderstood] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let live = true;
+ loadDomains()
+ .then((d) => live && setData(d))
+ .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)));
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ const steps: WizardStep[] = useMemo(
+ () => [
+ { id: 'kind', title: t('dirSetup.stepKind', 'Directory') },
+ { id: 'connect', title: t('dirSetup.stepConnect', 'Connection') },
+ { id: 'test', title: t('dirSetup.stepTest', 'Test a person') },
+ { id: 'domains', title: t('dirSetup.stepDomains', 'Domains') },
+ { id: 'review', title: t('dirSetup.stepReview', 'Review') },
+ { id: 'done', title: t('dirSetup.stepDone', 'Done') },
+ ],
+ [t],
+ );
+
+ const myDomain = me.includes('@') ? me.split('@').pop()!.toLowerCase() : '';
+ const chosen = data?.domains.filter((d) => picked.has(d.id)) ?? [];
+ const lockoutRisk = chosen.some((d) => d.name.toLowerCase() === myDomain) || asDefault;
+ const testPassed =
+ !!result?.opened &&
+ (preset?.kind === 'Oidc' ? true : result.lookup?.found === 'account' && result.signIn?.ok !== false);
+
+ // Save the directory (create, or update the one saved earlier in this run), then test it.
+ const saveAndTest = async () => {
+ if (!preset) return;
+ setBusy(true);
+ setError(null);
+ setResult(null);
+ try {
+ const description = t('dirSetup.description', '{{name}} (set up with the guide)', { name: preset.name });
+ const object = directoryObject(preset, conn, description);
+ let id = savedId;
+ if (id) {
+ const { '@type': _type, ...patch } = object;
+ void _type;
+ const [res] = await jmapSet('x:Directory', getAccountId('x:Directory'), { update: { [id]: patch } });
+ const f = failure(res?.[1] as unknown as JmapSetResponse, 'notUpdated');
+ if (f) throw new Error(f);
+ } else {
+ const [res] = await jmapSet('x:Directory', getAccountId('x:Directory'), { create: { dir: object } });
+ const body = res?.[1] as unknown as JmapSetResponse | undefined;
+ id = (body?.created?.dir as { id?: string } | undefined)?.id ?? null;
+ if (!id) throw new Error(failure(body, 'notCreated') ?? 'The directory could not be saved.');
+ setSavedId(id);
+ }
+ setConn((c) => ({ ...c, bindPassword: '' }));
+ setStep(2);
+ if (testAddress || preset.kind === 'Oidc') await runTest(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const runTest = async (id = savedId) => {
+ if (!id) return;
+ setBusy(true);
+ setError(null);
+ try {
+ // The server rebuilds directories a moment after a save.
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const res = await apiFetch('/api/directory/test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ // OpenID Connect has no lookup to try, so any address will do there.
+ body: JSON.stringify({
+ directoryId: id,
+ address: testAddress.trim() || 'check@invalid',
+ password: testPassword || undefined,
+ }),
+ });
+ if (!res.ok) {
+ const problem = (await res.json().catch(() => ({}))) as { detail?: string; title?: string };
+ throw new Error(problem.detail ?? problem.title ?? `The test failed (${res.status}).`);
+ }
+ const body = (await res.json()) as TestResult;
+ if (body.opened || !body.error?.startsWith("The server hasn't loaded")) {
+ setResult(body);
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 1500));
+ }
+ setResult({ opened: false, error: t('dirSetup.notLoaded', 'The server hasn’t loaded the directory yet.') });
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const apply = async () => {
+ if (!savedId || !data) return;
+ setBusy(true);
+ setError(null);
+ try {
+ if (chosen.length > 0) {
+ const update = Object.fromEntries(chosen.map((d) => [d.id, { directoryId: savedId }]));
+ const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), { update });
+ const f = failure(res?.[1] as unknown as JmapSetResponse, 'notUpdated');
+ if (f) throw new Error(f);
+ }
+ if (asDefault) {
+ const [res] = await jmapSet('x:Authentication', getAccountId('x:Authentication'), {
+ update: { singleton: { directoryId: savedId } },
+ });
+ const f = failure(res?.[1] as unknown as JmapSetResponse, 'notUpdated');
+ if (f) {
+ // Put the domains back, so a refused default leaves nothing half done.
+ await jmapSet('x:Domain', getAccountId('x:Domain'), {
+ update: Object.fromEntries(chosen.map((d) => [d.id, { directoryId: d.directoryId }])),
+ }).catch(() => undefined);
+ throw new Error(f);
+ }
+ }
+ setStep(5);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (!data) return error ? {error}
: ;
+
+ const common = {
+ icon: 'fingerprint',
+ title: t('dirSetup.title', 'Connect a sign-in directory'),
+ subtitle: t(
+ 'dirSetup.subtitle',
+ 'Let people sign in with the accounts they already have in Active Directory, LDAP or an OpenID Connect provider.',
+ ),
+ steps,
+ current: step,
+ onCancel: () => navigate(`/${section}/x:Directory`),
+ };
+ const errorLine = error && (
+
+
+ {error}
+
+ );
+ const field = (id: keyof Connection, label: string, placeholder?: string, hint?: string, type = 'text') => (
+
+
{label}
+
setConn((c) => ({ ...c, [id]: e.target.value }))}
+ className="max-w-lg"
+ />
+ {hint &&
{hint}
}
+
+ );
+
+ // ── Step 1: which directory ──
+ if (step === 0) {
+ return (
+ setStep(1)}
+ aside={
+
+
+ {t(
+ 'dirSetup.safe',
+ 'The directory is saved and tested on its own first. Sign-in only moves to it for the domains you pick at the end.',
+ )}
+
+ {t('dirSetup.sql', 'An SQL database as a directory is set up by hand, under Directories.')}
+
+ }
+ >
+
+ {PRESETS.map((p) => (
+ setPreset(p)}
+ aria-pressed={preset?.id === p.id}
+ className={cn(
+ 'flex flex-col items-start gap-1.5 rounded-xl border p-4 text-left transition-colors hover:border-primary/60',
+ preset?.id === p.id ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'bg-card',
+ )}
+ >
+ {p.name}
+ {p.hint}
+
+ ))}
+
+
+ );
+ }
+
+ // ── Step 2: connection ──
+ if (step === 1 && preset) {
+ const ldap = preset.kind === 'Ldap';
+ const ready = ldap
+ ? /^ldaps?:\/\//.test(conn.url.trim()) &&
+ conn.baseDn.trim() !== '' &&
+ (savedId !== null || conn.bindDn.trim() === '' || conn.bindPassword !== '')
+ : /^https:\/\//.test(conn.issuer.trim());
+ return (
+ setStep(0)}
+ onNext={() => void saveAndTest()}
+ nextLabel={t('dirSetup.saveNext', 'Save and continue')}
+ canNext={ready}
+ busy={busy}
+ aside={
+ ldap ? (
+
+
+ {t(
+ 'dirSetup.bind',
+ 'A read-only account the server uses to find people. Each person’s own password is then checked by signing in as them.',
+ )}
+
+
+ ) : (
+
+
+ {t(
+ 'dirSetup.oidc',
+ 'Register inbuxa as a client there. Mail apps can’t do browser sign-in, so people use app passwords for IMAP and SMTP.',
+ )}
+
+
+ )
+ }
+ >
+ {ldap ? (
+ <>
+ {field(
+ 'url',
+ t('dirSetup.url', 'Server'),
+ preset.example.url,
+ t(
+ 'dirSetup.urlHint',
+ 'ldaps:// on 636 is encrypted from the start. ldap:// uses STARTTLS unless it’s this machine.',
+ ),
+ )}
+ {field('baseDn', t('dirSetup.baseDn', 'Search base'), preset.example.baseDn)}
+ {field('bindDn', t('dirSetup.bindDn', 'Lookup account'), preset.example.bindDn)}
+ {field(
+ 'bindPassword',
+ t('dirSetup.bindPassword', 'Lookup account password'),
+ savedId ? t('dirSetup.keep', 'Leave empty to keep the saved one') : undefined,
+ undefined,
+ 'password',
+ )}
+
+
+ {t('dirSetup.invalidCerts', 'Accept an untrusted certificate')}
+
+ {t(
+ 'dirSetup.invalidCertsHint',
+ 'Only for a self-signed test server. Anyone in between could read passwords.',
+ )}
+
+
+ setConn((c) => ({ ...c, allowInvalidCerts: v }))}
+ />
+
+ >
+ ) : (
+ <>
+ {field('issuer', t('dirSetup.issuer', 'Issuer URL'), preset.example.issuer)}
+ {field(
+ 'usernameDomain',
+ t('dirSetup.usernameDomain', 'Domain for plain user names (optional)'),
+ 'example.org',
+ t('dirSetup.usernameDomainHint', 'Added to a user name without @, to make the address.'),
+ )}
+ >
+ )}
+ {ldap && (
+
+ {t('dirSetup.testAddress', 'A person to test with')}
+ setTestAddress(e.target.value)}
+ placeholder="jane@corp.example"
+ className="max-w-lg"
+ />
+
+ )}
+ {errorLine}
+
+ );
+ }
+
+ // ── Step 3: test a person ──
+ if (step === 2 && preset) {
+ const r = result;
+ return (
+ setStep(1)}
+ onNext={() => setStep(3)}
+ canNext={testPassed}
+ aside={
+
+
+ {t(
+ 'dirSetup.testHow',
+ 'The server looks the address up in the directory and, if you give a password, signs in as that person. Nothing is created here, and a wrong password doesn’t count against anyone.',
+ )}
+
+
+ }
+ >
+ {preset.kind === 'Ldap' && (
+
+
+ {t('dirSetup.testAddress', 'A person to test with')}
+ setTestAddress(e.target.value)}
+ />
+
+
+ {t('dirSetup.testPassword', 'Their password (optional)')}
+ setTestPassword(e.target.value)}
+ />
+
+
void runTest()}
+ disabled={busy || !testAddress.trim()}
+ >
+ {busy && }
+ {t('dirSetup.test', 'Test')}
+
+
+ )}
+ {preset.kind === 'Oidc' && !r && (
+ void runTest()} disabled={busy}>
+ {busy && }
+ {t('dirSetup.testOidc', 'Check the provider')}
+
+ )}
+
+ {r && (
+
+
+ {r.opened ? (
+
+ ) : (
+
+ )}
+ {r.opened
+ ? r.oidc
+ ? t('dirSetup.oidcOk', 'The provider answered, as {{issuer}}.', { issuer: r.oidc.issuer })
+ : t('dirSetup.opened', 'The server can use this directory.')
+ : t('dirSetup.notOpened', 'The directory didn’t open: {{error}}', { error: r.error ?? '' })}
+
+ {r.lookup && (
+
+ {r.lookup.found === 'account' ? (
+
+ ) : (
+
+ )}
+
+ {r.lookup.error
+ ? t('dirSetup.lookupError', 'The lookup failed: {{error}}', { error: r.lookup.error })
+ : r.lookup.found === 'account'
+ ? t('dirSetup.found', 'Found {{name}} <{{email}}>.', {
+ name: r.lookup.description ?? '',
+ email: r.lookup.email,
+ })
+ : r.lookup.found === 'group'
+ ? t('dirSetup.foundGroup', 'That address is a group, not a person. Try a person’s address.')
+ : t(
+ 'dirSetup.notFound',
+ 'Nobody with that address. Check the search base and that the address is in the mail attribute.',
+ )}
+ {r.lookup.found === 'account' && (r.lookup.aliases?.length || r.lookup.groups?.length) ? (
+
+ {r.lookup.aliases?.length
+ ? t('dirSetup.aliases', 'Also receives: {{list}}. ', { list: r.lookup.aliases.join(', ') })
+ : ''}
+ {r.lookup.groups?.length
+ ? t('dirSetup.groups', 'Groups: {{list}}.', { list: r.lookup.groups.join(', ') })
+ : ''}
+
+ ) : null}
+
+
+ )}
+ {r.signIn && (
+
+ {r.signIn.ok ? (
+
+ ) : (
+
+ )}
+ {r.signIn.ok
+ ? t('dirSetup.signInOk', 'The password signs in.')
+ : r.signIn.wrongPassword
+ ? t('dirSetup.wrongPassword', 'The directory says that password is wrong.')
+ : t('dirSetup.signInError', 'Signing in failed: {{error}}', { error: r.signIn.error ?? '' })}
+
+ )}
+
+ )}
+ {r && !testPassed && (
+
+ {t('dirSetup.fixHint', 'Go back to change the connection. Saving again updates the same directory.')}
+
+ )}
+ {errorLine}
+
+ );
+ }
+
+ // ── Step 4: domains ──
+ if (step === 3) {
+ return (
+ setStep(2)}
+ onNext={() => setStep(4)}
+ canNext={(chosen.length > 0 || asDefault) && (!lockoutRisk || understood)}
+ aside={
+
+
+ {t(
+ 'dirSetup.who',
+ 'On a domain that moves to this directory, only people the directory knows can sign in with a password. Existing accounts it doesn’t know stop signing in and, for LDAP, stop receiving mail. App passwords keep working.',
+ )}
+
+
+ }
+ >
+
+ {data.domains.map((d) => (
+
+
+ setPicked((s) => {
+ const n = new Set(s);
+ if (v) n.add(d.id);
+ else n.delete(d.id);
+ return n;
+ })
+ }
+ />
+
+ {d.name}
+
+ {d.directoryId === savedId
+ ? t('dirSetup.alreadyThis', 'Already uses this directory.')
+ : d.directoryId
+ ? t('dirSetup.otherDir', 'Uses another directory now.')
+ : t('dirSetup.defaultDir', 'Uses the server default now.')}{' '}
+ {d.accounts !== null && t('dirSetup.accounts', '{{count}} accounts.', { count: d.accounts })}{' '}
+ {d.name.toLowerCase() === myDomain && t('dirSetup.yours', 'You sign in on this domain.')}
+
+
+
+ ))}
+
+
+
+ {t('dirSetup.asDefault', 'Make it the server default')}
+
+ {t(
+ 'dirSetup.asDefaultHint',
+ 'For every domain without a directory of its own. Rarely what you want: prefer choosing domains.',
+ )}
+
+
+
+
+ {lockoutRisk && (
+
+
+
+ {t('dirSetup.lockout', 'This includes the account you’re signed in with ({{me}}).', { me })}
+
+
+ {t(
+ 'dirSetup.lockoutHint',
+ 'If the directory doesn’t know you, you can’t sign in again with your password. Test your own address first, keep an app password, or leave that domain out.',
+ )}
+
+
+ setUnderstood(Boolean(v))} />
+ {t('dirSetup.understood', 'I’ve checked I can still sign in')}
+
+
+ )}
+
+ );
+ }
+
+ // ── Step 5: review ──
+ if (step === 4) {
+ return (
+ setStep(3)}
+ onNext={() => void apply()}
+ nextLabel={t('dirSetup.apply', 'Switch sign-in over')}
+ busy={busy}
+ aside={
+
+
+ {t(
+ 'dirSetup.undo',
+ 'Set a domain’s directory back to the server default. Accounts made from the directory stay, with the last password it supplied.',
+ )}
+
+
+ }
+ >
+ {t('dirSetup.willChange', 'What will change')}
+
+ {chosen.map((d) => (
+
+ {t('dirSetup.domainMoves', '{{domain}} signs in through {{dir}}{{note}}.', {
+ domain: d.name,
+ dir: preset?.name ?? '',
+ note:
+ d.accounts !== null && d.accounts > 0
+ ? t('dirSetup.countNote', '; its {{count}} existing accounts must be in the directory', {
+ count: d.accounts,
+ })
+ : '',
+ })}
+
+ ))}
+ {asDefault && (
+
+ {t('dirSetup.defaultMoves', 'Every other domain without its own directory signs in through {{dir}}.', {
+ dir: preset?.name ?? '',
+ })}
+
+ )}
+
+ {errorLine}
+
+ );
+ }
+
+ // ── Step 6: done ──
+ return (
+ navigate(`/${section}/x:Directory`)}
+ nextLabel={t('wizard.finish', 'Finish')}
+ >
+
+
+ {t('dirSetup.done', 'Sign-in now goes through {{dir}}.', { dir: preset?.name ?? '' })}
+
+
+ {t(
+ 'dirSetup.doneHint',
+ 'Have someone sign in to the webmail with their directory password. Their account is created on first sign-in, with the name, aliases and groups the directory gives.',
+ )}
+
+
+ );
+}
diff --git a/src/features/directory/presets.test.ts b/src/features/directory/presets.test.ts
new file mode 100644
index 0000000..b414a14
--- /dev/null
+++ b/src/features/directory/presets.test.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, it, expect } from 'vitest';
+import { directoryObject, presetById, type Connection } from './presets';
+
+const conn: Connection = {
+ url: 'ldaps://dc1.corp.example:636',
+ baseDn: 'dc=corp,dc=example',
+ bindDn: 'cn=inbuxa,cn=Users,dc=corp,dc=example',
+ bindPassword: 'pw',
+ allowInvalidCerts: false,
+ issuer: '',
+ usernameDomain: '',
+};
+
+describe('directoryObject', () => {
+ it('builds an LDAP directory with the preset filters and the bind secret', () => {
+ const o = directoryObject(presetById('ad'), conn, 'AD');
+ expect(o).toMatchObject({
+ '@type': 'Ldap',
+ url: 'ldaps://dc1.corp.example:636',
+ useTls: false,
+ baseDn: 'dc=corp,dc=example',
+ bindSecret: { '@type': 'Value', secret: 'pw' },
+ groupClass: 'group',
+ });
+ });
+
+ it('asks for STARTTLS on ldap:// except to this machine', () => {
+ expect(directoryObject(presetById('openldap'), { ...conn, url: 'ldap://ldap.example.org' }, '').useTls).toBe(true);
+ expect(directoryObject(presetById('openldap'), { ...conn, url: 'ldap://localhost' }, '').useTls).toBe(false);
+ });
+
+ it('keeps the saved bind secret when no password is typed', () => {
+ expect(directoryObject(presetById('openldap'), { ...conn, bindPassword: '' }, '')).not.toHaveProperty('bindSecret');
+ });
+
+ it('builds an OIDC directory from the issuer', () => {
+ expect(
+ directoryObject(presetById('keycloak'), { ...conn, issuer: ' https://sso.example.org/realms/x ' }, 'KC'),
+ ).toEqual({
+ '@type': 'Oidc',
+ description: 'KC',
+ issuerUrl: 'https://sso.example.org/realms/x',
+ usernameDomain: null,
+ claimUsername: 'email',
+ claimName: 'name',
+ claimGroups: 'groups',
+ });
+ });
+});
diff --git a/src/features/directory/presets.ts b/src/features/directory/presets.ts
new file mode 100644
index 0000000..d088567
--- /dev/null
+++ b/src/features/directory/presets.ts
@@ -0,0 +1,169 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: starting points for the directories people actually run
+ * (settings-reorg, guided setup "Connect a sign-in directory"). They're
+ * starting points, not guarantees: every schema is set up a little
+ * differently, which is why the guide tests a real person before any
+ * domain signs in through the directory.
+ */
+
+export type Flavor = 'ad' | 'openldap' | 'freeipa' | 'keycloak' | 'authentik' | 'oidc';
+export type Kind = 'Ldap' | 'Oidc';
+
+export interface Preset {
+ id: Flavor;
+ kind: Kind;
+ name: string;
+ hint: string;
+ /** Example values for the connection fields. */
+ example: { url?: string; baseDn?: string; bindDn?: string; issuer?: string };
+ /** Directory fields the preset sets, in the server's JSON form. */
+ fields: Record;
+}
+
+const set = (...values: string[]) => Object.fromEntries(values.map((v) => [v, true]));
+
+export const PRESETS: Preset[] = [
+ {
+ id: 'ad',
+ kind: 'Ldap',
+ name: 'Active Directory',
+ hint: 'Windows Server or Samba AD. People sign in with their email address or user principal name.',
+ example: {
+ url: 'ldaps://dc1.corp.example:636',
+ baseDn: 'dc=corp,dc=example',
+ bindDn: 'cn=inbuxa,cn=Users,dc=corp,dc=example',
+ },
+ fields: {
+ filterLogin: '(&(objectClass=user)(|(mail=?)(userPrincipalName=?)))',
+ filterMailbox: '(|(&(objectClass=user)(mail=?))(&(objectClass=group)(mail=?)))',
+ filterMemberOf: null,
+ groupClass: 'group',
+ attrClass: set('objectClass'),
+ attrEmail: set('mail'),
+ attrEmailAlias: set('otherMailbox'),
+ attrDescription: set('displayName'),
+ attrMemberOf: set('memberOf'),
+ attrSecret: {},
+ attrSecretChanged: set('pwdLastSet'),
+ bindAuthentication: true,
+ },
+ },
+ {
+ id: 'openldap',
+ kind: 'Ldap',
+ name: 'OpenLDAP',
+ hint: 'inetOrgPerson accounts with mail and mailAlias, groupOfNames groups.',
+ example: { url: 'ldaps://ldap.example.org:636', baseDn: 'dc=example,dc=org', bindDn: 'cn=admin,dc=example,dc=org' },
+ fields: {
+ filterLogin: '(&(objectClass=inetOrgPerson)(mail=?))',
+ filterMailbox:
+ '(|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?))))',
+ filterMemberOf: '(&(objectClass=groupOfNames)(member=?))',
+ groupClass: 'groupOfNames',
+ attrClass: set('objectClass'),
+ attrEmail: set('mail'),
+ attrEmailAlias: set('mailAlias'),
+ attrDescription: set('cn'),
+ attrMemberOf: set('memberOf'),
+ attrSecret: {},
+ attrSecretChanged: set('pwdChangedTime'),
+ bindAuthentication: true,
+ },
+ },
+ {
+ id: 'freeipa',
+ kind: 'Ldap',
+ name: 'FreeIPA',
+ hint: 'Red Hat Identity Management. Accounts live under cn=accounts.',
+ example: {
+ url: 'ldaps://ipa.example.org:636',
+ baseDn: 'cn=accounts,dc=example,dc=org',
+ bindDn: 'uid=inbuxa,cn=sysaccounts,cn=etc,dc=example,dc=org',
+ },
+ fields: {
+ filterLogin: '(&(objectClass=person)(mail=?))',
+ filterMailbox: '(|(&(objectClass=person)(mail=?))(&(objectClass=groupOfNames)(mail=?)))',
+ filterMemberOf: null,
+ groupClass: 'groupOfNames',
+ attrClass: set('objectClass'),
+ attrEmail: set('mail'),
+ attrEmailAlias: {},
+ attrDescription: set('cn'),
+ attrMemberOf: set('memberOf'),
+ attrSecret: {},
+ attrSecretChanged: set('krbLastPwdChange'),
+ bindAuthentication: true,
+ },
+ },
+ {
+ id: 'keycloak',
+ kind: 'Oidc',
+ name: 'Keycloak',
+ hint: 'A realm’s OpenID Connect issuer. Sign-in happens at Keycloak; mail apps use tokens or app passwords.',
+ example: { issuer: 'https://sso.example.org/realms/example' },
+ fields: { claimUsername: 'email', claimName: 'name', claimGroups: 'groups' },
+ },
+ {
+ id: 'authentik',
+ kind: 'Oidc',
+ name: 'Authentik',
+ hint: 'An Authentik OAuth2/OpenID provider’s issuer.',
+ example: { issuer: 'https://auth.example.org/application/o/inbuxa/' },
+ fields: { claimUsername: 'email', claimName: 'name', claimGroups: 'groups' },
+ },
+ {
+ id: 'oidc',
+ kind: 'Oidc',
+ name: 'Another OpenID Connect provider',
+ hint: 'Any provider with a discovery document at /.well-known/openid-configuration.',
+ example: { issuer: 'https://id.example.org' },
+ fields: { claimUsername: 'email', claimName: 'name' },
+ },
+];
+
+export function presetById(id: Flavor): Preset {
+ return PRESETS.find((p) => p.id === id)!;
+}
+
+export interface Connection {
+ url: string;
+ baseDn: string;
+ bindDn: string;
+ /** Empty keeps the saved one when editing. */
+ bindPassword: string;
+ allowInvalidCerts: boolean;
+ issuer: string;
+ usernameDomain: string;
+}
+
+/** The x:Directory object this preset and connection make. */
+export function directoryObject(p: Preset, c: Connection, description: string): Record {
+ if (p.kind === 'Oidc') {
+ return {
+ '@type': 'Oidc',
+ description,
+ issuerUrl: c.issuer.trim(),
+ usernameDomain: c.usernameDomain.trim() || null,
+ ...p.fields,
+ };
+ }
+ const url = c.url.trim();
+ return {
+ '@type': 'Ldap',
+ description,
+ url,
+ // ldaps:// is encrypted by the URL itself; useTls asks for STARTTLS on ldap://.
+ useTls: url.startsWith('ldap://') && !/^ldap:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(url),
+ allowInvalidCerts: c.allowInvalidCerts,
+ baseDn: c.baseDn.trim(),
+ bindDn: c.bindDn.trim() || null,
+ ...(c.bindPassword ? { bindSecret: { '@type': 'Value', secret: c.bindPassword } } : {}),
+ ...p.fields,
+ };
+}
diff --git a/src/lib/layout.ts b/src/lib/layout.ts
index 124daa5..afb2533 100644
--- a/src/lib/layout.ts
+++ b/src/lib/layout.ts
@@ -108,6 +108,10 @@ function checkSpecialLink(
const ok = canGet ? canGet('sysMtaRoute') && canGet('sysMtaOutboundStrategy') : true;
return { visible: ok, enterprise: false };
}
+ if (viewName === 'Wizard/directory') {
+ const ok = canGet ? canGet('sysDirectory') && canGet('sysDomain') && canGet('sysAuthentication') : true;
+ return { visible: ok, enterprise: false };
+ }
if (viewName.startsWith('Wizard/dns/')) {
return { visible: canGet ? canGet('sysDomain') : true, enterprise: false };
}
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx
index c533f51..bc0c7be 100644
--- a/src/pages/AdminPanel.tsx
+++ b/src/pages/AdminPanel.tsx
@@ -106,6 +106,7 @@ export default function AdminPanel() {
if (!section) return t('dashboard.title', 'Dashboard');
if (!viewName) return section;
if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`;
+ if (viewName === 'Wizard/directory') return `${t('dirSetup.tabTitle', 'Sign-in directory')} · ${section}`;
if (viewName === 'Wizard/certificates') return `${t('certSetup.tabTitle', 'Certificates')} · ${section}`;
if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`;
if (viewName.startsWith('Dashboard/')) {