Directories: Description column and a Test card on each directory #110

Merged
jcoffey-dev merged 2 commits from feat/directory-description-and-test into main 2026-10-13 20:05:22 +00:00
9 changed files with 385 additions and 107 deletions

No files matched your search

+6
View File
@@ -69,6 +69,7 @@ import { celebrateIfFirstDomain } from '@/features/celebrate/firstDomain';
import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
import { DirectoryTestCard } from '@/features/directory/DirectoryTestCard';
import { changedFields } from '@/features/settings/changedFromDefault';
import { isSieveScriptField } from '@/lib/sievepad';
import { interpreterFor } from '@/lib/sieveLimits';
@@ -932,6 +933,11 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
</Card>
))}
{/* inbuxa: try a saved directory, as the guided setup does (admin #109) */}
{resolved.obj.objectName === 'x:Directory' && objectId && !isCreate && (
<DirectoryTestCard directoryId={String(objectId)} kind={selectedVariant} dirty={isDirty} />
)}
<div className="flex items-center justify-between pt-2 pb-8">
<div className="flex items-center gap-3">
{canDelete && (
+10 -105
View File
@@ -17,7 +17,7 @@
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 { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -25,13 +25,14 @@ 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';
import { testDirectory, type DirectoryTestResult } from './directoryTest';
import { DirectoryTestResults } from './DirectoryTestResults';
interface DomainRow {
id: string;
@@ -40,22 +41,6 @@ interface DomainRow {
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;
@@ -118,7 +103,7 @@ export function DirectorySetupPage() {
const [savedId, setSavedId] = useState<string | null>(null);
const [testAddress, setTestAddress] = useState('');
const [testPassword, setTestPassword] = useState('');
const [result, setResult] = useState<TestResult | null>(null);
const [result, setResult] = useState<DirectoryTestResult | 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);
@@ -193,28 +178,11 @@ export function DirectorySetupPage() {
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));
// OpenID Connect has no lookup to try, so any address will do there.
const body = await testDirectory(id, testAddress, testPassword);
if (body) {
setResult(body);
return;
}
setResult({ opened: false, error: t('dirSetup.notLoaded', 'The server hasn’t loaded the directory yet.') });
} catch (e) {
@@ -494,70 +462,7 @@ export function DirectorySetupPage() {
</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 && <DirectoryTestResults result={r} />}
{r && !testPassed && (
<p className="text-sm text-muted-foreground">
{t('dirSetup.fixHint', 'Go back to change the connection. Saving again updates the same directory.')}
@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: "Test this directory" on a saved directory's page (admin #109).
* The guided setup tests a directory as it goes; one added or changed here
* had no way to. First check the connection, then, once it works, try a
* person: the same server test the guide uses, on the saved settings.
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CHECK_ADDRESS, testDirectory, type DirectoryTestResult } from './directoryTest';
import { DirectoryTestResults, TestLine } from './DirectoryTestResults';
interface Props {
directoryId: string;
/** The saved directory's variant: Ldap, Sql or Oidc. */
kind: string | undefined;
/** Unsaved changes on the form: the test would try the saved settings, not these. */
dirty: boolean;
}
export function DirectoryTestCard({ directoryId, kind, dirty }: Props) {
const { t } = useTranslation();
const oidc = kind === 'Oidc';
const [check, setCheck] = useState<DirectoryTestResult | null>(null);
const [person, setPerson] = useState<DirectoryTestResult | null>(null);
const [address, setAddress] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState<'check' | 'person' | null>(null);
const [error, setError] = useState<string | null>(null);
// An edit changes what the server will test, so earlier results no longer hold.
const [wasDirty, setWasDirty] = useState(dirty);
if (dirty !== wasDirty) {
setWasDirty(dirty);
if (dirty) {
setCheck(null);
setPerson(null);
}
}
const run = async (which: 'check' | 'person') => {
setBusy(which);
setError(null);
if (which === 'check') setPerson(null);
try {
const body =
which === 'check'
? await testDirectory(directoryId, CHECK_ADDRESS)
: await testDirectory(directoryId, address, password);
const result = body ?? {
opened: false,
error: t('dirSetup.notLoaded', 'The server hasn’t loaded the directory yet.'),
};
if (which === 'check') setCheck(result);
else setPerson(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
};
// Looking up an address nobody has still searches the directory: an error there is a connection problem.
const connected = !!check?.opened && !check.lookup?.error;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('dirTest.title', 'Test this directory')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{dirty
? t('dirTest.saveFirst', 'Save your changes first. The test uses the saved settings.')
: t(
'dirTest.how',
'The test uses the saved settings. Nothing is created, and a wrong password doesn’t count against anyone.',
)}
</p>
<Button type="button" variant="outline" onClick={() => void run('check')} disabled={dirty || busy !== null}>
{busy === 'check' && <Loader2 className="h-4 w-4 animate-spin" />}
{oidc ? t('dirSetup.testOidc', 'Check the provider') : t('dirTest.check', 'Check the connection')}
</Button>
{check && (
<ul className="space-y-2 text-sm">
<TestLine ok={connected}>
{!check.opened
? t('dirSetup.notOpened', 'The directory didn’t open: {{error}}', { error: check.error ?? '' })
: check.lookup?.error
? t('dirTest.searchFailed', 'The directory opened, but searching it failed: {{error}}', {
error: check.lookup.error,
})
: check.oidc
? t('dirSetup.oidcOk', 'The provider answered, as {{issuer}}.', { issuer: check.oidc.issuer })
: t('dirTest.connected', 'The server can connect to this directory and search it.')}
</TestLine>
</ul>
)}
{connected && !oidc && (
<div className="space-y-4 border-t pt-4">
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<div className="space-y-1.5">
<Label htmlFor="dir-test-addr">{t('dirSetup.testAddress', 'A person to test with')}</Label>
<Input id="dir-test-addr" type="email" value={address} onChange={(e) => setAddress(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="dir-test-pass">{t('dirSetup.testPassword', 'Their password (optional)')}</Label>
<Input
id="dir-test-pass"
type="password"
autoComplete="off"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => void run('person')}
disabled={dirty || busy !== null || !address.trim()}
>
{busy === 'person' && <Loader2 className="h-4 w-4 animate-spin" />}
{t('dirSetup.test', 'Test')}
</Button>
</div>
{person && <DirectoryTestResults result={person} />}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
);
}
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** inbuxa: what a directory test found, line by line (guided setup and admin #109). */
import { useTranslation } from 'react-i18next';
import { CheckCircle2, XCircle } from 'lucide-react';
import type { DirectoryTestResult } from './directoryTest';
export function TestLine({ ok, children }: { ok: boolean; children: React.ReactNode }) {
return (
<li className="flex items-start gap-2">
{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" />
)}
{children}
</li>
);
}
export function DirectoryTestResults({ result: r }: { result: DirectoryTestResult }) {
const { t } = useTranslation();
return (
<ul className="space-y-2 text-sm">
<TestLine ok={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 ?? '' })}
</TestLine>
{r.lookup && (
<TestLine ok={r.lookup.found === 'account'}>
<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>
</TestLine>
)}
{r.signIn && (
<TestLine ok={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 ?? '' })}
</TestLine>
)}
</ul>
);
}
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: the server's directory test, `POST /api/directory/test`, shared by
* the guided setup and a saved directory's own page (admin #109). It tries a
* saved directory below the sign-in path: nothing is created, and a wrong
* password doesn't count against anyone.
*/
import { apiFetch } from '@/services/api';
export interface DirectoryTestResult {
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 };
}
/** An address no directory holds: looking it up shows the directory can be searched. */
export const CHECK_ADDRESS = 'check@invalid';
/**
* Test directory `id`, looking `address` up and, with a password, signing in.
* Null when the server still hasn't loaded the directory after a few tries.
*/
export async function testDirectory(
id: string,
address: string,
password?: string,
): Promise<DirectoryTestResult | null> {
// 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' },
body: JSON.stringify({ directoryId: id, address: address.trim() || CHECK_ADDRESS, password: password || 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 DirectoryTestResult;
if (body.opened || !body.error?.startsWith("The server hasn't loaded")) return body;
await new Promise((r) => setTimeout(r, 1500));
}
return null;
}
+8
View File
@@ -2329,5 +2329,13 @@
"expires": "Expires {{date}}",
"domainAliases_one": "{{count}} alias domain",
"domainAliases_other": "{{count}} alias domains"
},
"dirTest": {
"title": "Test this directory",
"saveFirst": "Save your changes first. The test uses the saved settings.",
"how": "The test uses the saved settings. Nothing is created, and a wrong password doesn’t count against anyone.",
"check": "Check the connection",
"searchFailed": "The directory opened, but searching it failed: {{error}}",
"connected": "The server can connect to this directory and search it."
}
}
+42
View File
@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { withExtraColumns } from './listColumns';
import type { List, Schema } from '@/types/schema';
function list(columns: List['columns']): List {
return { title: 'Directories', subtitle: '', singularName: 'directory', pluralName: 'directories', columns };
}
function schema(lists: Record<string, List>): Schema {
return { lists } as unknown as Schema;
}
describe('withExtraColumns', () => {
it('adds Description after Type on the Directories list', () => {
const out = withExtraColumns(schema({ 'x:Directory': list([{ name: '@type', label: 'Type' }]) }));
expect(out.lists['x:Directory'].columns).toEqual([
{ name: '@type', label: 'Type' },
{ name: 'description', label: 'Description' },
]);
});
it('leaves a list that already has the column alone', () => {
const served = schema({
'x:Directory': list([
{ name: 'description', label: 'Name' },
{ name: '@type', label: 'Type' },
]),
});
expect(withExtraColumns(served)).toBe(served);
});
it('leaves other lists, and a schema without the list, alone', () => {
const served = schema({ 'x:Domain': list([{ name: 'name', label: 'Name' }]) });
expect(withExtraColumns(served)).toBe(served);
});
});
+30
View File
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: columns a served list leaves out. The Directories list shows only
* each directory's type, so two LDAP directories look the same until you
* open them (admin #108).
*/
import type { Column, Schema } from '@/types/schema';
const EXTRA_COLUMNS: Record<string, Column[]> = {
'x:Directory': [{ name: 'description', label: 'Description' }],
};
/** The schema with the extra columns added after the served ones; applied once, when the schema loads. */
export function withExtraColumns(schema: Schema): Schema {
let lists = schema.lists;
for (const [viewName, extra] of Object.entries(EXTRA_COLUMNS)) {
const list = lists[viewName];
if (!list) continue;
const missing = extra.filter((c) => !list.columns.some((have) => have.name === c.name));
if (missing.length === 0) continue;
lists = { ...lists, [viewName]: { ...list, columns: [...list.columns, ...missing] } };
}
return lists === schema.lists ? schema : { ...schema, lists };
}
+4 -2
View File
@@ -10,6 +10,7 @@
import { create } from 'zustand';
import type { Schema, LayoutSubItem } from '@/types/schema';
import { withRegroupedSettings } from '@/lib/settingsLayout';
import { withExtraColumns } from '@/lib/listColumns';
export interface SearchIndexEntry {
text: string;
@@ -168,8 +169,9 @@ export const useSchemaStore = create<SchemaState>()((set) => ({
searchIndex: [],
setSchema: (served) => {
// inbuxa: Settings grouped by task, not by config file (settings-reorg).
const schema = withRegroupedSettings(served);
// inbuxa: Settings grouped by task, not by config file (settings-reorg),
// and columns the served lists leave out.
const schema = withExtraColumns(withRegroupedSettings(served));
const { viewToSection, linkEntries } = walkLayouts(schema);
const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries);
set({