diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx
index 9d5ca69..75f2d34 100644
--- a/src/components/forms/DynamicForm.tsx
+++ b/src/components/forms/DynamicForm.tsx
@@ -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
))}
+ {/* inbuxa: try a saved directory, as the guided setup does (admin #109) */}
+ {resolved.obj.objectName === 'x:Directory' && objectId && !isCreate && (
+
+ )}
+
{canDelete && (
diff --git a/src/features/directory/DirectorySetupPage.tsx b/src/features/directory/DirectorySetupPage.tsx
index e08fc5e..c6adf96 100644
--- a/src/features/directory/DirectorySetupPage.tsx
+++ b/src/features/directory/DirectorySetupPage.tsx
@@ -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(null);
const [testAddress, setTestAddress] = useState('');
const [testPassword, setTestPassword] = useState('');
- const [result, setResult] = useState(null);
+ 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);
@@ -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() {
)}
- {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 && }
{r && !testPassed && (
{t('dirSetup.fixHint', 'Go back to change the connection. Saving again updates the same directory.')}
diff --git a/src/features/directory/DirectoryTestCard.tsx b/src/features/directory/DirectoryTestCard.tsx
new file mode 100644
index 0000000..892d544
--- /dev/null
+++ b/src/features/directory/DirectoryTestCard.tsx
@@ -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(null);
+ const [person, setPerson] = useState(null);
+ const [address, setAddress] = useState('');
+ const [password, setPassword] = useState('');
+ const [busy, setBusy] = useState<'check' | 'person' | null>(null);
+ const [error, setError] = useState(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 (
+
+
+ {t('dirTest.title', 'Test this directory')}
+
+
+
+ {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.',
+ )}
+
+
+
+
+ {check && (
+
+
+ {!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.')}
+
+
+ )}
+
+ {connected && !oidc && (
+
+
+
+
+ setAddress(e.target.value)} />
+
+
+
+ setPassword(e.target.value)}
+ />
+
+
+
+ {person && }
+
+ )}
+
+ {error &&
{error}
}
+
+
+ );
+}
diff --git a/src/features/directory/DirectoryTestResults.tsx b/src/features/directory/DirectoryTestResults.tsx
new file mode 100644
index 0000000..566b9fb
--- /dev/null
+++ b/src/features/directory/DirectoryTestResults.tsx
@@ -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 (
+
+ {ok ? (
+
+ ) : (
+
+ )}
+ {children}
+
+ );
+}
+
+export function DirectoryTestResults({ result: r }: { result: DirectoryTestResult }) {
+ const { t } = useTranslation();
+ return (
+
+
+ {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.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
+ ? 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 ?? '' })}
+
+ )}
+
+ );
+}
diff --git a/src/features/directory/directoryTest.ts b/src/features/directory/directoryTest.ts
new file mode 100644
index 0000000..7c3d6e3
--- /dev/null
+++ b/src/features/directory/directoryTest.ts
@@ -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 {
+ // 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;
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 2598671..442d20b 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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."
}
}
diff --git a/src/lib/listColumns.test.ts b/src/lib/listColumns.test.ts
new file mode 100644
index 0000000..f5130ea
--- /dev/null
+++ b/src/lib/listColumns.test.ts
@@ -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): 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);
+ });
+});
diff --git a/src/lib/listColumns.ts b/src/lib/listColumns.ts
new file mode 100644
index 0000000..85c2236
--- /dev/null
+++ b/src/lib/listColumns.ts
@@ -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 = {
+ '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 };
+}
diff --git a/src/stores/schemaStore.ts b/src/stores/schemaStore.ts
index f4b7265..6aa140f 100644
--- a/src/stores/schemaStore.ts
+++ b/src/stores/schemaStore.ts
@@ -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()((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({