feat: add reusable mail reference selectors
This commit is contained in:
16
webui/package-lock.json
generated
16
webui/package-lock.json
generated
@@ -15,7 +15,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -94,13 +94,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.1"
|
"react-router": "7.18.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ import { ConfirmDialog } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { FormField, i18nMessage, useUnsavedDraftGuard } 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 {
|
import {
|
||||||
mailProfileEditTargetInitialSection,
|
mailProfileEditTargetInitialSection,
|
||||||
mailProfileEditTargetPanelMode,
|
mailProfileEditTargetPanelMode,
|
||||||
@@ -657,6 +666,7 @@ export function MailProfileScopeManager({
|
|||||||
|
|
||||||
<ProfileForm
|
<ProfileForm
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
profiles={profiles}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -981,6 +991,7 @@ export function MailProfilePolicyEditor({
|
|||||||
|
|
||||||
function ProfileForm({
|
function ProfileForm({
|
||||||
settings,
|
settings,
|
||||||
|
profiles,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
editing,
|
editing,
|
||||||
@@ -994,6 +1005,7 @@ function ProfileForm({
|
|||||||
setReuseCredentialId
|
setReuseCredentialId
|
||||||
}: {
|
}: {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
|
profiles: MailServerProfile[];
|
||||||
draft: ProfileDraft;
|
draft: ProfileDraft;
|
||||||
setDraft: (draft: ProfileDraft) => void;
|
setDraft: (draft: ProfileDraft) => void;
|
||||||
editing: EditingProfile;
|
editing: EditingProfile;
|
||||||
@@ -1006,6 +1018,8 @@ function ProfileForm({
|
|||||||
reuseCredentialId: string;
|
reuseCredentialId: string;
|
||||||
setReuseCredentialId: (credentialId: string) => void;
|
setReuseCredentialId: (credentialId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const modules = usePlatformModules();
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
||||||
@@ -1040,6 +1054,46 @@ function ProfileForm({
|
|||||||
);
|
);
|
||||||
const creatingCredential = editTarget.kind === "credentials" && !editTarget.credentialId;
|
const creatingCredential = editTarget.kind === "credentials" && !editTarget.credentialId;
|
||||||
const creatingNewCredential = creatingCredential && !reuseCredentialId;
|
const creatingNewCredential = creatingCredential && !reuseCredentialId;
|
||||||
|
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
|
||||||
|
() => 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<ReferenceOption[]>(
|
||||||
|
() => 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, {
|
const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, {
|
||||||
smtpHost: draft.smtpHost,
|
smtpHost: draft.smtpHost,
|
||||||
imapHost: draft.imapHost
|
imapHost: draft.imapHost
|
||||||
@@ -1190,13 +1244,28 @@ function ProfileForm({
|
|||||||
<FormField label="Description">
|
<FormField label="Description">
|
||||||
<input value={draft.credentialDescription} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialDescription: event.target.value })} />
|
<input value={draft.credentialDescription} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialDescription: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Available to modules" help="Comma-separated module IDs. Keep mail to use this credential here.">
|
<FormField label="Available to modules" help="Choose installed modules. Keep Mail selected to use this credential here.">
|
||||||
<input value={draft.credentialAllowedModules} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialAllowedModules: event.target.value })} />
|
<ReferenceMultiSelect
|
||||||
|
values={commaValues(draft.credentialAllowedModules)}
|
||||||
|
onChange={(values) => setDraft({ ...draft, credentialAllowedModules: values.join(", ") })}
|
||||||
|
provider={moduleReferenceProvider}
|
||||||
|
aria-label="Credential modules"
|
||||||
|
placeholder="Add a module"
|
||||||
|
disabled={credentialDisabled}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label="Limited to servers"
|
label="Limited to servers"
|
||||||
help="Optional comma-separated references, for example mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id. Leave blank for every permitted server.">
|
help="Choose an announced server or enter a module-qualified reference. Leave blank for every permitted server.">
|
||||||
<input value={draft.credentialAllowedServerRefs} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialAllowedServerRefs: event.target.value })} />
|
<ReferenceMultiSelect
|
||||||
|
values={commaValues(draft.credentialAllowedServerRefs)}
|
||||||
|
onChange={(values) => setDraft({ ...draft, credentialAllowedServerRefs: values.join(", ") })}
|
||||||
|
provider={serverReferenceProvider}
|
||||||
|
createCustomOption={(value) => customReferenceOption(value, "server")}
|
||||||
|
aria-label="Credential server restrictions"
|
||||||
|
placeholder="Add a server reference"
|
||||||
|
disabled={credentialDisabled}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.credentialIsDefault}
|
checked={draft.credentialIsDefault}
|
||||||
@@ -1463,18 +1532,8 @@ function updateProfilePayload(
|
|||||||
function credentialPayload(draft: ProfileDraft, protocol: MailProfileProtocol, preserveBlankPassword: boolean) {
|
function credentialPayload(draft: ProfileDraft, protocol: MailProfileProtocol, preserveBlankPassword: boolean) {
|
||||||
const username = protocol === "smtp" ? draft.smtpUsername : draft.imapUsername;
|
const username = protocol === "smtp" ? draft.smtpUsername : draft.imapUsername;
|
||||||
const password = protocol === "smtp" ? draft.smtpPassword : draft.imapPassword;
|
const password = protocol === "smtp" ? draft.smtpPassword : draft.imapPassword;
|
||||||
const allowedModules = [...new Set(
|
const allowedModules = commaValues(draft.credentialAllowedModules);
|
||||||
draft.credentialAllowedModules
|
const allowedServerRefs = commaValues(draft.credentialAllowedServerRefs);
|
||||||
.split(",")
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)];
|
|
||||||
const allowedServerRefs = [...new Set(
|
|
||||||
draft.credentialAllowedServerRefs
|
|
||||||
.split(",")
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)];
|
|
||||||
const payload: {
|
const payload: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -1824,6 +1883,15 @@ function stringValue(value: unknown): string {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commaValues(value: string): string[] {
|
||||||
|
return [...new Set(
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(err: unknown): string {
|
function errorMessage(err: unknown): string {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
|||||||
144
webui/src/features/mail/mailReferenceProviders.ts
Normal file
144
webui/src/features/mail/mailReferenceProviders.ts
Normal file
@@ -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<ReferenceOption[]> | null = null;
|
||||||
|
|
||||||
|
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
|
||||||
|
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<MailServerProfile[]> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createElement, lazy } from "react";
|
|||||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||||
|
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/mail-profiles.css";
|
import "./styles/mail-profiles.css";
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ export const mailModule: PlatformWebModule = {
|
|||||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
|
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
|
||||||
|
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
|
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||||
|
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
|
||||||
},
|
},
|
||||||
runtimeUiCapabilities: {
|
runtimeUiCapabilities: {
|
||||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||||
|
|||||||
Reference in New Issue
Block a user