initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

View File

@@ -0,0 +1,493 @@
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";
}