From dec5a4e3508639581e99dc32fc89feef190852d8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 29 Jul 2026 14:16:29 +0200 Subject: [PATCH] feat: add reusable mail reference selectors --- webui/package-lock.json | 16 +- webui/package.json | 2 +- .../features/mail/MailProfileManagement.tsx | 100 ++++++++++-- .../features/mail/mailReferenceProviders.ts | 144 ++++++++++++++++++ webui/src/module.ts | 4 +- 5 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 webui/src/features/mail/mailReferenceProviders.ts diff --git a/webui/package-lock.json b/webui/package-lock.json index 0aad571..b8fdd36 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -15,7 +15,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" }, "peerDependenciesMeta": { "@govoplan/core-webui": { @@ -71,9 +71,9 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "peer": true, "dependencies": { @@ -94,13 +94,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "peer": true, "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" diff --git a/webui/package.json b/webui/package.json index c76e688..61c8f16 100644 --- a/webui/package.json +++ b/webui/package.json @@ -18,7 +18,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 85866c6..e3a7690 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -45,6 +45,15 @@ import { ConfirmDialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { + ReferenceMultiSelect, + customReferenceOption, + platformModuleReferenceProvider, + staticReferenceOptionProvider, + usePlatformLanguage, + usePlatformModules, + type ReferenceOption +} from "@govoplan/core-webui"; import { mailProfileEditTargetInitialSection, mailProfileEditTargetPanelMode, @@ -657,6 +666,7 @@ export function MailProfileScopeManager({ void; editing: EditingProfile; @@ -1006,6 +1018,8 @@ function ProfileForm({ reuseCredentialId: string; setReuseCredentialId: (credentialId: string) => void; }) { + const modules = usePlatformModules(); + const { translateText } = usePlatformLanguage(); const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null); @@ -1040,6 +1054,46 @@ function ProfileForm({ ); const creatingCredential = editTarget.kind === "credentials" && !editTarget.credentialId; const creatingNewCredential = creatingCredential && !reuseCredentialId; + const moduleReferenceOptions = useMemo( + () => modules.map((module) => ({ + value: module.id, + label: translateText(module.label), + description: `${module.id} · version ${module.version}`, + kind: "module", + sourceModule: "core", + provenance: { version: module.version } + })), + [modules, translateText] + ); + const serverReferenceOptions = useMemo( + () => profiles.flatMap((profile) => + (profile.servers ?? []).map((server) => ({ + value: `mail:${server.id}`, + label: server.name, + description: `${profile.name} · ${server.protocol.toUpperCase()} · ${server.id}`, + kind: "mail_server", + availability: server.is_active ? "available" as const : "inactive" as const, + disabled: !server.is_active, + sourceModule: "mail", + provenance: { + profileId: profile.id, + serverId: server.id, + protocol: server.protocol, + scopeType: server.scope_type, + scopeId: server.scope_id + } + })) + ), + [profiles] + ); + const moduleReferenceProvider = useMemo( + () => platformModuleReferenceProvider(settings, moduleReferenceOptions), + [moduleReferenceOptions, settings] + ); + const serverReferenceProvider = useMemo( + () => staticReferenceOptionProvider(serverReferenceOptions), + [serverReferenceOptions] + ); const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, { smtpHost: draft.smtpHost, imapHost: draft.imapHost @@ -1190,13 +1244,28 @@ function ProfileForm({ setDraft({ ...draft, credentialDescription: event.target.value })} /> - - setDraft({ ...draft, credentialAllowedModules: event.target.value })} /> + + setDraft({ ...draft, credentialAllowedModules: values.join(", ") })} + provider={moduleReferenceProvider} + aria-label="Credential modules" + placeholder="Add a module" + disabled={credentialDisabled} + /> - setDraft({ ...draft, credentialAllowedServerRefs: event.target.value })} /> + help="Choose an announced server or enter a module-qualified reference. Leave blank for every permitted server."> + setDraft({ ...draft, credentialAllowedServerRefs: values.join(", ") })} + provider={serverReferenceProvider} + createCustomOption={(value) => customReferenceOption(value, "server")} + aria-label="Credential server restrictions" + placeholder="Add a server reference" + disabled={credentialDisabled} + /> value.trim()) - .filter(Boolean) - )]; - const allowedServerRefs = [...new Set( - draft.credentialAllowedServerRefs - .split(",") - .map((value) => value.trim()) - .filter(Boolean) - )]; + const allowedModules = commaValues(draft.credentialAllowedModules); + const allowedServerRefs = commaValues(draft.credentialAllowedServerRefs); const payload: { name: string; description: string | null; @@ -1824,6 +1883,15 @@ function stringValue(value: unknown): string { return String(value); } +function commaValues(value: string): string[] { + return [...new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + )]; +} + function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/webui/src/features/mail/mailReferenceProviders.ts b/webui/src/features/mail/mailReferenceProviders.ts new file mode 100644 index 0000000..927c952 --- /dev/null +++ b/webui/src/features/mail/mailReferenceProviders.ts @@ -0,0 +1,144 @@ +import { + filterSearchableSelectOptions, + unavailableReferenceOption, + type ApiSettings, + type CredentialReferenceSelectorContext, + type CredentialReferenceSelectorsUiCapability, + type ReferenceOption, + type ReferenceOptionProvider +} from "@govoplan/core-webui"; +import { + fetchMailSettingsDelta, + type MailServerProfile +} from "../../api/mail"; + +export const mailCredentialReferenceSelectors: + CredentialReferenceSelectorsUiCapability = { + serverProvider: createMailServerReferenceProvider + }; + +export function createMailServerReferenceProvider( + settings: ApiSettings, + context: CredentialReferenceSelectorContext +): ReferenceOptionProvider { + let cataloguePromise: Promise | null = null; + + async function catalogue(signal: AbortSignal): Promise { + cataloguePromise ??= loadProfiles(settings, context, signal) + .then(mailServerOptions) + .catch((error: unknown) => { + cataloguePromise = null; + throw error; + }); + const options = await cataloguePromise; + if (signal.aborted) throw abortError(); + return options; + } + + return { + async search(query, providerContext) { + const options = await catalogue(providerContext.signal); + return filterSearchableSelectOptions( + options, + query, + providerContext.limit + ) as ReferenceOption[]; + }, + async resolve(values, providerContext) { + const options = await catalogue(providerContext.signal); + const byValue = new Map( + options.map((option) => [option.value, option]) + ); + return values.map( + (value) => + byValue.get(value) + ?? unavailableReferenceOption(value, "Unavailable mail server") + ); + } + }; +} + +function mailServerOptions( + profiles: readonly MailServerProfile[] +): ReferenceOption[] { + return profiles.flatMap((profile) => + (profile.servers ?? []).map((server) => ({ + value: `mail:${server.id}`, + label: server.name, + description: [ + profile.name, + server.protocol.toUpperCase(), + server.is_active ? null : "Inactive", + server.id + ].filter(Boolean).join(" · "), + searchText: [ + profile.slug, + profile.name, + server.name, + server.protocol, + server.id + ].join(" "), + kind: "mail_server", + availability: server.is_active ? "available" : "inactive", + disabled: !server.is_active, + sourceModule: "mail", + provenance: { + profileId: profile.id, + serverId: server.id, + protocol: server.protocol, + scopeType: server.scope_type, + scopeId: server.scope_id + } + } satisfies ReferenceOption)) + ); +} + +async function loadProfiles( + settings: ApiSettings, + context: CredentialReferenceSelectorContext, + signal: AbortSignal +): Promise { + let watermark: string | null = null; + let profiles: MailServerProfile[] = []; + let first = true; + do { + const response = await fetchMailSettingsDelta(settings, { + scope_type: context.scopeType, + scope_id: context.scopeId, + include_inactive: true, + since: first ? null : watermark, + limit: 200 + }); + if (signal.aborted) throw abortError(); + profiles = response.full + ? response.profiles + : mergeProfiles(profiles, response.profiles, response.deleted); + watermark = response.watermark ?? null; + first = false; + if (!response.has_more) break; + } while (watermark); + return profiles; +} + +function mergeProfiles( + current: readonly MailServerProfile[], + changed: readonly MailServerProfile[], + deleted: readonly { resource_type: string; resource_id: string }[] +): MailServerProfile[] { + const removed = new Set( + deleted + .filter((item) => item.resource_type === "mail_profile") + .map((item) => item.resource_id) + ); + const merged = new Map( + current + .filter((profile) => !removed.has(profile.id)) + .map((profile) => [profile.id, profile]) + ); + for (const profile of changed) merged.set(profile.id, profile); + return [...merged.values()]; +} + +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index 1564a93..a2284ca 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -2,6 +2,7 @@ import { createElement, lazy } from "react"; import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui"; import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement"; import { validateMailPolicy } from "./features/mail/mailPolicyValidation"; +import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders"; import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/mail-profiles.css"; @@ -31,7 +32,8 @@ export const mailModule: PlatformWebModule = { { path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }], uiCapabilities: { - "mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability + "mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability, + "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }, runtimeUiCapabilities: { "mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability