initial commit after split

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

12
webui/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GovOPlaN</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1999
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
webui/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./app": {
"types": "./src/app.ts",
"import": "./src/app.ts"
}
},
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
},
"devDependencies": {
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependencies": {
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}

205
webui/src/App.tsx Normal file
View File

@@ -0,0 +1,205 @@
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { fetchMe } from "./api/auth";
import { fetchPlatformModules } from "./api/platform";
import { loadApiSettings, saveApiSettings } from "./api/client";
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
import { adminReadScopes } from "./utils/permissions";
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules } from "./platform/modules";
import PlaceholderPage from "./features/PlaceholderPage";
import { getCampaign } from "@govoplan/campaign-webui";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const CampaignListPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignListPage })));
const CampaignWorkspace = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignWorkspace })));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
const TemplatesPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.TemplatesPage })));
const FilesPage = lazy(() => import("@govoplan/files-webui").then((module) => ({ default: module.FilesPage })));
const OperatorQueuePage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.OperatorQueuePage })));
const AddressBookPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.AddressBookPage })));
export default function App() {
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
function updateSettings(next: ApiSettings) {
setSettings(next);
saveApiSettings(next);
}
function updateAuth(next: AuthInfo | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next);
if (accessToken !== undefined) {
setSettings(nextSettings);
saveApiSettings(nextSettings);
}
}
function handlePublicLogin(response: LoginResponse) {
const active = response.active_tenant ?? response.tenant;
updateAuth(
{
user: response.user,
tenant: active,
active_tenant: active,
tenants: response.tenants ?? [active],
scopes: response.scopes,
roles: response.roles,
groups: response.groups
},
""
);
}
useEffect(() => {
let cancelled = false;
setCheckingSession(true);
fetchMe(settings)
.then((me) => { if (!cancelled) setAuth(me); })
.catch(() => {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
}
})
.finally(() => {
if (!cancelled) setCheckingSession(false);
});
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!auth) return;
let cancelled = false;
fetchPlatformModules(settings)
.then((response) => { if (!cancelled) setPlatformModules(response.modules); })
.catch(() => { if (!cancelled) setPlatformModules(null); });
return () => { cancelled = true; };
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!auth) return;
let inFlight = false;
let lastRefreshAt = 0;
async function refreshVisibleSession() {
if (document.visibilityState === "hidden" || inFlight) return;
const now = Date.now();
if (now - lastRefreshAt < 5_000) return;
inFlight = true;
lastRefreshAt = now;
try {
setAuth(await fetchMe(settings));
} catch {
// A background refresh must not log the user out on a transient network error.
} finally {
inFlight = false;
}
}
window.addEventListener("focus", refreshVisibleSession);
document.addEventListener("visibilitychange", refreshVisibleSession);
return () => {
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
if (checkingSession) {
return (
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
<div className="public-landing">
<section className="public-card">
<div className="public-kicker">GovOPlaN</div>
<h1>Checking session...</h1>
<p>Please wait while the local session is verified.</p>
</section>
</div>
</AppShell>
);
}
if (!auth) {
return (
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
</AppShell>
);
}
const defaultRoute = firstAccessibleRoute(auth, webModules);
return (
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
<Suspense fallback={<div className="content-pad"><p className="muted">Loading module...</p></div>}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/campaigns" element={
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
<CampaignListPage settings={settings} />
</PermissionBoundary>
} />
<Route path="/campaigns/:campaignId/*" element={
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
<CampaignResourceRoute settings={settings} auth={auth} />
</PermissionBoundary>
} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/files" element={
<PermissionBoundary auth={auth} anyOf={["files:file:read"]} fallback={defaultRoute}>
<FilesPage settings={settings} auth={auth} />
</PermissionBoundary>
} />
<Route path="/address-book" element={<AddressBookPage />} />
<Route path="/operator" element={
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"]} fallback={defaultRoute}>
<OperatorQueuePage settings={settings} />
</PermissionBoundary>
} />
<Route path="/reports" element={
<PermissionBoundary auth={auth} anyOf={["campaigns:report:read"]} fallback={defaultRoute}>
<PlaceholderPage title="Reports" />
</PermissionBoundary>
} />
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
<Route path="/admin" element={
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
<AdminPage settings={settings} auth={auth} onAuthChange={updateAuth} />
</PermissionBoundary>
} />
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
</Routes>
</Suspense>
</AppShell>
);
}
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { campaignId = "" } = useParams();
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, campaignId, tenantId]);
return (
<ResourceAccessBoundary probe={probe} resetKey={`${tenantId}:${campaignId}`} fallback="/campaigns">
<CampaignWorkspace settings={settings} auth={auth} />
</ResourceAccessBoundary>
);
}

519
webui/src/api/admin.ts Normal file
View File

@@ -0,0 +1,519 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = {
account_id: string;
email: string;
display_name?: string | null;
};
export type RoleSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_builtin: boolean;
is_assignable: boolean;
user_assignments: number;
group_assignments: number;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
created_at: string;
updated_at: string;
system_template_id?: string | null;
system_required?: boolean;
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
account_is_active: boolean;
password_reset_required: boolean;
last_login_at?: string | null;
groups: GroupSummary[];
roles: RoleSummary[];
effective_scopes: string[];
is_owner: boolean;
is_last_active_owner: boolean;
created_at: string;
updated_at: string;
};
export type SystemAccountItem = {
account_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
roles: RoleSummary[];
last_login_at?: string | null;
};
export type SystemMembershipDraft = {
tenant_id: string;
is_active: boolean;
role_ids: string[];
group_ids: string[];
is_owner?: boolean;
is_last_active_owner?: boolean;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
settings: Record<string, unknown>;
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export type ApiKeyAdminItem = {
id: string;
user_id: string;
user_email: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
}
export function createTenant(settings: ApiSettings, payload: {
slug: string;
name: string;
owner_account_id?: string | null;
description?: string | null;
default_locale?: string;
settings?: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
}): Promise<TenantAdminItem> {
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
}
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
name: string;
description: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
}>): Promise<TenantAdminItem> {
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users;
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
group_ids?: string[];
role_ids?: string[];
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
}
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
display_name: string | null;
is_active: boolean;
group_ids: string[];
role_ids: string[];
}>): Promise<UserAdminItem> {
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups;
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
is_active?: boolean;
member_ids?: string[];
role_ids?: string[];
}): Promise<GroupSummary> {
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
name: string;
description: string | null;
is_active: boolean;
member_ids: string[];
role_ids: string[];
}>): Promise<GroupSummary> {
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
return response.roles;
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles;
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
role_ids?: string[];
}): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
method: "PUT",
body: JSON.stringify({ role_ids: roleIds })
});
}
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
scopes: string[];
expires_at?: string | null;
}): Promise<ApiKeyAdminItem & { secret: string }> {
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
}
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
role_ids?: string[];
memberships?: SystemMembershipDraft[];
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
method: "PUT", body: JSON.stringify({ memberships })
});
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
}

37
webui/src/api/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { ApiSettings, AuthInfo, LoginResponse } from "../types";
import { apiFetch } from "./client";
export async function login(
settings: ApiSettings,
payload: { email: string; password: string }
): Promise<LoginResponse> {
return apiFetch<LoginResponse>({ ...settings, accessToken: "", apiKey: "" }, "/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
method: "POST",
body: JSON.stringify({ tenant_id: tenantId })
});
}
export async function logout(settings: ApiSettings): Promise<void> {
await apiFetch(settings, "/api/v1/auth/logout", { method: "POST" });
}
export async function updateProfile(
settings: ApiSettings,
payload: { display_name?: string | null; tenant_display_name?: string | null }
): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
method: "PATCH",
body: JSON.stringify(payload)
});
}

155
webui/src/api/client.ts Normal file
View File

@@ -0,0 +1,155 @@
import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings";
const SESSION_STORAGE_KEY = "multimailer.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
readonly body: string;
constructor(status: number, statusText: string, body: string) {
super(`${status} ${statusText}: ${body}`);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
this.body = body;
}
}
export function isApiError(error: unknown, ...statuses: number[]): error is ApiError {
return error instanceof ApiError && (statuses.length === 0 || statuses.includes(error.status));
}
/**
* API endpoint helpers already pass paths beginning with /api/v1. The configured
* value is therefore an origin only. Normalize legacy values that included the
* API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs.
*/
export function normalizeApiBaseUrl(value: string): string {
const withoutTrailingSlash = value.trim().replace(/\/+$/, "");
return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, "");
}
export function apiUrl(settings: ApiSettings, path: string): string {
const baseUrl = normalizeApiBaseUrl(settings.apiBaseUrl);
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
}
export function loadApiSettings(): ApiSettings {
const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`);
const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? "";
const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl);
// Repair legacy values once loaded, while keeping an empty value for same-origin use.
if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) {
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl);
}
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
return {
// Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI.
apiBaseUrl,
apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "",
accessToken: ""
};
}
export function saveApiSettings(settings: ApiSettings): void {
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey);
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
}
export function clearAccessToken(): void {
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
}
function readCookie(name: string): string {
const prefix = `${encodeURIComponent(name)}=`;
return document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length) ?? "";
}
export function csrfToken(): string {
const value = readCookie(CSRF_COOKIE_NAME);
return value ? decodeURIComponent(value) : "";
}
function isUnsafeMethod(method?: string): boolean {
const normalized = (method || "GET").toUpperCase();
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
}
export function authHeaders(settings: ApiSettings): Headers {
const headers = new Headers();
if (settings.accessToken) {
headers.set("Authorization", `Bearer ${settings.accessToken}`);
} else if (settings.apiKey) {
headers.set("X-API-Key", settings.apiKey);
}
return headers;
}
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers || {});
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
for (const [key, value] of authHeaders(settings)) {
headers.set(key, value);
}
const csrf = csrfToken();
if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", csrf);
}
const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, response.statusText, text);
}
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return (await response.text()) as T;
}
return (await response.json()) as T;
}
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, response.statusText, text);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}

27
webui/src/api/platform.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { ApiSettings } from "../types";
import type { PlatformModuleInfo } from "../types";
import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
export type PlatformPermission = {
scope: string;
module_id: string;
resource: string;
action: string;
label: string;
description: string;
category: string;
level: "system" | "tenant";
deprecated: boolean;
};
export type PlatformPermissionsResponse = { permissions: PlatformPermission[] };
export async function fetchPlatformModules(settings: ApiSettings): Promise<PlatformModulesResponse> {
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
}
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
}

13
webui/src/app.ts Normal file
View File

@@ -0,0 +1,13 @@
import "./styles/tokens.css";
import "./styles/layout.css";
import "./styles/forms.css";
import "./styles/tables.css";
import "./styles/badges.css";
import "./styles/components.css";
import "./styles/dialogs.css";
import "./styles/retention-policies.css";
import "./styles/auth-gate.css";
import "@govoplan/campaign-webui/styles/campaign-workspace.css";
import "@govoplan/files-webui/styles/file-manager.css";
import "@govoplan/mail-webui/styles/mail-profiles.css";
export { default, default as GovoplanApp } from "./App";

View File

@@ -0,0 +1,57 @@
import { useEffect, useState, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import type { AuthInfo } from "../types";
import { isApiError } from "../api/client";
import DismissibleAlert from "./DismissibleAlert";
import { hasAnyScope } from "../utils/permissions";
type PermissionBoundaryProps = {
auth: AuthInfo;
anyOf: string[];
fallback: string;
children: ReactNode;
};
export function PermissionBoundary({ auth, anyOf, fallback, children }: PermissionBoundaryProps) {
return hasAnyScope(auth, anyOf) ? <>{children}</> : <Navigate to={fallback} replace />;
}
type ResourceAccessBoundaryProps = {
probe: () => Promise<unknown>;
resetKey: string;
fallback: string;
children: ReactNode;
};
/**
* Keeps resource routes tenant-agnostic. After a tenant switch the current URL
* is probed in the new tenant context; inaccessible or missing resources fall
* back to their parent collection instead of requiring switch-specific routing.
*/
export function ResourceAccessBoundary({ probe, resetKey, fallback, children }: ResourceAccessBoundaryProps) {
const [state, setState] = useState<"checking" | "allowed" | "denied" | "error">("checking");
const [message, setMessage] = useState("");
useEffect(() => {
let cancelled = false;
setState("checking");
setMessage("");
probe()
.then(() => { if (!cancelled) setState("allowed"); })
.catch((error) => {
if (cancelled) return;
if (isApiError(error, 403, 404)) {
setState("denied");
return;
}
setMessage(error instanceof Error ? error.message : String(error));
setState("error");
});
return () => { cancelled = true; };
}, [probe, resetKey]);
if (state === "denied") return <Navigate to={fallback} replace />;
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "The resource could not be loaded."}</DismissibleAlert></div>;
if (state === "checking") return <div className="content-pad"><p className="muted">Checking access</p></div>;
return <>{children}</>;
}

View File

@@ -0,0 +1,5 @@
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
return <button className={`btn btn-${variant} ${className}`} {...props} />;
}

View File

@@ -0,0 +1,44 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
type CardProps = {
title?: React.ReactNode;
children: React.ReactNode;
actions?: React.ReactNode;
collapsible?: boolean;
};
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
const [collapsed, setCollapsed] = useState(false);
const hasHeader = Boolean(title || actions || collapsible);
const body = <div className="card-body">{children}</div>;
const shouldRenderBody = !collapsible || !collapsed;
return (
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
{hasHeader && (
<header className="card-header">
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
{(actions || collapsible) && (
<div className="card-actions">
{actions}
{collapsible && (
<button
type="button"
className="card-collapse-toggle"
aria-label={collapsed ? "Show content" : "Show header only"}
aria-expanded={!collapsed}
title={collapsed ? "Show content" : "Show header only"}
onClick={() => setCollapsed((value) => !value)}
>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>
)}
</div>
)}
</header>
)}
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
</section>
);
}

View File

@@ -0,0 +1,50 @@
import Button from "./Button";
import Dialog from "./Dialog";
export type ConfirmDialogTone = "default" | "danger";
export type ConfirmDialogProps = {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: ConfirmDialogTone;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
tone = "default",
busy = false,
onConfirm,
onCancel
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
title={title}
role="alertdialog"
className="confirm-dialog"
footerClassName="button-row compact-actions"
closeOnBackdrop={!busy}
closeDisabled={busy}
showCloseButton
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
</>
)}
>
<p>{message}</p>
</Dialog>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useId, type ReactNode } from "react";
export type DialogProps = {
open: boolean;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
closeLabel?: string;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
closeDisabled?: boolean;
onClose?: () => void;
className?: string;
backdropClassName?: string;
headerClassName?: string;
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
};
function joinClasses(...classes: Array<string | undefined | false>) {
return classes.filter(Boolean).join(" ");
}
export default function Dialog({
open,
title,
children,
footer,
role = "dialog",
ariaDescribedBy,
closeLabel = "Close",
closeOnBackdrop = true,
showCloseButton = true,
closeDisabled = false,
onClose,
className = "",
backdropClassName = "",
headerClassName = "",
titleClassName = "",
bodyClassName = "",
footerClassName = ""
}: DialogProps) {
const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled;
useEffect(() => {
if (!open || !canClose) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose?.();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [canClose, onClose, open]);
if (!open) return null;
return (
<div
className={joinClasses("dialog-backdrop", backdropClassName)}
role="presentation"
onMouseDown={(event) => {
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
}}
>
<section
className={joinClasses("dialog-panel", className)}
role={role}
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
>
<div className={joinClasses("dialog-header", headerClassName)}>
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{title}</h2>
{showCloseButton && onClose && (
<button
type="button"
className="dialog-close"
onClick={onClose}
aria-label={closeLabel}
disabled={closeDisabled}
>
×
</button>
)}
</div>
<div className={joinClasses("dialog-body", bodyClassName)}>{children}</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{footer}</div>}
</section>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
type AlertTone = "success" | "info" | "warning" | "danger";
type DismissibleAlertProps = {
tone?: AlertTone;
children: ReactNode;
dismissible?: boolean;
className?: string;
compact?: boolean;
floating?: boolean;
resetKey?: string | number;
};
let floatingAlertRoot: HTMLElement | null = null;
function getFloatingAlertRoot(): HTMLElement | null {
if (typeof document === "undefined") return null;
if (floatingAlertRoot?.isConnected) return floatingAlertRoot;
const existing = document.getElementById("app-floating-alerts");
if (existing) {
floatingAlertRoot = existing;
return floatingAlertRoot;
}
floatingAlertRoot = document.createElement("div");
floatingAlertRoot.id = "app-floating-alerts";
floatingAlertRoot.className = "alert-floating-stack";
floatingAlertRoot.setAttribute("aria-label", "Application notices");
document.body.appendChild(floatingAlertRoot);
return floatingAlertRoot;
}
export default function DismissibleAlert({
tone = "info",
children,
dismissible = true,
className = "",
compact = false,
floating = false,
resetKey
}: DismissibleAlertProps) {
const [visible, setVisible] = useState(true);
useEffect(() => {
setVisible(true);
}, [resetKey, children]);
if (!visible) return null;
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
const alert = (
<div
className={`alert ${tone}${compact ? " compact-alert" : ""}${floating ? " alert-floating" : ""} alert-dismissible ${className}`.trim()}
role={role}
aria-live={role === "alert" ? "assertive" : "polite"}
>
<div className="alert-message">{children}</div>
{dismissible && (
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
<X size={16} strokeWidth={2.4} aria-hidden="true" />
</button>
)}
</div>
);
if (!floating) return alert;
const root = getFloatingAlertRoot();
return root ? createPortal(alert, root) : alert;
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) {
return (
<label className="form-field">
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
{children}
</label>
);
}

View File

@@ -0,0 +1,26 @@
import LoadingIndicator from "./LoadingIndicator";
type LoadingFrameProps = {
children: React.ReactNode;
loading?: boolean;
label?: string;
className?: string;
};
export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) {
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
return (
<div className={classNames} aria-busy={loading || undefined}>
{children}
{loading && (
<div className="loading-frame-overlay" role="status" aria-live="polite">
<div className="loading-frame-panel">
<LoadingIndicator label={label} size="md" />
<span>{label}</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
type LoadingIndicatorProps = {
label?: string;
size?: "sm" | "md";
};
export default function LoadingIndicator({ label = "Loading", size = "sm" }: LoadingIndicatorProps) {
return (
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={label} title={label}>
<span className="loading-envelope" aria-hidden="true" />
</span>
);
}

View File

@@ -0,0 +1,9 @@
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import LoadingIndicator from "./LoadingIndicator";
type PageTitleProps = {
children: React.ReactNode;
loading?: boolean;
};
export default function PageTitle({ children, loading = false }: PageTitleProps) {
return (
<h1 className="page-title-with-loader">
<span>{children}</span>
{loading && <LoadingIndicator label="Loading page data" />}
</h1>
);
}

View File

@@ -0,0 +1,3 @@
export default function StatusBadge({ status, label }: { status: string; label?: string }) {
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{label ?? status}</span>;
}

View File

@@ -0,0 +1,19 @@
import type { WizardStep } from "../types";
export default function Stepper({ steps, activeStep, onSelect }: { steps: WizardStep[]; activeStep: string; onSelect: (id: string) => void }) {
return (
<ol className="stepper">
{steps.map((step, index) => (
<li key={step.id} className={`step ${activeStep === step.id ? "active" : ""} step-${step.status || "todo"}`}>
<button onClick={() => onSelect(step.id)}>
<span className="step-number">{index + 1}</span>
<span>
<strong>{step.label}</strong>
{step.description && <small>{step.description}</small>}
</span>
</button>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
type ToggleSwitchProps = {
label: ReactNode;
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
help?: ReactNode;
};
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<input
className="toggle-switch-input"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange?.(event.target.checked)}
/>
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
<span className="toggle-switch-copy">
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
</span>
</label>
);
}

View File

@@ -0,0 +1,246 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import Button from "../Button";
import {
addressDisplayName,
dedupeAddresses,
isValidEmailAddress,
normalizeEmailAddress,
parseMailboxAddressText,
type MailboxAddress
} from "../../utils/emailAddresses";
type EmailAddressInputProps = {
value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void;
suggestions?: MailboxAddress[];
allowMultiple?: boolean;
clearOnAdd?: boolean;
disabled?: boolean;
addLabel?: string;
namePlaceholder?: string;
emailPlaceholder?: string;
emptyText?: string;
compact?: boolean;
showAddButton?: boolean;
};
export default function EmailAddressInput({
value,
onChange,
onAddressAdded,
suggestions = [],
allowMultiple = true,
clearOnAdd = false,
disabled = false,
addLabel = "Add",
namePlaceholder = "Name",
emailPlaceholder = "email@example.org",
emptyText = "No address added yet.",
compact = false,
showAddButton
}: EmailAddressInputProps) {
const inputId = useId();
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
const [entryText, setEntryText] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogName, setDialogName] = useState("");
const [dialogEmail, setDialogEmail] = useState("");
const [error, setError] = useState("");
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const addButtonRef = useRef<HTMLButtonElement | null>(null);
const canUseAddButton = showAddButton ?? allowMultiple;
const filteredSuggestions = useMemo(() => {
const query = entryText.trim().toLowerCase();
if (!query) return normalizedSuggestions.slice(0, 6);
return normalizedSuggestions
.filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query))
.slice(0, 6);
}, [entryText, normalizedSuggestions]);
useEffect(() => {
if (!dialogOpen) return;
function updatePopoverPosition() {
const trigger = addButtonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportPadding = 16;
const width = Math.min(360, Math.max(280, window.innerWidth - viewportPadding * 2));
const estimatedHeight = 250;
const left = Math.min(Math.max(viewportPadding, rect.right - width), window.innerWidth - width - viewportPadding);
const belowTop = rect.bottom + 8;
const aboveTop = rect.top - estimatedHeight - 8;
const top = belowTop + estimatedHeight > window.innerHeight && aboveTop > viewportPadding ? aboveTop : belowTop;
setPopoverStyle({
position: "fixed",
top,
left,
width,
zIndex: 10000
});
}
updatePopoverPosition();
window.addEventListener("resize", updatePopoverPosition);
window.addEventListener("scroll", updatePopoverPosition, true);
return () => {
window.removeEventListener("resize", updatePopoverPosition);
window.removeEventListener("scroll", updatePopoverPosition, true);
};
}, [dialogOpen]);
function removeAddress(emailToRemove: string) {
if (disabled) return;
onChange?.(normalizedValue.filter((address) => address.email !== emailToRemove));
setError("");
}
function commitAddress(candidate: MailboxAddress | null, sourceText = "") {
if (disabled) return false;
if (!candidate?.email) {
setError(sourceText ? "Use a valid address such as Name <email@example.org>." : "Enter an email address first.");
return false;
}
const normalized = normalizeEmailAddress(candidate);
if (!isValidEmailAddress(normalized.email)) {
setError("Enter a valid email address.");
return false;
}
if (allowMultiple && normalizedValue.some((address) => address.email === normalized.email)) {
setError("This address is already listed.");
return false;
}
onAddressAdded?.(normalized);
if (!clearOnAdd) {
const nextValue = allowMultiple ? dedupeAddresses([...normalizedValue, normalized]) : [normalized];
onChange?.(nextValue);
}
setEntryText("");
setDialogName("");
setDialogEmail("");
setDialogOpen(false);
setError("");
return true;
}
function commitTypedAddress() {
const text = entryText.trim();
if (!text) return;
commitAddress(parseMailboxAddressText(text), text);
}
function commitDialogAddress() {
commitAddress({ name: dialogName, email: dialogEmail }, `${dialogName} ${dialogEmail}`);
}
function handleTextKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
commitTypedAddress();
return;
}
if (event.key === "Backspace" && !entryText && normalizedValue.length > 0 && !disabled) {
event.preventDefault();
const last = normalizedValue[normalizedValue.length - 1];
removeAddress(last.email);
}
}
function applySuggestion(address: MailboxAddress) {
commitAddress(address);
}
const addressDialog = dialogOpen && canUseAddButton && !disabled ? createPortal(
<div
className="email-address-popover"
style={popoverStyle}
role="dialog"
aria-modal="false"
aria-labelledby={`${inputId}-dialog-title`}
onKeyDown={(event) => {
if (event.key === "Escape") setDialogOpen(false);
}}
>
<h4 id={`${inputId}-dialog-title`}>Add address</h4>
<label>
<span>Name</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
</label>
<label>
<span>Email address</span>
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
</label>
<div className="button-row compact-actions">
<Button type="button" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
</div>
</div>,
document.body
) : null;
return (
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>}
{normalizedValue.map((address) => {
const valid = isValidEmailAddress(address.email);
return (
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "Invalid email address"}>
<span className="email-chip-main">{addressDisplayName(address)}</span>
{address.name && <span className="email-chip-address">{address.email}</span>}
{!disabled && (
<button type="button" className="email-chip-remove" aria-label={`Remove ${address.email}`} onClick={() => removeAddress(address.email)}>
×
</button>
)}
</span>
);
})}
</div>
{!disabled && (
<>
<textarea
id={inputId}
className="email-address-textarea"
rows={1}
value={entryText}
onChange={(event) => {
setEntryText(event.target.value);
setError("");
}}
onKeyDown={handleTextKeyDown}
placeholder={`${namePlaceholder} <${emailPlaceholder}>`}
aria-label="Type a name and email address, then press Enter"
/>
{canUseAddButton && (
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="Open address form" title="Add address with form" onClick={() => setDialogOpen((open) => !open)}>
+
</button>
)}
</>
)}
</div>
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && (
<div className="email-address-suggestions" role="listbox" aria-label="Address suggestions">
{filteredSuggestions.map((item) => (
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
<span>{addressDisplayName(item)}</span>
{item.name && <small>{item.email}</small>}
</button>
))}
</div>
)}
{error && <p className="form-help danger-text">{error}</p>}
{addressDialog}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import InlineHelp from "./InlineHelp";
type FieldLabelProps = {
children: ReactNode;
help?: ReactNode;
className?: string;
};
export default function FieldLabel({ children, help, className = "" }: FieldLabelProps) {
return (
<span className={`field-label ${className}`.trim()}>
<span className="field-label-text">{children}</span>
{help && <InlineHelp>{help}</InlineHelp>}
</span>
);
}

View File

@@ -0,0 +1,186 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
type InlineHelpProps = {
children: ReactNode;
className?: string;
};
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 10000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove
? topCandidate
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid var(--line-dark)",
borderTop: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid var(--line-dark)",
borderBottom: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={`inline-help ${className}`.trim()}
tabIndex={0}
aria-label="Show field help"
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}
>
<span className="inline-help-mark" aria-hidden="true">?</span>
</span>
{isOpen && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
</>
);
}

View 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>;
}

View 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>;
}

View 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()} />
</>
);
}

View 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()} />
</>
);
}

View 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()} />
</>
);
}

View 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>
);
}

View 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)}
/>
</>
);
}

View 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()} />
</>
);
}

View 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()} />
</>
);
}

View 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>
);
}

View 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()} />
</>
);
}

View 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>;
}

View 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()} />
</>
);
}

View 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(", ") : "—";
}

View 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>
);
}

View 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>
);
}

View File

@@ -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, "");
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,493 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
getPrivacyRetentionPolicy,
updatePrivacyRetentionPolicy,
type PrivacyRetentionLimitPermissionPatch,
type PrivacyRetentionLimitPermissions,
type PrivacyRetentionPolicy,
type PrivacyRetentionPolicyFieldKey,
type PrivacyRetentionPolicyPatch,
type PrivacyRetentionPolicyScope
} from "../../api/admin";
import Button from "../../components/Button";
import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert";
import FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
export type RetentionPolicyTargetOption = {
id: string;
label: string;
secondary?: string | null;
};
type RetentionPolicyScopeManagerProps = {
settings: ApiSettings;
scopeType: PrivacyRetentionPolicyScope;
scopeId?: string | null;
targetOptions?: RetentionPolicyTargetOption[];
targetLabel?: string;
title?: string;
description?: string;
canWrite: boolean;
locked?: boolean;
};
type RetentionPolicyEditorProps = {
settings: ApiSettings;
scopeType: PrivacyRetentionPolicyScope;
scopeId?: string | null;
title?: string;
description?: string;
canWrite: boolean;
locked?: boolean;
};
type DayKey =
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days";
type FieldDefinition = {
key: PrivacyRetentionPolicyFieldKey;
label: string;
kind: "raw-json" | "days" | "audit";
systemOnly?: boolean;
};
type RawJsonValue = "inherit" | "keep" | "disable";
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: true,
generated_eml_retention_days: true,
stored_report_detail_retention_days: true,
mock_mailbox_retention_days: true,
audit_detail_retention_days: true,
audit_detail_level: true
};
const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: null,
generated_eml_retention_days: null,
stored_report_detail_retention_days: null,
mock_mailbox_retention_days: null,
audit_detail_retention_days: null,
audit_detail_level: "full",
allow_lower_level_limits: defaultAllowLowerLevelLimits
};
const fieldDefinitions: FieldDefinition[] = [
{ key: "store_raw_campaign_json", label: "Raw campaign JSON", kind: "raw-json" },
{ key: "raw_campaign_json_retention_days", label: "Raw campaign JSON days", kind: "days" },
{ key: "generated_eml_retention_days", label: "Generated EML days", kind: "days" },
{ key: "stored_report_detail_retention_days", label: "Stored report detail days", kind: "days" },
{ key: "mock_mailbox_retention_days", label: "Mock mailbox days", kind: "days", systemOnly: true },
{ key: "audit_detail_retention_days", label: "Audit detail days", kind: "days" },
{ key: "audit_detail_level", label: "Audit detail level", kind: "audit" }
];
export function RetentionPolicyScopeManager({
settings,
scopeType,
scopeId = null,
targetOptions = [],
targetLabel = "Target",
title = "Retention policy",
description,
canWrite,
locked = false
}: RetentionPolicyScopeManagerProps) {
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const activeScopeId = scopeId || selectedTargetId || null;
const defaultDescription = scopeType === "system"
? "System retention defaults and the fields lower levels may limit further."
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
useEffect(() => {
if (scopeId) {
setSelectedTargetId(scopeId);
return;
}
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
setSelectedTargetId(targetOptions[0].id);
}
}, [scopeId, selectedTargetId, targetOptions]);
return (
<div className="retention-policy-manager">
{targetOptions.length > 0 && !scopeId && (
<Card title={`${targetLabel} scope`}>
<div className="retention-policy-target-row">
<FormField label={targetLabel}>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
</select>
</FormField>
</div>
</Card>
)}
<RetentionPolicyEditor
settings={settings}
scopeType={scopeType}
scopeId={activeScopeId}
title={title}
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
canWrite={canWrite}
locked={locked}
/>
</div>
);
}
export function RetentionPolicyEditor({
settings,
scopeType,
scopeId = null,
title = "Retention policy",
description,
canWrite,
locked = false
}: RetentionPolicyEditorProps) {
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const isSystem = scopeType === "system";
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const scopeReady = !requiresTarget || Boolean(scopeId);
const disabled = locked || loading || busy || !canWrite || !scopeReady;
const showAllowColumn = scopeType !== "campaign";
const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null;
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]);
async function loadPolicy() {
setError("");
setSuccess("");
if (!scopeReady) {
setPolicy({});
setEffectivePolicy(null);
setParentPolicy(null);
return;
}
setLoading(true);
try {
const response = await getPrivacyRetentionPolicy(settings, scopeType, scopeId);
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
} catch (err) {
setPolicy({});
setEffectivePolicy(null);
setParentPolicy(null);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy() {
if (!scopeReady) return;
setBusy(true);
setError("");
setSuccess("");
try {
const payload = isSystem ? normalizeFullPolicy(policy) : normalizePatchForSave(policy, activeParentPolicy, scopeType);
const response = await updatePrivacyRetentionPolicy(settings, scopeType, payload, scopeId);
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
setSuccess("Retention policy saved.");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function patchPolicy(patch: PrivacyRetentionPolicyPatch) {
const next = { ...policy, ...patch };
if (policy.allow_lower_level_limits || patch.allow_lower_level_limits) {
next.allow_lower_level_limits = { ...(policy.allow_lower_level_limits ?? {}), ...(patch.allow_lower_level_limits ?? {}) };
}
setPolicy(isSystem ? normalizeFullPolicy(next) : normalizePatch(next));
}
function setRawJson(value: RawJsonValue | "store" | "disabled") {
if (isSystem) {
patchPolicy({ store_raw_campaign_json: value === "store" });
return;
}
patchPolicy({ store_raw_campaign_json: value === "inherit" ? undefined : value === "keep" });
}
function setRetentionDays(key: DayKey, value: string) {
const trimmed = value.trim();
patchPolicy({ [key]: trimmed === "" ? (isSystem ? null : undefined) : Math.max(0, Number(trimmed) || 0) });
}
function setAuditDetail(value: AuditDetailValue) {
patchPolicy({ audit_detail_level: value === "inherit" ? undefined : value });
}
function setAllowLowerLevelLimit(key: PrivacyRetentionPolicyFieldKey, allowed: boolean) {
patchPolicy({ allow_lower_level_limits: { [key]: allowed } });
}
function parentAllows(key: PrivacyRetentionPolicyFieldKey): boolean {
return !activeParentPolicy || activeParentPolicy.allow_lower_level_limits[key] !== false;
}
function localAllowsLower(key: PrivacyRetentionPolicyFieldKey): boolean {
if (isSystem) return activeFullPolicy.allow_lower_level_limits[key] !== false;
const localValue = policy.allow_lower_level_limits?.[key];
if (localValue !== undefined) return localValue && parentAllows(key);
return parentAllows(key);
}
const localPolicySummary = useMemo(() => isSystem ? "System baseline" : localPolicyCount(policy), [isSystem, policy]);
const defaultDescription = isSystem
? "Set concrete system retention values. Blank day fields mean unlimited retention."
: "Blank values inherit. Numeric values may only shorten the inherited retention period; audit detail may only become more restrictive.";
return (
<Card
title={title}
actions={
<div className="button-row compact-actions">
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
</div>
}
>
<div className="retention-policy-editor">
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
{!isSystem && <p className="muted small-note retention-policy-description">Mock mailbox retention is currently managed at system level because mock mailbox records do not carry tenant or campaign ownership metadata.</p>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
<section className="retention-policy-section">
<div className="subsection-heading split">
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
<span className="muted small-note">{localPolicySummary}</span>
</div>
<div className={`retention-policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
<div className="retention-policy-row retention-policy-row-header">
<span>Field</span>
<span>Value</span>
{showAllowColumn && <span>Lower levels</span>}
</div>
{visibleFields.map((field) => {
const fieldLocked = !parentAllows(field.key);
const fieldDisabled = disabled || fieldLocked;
return (
<div className="retention-policy-row" key={field.key}>
<div className="retention-policy-field-label">
<strong>{field.label}</strong>
{fieldLocked && <small>Locked by parent policy</small>}
{field.systemOnly && <small>System-level cleanup scope</small>}
</div>
<div>{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
{showAllowColumn && (
<ToggleSwitch
checked={localAllowsLower(field.key)}
disabled={disabled || fieldLocked}
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
label="Allow limiting"
/>
)}
</div>
);
})}
</div>
</section>
{effectivePolicy && (
<section className="retention-policy-section retention-policy-effective">
<h3>Effective policy</h3>
<div className="retention-policy-effective-grid">
<PolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
<PolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
<PolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
<PolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
<PolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
<PolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
<PolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
{showAllowColumn && <PolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
</div>
</section>
)}
</div>
</Card>
);
}
function renderFieldControl(
field: FieldDefinition,
fullPolicy: PrivacyRetentionPolicy,
patchPolicy: PrivacyRetentionPolicyPatch,
isSystem: boolean,
disabled: boolean,
setRawJson: (value: RawJsonValue | "store" | "disabled") => void,
setRetentionDays: (key: DayKey, value: string) => void,
setAuditDetail: (value: AuditDetailValue) => void,
parentPolicy: PrivacyRetentionPolicy | null
) {
if (field.kind === "raw-json") {
if (isSystem) {
return <RawJsonSystemSelect value={fullPolicy.store_raw_campaign_json ? "store" : "disabled"} disabled={disabled} onChange={setRawJson} />;
}
return <RawJsonScopedSelect value={rawJsonValue(patchPolicy.store_raw_campaign_json)} parentStoresRawJson={parentPolicy?.store_raw_campaign_json ?? true} disabled={disabled} onChange={setRawJson} />;
}
if (field.kind === "audit") {
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} disabled={disabled} onChange={setAuditDetail} />;
}
return (
<RetentionDaysField
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
disabled={disabled}
placeholder={isSystem ? "Unlimited" : "Inherit"}
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
/>
);
}
function RawJsonSystemSelect({ value, disabled, onChange }: { value: "store" | "disabled"; disabled: boolean; onChange: (value: "store" | "disabled") => void }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as "store" | "disabled")}>
<option value="store">Store</option>
<option value="disabled">Disable storage</option>
</select>
);
}
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: { value: RawJsonValue; parentStoresRawJson: boolean; disabled: boolean; onChange: (value: RawJsonValue) => void }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as RawJsonValue)}>
<option value="inherit">Inherit</option>
<option value="keep" disabled={!parentStoresRawJson}>Store if parent allows</option>
<option value="disable">Disable storage</option>
</select>
);
}
function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) {
return <input type="number" min={0} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
}
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
{includeInherit && <option value="inherit">Inherit</option>}
<option value="full">Full</option>
<option value="redacted">Redacted</option>
<option value="minimal">Minimal</option>
</select>
);
}
function PolicyValue({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function rawJsonValue(value: boolean | undefined): RawJsonValue {
if (value === undefined) return "inherit";
return value ? "keep" : "disable";
}
function auditDetailValue(value: PrivacyRetentionPolicyPatch["audit_detail_level"]): AuditDetailValue {
return value ?? "inherit";
}
function daysLabel(value?: number | null): string {
return value === null || value === undefined ? "Unlimited" : `${value} day${value === 1 ? "" : "s"}`;
}
function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]): string {
if (value === "full") return "Full";
if (value === "redacted") return "Redacted";
return "Minimal";
}
function allowLowerSummary(allow: PrivacyRetentionLimitPermissions): string {
const count = fieldDefinitions.filter((field) => allow[field.key] !== false).length;
if (count === fieldDefinitions.length) return "Allowed";
if (count === 0) return "Locked";
return `${count}/${fieldDefinitions.length} allowed`;
}
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
let count = 0;
for (const field of fieldDefinitions) {
if (field.systemOnly) continue;
if (policy[field.key as keyof PrivacyRetentionPolicyPatch] !== undefined && policy[field.key as keyof PrivacyRetentionPolicyPatch] !== null) count += 1;
}
const allowCount = Object.keys(policy.allow_lower_level_limits ?? {}).length;
count += allowCount;
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
}
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
}
function normalizeFullPolicy(policy: PrivacyRetentionPolicyPatch | Partial<PrivacyRetentionPolicy> | null | undefined): PrivacyRetentionPolicy {
return {
...defaultPrivacyPolicy,
...(policy ?? {}),
raw_campaign_json_retention_days: policy?.raw_campaign_json_retention_days ?? null,
generated_eml_retention_days: policy?.generated_eml_retention_days ?? null,
stored_report_detail_retention_days: policy?.stored_report_detail_retention_days ?? null,
mock_mailbox_retention_days: policy?.mock_mailbox_retention_days ?? null,
audit_detail_retention_days: policy?.audit_detail_retention_days ?? null,
allow_lower_level_limits: normalizeAllowLimits(policy?.allow_lower_level_limits)
};
}
function normalizePatch(policy: PrivacyRetentionPolicyPatch | null | undefined): PrivacyRetentionPolicyPatch {
const normalized: PrivacyRetentionPolicyPatch = {};
if (!policy) return normalized;
if (policy.store_raw_campaign_json !== undefined) normalized.store_raw_campaign_json = policy.store_raw_campaign_json;
for (const field of fieldDefinitions) {
if (field.kind !== "days" || field.systemOnly) continue;
const value = policy[field.key as DayKey];
if (value !== undefined && value !== null) normalized[field.key as DayKey] = Math.max(0, Number(value) || 0);
}
if (policy.audit_detail_level) normalized.audit_detail_level = policy.audit_detail_level;
if (policy.allow_lower_level_limits) normalized.allow_lower_level_limits = { ...policy.allow_lower_level_limits };
return normalized;
}
function normalizePatchForSave(policy: PrivacyRetentionPolicyPatch, parentPolicy: PrivacyRetentionPolicy | null, scopeType: PrivacyRetentionPolicyScope): PrivacyRetentionPolicyPatch {
const normalized = normalizePatch(policy);
const allowPatch = { ...(normalized.allow_lower_level_limits ?? {}) };
for (const field of fieldDefinitions) {
if (field.systemOnly || scopeType === "campaign") delete allowPatch[field.key];
if (field.systemOnly) delete normalized[field.key as keyof PrivacyRetentionPolicyPatch];
if (parentPolicy && parentPolicy.allow_lower_level_limits[field.key] === false) {
delete normalized[field.key as keyof PrivacyRetentionPolicyPatch];
delete allowPatch[field.key];
}
}
if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch;
else delete normalized.allow_lower_level_limits;
return normalized;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return "Request failed";
}

View 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)));
}

42
webui/src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
export * from "./types";
export * from "./api/client";
export * from "./api/auth";
export * from "./api/platform";
export * from "./utils/permissions";
export * from "./utils/fieldHelp";
export * from "./utils/helpContext";
export * from "./utils/emailAddresses";
export * from "./utils/arrayOrder";
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
export { default as Button } from "./components/Button";
export { default as Card } from "./components/Card";
export { default as ConfirmDialog } from "./components/ConfirmDialog";
export { default as Dialog } from "./components/Dialog";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as FormField } from "./components/FormField";
export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as MetricCard } from "./components/MetricCard";
export { default as PageTitle } from "./components/PageTitle";
export { default as StatusBadge } from "./components/StatusBadge";
export { default as Stepper } from "./components/Stepper";
export { default as ToggleSwitch } from "./components/ToggleSwitch";
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
export { default as LoginModal } from "./features/auth/LoginModal";
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
export { default as AppShell } from "./layout/AppShell";
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
export { default as HelpMenu } from "./layout/HelpMenu";
export { default as IconRail } from "./layout/IconRail";
export { default as ModuleSubnav } from "./layout/ModuleSubnav";
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
export { default as Titlebar } from "./layout/Titlebar";

View File

@@ -0,0 +1,50 @@
import { useLocation } from "react-router-dom";
import type { ApiSettings, AuthInfo, PlatformNavItem } from "../types";
import IconRail from "./IconRail";
import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar";
type Props = {
children: React.ReactNode;
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
publicMode?: boolean;
navItems?: PlatformNavItem[];
};
export default function AppShell({
children,
settings,
auth,
onSettingsChange,
onAuthChange,
publicMode = false,
navItems = []
}: Props) {
const location = useLocation();
if (publicMode) {
return (
<div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} />
<div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<main className="public-content">{children}</main>
</div>
</div>
);
}
return (
<div className="app-shell">
<IconRail auth={auth} navItems={navItems} />
<div className="app-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<BreadcrumbBar pathname={location.pathname} />
<main className="app-content">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
export default function BreadcrumbBar({ pathname }: { pathname: string }) {
const parts = pathname.split("/").filter(Boolean);
const labels = parts.length ? parts : ["campaigns"];
return (
<div className="breadcrumb-bar">
<nav className="breadcrumbs" aria-label="Breadcrumb">
{labels.map((part, index) => {
const href = `/${labels.slice(0, index + 1).join("/")}`;
return (
<span className="crumb" key={`${part}-${index}`}>
<Link className="crumb-link" to={href}>{labelFor(part, labels, index)}</Link>
{index < labels.length - 1 && <ChevronRight size={16} aria-hidden="true" />}
</span>
);
})}
</nav>
</div>
);
}
const campaignRouteLabels: Record<string, string> = {
data: "Sender & Recipients",
campaign: "Sender & Recipients",
settings: "Campaign settings",
"global-settings": "Campaign settings",
fields: "Fields",
recipients: "Sender & Recipients",
"recipient-data": "Recipient data",
template: "Template",
files: "Attachments",
attachments: "Attachments",
mail: "Server settings",
"mail-settings": "Server settings",
"server-settings": "Server settings",
review: "Review & Send",
send: "Review & Send",
report: "Report",
reports: "Report",
audit: "Audit log",
json: "JSON",
wizard: "Wizard",
create: "Create",
};
const topLevelRouteLabels: Record<string, string> = {
campaigns: "Campaigns",
dashboard: "Dashboard",
templates: "Templates",
files: "Files",
"address-book": "Address Book",
reports: "Reports",
settings: "Settings",
admin: "Admin",
};
function labelFor(value: string, parts: string[], index: number): string {
if (parts[0] === "campaigns" && index === 1) return "Campaign";
if (parts[0] === "campaigns" && index >= 2) {
const mapped = campaignRouteLabels[value];
if (mapped) return mapped;
}
const mapped = topLevelRouteLabels[value];
if (mapped) return mapped;
if (value.length > 18) return "Campaign";
return value.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import Button from "../components/Button";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
export default function HelpMenu() {
const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [contextOpen, setContextOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const helpContext = helpContextForPathname(location.pathname);
useEffect(() => {
function openContextHelp(event: KeyboardEvent) {
if (event.key !== "F1") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
setOpen(false);
setContextOpen(true);
}
function onPointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
window.addEventListener("keydown", openContextHelp, true);
window.addEventListener("mousedown", onPointerDown);
return () => {
window.removeEventListener("keydown", openContextHelp, true);
window.removeEventListener("mousedown", onPointerDown);
};
}, []);
function openHelp() {
setOpen(false);
setContextOpen(true);
}
return (
<div className="context-menu-wrap" ref={wrapRef}>
<button
className="titlebar-link"
onClick={() => setOpen(!open)}
onKeyDown={(event) => {
if (event.key === "F1") {
event.preventDefault();
event.stopPropagation();
openHelp();
}
}}
title="Help (F1)"
>
<HelpCircle size={17} /> Help
</button>
{open && (
<div className="dropdown-menu">
<button className="dropdown-item" onClick={openHelp}>
<HelpCircle size={16} /> Help <small>F1</small>
</button>
<hr />
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> User docs</a>
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> Admin docs</a>
<hr />
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> GitLab</a>
<button className="dropdown-item" onClick={() => { setAboutOpen(true); setOpen(false); }}><Info size={16} /> About</button>
</div>
)}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
</div>
);
}
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" data-help-context={context.id}>
<div className="modal-panel">
<header className="modal-header">
<h2>Context help</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="help-panel-section">
<h3>{context.title}</h3>
<p className="mono-small">Help context: {context.id}</p>
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
</div>
<div className="help-panel-section">
<h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
</div>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function AboutModal({ onClose }: { onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel">
<header className="modal-header">
<h2>About MultiMailer</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="about-logo" />
<p><strong>MultiMailer WebUI</strong></p>
<p className="muted">Version 0.1.0 early development build.</p>
<p>MultiMailer is a local-first / server-assisted campaign mailer for structured, personalized bulk messages with attachment resolution, review workflows and auditable sending.</p>
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
<p className="muted">License: project license pending / to be finalized. Backend components are currently development prototypes.</p>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Settings, Shield } from "lucide-react";
import { NavLink } from "react-router-dom";
import type { AuthInfo, PlatformNavItem } from "../types";
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems]
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
.filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export default function IconRail({
compact = false,
auth = null,
navItems = []
}: {
compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[];
}) {
const visibleItems = visibleNavItems(auth, navItems);
const items = hasAnyScope(auth, adminReadScopes)
? [...visibleItems, { to: "/admin", label: "Admin", icon: Shield }]
: visibleItems;
return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
<div className="brand-mark" title="GovOPlaN">G</div>
{!compact && (
<>
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={label}>
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
</NavLink>
))}
</nav>
<div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
<Settings size={20} />
</NavLink>
</div>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,62 @@
export type ModuleSubnavItem<T extends string> = {
id: T;
label: string;
subtle?: boolean;
primary?: boolean;
};
export type ModuleSubnavAction = {
actionId: string;
label: string;
onClick: () => void;
subtle?: boolean;
primary?: boolean;
};
export type ModuleSubnavEntry<T extends string> = ModuleSubnavItem<T> | ModuleSubnavAction;
export type ModuleSubnavGroup<T extends string> = {
title?: string;
items: ModuleSubnavEntry<T>[];
};
function isAction<T extends string>(entry: ModuleSubnavEntry<T>): entry is ModuleSubnavAction {
return "actionId" in entry;
}
export default function ModuleSubnav<T extends string>({
active,
groups,
onSelect,
className = ""
}: {
active: T;
groups: ModuleSubnavGroup<T>[];
onSelect: (section: T) => void;
className?: string;
}) {
return (
<aside className={`section-sidebar ${className}`.trim()}>
{groups.map((group, groupIndex) => (
<div className="section-group" key={`${group.title ?? "group"}-${groupIndex}`}>
{group.title && <div className={`section-title ${groupIndex > 0 ? "section-title-lower" : ""}`.trim()}>{group.title}</div>}
{group.items.map((entry) => {
const key = isAction(entry) ? entry.actionId : entry.id;
const activeClass = !isAction(entry) && active === entry.id ? " active" : "";
const primaryClass = entry.primary ? " section-link-primary" : "";
const subtleClass = entry.subtle ? " subtle" : "";
return (
<button
key={key}
className={`section-link${primaryClass}${subtleClass}${activeClass}`}
onClick={() => (isAction(entry) ? entry.onClick() : onSelect(entry.id))}
>
{entry.label}
</button>
);
})}
</div>
))}
</aside>
);
}

View File

@@ -0,0 +1,168 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu";
import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert";
import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions";
type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
};
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
const navigate = useNavigate();
const [accountOpen, setAccountOpen] = useState(false);
const [tenantOpen, setTenantOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
const [tenantError, setTenantError] = useState("");
const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null);
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
const canSwitchTenant = tenants.length > 1;
const canAdministerTenants = hasAnyScope(auth, [
"system:tenants:read",
"system:tenants:create",
"system:tenants:update",
"system:tenants:suspend"
]);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (accountRef.current && !accountRef.current.contains(target)) {
setAccountOpen(false);
}
if (tenantRef.current && !tenantRef.current.contains(target)) {
setTenantOpen(false);
}
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
function handleLogin(response: LoginResponse) {
const active = response.active_tenant ?? response.tenant;
onAuthChange(
{
user: response.user,
tenant: active,
active_tenant: active,
tenants: response.tenants ?? [active],
scopes: response.scopes,
roles: response.roles,
groups: response.groups
},
""
);
}
async function handleLogout() {
try {
await logout(settings);
} catch {
// Logout is best effort; clear local session either way.
}
onAuthChange(null, "");
setAccountOpen(false);
}
async function handleTenantSelect(tenant: AuthTenantMembership) {
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
setTenantOpen(false);
return;
}
setSwitchingTenantId(tenant.id);
setTenantError("");
try {
const next = await switchTenant(settings, tenant.id);
// Keep the current URL. The tenant-keyed route tree reloads in the new
// context; permission and resource boundaries then retain the page or
// fall back to the nearest accessible parent.
onAuthChange(next);
setTenantOpen(false);
} catch (error) {
setTenantError(error instanceof Error ? error.message : "Tenant switch failed.");
} finally {
setSwitchingTenantId(null);
}
}
return (
<header className="titlebar">
{auth && activeTenant && showTenantControl && (
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">tenant:</span>
{canSwitchTenant ? (
<>
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
<strong>{activeTenant.name}</strong>
<span className="tenant-caret"></span>
</button>
{tenantOpen && (
<div className="dropdown-menu tenant-menu">
{tenants.map((tenant) => {
const active = tenant.id === activeTenant.id;
return (
<button
key={tenant.id}
className={`dropdown-item ${active ? "active" : ""}`}
onClick={() => void handleTenantSelect(tenant)}
disabled={Boolean(switchingTenantId)}
>
<span>{switchingTenantId === tenant.id ? "Switching…" : tenant.name}</span>
{active && <Check size={16} />}
</button>
);
})}
{tenantError && <DismissibleAlert tone="danger" compact dismissible={false} className="dropdown-error">{tenantError}</DismissibleAlert>}
</div>
)}
</>
) : (
<strong>{activeTenant.name}</strong>
)}
</div>
)}
<div className="titlebar-spacer" />
<HelpMenu />
<div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
<UserCircle size={22} />
<span>{auth?.user.display_name || auth?.user.email || "Sign in"}</span>
<span className="tenant-caret"></span>
</button>
{accountOpen && (
<div className="dropdown-menu account-menu">
{auth ? (
<>
<div className="account-menu-header">
<strong>{auth.user.display_name || auth.user.email}</strong>
<span>{auth.user.email}</span>
</div>
<button className="dropdown-item" onClick={() => { setAccountOpen(false); navigate("/settings?section=profile"); }}><Settings size={16} /> Account settings</button>
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
</>
) : (
<button className="dropdown-item" onClick={() => { setLoginOpen(true); setAccountOpen(false); }}><UserCircle size={16} /> Sign in</button>
)}
</div>
)}
</div>
{loginOpen && <LoginModal settings={settings} onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
</header>
);
}

12
webui/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./app";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,89 @@
import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, type LucideIcon } from "lucide-react";
import campaignModule from "@govoplan/campaign-webui";
import filesModule from "@govoplan/files-webui";
import mailModule from "@govoplan/mail-webui";
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
export const shellNavItems: PlatformNavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, order: 10 }
];
const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule];
const iconByName: Record<string, LucideIcon> = {
activity: Activity,
campaign: MailCheck,
file: Folder,
files: Folder,
folder: Folder,
form: Form,
mail: MailCheck,
reports: FileText,
users: Users
};
function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem {
return {
to: item.path,
label: item.label,
icon: item.icon ? iconByName[item.icon] : undefined,
allOf: item.required_all,
anyOf: item.required_any,
order: item.order
};
}
function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo): PlatformWebModule {
const backendNav = info.frontend?.nav.length ? info.frontend.nav : info.nav;
return {
...module,
label: info.name,
version: info.version,
dependencies: info.dependencies,
optionalDependencies: info.optional_dependencies,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems
};
}
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
if (!platformModules?.length) return legacyModules;
const localById = new Map(legacyModules.map((module) => [module.id, module]));
return platformModules
.filter((module) => module.enabled)
.map((module) => {
const local = localById.get(module.id);
return local ? applyServerMetadata(local, module) : null;
})
.filter((module): module is PlatformWebModule => module !== null);
}
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = legacyModules): boolean {
return modules.some((module) => module.id === moduleId);
}
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = legacyModules): boolean {
const module = modules.find((item) => item.id === moduleId);
if (!module) return false;
return Boolean(module.dependencies?.includes(dependencyId) || (module.optionalDependencies?.includes(dependencyId) && moduleInstalled(dependencyId, modules)));
}
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = legacyModules): PlatformNavItem[] {
return navItemsForModules(modules).filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = legacyModules): string {
const preferred = visibleNavItems(auth, modules).find((item) => item.to !== "/dashboard");
if (preferred) return preferred.to;
if (hasAnyScope(auth, adminReadScopes)) return "/admin";
return "/dashboard";
}

View File

@@ -0,0 +1,164 @@
/* Auth gate + titlebar/tenant/help hover refinements */
.public-shell {
grid-template-columns: 58px 1fr;
}
.public-main {
grid-template-rows: 64px 1fr;
}
.icon-rail.compact .brand-mark {
margin-top: 15px;
}
.public-content {
min-height: calc(100vh - 64px);
background:
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
var(--bg);
}
.public-landing {
min-height: calc(100vh - 64px);
display: grid;
place-items: center;
padding: 42px;
}
.public-card {
width: min(720px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 42px 48px;
}
.public-kicker {
color: var(--accent);
text-transform: uppercase;
font-weight: 800;
font-size: 13px;
letter-spacing: .08em;
margin-bottom: 14px;
}
.public-card h1 {
margin: 0;
max-width: 620px;
font-size: clamp(30px, 4vw, 46px);
line-height: 1.08;
color: var(--text-strong);
letter-spacing: -.035em;
}
.public-card p {
margin: 18px 0 0;
max-width: 560px;
color: var(--muted);
font-size: 17px;
line-height: 1.55;
}
.public-actions {
margin-top: 28px;
display: flex;
gap: 12px;
}
.public-footnote {
margin-top: 22px;
color: var(--muted);
font-size: 13px;
}
/* Tenant: no field border. Only Titlebar decides whether it is rendered/dropdown. */
.tenant-selector {
position: relative;
height: auto;
min-width: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 0;
gap: 15px;
}
.tenant-name-button {
border: 0;
background: transparent;
color: var(--text-strong);
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit;
font-weight: 700;
padding: 8px 10px;
margin: -8px -10px;
border-radius: 7px;
cursor: pointer;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
}
/* Help/account should feel button-like only on interaction. */
.titlebar-link,
.account-pill {
padding: 8px 10px;
margin: -8px -10px;
border-radius: 7px;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
cursor: pointer;
}
.tenant-name-button:hover,
.titlebar-link:hover,
.account-pill:hover,
.context-menu-wrap:focus-within .account-pill {
background: rgba(0,0,0,.055);
color: var(--text-strong);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.04);
}
.dropdown-menu {
animation: dropdown-in .08s ease-out;
padding: 6px;
}
.dropdown-item {
border-radius: 6px;
transition: background-color .12s ease, color .12s ease;
}
.dropdown-item:hover,
.dropdown-item.active {
background: rgba(239, 107, 58, .10);
color: var(--text-strong);
}
.dropdown-item.active {
font-weight: 700;
}
.tenant-menu {
min-width: 230px;
right: inherit;
left: 0;
}
.tenant-menu .dropdown-item {
justify-content: space-between;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,5 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: #e7e4df; color: #666; text-transform: uppercase; }
.status-ready, .status-sent, .status-appended { background: #d6eee9; color: #34796d; }
.status-warning, .status-needs-review, .status-pending { background: #ffedc6; color: #a06b00; }
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; }
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; }

View File

@@ -0,0 +1,629 @@
/* Shared application components: loading indicator, mail-address editor and toggle switch. */
.page-title-with-loader {
display: inline-flex;
align-items: center;
gap: 10px;
}
.loading-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
vertical-align: middle;
}
.loading-indicator-sm { width: 22px; height: 22px; }
.loading-indicator-md { width: 30px; height: 30px; }
.loading-envelope {
position: relative;
display: inline-block;
width: 18px;
height: 13px;
border: 2px solid #8c8881;
border-radius: 3px;
background: rgba(255,255,255,.7);
animation: loading-envelope-float .74s ease-in-out infinite alternate;
}
.loading-envelope::before,
.loading-envelope::after {
content: "";
position: absolute;
top: 1px;
width: 10px;
height: 10px;
border-top: 2px solid #8c8881;
}
.loading-envelope::before {
left: 1px;
transform: rotate(36deg);
transform-origin: top left;
}
.loading-envelope::after {
right: 1px;
transform: rotate(-36deg);
transform-origin: top right;
}
@keyframes loading-envelope-float {
from { transform: translateY(0) rotate(-3deg); opacity: .62; }
to { transform: translateY(-2px) rotate(3deg); opacity: 1; }
}
.email-address-input {
display: grid;
gap: 10px;
}
.email-chip-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 34px;
}
.email-chip {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
border: 1px solid #c9c5bd;
border-radius: 999px;
background: linear-gradient(#ffffff, #f2f1ef);
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
padding: 5px 8px 5px 11px;
color: var(--text-strong);
font-size: 13px;
line-height: 1.2;
}
.email-chip.invalid {
border-color: #c96b63;
background: #f6e3df;
color: #873c35;
}
.email-chip-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.email-chip-address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 12px;
}
.email-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 0;
border-radius: 999px;
background: rgba(0,0,0,.08);
color: #57534d;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.email-chip-remove:hover { background: rgba(0,0,0,.15); }
.email-chip-empty {
color: var(--muted);
font-size: 13px;
}
.email-address-hint {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 430px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 92px; text-align: right; }
.recipient-field-input,
.recipient-attachments-input {
min-width: 150px;
font-size: 12px;
}
.recipient-attachments-input {
min-width: 260px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.card-title-node,
.card-heading-with-loader {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text-strong);
font-weight: 800;
}
.card-heading-with-loader {
font-size: 18px;
}
/* Mail-style address editor: textarea surface + pills + add dialog. */
.email-address-editor {
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
padding: 7px 8px;
}
.email-address-editor:focus-within {
border-color: #9bb7d3;
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8);
}
.email-address-editor.has-error {
border-color: #c96b63;
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13);
}
.email-address-editor .email-chip-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
min-height: 0;
}
.email-address-textarea {
flex: 1 1 230px;
min-width: 190px;
min-height: 26px;
max-height: 96px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
resize: none;
overflow: hidden;
padding: 4px 2px;
font: inherit;
line-height: 1.35;
}
.email-address-textarea:focus {
outline: none;
box-shadow: none;
}
.email-address-input.has-add-button .email-address-editor {
padding-right: 42px;
}
.email-address-plus {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid #c7c2b8;
border-radius: 999px;
background: linear-gradient(#fff, #f0efec);
color: #4f4b46;
cursor: pointer;
font-size: 19px;
font-weight: 800;
line-height: 1;
padding: 0;
}
.email-address-plus:hover {
border-color: #aaa49a;
background: linear-gradient(#fff, #e9e7e3);
}
.email-address-popover {
position: absolute;
z-index: 12;
top: calc(100% + 7px);
right: 0;
width: min(360px, calc(100vw - 64px));
display: grid;
gap: 10px;
border: 1px solid var(--line-dark);
border-radius: 8px;
background: #fff;
box-shadow: var(--shadow-popover);
padding: 14px;
}
.email-address-popover h4 {
margin: 0;
color: var(--text-strong);
font-size: 15px;
}
.email-address-popover label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.email-address-popover input {
font-size: 14px;
}
.email-address-suggestions {
display: grid;
gap: 4px;
max-width: 560px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
padding: 6px;
}
.email-address-suggestions button {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-strong);
cursor: pointer;
padding: 7px 8px;
text-align: left;
}
.email-address-suggestions button:hover {
background: var(--panel-soft);
}
.email-address-suggestions small {
color: var(--muted);
}
.email-address-input.compact {
min-width: 420px;
}
.email-address-input.compact .email-address-editor {
min-height: 40px;
}
.email-address-input.compact .email-address-textarea {
flex-basis: 180px;
min-width: 150px;
}
.email-address-input.disabled .email-address-editor {
background: var(--panel-soft);
}
.email-address-input.disabled .email-address-plus,
.email-address-input.disabled .email-address-textarea,
.email-address-input.disabled .email-address-suggestions,
.email-address-input.disabled .email-address-popover {
display: none;
}
/* Bootstrap-like switch controls without importing Bootstrap. */
.toggle-switch-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
color: var(--text);
cursor: pointer;
font-weight: 700;
}
.toggle-switch-row.disabled {
cursor: default;
opacity: .72;
}
.toggle-switch-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.toggle-switch-track {
position: relative;
flex: 0 0 auto;
width: 42px;
height: 24px;
border: 1px solid #aeb4bb;
border-radius: 999px;
background: #cfd4da;
box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
}
.toggle-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.22);
transition: transform .16s ease;
}
.toggle-switch-input:checked + .toggle-switch-track {
border-color: #0d6efd;
background: #0d6efd;
}
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
transform: translateX(18px);
}
.toggle-switch-input:focus-visible + .toggle-switch-track {
box-shadow: 0 0 0 3px rgba(13,110,253,.2), inset 0 1px 2px rgba(0,0,0,.12);
}
.toggle-switch-input:disabled + .toggle-switch-track {
filter: grayscale(.15);
opacity: .7;
}
.toggle-switch-copy {
display: grid;
gap: 2px;
}
.toggle-switch-label {
color: var(--text-strong);
}
.toggle-switch-help {
color: var(--muted);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
@media (max-width: 900px) {
.email-address-input.compact { min-width: 260px; }
.email-address-input.has-add-button .email-address-editor { padding-right: 39px; }
.email-address-popover {
left: 0;
right: auto;
width: min(340px, calc(100vw - 48px));
}
}
/* Reusable inline help markers for form labels and compact contextual hints. */
.field-label {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.field-label-text {
min-width: 0;
}
.inline-help {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
outline: none;
}
.inline-help-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 999px;
color: #686560;
background: var(--line-dark);
font-size: 11px;
font-weight: 800;
line-height: 1;
cursor: context-menu;
}
.inline-help:focus-visible .inline-help-mark {
box-shadow: var(--focus-ring);
}
.inline-help-bubble {
position: absolute;
z-index: 40;
left: 50%;
bottom: calc(100% + 9px);
width: max-content;
max-width: min(320px, calc(100vw - 48px));
transform: translate(-50%, 3px);
border: 1px solid var(--line-dark);
border-radius: 7px;
background: var(--surface);
box-shadow: var(--shadow-popover);
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
padding: 9px 10px;
white-space: normal;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity .14s ease .35s, transform .14s ease .35s, visibility 0s linear .49s;
}
.inline-help-bubble::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
width: 9px;
height: 9px;
border-right: 1px solid var(--line-dark);
border-bottom: 1px solid var(--line-dark);
background: var(--surface);
transform: translate(-50%, -5px) rotate(45deg);
}
.inline-help:hover .inline-help-bubble,
.inline-help:focus-within .inline-help-bubble {
visibility: visible;
opacity: 1;
transform: translate(-50%, 0);
transition-delay: .35s, .35s, .35s;
}
.field-with-action {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.field-with-action input,
.field-with-action select,
.field-with-action textarea {
flex: 1 1 auto;
min-width: 0;
}
.field-with-action button,
.field-with-action .button {
flex: 0 0 auto;
white-space: nowrap;
}
.loading-frame {
position: relative;
}
.loading-frame.is-loading {
min-height: 120px;
}
.loading-frame.is-loading > :not(.loading-frame-overlay) {
pointer-events: none;
user-select: none;
}
.loading-frame-overlay {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
min-height: 120px;
padding: 1.25rem;
border-radius: var(--radius-lg, 18px);
background: rgba(255, 255, 255, 0.00);
backdrop-filter: blur(1.5px);
margin: -10px;
}
.loading-frame-panel {
display: inline-flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12));
border-radius: 999px;
color: var(--text, #172033);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12));
font-size: 0.9rem;
font-weight: 600;
}
/* Collapsible cards */
.card-collapsible .card-header {
gap: 12px;
}
.card-collapsible .card-actions {
align-items: center;
}
.card-collapse-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid #c9c3b9;
border-radius: 999px;
background: linear-gradient(#ffffff, #f1efeb);
color: #4f4a43;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.card-collapse-toggle:hover {
border-color: #aaa299;
background: linear-gradient(#ffffff, #e8e5df);
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
}
.card-collapse-toggle:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22);
outline-offset: 2px;
}
.card-collapse-toggle svg {
transform: rotate(-180deg);
transition: transform .22s ease;
}
.card-collapsible.is-collapsed .card-collapse-toggle svg {
transform: none;
}
.card-collapse-region {
display: block;
width: 100%;
min-width: 0;
}
.card-collapsible.is-collapsed .card-collapse-region {
display: none;
}
.card-collapse-region > .card-body {
width: 100%;
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.card-collapse-toggle,
.card-collapse-toggle svg,
.card-collapse-region {
transition: none;
}
}
/* Reusable dismissible page alerts */
.alert-dismissible {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
position: relative;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
.alert-dismissible .alert-message {
min-width: 0;
flex: 1 1 auto;
}
.alert-floating-stack {
position: fixed;
top: 131px;
left: 50%;
z-index: 1200;
display: grid;
gap: 10px;
width: min(640px, calc(100vw - 32px));
transform: translateX(-50%);
pointer-events: none;
}
.alert-floating {
position: relative;
top: auto;
width: 100%;
margin: 0;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 12px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
pointer-events: auto;
}
.alert-dismiss {
border: 0;
background: transparent;
color: currentColor;
opacity: 0.78;
cursor: pointer;
line-height: 1;
padding: 2px;
border-radius: 999px;
flex: 0 0 auto;
}
.alert-dismiss:hover,
.alert-dismiss:focus-visible {
opacity: 1;
background: rgba(255, 255, 255, 0.45);
}

View File

@@ -0,0 +1,165 @@
/* Shared overlay, modal and Dialog component styles. */
.overlay-backdrop {
position: fixed;
inset: 0;
z-index: 9000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(37, 40, 42, 0.38);
}
.modal-panel {
width: min(560px, 100%);
max-height: calc(100vh - 48px);
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.26);
}
.modal-header {
min-height: 58px;
display: flex;
align-items: center;
flex: 0 0 auto;
padding: 0 22px;
border-bottom: 1px solid var(--line);
}
.modal-header h2 {
margin: 0;
color: var(--text-strong);
font-size: 18px;
}
.modal-close {
margin-left: auto;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.modal-body {
min-height: 0;
overflow: auto;
padding: 22px;
}
.modal-footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 22px;
border-top: 1px solid var(--line);
background: var(--panel-soft);
}
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 12000;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(2px);
}
.dialog-panel {
width: min(560px, 100%);
max-height: min(760px, calc(100vh - 3rem));
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--line, rgba(15, 23, 42, 0.14));
border-radius: var(--radius-lg, 18px);
background: var(--surface, #fff);
color: var(--text, #172033);
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
}
.dialog-header {
flex: 0 0 auto;
min-height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
background: var(--bar, #f5f4f1);
}
.dialog-title {
margin: 0;
color: var(--text-strong, #111827);
font-size: 1.05rem;
}
.dialog-close {
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--muted, #5f6b7a);
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
}
.dialog-close:hover,
.dialog-close:focus-visible {
background: rgba(15, 23, 42, 0.07);
color: var(--text-strong, #111827);
outline: none;
}
.dialog-close:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.dialog-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 20px;
color: var(--text, #172033);
}
.dialog-footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding: 12px 18px;
border-top: 1px solid var(--line);
background: var(--bar, #f5f4f1);
}
.confirm-dialog {
width: min(460px, calc(100vw - 40px));
}
.confirm-dialog > .dialog-header,
.confirm-dialog > .dialog-footer {
background: var(--panel, #fff);
}
.confirm-dialog p {
margin: 0;
color: var(--muted, #5f6b7a);
line-height: 1.5;
}

View File

@@ -0,0 +1,12 @@
.form-grid { display: grid; gap: 18px; }
.form-grid.compact { grid-template-columns: 1fr 1fr; }
.form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: #6b6863; }
.form-help { font-size: 12px; color: var(--muted); }
input, select, textarea { border: 1px solid var(--line); border-radius: 5px; background: #fff; font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: inset 0 1px 2px rgba(0,0,0,.04); }
textarea { resize: vertical; }
.btn { border: 1px solid var(--line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: #f8f8f7; color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.15); }
.btn-primary { background: var(--green); border-color: #5aa99b; color: #fff; }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
.btn-danger { background: var(--red); color: #fff; border-color: #c94d43; }
.btn:disabled { opacity: .55; cursor: not-allowed; }

144
webui/src/styles/layout.css Normal file
View File

@@ -0,0 +1,144 @@
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 58px 1fr; }
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; }
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute;
top: 9px;
left: 9px;
width: 15px;
height: 15px;
border-radius: 50%;
content: "";
background-color: var(--rail-bg);
}
.icon-nav { width: 100%; display: flex; flex-direction: column; }
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
.app-main { min-width: 0; display: grid; grid-template-rows: 64px 51px 1fr; min-height: 100vh; }
.titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
.titlebar-spacer { flex: 1; }
.titlebar-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.account-pill { color: var(--text); }
.api-mini { display: flex; gap: 6px; }
.api-mini input { width: 155px; height: 30px; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: 1px solid var(--line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 90; }
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: #615f5c; }
.crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; }
.workspace { height: calc(100vh - 112px); display: grid; grid-template-columns: 198px 1fr; }
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; }
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; }
.section-link { width: calc(100% + 3px); height: 48px; border: 0; padding: 0 10px 0 22px; background: transparent; text-align: left; color: #686560; font: inherit; cursor: pointer; margin-left: -3px; }
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; }
.workspace-content { min-width: 0; max-width: 100%; overflow: auto; }
.content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; }
.page-heading { margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
.page-heading p { margin: 6px 0 0; color: var(--muted); }
.page-heading.split { display: flex; align-items: center; justify-content: space-between; }
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: scroll; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
.card-body { padding: 22px 24px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.metric-grid.inside { margin: 14px 0; }
.metric-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); }
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
.wizard-heading { display: flex; justify-content: space-between; margin-bottom: 18px; }
.wizard-heading h1 { margin: 0; }
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
.wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.stepper { list-style: none; padding: 22px 0; margin: 0; background: #f6f5f3; border-right: 1px solid var(--line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: #999; cursor: pointer; }
.step.active button { background: #fff; color: var(--text-strong); }
.step-number { width: 32px; height: 32px; border-radius: 50%; background: #d8d6d2; color: #fff; display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
.step.active .step-number { background: var(--accent); }
.step small { display: block; margin-top: 3px; color: var(--muted); }
.step-intro h2 { margin: 0; color: var(--text-strong); }
.step-intro p { margin-top: 6px; color: var(--muted); }
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.alert.warning { background: #ffe1a3; } .alert.danger { background: #f3c5be; }
.table-like { border: 1px solid var(--line); }
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: 1px solid var(--line); }
.table-row-link:hover { background: var(--panel-soft); }
.mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
.code-panel { background: #292929; color: #f1f1f1; padding: 18px; border-radius: 4px; overflow: auto; }
@media (max-width: 900px) {
.api-mini { display: none; }
.workspace { grid-template-columns: 1fr; }
.section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; }
.metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }
}
/* Layout additions: login and help dropdown */
.tenant-selector.disabled { opacity: .86; cursor: default; }
.context-menu-wrap { position: relative; }
.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: #fff; border: 1px solid var(--line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
.dropdown-menu hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
.dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 4px; text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; }
.dropdown-item:hover { background: var(--panel-soft); color: var(--text-strong); }
.dropdown-item small { margin-left: auto; color: var(--muted); }
.account-menu { min-width: 260px; }
.account-menu-header { padding: 8px 9px 10px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
.account-menu-header strong { display: block; color: var(--text-strong); }
.account-menu-header span { color: var(--muted); font-size: 13px; }
.login-hint { background: #f6f5f3; border: 1px solid var(--line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
.help-panel-section { margin-bottom: 16px; }
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
.about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); margin-bottom: 12px; }
.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: 1px solid var(--line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: #fff; font-size: 12px; font-weight: 700; color: #666; }
.titlebar .tenant-selector strong { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Campaign workspace polish */
.crumb-link { color: inherit; text-decoration: none; border-radius: 4px; padding: 4px 5px; margin: -4px -5px; }
.crumb-link:hover { background: rgba(255,255,255,.35); color: var(--text-strong); }
.compact-actions { margin: 0; align-items: center; }
.below-grid { margin-top: 18px; }
.detail-list { display: grid; gap: 12px; margin: 0; }
.detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.detail-list div:last-child { border-bottom: 0; padding-bottom: 0; }
.detail-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.detail-list dd { margin: 0; min-width: 0; }
.next-action-card { border-left: 4px solid var(--blue); padding-left: 16px; }
.next-action-card h2 { margin: 0; color: var(--text-strong); font-size: 19px; }
.next-action-card p { margin: 8px 0 0; color: var(--muted); }
.next-action-good { border-left-color: var(--green); }
.next-action-warning { border-left-color: var(--amber); }
.next-action-info { border-left-color: var(--blue); }
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.summary-tile { background: var(--panel-soft); border: 1px solid var(--line); border-radius: 6px; padding: 14px; }
.summary-tile span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.summary-tile strong { display: block; margin-top: 6px; color: var(--text-strong); font-size: 24px; }
.empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
.empty-state h2 { margin: 0; color: var(--text-strong); }
.empty-state p { max-width: 520px; margin: 8px auto 18px; color: var(--muted); }
@media (max-width: 900px) {
.page-heading.split { align-items: flex-start; flex-direction: column; }
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
}
/* Side rail: settings lives as the bottom utility entry. */
.icon-rail-bottom {
width: 100%;
margin-top: auto;
padding: 12px 0 20px;
}
.icon-rail-bottom .icon-nav-item {
width: 100%;
}

View File

@@ -0,0 +1,124 @@
.retention-policy-manager {
display: grid;
gap: 18px;
}
.retention-policy-target-row {
max-width: 520px;
}
.retention-policy-editor {
display: grid;
gap: 18px;
}
.retention-policy-description {
margin: 0;
}
.retention-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.retention-policy-section h3 {
margin: 0;
color: var(--text-strong);
}
.retention-policy-table {
display: grid;
gap: 0;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: hidden;
}
.retention-policy-row {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr);
align-items: center;
gap: 14px;
padding: 12px 14px;
background: var(--surface);
border-top: 1px solid var(--line);
}
.retention-policy-table.with-allow-column .retention-policy-row {
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr) minmax(170px, 0.65fr);
}
.retention-policy-row:first-child {
border-top: 0;
}
.retention-policy-row-header {
background: var(--surface-subtle);
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.retention-policy-field-label {
display: grid;
gap: 3px;
min-width: 0;
}
.retention-policy-field-label strong {
color: var(--text-strong);
}
.retention-policy-field-label small {
color: var(--muted);
}
.retention-run-card .admin-json-preview {
margin-top: 14px;
}
.retention-policy-effective {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.retention-policy-effective-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.retention-policy-effective-grid div {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
.retention-policy-effective-grid span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.retention-policy-effective-grid strong {
color: var(--text-strong);
}
@media (max-width: 900px) {
.retention-policy-effective-grid,
.retention-policy-row,
.retention-policy-table.with-allow-column .retention-policy-row {
grid-template-columns: 1fr;
}
.retention-policy-row-header {
display: none;
}
}

712
webui/src/styles/tables.css Normal file
View File

@@ -0,0 +1,712 @@
/* Legacy mapping / rule tables used by early wizard surfaces. */
.mapping-table {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.mapping-header,
.mapping-row {
display: grid;
grid-template-columns: 1.3fr 1.3fr .8fr 1fr .6fr;
align-items: center;
}
.mapping-header {
background: var(--bar);
color: #6b6863;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.mapping-header span,
.mapping-row span {
padding: 12px 14px;
border-right: 1px solid var(--line);
}
.mapping-row {
background: #fff;
border-top: 1px solid var(--line);
}
.attachment-rule-card {
margin: 18px 0;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 18px;
}
.attachment-rule-header {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
/* Application data tables, visually aligned with the SaaS-style reference table. */
.app-table-wrap {
margin: -22px -24px;
overflow-x: auto;
}
.app-table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
.app-table thead {
background: var(--bar);
color: #625f5a;
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
}
.app-table th {
height: 40px;
padding: 0 16px;
border-right: 1px solid var(--line-dark);
font-weight: 800;
text-align: left;
white-space: nowrap;
}
.app-table th:last-child { border-right: 0; }
.app-table td {
min-height: 56px;
padding: 16px;
border-top: 1px solid var(--line);
vertical-align: middle;
}
.app-table tbody tr:hover { background: var(--panel-soft); }
.table-primary-link,
.table-action-link {
color: var(--text-strong);
font-weight: 800;
text-decoration: none;
}
.table-primary-link:hover,
.table-action-link:hover { text-decoration: underline; }
.table-subline {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
/* Campaign list table; the containing card/sticky behavior lives in campaign-workspace.css. */
.campaign-table-wrap {
margin: 0;
overflow-x: auto;
overflow-y: visible;
}
.campaign-table {
table-layout: fixed;
}
.campaign-table thead tr {
box-shadow: 0 12px 16px -18px rgba(45, 43, 40, .8);
}
.campaign-table thead th {
background: var(--bar);
border-right: 1px solid var(--line-dark);
}
.campaign-table th:nth-child(1),
.campaign-table td:nth-child(1) { width: auto; min-width: 360px; }
.campaign-table th:nth-child(2),
.campaign-table td:nth-child(2) { width: 150px; }
.campaign-table th:nth-child(3),
.campaign-table td:nth-child(3) { width: 230px; }
.campaign-table th:nth-child(4),
.campaign-table td:nth-child(4) { width: 230px; }
.campaign-table th:nth-child(5),
.campaign-table td:nth-child(5) { width: 105px; text-align: right; }
.campaign-table td.updated-cell {
color: #696660;
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
}
.campaign-table td.version-cell {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Reusable data grid */
.data-grid-shell {
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.data-grid-scroll-region {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.card-body > .data-grid-shell:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
border: 0;
border-radius: 0;
}
.data-grid-container {
height: 100%;
}
.data-grid {
display: grid;
align-items: stretch;
min-width: 0;
}
.data-grid-fit-container,
.data-grid-fit-content,
.data-grid-has-flex,
.data-grid-fixed-only {
max-width: none;
}
/* initialFit controls only the first layout. resizeBehavior controls the
persistent width invariant after that initial measurement. */
.data-grid-resize-free {
width: max-content;
min-width: 0;
}
.data-grid-resize-free.data-grid-initial-fit-container {
min-width: 100%;
}
.data-grid-resize-cover,
.data-grid-resize-constrained {
width: 100%;
min-width: 100%;
}
.data-grid-buffer-cell {
min-width: 0;
max-width: none;
padding: 0;
gap: 0;
border-right: 0;
pointer-events: none;
}
.data-grid-container .data-grid {
min-height: 100%;
}
.data-grid-cell {
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 11px 12px;
border-right: 1px solid rgba(189, 184, 176, 0.32);
border-bottom: 1px solid var(--border-soft, var(--line));
background: var(--surface);
display: flex;
align-items: center;
gap: 8px;
}
.data-grid-cell:last-child {
border-right: 0;
}
.data-grid-cell > * {
min-width: 0;
max-width: 100%;
}
.data-grid-body-cell.is-last-row {
border-bottom: 0;
}
.data-grid-header-cell {
position: sticky;
top: 0;
z-index: 4;
background: var(--bar);
border-bottom: 1px solid var(--line-dark);
color: #625f5a;
font-size: 12px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
box-shadow: none;
}
.data-grid-body-cell {
color: var(--text);
}
.data-grid-body-cell.data-grid-row-odd {
background: #fbfaf8;
}
.data-grid-body-cell.data-grid-row-even {
background: var(--surface);
}
.data-grid-body-cell.align-center,
.data-grid-header-cell.align-center {
justify-content: center;
text-align: center;
}
.data-grid-body-cell.align-right,
.data-grid-header-cell.align-right {
justify-content: flex-end;
text-align: right;
}
.data-grid-header-button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 7px;
flex: 1 1 auto;
min-width: 0;
color: inherit;
cursor: default;
}
.data-grid-header-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-grid-header-label {
display: inline-flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
}
.data-grid-header-label > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-grid-header-cell.is-sortable .data-grid-header-button {
cursor: pointer;
}
.data-grid-header-cell.is-sorted .data-grid-header-button {
color: var(--text-strong);
}
.data-grid-resize-handle,
.data-grid-filter-trigger {
border: 0;
background: transparent;
color: rgba(98, 95, 90, .68);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 999px;
}
.data-grid-resize-handle {
cursor: col-resize;
width: 16px;
align-self: stretch;
}
.data-grid-filter-trigger {
width: 24px;
height: 24px;
cursor: pointer;
}
.data-grid-filter-trigger:hover,
.data-grid-filter-trigger:focus-visible,
.data-grid-filter-trigger.is-open {
color: var(--text-strong);
background: rgba(255, 255, 255, .45);
outline: none;
}
.data-grid-filter-trigger.has-filter {
color: var(--text-strong);
background: rgba(255, 255, 255, .62);
box-shadow: inset 0 0 0 1px rgba(98, 95, 90, .24);
}
.data-grid-filter-popover {
position: fixed;
z-index: 12000;
border: 1px solid var(--line-dark);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-menu);
padding: 12px;
color: var(--text);
}
.data-grid-filter-popover-header {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
margin-bottom: 10px;
color: var(--text-strong);
}
.data-grid-filter-popover-header strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-grid-filter-popover-header button,
.data-grid-filter-input-wrap button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.data-grid-filter-popover-header button:hover,
.data-grid-filter-input-wrap button:hover,
.data-grid-filter-popover-header button:focus-visible,
.data-grid-filter-input-wrap button:focus-visible {
color: var(--text-strong);
background: var(--panel-soft);
outline: none;
}
.data-grid-filter-stack {
display: grid;
gap: 10px;
}
.data-grid-filter-field {
display: grid;
gap: 5px;
font-size: 12px;
color: var(--muted);
font-weight: 700;
}
.data-grid-filter-field input,
.data-grid-filter-field select {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--text);
font: inherit;
font-weight: 500;
padding: 8px 10px;
}
.data-grid-filter-field input:focus,
.data-grid-filter-field select:focus {
outline: none;
box-shadow: var(--focus-ring);
border-color: var(--blue);
}
.data-grid-filter-input-wrap {
position: relative;
}
.data-grid-filter-input-wrap input {
padding-right: 32px;
}
.data-grid-filter-input-wrap button {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
}
.data-grid-empty {
grid-column: 1 / -1;
padding: 20px;
color: var(--muted);
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.data-grid-cell.is-sticky-start,
.data-grid-cell.is-sticky-end {
position: sticky;
z-index: 2;
background-clip: padding-box;
}
.data-grid-cell.is-sticky-start {
border-right: 1px solid var(--line-dark);
box-shadow: 8px 0 14px -14px rgba(45, 43, 40, .6);
}
.data-grid-cell.is-sticky-end {
border-left: 1px solid var(--line-dark);
box-shadow: -8px 0 14px -14px rgba(45, 43, 40, .6);
}
.data-grid-header-cell.is-sticky-start,
.data-grid-header-cell.is-sticky-end {
z-index: 5;
border-bottom: 1px solid var(--line-dark);
background: var(--bar);
}
.data-grid-body-cell.current-version-row,
.data-grid-body-cell.current-recipient-row,
.data-grid-body-cell.current-row {
font-weight: 700;
}
/* List-valued DataGrid columns and checkbox filters. */
.data-grid-list-select {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--text);
font: inherit;
padding: 6px 28px 6px 8px;
}
.data-grid-list-select:focus {
outline: none;
border-color: var(--blue);
box-shadow: var(--focus-ring);
}
.data-grid-list-filter {
display: grid;
gap: 10px;
}
.data-grid-list-filter-actions {
display: flex;
align-items: center;
gap: 8px;
}
.data-grid-list-filter-actions button {
border: 0;
background: transparent;
color: var(--blue);
padding: 0;
font: inherit;
font-size: 12px;
font-weight: 750;
cursor: pointer;
}
.data-grid-list-filter-actions button:hover,
.data-grid-list-filter-actions button:focus-visible {
color: var(--text-strong);
text-decoration: underline;
outline: none;
}
.data-grid-list-filter-options {
max-height: 230px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
}
.data-grid-list-filter-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 6px 8px;
border-bottom: 1px solid var(--line);
}
.data-grid-list-filter-row:last-child {
border-bottom: 0;
}
.data-grid-list-filter-row > label {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
cursor: pointer;
}
.data-grid-list-filter-row input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
flex: 0 0 auto;
}
.data-grid-list-option-remove,
.data-grid-list-option-add button {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
width: 28px;
height: 28px;
display: inline-grid;
place-items: center;
cursor: pointer;
}
.data-grid-list-option-remove:hover,
.data-grid-list-option-remove:focus-visible,
.data-grid-list-option-add button:hover,
.data-grid-list-option-add button:focus-visible {
color: var(--text-strong);
background: var(--panel-soft);
outline: none;
}
.data-grid-list-option-add {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.data-grid-list-option-add input {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--text);
font: inherit;
padding: 8px 10px;
}
/* Consistent row-level collection actions for editable DataGrid tables. */
.data-grid-row-actions {
display: grid;
grid-template-columns: repeat(4, 36px);
align-items: center;
justify-content: end;
gap: 4px;
width: 100%;
}
.data-grid-row-action.btn {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
min-width: 36px;
padding: 0;
}
.data-grid-row-action.btn:disabled {
opacity: .45;
}
.data-grid-empty-message {
color: var(--muted);
min-height: 64px;
}
.data-grid-empty-action-cell {
justify-content: flex-end;
min-height: 64px;
}
.data-grid-empty-row-actions {
justify-content: end;
}
.data-grid-pagination {
min-height: 52px;
padding: 8px 12px;
border-top: 1px solid var(--border-soft, var(--line));
background: var(--panel);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 18px;
color: var(--muted);
font-size: 13px;
}
.data-grid-pagination-summary {
margin-right: auto;
font-variant-numeric: tabular-nums;
}
.data-grid-page-size,
.data-grid-page-controls {
display: grid;
align-items: center;
gap: 8px;
grid-auto-flow: column;
}
.data-grid-page-size select {
min-width: 72px;
height: 34px;
}
.data-grid-page-controls > span {
min-width: 88px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.data-grid-page-controls button {
width: 32px;
height: 32px;
border: 1px solid var(--border);
border-radius: var(--radius-xs, 5px);
background: var(--surface);
color: var(--text);
display: inline-grid;
place-items: center;
}
.data-grid-page-controls button:hover:not(:disabled),
.data-grid-page-controls button:focus-visible {
border-color: var(--line-dark);
background: var(--surface-strong, #f5f3ef);
}
.data-grid-page-controls button:disabled {
opacity: .42;
cursor: not-allowed;
}
@media (max-width: 700px) {
.data-grid-pagination {
flex-wrap: wrap;
justify-content: space-between;
}
.data-grid-pagination-summary {
width: 100%;
}
}

View File

@@ -0,0 +1,50 @@
:root {
--rail-bg: #25282a;
--rail-bg-active: #1c1e20;
--accent: #ef6b3a;
--accent-soft: rgba(239, 107, 58, .35);
--bg: #e9e7e4;
--bar: #d4d0ca;
--panel: #f7f6f4;
--panel-soft: #ffffff;
--panel-header: #ffffff;
--surface: #ffffff;
--surface-subtle: var(--panel-soft);
--line: #d6d2cc;
--line-dark: #bdb8b0;
--border: var(--line);
--text: #48494c;
--text-strong: #303135;
--muted: #8a8781;
--muted-text: var(--muted);
--green: #7bbcaf;
--amber: #f5c56c;
--red: #e06a5f;
--blue: #7ea6c5;
--success-bg: #d8eee8;
--success-text: #315f55;
--info-bg: #dce9f3;
--info-text: #365c76;
--danger-text: #a64840;
--focus-ring: 0 0 0 3px rgba(82, 130, 177, .14);
--shadow: 0 1px 2px rgba(0,0,0,.15), 0 10px 30px rgba(0,0,0,.04);
--shadow-menu: 0 16px 40px rgba(0,0,0,.18);
--shadow-popover: 0 3px 8px -3px rgba(0,0,0,.16);
--radius: 8px;
--radius-sm: 4px;
--radius-md: var(--radius);
--radius-lg: 12px;
--radius-pill: 999px;
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); }
a { color: inherit; }

187
webui/src/types.ts Normal file
View File

@@ -0,0 +1,187 @@
import type { ComponentType, ReactNode } from "react";
export type ApiSettings = {
apiBaseUrl: string;
apiKey: string;
accessToken: string;
};
export type AuthUser = {
id: string;
account_id: string;
email: string;
display_name?: string | null;
tenant_display_name?: string | null;
is_tenant_admin?: boolean;
password_reset_required?: boolean;
};
export type AuthTenant = {
id: string;
slug: string;
name: string;
is_active?: boolean;
default_locale?: string;
};
export type AuthTenantMembership = AuthTenant & {
roles?: string[];
is_active?: boolean;
};
export type AuthRole = {
id: string;
slug: string;
name: string;
permissions: string[];
level?: "tenant" | "system";
};
export type AuthGroup = {
id: string;
slug: string;
name: string;
};
export type AuthInfo = {
user: AuthUser;
// Backwards-compatible active tenant alias returned by older/newer APIs.
tenant: AuthTenant;
active_tenant?: AuthTenant;
tenants?: AuthTenantMembership[];
scopes: string[];
roles: AuthRole[];
groups: AuthGroup[];
};
export type LoginResponse = AuthInfo & {
access_token: string;
token_type: "bearer";
expires_at: string;
};
export type NavSection =
| "dashboard"
| "campaigns"
| "files"
| "address-book"
| "templates"
| "reports"
| "admin"
| "settings";
export type CampaignWorkspaceSection =
| "overview"
| "campaign"
| "global-settings"
| "fields"
| "recipients"
| "recipient-data"
| "template"
| "files"
| "mail-settings"
| "review"
| "report"
| "audit"
| "json";
export type WizardStep = {
id: string;
label: string;
description?: string;
status?: "todo" | "active" | "done" | "warning" | "blocked";
};
export type CampaignListItem = {
id: string;
external_id?: string;
name: string;
description?: string | null;
status: string;
current_version_id?: string | null;
owner_user_id?: string | null;
owner_group_id?: string | null;
created_at?: string;
updated_at?: string;
updatedAt?: string;
ready?: number;
warnings?: number;
blocked?: number;
sent?: number;
failed?: number;
};
export type PlatformNavItem = {
to: string;
label: string;
icon?: ComponentType<{ size?: number }>;
anyOf?: string[];
allOf?: string[];
order?: number;
};
export type PlatformRouteContext = {
auth: AuthInfo;
};
export type PlatformRouteContribution = {
path: string;
anyOf?: string[];
allOf?: string[];
render: (context: PlatformRouteContext) => ReactNode;
};
export type PlatformWebModule = {
id: string;
label: string;
version: string;
dependencies?: string[];
optionalDependencies?: string[];
navItems?: PlatformNavItem[];
routes?: PlatformRouteContribution[];
};
export type PlatformFrontendRouteInfo = {
path: string;
component: string;
required_all: string[];
required_any: string[];
order: number;
};
export type PlatformFrontendModuleInfo = {
module_id: string;
package_name?: string | null;
asset_manifest?: string | null;
routes: PlatformFrontendRouteInfo[];
nav: Array<{
path: string;
label: string;
icon?: string | null;
section?: string | null;
required_all: string[];
required_any: string[];
order: number;
}>;
settings_routes: PlatformFrontendRouteInfo[];
};
export type PlatformModuleInfo = {
id: string;
name: string;
version: string;
dependencies: string[];
optional_dependencies: string[];
enabled: boolean;
nav: Array<{
path: string;
label: string;
icon?: string | null;
section?: string | null;
required_all: string[];
required_any: string[];
order: number;
}>;
frontend?: PlatformFrontendModuleInfo | null;
};

View File

@@ -0,0 +1,14 @@
export function insertAfter<T>(items: T[], index: number, item: T): T[] {
const insertionIndex = Math.max(0, Math.min(items.length, index + 1));
return [...items.slice(0, insertionIndex), item, ...items.slice(insertionIndex)];
}
export function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length || fromIndex === toIndex) {
return items;
}
const next = [...items];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}

View File

@@ -0,0 +1,107 @@
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export type MailboxAddress = {
name?: string;
email: string;
};
export function normalizeEmailAddress(address: MailboxAddress): MailboxAddress {
return {
name: (address.name ?? "").trim(),
email: (address.email ?? "").trim().toLowerCase()
};
}
export function isValidEmailAddress(email: string): boolean {
const normalized = email.trim();
if (!normalized || normalized.length > 254) return false;
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(normalized);
}
export function addressFromRecord(value: unknown): MailboxAddress | null {
const record = asRecord(value);
const email = typeof record.email === "string" ? record.email.trim() : "";
if (!email) return null;
const name = typeof record.name === "string" ? record.name.trim() : "";
return normalizeEmailAddress({ name, email });
}
export function addressesFromValue(value: unknown): MailboxAddress[] {
if (Array.isArray(value)) {
return value.map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
}
const single = addressFromRecord(value);
return single ? [single] : [];
}
export function parseMailboxAddressText(input: string): MailboxAddress | null {
const text = input.trim().replace(/[;,]+$/g, "");
if (!text) return null;
const angleMatch = text.match(/^(.+?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/);
if (angleMatch) {
return normalizeEmailAddress({ name: cleanAddressName(angleMatch[1]), email: angleMatch[2] });
}
const emailMatch = text.match(/([^\s<>;,]+@[^\s<>;,]+\.[^\s<>;,]+)/);
if (!emailMatch) return null;
const email = emailMatch[1];
const name = cleanAddressName(`${text.slice(0, emailMatch.index)} ${text.slice((emailMatch.index ?? 0) + email.length)}`);
return normalizeEmailAddress({ name, email });
}
function cleanAddressName(value: string): string {
return value
.trim()
.replace(/[<>]/g, "")
.replace(/^[\s"'`]+|[\s"'`]+$/g, "")
.replace(/[;,]+$/g, "")
.trim();
}
export function collectCampaignAddressSuggestions(draft: Record<string, unknown> | null | undefined): MailboxAddress[] {
if (!draft) return [];
const suggestions: MailboxAddress[] = [];
const recipients = asRecord(draft.recipients);
const sender = addressFromRecord(recipients.from);
if (sender) suggestions.push(sender);
for (const key of ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]) {
suggestions.push(...addressesFromValue(recipients[key]));
}
const entries = asRecord(draft.entries);
for (const entryValue of asArray(entries.inline)) {
const entry = asRecord(entryValue);
const toAddresses = asArray(entry.to).map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
suggestions.push(...toAddresses);
const direct = addressFromRecord(entry.recipient) ?? addressFromRecord(entry);
if (direct) suggestions.push(direct);
}
return dedupeAddresses(suggestions);
}
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Map<string, MailboxAddress>();
addresses.map(normalizeEmailAddress).forEach((address) => {
if (!address.email) return;
const existing = seen.get(address.email);
if (!existing || (!existing.name && address.name)) {
seen.set(address.email, address);
}
});
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
}
export function addressDisplayName(address: MailboxAddress): string {
const normalized = normalizeEmailAddress(address);
return normalized.name || normalized.email;
}

View File

@@ -0,0 +1,86 @@
const FIELD_HELP_BY_LABEL: Record<string, string> = {
"API base URL": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
"Automation API key": "Fallback API key for scripted or non-login access. Interactive browser sessions should use login tokens instead.",
"Email": "Email address used to identify the user account for login or address entry.",
"Password": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
"Name": "Human-readable display name shown in lists, reports or outgoing address headers.",
"Email address": "The mailbox address, for example office@example.org.",
"Campaign ID": "Stable technical identifier used in JSON, reports, filenames and backend references.",
"Campaign name": "Human-readable campaign name shown in lists, review screens and reports.",
"Mode": "Campaign operating mode. Use draft while editing, test for trial runs and send when preparing the real campaign.",
"Scenario": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
"Description": "Internal explanation of the campaign purpose. This is not sent to recipients.",
"Default From address": "Default sender address used for outgoing messages unless individual senders are allowed and set per recipient.",
"From name": "Display name shown with the sender address in outgoing messages.",
"From email": "Mailbox address used as the sender of outgoing messages.",
"Global Reply-To address": "Default reply destination for recipient responses. Leave empty to let replies go to the From address.",
"Reply-To": "Address recipients should reply to when it differs from the sender address.",
"To": "Global To recipients added to every message in addition to recipient-specific recipients.",
"Global recipients": "Default recipients included in every generated message.",
"CC": "Global carbon-copy recipients included in every generated message.",
"BCC": "Global blind-copy recipients included in every generated message.",
"Template source": "Where this campaign template comes from. Inline means it is stored directly in the campaign draft.",
"Library template": "Reusable template record this campaign should refer to once the template backend is available.",
"Subject": "Message subject. Placeholders can reference campaign/global/recipient fields.",
"Body": "Template body content shown for review. Placeholders are checked against campaign fields.",
"Plain text body": "Plain text version of the email body. It should remain readable without HTML rendering.",
"HTML body": "Optional HTML version of the email body for richer formatting.",
"Attachment base path": "Base folder used to resolve campaign attachment rules and relative file patterns.",
"Campaign attachment base path": "Base folder used during campaign creation to resolve attachment files and patterns.",
"Default missing behavior": "Default review behavior when an expected attachment cannot be found.",
"Default ambiguous behavior": "Default review behavior when a file pattern matches more than one attachment.",
"Base directory": "Folder below the campaign attachment base path where this rule starts looking for files.",
"File filter": "Filename or pattern used to select matching files. Field placeholders can be introduced later.",
"Include subdirectories": "Whether the rule should also search below nested folders.",
"Allow multiple matches": "Whether more than one matching file may be attached for this rule.",
"Missing behavior": "Action or review severity when this rule finds no matching file.",
"Ambiguous behavior": "Action or review severity when this rule finds multiple possible matches.",
"Host": "Mail server hostname or IP address for the selected protocol.",
"SMTP host": "SMTP server hostname or IP address used for sending messages.",
"Port": "Network port used by the selected mail protocol.",
"SMTP port": "Network port used by the SMTP server, commonly 587 for STARTTLS or 465 for TLS.",
"Username": "Login name for the selected mail server. Often the same as the mailbox address.",
"Security": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
"Timeout seconds": "Maximum time to wait for the mail server before the connection test or send attempt fails.",
"Detected/saved sent folder": "IMAP folder name used as the detected or manually configured Sent folder.",
"Append folder": "Folder where successfully sent messages should be appended via IMAP.",
"IMAP append to Sent": "Whether sent messages should also be copied into the configured Sent folder via IMAP.",
"Messages per minute": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
"Concurrency": "Number of send operations that may run in parallel. Keep low until account and provider limits are clear.",
"Max attempts": "Maximum retry attempts for a message before it remains failed for review.",
"Source type": "Recipient data source format. Inline data is edited in the campaign; external sources will be parsed later.",
"Source path": "Path or identifier for an external recipient source such as a CSV file.",
"Remember previously used addresses": "Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only.",
"External address-book sync": "Placeholder for CardDAV, Google, LDAP or similar address sources.",
"Allow individual senders": "Permit recipient rows to override the campaign's default sender address.",
"Allow individual From": "Permit per-recipient sender overrides when building messages.",
"Allow individual Reply-To": "Permit recipient rows to override the global Reply-To address.",
"Allow individual To": "Permit recipient rows to define their own To recipients in addition to global headers.",
"Allow individual CC": "Permit recipient rows to define their own CC recipients.",
"Allow individual BCC": "Permit recipient rows to define their own BCC recipients.",
"Allow individual attachments": "Permit recipient-specific attachment rules in addition to global files.",
"Send without attachments": "Allow messages to be sent even when no attachment is resolved. Disable to force review or blocking.",
"Status tracking": "Store per-message build, validation, queue, send and IMAP status for review and reporting.",
"Suggest addresses from this campaign": "Use addresses already present in this campaign as autocomplete suggestions.",
"Remember newly used addresses": "Prepare newly entered addresses for a future user-level address memory.",
"Show guided warnings while editing": "Show inline guidance and warnings while campaign data is being edited.",
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
"Subdirs": "Search nested folders below the configured base directory.",
"Can override": "Allow recipient-specific values to override the global value for this field.",
"Enable IMAP": "Enable IMAP settings so the server can test login, list folders and optionally append sent copies.",
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
};
export function helpForFieldLabel(label: unknown): string | undefined {
if (typeof label !== "string") return undefined;
return FIELD_HELP_BY_LABEL[label];
}

View File

@@ -0,0 +1,66 @@
export type HelpContext = {
id: string;
title: string;
route: string;
};
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
data: { id: "campaign.settings", title: "Campaign settings" },
campaign: { id: "campaign.settings", title: "Campaign settings" },
fields: { id: "campaign.fields", title: "Campaign fields" },
template: { id: "campaign.template", title: "Template" },
files: { id: "campaign.attachments", title: "Attachments" },
attachments: { id: "campaign.attachments", title: "Attachments" },
recipients: { id: "campaign.recipients", title: "Sender & Recipients" },
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
mail: { id: "campaign.server-settings", title: "Server settings" },
"global-settings": { id: "campaign.global-settings", title: "Policies" },
settings: { id: "campaign.global-settings", title: "Policies" },
review: { id: "campaign.review-send", title: "Review & Send" },
send: { id: "campaign.review-send", title: "Review & Send" },
report: { id: "campaign.report", title: "Report" },
reports: { id: "campaign.report", title: "Report" },
audit: { id: "campaign.audit", title: "Audit log" },
json: { id: "campaign.json", title: "JSON" }
};
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
dashboard: { id: "app.dashboard", title: "Dashboard" },
campaigns: { id: "campaigns.list", title: "Campaigns" },
templates: { id: "templates.list", title: "Templates" },
files: { id: "files.list", title: "Files" },
"address-book": { id: "address-book.list", title: "Address Book" },
reports: { id: "reports.list", title: "Reports" },
settings: { id: "app.settings", title: "Settings" },
admin: { id: "app.admin", title: "Admin" }
};
export function helpContextForPathname(pathname: string): HelpContext {
const route = pathname || "/";
const segments = route.split("/").filter(Boolean);
if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "Campaign overview", route };
if (segments[2] === "wizard") {
const step = segments[3] || "create";
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
}
const context = campaignSectionContexts[segments[2]];
if (context) return { ...context, route };
return { id: "campaign.workspace", title: "Campaign workspace", route };
}
const context = topLevelContexts[segments[0] || "campaigns"];
if (context) return { ...context, route };
return { id: "app.general", title: "Application", route };
}
export function helpQueryForContext(context: HelpContext): string {
return `context=${encodeURIComponent(context.id)}`;
}
function capitalize(value: string): string {
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}

View File

@@ -0,0 +1,130 @@
import type { AuthInfo } from "../types";
const legacyTenantScopes = [
"campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"campaign:validate", "campaign:build", "campaign:review", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile",
"recipients:read", "recipients:write", "recipients:import", "recipients:export",
"files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin",
"reports:read", "reports:export", "reports:send", "audit:read",
"mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:write", "mail_servers:manage_credentials",
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
"admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke",
"admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write"
];
const legacyToModuleScopes: Record<string, string> = {
"campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update",
"campaign:copy": "campaigns:campaign:copy",
"campaign:archive": "campaigns:campaign:archive",
"campaign:delete": "campaigns:campaign:delete",
"campaign:share": "campaigns:campaign:share",
"campaign:validate": "campaigns:campaign:validate",
"campaign:build": "campaigns:campaign:build",
"campaign:review": "campaigns:campaign:review",
"campaign:send_test": "campaigns:campaign:send_test",
"campaign:queue": "campaigns:campaign:queue",
"campaign:control": "campaigns:campaign:control",
"campaign:send": "campaigns:campaign:send",
"campaign:retry": "campaigns:campaign:retry",
"campaign:reconcile": "campaigns:campaign:reconcile",
"recipients:read": "campaigns:recipient:read",
"recipients:write": "campaigns:recipient:write",
"recipients:import": "campaigns:recipient:import",
"recipients:export": "campaigns:recipient:export",
"reports:read": "campaigns:report:read",
"reports:export": "campaigns:report:export",
"reports:send": "campaigns:report:send",
"files:read": "files:file:read",
"files:download": "files:file:download",
"files:upload": "files:file:upload",
"files:organize": "files:file:organize",
"files:share": "files:file:share",
"files:delete": "files:file:delete",
"files:admin": "files:file:admin",
"mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test",
"mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage"
};
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
const moduleTenantScopes = new Set(Object.values(legacyToModuleScopes));
const tenantScopes = new Set([...legacyTenantScopes, ...moduleTenantScopes]);
const tenantWildcardLegacyScopes = new Set(["attachments:read", "attachments:write"]);
const legacyAliases: Record<string, string[]> = {
"campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"],
"attachments:read": ["files:read", "files:download"],
"attachments:write": ["files:upload", "files:organize", "files:share", "files:delete"],
"admin:users": ["admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", "admin:roles:read", "admin:roles:write", "admin:roles:assign"],
"admin:users:write": ["admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"],
"admin:api_keys:write": ["admin:api_keys:create", "admin:api_keys:revoke"],
"admin:settings": ["admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"],
"system:tenants:write": ["system:tenants:create", "system:tenants:update", "system:tenants:suspend"],
"system:access:write": ["system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"]
};
function compatibleScopes(scope: string): string[] {
const mapped = legacyToModuleScopes[scope] ?? moduleToLegacyScopes[scope];
return mapped ? [scope, mapped] : [scope];
}
function primitiveScopeGrants(granted: string, required: string): boolean {
if (granted === required) return true;
if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
if (granted === "*" || granted === "tenant:*") {
return required === "*" || required === "tenant:*" || tenantScopes.has(required) || tenantWildcardLegacyScopes.has(required);
}
if (granted === "system:*") return required.startsWith("system:") || required.startsWith("access:");
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
return false;
}
export function scopeGrants(granted: string, required: string): boolean {
for (const grantedCandidate of compatibleScopes(granted)) {
for (const requiredCandidate of compatibleScopes(required)) {
if (primitiveScopeGrants(grantedCandidate, requiredCandidate)) return true;
}
}
return false;
}
export function hasTenantWildcard(scopes: string[]): boolean {
return scopes.includes("*") || scopes.includes("tenant:*");
}
export function hasScope(auth: AuthInfo | null | undefined, required: string): boolean {
return Boolean(auth?.scopes?.some((scope) => scopeGrants(scope, required)));
}
export function hasAnyScope(auth: AuthInfo | null | undefined, required: string[]): boolean {
return required.some((scope) => hasScope(auth, scope));
}
export const adminReadScopes = [
"admin:users:read",
"admin:groups:read",
"admin:roles:read",
"admin:api_keys:read",
"admin:settings:read",
"admin:policies:read",
"mail:profile:read",
"mail_servers:read",
"audit:read",
"system:tenants:read",
"system:accounts:read",
"system:access:read",
"system:roles:read",
"system:audit:read",
"system:settings:read",
"system:governance:read",
"access:tenant:read",
"access:account:read",
"access:governance:read"
];

1
webui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

37
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2020"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"paths": {
"@govoplan/core-webui": [
"./src/index.ts"
],
"@govoplan/core-webui/app": [
"./src/app.ts"
]
}
},
"include": [
"src"
]
}

35
webui/vite.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
preserveSymlinks: true,
dedupe: ["react", "react-dom", "react-router-dom"],
alias: [
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
]
},
server: {
fs: {
allow: [
fileURLToPath(new URL('.', import.meta.url)),
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url))
]
},
proxy: {
"/api": {
target: process.env.VITE_API_PROXY_TARGET || "http://127.0.0.1:8000",
changeOrigin: true
},
"/health": {
target: process.env.VITE_API_PROXY_TARGET || "http://127.0.0.1:8000",
changeOrigin: true
}
}
}
});