Release v0.1.6

This commit is contained in:
2026-07-07 16:00:38 +02:00
parent a2053518d1
commit 150b720f12
149 changed files with 14311 additions and 8005 deletions

View File

@@ -1,13 +1,16 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
@@ -31,9 +34,66 @@
"react-router-dom": "^7.1.1"
}
},
"../../govoplan-access/webui": {
"name": "@govoplan/access-webui",
"version": "0.1.6",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui",
"version": "0.1.6",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-calendar/webui": {
"name": "@govoplan/calendar-webui",
"version": "0.1.6",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"read-excel-file": "^9.2.0"
},
@@ -41,7 +101,7 @@
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -55,9 +115,9 @@
},
"../../govoplan-files/webui": {
"name": "@govoplan/files-webui",
"version": "0.1.4",
"version": "0.1.6",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
@@ -74,12 +134,12 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.4",
"version": "0.1.6",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -815,6 +875,18 @@
"node": ">=18"
}
},
"node_modules/@govoplan/access-webui": {
"resolved": "../../govoplan-access/webui",
"link": true
},
"node_modules/@govoplan/admin-webui": {
"resolved": "../../govoplan-admin/webui",
"link": true
},
"node_modules/@govoplan/calendar-webui": {
"resolved": "../../govoplan-calendar/webui",
"link": true
},
"node_modules/@govoplan/campaign-webui": {
"resolved": "../../govoplan-campaign/webui",
"link": true

View File

@@ -1,16 +1,19 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4"
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -709,14 +712,65 @@
"node": ">=18"
}
},
"node_modules/@govoplan/access-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#37828fe34099c17267e63cfbc3a78ae1075b6b52",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@govoplan/admin-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#0f2a9beca76c695a70a709bce2d843e6b1349277",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@govoplan/calendar-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#c3c867391c39813593930f0272a1a0142c70872b",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#fb6fb67d451858cca891d8a2fe5b6b8790f75f0d",
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#57f6066bf72ba817ce5a1973405d2422ae59678d",
"dependencies": {
"read-excel-file": "^9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -729,10 +783,10 @@
}
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#ee5b06b1e7d60f458ceb9ed943369c55201698ce",
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#18e6c3eb9b776bd58c18a750725d6cd8a403bc5c",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
@@ -748,10 +802,10 @@
}
},
"node_modules/@govoplan/mail-webui": {
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3743b236130d6684b5e8d406f5070e3ff9e33d07",
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#b4b0c76455e5fddadd03af3b023c82678dedf36f",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.4",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -25,6 +25,9 @@
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
},
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.4",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -22,9 +22,12 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4"
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6"
},
"devDependencies": {
"lucide-react": "^0.555.0",

View File

@@ -1,6 +1,8 @@
import { spawnSync } from "node:child_process";
const packageByModule = {
access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui",
campaigns: "@govoplan/campaign-webui",
files: "@govoplan/files-webui",
mail: "@govoplan/mail-webui"
@@ -8,12 +10,15 @@ const packageByModule = {
const cases = [
{ name: "core-only", modules: [] },
{ name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] },
{ name: "access-with-admin", modules: ["access", "admin"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
{ name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "full-product", modules: ["campaigns", "files", "mail"] }
{ name: "full-product", modules: ["access", "admin", "campaigns", "files", "mail"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -1,30 +1,32 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe } from "./api/auth";
import { fetchPlatformModules } from "./api/platform";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo, PlatformWebModule } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary";
import { adminReadScopes } from "./utils/permissions";
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { firstAccessibleRoute, loadRemoteWebModules, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
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 [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{ enabled: boolean; message?: string | null }>({ enabled: false, message: null });
const [reloginMessage, setReloginMessage] = useState("");
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
@@ -75,6 +77,18 @@ export default function App() {
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
}, [auth]);
useEffect(() => {
let cancelled = false;
fetchPlatformStatus(settings)
.then((response) => {
if (!cancelled) setMaintenanceMode(response.maintenance_mode);
})
.catch(() => {
if (!cancelled) setMaintenanceMode({ enabled: false, message: null });
});
return () => { cancelled = true; };
}, [settings.apiBaseUrl]);
useEffect(() => {
let cancelled = false;
setCheckingSession(true);
@@ -87,6 +101,7 @@ export default function App() {
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
}
})
.finally(() => {
@@ -99,13 +114,52 @@ export default function App() {
if (!auth) return;
let cancelled = false;
fetchPlatformModules(settings)
.then((response) => { if (!cancelled) setPlatformModules(response.modules); })
.catch(() => { if (!cancelled) setPlatformModules(null); });
let inFlight = false;
let lastRefreshAt = 0;
function loadModules() {
inFlight = true;
lastRefreshAt = Date.now();
return fetchPlatformModules(settings)
.then((response) => { if (!cancelled) setPlatformModules(response.modules); })
.catch(() => { if (!cancelled) setPlatformModules(null); })
.finally(() => { inFlight = false; });
}
return () => { cancelled = true; };
void loadModules();
function handleModulesChanged() {
void loadModules();
}
function refreshVisibleModules() {
if (document.visibilityState === "hidden" || inFlight) return;
if (Date.now() - lastRefreshAt < 5_000) return;
void loadModules();
}
window.addEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged);
window.addEventListener("focus", refreshVisibleModules);
document.addEventListener("visibilitychange", refreshVisibleModules);
return () => {
cancelled = true;
window.removeEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged);
window.removeEventListener("focus", refreshVisibleModules);
document.removeEventListener("visibilitychange", refreshVisibleModules);
};
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
if (!auth || !platformModules?.length) {
setRemoteWebModules([]);
return () => { cancelled = true; };
}
loadRemoteWebModules(platformModules, localWebModules)
.then((modules) => { if (!cancelled) setRemoteWebModules(modules); })
.catch(() => { if (!cancelled) setRemoteWebModules([]); });
return () => { cancelled = true; };
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
useEffect(() => {
if (!auth) return;
@@ -159,7 +213,7 @@ export default function App() {
<PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
</AppShell>
</UnsavedChangesProvider>
</PlatformModulesProvider>
@@ -182,17 +236,12 @@ export default function App() {
path={route.path}
element={
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
{route.render({ settings, auth })}
{route.render({ settings, auth, onAuthChange: updateAuth })}
</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>
@@ -210,3 +259,16 @@ export default function App() {
</PlatformModulesProvider>
);
}
function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] {
if (remoteModules.length === 0) return localModules;
const seen = new Set(localModules.map((module) => module.id));
return [
...localModules,
...remoteModules.filter((module) => {
if (seen.has(module.id)) return false;
seen.add(module.id);
return true;
})
];
}

View File

@@ -4,6 +4,13 @@ import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
export type PlatformStatusResponse = {
maintenance_mode: {
enabled: boolean;
message?: string | null;
};
};
export type PlatformPermission = {
scope: string;
module_id: string;
@@ -22,6 +29,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
}
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
}
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
}

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import Button from "../../../components/Button";
import Button from "../Button";
type Props = {
label: string;

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import PageTitle from "../../../components/PageTitle";
import LoadingFrame from "../../../components/LoadingFrame";
import DismissibleAlert from "../../../components/DismissibleAlert";
import DismissibleAlert from "../DismissibleAlert";
import LoadingFrame from "../LoadingFrame";
import PageTitle from "../PageTitle";
type Props = {
title: string;
@@ -19,7 +19,7 @@ export default function AdminPageLayout({
title,
description,
loading = false,
loadingLabel = "Loading administration data",
loadingLabel = "Loading administration data...",
error = "",
success = "",
actions,

View File

@@ -18,7 +18,7 @@ export function adminErrorMessage(error: unknown): string {
return message;
}
export function formatDateTime(value?: string | null): string {
export function formatAdminDateTime(value?: string | null): string {
return formatPlatformDateTime(value, { fallback: "Never" });
}

View File

@@ -1,123 +0,0 @@
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(10);
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: [10, 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

@@ -1,93 +0,0 @@
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="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-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>}
<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="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-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-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>
</>}
</AdminPageLayout>
);
}
function hasSystemArea(sections: ReadonlySet<string>): boolean {
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 }) {
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

@@ -1,206 +0,0 @@
import { useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } 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 TenantSettingsPanel from "./TenantSettingsPanel";
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 { usePlatformUiCapability } from "../../platform/ModuleContext";
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 mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const mailProfilesAvailable = Boolean(mailProfilesUi);
const available = useMemo(() => {
const sections = new Set<AdminSection>(["overview"]);
if (hasScope(auth, "system:settings:read")) {
sections.add("system-settings");
sections.add("system-retention");
if (mailProfilesAvailable) 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 (mailProfilesAvailable && 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, mailProfilesAvailable]);
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: "General" }] : []),
...(available.has("system-tenants") ? [{ id: "system-tenants" as const, label: "Tenants" }] : []),
...(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: "General" }] : []),
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
...(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: "GROUP",
items: [
...(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);
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" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
{active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
</div>
</section>
</div>
);
}

View File

@@ -1,127 +0,0 @@
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

@@ -1,232 +0,0 @@
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

@@ -1,142 +0,0 @@
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

@@ -1,123 +0,0 @@
import { useEffect, useState } from "react";
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "../../types";
import { fetchGroups, fetchUsers } from "../../api/admin";
import Card from "../../components/Card";
import AdminPageLayout from "./components/AdminPageLayout";
import { adminErrorMessage } from "./adminUtils";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
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 mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
const [targetError, setTargetError] = useState("");
useEffect(() => {
if (!MailProfileScopeManager) {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]);
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];
if (!MailProfileScopeManager) {
return (
<AdminPageLayout title={labels.title} description={labels.description}>
<Card title="Mail module unavailable">
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p>
</Card>
</AdminPageLayout>
);
}
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

@@ -1,143 +0,0 @@
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 override permissions.",
policyTitle: "System retention policy",
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
},
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 override toggles decide which fields users, groups and campaigns may override."
},
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 override toggles decide which fields user-owned campaigns may override."
},
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 override toggles decide which fields group-owned campaigns may override."
}
};
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

@@ -1,133 +0,0 @@
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

@@ -1,257 +0,0 @@
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

@@ -1,100 +0,0 @@
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 general 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

@@ -1,227 +0,0 @@
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 PasswordField from "../../components/PasswordField";
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">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
</FormField>
)}
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
</div>
<div className="admin-assignment-grid">
<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

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

View File

@@ -1,192 +0,0 @@
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 PasswordField from "../../components/PasswordField";
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">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
</FormField>
)}
<FormField label="Membership status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
</div>
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}
{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

@@ -1,53 +0,0 @@
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

@@ -5,9 +5,11 @@ import LoginModal from "./LoginModal";
export default function PublicLandingPage({
settings,
maintenanceMode,
onLogin
}: {
settings: ApiSettings;
maintenanceMode?: { enabled: boolean; message?: string | null };
onLogin: (response: LoginResponse) => void;
}) {
const [loginOpen, setLoginOpen] = useState(false);
@@ -21,6 +23,13 @@ export default function PublicLandingPage({
Sign in to open the modules available to your tenant and role.
</p>
{maintenanceMode?.enabled && (
<div className="public-maintenance alert warning">
<strong>Maintenance mode is active.</strong>
<span>{maintenanceMode.message || "Only users with maintenance access can use the system right now."}</span>
</div>
)}
<div className="public-actions">
<Button variant="primary" onClick={() => setLoginOpen(true)}>Sign in</Button>
</div>

View File

@@ -5,6 +5,7 @@ export * from "./api/auth";
export * from "./api/platform";
export * from "./platform/modules";
export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";
export * from "./utils/permissions";
export * from "./utils/fieldHelp";
@@ -14,6 +15,10 @@ export * from "./utils/datetime";
export * from "./utils/arrayOrder";
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button";
export { default as Card } from "./components/Card";
export { default as ConfirmDialog } from "./components/ConfirmDialog";

View File

@@ -1,8 +1,8 @@
import { Settings, Shield } from "lucide-react";
import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import type { AuthInfo, PlatformNavItem } from "../types";
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
import { hasAnyScope, hasScope } from "../utils/permissions";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
@@ -26,10 +26,7 @@ export default function IconRail({
navItems?: PlatformNavItem[];
}) {
const location = useLocation();
const visibleItems = visibleNavItems(auth, navItems);
const items = hasAnyScope(auth, adminReadScopes)
? [...visibleItems, { to: "/admin", label: "Admin", icon: Shield }]
: visibleItems;
const items = visibleNavItems(auth, navItems);
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);

View File

@@ -1,6 +1,6 @@
import { createContext, type ReactNode, useContext } from "react";
import type { PlatformWebModule } from "../types";
import { moduleInstalled, uiCapability } from "./modules";
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
@@ -19,3 +19,7 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
return uiCapability<T>(capabilityName, usePlatformModules());
}
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
return uiCapabilities<T>(capabilityName, usePlatformModules());
}

View File

@@ -0,0 +1,5 @@
export const PLATFORM_MODULES_CHANGED_EVENT = "govoplan:platform-modules-changed";
export function dispatchPlatformModulesChanged() {
window.dispatchEvent(new CustomEvent(PLATFORM_MODULES_CHANGED_EVENT));
}

View File

@@ -8,6 +8,13 @@ export function uiCapability<T = unknown>(capabilityName: string, modules: Platf
return null;
}
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T[] {
return modules.flatMap((module) => {
const value = module.uiCapabilities?.[capabilityName];
return value === undefined ? [] : [value as T];
});
}
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[]): boolean {
return uiCapability(capabilityName, modules) !== null;
}

View File

@@ -1,4 +1,4 @@
import { Activity, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Users, type LucideIcon } from "lucide-react";
import { Activity, CalendarDays, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Shield, Users, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
import {
@@ -6,18 +6,36 @@ import {
moduleInstalled as moduleInstalledForModules,
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
routeContributionsForModules as routeContributionsForModuleList,
uiCapabilities as uiCapabilitiesForModules,
uiCapability as uiCapabilityForModules
} from "./moduleLogic";
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
import { hasAnyScope, hasScope } from "../utils/permissions";
export const shellNavItems: PlatformNavItem[] = [
{ to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
];
const localModules: PlatformWebModule[] = installedWebModules;
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
declare global {
interface Window {
__GOVOPLAN_REMOTE_MODULE_KEYS__?: Record<string, JsonWebKey>;
}
}
type RemoteAssetManifest = {
contractVersion?: string;
moduleId?: string;
entry?: string;
entryIntegrity?: string;
moduleExport?: string;
};
const iconByName: Record<string, LucideIcon> = {
activity: Activity,
admin: Shield,
calendar: CalendarDays,
campaign: Mails,
dashboard: LayoutDashboard,
file: Folder,
@@ -69,6 +87,11 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
version: info.version,
dependencies: info.dependencies,
optionalDependencies: info.optional_dependencies,
remoteAssetManifest: info.frontend?.asset_manifest ?? module.remoteAssetManifest,
remoteAssetSignature: info.frontend?.asset_manifest_signature ?? module.remoteAssetSignature,
remoteAssetPublicKeyId: info.frontend?.asset_manifest_public_key_id ?? module.remoteAssetPublicKeyId,
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
uiCapabilities: {
...(module.uiCapabilities ?? {}),
@@ -90,6 +113,142 @@ export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[]
.filter((module): module is PlatformWebModule => module !== null);
}
export async function loadRemoteWebModules(
platformModules: PlatformModuleInfo[] | null | undefined,
alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const loadedIds = new Set(alreadyLoaded.map((module) => module.id));
const candidates = platformModules.filter((module) => {
return module.enabled && !loadedIds.has(module.id) && Boolean(module.frontend?.asset_manifest);
});
if (candidates.length === 0) return [];
const loaded = await Promise.all(candidates.map((module) => {
const cacheKey = `${module.id}:${module.frontend?.asset_manifest ?? ""}:${module.version}`;
let promise = remoteModuleCache.get(cacheKey);
if (!promise) {
promise = loadRemoteWebModule(module).catch((error) => {
console.warn(`GovOPlaN remote module ${module.id} was not loaded:`, error);
return null;
});
remoteModuleCache.set(cacheKey, promise);
}
return promise;
}));
return loaded.filter((module): module is PlatformWebModule => module !== null);
}
async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
const frontend = info.frontend;
if (!frontend?.asset_manifest) return null;
const manifestUrl = absoluteModuleAssetUrl(frontend.asset_manifest);
const manifestResponse = await fetch(manifestUrl, { credentials: "same-origin" });
if (!manifestResponse.ok) throw new Error(`manifest fetch failed with HTTP ${manifestResponse.status}`);
const manifestBuffer = await manifestResponse.arrayBuffer();
if (frontend.asset_manifest_integrity) {
const ok = await verifyIntegrity(manifestBuffer, frontend.asset_manifest_integrity);
if (!ok) throw new Error("manifest integrity check failed");
}
const manifestText = new TextDecoder().decode(manifestBuffer);
if (frontend.asset_manifest_signature && frontend.asset_manifest_public_key_id) {
const signatureOk = await verifyManifestSignature(
manifestBuffer,
frontend.asset_manifest_signature,
frontend.asset_manifest_public_key_id
);
if (signatureOk === false) throw new Error("manifest signature check failed");
if (signatureOk === null && !frontend.asset_manifest_integrity) throw new Error("manifest signature key is not trusted by this shell");
} else if (!frontend.asset_manifest_integrity) {
throw new Error("remote manifests need an integrity hash or a verifiable signature");
}
const manifest = JSON.parse(manifestText) as RemoteAssetManifest;
const contractVersion = manifest.contractVersion ?? frontend.asset_manifest_contract_version ?? "1";
if (contractVersion !== "1") throw new Error(`unsupported remote asset contract ${contractVersion}`);
if (manifest.moduleId && manifest.moduleId !== info.id) throw new Error(`manifest module id ${manifest.moduleId} does not match ${info.id}`);
if (!manifest.entry) throw new Error("remote manifest has no entry");
if (!manifest.entryIntegrity) throw new Error("remote entry needs an integrity hash");
const entryUrl = absoluteModuleAssetUrl(manifest.entry, manifestUrl);
const entryResponse = await fetch(entryUrl, { credentials: "same-origin" });
if (!entryResponse.ok) throw new Error(`entry fetch failed with HTTP ${entryResponse.status}`);
const entryBuffer = await entryResponse.arrayBuffer();
if (!(await verifyIntegrity(entryBuffer, manifest.entryIntegrity))) throw new Error("entry integrity check failed");
const blobUrl = URL.createObjectURL(new Blob([entryBuffer], { type: "text/javascript" }));
try {
const imported = await import(/* @vite-ignore */ blobUrl) as Record<string, unknown>;
const exported = manifest.moduleExport ? imported[manifest.moduleExport] : imported.default ?? imported.module;
if (!isPlatformWebModule(exported)) throw new Error("entry did not export a PlatformWebModule");
if (exported.id !== info.id) throw new Error(`entry module id ${exported.id} does not match ${info.id}`);
return applyServerMetadata(exported, info);
} finally {
URL.revokeObjectURL(blobUrl);
}
}
function absoluteModuleAssetUrl(value: string, base = window.location.origin): string {
return new URL(value, base).toString();
}
async function verifyIntegrity(buffer: ArrayBuffer, integrity: string): Promise<boolean> {
const separator = integrity.indexOf("-");
const algorithm = separator > 0 ? integrity.slice(0, separator) : "";
const expected = separator > 0 ? integrity.slice(separator + 1) : "";
if (!algorithm || !expected) return false;
const digestName = {
"SHA-256": "SHA-256",
"SHA-384": "SHA-384",
"SHA-512": "SHA-512",
SHA256: "SHA-256",
SHA384: "SHA-384",
SHA512: "SHA-512"
}[algorithm.toUpperCase()];
if (!digestName) return false;
const digest = await crypto.subtle.digest(digestName, buffer);
return constantTimeEqual(base64FromBuffer(digest), expected) || constantTimeEqual(hexFromBuffer(digest), expected.toLowerCase());
}
async function verifyManifestSignature(buffer: ArrayBuffer, signature: string, keyId: string): Promise<boolean | null> {
const jwk = window.__GOVOPLAN_REMOTE_MODULE_KEYS__?.[keyId];
if (!jwk) return null;
const isOkp = jwk.kty === "OKP";
const importAlgorithm = isOkp ? { name: "Ed25519" } : { name: "ECDSA", namedCurve: jwk.crv ?? "P-256" };
const verifyAlgorithm = isOkp ? { name: "Ed25519" } : { name: "ECDSA", hash: "SHA-256" };
const key = await crypto.subtle.importKey("jwk", jwk, importAlgorithm as AlgorithmIdentifier, false, ["verify"]);
return crypto.subtle.verify(verifyAlgorithm as AlgorithmIdentifier, key, base64ToBuffer(signature), buffer);
}
function isPlatformWebModule(value: unknown): value is PlatformWebModule {
if (!value || typeof value !== "object") return false;
const item = value as Partial<PlatformWebModule>;
return typeof item.id === "string" && typeof item.label === "string" && typeof item.version === "string";
}
function base64FromBuffer(buffer: ArrayBuffer): string {
let binary = "";
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBuffer(value: string): ArrayBuffer {
const normalized = value.trim().replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return buffer;
}
function hexFromBuffer(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function constantTimeEqual(left: string, right: string): boolean {
if (left.length !== right.length) return false;
let result = 0;
for (let index = 0; index < left.length; index += 1) result |= left.charCodeAt(index) ^ right.charCodeAt(index);
return result === 0;
}
export function installedLocalWebModules(): PlatformWebModule[] {
return localModules;
@@ -99,6 +258,10 @@ export function uiCapability<T = unknown>(capabilityName: string, modules: Platf
return uiCapabilityForModules<T>(capabilityName, modules);
}
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T[] {
return uiCapabilitiesForModules<T>(capabilityName, modules);
}
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean {
return hasUiCapabilityForModules(capabilityName, modules);
}
@@ -132,6 +295,5 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): 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

@@ -70,6 +70,21 @@
gap: 12px;
}
.public-maintenance {
display: grid;
gap: 4px;
margin-top: 18px;
text-align: left;
}
.public-maintenance strong {
color: var(--text-strong);
}
.public-maintenance span {
color: var(--text);
}
.public-footnote {
margin-top: 22px;
color: var(--muted);

View File

@@ -1,5 +1,6 @@
.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-ready, .status-sent, .status-appended, .status-success, .status-active { 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; }
.status-inactive, .status-locked { background: #e7e4df; color: #666; }

View File

@@ -232,6 +232,593 @@
outline-offset: 1px;
}
.admin-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.admin-overview-link {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
text-align: left;
cursor: pointer;
color: var(--text);
}
.admin-overview-link:hover {
background: #fff;
border-color: var(--line-dark);
}
.admin-overview-link strong {
display: block;
color: var(--text-strong);
margin-bottom: 5px;
}
.admin-overview-link span {
display: block;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.module-management-list {
display: grid;
gap: 10px;
}
.module-management-metrics {
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.module-management-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 170px;
align-items: center;
gap: 18px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
}
.module-management-row.pending {
border-color: color-mix(in srgb, var(--amber) 60%, var(--line));
}
.module-management-main {
display: grid;
gap: 8px;
min-width: 0;
}
.module-management-title,
.module-management-meta,
.module-management-details {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.module-management-title strong {
color: var(--text-strong);
}
.module-management-title code {
color: var(--muted);
font-size: 12px;
}
.module-management-title span,
.module-management-meta span,
.module-management-details span {
color: var(--muted);
font-size: 12px;
}
.module-management-toggle {
display: grid;
gap: 8px;
justify-self: end;
width: 170px;
}
.module-management-notes {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
.module-management-notes p {
margin: 0;
}
.module-install-plan-panel {
display: grid;
gap: 14px;
margin-top: 22px;
}
.module-package-catalog,
.module-installer-request-panel {
display: grid;
gap: 12px;
margin-top: 22px;
}
.module-package-catalog-list {
display: grid;
gap: 10px;
}
.module-package-catalog-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
}
.module-package-catalog-description {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.module-installer-request-grid {
display: grid;
grid-template-columns: repeat(2, minmax(240px, 1fr));
gap: 12px;
align-items: start;
}
.module-installer-request-grid label {
display: grid;
gap: 6px;
}
.module-installer-request-grid label span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.module-installer-request-grid textarea {
min-height: 74px;
resize: vertical;
}
.module-install-plan-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.module-install-plan-heading h2 {
margin: 0;
color: var(--text-strong);
font-size: 18px;
}
.module-install-plan-heading p,
.module-install-plan-empty {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.module-install-plan-list {
display: grid;
gap: 10px;
}
.module-install-preflight {
display: grid;
gap: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 12px 14px;
}
.module-install-preflight.is-blocked {
border-color: color-mix(in srgb, var(--red) 55%, var(--line));
}
.module-install-preflight.is-allowed {
border-color: color-mix(in srgb, var(--green) 55%, var(--line));
}
.module-install-preflight-title,
.module-install-preflight-issue {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.module-install-preflight-title strong {
color: var(--text-strong);
}
.module-install-preflight-title span,
.module-install-preflight-issue span {
color: var(--muted);
font-size: 13px;
}
.module-install-preflight-issues {
display: grid;
gap: 6px;
}
.module-install-preflight-issue {
min-height: 30px;
padding: 6px 8px;
border-radius: 4px;
background: rgba(255,255,255,.58);
}
.module-install-preflight-issue strong {
min-width: 64px;
text-transform: uppercase;
color: var(--muted);
font-size: 11px;
}
.module-install-preflight-issue.severity-blocker strong {
color: var(--red);
}
.module-install-preflight-issue.severity-warning strong {
color: var(--amber);
}
.module-install-checklist {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
}
.module-install-checklist-item {
display: grid;
gap: 4px;
align-content: start;
min-height: 86px;
border: 1px solid var(--line);
border-radius: 6px;
background: rgba(255,255,255,.6);
padding: 9px 10px;
}
.module-install-checklist-item strong {
color: var(--text-strong);
font-size: 13px;
}
.module-install-checklist-item span {
justify-self: start;
border-radius: 999px;
background: var(--panel);
color: var(--muted);
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 4px 7px;
text-transform: uppercase;
}
.module-install-checklist-item p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.module-install-checklist-item.status-blocked {
border-color: color-mix(in srgb, var(--red) 45%, var(--line));
}
.module-install-checklist-item.status-blocked span {
color: var(--red);
}
.module-install-checklist-item.status-warning,
.module-install-checklist-item.status-pending {
border-color: color-mix(in srgb, var(--amber) 45%, var(--line));
}
.module-install-checklist-item.status-warning span,
.module-install-checklist-item.status-pending span {
color: var(--amber);
}
.module-install-checklist-item.status-done span {
color: var(--green);
}
.module-install-plan-row {
display: grid;
grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
gap: 12px;
align-items: end;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
}
.module-install-plan-row label {
display: grid;
gap: 6px;
min-width: 0;
}
.module-install-plan-row label span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.module-install-plan-row .wide {
grid-column: span 2;
}
.module-install-plan-actions {
display: flex;
justify-content: flex-end;
}
.module-install-plan-commands {
margin: 0;
}
.module-installer-runs {
display: grid;
gap: 12px;
margin-top: 22px;
}
.module-installer-runs-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.module-installer-runs-heading h2 {
margin: 0;
color: var(--text-strong);
font-size: 18px;
}
.module-installer-runs-heading p {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.module-installer-run-list {
display: grid;
gap: 10px;
}
.module-installer-run-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
align-items: center;
gap: 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
}
.module-installer-run-main,
.module-installer-run-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.module-installer-run-main {
display: grid;
}
.module-installer-run-title strong {
color: var(--text-strong);
font-size: 13px;
}
.module-installer-run-row code {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-installer-run-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.module-installer-run-actions code {
flex: 1 1 auto;
text-align: right;
}
.module-installer-run-error {
margin: 0;
color: var(--red);
font-size: 12px;
}
@media (max-width: 760px) {
.module-management-row {
grid-template-columns: 1fr;
}
.module-management-toggle {
justify-self: start;
width: 170px;
}
.module-install-plan-heading {
display: grid;
}
.module-install-plan-row {
grid-template-columns: 1fr;
}
.module-install-plan-row .wide {
grid-column: auto;
}
.module-package-catalog-row,
.module-installer-request-grid {
grid-template-columns: 1fr;
}
.module-installer-runs-heading,
.module-installer-run-row {
display: grid;
grid-template-columns: 1fr;
}
.module-installer-run-actions {
justify-content: flex-start;
}
}
.admin-section-page {
display: grid;
gap: 16px;
width: 100%;
max-width: 100%;
min-width: 0;
}
.admin-section-page > .loading-frame {
width: 100%;
max-width: 100%;
min-width: 0;
}
.admin-page-heading {
margin-bottom: 0;
}
.admin-page-actions {
align-self: start;
}
.admin-icon-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 36px;
justify-content: end;
gap: 5px;
width: 100%;
}
.admin-icon-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
min-width: 36px;
padding: 0;
}
.admin-icon-button.btn svg {
width: 17px;
height: 17px;
}
.admin-form-grid {
display: grid;
gap: 14px;
}
.admin-form-grid.two-columns,
.admin-assignment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-assignment-grid {
display: grid;
gap: 18px;
margin-top: 18px;
}
.admin-selection-list {
display: grid;
gap: 6px;
max-height: 300px;
overflow: auto;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
}
.admin-selection-item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 8px 9px;
border-radius: 7px;
cursor: pointer;
}
.admin-selection-item:hover {
background: var(--surface, #fff);
}
.admin-selection-item.disabled {
cursor: not-allowed;
opacity: .65;
}
.admin-selection-item small {
display: block;
margin-top: 2px;
color: var(--muted);
}
@media (max-width: 850px) {
.admin-form-grid.two-columns,
.admin-assignment-grid,
.admin-permission-groups {
grid-template-columns: 1fr;
}
}
/* Mail-style address editor: textarea surface + pills + add dialog. */
.email-address-editor {
position: relative;

View File

@@ -163,3 +163,25 @@
color: var(--muted, #5f6b7a);
line-height: 1.5;
}
.admin-dialog {
width: min(680px, calc(100vw - 40px));
}
.admin-dialog-wide {
width: min(1040px, calc(100vw - 40px));
}
.admin-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.admin-dialog > .dialog-footer {
flex: 0 0 auto;
padding: 12px 18px;
border-top: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
}

View File

@@ -145,6 +145,32 @@
scrollbar-gutter: stable;
}
.admin-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.admin-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
}
.card-body > .admin-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .admin-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.card-body > .data-grid-shell:only-child {
margin: -22px -24px;
width: calc(100% + 48px);

View File

@@ -131,6 +131,29 @@ export type PlatformNavItem = {
export type PlatformRouteContext = {
settings: ApiSettings;
auth: AuthInfo;
onAuthChange?: (auth: AuthInfo | null, accessToken?: string) => void;
};
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
export type AdminSectionRenderContext = PlatformRouteContext & {
refreshAuth: () => Promise<void>;
availableSections: ReadonlySet<string>;
selectSection: (section: string) => void;
};
export type AdminSectionContribution = {
id: string;
label: string;
group?: AdminSectionGroup;
order?: number;
anyOf?: string[];
allOf?: string[];
render: (context: AdminSectionRenderContext) => ReactNode;
};
export type AdminSectionsUiCapability = {
sections: AdminSectionContribution[];
};
export type PlatformRouteContribution = {
@@ -149,6 +172,11 @@ export type PlatformWebModule = {
version: string;
dependencies?: string[];
optionalDependencies?: string[];
remoteAssetManifest?: string | null;
remoteAssetSignature?: string | null;
remoteAssetPublicKeyId?: string | null;
remoteAssetIntegrity?: string | null;
remoteAssetContractVersion?: string | null;
navItems?: PlatformNavItem[];
routes?: PlatformRouteContribution[];
uiCapabilities?: PlatformUiCapabilities;
@@ -441,6 +469,10 @@ export type PlatformFrontendModuleInfo = {
module_id: string;
package_name?: string | null;
asset_manifest?: string | null;
asset_manifest_signature?: string | null;
asset_manifest_public_key_id?: string | null;
asset_manifest_integrity?: string | null;
asset_manifest_contract_version?: string | null;
routes: PlatformFrontendRouteInfo[];
nav: Array<{
path: string;

View File

@@ -1,19 +1,5 @@
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:mailbox_read", "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",
@@ -54,9 +40,6 @@ const legacyToModuleScopes: Record<string, string> = {
};
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"],
@@ -78,8 +61,9 @@ function compatibleScopes(scope: string): string[] {
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 === "*") return true;
if (granted === "tenant:*") {
return required === "tenant:*" || !required.startsWith("system:");
}
if (granted === "system:*") return required.startsWith("system:") || required.startsWith("access:");
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;

View File

@@ -6,6 +6,7 @@ import {
routeContributionsForModules,
uiCapability
} from "../src/platform/moduleLogic";
import { scopeGrants } from "../src/utils/permissions";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
@@ -63,3 +64,6 @@ assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail c
const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path);
assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order");
assert(scopeGrants("tenant:*", "calendar:event:read"), "tenant wildcard should grant tenant-level module scopes");
assert(!scopeGrants("tenant:*", "system:settings:read"), "tenant wildcard should not grant system scopes");

View File

@@ -11,6 +11,9 @@ const installedModulesVirtualId = "virtual:govoplan-installed-modules";
const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
const defaultWebModulePackages = [
"@govoplan/access-webui",
"@govoplan/admin-webui",
"@govoplan/calendar-webui",
"@govoplan/campaign-webui",
"@govoplan/files-webui",
"@govoplan/mail-webui"
@@ -77,6 +80,9 @@ export default defineConfig({
fs: {
allow: [
fileURLToPath(new URL('.', import.meta.url)),
fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-calendar/webui', 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))