Release v0.1.2
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getPrivacyRetentionPolicy,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
|
||||
@@ -63,6 +63,8 @@ type FieldDefinition = {
|
||||
type RawJsonValue = "inherit" | "keep" | "disable";
|
||||
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
|
||||
|
||||
const auditDetailOrder: Record<PrivacyRetentionPolicy["audit_detail_level"], number> = { full: 0, redacted: 1, minimal: 2 };
|
||||
|
||||
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||
store_raw_campaign_json: true,
|
||||
raw_campaign_json_retention_days: true,
|
||||
@@ -107,10 +109,13 @@ export function RetentionPolicyScopeManager({
|
||||
}: RetentionPolicyScopeManagerProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
|
||||
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
|
||||
const targetSelectionRequired = requiresTarget && !scopeId;
|
||||
const hasSelectableTarget = targetOptions.length > 0;
|
||||
const activeScopeId = scopeId || selectedTargetId || null;
|
||||
const defaultDescription = scopeType === "system"
|
||||
? "System retention defaults and the fields lower levels may limit further."
|
||||
? "System retention defaults and the fields lower levels may override."
|
||||
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
|
||||
const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`;
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeId) {
|
||||
@@ -119,35 +124,46 @@ export function RetentionPolicyScopeManager({
|
||||
}
|
||||
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
|
||||
setSelectedTargetId(targetOptions[0].id);
|
||||
return;
|
||||
}
|
||||
if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId("");
|
||||
}, [scopeId, selectedTargetId, targetOptions]);
|
||||
|
||||
return (
|
||||
<div className="retention-policy-manager">
|
||||
{targetOptions.length > 0 && !scopeId && (
|
||||
{targetSelectionRequired && (
|
||||
<Card title={`${targetLabel} scope`}>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
<select value={selectedTargetId} disabled={!hasSelectableTarget} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
||||
{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}
|
||||
/>
|
||||
{(!requiresTarget || Boolean(activeScopeId)) && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pluralizeLabel(label: string): string {
|
||||
if (label.endsWith("s")) return label;
|
||||
if (label.endsWith("y")) return `${label.slice(0, -1)}ies`;
|
||||
return `${label}s`;
|
||||
}
|
||||
|
||||
export function RetentionPolicyEditor({
|
||||
settings,
|
||||
scopeType,
|
||||
@@ -171,6 +187,7 @@ export function RetentionPolicyEditor({
|
||||
const scopeReady = !requiresTarget || Boolean(scopeId);
|
||||
const disabled = locked || loading || busy || !canWrite || !scopeReady;
|
||||
const showAllowColumn = scopeType !== "campaign";
|
||||
const showEffectiveColumn = !isSystem && scopeReady;
|
||||
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]);
|
||||
@@ -291,29 +308,37 @@ export function RetentionPolicyEditor({
|
||||
<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">
|
||||
<div className={`retention-policy-table policy-table${showEffectiveColumn ? " with-effective-column" : ""}${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row policy-row retention-policy-row-header policy-row-header">
|
||||
<span>Field</span>
|
||||
<span>Value</span>
|
||||
<span>{isSystem ? "Value" : "Local setting"}</span>
|
||||
{showEffectiveColumn && <span>Effective policy</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">
|
||||
<div className="retention-policy-row policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label 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>
|
||||
<div className="policy-control">{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
{showEffectiveColumn && (
|
||||
<div className="retention-policy-effective-cell policy-effective-cell">
|
||||
<FieldLabel className="retention-policy-effective-value policy-effective-value" help={retentionPolicyPathHelp(field, effectivePolicySources, scopeType)}>
|
||||
{retentionFieldValue(field, effectivePolicy, loading)}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
)}
|
||||
{showAllowColumn && (
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="Allow limiting"
|
||||
label="Allow override"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -322,28 +347,79 @@ export function RetentionPolicyEditor({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{effectivePolicy && (
|
||||
<EffectivePolicyBlock
|
||||
title="Effective policy"
|
||||
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
|
||||
className="retention-policy-section retention-policy-effective"
|
||||
gridClassName="retention-policy-effective-grid"
|
||||
>
|
||||
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</EffectivePolicyBlock>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type PolicySourceItem = {
|
||||
label: string;
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function retentionPolicyPathHelp(field: FieldDefinition, sources: PolicySourcePathItem[], scopeType: PrivacyRetentionPolicyScope): ReactNode {
|
||||
const items = normalizePolicySourceItems(sources.length > 0 ? sources : retentionPolicySourcePath(scopeType));
|
||||
return <PolicyPathHelp lines={retentionPolicyPathLines(field, items)} />;
|
||||
}
|
||||
|
||||
function PolicyPathHelp({ lines }: { lines: string[] }) {
|
||||
return (
|
||||
<span className="policy-path-help">
|
||||
{lines.map((line, index) => <span className="policy-path-help-line" key={`${line}-${index}`}>{line}</span>)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceItem[]): string[] {
|
||||
if (items.length === 0) return ["System: Default"];
|
||||
const lines: string[] = [];
|
||||
for (const [index, item] of items.entries()) {
|
||||
const sourcePolicy = asRecord(item.policy);
|
||||
const value = retentionSourceValue(field, sourcePolicy, index === 0);
|
||||
const locked = asRecord(sourcePolicy.allow_lower_level_limits)[field.key] === false;
|
||||
lines.push(`${policyPathPrefix(index)}${item.label}: ${locked ? `${value} without override` : value}`);
|
||||
if (locked) break;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function retentionSourceValue(field: FieldDefinition, sourcePolicy: Record<string, unknown>, isSystem: boolean): string {
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "Default" : "Inherit";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "Inherit";
|
||||
if (field.kind === "raw-json") {
|
||||
const value = sourcePolicy.store_raw_campaign_json;
|
||||
return value === false ? "Disabled" : value === true ? "Stored" : "Inherit";
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
const value = sourcePolicy.audit_detail_level;
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "Inherit";
|
||||
}
|
||||
const value = sourcePolicy[field.key];
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "Inherit";
|
||||
}
|
||||
|
||||
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
|
||||
return items.map((item) => {
|
||||
if (typeof item === "string") return { label: item };
|
||||
return { label: item.label, policy: item.policy };
|
||||
}).filter((item) => item.label.trim());
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function policyPathPrefix(index: number): string {
|
||||
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
|
||||
}
|
||||
|
||||
function retentionFieldValue(field: FieldDefinition, policy: PrivacyRetentionPolicy | null, loading: boolean): string {
|
||||
if (!policy) return loading ? "Loading..." : "Unavailable";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "Stored" : "Disabled";
|
||||
if (field.kind === "audit") return auditDetailLabel(policy.audit_detail_level);
|
||||
return daysLabel(policy[field.key as DayKey]);
|
||||
}
|
||||
|
||||
function renderFieldControl(
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
@@ -362,13 +438,15 @@ function renderFieldControl(
|
||||
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 <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} parentValue={isSystem ? null : parentPolicy?.audit_detail_level ?? null} disabled={disabled} onChange={setAuditDetail} />;
|
||||
}
|
||||
const parentDayLimit = !isSystem && parentPolicy ? parentPolicy[field.key as DayKey] : null;
|
||||
return (
|
||||
<RetentionDaysField
|
||||
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
|
||||
disabled={disabled}
|
||||
placeholder={isSystem ? "Unlimited" : "Inherit"}
|
||||
max={parentDayLimit}
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
|
||||
/>
|
||||
);
|
||||
@@ -393,17 +471,32 @@ function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }:
|
||||
);
|
||||
}
|
||||
|
||||
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 RetentionDaysField({ value, disabled, placeholder, max, onChange }: { value?: number | null; disabled: boolean; placeholder: string; max?: number | null; onChange: (value: string) => void }) {
|
||||
function handleChange(nextValue: string) {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed === "" || max === null || max === undefined) {
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
onChange(Number.isFinite(parsed) && parsed > max ? String(max) : nextValue);
|
||||
}
|
||||
|
||||
return <input type="number" min={0} max={max ?? undefined} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => handleChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
const minimumOrder = parentValue ? auditDetailOrder[parentValue] : 0;
|
||||
function optionDisabled(option: PrivacyRetentionPolicy["audit_detail_level"]): boolean {
|
||||
return auditDetailOrder[option] < minimumOrder;
|
||||
}
|
||||
|
||||
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>
|
||||
<option value="full" disabled={optionDisabled("full")}>Full</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>Redacted</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>Minimal</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -427,13 +520,6 @@ function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]):
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user