From d75b64a7a1531be5c33ca6b0e389c9e92c6bad0a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 24 Jun 2026 20:01:21 +0200 Subject: [PATCH] Prepare v0.1.0 release --- README.md | 4 + package.json | 32 ++ webui/package.json | 6 + .../features/campaigns/MailSettingsPage.tsx | 242 +-------- .../campaigns/components/FieldValueInput.tsx | 16 +- webui/src/features/campaigns/policyUi.ts | 36 ++ .../privacy/RetentionPolicyManagement.tsx | 495 +----------------- webui/src/index.ts | 1 + webui/tests/policy-ui.test.ts | 42 ++ webui/tsconfig.policy-tests.json | 20 + 10 files changed, 186 insertions(+), 708 deletions(-) create mode 100644 package.json create mode 100644 webui/src/features/campaigns/policyUi.ts create mode 100644 webui/tests/policy-ui.test.ts create mode 100644 webui/tsconfig.policy-tests.json diff --git a/README.md b/README.md index c6c4408..c001cf8 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,7 @@ Frontend package: ``` Platform RBAC and governance rules are documented in `govoplan-core/docs/`. + +## Release packaging + +The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. The campaign WebUI package depends on the tagged files and mail WebUI packages for release builds. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e624355 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/campaign-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/campaign-workspace.css": "./webui/src/styles/campaign-workspace.css" + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "dependencies": { + "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.0", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.0" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.0", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + } +} diff --git a/webui/package.json b/webui/package.json index 7c4bdc9..ac6a8df 100644 --- a/webui/package.json +++ b/webui/package.json @@ -23,5 +23,11 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" + }, + "scripts": { + "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js" + }, + "devDependencies": { + "typescript": "^5.7.2" } } diff --git a/webui/src/features/campaigns/MailSettingsPage.tsx b/webui/src/features/campaigns/MailSettingsPage.tsx index 4956fbd..b9b4480 100644 --- a/webui/src/features/campaigns/MailSettingsPage.tsx +++ b/webui/src/features/campaigns/MailSettingsPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from "react"; -import { MailServerSettingsPanel, formatDateTime as formatPlatformDateTime, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui"; +import { useEffect, useState } from "react"; +import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -8,33 +8,27 @@ import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; -import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import DismissibleAlert from "../../components/DismissibleAlert"; -import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { MailProfilePolicyEditor } from "../mail/MailProfileManagement"; import { - clearMockMailboxMessages, createMailServerProfile, getMailProfilePolicy, - getMockMailboxMessage, listImapFolders, listMailProfileImapFolders, listMailServerProfiles, - listMockMailboxMessages, testImapSettings, testMailProfileImap, testMailProfileSmtp, testSmtpSettings, - updateMockMailboxFailures, type MailProfilePolicy, type MailSecurity, - type MailServerProfile, - type MockMailboxMessage + type MailServerProfile } from "../../api/mail"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { getBool, getNumber, getText } from "./utils/draftEditor"; +import { campaignMailSettingsPolicyState } from "./policyUi"; const securityOptions = ["plain", "tls", "starttls"]; @@ -43,11 +37,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [folderResult, setFolderResult] = useState(null); - const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null); - const [mockMessages, setMockMessages] = useState([]); - const [selectedMockMessage, setSelectedMockMessage] = useState(null); - const [mockError, setMockError] = useState(""); - const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false); + const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null); const [mailProfiles, setMailProfiles] = useState([]); const [policyProfiles, setPolicyProfiles] = useState([]); const [effectiveMailPolicy, setEffectiveMailPolicy] = useState(null); @@ -55,7 +45,6 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const [profileName, setProfileName] = useState(""); const [profileMessage, setProfileMessage] = useState(""); const [profileError, setProfileError] = useState(""); - const mockSandboxSnapshot = useRef | null>(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); @@ -83,11 +72,14 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled; const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false; const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false; - const smtpDisabled = locked || usingMailProfile; - const smtpCredentialDisabled = locked || (usingMailProfile && smtpCredentialsInherited); - const imapServerDisabled = locked || usingMailProfile || !imapEnabled; - const imapCredentialDisabled = locked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited); - const imapDisabled = locked || !effectiveImapAvailable; + const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicy, selectedProfileId, locked }); + const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed; + const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked; + const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked; + const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited); + const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile || !imapEnabled; + const imapCredentialDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited); + const imapDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable; useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]); @@ -115,13 +107,16 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A function selectMailProfile(profileId: string) { if (locked) return; + if (!profileId && !campaignProfilesAllowed) { + setProfileError(mailPolicyState.inlineBlockedMessage); + return; + } patch(["server", "mail_profile_id"], profileId); setProfileMessage(""); setProfileError(""); if (profileId) { patch(["server", "smtp"], {}); patch(["server", "imap"], {}); - setMockSandboxEnabled(false); setSmtpTestResult(null); setImapTestResult(null); setFolderResult(null); @@ -129,7 +124,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } async function saveCurrentSettingsAsProfile() { - if (locked || usingMailProfile) return; + if (locked || usingMailProfile || !campaignProfilesAllowed) return; setProfilesLoading(true); setProfileMessage(""); setProfileError(""); @@ -248,7 +243,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } async function runSmtpTest() { - if (locked) return; + if (locked || inlineMailSettingsBlocked) return; setMailActionState("smtp"); setLocalError(""); try { @@ -303,109 +298,6 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } } - function toggleMockMailSandbox(enabled: boolean) { - if (locked || usingMailProfile) return; - setMockSandboxEnabled(enabled); - - if (enabled) { - mockSandboxSnapshot.current = { - smtp: { ...smtp }, - imap: { ...imap }, - imap_append_sent: { ...imapAppend } - }; - patch(["server", "smtp", "host"], "mock.smtp.local"); - patch(["server", "smtp", "port"], 2525); - patch(["server", "smtp", "security"], "plain"); - patch(["server", "smtp", "username"], "mock"); - patch(["server", "smtp", "password"], "mock"); - patch(["server", "imap", "enabled"], true); - patch(["server", "imap", "host"], "mock.imap.local"); - patch(["server", "imap", "port"], 1143); - patch(["server", "imap", "security"], "plain"); - patch(["server", "imap", "username"], "mock"); - patch(["server", "imap", "password"], "mock"); - patch(["server", "imap", "sent_folder"], "Sent"); - patch(["delivery", "imap_append_sent", "enabled"], true); - patch(["delivery", "imap_append_sent", "folder"], "Sent"); - setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } }); - return; - } - - const snapshot = mockSandboxSnapshot.current; - if (snapshot) { - patch(["server", "smtp"], asRecord(snapshot.smtp)); - patch(["server", "imap"], asRecord(snapshot.imap)); - patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent)); - } - mockSandboxSnapshot.current = null; - setSmtpTestResult(null); - setImapTestResult(null); - } - - async function loadMockMailbox() { - setMailActionState("mock"); - setMockError(""); - try { - const response = await listMockMailboxMessages(settings); - setMockMessages(response.messages); - } catch (err) { - setMockError(err instanceof Error ? err.message : String(err)); - } finally { - setMailActionState(null); - } - } - - async function openMockMessage(id: string) { - setMailActionState("mock"); - setMockError(""); - try { - const response = await getMockMailboxMessage(settings, id); - setSelectedMockMessage(response.message); - } catch (err) { - setMockError(err instanceof Error ? err.message : String(err)); - } finally { - setMailActionState(null); - } - } - - async function clearMockMailbox() { - setMailActionState("mock"); - setMockError(""); - try { - await clearMockMailboxMessages(settings); - setMockMessages([]); - setSelectedMockMessage(null); - } catch (err) { - setMockError(err instanceof Error ? err.message : String(err)); - } finally { - setMailActionState(null); - } - } - - async function failNextMockSmtp() { - setMailActionState("mock"); - setMockError(""); - try { - await updateMockMailboxFailures(settings, { fail_next_smtp: true }); - } catch (err) { - setMockError(err instanceof Error ? err.message : String(err)); - } finally { - setMailActionState(null); - } - } - - async function failNextMockImap() { - setMailActionState("mock"); - setMockError(""); - try { - await updateMockMailboxFailures(settings, { fail_next_imap: true }); - } catch (err) { - setMockError(err instanceof Error ? err.message : String(err)); - } finally { - setMailActionState(null); - } - } - return (
@@ -415,7 +307,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
- +
@@ -435,8 +327,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A ownerGroupId={data.campaign?.owner_group_id} canWrite={!locked} locked={locked} - title="Campaign mail profile policy" - description="Campaign-level allow-list and wildcard limits applied after system, tenant and owner policies." + title="Campaign-local mail policy" + description="Campaign-local mail limits applied after system, tenant and owner policies." onSaved={refreshMailProfiles} /> @@ -445,21 +337,22 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A actions={
- +
} >
- setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} mail profile` : "Campaign mail profile"} /> + setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} />
+ {!campaignProfilesAllowed &&

Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.

} {selectedProfile && (

Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.

)} @@ -492,12 +385,11 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A onImapEnabledChange={toggleImap} smtpDisabled={smtpDisabled} smtpCredentialDisabled={smtpCredentialDisabled} - smtpActionDisabled={locked} - imapToggleDisabled={locked || usingMailProfile} + smtpActionDisabled={locked || inlineMailSettingsBlocked} + imapToggleDisabled={locked || usingMailProfile || inlineMailSettingsBlocked} imapServerDisabled={imapServerDisabled} imapCredentialDisabled={imapCredentialDisabled} imapActionDisabled={imapDisabled} - mockToggle={{ checked: mockSandboxEnabled, disabled: locked || usingMailProfile, onChange: toggleMockMailSandbox }} append={{ enabled: getBool(imapAppend, "enabled"), folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")), @@ -521,88 +413,12 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A /> - -
-
-

Captured messages

-

Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.

-
-
- - - - -
-
- {mockError && {mockError}} - message.id} - emptyText="No mock messages captured yet." - className="data-table compact-table" - /> - -
-
- +
- {selectedMockMessage && ( - setSelectedMockMessage(null)} - /> - )} ); } - - -function mockMessageMetaItems(message: MockMailboxMessage) { - return [ - { label: "From", value: message.from_header || message.envelope_from || "—" }, - { label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" }, - { label: "Kind", value: message.kind || "—" }, - { label: "Folder", value: message.folder || "—" }, - { label: "Message-ID", value: message.message_id || "—" }, - { label: "Size", value: `${message.size_bytes || 0} bytes` } - ]; -} - -function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] { - return (message.attachments ?? []).map((attachment, index) => ({ - filename: attachment.filename || `Attachment ${index + 1}`, - contentType: attachment.content_type || undefined, - sizeBytes: attachment.size_bytes ?? undefined - })); -} - -function formatMockDate(value: string): string { - return formatPlatformDateTime(value); -} - - -function mockMailboxColumns(openMockMessage: (id: string) => Promise): DataGridColumn[] { - return [ - { id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: [{ value: "SMTP", label: "SMTP" }, { value: "IMAP", label: "IMAP" }] }, render: (message) => {message.kind === "imap_append" ? "IMAP" : "SMTP"}, value: (message) => message.kind === "imap_append" ? "IMAP" : "SMTP" }, - { id: "received", header: "Received", width: 180, sortable: true, filterable: true, filterType: "date", value: (message) => formatMockDate(message.created_at), sortValue: (message) => message.created_at ?? "" }, - { id: "subject", header: "Subject", width: "minmax(240px, 1fr)", sortable: true, filterable: true, value: (message) => message.subject || "—" }, - { id: "envelope", header: "Envelope", width: 300, resizable: true, filterable: true, value: (message) => `${message.envelope_from || message.folder || "—"} → ${(message.envelope_recipients || []).join(", ") || message.folder || "—"}` }, - { id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, filterType: "integer", value: (message) => message.attachment_count ?? 0 }, - { id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => } - ]; -} diff --git a/webui/src/features/campaigns/components/FieldValueInput.tsx b/webui/src/features/campaigns/components/FieldValueInput.tsx index cd42a00..0170c16 100644 --- a/webui/src/features/campaigns/components/FieldValueInput.tsx +++ b/webui/src/features/campaigns/components/FieldValueInput.tsx @@ -1,3 +1,4 @@ +import { PasswordField } from "@govoplan/core-webui"; import { inputValueToFieldValue, normalizeFieldType, valueToInputText } from "../utils/fieldDefinitions"; export type FieldValueInputProps = { @@ -11,6 +12,19 @@ export type FieldValueInputProps = { export default function FieldValueInput({ fieldType = "string", value, disabled = false, placeholder, className, onChange }: FieldValueInputProps) { const normalizedType = normalizeFieldType(fieldType); + if (normalizedType === "password") { + return ( + onChange(inputValueToFieldValue(normalizedType, nextValue))} + /> + ); + } + const inputType = inputTypeForField(normalizedType); const step = normalizedType === "integer" ? "1" : normalizedType === "double" ? "any" : undefined; @@ -22,7 +36,6 @@ export default function FieldValueInput({ fieldType = "string", value, disabled value={valueToInputText(value, normalizedType)} disabled={disabled} placeholder={placeholder} - autoComplete={normalizedType === "password" ? "new-password" : undefined} onChange={(event) => onChange(inputValueToFieldValue(normalizedType, event.target.value))} /> ); @@ -31,6 +44,5 @@ export default function FieldValueInput({ fieldType = "string", value, disabled function inputTypeForField(fieldType: string): string { if (fieldType === "integer" || fieldType === "double") return "number"; if (fieldType === "date") return "date"; - if (fieldType === "password") return "password"; return "text"; } diff --git a/webui/src/features/campaigns/policyUi.ts b/webui/src/features/campaigns/policyUi.ts new file mode 100644 index 0000000..8ef969a --- /dev/null +++ b/webui/src/features/campaigns/policyUi.ts @@ -0,0 +1,36 @@ +export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "Inline SMTP/IMAP settings are blocked by the effective mail policy. Select an allowed reusable profile."; + +export type CampaignMailPolicy = { + allow_campaign_profiles?: boolean | null; +}; + +export type CampaignMailSettingsPolicyState = { + campaignProfilesAllowed: boolean; + usingMailProfile: boolean; + inlineMailSettingsBlocked: boolean; + inlineOptionDisabled: boolean; + canSelectInlineSettings: boolean; + inlineBlockedMessage: string; +}; + +export function campaignMailSettingsPolicyState({ + effectivePolicy, + selectedProfileId, + locked = false +}: { + effectivePolicy: CampaignMailPolicy | null | undefined; + selectedProfileId: string | null | undefined; + locked?: boolean; +}): CampaignMailSettingsPolicyState { + const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true; + const usingMailProfile = Boolean(selectedProfileId); + const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile; + return { + campaignProfilesAllowed, + usingMailProfile, + inlineMailSettingsBlocked, + inlineOptionDisabled: !campaignProfilesAllowed, + canSelectInlineSettings: !locked && campaignProfilesAllowed, + inlineBlockedMessage: INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE + }; +} diff --git a/webui/src/features/privacy/RetentionPolicyManagement.tsx b/webui/src/features/privacy/RetentionPolicyManagement.tsx index 6673e0e..e88854e 100644 --- a/webui/src/features/privacy/RetentionPolicyManagement.tsx +++ b/webui/src/features/privacy/RetentionPolicyManagement.tsx @@ -1,493 +1,2 @@ -import { useEffect, useMemo, useState } from "react"; -import type { ApiSettings } from "../../types"; -import { - getPrivacyRetentionPolicy, - updatePrivacyRetentionPolicy, - type PrivacyRetentionLimitPermissionPatch, - type PrivacyRetentionLimitPermissions, - type PrivacyRetentionPolicy, - type PrivacyRetentionPolicyFieldKey, - type PrivacyRetentionPolicyPatch, - type PrivacyRetentionPolicyScope -} from "../../api/admin"; -import Button from "../../components/Button"; -import Card from "../../components/Card"; -import DismissibleAlert from "../../components/DismissibleAlert"; -import FormField from "../../components/FormField"; -import ToggleSwitch from "../../components/ToggleSwitch"; - -export type RetentionPolicyTargetOption = { - id: string; - label: string; - secondary?: string | null; -}; - -type RetentionPolicyScopeManagerProps = { - settings: ApiSettings; - scopeType: PrivacyRetentionPolicyScope; - scopeId?: string | null; - targetOptions?: RetentionPolicyTargetOption[]; - targetLabel?: string; - title?: string; - description?: string; - canWrite: boolean; - locked?: boolean; -}; - -type RetentionPolicyEditorProps = { - settings: ApiSettings; - scopeType: PrivacyRetentionPolicyScope; - scopeId?: string | null; - title?: string; - description?: string; - canWrite: boolean; - locked?: boolean; -}; - -type DayKey = - | "raw_campaign_json_retention_days" - | "generated_eml_retention_days" - | "stored_report_detail_retention_days" - | "mock_mailbox_retention_days" - | "audit_detail_retention_days"; - -type FieldDefinition = { - key: PrivacyRetentionPolicyFieldKey; - label: string; - kind: "raw-json" | "days" | "audit"; - systemOnly?: boolean; -}; - -type RawJsonValue = "inherit" | "keep" | "disable"; -type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"]; - -const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = { - store_raw_campaign_json: true, - raw_campaign_json_retention_days: true, - generated_eml_retention_days: true, - stored_report_detail_retention_days: true, - mock_mailbox_retention_days: true, - audit_detail_retention_days: true, - audit_detail_level: true -}; - -const defaultPrivacyPolicy: PrivacyRetentionPolicy = { - store_raw_campaign_json: true, - raw_campaign_json_retention_days: null, - generated_eml_retention_days: null, - stored_report_detail_retention_days: null, - mock_mailbox_retention_days: null, - audit_detail_retention_days: null, - audit_detail_level: "full", - allow_lower_level_limits: defaultAllowLowerLevelLimits -}; - -const fieldDefinitions: FieldDefinition[] = [ - { key: "store_raw_campaign_json", label: "Raw campaign JSON", kind: "raw-json" }, - { key: "raw_campaign_json_retention_days", label: "Raw campaign JSON days", kind: "days" }, - { key: "generated_eml_retention_days", label: "Generated EML days", kind: "days" }, - { key: "stored_report_detail_retention_days", label: "Stored report detail days", kind: "days" }, - { key: "mock_mailbox_retention_days", label: "Mock mailbox days", kind: "days", systemOnly: true }, - { key: "audit_detail_retention_days", label: "Audit detail days", kind: "days" }, - { key: "audit_detail_level", label: "Audit detail level", kind: "audit" } -]; - -export function RetentionPolicyScopeManager({ - settings, - scopeType, - scopeId = null, - targetOptions = [], - targetLabel = "Target", - title = "Retention policy", - description, - canWrite, - locked = false -}: RetentionPolicyScopeManagerProps) { - const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || ""); - const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; - const activeScopeId = scopeId || selectedTargetId || null; - const defaultDescription = scopeType === "system" - ? "System retention defaults and the fields lower levels may limit further." - : "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed."; - - useEffect(() => { - if (scopeId) { - setSelectedTargetId(scopeId); - return; - } - if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) { - setSelectedTargetId(targetOptions[0].id); - } - }, [scopeId, selectedTargetId, targetOptions]); - - return ( -
- {targetOptions.length > 0 && !scopeId && ( - -
- - - -
-
- )} - -
- ); -} - -export function RetentionPolicyEditor({ - settings, - scopeType, - scopeId = null, - title = "Retention policy", - description, - canWrite, - locked = false -}: RetentionPolicyEditorProps) { - const [policy, setPolicy] = useState({}); - const [effectivePolicy, setEffectivePolicy] = useState(null); - const [parentPolicy, setParentPolicy] = useState(null); - const [loading, setLoading] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); - - const isSystem = scopeType === "system"; - const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; - const scopeReady = !requiresTarget || Boolean(scopeId); - const disabled = locked || loading || busy || !canWrite || !scopeReady; - const showAllowColumn = scopeType !== "campaign"; - const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null; - const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } }); - const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]); - - useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]); - - async function loadPolicy() { - setError(""); - setSuccess(""); - if (!scopeReady) { - setPolicy({}); - setEffectivePolicy(null); - setParentPolicy(null); - return; - } - setLoading(true); - try { - const response = await getPrivacyRetentionPolicy(settings, scopeType, scopeId); - setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy)); - setEffectivePolicy(normalizeFullPolicy(response.effective_policy)); - setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null); - } catch (err) { - setPolicy({}); - setEffectivePolicy(null); - setParentPolicy(null); - setError(errorMessage(err)); - } finally { - setLoading(false); - } - } - - async function savePolicy() { - if (!scopeReady) return; - setBusy(true); - setError(""); - setSuccess(""); - try { - const payload = isSystem ? normalizeFullPolicy(policy) : normalizePatchForSave(policy, activeParentPolicy, scopeType); - const response = await updatePrivacyRetentionPolicy(settings, scopeType, payload, scopeId); - setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy)); - setEffectivePolicy(normalizeFullPolicy(response.effective_policy)); - setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null); - setSuccess("Retention policy saved."); - } catch (err) { - setError(errorMessage(err)); - } finally { - setBusy(false); - } - } - - function patchPolicy(patch: PrivacyRetentionPolicyPatch) { - const next = { ...policy, ...patch }; - if (policy.allow_lower_level_limits || patch.allow_lower_level_limits) { - next.allow_lower_level_limits = { ...(policy.allow_lower_level_limits ?? {}), ...(patch.allow_lower_level_limits ?? {}) }; - } - setPolicy(isSystem ? normalizeFullPolicy(next) : normalizePatch(next)); - } - - function setRawJson(value: RawJsonValue | "store" | "disabled") { - if (isSystem) { - patchPolicy({ store_raw_campaign_json: value === "store" }); - return; - } - patchPolicy({ store_raw_campaign_json: value === "inherit" ? undefined : value === "keep" }); - } - - function setRetentionDays(key: DayKey, value: string) { - const trimmed = value.trim(); - patchPolicy({ [key]: trimmed === "" ? (isSystem ? null : undefined) : Math.max(0, Number(trimmed) || 0) }); - } - - function setAuditDetail(value: AuditDetailValue) { - patchPolicy({ audit_detail_level: value === "inherit" ? undefined : value }); - } - - function setAllowLowerLevelLimit(key: PrivacyRetentionPolicyFieldKey, allowed: boolean) { - patchPolicy({ allow_lower_level_limits: { [key]: allowed } }); - } - - function parentAllows(key: PrivacyRetentionPolicyFieldKey): boolean { - return !activeParentPolicy || activeParentPolicy.allow_lower_level_limits[key] !== false; - } - - function localAllowsLower(key: PrivacyRetentionPolicyFieldKey): boolean { - if (isSystem) return activeFullPolicy.allow_lower_level_limits[key] !== false; - const localValue = policy.allow_lower_level_limits?.[key]; - if (localValue !== undefined) return localValue && parentAllows(key); - return parentAllows(key); - } - - const localPolicySummary = useMemo(() => isSystem ? "System baseline" : localPolicyCount(policy), [isSystem, policy]); - const defaultDescription = isSystem - ? "Set concrete system retention values. Blank day fields mean unlimited retention." - : "Blank values inherit. Numeric values may only shorten the inherited retention period; audit detail may only become more restrictive."; - - return ( - - - - - } - > -
-

{description ?? defaultDescription}

- {!isSystem &&

Mock mailbox retention is currently managed at system level because mock mailbox records do not carry tenant or campaign ownership metadata.

} - {error && {error}} - {success && {success}} - -
-
-

{isSystem ? "System policy" : "Local policy"}

- {localPolicySummary} -
-
-
- Field - Value - {showAllowColumn && Lower levels} -
- {visibleFields.map((field) => { - const fieldLocked = !parentAllows(field.key); - const fieldDisabled = disabled || fieldLocked; - return ( -
-
- {field.label} - {fieldLocked && Locked by parent policy} - {field.systemOnly && System-level cleanup scope} -
-
{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}
- {showAllowColumn && ( - setAllowLowerLevelLimit(field.key, checked)} - label="Allow limiting" - /> - )} -
- ); - })} -
-
- - {effectivePolicy && ( -
-

Effective policy

-
- - - - - - - - {showAllowColumn && } -
-
- )} -
-
- ); -} - -function renderFieldControl( - field: FieldDefinition, - fullPolicy: PrivacyRetentionPolicy, - patchPolicy: PrivacyRetentionPolicyPatch, - isSystem: boolean, - disabled: boolean, - setRawJson: (value: RawJsonValue | "store" | "disabled") => void, - setRetentionDays: (key: DayKey, value: string) => void, - setAuditDetail: (value: AuditDetailValue) => void, - parentPolicy: PrivacyRetentionPolicy | null -) { - if (field.kind === "raw-json") { - if (isSystem) { - return ; - } - return ; - } - if (field.kind === "audit") { - return ; - } - return ( - setRetentionDays(field.key as DayKey, value)} - /> - ); -} - -function RawJsonSystemSelect({ value, disabled, onChange }: { value: "store" | "disabled"; disabled: boolean; onChange: (value: "store" | "disabled") => void }) { - return ( - - ); -} - -function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: { value: RawJsonValue; parentStoresRawJson: boolean; disabled: boolean; onChange: (value: RawJsonValue) => void }) { - return ( - - ); -} - -function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) { - return onChange(event.target.value)} />; -} - -function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) { - return ( - - ); -} - -function PolicyValue({ label, value }: { label: string; value: string }) { - return
{label}{value}
; -} - -function rawJsonValue(value: boolean | undefined): RawJsonValue { - if (value === undefined) return "inherit"; - return value ? "keep" : "disable"; -} - -function auditDetailValue(value: PrivacyRetentionPolicyPatch["audit_detail_level"]): AuditDetailValue { - return value ?? "inherit"; -} - -function daysLabel(value?: number | null): string { - return value === null || value === undefined ? "Unlimited" : `${value} day${value === 1 ? "" : "s"}`; -} - -function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]): string { - if (value === "full") return "Full"; - if (value === "redacted") return "Redacted"; - return "Minimal"; -} - -function allowLowerSummary(allow: PrivacyRetentionLimitPermissions): string { - const count = fieldDefinitions.filter((field) => allow[field.key] !== false).length; - if (count === fieldDefinitions.length) return "Allowed"; - if (count === 0) return "Locked"; - return `${count}/${fieldDefinitions.length} allowed`; -} - -function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string { - let count = 0; - for (const field of fieldDefinitions) { - if (field.systemOnly) continue; - if (policy[field.key as keyof PrivacyRetentionPolicyPatch] !== undefined && policy[field.key as keyof PrivacyRetentionPolicyPatch] !== null) count += 1; - } - const allowCount = Object.keys(policy.allow_lower_level_limits ?? {}).length; - count += allowCount; - return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`; -} - -function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions { - return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) }; -} - -function normalizeFullPolicy(policy: PrivacyRetentionPolicyPatch | Partial | null | undefined): PrivacyRetentionPolicy { - return { - ...defaultPrivacyPolicy, - ...(policy ?? {}), - raw_campaign_json_retention_days: policy?.raw_campaign_json_retention_days ?? null, - generated_eml_retention_days: policy?.generated_eml_retention_days ?? null, - stored_report_detail_retention_days: policy?.stored_report_detail_retention_days ?? null, - mock_mailbox_retention_days: policy?.mock_mailbox_retention_days ?? null, - audit_detail_retention_days: policy?.audit_detail_retention_days ?? null, - allow_lower_level_limits: normalizeAllowLimits(policy?.allow_lower_level_limits) - }; -} - -function normalizePatch(policy: PrivacyRetentionPolicyPatch | null | undefined): PrivacyRetentionPolicyPatch { - const normalized: PrivacyRetentionPolicyPatch = {}; - if (!policy) return normalized; - if (policy.store_raw_campaign_json !== undefined) normalized.store_raw_campaign_json = policy.store_raw_campaign_json; - for (const field of fieldDefinitions) { - if (field.kind !== "days" || field.systemOnly) continue; - const value = policy[field.key as DayKey]; - if (value !== undefined && value !== null) normalized[field.key as DayKey] = Math.max(0, Number(value) || 0); - } - if (policy.audit_detail_level) normalized.audit_detail_level = policy.audit_detail_level; - if (policy.allow_lower_level_limits) normalized.allow_lower_level_limits = { ...policy.allow_lower_level_limits }; - return normalized; -} - -function normalizePatchForSave(policy: PrivacyRetentionPolicyPatch, parentPolicy: PrivacyRetentionPolicy | null, scopeType: PrivacyRetentionPolicyScope): PrivacyRetentionPolicyPatch { - const normalized = normalizePatch(policy); - const allowPatch = { ...(normalized.allow_lower_level_limits ?? {}) }; - for (const field of fieldDefinitions) { - if (field.systemOnly || scopeType === "campaign") delete allowPatch[field.key]; - if (field.systemOnly) delete normalized[field.key as keyof PrivacyRetentionPolicyPatch]; - if (parentPolicy && parentPolicy.allow_lower_level_limits[field.key] === false) { - delete normalized[field.key as keyof PrivacyRetentionPolicyPatch]; - delete allowPatch[field.key]; - } - } - if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch; - else delete normalized.allow_lower_level_limits; - return normalized; -} - -function errorMessage(err: unknown): string { - if (err instanceof Error) return err.message; - return "Request failed"; -} +export { RetentionPolicyEditor, RetentionPolicyScopeManager } from "@govoplan/core-webui"; +export type { RetentionPolicyTargetOption } from "@govoplan/core-webui"; diff --git a/webui/src/index.ts b/webui/src/index.ts index 15e69d9..ff96e53 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -2,6 +2,7 @@ import "./styles/campaign-workspace.css"; export { default } from "./module"; export * from "./module"; export * from "./api/campaigns"; +export * from "./features/campaigns/policyUi"; export { default as AddressBookPage } from "./features/addressbook/AddressBookPage"; export { default as CampaignListPage } from "./features/campaigns/CampaignListPage"; export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace"; diff --git a/webui/tests/policy-ui.test.ts b/webui/tests/policy-ui.test.ts new file mode 100644 index 0000000..c2f3130 --- /dev/null +++ b/webui/tests/policy-ui.test.ts @@ -0,0 +1,42 @@ +import { campaignMailSettingsPolicyState } from "../src/features/campaigns/policyUi"; + +function assert(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +const blockedInline = campaignMailSettingsPolicyState({ + effectivePolicy: { allow_campaign_profiles: false }, + selectedProfileId: "" +}); + +assert(blockedInline.campaignProfilesAllowed === false, "Blocked policy must not allow campaign-local settings."); +assert(blockedInline.inlineMailSettingsBlocked === true, "Inline SMTP/IMAP must be blocked when no reusable profile is selected."); +assert(blockedInline.inlineOptionDisabled === true, "Inline option must be disabled under a blocking policy."); +assert(blockedInline.canSelectInlineSettings === false, "Blocked inline option must not be selectable."); + +const blockedWithReusableProfile = campaignMailSettingsPolicyState({ + effectivePolicy: { allow_campaign_profiles: false }, + selectedProfileId: "tenant-profile" +}); + +assert(blockedWithReusableProfile.inlineMailSettingsBlocked === false, "Reusable profiles must remain usable when campaign-local settings are blocked."); +assert(blockedWithReusableProfile.inlineOptionDisabled === true, "Inline option remains disabled even while a reusable profile is selected."); + +const allowedInline = campaignMailSettingsPolicyState({ + effectivePolicy: { allow_campaign_profiles: true }, + selectedProfileId: "" +}); + +assert(allowedInline.campaignProfilesAllowed === true, "Allowed policy must allow campaign-local settings."); +assert(allowedInline.inlineMailSettingsBlocked === false, "Inline SMTP/IMAP must be available when campaign-local settings are allowed."); +assert(allowedInline.inlineOptionDisabled === false, "Inline option must be enabled under an allowing policy."); +assert(allowedInline.canSelectInlineSettings === true, "Allowed inline option must be selectable."); + +const lockedInline = campaignMailSettingsPolicyState({ + effectivePolicy: { allow_campaign_profiles: true }, + selectedProfileId: "", + locked: true +}); + +assert(lockedInline.inlineOptionDisabled === false, "Policy does not disable inline settings when the policy allows them."); +assert(lockedInline.canSelectInlineSettings === false, "A locked version still must not allow selecting inline settings."); diff --git a/webui/tsconfig.policy-tests.json b/webui/tsconfig.policy-tests.json new file mode 100644 index 0000000..01ffac4 --- /dev/null +++ b/webui/tsconfig.policy-tests.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": ".policy-test-build", + "rootDir": "." + }, + "include": [ + "tests/policy-ui.test.ts", + "src/features/campaigns/policyUi.ts" + ] +}