Release v0.1.3
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
86
webui/src/features/admin/TenantSettingsPanel.tsx
Normal file
86
webui/src/features/admin/TenantSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user