Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 02564047e9
commit df701fddd2
29 changed files with 600 additions and 154 deletions

View File

@@ -26,7 +26,7 @@ export default function AdminAuditPanel({
const [items, setItems] = useState<AuditAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [pageSize, setPageSize] = useState(10);
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
const [loading, setLoading] = useState(true);
@@ -106,7 +106,7 @@ export default function AdminAuditPanel({
page,
pageSize,
totalRows: total,
pageSizeOptions: [25, 50, 100, 250],
pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading,
onPageChange: setPage,
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }

View File

@@ -43,13 +43,14 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
{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 override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
{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-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
</div>
</Card>}
@@ -64,15 +65,13 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
<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-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
{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-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
{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-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-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
</div>
</Card>
@@ -82,7 +81,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
}
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));
return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-audit"].some((section) => sections.has(section));
}
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {

View File

@@ -8,6 +8,7 @@ import { adminReadScopes, hasAnyScope, hasScope } from "../../utils/permissions"
import AdminOverviewPanel from "./AdminOverviewPanel";
import SystemUsersPanel from "./SystemUsersPanel";
import SystemSettingsPanel from "./SystemSettingsPanel";
import TenantSettingsPanel from "./TenantSettingsPanel";
import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel";
import SystemRolesPanel from "./SystemRolesPanel";
import TenantsPanel from "./TenantsPanel";
@@ -18,7 +19,6 @@ import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel";
import MailProfilesPanel from "./MailProfilesPanel";
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
import PageTitle from "../../components/PageTitle";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
type AdminSection =
@@ -120,42 +120,42 @@ export default function AdminPage({
{
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-settings") ? [{ id: "system-settings" as const, label: "General" }] : []),
...(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-groups") ? [{ id: "system-groups" as const, label: "Groups" }] : []),
...(available.has("system-users") ? [{ id: "system-users" as const, label: "Users" }] : []),
...(available.has("system-mail-servers") ? [{ id: "system-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("system-retention") ? [{ id: "system-retention" as const, label: "Retention" }] : []),
...(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-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []),
...(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-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
...(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-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
...(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" }] : []),
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []),
]
},
{
title: "USER",
items: [
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []),
]
}
].filter((group) => group.items.length > 0);
@@ -197,14 +197,10 @@ export default function AdminPage({
{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-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
{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

@@ -75,7 +75,7 @@ export default function SystemSettingsPanel({ settings, canWrite }: { settings:
return (
<AdminPageLayout
title="System settings"
title="System general settings"
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
loading={loading}
error={error}

View File

@@ -0,0 +1,86 @@
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 { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin";
import AdminPageLayout from "./components/AdminPageLayout";
import { adminErrorMessage } from "./adminUtils";
const fallback: TenantSettingsItem = {
id: "",
slug: "",
name: "",
default_locale: "en",
settings: {}
};
export default function TenantSettingsPanel({
settings,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
const [draft, setDraft] = useState<TenantSettingsItem>(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("");
setSuccess("");
try {
setDraft(await fetchTenantSettings(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
async function save() {
setBusy(true);
setError("");
setSuccess("");
try {
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale });
setDraft(saved);
setSuccess("Tenant general settings saved.");
await onAuthRefresh();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
return (
<AdminPageLayout
title="Tenant general settings"
description="Settings for the active tenant context."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>}
>
<div className="admin-settings-form">
<Card title="Locale">
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults.">
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} />
</FormField>
<dl className="detail-list">
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>Slug</dt><dd>{draft.slug || "-"}</dd></div>
</dl>
</Card>
</div>
</AdminPageLayout>
);
}

View File

@@ -1,7 +1,8 @@
import { useState } from "react";
import { useId, useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types";
import { login } from "../../api/auth";
import Button from "../../components/Button";
import Dialog from "../../components/Dialog";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import DismissibleAlert from "../../components/DismissibleAlert";
@@ -23,6 +24,7 @@ export default function LoginModal({
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
async function submit(event: React.FormEvent) {
event.preventDefault();
@@ -40,27 +42,27 @@ export default function LoginModal({
}
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<form className="modal-panel" onSubmit={submit}>
<header className="modal-header">
<h2>{title}</h2>
<button className="modal-close" type="button" onClick={onClose}>×</button>
</header>
<div className="modal-body form-grid">
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{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">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</div>
<footer className="modal-footer">
<Dialog
open
title={title}
onClose={onClose}
footer={(
<>
<Button type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
</footer>
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
</>
)}
>
<form id={formId} className="form-grid" onSubmit={submit}>
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{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">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</form>
</div>
</Dialog>
);
}