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.', + )} +

+
+ + 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') => ( +
+ + 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) => ( + + ))} +
+
+ ); + } + + // ── 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', + )} + + + ) : ( + <> + {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 && ( +
+ + 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' && ( +
+
+ + setTestAddress(e.target.value)} + /> +
+
+ + setTestPassword(e.target.value)} + /> +
+ +
+ )} + {preset.kind === 'Oidc' && !r && ( + + )} + + {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; + }) + } + /> + +
  • + ))} +
+ + {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.', + )} +

+ +
+ )} +
+ ); + } + + // ── 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/')) {