Prepare v0.1.0 release dependencies

This commit is contained in:
2026-06-24 20:02:49 +02:00
parent ce30b4d054
commit 9c2b84a64a
27 changed files with 900 additions and 48 deletions

View File

@@ -0,0 +1,49 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./app": {
"types": "./src/app.ts",
"import": "./src/app.ts"
}
},
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"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",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.0"
},
"devDependencies": {
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependencies": {
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}

View File

@@ -147,12 +147,21 @@ export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type SystemSettingsItem = {

View File

@@ -3,6 +3,17 @@ import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings";
const SESSION_STORAGE_KEY = "multimailer.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
const RECENT_SAFE_REQUEST_TTL_MS = 750;
const MAX_RECENT_SAFE_REQUESTS = 100;
type RecentSafeRequest = {
value: unknown;
expiresAt: number;
};
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
const recentSafeRequests = new Map<string, RecentSafeRequest>();
let safeRequestGeneration = 0;
export class ApiError extends Error {
readonly status: number;
@@ -91,6 +102,63 @@ function isUnsafeMethod(method?: string): boolean {
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
}
function canReuseSafeRequest(method: string, init?: RequestInit): boolean {
return (method === "GET" || method === "HEAD")
&& !init?.body
&& !init?.signal
&& init?.cache !== "no-store"
&& init?.cache !== "reload";
}
function requestHeadersKey(headers: Headers): string {
return [...headers.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}:${value}`)
.join("\n");
}
function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string {
return [
method,
url,
init?.credentials ?? "include",
init?.mode ?? "",
init?.redirect ?? "",
requestHeadersKey(headers),
].join("\n\n");
}
function pruneRecentSafeRequests(now = Date.now()): void {
for (const [key, entry] of recentSafeRequests) {
if (entry.expiresAt <= now) {
recentSafeRequests.delete(key);
}
}
while (recentSafeRequests.size > MAX_RECENT_SAFE_REQUESTS) {
const oldestKey = recentSafeRequests.keys().next().value;
if (!oldestKey) break;
recentSafeRequests.delete(oldestKey);
}
}
function recentSafeResponse(key: string): unknown | undefined {
const now = Date.now();
const recent = recentSafeRequests.get(key);
if (!recent) return undefined;
if (recent.expiresAt <= now) {
recentSafeRequests.delete(key);
return undefined;
}
return recent.value;
}
function rememberSafeResponse(key: string, value: unknown): void {
const now = Date.now();
recentSafeRequests.set(key, { value, expiresAt: now + RECENT_SAFE_REQUEST_TTL_MS });
pruneRecentSafeRequests(now);
}
export function authHeaders(settings: ApiSettings): Headers {
const headers = new Headers();
if (settings.accessToken) {
@@ -103,6 +171,7 @@ export function authHeaders(settings: ApiSettings): Headers {
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers || {});
const method = (init?.method || "GET").toUpperCase();
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
@@ -113,27 +182,69 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
}
const csrf = csrfToken();
if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) {
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", csrf);
}
const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, response.statusText, text);
if (isUnsafeMethod(method)) {
safeRequestGeneration += 1;
inFlightSafeRequests.clear();
recentSafeRequests.clear();
}
if (response.status === 204) {
return undefined as T;
const url = apiUrl(settings, path);
const fetchInit = { ...init, headers, credentials: init?.credentials ?? "include" };
async function runFetch(): Promise<T> {
const response = await fetch(url, fetchInit);
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, response.statusText, text);
}
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return (await response.text()) as T;
}
return (await response.json()) as T;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return (await response.text()) as T;
if (!canReuseSafeRequest(method, init)) {
return runFetch();
}
return (await response.json()) as T;
const cacheKey = safeRequestKey(url, method, headers, init);
const requestGeneration = safeRequestGeneration;
const recent = recentSafeResponse(cacheKey);
if (recent !== undefined) {
return recent as T;
}
const existing = inFlightSafeRequests.get(cacheKey);
if (existing) {
return existing as Promise<T>;
}
const request = runFetch()
.then((value) => {
if (requestGeneration === safeRequestGeneration) {
rememberSafeResponse(cacheKey, value);
}
return value;
})
.finally(() => {
if (inFlightSafeRequests.get(cacheKey) === request) {
inFlightSafeRequests.delete(cacheKey);
}
});
inFlightSafeRequests.set(cacheKey, request);
return request;
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import PolicySourcePath, { type PolicySourcePathItem } from "./PolicySourcePath";
export type EffectivePolicyBlockProps = {
title: string;
sourcePath?: PolicySourcePathItem[];
children: ReactNode;
className?: string;
gridClassName?: string;
};
export function EffectivePolicyValue({ label, value }: { label: string; value: ReactNode }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
export default function EffectivePolicyBlock({ title, sourcePath, children, className = "", gridClassName = "" }: EffectivePolicyBlockProps) {
return (
<section className={`policy-effective-block ${className}`.trim()}>
<h3>{title}</h3>
{sourcePath && <PolicySourcePath items={sourcePath} />}
<div className={gridClassName || "policy-effective-grid"}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import { useId, useState, type InputHTMLAttributes } from "react";
import { Eye, EyeOff } from "lucide-react";
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
value: string;
onValueChange: (value: string) => void;
saved?: boolean;
savedPlaceholder?: string;
revealLabel?: string;
hideLabel?: string;
inputClassName?: string;
};
export default function PasswordField({
value,
onValueChange,
saved = false,
savedPlaceholder = "••••••••",
revealLabel = "Show password",
hideLabel = "Hide password",
placeholder,
disabled = false,
className = "",
inputClassName = "",
id,
...inputProps
}: PasswordFieldProps) {
const generatedId = useId();
const inputId = id ?? generatedId;
const [visible, setVisible] = useState(false);
const hasTypedPassword = value.length > 0;
const showSavedPlaceholder = saved && !hasTypedPassword;
const canReveal = hasTypedPassword && !disabled;
const inputType = visible && canReveal ? "text" : "password";
return (
<div className={`password-field ${canReveal ? "has-toggle" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
<input
{...inputProps}
id={inputId}
className={inputClassName}
type={inputType}
value={value}
disabled={disabled}
placeholder={showSavedPlaceholder ? savedPlaceholder : placeholder}
onChange={(event) => {
onValueChange(event.target.value);
if (!event.target.value) setVisible(false);
}}
/>
{canReveal && (
<button
type="button"
className="password-field-toggle"
aria-label={visible ? hideLabel : revealLabel}
title={visible ? hideLabel : revealLabel}
onClick={() => setVisible((current) => !current)}
>
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
export type PolicyLockedHintProps = {
children: ReactNode;
className?: string;
};
export default function PolicyLockedHint({ children, className = "" }: PolicyLockedHintProps) {
if (!children) return null;
return <p className={`muted small-note policy-locked-hint ${className}`.trim()}>{children}</p>;
}

View File

@@ -0,0 +1,66 @@
export type PolicySourcePathItem = string | {
scope_type?: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
appliedFields?: string[];
};
export type PolicySourcePathProps = {
items: PolicySourcePathItem[];
label?: string;
className?: string;
};
type NormalizedPolicySourcePathItem = {
label: string;
appliedFields: string[];
};
export default function PolicySourcePath({ items, label = "Source path", className = "" }: PolicySourcePathProps) {
const cleanItems = items.map(normalizeSourceItem).filter((item): item is NormalizedPolicySourcePathItem => Boolean(item?.label));
if (cleanItems.length === 0) return null;
return (
<div className={`policy-source-path ${className}`.trim()} aria-label={label}>
<span className="policy-source-path-label">{label}</span>
<ol>
{cleanItems.map((item, index) => (
<li key={`${item.label}-${index}`}>
<span>{item.label}</span>
{item.appliedFields.length > 0 && <small>{sourceFieldSummary(item.appliedFields)}</small>}
</li>
))}
</ol>
</div>
);
}
function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySourcePathItem | null {
if (typeof item === "string") {
const label = item.trim();
return label ? { label, appliedFields: [] } : null;
}
const label = item.label?.trim();
if (!label) return null;
const appliedFields = [...(item.applied_fields ?? item.appliedFields ?? [])].map((field) => field.trim()).filter(Boolean);
return { label, appliedFields };
}
function sourceFieldSummary(fields: string[]): string {
const unique = Array.from(new Set(fields));
if (unique.length === 0) return "";
if (unique.length === 1 && unique[0] === "defaults") return "defaults";
if (unique.length <= 3) return unique.map(sourceFieldLabel).join(", ");
return `${unique.slice(0, 2).map(sourceFieldLabel).join(", ")} +${unique.length - 2}`;
}
function sourceFieldLabel(field: string): string {
if (field === "defaults") return "defaults";
const clean = field
.replace(/^whitelist[.]/, "allow ")
.replace(/^blacklist[.]/, "block ")
.replace(/[_.]/g, " ")
.trim();
return clean.charAt(0).toUpperCase() + clean.slice(1);
}

View File

@@ -20,7 +20,7 @@ const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 10000,
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import Button from "../Button";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import PasswordField from "../PasswordField";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
@@ -50,10 +51,14 @@ export type MailServerSettingsPanelProps = {
onImapEnabledChange?: (enabled: boolean) => void;
smtpDisabled?: boolean;
smtpCredentialDisabled?: boolean;
smtpPasswordSaved?: boolean;
smtpSavedPasswordPlaceholder?: string;
smtpActionDisabled?: boolean;
imapToggleDisabled?: boolean;
imapServerDisabled?: boolean;
imapCredentialDisabled?: boolean;
imapPasswordSaved?: boolean;
imapSavedPasswordPlaceholder?: string;
imapActionDisabled?: boolean;
smtpTitle?: ReactNode;
imapTitle?: ReactNode;
@@ -98,10 +103,14 @@ export default function MailServerSettingsPanel({
onImapEnabledChange,
smtpDisabled = false,
smtpCredentialDisabled = smtpDisabled,
smtpPasswordSaved = false,
smtpSavedPasswordPlaceholder = "••••••••",
smtpActionDisabled = smtpDisabled,
imapToggleDisabled = false,
imapServerDisabled = false,
imapCredentialDisabled = imapServerDisabled,
imapPasswordSaved = false,
imapSavedPasswordPlaceholder = "••••••••",
imapActionDisabled = imapServerDisabled,
smtpTitle = "SMTP login",
imapTitle = "IMAP sent-folder append",
@@ -149,7 +158,16 @@ export default function MailServerSettingsPanel({
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
<FormField label="Password"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></FormField>
<FormField label="Password">
<PasswordField
value={stringValue(smtp.password)}
disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onSmtpChange({ password })}
/>
</FormField>
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
</div>
@@ -174,7 +192,16 @@ export default function MailServerSettingsPanel({
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
<FormField label="Password"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></FormField>
<FormField label="Password">
<PasswordField
value={stringValue(imap.password)}
disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onImapChange({ password })}
/>
</FormField>
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>

View File

@@ -6,6 +6,7 @@ import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import StatusBadge from "../../components/StatusBadge";
import {
createSystemAccount,
@@ -192,7 +193,16 @@ export default function SystemUsersPanel({
<div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" && <FormField label="Initial password"><input type="password" value={draft.password} placeholder="Leave empty to generate" onChange={(event) => setDraft({ ...draft, password: event.target.value })} /></FormField>}
{editing === "new" && (
<FormField label="Initial password">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
</FormField>
)}
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
</div>
<div className="admin-assignment-grid">

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "../../types";
import { createTenant, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import Button from "../../components/Button";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
@@ -65,6 +65,7 @@ export default function TenantsPanel({
onAuthRefresh: () => Promise<void>;
}) {
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
@@ -79,12 +80,14 @@ export default function TenantsPanel({
setLoading(true);
setError("");
try {
const [nextTenants, nextOwnerCandidates] = await Promise.all([
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
fetchTenants(settings),
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([])
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
fetchSystemSettings(settings).catch(() => null)
]);
setTenants(nextTenants);
setOwnerCandidates(nextOwnerCandidates);
setSystemSettings(nextSystemSettings);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
@@ -180,6 +183,14 @@ export default function TenantsPanel({
}
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false;
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
const systemDeniedGovernance = [
systemAllowsCustomGroups ? "" : "custom groups",
systemAllowsCustomRoles ? "" : "custom roles",
systemAllowsApiKeys ? "" : "API keys"
].filter(Boolean).join(", ");
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
{ id: "name", header: "Tenant", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
{ id: "users", header: "Users", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
@@ -219,11 +230,12 @@ export default function TenantsPanel({
</div>
<h3>System governance overrides</h3>
<div className="admin-form-grid two-columns">
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
</div>
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p>
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</p>}
</Dialog>
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
@@ -243,6 +255,6 @@ export default function TenantsPanel({
);
}
function GovernanceSelect({ label, value, onChange, disabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean }) {
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow">Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) {
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
}

View File

@@ -6,6 +6,7 @@ import Button from "../../components/Button";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import StatusBadge from "../../components/StatusBadge";
import ConfirmDialog from "../../components/ConfirmDialog";
import AdminSelectionList from "./components/AdminSelectionList";
@@ -151,7 +152,16 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
<div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" && <FormField label="Initial password"><input type="password" value={draft.password} placeholder="Leave empty to generate" onChange={(event) => setDraft({ ...draft, password: event.target.value })} /></FormField>}
{editing === "new" && (
<FormField label="Initial password">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
</FormField>
)}
<FormField label="Membership status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
</div>
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}

View File

@@ -3,6 +3,7 @@ import type { ApiSettings, LoginResponse } from "../../types";
import { login } from "../../api/auth";
import Button from "../../components/Button";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import DismissibleAlert from "../../components/DismissibleAlert";
export default function LoginModal({
@@ -47,7 +48,7 @@ export default function LoginModal({
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
</FormField>
<FormField label="Password">
<input type="password" value={password} autoComplete="current-password" onChange={(e) => setPassword(e.target.value)} />
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</div>
<footer className="modal-footer">

View File

@@ -13,6 +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 FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
@@ -158,6 +160,7 @@ export function RetentionPolicyEditor({
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -181,6 +184,7 @@ export function RetentionPolicyEditor({
setPolicy({});
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
return;
}
setLoading(true);
@@ -189,10 +193,12 @@ export function RetentionPolicyEditor({
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
} catch (err) {
setPolicy({});
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
setError(errorMessage(err));
} finally {
setLoading(false);
@@ -210,6 +216,7 @@ export function RetentionPolicyEditor({
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
setSuccess("Retention policy saved.");
} catch (err) {
setError(errorMessage(err));
@@ -316,19 +323,21 @@ export function RetentionPolicyEditor({
</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>
<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>
@@ -399,10 +408,6 @@ function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { valu
);
}
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";
@@ -440,6 +445,14 @@ function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
}
function retentionPolicySourcePath(scopeType: PrivacyRetentionPolicyScope): string[] {
if (scopeType === "system") return ["System"];
if (scopeType === "tenant") return ["System", "Tenant"];
if (scopeType === "user") return ["System", "Tenant", "User"];
if (scopeType === "group") return ["System", "Tenant", "Group"];
return ["System", "Tenant", "Owner policy", "Campaign"];
}
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
}

View File

@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
@@ -240,7 +241,11 @@ export default function SettingsPage({
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
</FormField>
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
<input type="password" value={settings.apiKey} onChange={(e) => onSettingsChange({ ...settings, apiKey: e.target.value })} />
<PasswordField
value={settings.apiKey}
autoComplete="off"
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })}
/>
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>

View File

@@ -17,6 +17,8 @@ export { default as Card } from "./components/Card";
export { default as ConfirmDialog } from "./components/ConfirmDialog";
export { default as Dialog } from "./components/Dialog";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
export { default as FormField } from "./components/FormField";
export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator";
@@ -26,6 +28,12 @@ export { default as MetricCard } from "./components/MetricCard";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
export { default as PasswordField } from "./components/PasswordField";
export type { PasswordFieldProps } from "./components/PasswordField";
export { default as PolicySourcePath } from "./components/PolicySourcePath";
export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath";
export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
export { default as StatusBadge } from "./components/StatusBadge";
export { default as Stepper } from "./components/Stepper";
export { default as ToggleSwitch } from "./components/ToggleSwitch";
@@ -39,6 +47,8 @@ export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQu
export { default as LoginModal } from "./features/auth/LoginModal";
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
export { RetentionPolicyEditor, RetentionPolicyScopeManager } from "./features/privacy/RetentionPolicyManagement";
export type { RetentionPolicyTargetOption } from "./features/privacy/RetentionPolicyManagement";
export { default as AppShell } from "./layout/AppShell";
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";

View File

@@ -142,6 +142,96 @@
font-size: 18px;
}
.policy-source-path {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.policy-source-path-label {
font-weight: 800;
text-transform: uppercase;
}
.policy-source-path ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.policy-source-path li {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-strong);
font-weight: 700;
}
.policy-source-path li small {
max-width: 180px;
overflow: hidden;
color: var(--muted);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.policy-source-path li small::before {
content: "(";
}
.policy-source-path li small::after {
content: ")";
}
.policy-source-path li + li::before {
content: ">";
color: var(--muted);
font-weight: 700;
}
.password-field {
position: relative;
width: 100%;
}
.password-field.has-toggle input {
padding-right: 42px;
}
.password-field.is-saved-empty input::placeholder {
color: var(--muted);
opacity: 1;
}
.password-field-toggle {
position: absolute;
top: 50%;
right: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 0;
transform: translateY(-50%);
}
.password-field-toggle:hover {
background: rgba(0, 0, 0, .06);
color: var(--text-strong);
}
.password-field-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff);
outline-offset: 1px;
}
/* Mail-style address editor: textarea surface + pills + add dialog. */
.email-address-editor {
position: relative;