Prepare v0.1.0 release
This commit is contained in:
@@ -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.
|
||||
|
||||
32
package.json
Normal file
32
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
|
||||
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
|
||||
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
||||
const [mockError, setMockError] = useState("");
|
||||
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(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<Record<string, unknown> | 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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -415,7 +307,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Profile">
|
||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||
<option value="">Inline SMTP/IMAP settings</option>
|
||||
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>Inline SMTP/IMAP settings</option>
|
||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="New profile name">
|
||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} mail profile` : "Campaign mail profile"} />
|
||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} />
|
||||
</FormField>
|
||||
</div>
|
||||
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>}
|
||||
{selectedProfile && (
|
||||
<p className="muted small-note">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"}.</p>
|
||||
)}
|
||||
@@ -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
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Mock mail sandbox">
|
||||
<div className="subsection-heading split">
|
||||
<div>
|
||||
<h3>Captured messages</h3>
|
||||
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
|
||||
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
|
||||
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
|
||||
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mockError && <DismissibleAlert tone="danger" resetKey={mockError} floating>{mockError}</DismissibleAlert>}
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-server-mock-mailbox`}
|
||||
rows={mockMessages}
|
||||
columns={mockMailboxColumns(openMockMessage)}
|
||||
getRowKey={(message) => message.id}
|
||||
emptyText="No mock messages captured yet."
|
||||
className="data-table compact-table"
|
||||
/>
|
||||
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || inlineMailSettingsBlocked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
{selectedMockMessage && (
|
||||
<MessagePreviewOverlay
|
||||
title="Captured mock mail"
|
||||
subject={selectedMockMessage.subject || "Mock message"}
|
||||
bodyMode="text"
|
||||
text={selectedMockMessage.body_preview || ""}
|
||||
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
|
||||
recipientNote={selectedMockMessage.created_at ? formatPlatformDateTime(selectedMockMessage.created_at) : undefined}
|
||||
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
||||
attachments={mockMessageAttachments(selectedMockMessage)}
|
||||
raw={selectedMockMessage.raw_eml}
|
||||
rawLabel="Raw MIME"
|
||||
onClose={() => setSelectedMockMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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<void>): DataGridColumn<MockMailboxMessage>[] {
|
||||
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) => <span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span>, 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) => <Button onClick={() => openMockMessage(message.id)}>Open</Button> }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<PasswordField
|
||||
inputClassName={className}
|
||||
value={valueToInputText(value, normalizedType)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(nextValue) => 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";
|
||||
}
|
||||
|
||||
36
webui/src/features/campaigns/policyUi.ts
Normal file
36
webui/src/features/campaigns/policyUi.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="retention-policy-manager">
|
||||
{targetOptions.length > 0 && !scopeId && (
|
||||
<Card title={`${targetLabel} scope`}>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RetentionPolicyEditor({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId = null,
|
||||
title = "Retention policy",
|
||||
description,
|
||||
canWrite,
|
||||
locked = false
|
||||
}: RetentionPolicyEditorProps) {
|
||||
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
|
||||
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(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 (
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="retention-policy-editor">
|
||||
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
|
||||
{!isSystem && <p className="muted small-note retention-policy-description">Mock mailbox retention is currently managed at system level because mock mailbox records do not carry tenant or campaign ownership metadata.</p>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<section className="retention-policy-section">
|
||||
<div className="subsection-heading split">
|
||||
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
|
||||
<span className="muted small-note">{localPolicySummary}</span>
|
||||
</div>
|
||||
<div className={`retention-policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row retention-policy-row-header">
|
||||
<span>Field</span>
|
||||
<span>Value</span>
|
||||
{showAllowColumn && <span>Lower levels</span>}
|
||||
</div>
|
||||
{visibleFields.map((field) => {
|
||||
const fieldLocked = !parentAllows(field.key);
|
||||
const fieldDisabled = disabled || fieldLocked;
|
||||
return (
|
||||
<div className="retention-policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label">
|
||||
<strong>{field.label}</strong>
|
||||
{fieldLocked && <small>Locked by parent policy</small>}
|
||||
{field.systemOnly && <small>System-level cleanup scope</small>}
|
||||
</div>
|
||||
<div>{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
{showAllowColumn && (
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="Allow limiting"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{effectivePolicy && (
|
||||
<section className="retention-policy-section retention-policy-effective">
|
||||
<h3>Effective policy</h3>
|
||||
<div className="retention-policy-effective-grid">
|
||||
<PolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<PolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<PolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<PolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<PolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<PolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<PolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <PolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 <RawJsonSystemSelect value={fullPolicy.store_raw_campaign_json ? "store" : "disabled"} disabled={disabled} onChange={setRawJson} />;
|
||||
}
|
||||
return <RawJsonScopedSelect value={rawJsonValue(patchPolicy.store_raw_campaign_json)} parentStoresRawJson={parentPolicy?.store_raw_campaign_json ?? true} disabled={disabled} onChange={setRawJson} />;
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} disabled={disabled} onChange={setAuditDetail} />;
|
||||
}
|
||||
return (
|
||||
<RetentionDaysField
|
||||
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
|
||||
disabled={disabled}
|
||||
placeholder={isSystem ? "Unlimited" : "Inherit"}
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RawJsonSystemSelect({ value, disabled, onChange }: { value: "store" | "disabled"; disabled: boolean; onChange: (value: "store" | "disabled") => void }) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as "store" | "disabled")}>
|
||||
<option value="store">Store</option>
|
||||
<option value="disabled">Disable storage</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: { value: RawJsonValue; parentStoresRawJson: boolean; disabled: boolean; onChange: (value: RawJsonValue) => void }) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as RawJsonValue)}>
|
||||
<option value="inherit">Inherit</option>
|
||||
<option value="keep" disabled={!parentStoresRawJson}>Store if parent allows</option>
|
||||
<option value="disable">Disable storage</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) {
|
||||
return <input type="number" min={0} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
|
||||
{includeInherit && <option value="inherit">Inherit</option>}
|
||||
<option value="full">Full</option>
|
||||
<option value="redacted">Redacted</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyValue({ label, value }: { label: string; value: string }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
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<PrivacyRetentionPolicy> | 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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
42
webui/tests/policy-ui.test.ts
Normal file
42
webui/tests/policy-ui.test.ts
Normal file
@@ -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.");
|
||||
20
webui/tsconfig.policy-tests.json
Normal file
20
webui/tsconfig.policy-tests.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user