initial commit after split
This commit is contained in:
15
webui/src/features/PlaceholderPage.tsx
Normal file
15
webui/src/features/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Card from "../components/Card";
|
||||
|
||||
export default function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>This module is prepared but not implemented yet.</p>
|
||||
</div>
|
||||
<Card>
|
||||
<p className="muted">Next passes will add functionality here.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
webui/src/features/admin/AdminAuditPanel.tsx
Normal file
123
webui/src/features/admin/AdminAuditPanel.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn, type DataGridQueryState } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage, formatDateTime } from "./adminUtils";
|
||||
|
||||
const DEFAULT_QUERY: DataGridQueryState = {
|
||||
sort: { columnId: "time", direction: "desc" },
|
||||
filters: {}
|
||||
};
|
||||
|
||||
export default function AdminAuditPanel({
|
||||
settings,
|
||||
auth,
|
||||
systemMode = false
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
systemMode?: boolean;
|
||||
}) {
|
||||
const [items, setItems] = useState<AuditAdminItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
|
||||
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const sortColumn = query.sort?.columnId;
|
||||
const response = await fetchAdminAudit(settings, {
|
||||
scope: systemMode ? "system" : "tenant",
|
||||
page,
|
||||
pageSize,
|
||||
sortBy: sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn)
|
||||
? sortColumn as "time" | "actor" | "action" | "object" | "tenant"
|
||||
: "time",
|
||||
sortDirection: query.sort?.direction ?? "desc",
|
||||
filters: query.filters
|
||||
});
|
||||
setItems(response.items);
|
||||
setTotal(response.total);
|
||||
if (response.page !== page) setPage(response.page);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const handleQueryChange = useCallback((next: DataGridQueryState) => {
|
||||
setQuery((current) => {
|
||||
if (JSON.stringify(current) === JSON.stringify(next)) return current;
|
||||
setPage(1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
|
||||
{ id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
||||
{ id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
|
||||
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
||||
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
|
||||
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
|
||||
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
|
||||
], [systemMode]);
|
||||
|
||||
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const lastShown = Math.min(total, page * pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title={systemMode ? "System audit" : "Tenant audit"}
|
||||
description={systemMode
|
||||
? `System-level administrative history. Showing ${firstShown}–${lastShown} of ${total} records.`
|
||||
: `Tenant-level administrative history for the active tenant. Showing ${firstShown}–${lastShown} of ${total} records.`}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}
|
||||
>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={systemMode ? "admin-system-audit-v5" : "admin-tenant-audit-v5"}
|
||||
rows={items}
|
||||
columns={columns}
|
||||
initialFit="container" getRowKey={(row) => row.id}
|
||||
emptyText="No administrative audit records found."
|
||||
className="admin-audit-grid"
|
||||
initialSort={{ columnId: "time", direction: "desc" }}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
pageSize,
|
||||
totalRows: total,
|
||||
pageSizeOptions: [25, 50, 100, 250],
|
||||
disabled: loading,
|
||||
onPageChange: setPage,
|
||||
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
|
||||
}}
|
||||
onQueryChange={handleQueryChange}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
|
||||
{selected && <><dl className="admin-details-grid"><div><dt>Scope</dt><dd>{selected.scope}</dd></div><div><dt>Action</dt><dd>{selected.action}</dd></div><div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div><div><dt>Object</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>Tenant context</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
webui/src/features/admin/AdminOverviewPanel.tsx
Normal file
94
webui/src/features/admin/AdminOverviewPanel.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
||||
import Card from "../../components/Card";
|
||||
import Button from "../../components/Button";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
|
||||
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet<string> }) {
|
||||
const [overview, setOverview] = useState<AdminOverview | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try { setOverview(await fetchAdminOverview(settings)); }
|
||||
catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
const hasSystemMetrics = Boolean(overview && [
|
||||
overview.tenant_count,
|
||||
overview.system_account_count,
|
||||
overview.system_group_template_count,
|
||||
overview.system_role_template_count
|
||||
].some((value) => value !== null && value !== undefined));
|
||||
|
||||
return (
|
||||
<AdminPageLayout title="Administration" description="System-wide governance and tenant-local access management, separated by scope and enforced by the backend." loading={loading} error={error} actions={<Button onClick={() => void load()} disabled={loading}>Reload</Button>}>
|
||||
{overview && <>
|
||||
{hasSystemMetrics && <>
|
||||
<div className="admin-overview-section-label">System</div>
|
||||
<div className="metric-grid">
|
||||
<Metric title="Tenants" value={overview.tenant_count ?? "—"} text="Registered tenant spaces." />
|
||||
<Metric title="Users" value={overview.system_account_count ?? "—"} text="Global login accounts across all tenants." />
|
||||
<Metric title="Central groups" value={overview.system_group_template_count ?? "—"} text="System-governed group definitions." />
|
||||
<Metric title="Tenant roles" value={overview.system_role_template_count ?? "—"} text="Centrally governed tenant roles." />
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{hasSystemArea(availableSections) && <Card title="System administration">
|
||||
<div className="admin-overview-grid">
|
||||
{availableSections.has("system-settings") && <AreaLink title="Settings" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level limiting permissions." onClick={() => onSelect("system-retention")} />}
|
||||
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
||||
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
||||
{availableSections.has("system-groups") && <AreaLink title="Central groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
||||
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
|
||||
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
|
||||
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
|
||||
</div>
|
||||
</Card>}
|
||||
|
||||
<div className="admin-overview-section-label">Active tenant: {overview.active_tenant_name}</div>
|
||||
<div className="metric-grid">
|
||||
<Metric title="Tenant users" value={`${overview.active_user_count}/${overview.user_count}`} text="Active and total memberships." />
|
||||
<Metric title="Groups" value={overview.group_count} text="Tenant-local and centrally managed groups." />
|
||||
<Metric title="Roles" value={overview.role_count} text="Tenant-local and centrally managed roles." />
|
||||
<Metric title="API keys" value={overview.active_api_key_count} text="Active tenant automation credentials." />
|
||||
</div>
|
||||
|
||||
<Card title="Tenant administration">
|
||||
<div className="admin-overview-grid">
|
||||
{availableSections.has("tenant-settings") && <AreaLink title="Settings" text="Tenant-specific settings and governance overrides." onClick={() => onSelect("tenant-settings")} />}
|
||||
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
|
||||
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
|
||||
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
|
||||
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
|
||||
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
|
||||
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
|
||||
{availableSections.has("tenant-user-retention") && <AreaLink title="User retention" text="Retention limits for user-owned campaigns." onClick={() => onSelect("tenant-user-retention")} />}
|
||||
{availableSections.has("tenant-group-retention") && <AreaLink title="Group retention" text="Retention limits for group-owned campaigns." onClick={() => onSelect("tenant-group-retention")} />}
|
||||
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
|
||||
</div>
|
||||
</Card>
|
||||
</>}
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function hasSystemArea(sections: ReadonlySet<string>): boolean {
|
||||
return ["system-settings", "system-retention", "system-tenants", "system-users", "system-groups", "system-roles", "system-role-templates", "system-audit"].some((section) => sections.has(section));
|
||||
}
|
||||
|
||||
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {
|
||||
return <Card title={title}><strong className="module-big-number">{value}</strong><p className="muted">{text}</p></Card>;
|
||||
}
|
||||
|
||||
function AreaLink({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
|
||||
return <button className="admin-overview-link" onClick={onClick}><strong>{title}</strong><span>{text}</span></button>;
|
||||
}
|
||||
206
webui/src/features/admin/AdminPage.tsx
Normal file
206
webui/src/features/admin/AdminPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { fetchMe } from "../../api/auth";
|
||||
import Card from "../../components/Card";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import AdminOverviewPanel from "./AdminOverviewPanel";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
import SystemSettingsPanel from "./SystemSettingsPanel";
|
||||
import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel";
|
||||
import SystemRolesPanel from "./SystemRolesPanel";
|
||||
import TenantsPanel from "./TenantsPanel";
|
||||
import UsersPanel from "./UsersPanel";
|
||||
import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import AdminAuditPanel from "./AdminAuditPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
|
||||
type AdminSection =
|
||||
| "overview"
|
||||
| "system-settings"
|
||||
| "system-retention"
|
||||
| "system-mail-servers"
|
||||
| "system-tenants"
|
||||
| "system-users"
|
||||
| "system-groups"
|
||||
| "system-roles"
|
||||
| "system-role-templates"
|
||||
| "system-audit"
|
||||
| "tenant-users"
|
||||
| "tenant-groups"
|
||||
| "tenant-roles"
|
||||
| "tenant-api-keys"
|
||||
| "tenant-mail-servers"
|
||||
| "tenant-user-mail-servers"
|
||||
| "tenant-group-mail-servers"
|
||||
| "tenant-retention"
|
||||
| "tenant-user-retention"
|
||||
| "tenant-group-retention"
|
||||
| "tenant-settings"
|
||||
| "tenant-audit";
|
||||
|
||||
export default function AdminPage({
|
||||
settings,
|
||||
auth,
|
||||
onAuthChange
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const available = useMemo(() => {
|
||||
const sections = new Set<AdminSection>(["overview"]);
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
sections.add("system-settings");
|
||||
sections.add("system-retention");
|
||||
sections.add("system-mail-servers");
|
||||
}
|
||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||
if (hasScope(auth, "system:governance:read")) sections.add("system-groups");
|
||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||
if (hasScope(auth, "system:governance:read")) sections.add("system-role-templates");
|
||||
if (hasScope(auth, "system:audit:read")) sections.add("system-audit");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
sections.add("tenant-mail-servers");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
|
||||
}
|
||||
if (hasScope(auth, "admin:policies:read")) {
|
||||
sections.add("tenant-retention");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-retention");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
if (hasScope(auth, "audit:read")) sections.add("tenant-audit");
|
||||
return sections;
|
||||
}, [auth]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const active: AdminSection = requestedSection && available.has(requestedSection) ? requestedSection : "overview";
|
||||
|
||||
function selectSection(section: AdminSection) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (section === "overview") next.delete("section");
|
||||
else next.set("section", section);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedSection && !available.has(requestedSection)) selectSection("overview");
|
||||
}, [requestedSection, available]);
|
||||
|
||||
async function refreshAuth() {
|
||||
onAuthChange(await fetchMe(settings));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth().catch(() => undefined);
|
||||
}, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return <div className="content-pad"><Card title="Administration unavailable"><p>Your current roles do not grant administrative access.</p></Card></div>;
|
||||
}
|
||||
|
||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
||||
{ items: [{ id: "overview" as const, label: "Overview" }] },
|
||||
{
|
||||
title: "SYSTEM",
|
||||
items: [
|
||||
...(available.has("system-settings") ? [{ id: "system-settings" as const, label: "Settings" }] : []),
|
||||
...(available.has("system-retention") ? [{ id: "system-retention" as const, label: "Retention" }] : []),
|
||||
...(available.has("system-mail-servers") ? [{ id: "system-mail-servers" as const, label: "Mail servers" }] : []),
|
||||
...(available.has("system-tenants") ? [{ id: "system-tenants" as const, label: "Tenants" }] : []),
|
||||
...(available.has("system-users") ? [{ id: "system-users" as const, label: "Users" }] : []),
|
||||
...(available.has("system-groups") ? [{ id: "system-groups" as const, label: "Groups" }] : []),
|
||||
...(available.has("system-roles") ? [{ id: "system-roles" as const, label: "System roles" }] : []),
|
||||
...(available.has("system-role-templates") ? [{ id: "system-role-templates" as const, label: "Tenant roles" }] : []),
|
||||
...(available.has("system-audit") ? [{ id: "system-audit" as const, label: "Audit" }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "TENANT",
|
||||
items: [
|
||||
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "Settings" }] : []),
|
||||
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
|
||||
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
|
||||
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
|
||||
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
|
||||
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
|
||||
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
|
||||
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "USER",
|
||||
items: [
|
||||
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "User mail" }] : []),
|
||||
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "User retention" }] : []),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "GROUP",
|
||||
items: [
|
||||
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Group mail" }] : []),
|
||||
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Group retention" }] : []),
|
||||
]
|
||||
}
|
||||
].filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<ModuleSubnav active={active} groups={adminSubnav} onSelect={selectSection} />
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
{active === "overview" && <AdminOverviewPanel settings={settings} availableSections={available} onSelect={(section) => available.has(section as AdminSection) && selectSection(section as AdminSection)} />}
|
||||
{active === "system-settings" && <SystemSettingsPanel settings={settings} canWrite={hasScope(auth, "system:settings:write")} />}
|
||||
{active === "system-retention" && <RetentionPoliciesPanel settings={settings} scopeType="system" canWrite={hasScope(auth, "system:settings:write")} />}
|
||||
{active === "system-mail-servers" && <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />}
|
||||
{active === "system-tenants" && <TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />}
|
||||
{active === "system-users" && <SystemUsersPanel
|
||||
settings={settings}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
|
||||
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
|
||||
onAuthRefresh={refreshAuth}
|
||||
/>}
|
||||
{active === "system-groups" && <GovernanceTemplatesPanel settings={settings} kind="group" canWrite={hasScope(auth, "system:governance:write")} onAuthRefresh={refreshAuth} />}
|
||||
{active === "system-roles" && <SystemRolesPanel
|
||||
settings={settings}
|
||||
canWrite={hasScope(auth, "system:roles:write")}
|
||||
onAuthRefresh={refreshAuth}
|
||||
/>}
|
||||
{active === "system-role-templates" && <GovernanceTemplatesPanel settings={settings} kind="role" canWrite={hasScope(auth, "system:governance:write")} onAuthRefresh={refreshAuth} />}
|
||||
{active === "system-audit" && <AdminAuditPanel settings={settings} auth={auth} systemMode />}
|
||||
{active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
|
||||
{active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
|
||||
{active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{active === "tenant-settings" && <PreparationPage title="Tenant settings" text="Tenant-specific policy values are now represented by typed governance overrides on the system Tenants page; further campaign-policy inheritance will build on that foundation." />}
|
||||
{active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreparationPage({ title, text }: { title: string; text: string }) {
|
||||
return <div className="admin-section-page"><div className="page-heading workspace-heading"><div><PageTitle>{title}</PageTitle><p>{text}</p></div></div><Card><p className="muted">This boundary is intentionally visible but not presented as an implemented editor.</p></Card></div>;
|
||||
}
|
||||
127
webui/src/features/admin/ApiKeysPanel.tsx
Normal file
127
webui/src/features/admin/ApiKeysPanel.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { createApiKey, fetchApiKeys, fetchPermissionCatalog, fetchUsers, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage, formatDateTime } from "./adminUtils";
|
||||
import { scopeGrants } from "../../utils/permissions";
|
||||
|
||||
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) {
|
||||
const [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" });
|
||||
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
|
||||
const [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]);
|
||||
setKeys(nextKeys);
|
||||
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
|
||||
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]);
|
||||
|
||||
const selectedUser = users.find((user) => user.id === draft.userId);
|
||||
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
|
||||
|
||||
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
|
||||
{ id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}…</div></div> },
|
||||
{ id: "owner", header: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
|
||||
{ id: "scopes", header: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
||||
{ id: "last_used", header: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||
{ id: "expires", header: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} disabled />
|
||||
<AdminIconButton label={`Revoke ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
|
||||
</div> }
|
||||
], [canRevoke]);
|
||||
|
||||
function openCreate() {
|
||||
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
|
||||
setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" });
|
||||
setCreating(true);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null });
|
||||
setSecret({ name: created.name, value: created.secret });
|
||||
setCreating(false);
|
||||
setSuccess(`API key ${created.name} created.`);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!revoking) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeApiKey(settings, revoking.id);
|
||||
setSuccess(`API key ${revoking.name} revoked.`);
|
||||
setRevoking(null);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant API keys" description="Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited." loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> Show revoked</label><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add API key" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No API keys found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={creating} title="Create API key" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "Creating…" : "Create key"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Owner"><select value={draft.userId} onChange={(event) => { const userId = event.target.value; const user = users.find((item) => item.id === userId); const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope)); setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) }); }}><option value="">Select user</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||
<FormField label="Expiry"><input type="datetime-local" value={draft.expiresAt} onChange={(event) => setDraft({ ...draft, expiresAt: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field"><span className="form-label">Allowed scopes</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} — ${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." /></div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="API key details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>Name</dt><dd>{viewing.name}</dd></div><div><dt>Prefix</dt><dd>{viewing.prefix}…</dd></div>
|
||||
<div><dt>Owner</dt><dd>{viewing.user_email}</dd></div><div><dt>Status</dt><dd>{viewing.revoked_at ? "Revoked" : "Active"}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Last used</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
|
||||
<div><dt>Expires</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}</dd></div><div><dt>Revoked</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
|
||||
</dl><h3>Scopes</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(secret)} title="API key secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
||||
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(revoking)} title="Revoke API key" message={`Revoke ${revoking?.name}? Existing clients will immediately lose access.`} confirmLabel="Revoke key" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
232
webui/src/features/admin/GovernanceTemplatesPanel.tsx
Normal file
232
webui/src/features/admin/GovernanceTemplatesPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import {
|
||||
createGovernanceTemplate,
|
||||
deleteGovernanceTemplate,
|
||||
fetchGovernanceTemplates,
|
||||
fetchPermissionCatalog,
|
||||
fetchTenants,
|
||||
updateGovernanceTemplate,
|
||||
type GovernanceAssignment,
|
||||
type GovernanceTemplateItem,
|
||||
type PermissionItem,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
import { adminErrorMessage, joinLabels } from "./adminUtils";
|
||||
|
||||
const emptyDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
permissions: [] as string[],
|
||||
assignments: [] as GovernanceAssignment[]
|
||||
};
|
||||
|
||||
export default function GovernanceTemplatesPanel({
|
||||
settings,
|
||||
kind,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
kind: "group" | "role";
|
||||
canWrite: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const [editing, setEditing] = useState<GovernanceTemplateItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
|
||||
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
|
||||
fetchGovernanceTemplates(settings, kind),
|
||||
fetchTenants(settings),
|
||||
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
|
||||
]);
|
||||
setItems(nextItems);
|
||||
setTenants(nextTenants);
|
||||
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(item: GovernanceTemplateItem) {
|
||||
setDraft({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
isActive: item.is_active,
|
||||
permissions: item.permissions,
|
||||
assignments: item.assignments
|
||||
});
|
||||
setEditing(item);
|
||||
}
|
||||
|
||||
function assignmentMode(tenantId: string): "none" | "available" | "required" {
|
||||
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
|
||||
}
|
||||
|
||||
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
assignments: mode === "none"
|
||||
? current.assignments.filter((item) => item.tenant_id !== tenantId)
|
||||
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
|
||||
}));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createGovernanceTemplate(settings, {
|
||||
kind,
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: kind === "role" ? draft.permissions : [],
|
||||
is_active: draft.isActive,
|
||||
assignments: draft.assignments
|
||||
});
|
||||
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
|
||||
} else if (editing) {
|
||||
await updateGovernanceTemplate(settings, editing.id, {
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: kind === "role" ? draft.permissions : [],
|
||||
is_active: draft.isActive,
|
||||
assignments: draft.assignments
|
||||
});
|
||||
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteGovernanceTemplate(settings, deleting.id);
|
||||
setSuccess(`${deleting.name} deleted.`);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
||||
{
|
||||
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, 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: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
|
||||
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
|
||||
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
|
||||
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
|
||||
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
|
||||
}).join(", ") : "—"
|
||||
},
|
||||
...(kind === "role" ? [{
|
||||
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
|
||||
value: (row: GovernanceTemplateItem) => row.effective_permission_count
|
||||
}] : []),
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{
|
||||
id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right",
|
||||
render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
||||
</div>
|
||||
}
|
||||
], [canWrite, kind, tenants]);
|
||||
|
||||
const title = kind === "group" ? "Central groups" : "Tenant roles";
|
||||
const description = kind === "group"
|
||||
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
|
||||
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title={title}
|
||||
description={description}
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
|
||||
>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant role")}
|
||||
onClose={() => !busy && setEditing(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : kind === "group" ? "Save template" : "Save tenant role"}</Button></>}
|
||||
>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
|
||||
<div className="form-field">
|
||||
<span className="form-label">Tenant availability</span>
|
||||
<div className="admin-selection-list admin-governance-mode">
|
||||
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
|
||||
</div>
|
||||
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
142
webui/src/features/admin/GroupsPanel.tsx
Normal file
142
webui/src/features/admin/GroupsPanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { createGroup, fetchGroups, fetchRoles, fetchUsers, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage, formatDateTime, joinLabels } from "./adminUtils";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||
|
||||
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canDefine: boolean;
|
||||
canManageMembers: boolean;
|
||||
canAssignRoles: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<GroupSummary | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<GroupSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]);
|
||||
setGroups(nextGroups);
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
|
||||
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
|
||||
function openEdit(group: GroupSummary) {
|
||||
setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) });
|
||||
setEditing(group);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] });
|
||||
setSuccess(`Group ${draft.name} created.`);
|
||||
} else if (editing) {
|
||||
const managed = Boolean(editing.system_template_id);
|
||||
await updateGroup(settings, editing.id, {
|
||||
...(canDefine && !managed ? { name: draft.name, description: draft.description || null } : {}),
|
||||
...(canDefine && !editing.system_required ? { is_active: draft.isActive } : {}),
|
||||
...(canManageMembers ? { member_ids: draft.memberIds } : {}),
|
||||
...(canAssignRoles ? { role_ids: draft.roleIds } : {})
|
||||
});
|
||||
setSuccess(`Group ${draft.name} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateGroup(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(`Group ${deactivating.name} deactivated.`);
|
||||
setDeactivating(null);
|
||||
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||
{ id: "group", header: "Group", width: "minmax(220px, 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} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
|
||||
{ id: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||
{ id: "roles", header: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
|
||||
<AdminIconButton label={`Deactivate ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
||||
</div> }
|
||||
], [canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant groups" description="Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add group" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No groups found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create group" : "Edit group"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "Saving…" : "Save group"}</Button></>}>
|
||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.</p>}
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_required))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">Members</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="No tenant users exist." /></div>
|
||||
<div><span className="form-label">Inherited roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
|
||||
</div>
|
||||
<p className="muted small-note">The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Group details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<div><dt>Group</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Management</dt><dd>{viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant managed"}</dd></div>
|
||||
<div><dt>Members</dt><dd>{viewing.member_count}</dd></div><div><dt>Roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate group" message={`Deactivate ${deactivating?.name}? Memberships remain stored, but role inheritance stops.`} confirmLabel="Deactivate group" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
webui/src/features/admin/MailProfilesPanel.tsx
Normal file
103
webui/src/features/admin/MailProfilesPanel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { fetchGroups, fetchUsers } from "../../api/admin";
|
||||
import type { MailProfileScope } from "@govoplan/mail-webui";
|
||||
import { MailProfileScopeManager, type MailProfileTargetOption } from "@govoplan/mail-webui";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||
canWriteProfiles: boolean;
|
||||
canManageCredentials: boolean;
|
||||
canWritePolicy: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; profileTitle: string; policyTitle: string }> = {
|
||||
system: {
|
||||
title: "System mail profiles",
|
||||
description: "Instance-level mail server profiles and policy limits inherited by every tenant.",
|
||||
profileTitle: "System profiles",
|
||||
policyTitle: "System mail profile policy"
|
||||
},
|
||||
tenant: {
|
||||
title: "Tenant mail profiles",
|
||||
description: "Tenant-level mail server profiles and policy limits for the active tenant.",
|
||||
profileTitle: "Tenant profiles",
|
||||
policyTitle: "Tenant mail profile policy"
|
||||
},
|
||||
user: {
|
||||
title: "User mail profiles",
|
||||
description: "User-scoped profiles and policy limits for campaign owners in the active tenant.",
|
||||
targetLabel: "User",
|
||||
profileTitle: "User profiles",
|
||||
policyTitle: "User mail profile policy"
|
||||
},
|
||||
group: {
|
||||
title: "Group mail profiles",
|
||||
description: "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
|
||||
targetLabel: "Group",
|
||||
profileTitle: "Group profiles",
|
||||
policyTitle: "Group mail profile policy"
|
||||
}
|
||||
};
|
||||
|
||||
export default function MailProfilesPanel({ settings, scopeType, canWriteProfiles, canManageCredentials, canWritePolicy }: Props) {
|
||||
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
|
||||
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await fetchUsers(settings);
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await fetchGroups(settings);
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
profileTitle={labels.profileTitle}
|
||||
policyTitle={labels.policyTitle}
|
||||
canWriteProfiles={canWriteProfiles}
|
||||
canManageCredentials={canManageCredentials}
|
||||
canWritePolicy={canWritePolicy}
|
||||
/>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
143
webui/src/features/admin/RetentionPoliciesPanel.tsx
Normal file
143
webui/src/features/admin/RetentionPoliciesPanel.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { fetchGroups, fetchUsers, runRetentionPolicy, type PrivacyRetentionPolicyScope, type RetentionRunResponse } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import { RetentionPolicyScopeManager, type RetentionPolicyTargetOption } from "../privacy/RetentionPolicyManagement";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
||||
system: {
|
||||
title: "System retention",
|
||||
description: "Instance-wide privacy retention policy and lower-level limiting permissions.",
|
||||
policyTitle: "System retention policy",
|
||||
policyDescription: "Set concrete system retention values. The Allow limiting toggles decide which fields tenants, owners and campaigns may restrict further."
|
||||
},
|
||||
tenant: {
|
||||
title: "Tenant retention",
|
||||
description: "Tenant-level privacy and retention limits for the active tenant.",
|
||||
policyTitle: "Tenant retention policy",
|
||||
policyDescription: "Tenant limits may only narrow the system policy. The Allow limiting toggles decide which fields users, groups and campaigns may restrict further."
|
||||
},
|
||||
user: {
|
||||
title: "User retention",
|
||||
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
|
||||
targetLabel: "User",
|
||||
policyTitle: "User retention policy",
|
||||
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow limiting toggles decide which fields user-owned campaigns may restrict further."
|
||||
},
|
||||
group: {
|
||||
title: "Group retention",
|
||||
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
|
||||
targetLabel: "Group",
|
||||
policyTitle: "Group retention policy",
|
||||
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow limiting toggles decide which fields group-owned campaigns may restrict further."
|
||||
}
|
||||
};
|
||||
|
||||
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
|
||||
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
|
||||
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
||||
const [targetError, setTargetError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [runError, setRunError] = useState("");
|
||||
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
|
||||
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
|
||||
|
||||
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await fetchUsers(settings);
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await fetchGroups(settings);
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRetention(dryRun: boolean) {
|
||||
setBusy(true);
|
||||
setRunError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const response = await runRetentionPolicy(settings, dryRun);
|
||||
setRetentionResult(response);
|
||||
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
|
||||
setConfirmRetentionRun(false);
|
||||
} catch (err) { setRunError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
|
||||
<RetentionPolicyScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
title={labels.policyTitle}
|
||||
description={labels.policyDescription}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
{scopeType === "system" && (
|
||||
<div className="retention-run-card">
|
||||
<Card title="Retention execution">
|
||||
<p className="muted small-note">Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.</p>
|
||||
<div className="button-row compact-actions subsection-bottom-actions">
|
||||
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
|
||||
</div>
|
||||
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
<ConfirmDialog
|
||||
open={confirmRetentionRun}
|
||||
title="Apply retention policy"
|
||||
message="This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed."
|
||||
confirmLabel="Apply retention"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmRetentionRun(false)}
|
||||
onConfirm={() => void runRetention(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
webui/src/features/admin/RolesPanel.tsx
Normal file
133
webui/src/features/admin/RolesPanel.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { createRole, deleteRole, fetchPermissionCatalog, fetchRoles, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
import { hasTenantWildcard } from "../../utils/permissions";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
|
||||
|
||||
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: { settings: ApiSettings; auth: AuthInfo; canDefine: boolean; onAuthRefresh: () => Promise<void> }) {
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, nextPermissions] = await Promise.all([fetchRoles(settings), fetchPermissionCatalog(settings)]);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
|
||||
const permissionGroups = useMemo(() => {
|
||||
const groups = new Map<string, PermissionItem[]>();
|
||||
for (const permission of permissions) groups.set(permission.category, [...(groups.get(permission.category) ?? []), permission]);
|
||||
return Array.from(groups.entries());
|
||||
}, [permissions]);
|
||||
|
||||
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
|
||||
function openEdit(role: RoleSummary) {
|
||||
if (role.is_builtin || role.system_template_id) return;
|
||||
setDraft({ slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable });
|
||||
setEditing(role);
|
||||
setError("");
|
||||
}
|
||||
function togglePermission(scope: string, checked: boolean) {
|
||||
const next = new Set(draft.permissions);
|
||||
if (checked) next.add(scope); else next.delete(scope);
|
||||
setDraft({ ...draft, permissions: Array.from(next) });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
|
||||
setSuccess(`Role ${draft.name} created.`);
|
||||
} else if (editing) {
|
||||
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
|
||||
setSuccess(`Role ${draft.name} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteRole(settings, deleting.id);
|
||||
setSuccess(`Role ${deleting.name} deleted.`);
|
||||
setDeleting(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{ id: "role", header: "Role", width: "minmax(220px, 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} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
|
||||
{ id: "permissions", header: "Permissions", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||
{ id: "assignments", header: "Assignments", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||
{ id: "type", header: "Type", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "Built-in" : row.system_template_id ? "System" : "Custom"} /> },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
||||
</div> }
|
||||
], [canDefine, permissions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant roles" description="Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No roles found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create role" : "Edit role"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>}
|
||||
</div>
|
||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Role details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>Role</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>Type</dt><dd>{viewing.is_builtin ? "Built-in" : viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant custom"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div>
|
||||
<div><dt>User assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Group assignments</dt><dd>{viewing.group_assignments}</dd></div>
|
||||
</dl><h3>Permissions</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="Delete role" message={`Delete ${deleting?.name}? Only unassigned tenant-defined roles can be deleted.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
webui/src/features/admin/SystemRolesPanel.tsx
Normal file
257
webui/src/features/admin/SystemRolesPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createSystemRole,
|
||||
deleteSystemRole,
|
||||
fetchPermissionCatalog,
|
||||
fetchSystemRoles,
|
||||
updateSystemRole,
|
||||
type PermissionItem,
|
||||
type RoleSummary
|
||||
} from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
import { adminErrorMessage, joinLabels } from "./adminUtils";
|
||||
|
||||
const emptyDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
description: "",
|
||||
permissions: [] as string[],
|
||||
isAssignable: true
|
||||
};
|
||||
|
||||
export default function SystemRolesPanel({
|
||||
settings,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
canWrite: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, catalogue] = await Promise.all([
|
||||
fetchSystemRoles(settings),
|
||||
fetchPermissionCatalog(settings)
|
||||
]);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(catalogue.filter((item) => item.level === "system"));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(role: RoleSummary) {
|
||||
setDraft({
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description || "",
|
||||
permissions: role.permissions,
|
||||
isAssignable: role.is_assignable
|
||||
});
|
||||
setEditing(role);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createSystemRole(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions
|
||||
});
|
||||
setSuccess(`System role ${draft.name} created.`);
|
||||
} else if (editing) {
|
||||
await updateSystemRole(settings, editing.id, {
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions,
|
||||
is_assignable: draft.isAssignable
|
||||
});
|
||||
setSuccess(`System role ${draft.name} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteSystemRole(settings, deleting.id);
|
||||
setSuccess(`System role ${deleting.name} deleted.`);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{
|
||||
id: "role",
|
||||
header: "System role",
|
||||
width: 240,
|
||||
minWidth: 180,
|
||||
maxWidth: 380,
|
||||
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: "description",
|
||||
header: "Description",
|
||||
width: 360,
|
||||
fill: true,
|
||||
minWidth: 220,
|
||||
maxWidth: 640,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.description || "",
|
||||
render: (row) => row.description || "—"
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
header: "Permissions",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.effective_permission_count,
|
||||
render: (row) => String(row.effective_permission_count)
|
||||
},
|
||||
{
|
||||
id: "assignable",
|
||||
header: "Assignable",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.is_assignable ? "yes" : "no",
|
||||
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "Yes" : "No"} />
|
||||
},
|
||||
{
|
||||
id: "assignments",
|
||||
header: "Accounts",
|
||||
width: 110,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.user_assignments
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
], [canWrite]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="System roles"
|
||||
description="Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add system role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
|
||||
>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No system roles found." />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "Create system role" : "Edit system role"}
|
||||
onClose={() => !busy && setEditing(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}
|
||||
>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<span className="form-label">System permissions</span>
|
||||
<AdminSelectionList
|
||||
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
|
||||
selected={draft.permissions}
|
||||
onChange={(next) => setDraft({ ...draft, permissions: next })}
|
||||
/>
|
||||
<p className="muted small-note">A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "System role details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Protected</dt><dd>{viewing.slug === "system_owner" ? "Yes" : "No"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div><div><dt>Account assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Effective permissions</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>Assigned scopes</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="Delete system role" message={`Delete ${deleting?.name}? The role must have no account assignments.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
webui/src/features/admin/SystemSettingsPanel.tsx
Normal file
100
webui/src/features/admin/SystemSettingsPanel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { fetchSystemSettings, updateSystemSettings, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsItem } from "../../api/admin";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
|
||||
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 fallback: SystemSettingsItem = {
|
||||
default_locale: "en",
|
||||
allow_tenant_custom_groups: true,
|
||||
allow_tenant_custom_roles: true,
|
||||
allow_tenant_api_keys: true,
|
||||
privacy_retention_policy: defaultPrivacyPolicy,
|
||||
settings: {}
|
||||
};
|
||||
|
||||
export default function SystemSettingsPanel({ settings, canWrite }: { settings: ApiSettings; canWrite: boolean }) {
|
||||
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const loaded = await fetchSystemSettings(settings);
|
||||
setDraft(loaded);
|
||||
}
|
||||
catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDraft(await updateSystemSettings(settings, {
|
||||
default_locale: draft.default_locale,
|
||||
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
|
||||
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys: draft.allow_tenant_api_keys
|
||||
}));
|
||||
setSuccess("System settings saved.");
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="System settings"
|
||||
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "Saving…" : "Save settings"}</Button></>}
|
||||
>
|
||||
<div className="admin-settings-form">
|
||||
<Card title="Defaults for newly created tenants">
|
||||
<FormField label="Default locale"><input value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /></FormField>
|
||||
</Card>
|
||||
<Card title="Tenant administration capabilities">
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="Allow tenant-defined groups by default" />
|
||||
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="Allow tenant-defined roles by default" />
|
||||
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="Allow tenant API keys by default" />
|
||||
</div>
|
||||
<p className="muted small-note">These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
217
webui/src/features/admin/SystemUsersPanel.tsx
Normal file
217
webui/src/features/admin/SystemUsersPanel.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import {
|
||||
createSystemAccount,
|
||||
fetchSystemAccounts,
|
||||
fetchTenants,
|
||||
updateSystemAccount,
|
||||
updateSystemAccountRoles,
|
||||
updateSystemMemberships,
|
||||
type RoleSummary,
|
||||
type SystemAccountItem,
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
import { adminErrorMessage, formatDateTime, joinLabels } from "./adminUtils";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
passwordResetRequired: true,
|
||||
isActive: true,
|
||||
roleIds: [] as string[],
|
||||
memberships: [] as SystemMembershipDraft[]
|
||||
};
|
||||
|
||||
export default function SystemUsersPanel({
|
||||
settings,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
canAssignRoles,
|
||||
canManageMemberships,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
canAssignRoles: boolean;
|
||||
canManageMemberships: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; value: string } | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [access, nextTenants] = await Promise.all([fetchSystemAccounts(settings), fetchTenants(settings)]);
|
||||
setAccounts(access.accounts);
|
||||
setRoles(access.roles);
|
||||
setTenants(nextTenants);
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(item: SystemAccountItem) {
|
||||
setDraft({
|
||||
email: item.email,
|
||||
displayName: item.display_name || "",
|
||||
password: "",
|
||||
passwordResetRequired: false,
|
||||
isActive: item.is_active,
|
||||
roleIds: item.roles.map((role) => role.id),
|
||||
memberships: item.memberships.map((membership) => ({
|
||||
tenant_id: membership.tenant_id,
|
||||
is_active: membership.is_active,
|
||||
role_ids: membership.role_ids || [],
|
||||
group_ids: membership.group_ids || [],
|
||||
is_owner: membership.is_owner,
|
||||
is_last_active_owner: membership.is_last_active_owner
|
||||
}))
|
||||
});
|
||||
setEditing(item);
|
||||
}
|
||||
|
||||
function membership(tenantId: string) {
|
||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
function setMembership(tenantId: string, enabled: boolean) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
memberships: enabled
|
||||
? [...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }]
|
||||
: current.memberships.filter((item) => item.tenant_id !== tenantId)
|
||||
}));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const response = await createSystemAccount(settings, {
|
||||
email: draft.email,
|
||||
display_name: draft.displayName || null,
|
||||
password: draft.password || null,
|
||||
password_reset_required: draft.passwordResetRequired,
|
||||
is_active: draft.isActive,
|
||||
role_ids: canAssignRoles ? draft.roleIds : [],
|
||||
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : []
|
||||
});
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
|
||||
setSuccess(`Global account ${response.account.email} created.`);
|
||||
} else if (editing) {
|
||||
const accountChanges: { display_name?: string | null; is_active?: boolean } = {};
|
||||
if (canUpdate) accountChanges.display_name = draft.displayName || null;
|
||||
if (canSuspend) accountChanges.is_active = draft.isActive;
|
||||
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
|
||||
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds);
|
||||
if (canManageMemberships) await updateSystemMemberships(settings, editing.account_id, draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })));
|
||||
setSuccess(`Global account ${editing.email} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
|
||||
setSuccess(`${deactivating.email} deactivated.`);
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
|
||||
{ id: "account", header: "Account", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "tenants", header: "Tenant memberships", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
|
||||
{ id: "roles", header: "System roles", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
||||
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
||||
</div> }
|
||||
], [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Central users"
|
||||
description="Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add global account" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
|
||||
>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="No global accounts found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create global account" : "Edit global account"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "Saving…" : "Save account"}</Button></>}>
|
||||
<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>}
|
||||
<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">
|
||||
<div><span className="form-label">System roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||
<div><span className="form-label">Tenant memberships</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
||||
</div>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.</p>}
|
||||
<p className="muted small-note">Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Global account details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>Account</dt><dd>{viewing.email}</dd></div><div><dt>Display name</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>System roles</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>Tenants</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
|
||||
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate global account" message={`Deactivate ${deactivating?.email}? All sessions and tenant access will stop. Historical ownership remains intact.`} confirmLabel="Deactivate account" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
248
webui/src/features/admin/TenantsPanel.tsx
Normal file
248
webui/src/features/admin/TenantsPanel.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
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 Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage, formatDateTime } from "./adminUtils";
|
||||
|
||||
type OverrideValue = "inherit" | "allow" | "deny";
|
||||
type TenantDraft = {
|
||||
slug: string;
|
||||
name: string;
|
||||
ownerAccountId: string;
|
||||
description: string;
|
||||
defaultLocale: string;
|
||||
isActive: boolean;
|
||||
customGroups: OverrideValue;
|
||||
customRoles: OverrideValue;
|
||||
apiKeys: OverrideValue;
|
||||
};
|
||||
|
||||
const emptyDraft: TenantDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
ownerAccountId: "",
|
||||
description: "",
|
||||
defaultLocale: "en",
|
||||
isActive: true,
|
||||
customGroups: "inherit",
|
||||
customRoles: "inherit",
|
||||
apiKeys: "inherit"
|
||||
};
|
||||
|
||||
function fromOverride(value?: boolean | null): OverrideValue {
|
||||
if (value === true) return "allow";
|
||||
if (value === false) return "deny";
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function toOverride(value: OverrideValue): boolean | null {
|
||||
if (value === "allow") return true;
|
||||
if (value === "deny") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TenantsPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
|
||||
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextTenants, nextOwnerCandidates] = await Promise.all([
|
||||
fetchTenants(settings),
|
||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([])
|
||||
]);
|
||||
setTenants(nextTenants);
|
||||
setOwnerCandidates(nextOwnerCandidates);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft({ ...emptyDraft, ownerAccountId: auth.user.account_id });
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(tenant: TenantAdminItem) {
|
||||
setDraft({
|
||||
slug: tenant.slug,
|
||||
name: tenant.name,
|
||||
ownerAccountId: "",
|
||||
description: tenant.description || "",
|
||||
defaultLocale: tenant.default_locale || "en",
|
||||
isActive: tenant.is_active,
|
||||
customGroups: fromOverride(tenant.allow_custom_groups),
|
||||
customRoles: fromOverride(tenant.allow_custom_roles),
|
||||
apiKeys: fromOverride(tenant.allow_api_keys)
|
||||
});
|
||||
setEditing(tenant);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const governance = {
|
||||
allow_custom_groups: toOverride(draft.customGroups),
|
||||
allow_custom_roles: toOverride(draft.customRoles),
|
||||
allow_api_keys: toOverride(draft.apiKeys)
|
||||
};
|
||||
if (editing === "new") {
|
||||
const created = await createTenant(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
owner_account_id: draft.ownerAccountId || null,
|
||||
description: draft.description || null,
|
||||
default_locale: draft.defaultLocale,
|
||||
settings: {},
|
||||
...governance
|
||||
});
|
||||
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
|
||||
setSuccess(`Tenant ${created.name} created with ${selectedOwner?.display_name || selectedOwner?.email || "the selected account"} as Owner.`);
|
||||
await onAuthRefresh();
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateTenant>[2] = {};
|
||||
if (canUpdate) {
|
||||
payload.name = draft.name;
|
||||
payload.description = draft.description || null;
|
||||
payload.default_locale = draft.defaultLocale;
|
||||
payload.allow_custom_groups = governance.allow_custom_groups;
|
||||
payload.allow_custom_roles = governance.allow_custom_roles;
|
||||
payload.allow_api_keys = governance.allow_api_keys;
|
||||
}
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
await updateTenant(settings, editing.id, payload);
|
||||
setSuccess(`Tenant ${draft.name} updated.`);
|
||||
await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function suspend() {
|
||||
if (!confirmSuspend) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateTenant(settings, confirmSuspend.id, { is_active: false });
|
||||
setSuccess(`${confirmSuspend.name} suspended.`);
|
||||
setConfirmSuspend(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
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}` },
|
||||
{ id: "groups", header: "Groups", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
|
||||
{ id: "campaigns", header: "Campaigns", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
|
||||
{ id: "files", header: "Files", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||
{ id: "locale", header: "Locale", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
|
||||
<AdminIconButton label={`Suspend ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
|
||||
</div> }
|
||||
], [activeTenantId, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Tenants"
|
||||
description="Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
|
||||
>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenants found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create tenant" : "Edit tenant"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" && !draft.ownerAccountId)}>{busy ? "Saving…" : "Save tenant"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="Initial tenant owner"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? `${candidate.display_name} (${candidate.email})` : candidate.email}</option>)}</select></FormField>}
|
||||
<FormField label="Default locale"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Suspended</option></select></FormField>}
|
||||
<FormField label="Description"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</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 })} />
|
||||
</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>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>Tenant</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Suspended"}</dd></div><div><dt>Default locale</dt><dd>{viewing.default_locale}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
<div><dt>Custom groups</dt><dd>{viewing.effective_governance.allow_custom_groups ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
||||
<div><dt>Custom roles</dt><dd>{viewing.effective_governance.allow_custom_roles ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
||||
<div><dt>API keys</dt><dd>{viewing.effective_governance.allow_api_keys ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
||||
<div><dt>Objects</dt><dd>{viewing.counts.users ?? 0} users, {viewing.counts.groups ?? 0} groups, {viewing.counts.campaigns ?? 0} campaigns, {viewing.counts.files ?? 0} files</dd></div>
|
||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="Suspend tenant" message={`Suspend ${confirmSuspend?.name}? Existing data remains retained, but its members cannot use the tenant.`} confirmLabel="Suspend tenant" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
182
webui/src/features/admin/UsersPanel.tsx
Normal file
182
webui/src/features/admin/UsersPanel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { createUser, fetchGroups, fetchRoles, fetchUsers, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
import AdminIconButton from "./components/AdminIconButton";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage, formatDateTime, joinLabels } from "./adminUtils";
|
||||
import { hasTenantWildcard } from "../../utils/permissions";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
passwordResetRequired: true,
|
||||
isActive: true,
|
||||
groupIds: [] as string[],
|
||||
roleIds: [] as string[]
|
||||
};
|
||||
|
||||
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
canManageGroups: boolean;
|
||||
canAssignRoles: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; password: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextUsers, nextGroups, nextRoles] = await Promise.all([fetchUsers(settings), fetchGroups(settings), fetchRoles(settings)]);
|
||||
setUsers(nextUsers);
|
||||
setGroups(nextGroups);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(user: UserAdminItem) {
|
||||
setDraft({
|
||||
email: user.email,
|
||||
displayName: user.display_name || "",
|
||||
password: "",
|
||||
passwordResetRequired: user.password_reset_required,
|
||||
isActive: user.is_active,
|
||||
groupIds: user.groups.map((group) => group.id),
|
||||
roleIds: user.roles.map((role) => role.id)
|
||||
});
|
||||
setEditing(user);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const response = await createUser(settings, {
|
||||
email: draft.email,
|
||||
display_name: draft.displayName || null,
|
||||
password: draft.password || null,
|
||||
password_reset_required: draft.passwordResetRequired,
|
||||
is_active: draft.isActive,
|
||||
group_ids: canManageGroups ? draft.groupIds : [],
|
||||
role_ids: canAssignRoles ? draft.roleIds : []
|
||||
});
|
||||
setSuccess(`Tenant membership created for ${response.user.email}.`);
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password });
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateUser>[2] = {};
|
||||
if (canUpdate) payload.display_name = draft.displayName || null;
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
if (canManageGroups) payload.group_ids = draft.groupIds;
|
||||
if (canAssignRoles) payload.role_ids = draft.roleIds;
|
||||
await updateUser(settings, editing.id, payload);
|
||||
setSuccess(`Access updated for ${editing.email}.`);
|
||||
if (editing.id === auth.user.id) await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateUser(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(`${deactivating.email} deactivated in this tenant.`);
|
||||
if (deactivating.id === auth.user.id) await onAuthRefresh();
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||
{ id: "user", header: "User", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "groups", header: "Groups", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||
{ id: "roles", header: "Direct roles", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "scope_count", header: "Permissions", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "All" : String(row.effective_scopes.length) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
||||
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
||||
</div> }
|
||||
], [canAssignRoles, canManageGroups, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant users" description="Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant user" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenant users found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Add tenant user" : "Edit tenant user"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "Saving…" : "Save user"}</Button></>}>
|
||||
<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>}
|
||||
<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>}
|
||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.</p>}
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">Groups</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="No groups exist yet." /></div>
|
||||
<div><span className="form-label">Direct roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
|
||||
</div>
|
||||
<p className="muted small-note">Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Tenant user details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<div><dt>User</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>Email</dt><dd>{viewing.email}</dd></div>
|
||||
<div><dt>Membership</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Global account</dt><dd>{viewing.account_is_active ? "Active" : "Inactive"}</dd></div>
|
||||
<div><dt>Groups</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>Direct roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>Effective permissions</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "All tenant permissions" : viewing.effective_scopes.join(", ") || "None"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
|
||||
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">Transmit it through a separate secure channel.</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate tenant membership" message={`Deactivate ${deactivating?.email} in the active tenant? Historical ownership remains intact.`} confirmLabel="Deactivate user" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
webui/src/features/admin/adminUtils.ts
Normal file
27
webui/src/features/admin/adminUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function adminErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "The administrative operation failed.";
|
||||
const message = error.message;
|
||||
const jsonStart = message.indexOf("{");
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as { detail?: unknown };
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (Array.isArray(parsed.detail)) {
|
||||
return parsed.detail.map((item) => typeof item === "object" && item && "msg" in item ? String((item as { msg: unknown }).msg) : String(item)).join("; ");
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the original message.
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
export function joinLabels(values: Array<{ name: string }>): string {
|
||||
return values.length ? values.map((value) => value.name).join(", ") : "—";
|
||||
}
|
||||
26
webui/src/features/admin/components/AdminIconButton.tsx
Normal file
26
webui/src/features/admin/components/AdminIconButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "../../../components/Button";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
className="admin-icon-button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
45
webui/src/features/admin/components/AdminPageLayout.tsx
Normal file
45
webui/src/features/admin/components/AdminPageLayout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import PageTitle from "../../../components/PageTitle";
|
||||
import LoadingFrame from "../../../components/LoadingFrame";
|
||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
error?: string;
|
||||
success?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminPageLayout({
|
||||
title,
|
||||
description,
|
||||
loading = false,
|
||||
loadingLabel = "Loading administration data…",
|
||||
error = "",
|
||||
success = "",
|
||||
actions,
|
||||
children,
|
||||
className = ""
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={`admin-section-page ${className}`.trim()}>
|
||||
<div className="page-heading split workspace-heading admin-page-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="button-row compact-actions admin-page-actions">{actions}</div>}
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label={loadingLabel}>
|
||||
{children}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Button from "../../../components/Button";
|
||||
import Card from "../../../components/Card";
|
||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
|
||||
export type AdminPlaceholderTableConfig = {
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: string[];
|
||||
action: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type PlaceholderRow = Record<string, string> & { id: string };
|
||||
|
||||
export default function AdminPlaceholderTable({ title, columns, rows, action, note }: AdminPlaceholderTableConfig) {
|
||||
const normalizedRows = rows.map((row, rowIndex) => {
|
||||
const values = row.split("|");
|
||||
return columns.reduce<PlaceholderRow>((record, column, columnIndex) => {
|
||||
record[column] = values[columnIndex] ?? "";
|
||||
return record;
|
||||
}, { id: `${title}-${rowIndex}` });
|
||||
});
|
||||
const dataColumns: DataGridColumn<PlaceholderRow>[] = columns.map((column, index) => ({
|
||||
id: column,
|
||||
header: column,
|
||||
width: index === 0 ? "minmax(180px, 1fr)" : 180,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
resizable: true,
|
||||
sticky: index === 0 ? "start" : undefined,
|
||||
value: (row) => row[column] ?? ""
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card title={title} actions={<Button disabled>{action}</Button>}>
|
||||
<DismissibleAlert tone="info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</DismissibleAlert>
|
||||
{note && <p className="muted">{note}</p>}
|
||||
<DataGrid
|
||||
id={`admin-${slugify(title)}`}
|
||||
rows={normalizedRows}
|
||||
columns={dataColumns}
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="No rows configured."
|
||||
className="compact-table-wrap module-table"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
||||
}
|
||||
44
webui/src/features/admin/components/AdminSelectionList.tsx
Normal file
44
webui/src/features/admin/components/AdminSelectionList.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
type Option = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function AdminSelectionList({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyText = "No options available."
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (!options.length) return <p className="muted small-note">{emptyText}</p>;
|
||||
const selectedSet = new Set(selected);
|
||||
return (
|
||||
<div className="admin-selection-list">
|
||||
{options.map((option) => (
|
||||
<label key={option.id} className={`admin-selection-item ${option.disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.id)}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (event.target.checked) next.add(option.id);
|
||||
else next.delete(option.id);
|
||||
onChange(Array.from(next));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
webui/src/features/auth/LoginModal.tsx
Normal file
60
webui/src/features/auth/LoginModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, LoginResponse } from "../../types";
|
||||
import { login } from "../../api/auth";
|
||||
import Button from "../../components/Button";
|
||||
import FormField from "../../components/FormField";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
|
||||
export default function LoginModal({
|
||||
settings,
|
||||
onClose,
|
||||
onLogin
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await login(settings, { email, password });
|
||||
onLogin(response);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
||||
<form className="modal-panel" onSubmit={submit}>
|
||||
<header className="modal-header">
|
||||
<h2>Sign in</h2>
|
||||
<button className="modal-close" type="button" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body form-grid">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="Email">
|
||||
<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)} />
|
||||
</FormField>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
webui/src/features/auth/PublicLandingPage.tsx
Normal file
45
webui/src/features/auth/PublicLandingPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, LoginResponse } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import LoginModal from "./LoginModal";
|
||||
|
||||
export default function PublicLandingPage({
|
||||
settings,
|
||||
onLogin
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
}) {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">GovOPlaN</div>
|
||||
<h1>Modular planning tools with controlled access.</h1>
|
||||
<p>
|
||||
Sign in to open the modules available to your tenant and role.
|
||||
</p>
|
||||
|
||||
<div className="public-actions">
|
||||
<Button variant="primary" onClick={() => setLoginOpen(true)}>Sign in</Button>
|
||||
</div>
|
||||
|
||||
<div className="public-footnote">
|
||||
Access is restricted. Sign in to open available modules and administration.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loginOpen && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onLogin={(response) => {
|
||||
onLogin(response);
|
||||
setLoginOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
webui/src/features/dashboard/DashboardPage.tsx
Normal file
22
webui/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Campaigns" value="0" detail="Connect the API to load data" />
|
||||
<MetricCard label="Queued" value="0" tone="info" />
|
||||
<MetricCard label="Needs review" value="0" tone="warning" />
|
||||
<MetricCard label="Failed" value="0" tone="danger" />
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Recommended next action"><p className="muted">Create or open a campaign to continue.</p></Card>
|
||||
<Card title="System status"><p className="muted">API health and queue metrics will appear here.</p></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
493
webui/src/features/privacy/RetentionPolicyManagement.tsx
Normal file
493
webui/src/features/privacy/RetentionPolicyManagement.tsx
Normal 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";
|
||||
}
|
||||
290
webui/src/features/settings/SettingsPage.tsx
Normal file
290
webui/src/features/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { apiFetch } from "../../api/client";
|
||||
import { updateProfile } from "../../api/auth";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
return [
|
||||
{
|
||||
title: "ACCOUNT",
|
||||
items: [
|
||||
{ id: "profile", label: "My profile" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "Mail profiles" }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "UI SETTINGS",
|
||||
items: [
|
||||
{ id: "interface", label: "Interface" },
|
||||
{ id: "workspace", label: "Workspace" },
|
||||
{ id: "local-connection", label: "Local connection" },
|
||||
{ id: "notifications", label: "Notifications" }
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export default function SettingsPage({
|
||||
settings,
|
||||
auth,
|
||||
onSettingsChange,
|
||||
onAuthChange
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canUseMailProfiles = hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles), [canUseMailProfiles]);
|
||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
const [compactTables, setCompactTables] = useState(false);
|
||||
const [showHelpHints, setShowHelpHints] = useState(true);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [stickySections, setStickySections] = useState(true);
|
||||
const [profileName, setProfileName] = useState(auth.user.display_name || "");
|
||||
const [tenantProfileName, setTenantProfileName] = useState(auth.user.tenant_display_name || "");
|
||||
const [profileBusy, setProfileBusy] = useState(false);
|
||||
const [profileResult, setProfileResult] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
setActive(requestedSection);
|
||||
} else if (!settingsSectionAvailable(settingsSubnav, active)) {
|
||||
setActive("interface");
|
||||
}
|
||||
}, [active, requestedSection, settingsSubnav]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
setTenantProfileName(auth.user.tenant_display_name || "");
|
||||
}, [auth.user.display_name, auth.user.tenant_display_name]);
|
||||
|
||||
function selectSection(section: SettingsSection) {
|
||||
setActive(section);
|
||||
setSearchParams(section === "interface" ? {} : { section });
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
setProfileBusy(true);
|
||||
setProfileResult("");
|
||||
try {
|
||||
const next = await updateProfile(settings, {
|
||||
display_name: profileName.trim() || null,
|
||||
tenant_display_name: tenantProfileName.trim() || null
|
||||
});
|
||||
onAuthChange(next);
|
||||
setProfileResult("Profile saved. The account menu has been updated.");
|
||||
} catch (err) {
|
||||
setProfileResult(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setProfileBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult("");
|
||||
try {
|
||||
await apiFetch<unknown>(settings, "/health");
|
||||
setTestResult("Connection successful. The backend health endpoint responded.");
|
||||
} catch (err) {
|
||||
setTestResult(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<ModuleSubnav active={active} groups={settingsSubnav} onSelect={selectSection} />
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Settings</PageTitle>
|
||||
<p>Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "profile" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="My profile">
|
||||
<div className="form-grid">
|
||||
<FormField label="Account display name" help="This global name is shown in the title bar and follows you across tenant memberships.">
|
||||
<input value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder={auth.user.email} />
|
||||
</FormField>
|
||||
<FormField label="Tenant display name" help="Optional local alias for the active tenant. It does not replace the global account name in the title bar.">
|
||||
<input value={tenantProfileName} onChange={(event) => setTenantProfileName(event.target.value)} placeholder="Use account display name" />
|
||||
</FormField>
|
||||
<FormField label="Email">
|
||||
<input value={auth.user.email} disabled />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "Saving…" : "Save profile"}</Button>
|
||||
</div>
|
||||
{profileResult && <DismissibleAlert tone={profileResult.startsWith("Profile saved") ? "success" : "warning"} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Account context">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Account ID</dt><dd>{auth.user.account_id}</dd></div>
|
||||
<div><dt>Active tenant</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
|
||||
<div><dt>Tenant roles</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "None"}</dd></div>
|
||||
<div><dt>System roles</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "None"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Email changes and password management require dedicated identity-verification workflows and are intentionally not part of this first self-service profile editor.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "mail-profiles" && (
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="My mail server profiles"
|
||||
policyTitle="My mail profile policy"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{active === "interface" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Interface preferences">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Compact tables"
|
||||
help="Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally."
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show inline help hints"
|
||||
help="Controls contextual UI help markers once persisted user preferences are available."
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Reduce motion"
|
||||
help="Prepared preference for users who prefer fewer animations."
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Theme and language">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Theme</dt><dd>System default for now</dd></div>
|
||||
<div><dt>Accent color</dt><dd>Default brand accent</dd></div>
|
||||
<div><dt>Language</dt><dd>Browser/application default</dd></div>
|
||||
<div><dt>Density</dt><dd>{compactTables ? "Compact preview" : "Comfortable"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "workspace" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Campaign workspace">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Sticky section sidebars"
|
||||
help="Keeps campaign and admin section navigation in view while scrolling."
|
||||
checked={stickySections}
|
||||
onChange={setStickySections}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Keep page shell visible while loading"
|
||||
help="The current UI already keeps the section shell visible and overlays loading indicators during refresh."
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Editor behavior">
|
||||
<div className="placeholder-stack">
|
||||
<span>Manual save with unsaved-change guard</span>
|
||||
<span>Readable chooser fields for file/path selection</span>
|
||||
<span>Field-type-aware recipient data inputs</span>
|
||||
<span>Template placeholder chips and preview overlays</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "local-connection" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Local API connection">
|
||||
<div className="form-grid">
|
||||
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
|
||||
<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 })} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
||||
</div>
|
||||
{testResult && <DismissibleAlert tone={testResult.startsWith("Connection successful") ? "success" : "warning"} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Session state">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Browser session</dt><dd>Active via HttpOnly cookie</dd></div>
|
||||
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
|
||||
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "notifications" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Notification preferences">
|
||||
<p className="muted">Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>In-app completion notices</span>
|
||||
<span>Email summary preferences</span>
|
||||
<span>Failure and warning alerts</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Quiet UI mode">
|
||||
<div className="placeholder-stack">
|
||||
<span>Mute non-critical banners</span>
|
||||
<span>Batch repetitive notices</span>
|
||||
<span>Keep validation and send warnings visible</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
|
||||
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
|
||||
}
|
||||
Reference in New Issue
Block a user