Guided setup: connect a sign-in directory #41
No files matched your search
@@ -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 <ConnectDnsPage domainId={param} />;
|
||||
}
|
||||
// inbuxa: guided setup, connect a sign-in directory (settings-reorg).
|
||||
if (wizard === 'directory') {
|
||||
return <DirectorySetupPage />;
|
||||
}
|
||||
// inbuxa: guided setup, how this server sends mail (settings-reorg).
|
||||
if (wizard === 'sending') {
|
||||
return <SendingSetupPage />;
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<DirectorySetupCard className="mx-auto max-w-4xl" />
|
||||
<DynamicForm viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (resolved.objectName === 'x:Security') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -290,5 +308,13 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (viewName === 'x:Directory') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DirectorySetupCard />
|
||||
<DynamicList viewName={viewName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicList viewName={viewName} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Fingerprint className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{t('dirCard.title', 'Sign in with accounts you already have')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'dirCard.hint',
|
||||
'A guide connects Active Directory, LDAP or an OpenID Connect provider, tests a real person, then moves the domains you pick.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setOpen(true)}>
|
||||
{t('dirCard.start', 'Set it up')}
|
||||
</Button>
|
||||
<LaunchChoice
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t('dirCard.chooseTitle', 'Connect a sign-in directory')}
|
||||
guidedHint={t(
|
||||
'dirCard.guidedHint',
|
||||
'Pick your directory, fill in the connection, test a person, then choose domains. Nothing moves until the test passes.',
|
||||
)}
|
||||
manualHint={t('dirCard.manualHint', 'Create the directory and set each domain’s directory yourself.')}
|
||||
onGuided={() => navigate(`/${section}/${DIRECTORY_WIZARD_VIEW}`)}
|
||||
onManual={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>[] } | 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<Preset | null>(null);
|
||||
const [conn, setConn] = useState<Connection>({
|
||||
url: '',
|
||||
baseDn: '',
|
||||
bindDn: '',
|
||||
bindPassword: '',
|
||||
allowInvalidCerts: false,
|
||||
issuer: '',
|
||||
usernameDomain: '',
|
||||
});
|
||||
const [savedId, setSavedId] = useState<string | null>(null);
|
||||
const [testAddress, setTestAddress] = useState('');
|
||||
const [testPassword, setTestPassword] = useState('');
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
const [data, setData] = useState<{ domains: DomainRow[]; serverDefault: string | null } | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [asDefault, setAsDefault] = useState(false);
|
||||
const [understood, setUnderstood] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 ? <div className="p-8 text-center text-destructive">{error}</div> : <LoadingFallback />;
|
||||
|
||||
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 && (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
const field = (id: keyof Connection, label: string, placeholder?: string, hint?: string, type = 'text') => (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`dir-${id}`}>{label}</Label>
|
||||
<Input
|
||||
id={`dir-${id}`}
|
||||
type={type}
|
||||
value={String(conn[id])}
|
||||
placeholder={placeholder}
|
||||
autoComplete={type === 'password' ? 'new-password' : 'off'}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setConn((c) => ({ ...c, [id]: e.target.value }))}
|
||||
className="max-w-lg"
|
||||
/>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Step 1: which directory ──
|
||||
if (step === 0) {
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
canNext={preset !== null}
|
||||
onNext={() => setStep(1)}
|
||||
aside={
|
||||
<WizardNote title={t('dirSetup.safeTitle', 'Nothing changes yet')}>
|
||||
<p>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
<p>{t('dirSetup.sql', 'An SQL database as a directory is set up by hand, under Directories.')}</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(0)}
|
||||
onNext={() => void saveAndTest()}
|
||||
nextLabel={t('dirSetup.saveNext', 'Save and continue')}
|
||||
canNext={ready}
|
||||
busy={busy}
|
||||
aside={
|
||||
ldap ? (
|
||||
<WizardNote title={t('dirSetup.bindTitle', 'The lookup account')}>
|
||||
<p>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
) : (
|
||||
<WizardNote title={t('dirSetup.oidcTitle', 'At the provider')}>
|
||||
<p>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
)
|
||||
}
|
||||
>
|
||||
{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',
|
||||
)}
|
||||
<label className="flex items-center justify-between gap-4 text-sm">
|
||||
<span>
|
||||
{t('dirSetup.invalidCerts', 'Accept an untrusted certificate')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t(
|
||||
'dirSetup.invalidCertsHint',
|
||||
'Only for a self-signed test server. Anyone in between could read passwords.',
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={conn.allowInvalidCerts}
|
||||
onCheckedChange={(v) => setConn((c) => ({ ...c, allowInvalidCerts: v }))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dir-test-address">{t('dirSetup.testAddress', 'A person to test with')}</Label>
|
||||
<Input
|
||||
id="dir-test-address"
|
||||
type="email"
|
||||
value={testAddress}
|
||||
onChange={(e) => setTestAddress(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="max-w-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{errorLine}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: test a person ──
|
||||
if (step === 2 && preset) {
|
||||
const r = result;
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(1)}
|
||||
onNext={() => setStep(3)}
|
||||
canNext={testPassed}
|
||||
aside={
|
||||
<WizardNote title={t('dirSetup.testTitle', 'What the test does')}>
|
||||
<p>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
{preset.kind === 'Ldap' && (
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dir-t-addr">{t('dirSetup.testAddress', 'A person to test with')}</Label>
|
||||
<Input
|
||||
id="dir-t-addr"
|
||||
type="email"
|
||||
value={testAddress}
|
||||
onChange={(e) => setTestAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dir-t-pass">{t('dirSetup.testPassword', 'Their password (optional)')}</Label>
|
||||
<Input
|
||||
id="dir-t-pass"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={testPassword}
|
||||
onChange={(e) => setTestPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void runTest()}
|
||||
disabled={busy || !testAddress.trim()}
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('dirSetup.test', 'Test')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{preset.kind === 'Oidc' && !r && (
|
||||
<Button type="button" variant="outline" onClick={() => void runTest()} disabled={busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('dirSetup.testOidc', 'Check the provider')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{r && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
{r.opened ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
{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 ?? '' })}
|
||||
</li>
|
||||
{r.lookup && (
|
||||
<li className="flex items-start gap-2">
|
||||
{r.lookup.found === 'account' ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span>
|
||||
{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) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{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(', ') })
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{r.signIn && (
|
||||
<li className="flex items-start gap-2">
|
||||
{r.signIn.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
{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 ?? '' })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{r && !testPassed && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dirSetup.fixHint', 'Go back to change the connection. Saving again updates the same directory.')}
|
||||
</p>
|
||||
)}
|
||||
{errorLine}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 4: domains ──
|
||||
if (step === 3) {
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(2)}
|
||||
onNext={() => setStep(4)}
|
||||
canNext={(chosen.length > 0 || asDefault) && (!lockoutRisk || understood)}
|
||||
aside={
|
||||
<WizardNote title={t('dirSetup.whoTitle', 'Who this affects')}>
|
||||
<p>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<ul className="divide-y rounded-xl border">
|
||||
{data.domains.map((d) => (
|
||||
<li key={d.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<Checkbox
|
||||
id={`dd-${d.id}`}
|
||||
checked={picked.has(d.id)}
|
||||
onCheckedChange={(v) =>
|
||||
setPicked((s) => {
|
||||
const n = new Set(s);
|
||||
if (v) n.add(d.id);
|
||||
else n.delete(d.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`dd-${d.id}`} className="min-w-0 flex-1">
|
||||
<span className="font-medium">{d.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{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.')}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="flex items-start justify-between gap-4 rounded-xl border p-4 text-sm">
|
||||
<span>
|
||||
<span className="font-medium">{t('dirSetup.asDefault', 'Make it the server default')}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t(
|
||||
'dirSetup.asDefaultHint',
|
||||
'For every domain without a directory of its own. Rarely what you want: prefer choosing domains.',
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<Switch checked={asDefault} onCheckedChange={setAsDefault} />
|
||||
</label>
|
||||
{lockoutRisk && (
|
||||
<div className="space-y-2 rounded-xl border border-amber-500/40 bg-amber-500/5 p-4 text-sm">
|
||||
<p className="flex items-start gap-2 font-medium">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
{t('dirSetup.lockout', 'This includes the account you’re signed in with ({{me}}).', { me })}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={understood} onCheckedChange={(v) => setUnderstood(Boolean(v))} />
|
||||
{t('dirSetup.understood', 'I’ve checked I can still sign in')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 5: review ──
|
||||
if (step === 4) {
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(3)}
|
||||
onNext={() => void apply()}
|
||||
nextLabel={t('dirSetup.apply', 'Switch sign-in over')}
|
||||
busy={busy}
|
||||
aside={
|
||||
<WizardNote tone="undo" title={t('dirSetup.undoTitle', 'Changing your mind')}>
|
||||
<p>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<p className="font-medium">{t('dirSetup.willChange', 'What will change')}</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm">
|
||||
{chosen.map((d) => (
|
||||
<li key={d.id}>
|
||||
{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,
|
||||
})
|
||||
: '',
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
{asDefault && (
|
||||
<li>
|
||||
{t('dirSetup.defaultMoves', 'Every other domain without its own directory signs in through {{dir}}.', {
|
||||
dir: preset?.name ?? '',
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{errorLine}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 6: done ──
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onNext={() => navigate(`/${section}/x:Directory`)}
|
||||
nextLabel={t('wizard.finish', 'Finish')}
|
||||
>
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
{t('dirSetup.done', 'Sign-in now goes through {{dir}}.', { dir: preset?.name ?? '' })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
Reference in new issue
Block a user