From 16b25968ad8edc77a8530b5abdd593d9532817c9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 13 Oct 2026 12:46:30 -0700 Subject: [PATCH 1/2] Directories: show each directory's Description next to its Type The served list has only a Type column, so two LDAP directories look the same until you open them. The console adds a Description column to that list when the schema loads, the same way it regroups Settings. Fixes #108 --- src/lib/listColumns.test.ts | 42 +++++++++++++++++++++++++++++++++++++ src/lib/listColumns.ts | 30 ++++++++++++++++++++++++++ src/stores/schemaStore.ts | 6 ++++-- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/lib/listColumns.test.ts create mode 100644 src/lib/listColumns.ts 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({ -- 2.54.0 From d1456bcc62036b856444f56af23d2cae6bd15362 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 13 Oct 2026 12:54:09 -0700 Subject: [PATCH 2/2] Directories: a Test card on a saved directory's page The guided setup tests a directory as it goes, but one added or changed under Settings had no way to. Its page now ends with "Test this directory": check the connection first (a lookup of an address nobody has, so a search error shows as a connection problem), then, once that works, try a person's address and password. OpenID Connect gets "Check the provider" only. It runs the guide's server test on the saved settings, so with unsaved changes it asks for a save first and drops earlier results. The guide's test call and result lines move to directoryTest.ts and DirectoryTestResults.tsx so both use the same ones. Fixes #109 --- src/components/forms/DynamicForm.tsx | 6 + src/features/directory/DirectorySetupPage.tsx | 115 ++------------ src/features/directory/DirectoryTestCard.tsx | 148 ++++++++++++++++++ .../directory/DirectoryTestResults.tsx | 77 +++++++++ src/features/directory/directoryTest.ts | 60 +++++++ src/i18n/en.json | 8 + 6 files changed, 309 insertions(+), 105 deletions(-) create mode 100644 src/features/directory/DirectoryTestCard.tsx create mode 100644 src/features/directory/DirectoryTestResults.tsx create mode 100644 src/features/directory/directoryTest.ts 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." } } -- 2.54.0