Release v0.1.2
This commit is contained in:
@@ -19,7 +19,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"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/files-webui": "file:../../govoplan-files/webui",
|
||||
|
||||
37
webui/scripts/test-module-permutations.mjs
Normal file
37
webui/scripts/test-module-permutations.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const packageByModule = {
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
mail: "@govoplan/mail-webui"
|
||||
};
|
||||
|
||||
const cases = [
|
||||
{ name: "core-only", modules: [] },
|
||||
{ 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"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm");
|
||||
const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
|
||||
console.log(`\n== WebUI module permutation: ${testCase.name} ==`);
|
||||
const result = spawnSync(command, baseArgs, {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ 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 { loadApiSettings, saveApiSettings } from "./api/client";
|
||||
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } 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 { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -19,6 +22,7 @@ export default function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
|
||||
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
@@ -38,22 +42,39 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublicLogin(response: LoginResponse) {
|
||||
function authFromLoginResponse(response: LoginResponse): AuthInfo {
|
||||
const active = response.active_tenant ?? response.tenant;
|
||||
updateAuth(
|
||||
{
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
},
|
||||
""
|
||||
);
|
||||
return {
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
};
|
||||
}
|
||||
|
||||
function handlePublicLogin(response: LoginResponse) {
|
||||
updateAuth(authFromLoginResponse(response), "");
|
||||
}
|
||||
|
||||
function handleRelogin(response: LoginResponse) {
|
||||
updateAuth(authFromLoginResponse(response), "");
|
||||
setReloginMessage("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthRequired(event: Event) {
|
||||
if (!auth) return;
|
||||
const detail = (event as CustomEvent<AuthRequiredEventDetail>).detail;
|
||||
setReloginMessage(detail?.message || "Your session has expired. Sign in again to continue.");
|
||||
}
|
||||
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
||||
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
||||
}, [auth]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
@@ -117,7 +138,9 @@ export default function App() {
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">GovOPlaN</div>
|
||||
@@ -125,26 +148,34 @@ export default function App() {
|
||||
<p>Please wait while the local session is verified.</p>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
||||
</AppShell>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||
|
||||
return (
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">Loading module...</p></div>}>
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage settings={settings} />} />
|
||||
{moduleRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
@@ -165,6 +196,17 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
{reloginMessage && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
title="Session expired"
|
||||
message={reloginMessage}
|
||||
onClose={() => setReloginMessage("")}
|
||||
onLogin={handleRelogin}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export type PolicySourceStep = {
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
|
||||
@@ -6,6 +6,14 @@ const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||
|
||||
export const AUTH_REQUIRED_EVENT = "govoplan:auth-required";
|
||||
|
||||
export type AuthRequiredEventDetail = {
|
||||
path: string;
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type RecentSafeRequest = {
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
@@ -169,6 +177,25 @@ export function authHeaders(settings: ApiSettings): Headers {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function shouldNotifyAuthRequired(path: string): boolean {
|
||||
const normalized = path.split("?", 1)[0].replace(/\/+$/, "");
|
||||
return !normalized.endsWith("/auth/login");
|
||||
}
|
||||
|
||||
function notifyAuthRequired(path: string): void {
|
||||
if (typeof window === "undefined" || !shouldNotifyAuthRequired(path)) return;
|
||||
const detail: AuthRequiredEventDetail = {
|
||||
path,
|
||||
status: 401,
|
||||
message: "Your session has expired. Sign in again to continue."
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent<AuthRequiredEventDetail>(AUTH_REQUIRED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
function authExpiredError(statusText: string): ApiError {
|
||||
return new ApiError(401, statusText || "Unauthorized", "Your session has expired. Sign in again to continue.");
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
@@ -200,6 +227,10 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
|
||||
@@ -252,6 +283,10 @@ export async function apiDownload(settings: ApiSettings, path: string, filename:
|
||||
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
@@ -7,7 +7,4 @@ import "./styles/components.css";
|
||||
import "./styles/dialogs.css";
|
||||
import "./styles/retention-policies.css";
|
||||
import "./styles/auth-gate.css";
|
||||
import "@govoplan/campaign-webui/styles/campaign-workspace.css";
|
||||
import "@govoplan/files-webui/styles/file-manager.css";
|
||||
import "@govoplan/mail-webui/styles/mail-profiles.css";
|
||||
export { default, default as GovoplanApp } from "./App";
|
||||
|
||||
@@ -1,19 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type CardProps = {
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
collapsible?: boolean;
|
||||
collapseKey?: string;
|
||||
persistCollapse?: boolean;
|
||||
};
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
function resolveCollapseStorageKey(collapsible: boolean, persistCollapse: boolean, collapseKey: string | undefined, title: ReactNode): string | null {
|
||||
if (!collapsible || !persistCollapse) return null;
|
||||
if (collapseKey) return `govoplan.card.collapsed:${collapseKey}`;
|
||||
if (typeof window === "undefined" || typeof title !== "string") return null;
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) return null;
|
||||
return `govoplan.card.collapsed:${window.location.pathname}:${normalizedTitle}`;
|
||||
}
|
||||
|
||||
function readCollapseState(storageKey: string | null): boolean {
|
||||
if (!storageKey || typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapseState(storageKey: string | null, collapsed: boolean): void {
|
||||
if (!storageKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
|
||||
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className="card-body">{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
|
||||
useEffect(() => {
|
||||
setCollapseState({ storageKey, collapsed: readCollapseState(storageKey) });
|
||||
}, [storageKey]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
const nextCollapsed = !collapsed;
|
||||
writeCollapseState(storageKey, nextCollapsed);
|
||||
setCollapseState({ storageKey, collapsed: nextCollapsed });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
{hasHeader && (
|
||||
@@ -29,7 +70,7 @@ export default function Card({ title, children, actions, collapsible = false }:
|
||||
aria-label={collapsed ? "Show content" : "Show header only"}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Show content" : "Show header only"}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,7 @@ type DismissibleAlertProps = {
|
||||
compact?: boolean;
|
||||
floating?: boolean;
|
||||
resetKey?: string | number;
|
||||
dismissStorageKey?: string;
|
||||
};
|
||||
|
||||
let floatingAlertRoot: HTMLElement | null = null;
|
||||
@@ -34,6 +35,29 @@ function getFloatingAlertRoot(): HTMLElement | null {
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
function resolveDismissStorageKey(dismissStorageKey: string | undefined, resetKey: string | number | undefined): string | null {
|
||||
if (!dismissStorageKey) return null;
|
||||
return `govoplan.alert.dismissed:${dismissStorageKey}:${resetKey ?? "default"}`;
|
||||
}
|
||||
|
||||
function readDismissed(storageKey: string | null): boolean {
|
||||
if (!storageKey || typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDismissed(storageKey: string | null): void {
|
||||
if (!storageKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, "1");
|
||||
} catch {
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export default function DismissibleAlert({
|
||||
tone = "info",
|
||||
children,
|
||||
@@ -41,16 +65,24 @@ export default function DismissibleAlert({
|
||||
className = "",
|
||||
compact = false,
|
||||
floating = false,
|
||||
resetKey
|
||||
resetKey,
|
||||
dismissStorageKey
|
||||
}: DismissibleAlertProps) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const storageKey = resolveDismissStorageKey(dismissStorageKey, resetKey);
|
||||
const [alertState, setAlertState] = useState(() => ({ storageKey, visible: !readDismissed(storageKey) }));
|
||||
const visible = alertState.storageKey === storageKey ? alertState.visible : !readDismissed(storageKey);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(true);
|
||||
}, [resetKey, children]);
|
||||
setAlertState({ storageKey, visible: !readDismissed(storageKey) });
|
||||
}, [storageKey, resetKey, children]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function dismiss() {
|
||||
writeDismissed(storageKey);
|
||||
setAlertState({ storageKey, visible: false });
|
||||
}
|
||||
|
||||
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
|
||||
const alert = (
|
||||
<div
|
||||
@@ -60,7 +92,7 @@ export default function DismissibleAlert({
|
||||
>
|
||||
<div className="alert-message">{children}</div>
|
||||
{dismissible && (
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={dismiss}>
|
||||
<X size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
||||
|
||||
export type MessageDisplayField = {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
value?: ReactNode | null;
|
||||
};
|
||||
|
||||
export type MessageDisplayAttachment = {
|
||||
id?: string | null;
|
||||
filename?: string | null;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
contentType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
detail?: string | null;
|
||||
archiveGroup?: string | null;
|
||||
archiveLabel?: string | null;
|
||||
protected?: boolean | null;
|
||||
protectionNote?: string | null;
|
||||
};
|
||||
|
||||
type MessageDisplayBodyMode = "text" | "html";
|
||||
|
||||
type MessageDisplayPanelProps = {
|
||||
title?: string | null;
|
||||
fields?: MessageDisplayField[];
|
||||
bodyText?: string | null;
|
||||
bodyHtml?: string | null;
|
||||
bodyPreview?: string | null;
|
||||
preferredBodyMode?: MessageDisplayBodyMode;
|
||||
deriveTextFromHtml?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: MessageDisplayAttachment[];
|
||||
emptyText?: string;
|
||||
@@ -29,57 +39,210 @@ export default function MessageDisplayPanel({
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
bodyPreview,
|
||||
preferredBodyMode,
|
||||
deriveTextFromHtml = true,
|
||||
headers = {},
|
||||
attachments = [],
|
||||
emptyText = "Select an item to inspect its content."
|
||||
}: MessageDisplayPanelProps) {
|
||||
if (!title && fields.length === 0 && !bodyText && !bodyHtml && !bodyPreview && attachments.length === 0 && Object.keys(headers).length === 0) {
|
||||
const visibleFields = fields.filter((field) => hasRenderableValue(field.value));
|
||||
const headerEntries = Object.entries(headers);
|
||||
const hasHtml = Boolean(bodyHtml?.trim());
|
||||
const explicitTextBody = bodyText?.trim() || bodyPreview?.trim() || "";
|
||||
const textBody = explicitTextBody || (deriveTextFromHtml && hasHtml ? stripHtml(bodyHtml || "") : "");
|
||||
const hasText = Boolean(textBody.trim());
|
||||
const defaultBodyMode = preferredBodyMode === "html" && hasHtml ? "html" : preferredBodyMode === "text" && hasText ? "text" : hasHtml ? "html" : "text";
|
||||
const [bodyMode, setBodyMode] = useState<MessageDisplayBodyMode>(defaultBodyMode);
|
||||
const groupedAttachments = useMemo(() => groupAttachments(attachments), [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
setBodyMode(defaultBodyMode);
|
||||
}, [defaultBodyMode, title, bodyText, bodyHtml, bodyPreview]);
|
||||
|
||||
if (!title && visibleFields.length === 0 && !hasText && !hasHtml && attachments.length === 0 && headerEntries.length === 0) {
|
||||
return <p className="muted">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
|
||||
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
|
||||
const showBodySwitch = hasHtml && hasText;
|
||||
|
||||
return (
|
||||
<div className="message-display-panel">
|
||||
<div className="message-display-header">
|
||||
<h3>{title || "(no subject)"}</h3>
|
||||
{fields.filter((field) => field.value).map((field) => (
|
||||
<div key={field.label}>
|
||||
<span>{field.label}</span>
|
||||
<strong>{field.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
{visibleFields.length > 0 && (
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.label}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre className="message-display-body">{body || "No readable body content."}</pre>
|
||||
<section className="message-display-body-section" aria-label="Message body">
|
||||
<div className="message-display-section-heading">
|
||||
<h4>Message</h4>
|
||||
{showBodySwitch && (
|
||||
<div className="message-display-body-switch" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={activeBodyMode === "html" ? "active" : ""} onClick={() => setBodyMode("html")}>HTML</button>
|
||||
<button type="button" className={activeBodyMode === "text" ? "active" : ""} onClick={() => setBodyMode("text")}>Text</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeBodyMode === "html" ? (
|
||||
<iframe className="message-display-html-frame" title="Rendered HTML message body" sandbox="" srcDoc={bodyHtml || "<p>No HTML body content.</p>"} />
|
||||
) : (
|
||||
<pre className="message-display-body">{textBody || "No readable body content."}</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{attachments.length ? (
|
||||
<div className="message-display-attachments">
|
||||
<h4>Attachments</h4>
|
||||
{attachments.map((attachment, index) => (
|
||||
<div key={attachment.id || `${attachment.filename || "attachment"}-${index}`}>
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>{attachment.filename || "unnamed attachment"}</span>
|
||||
<small>{attachment.contentType} - {formatBytes(attachment.sizeBytes)}</small>
|
||||
</div>
|
||||
))}
|
||||
<div className="message-display-attachments-scroll">
|
||||
{groupedAttachments.direct.length > 0 && (
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
)}
|
||||
{groupedAttachments.archives.map((archive) => (
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
<header>
|
||||
<div>
|
||||
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
||||
</div>
|
||||
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>}
|
||||
</header>
|
||||
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
|
||||
<div className="message-display-attachment-list">
|
||||
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
<dl>
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
{headerEntries.length > 0 && (
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
<dl>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttachment; index: number }) {
|
||||
const contentType = formatContentType(attachment.contentType);
|
||||
const size = formatBytes(attachment.sizeBytes);
|
||||
const hasMeta = Boolean(contentType || size);
|
||||
return (
|
||||
<div className="message-display-attachment-row">
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{attachment.filename || `Attachment ${index + 1}`}</strong>
|
||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||
{hasMeta && (
|
||||
<small className="message-display-attachment-meta">
|
||||
{contentType && <span title={contentType.full}>{contentType.label}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupAttachments(attachments: MessageDisplayAttachment[]) {
|
||||
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
|
||||
const archiveMap = new Map<string, MessageDisplayAttachment[]>();
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.archiveGroup) continue;
|
||||
const current = archiveMap.get(attachment.archiveGroup) ?? [];
|
||||
current.push(attachment);
|
||||
archiveMap.set(attachment.archiveGroup, current);
|
||||
}
|
||||
return {
|
||||
direct,
|
||||
archives: [...archiveMap.entries()].map(([key, items]) => ({
|
||||
key,
|
||||
label: items[0]?.archiveLabel || key,
|
||||
protected: items.some((item) => item.protected),
|
||||
protectionNote: items.find((item) => item.protectionNote)?.protectionNote ?? null,
|
||||
items
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentKey(attachment: MessageDisplayAttachment, index: number): string {
|
||||
return String(attachment.id || `${attachment.archiveGroup || "direct"}:${attachment.filename || "attachment"}:${index}`);
|
||||
}
|
||||
|
||||
function hasRenderableValue(value: ReactNode | null | undefined): boolean {
|
||||
if (value === null || value === undefined || value === false) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatProtectionNote(value: string): string {
|
||||
return value
|
||||
.replace(/^Password-protected ZIP(?: using)?\s*/i, "")
|
||||
.replace(/^Password protected(?: using)?\s*/i, "")
|
||||
.replace(/^using\s+/i, "")
|
||||
.replace(/\.\s*Encryption:/i, " · Encryption:")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatContentType(value?: string | null): { label: string; full: string } | null {
|
||||
const full = value?.trim();
|
||||
if (!full) return null;
|
||||
const normalized = full.toLowerCase();
|
||||
const known: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
"application/zip": "ZIP archive",
|
||||
"application/x-zip-compressed": "ZIP archive",
|
||||
"application/octet-stream": "Binary file",
|
||||
"text/plain": "Plain text",
|
||||
"text/html": "HTML",
|
||||
"text/csv": "CSV",
|
||||
"application/json": "JSON",
|
||||
"image/jpeg": "JPEG image",
|
||||
"image/png": "PNG image",
|
||||
"image/gif": "GIF image",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX spreadsheet",
|
||||
"application/vnd.ms-excel": "Excel spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX document",
|
||||
"application/msword": "Word document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX presentation",
|
||||
"application/vnd.ms-powerpoint": "PowerPoint presentation"
|
||||
};
|
||||
if (known[normalized]) return { label: known[normalized], full };
|
||||
const subtype = normalized.split("/")[1]?.split(";")[0] || normalized;
|
||||
const cleaned = subtype
|
||||
.replace(/^vnd\./, "")
|
||||
.replace(/^x-/, "")
|
||||
.replace(/openxmlformats-officedocument\./g, "")
|
||||
.replace(/[.+_-]+/g, " ")
|
||||
.trim();
|
||||
const label = titleCase(cleaned || normalized);
|
||||
return { label: label.length > 34 ? `${label.slice(0, 31)}...` : label, full };
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
if (!value) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type PolicySourcePathItem = string | {
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
appliedFields?: string[];
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type PolicySourcePathProps = {
|
||||
|
||||
180
webui/src/components/UnsavedChangesGuard.tsx
Normal file
180
webui/src/components/UnsavedChangesGuard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Button from "./Button";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
|
||||
export type UnsavedNavigationAction = () => void;
|
||||
|
||||
export type UnsavedChangesRegistration = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onSave: () => boolean | Promise<boolean>;
|
||||
onDiscard?: () => void;
|
||||
};
|
||||
|
||||
type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
registrationRef.current = registration;
|
||||
}, [registration]);
|
||||
|
||||
const hasUnsavedChanges = Boolean(registration);
|
||||
|
||||
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
|
||||
setRegistration(next);
|
||||
return () => {
|
||||
setRegistration((current) => current === next ? null : current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const proceed = useCallback((action: UnsavedNavigationAction) => {
|
||||
setPendingAction(null);
|
||||
setSaveError("");
|
||||
action();
|
||||
}, []);
|
||||
|
||||
const requestNavigation = useCallback((action: UnsavedNavigationAction) => {
|
||||
const active = registrationRef.current;
|
||||
if (!active) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
setSaveError("");
|
||||
setPendingAction(() => action);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!registrationRef.current) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!registrationRef.current) return;
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
if (anchor.target && anchor.target !== "_self") return;
|
||||
if (anchor.hasAttribute("download")) return;
|
||||
if (anchor.getAttribute("href")?.startsWith("#")) return;
|
||||
|
||||
const destination = new URL(anchor.href, window.location.href);
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.href === current.href) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
requestNavigation(() => {
|
||||
if (destination.origin === current.origin) {
|
||||
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
|
||||
} else {
|
||||
window.location.assign(destination.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", onDocumentClick, true);
|
||||
return () => document.removeEventListener("click", onDocumentClick, true);
|
||||
}, [navigate, requestNavigation]);
|
||||
|
||||
async function handleSaveAndLeave() {
|
||||
const action = pendingAction;
|
||||
const active = registrationRef.current;
|
||||
if (!action || !active) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
const ok = await active.onSave();
|
||||
if (!ok) {
|
||||
setSaveError("The changes could not be saved. Please review the page message and try again.");
|
||||
return;
|
||||
}
|
||||
proceed(action);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscardAndLeave() {
|
||||
const action = pendingAction;
|
||||
const active = registrationRef.current;
|
||||
if (!action) return;
|
||||
active?.onDiscard?.();
|
||||
proceed(action);
|
||||
}
|
||||
|
||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChanges,
|
||||
requestNavigation
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
{pendingAction && registration && (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="modal-panel unsaved-changes-dialog">
|
||||
<header className="modal-header">
|
||||
<h2>{registration.title ?? "Unsaved changes"}</h2>
|
||||
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
|
||||
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
||||
</div>
|
||||
<footer className="modal-footer unsaved-changes-actions">
|
||||
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: false,
|
||||
registerUnsavedChanges: () => () => undefined,
|
||||
requestNavigation: (action) => action()
|
||||
};
|
||||
|
||||
export function useUnsavedChanges() {
|
||||
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
|
||||
}
|
||||
|
||||
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
|
||||
const { registerUnsavedChanges } = useUnsavedChanges();
|
||||
|
||||
useEffect(() => {
|
||||
return registerUnsavedChanges(registration);
|
||||
}, [registerUnsavedChanges, registration]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import Button from "../Button";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
@@ -7,17 +7,21 @@ import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||
|
||||
export type MailServerCredentialSettings = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerSmtpSettings = {
|
||||
host?: string | null;
|
||||
port?: string | number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailServerSecurity | null;
|
||||
timeout_seconds?: string | number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerImapSettings = MailServerSmtpSettings & {
|
||||
enabled?: boolean;
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
|
||||
@@ -48,20 +52,20 @@ export type MailServerSettingsPanelProps = {
|
||||
imap: MailServerImapSettings;
|
||||
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
|
||||
onImapChange: (patch: Partial<MailServerImapSettings>) => void;
|
||||
onImapEnabledChange?: (enabled: boolean) => void;
|
||||
smtpCredentials?: MailServerCredentialSettings;
|
||||
imapCredentials?: MailServerCredentialSettings;
|
||||
onSmtpCredentialsChange?: (patch: Partial<MailServerCredentialSettings>) => void;
|
||||
onImapCredentialsChange?: (patch: Partial<MailServerCredentialSettings>) => void;
|
||||
smtpDisabled?: boolean;
|
||||
smtpCredentialDisabled?: boolean;
|
||||
smtpPasswordSaved?: boolean;
|
||||
smtpSavedPasswordPlaceholder?: string;
|
||||
smtpActionDisabled?: boolean;
|
||||
imapToggleDisabled?: boolean;
|
||||
imapServerDisabled?: boolean;
|
||||
imapCredentialDisabled?: boolean;
|
||||
imapPasswordSaved?: boolean;
|
||||
imapSavedPasswordPlaceholder?: string;
|
||||
imapActionDisabled?: boolean;
|
||||
smtpTitle?: ReactNode;
|
||||
imapTitle?: ReactNode;
|
||||
smtpTestLabel?: string;
|
||||
imapTestLabel?: string;
|
||||
folderLookupLabel?: string;
|
||||
@@ -91,29 +95,127 @@ export type MailServerSettingsPanelProps = {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
floatingResults?: boolean;
|
||||
initialSection?: MailServerSettingsSection;
|
||||
};
|
||||
|
||||
const securityOptions = ["plain", "tls", "starttls"];
|
||||
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||
const securityOptions = mailServerSecurityOptions;
|
||||
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
|
||||
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||
if (security === "tls") return 465;
|
||||
if (security === "plain") return 25;
|
||||
return 587;
|
||||
}
|
||||
|
||||
export function defaultImapPort(security: MailServerSecurity | null | undefined): number {
|
||||
return security === "tls" ? 993 : 143;
|
||||
}
|
||||
|
||||
|
||||
export function mailTextOrNull(value: string | number | null | undefined, trim = true): string | null {
|
||||
const text = value === null || value === undefined ? "" : String(value);
|
||||
const normalized = trim ? text.trim() : text;
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function mailNumberOrNull(value: string | number | null | undefined): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function mailNumberOrDefault(value: string | number | null | undefined, fallback: number): number {
|
||||
return mailNumberOrNull(value) ?? fallback;
|
||||
}
|
||||
|
||||
export function normalizeMailServerSecurity<TSecurity extends string = MailServerSecurityOption>(
|
||||
value: string | null | undefined,
|
||||
options: { fallback: TSecurity; allowedSecurity?: readonly TSecurity[] },
|
||||
): TSecurity {
|
||||
const allowed = options.allowedSecurity ?? (mailServerSecurityOptions as unknown as readonly TSecurity[]);
|
||||
return allowed.includes(value as TSecurity) ? (value as TSecurity) : options.fallback;
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayload(
|
||||
username: string | number | null | undefined,
|
||||
password: string | number | null | undefined,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
const payload: { username?: string | null; password?: string | null } = { username: mailTextOrNull(username) };
|
||||
if (String(password ?? "") || !preserveBlankPassword) payload.password = mailTextOrNull(password, false);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function mailRecordText(record: Record<string, unknown>, key: string, fallback = ""): string {
|
||||
const value = record[key];
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayloadFromRecords(
|
||||
primary: Record<string, unknown>,
|
||||
legacy: Record<string, unknown>,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
return mailTransportCredentialsPayload(
|
||||
mailRecordText(primary, "username", mailRecordText(legacy, "username")),
|
||||
mailRecordText(primary, "password", mailRecordText(legacy, "password")),
|
||||
preserveBlankPassword,
|
||||
);
|
||||
}
|
||||
|
||||
export function mailSmtpSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerSmtpSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; timeout_seconds: number } {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
};
|
||||
}
|
||||
|
||||
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerImapSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; sent_folder: string; timeout_seconds: number } {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
sent_folder: mailTextOrNull(settings.sent_folder) || "auto",
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMailImapSettings(values: Array<string | number | null | undefined>): boolean {
|
||||
return values.some((value) => String(value ?? "").trim() !== "");
|
||||
}
|
||||
|
||||
export default function MailServerSettingsPanel({
|
||||
smtp,
|
||||
imap,
|
||||
onSmtpChange,
|
||||
onImapChange,
|
||||
onImapEnabledChange,
|
||||
smtpCredentials,
|
||||
imapCredentials,
|
||||
onSmtpCredentialsChange,
|
||||
onImapCredentialsChange,
|
||||
smtpDisabled = false,
|
||||
smtpCredentialDisabled = smtpDisabled,
|
||||
smtpPasswordSaved = false,
|
||||
smtpSavedPasswordPlaceholder = "••••••••",
|
||||
smtpActionDisabled = smtpDisabled,
|
||||
imapToggleDisabled = false,
|
||||
imapServerDisabled = false,
|
||||
imapCredentialDisabled = imapServerDisabled,
|
||||
imapPasswordSaved = false,
|
||||
imapSavedPasswordPlaceholder = "••••••••",
|
||||
imapActionDisabled = imapServerDisabled,
|
||||
smtpTitle = "SMTP login",
|
||||
imapTitle = "IMAP sent-folder append",
|
||||
smtpTestLabel = "Test SMTP",
|
||||
imapTestLabel = "Test IMAP",
|
||||
folderLookupLabel = "Folders...",
|
||||
@@ -130,7 +232,8 @@ export default function MailServerSettingsPanel({
|
||||
append,
|
||||
disabled = false,
|
||||
className = "",
|
||||
floatingResults = false
|
||||
floatingResults = false,
|
||||
initialSection = "smtp"
|
||||
}: MailServerSettingsPanelProps) {
|
||||
const smtpFieldsDisabled = disabled || smtpDisabled;
|
||||
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
|
||||
@@ -138,92 +241,156 @@ export default function MailServerSettingsPanel({
|
||||
const imapFieldsDisabled = disabled || imapServerDisabled;
|
||||
const imapCredentialFieldsDisabled = disabled || imapCredentialDisabled;
|
||||
const imapActionsDisabled = disabled || imapActionDisabled;
|
||||
const smtpCredentialValues = smtpCredentials ?? { username: smtp.username, password: smtp.password };
|
||||
const imapCredentialValues = imapCredentials ?? { username: imap.username, password: imap.password };
|
||||
const smtpSecurity = stringValue(smtp.security, "starttls");
|
||||
const imapSecurity = stringValue(imap.security, "tls");
|
||||
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
||||
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
||||
const appendTargetHelp = append
|
||||
? "Folder for sent-message copies. Leave as auto unless this campaign needs a different target."
|
||||
: "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.";
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
||||
const sections: { id: MailServerSettingsSection; label: string }[] = [
|
||||
{ id: "smtp", label: "SMTP" },
|
||||
{ id: "imap", label: "IMAP" },
|
||||
{ id: "advanced", label: "Advanced" }
|
||||
];
|
||||
|
||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||
const port = portForSecurityChange("smtp", smtp.port, smtpSecurity, security);
|
||||
onSmtpChange(port === undefined ? { security } : { security, port });
|
||||
}
|
||||
|
||||
function patchImapSecurity(security: MailServerSecurity) {
|
||||
const port = portForSecurityChange("imap", imap.port, imapSecurity, security);
|
||||
onImapChange(port === undefined ? { security } : { security, port });
|
||||
}
|
||||
|
||||
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);
|
||||
else onSmtpChange(patch);
|
||||
}
|
||||
|
||||
function patchImapCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onImapCredentialsChange) onImapCredentialsChange(patch);
|
||||
else onImapChange(patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
<div className="mail-server-settings-grid">
|
||||
<section className="mail-server-subsection">
|
||||
<div className="mail-server-section-heading split">
|
||||
<h3>{smtpTitle}</h3>
|
||||
{mockToggle && (
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "Mock server settings"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(smtp.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => onSmtpChange({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
</div>
|
||||
{onTestSmtp && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
<div className="mail-server-segmented-control" role="tablist" aria-label="Mail server settings sections">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
role="tab"
|
||||
aria-selected={activeSection === section.id}
|
||||
className={`mail-server-segmented-tab${activeSection === section.id ? " is-active" : ""}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mail-server-subsection">
|
||||
<div className="mail-server-section-heading split">
|
||||
<h3>{imapTitle}</h3>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
{onImapEnabledChange && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch label="Enable IMAP" checked={Boolean(imap.enabled)} disabled={disabled || imapToggleDisabled} onChange={onImapEnabledChange} />
|
||||
<div className="mail-server-settings-view">
|
||||
{activeSection === "smtp" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="SMTP settings">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestSmtp && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(imap.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => onImapChange({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
|
||||
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append && (
|
||||
<>
|
||||
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSection === "imap" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="IMAP settings">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestImap && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSection === "advanced" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="Advanced mail settings">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
||||
{mockToggle && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "Mock server settings"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FormField label="SMTP timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="IMAP timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
</div>
|
||||
<FormField label="Append folder"><input value={append.folder} disabled={disabled || append.folderDisabled} onChange={(event) => append.onFolderChange(event.target.value)} /></FormField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(onTestImap || onLookupFolders) && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
{onTestImap && <Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>}
|
||||
{onLookupFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||
)}
|
||||
<FormField label="Append target folder" help={appendTargetHelp}>
|
||||
<div className="field-with-action mail-server-folder-field">
|
||||
<input
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="auto"
|
||||
/>
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||
</div>
|
||||
</FormField>
|
||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
||||
</div>
|
||||
)}
|
||||
{onLookupFolders && <p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
<MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled} onUseDetected={onUseDetectedFolder} />
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -278,6 +445,21 @@ function SecuritySelect({ value, disabled, onChange }: { value: string; disabled
|
||||
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
||||
}
|
||||
|
||||
function portForSecurityChange(protocol: "smtp" | "imap", currentPort: string | number | null | undefined, currentSecurity: MailServerSecurity, nextSecurity: MailServerSecurity): number | undefined {
|
||||
const current = numberValue(currentPort);
|
||||
const previousDefault = protocol === "smtp" ? defaultSmtpPort(currentSecurity) : defaultImapPort(currentSecurity);
|
||||
if (current !== null && current !== previousDefault) return undefined;
|
||||
return protocol === "smtp" ? defaultSmtpPort(nextSecurity) : defaultImapPort(nextSecurity);
|
||||
}
|
||||
|
||||
function numberValue(value: string | number | null | undefined): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function stringValue(value: string | number | null | undefined, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{hasSystemArea(availableSections) && <Card title="System administration">
|
||||
<div className="admin-overview-grid">
|
||||
{availableSections.has("system-settings") && <AreaLink title="Settings" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level limiting permissions." onClick={() => onSelect("system-retention")} />}
|
||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
|
||||
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
||||
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
||||
{availableSections.has("system-groups") && <AreaLink title="Central groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
|
||||
import { fetchMe } from "../../api/auth";
|
||||
import Card from "../../components/Card";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
@@ -19,6 +19,7 @@ import AdminAuditPanel from "./AdminAuditPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
|
||||
type AdminSection =
|
||||
| "overview"
|
||||
@@ -53,12 +54,15 @@ export default function AdminPage({
|
||||
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");
|
||||
sections.add("system-mail-servers");
|
||||
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");
|
||||
@@ -70,7 +74,7 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
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");
|
||||
@@ -83,7 +87,7 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
if (hasScope(auth, "audit:read")) sections.add("tenant-audit");
|
||||
return sections;
|
||||
}, [auth]);
|
||||
}, [auth, mailProfilesAvailable]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const active: AdminSection = requestedSection && available.has(requestedSection) ? requestedSection : "overview";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "../../types";
|
||||
import { fetchGroups, fetchUsers } from "../../api/admin";
|
||||
import type { MailProfileScope } from "@govoplan/mail-webui";
|
||||
import { MailProfileScopeManager, type MailProfileTargetOption } from "@govoplan/mail-webui";
|
||||
import Card from "../../components/Card";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -44,11 +44,21 @@ const copy: Record<Props["scopeType"], { title: string; description: string; tar
|
||||
};
|
||||
|
||||
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(scopeType === "user" || scopeType === "group");
|
||||
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
|
||||
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") {
|
||||
@@ -85,6 +95,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
|
||||
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
|
||||
|
||||
@@ -17,29 +17,29 @@ type Props = {
|
||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
||||
system: {
|
||||
title: "System retention",
|
||||
description: "Instance-wide privacy retention policy and lower-level limiting permissions.",
|
||||
description: "Instance-wide privacy retention policy and lower-level override permissions.",
|
||||
policyTitle: "System retention policy",
|
||||
policyDescription: "Set concrete system retention values. The Allow limiting toggles decide which fields tenants, owners and campaigns may restrict further."
|
||||
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 limiting toggles decide which fields users, groups and campaigns may restrict further."
|
||||
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 limiting toggles decide which fields user-owned campaigns may restrict further."
|
||||
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 limiting toggles decide which fields group-owned campaigns may restrict further."
|
||||
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
export default function LoginModal({
|
||||
settings,
|
||||
onClose,
|
||||
onLogin
|
||||
onLogin,
|
||||
title = "Sign in",
|
||||
message
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -39,10 +43,11 @@ export default function LoginModal({
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
||||
<form className="modal-panel" onSubmit={submit}>
|
||||
<header className="modal-header">
|
||||
<h2>Sign in</h2>
|
||||
<h2>{title}</h2>
|
||||
<button className="modal-close" type="button" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body form-grid">
|
||||
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="Email">
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
|
||||
@@ -1,22 +1,149 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { apiFetch, isApiError } from "../../api/client";
|
||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||
import type { ApiSettings } from "../../types";
|
||||
|
||||
type DashboardCampaign = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
external_id?: string | null;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
type CampaignListResponse = DashboardCampaign[] | { campaigns?: DashboardCampaign[]; items?: DashboardCampaign[]; results?: DashboardCampaign[] };
|
||||
|
||||
export default function DashboardPage({ settings }: { settings: ApiSettings }) {
|
||||
const modules = usePlatformModules();
|
||||
const campaignsInstalled = modules.some((module) => module.id === "campaigns");
|
||||
const [campaigns, setCampaigns] = useState<DashboardCampaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignsInstalled) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns")
|
||||
.then((response) => {
|
||||
if (!cancelled) setCampaigns(campaignsFromResponse(response));
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(reason, 403, 404)) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignsInstalled, settings]);
|
||||
|
||||
const statusCounts = useMemo(() => countByStatus(campaigns), [campaigns]);
|
||||
const recentCampaigns = useMemo(
|
||||
() => [...campaigns].sort((left, right) => timestamp(right) - timestamp(left)).slice(0, 5),
|
||||
[campaigns],
|
||||
);
|
||||
const activeCampaigns = (statusCounts.active ?? 0) + (statusCounts.draft ?? 0);
|
||||
const completedCampaigns = (statusCounts.completed ?? 0) + (statusCounts.sent ?? 0);
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Dashboard</h1>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Tenant overview across installed modules and accessible campaigns.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="warning" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Campaigns" value="0" detail="Connect the API to load data" />
|
||||
<MetricCard label="Queued" value="0" tone="info" />
|
||||
<MetricCard label="Needs review" value="0" tone="warning" />
|
||||
<MetricCard label="Failed" value="0" tone="danger" />
|
||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "Core only"} />
|
||||
<MetricCard label="Campaigns" value={campaignsInstalled ? campaigns.length : "—"} tone="neutral" detail={campaignsInstalled ? "Accessible to you" : "Campaign module not installed"} />
|
||||
<MetricCard label="Active drafts" value={campaignsInstalled ? activeCampaigns : "—"} tone="warning" detail="Draft or active" />
|
||||
<MetricCard label="Completed" value={campaignsInstalled ? completedCampaigns : "—"} tone="good" detail="Completed or sent" />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Recommended next action"><p className="muted">Create or open a campaign to continue.</p></Card>
|
||||
<Card title="System status"><p className="muted">API health and queue metrics will appear here.</p></Card>
|
||||
<Card title="Recent campaigns" collapsible>
|
||||
<LoadingFrame loading={loading} label="Loading campaigns...">
|
||||
{!campaignsInstalled && <p className="muted">Install the Campaign module to show campaign activity here.</p>}
|
||||
{campaignsInstalled && recentCampaigns.length === 0 && <p className="muted">No accessible campaigns found.</p>}
|
||||
{recentCampaigns.length > 0 && (
|
||||
<dl className="detail-list">
|
||||
{recentCampaigns.map((campaign) => (
|
||||
<div key={campaign.id}>
|
||||
<dt><StatusBadge status={campaign.status || "draft"} /></dt>
|
||||
<dd>
|
||||
<strong>{campaign.name || campaign.external_id || campaign.id}</strong>
|
||||
<span className="muted"> · {formatDashboardDate(campaign.updated_at ?? campaign.updatedAt)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Card title="Installed modules" collapsible>
|
||||
{modules.length === 0 ? <p className="muted">No feature modules are active.</p> : (
|
||||
<dl className="detail-list">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id}>
|
||||
<dt>{module.id}</dt>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> · v{module.version}</span></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function campaignsFromResponse(response: CampaignListResponse): DashboardCampaign[] {
|
||||
if (Array.isArray(response)) return response;
|
||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||
}
|
||||
|
||||
function countByStatus(campaigns: DashboardCampaign[]): Record<string, number> {
|
||||
return campaigns.reduce<Record<string, number>>((counts, campaign) => {
|
||||
const status = String(campaign.status || "draft").toLowerCase();
|
||||
counts[status] = (counts[status] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function timestamp(campaign: DashboardCampaign): number {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? "";
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatDashboardDate(value?: string | null): string {
|
||||
if (!value) return "not updated";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
||||
}
|
||||
|
||||
function moduleLabels(modules: Array<{ label: string }>): string[] {
|
||||
return modules.map((module) => module.label).slice(0, 4);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getPrivacyRetentionPolicy,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
|
||||
@@ -63,6 +63,8 @@ type FieldDefinition = {
|
||||
type RawJsonValue = "inherit" | "keep" | "disable";
|
||||
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
|
||||
|
||||
const auditDetailOrder: Record<PrivacyRetentionPolicy["audit_detail_level"], number> = { full: 0, redacted: 1, minimal: 2 };
|
||||
|
||||
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||
store_raw_campaign_json: true,
|
||||
raw_campaign_json_retention_days: true,
|
||||
@@ -107,10 +109,13 @@ export function RetentionPolicyScopeManager({
|
||||
}: RetentionPolicyScopeManagerProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
|
||||
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
|
||||
const targetSelectionRequired = requiresTarget && !scopeId;
|
||||
const hasSelectableTarget = targetOptions.length > 0;
|
||||
const activeScopeId = scopeId || selectedTargetId || null;
|
||||
const defaultDescription = scopeType === "system"
|
||||
? "System retention defaults and the fields lower levels may limit further."
|
||||
? "System retention defaults and the fields lower levels may override."
|
||||
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
|
||||
const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`;
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeId) {
|
||||
@@ -119,35 +124,46 @@ export function RetentionPolicyScopeManager({
|
||||
}
|
||||
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
|
||||
setSelectedTargetId(targetOptions[0].id);
|
||||
return;
|
||||
}
|
||||
if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId("");
|
||||
}, [scopeId, selectedTargetId, targetOptions]);
|
||||
|
||||
return (
|
||||
<div className="retention-policy-manager">
|
||||
{targetOptions.length > 0 && !scopeId && (
|
||||
{targetSelectionRequired && (
|
||||
<Card title={`${targetLabel} scope`}>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
<select value={selectedTargetId} disabled={!hasSelectableTarget} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
{(!requiresTarget || Boolean(activeScopeId)) && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pluralizeLabel(label: string): string {
|
||||
if (label.endsWith("s")) return label;
|
||||
if (label.endsWith("y")) return `${label.slice(0, -1)}ies`;
|
||||
return `${label}s`;
|
||||
}
|
||||
|
||||
export function RetentionPolicyEditor({
|
||||
settings,
|
||||
scopeType,
|
||||
@@ -171,6 +187,7 @@ export function RetentionPolicyEditor({
|
||||
const scopeReady = !requiresTarget || Boolean(scopeId);
|
||||
const disabled = locked || loading || busy || !canWrite || !scopeReady;
|
||||
const showAllowColumn = scopeType !== "campaign";
|
||||
const showEffectiveColumn = !isSystem && scopeReady;
|
||||
const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null;
|
||||
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
|
||||
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
|
||||
@@ -291,29 +308,37 @@ export function RetentionPolicyEditor({
|
||||
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
|
||||
<span className="muted small-note">{localPolicySummary}</span>
|
||||
</div>
|
||||
<div className={`retention-policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row retention-policy-row-header">
|
||||
<div className={`retention-policy-table policy-table${showEffectiveColumn ? " with-effective-column" : ""}${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row policy-row retention-policy-row-header policy-row-header">
|
||||
<span>Field</span>
|
||||
<span>Value</span>
|
||||
<span>{isSystem ? "Value" : "Local setting"}</span>
|
||||
{showEffectiveColumn && <span>Effective policy</span>}
|
||||
{showAllowColumn && <span>Lower levels</span>}
|
||||
</div>
|
||||
{visibleFields.map((field) => {
|
||||
const fieldLocked = !parentAllows(field.key);
|
||||
const fieldDisabled = disabled || fieldLocked;
|
||||
return (
|
||||
<div className="retention-policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label">
|
||||
<div className="retention-policy-row policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label policy-field-label">
|
||||
<strong>{field.label}</strong>
|
||||
{fieldLocked && <small>Locked by parent policy</small>}
|
||||
{field.systemOnly && <small>System-level cleanup scope</small>}
|
||||
</div>
|
||||
<div>{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
<div className="policy-control">{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
{showEffectiveColumn && (
|
||||
<div className="retention-policy-effective-cell policy-effective-cell">
|
||||
<FieldLabel className="retention-policy-effective-value policy-effective-value" help={retentionPolicyPathHelp(field, effectivePolicySources, scopeType)}>
|
||||
{retentionFieldValue(field, effectivePolicy, loading)}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
)}
|
||||
{showAllowColumn && (
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="Allow limiting"
|
||||
label="Allow override"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -322,28 +347,79 @@ export function RetentionPolicyEditor({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{effectivePolicy && (
|
||||
<EffectivePolicyBlock
|
||||
title="Effective policy"
|
||||
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
|
||||
className="retention-policy-section retention-policy-effective"
|
||||
gridClassName="retention-policy-effective-grid"
|
||||
>
|
||||
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</EffectivePolicyBlock>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type PolicySourceItem = {
|
||||
label: string;
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function retentionPolicyPathHelp(field: FieldDefinition, sources: PolicySourcePathItem[], scopeType: PrivacyRetentionPolicyScope): ReactNode {
|
||||
const items = normalizePolicySourceItems(sources.length > 0 ? sources : retentionPolicySourcePath(scopeType));
|
||||
return <PolicyPathHelp lines={retentionPolicyPathLines(field, items)} />;
|
||||
}
|
||||
|
||||
function PolicyPathHelp({ lines }: { lines: string[] }) {
|
||||
return (
|
||||
<span className="policy-path-help">
|
||||
{lines.map((line, index) => <span className="policy-path-help-line" key={`${line}-${index}`}>{line}</span>)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceItem[]): string[] {
|
||||
if (items.length === 0) return ["System: Default"];
|
||||
const lines: string[] = [];
|
||||
for (const [index, item] of items.entries()) {
|
||||
const sourcePolicy = asRecord(item.policy);
|
||||
const value = retentionSourceValue(field, sourcePolicy, index === 0);
|
||||
const locked = asRecord(sourcePolicy.allow_lower_level_limits)[field.key] === false;
|
||||
lines.push(`${policyPathPrefix(index)}${item.label}: ${locked ? `${value} without override` : value}`);
|
||||
if (locked) break;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function retentionSourceValue(field: FieldDefinition, sourcePolicy: Record<string, unknown>, isSystem: boolean): string {
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "Default" : "Inherit";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "Inherit";
|
||||
if (field.kind === "raw-json") {
|
||||
const value = sourcePolicy.store_raw_campaign_json;
|
||||
return value === false ? "Disabled" : value === true ? "Stored" : "Inherit";
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
const value = sourcePolicy.audit_detail_level;
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "Inherit";
|
||||
}
|
||||
const value = sourcePolicy[field.key];
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "Inherit";
|
||||
}
|
||||
|
||||
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
|
||||
return items.map((item) => {
|
||||
if (typeof item === "string") return { label: item };
|
||||
return { label: item.label, policy: item.policy };
|
||||
}).filter((item) => item.label.trim());
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function policyPathPrefix(index: number): string {
|
||||
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
|
||||
}
|
||||
|
||||
function retentionFieldValue(field: FieldDefinition, policy: PrivacyRetentionPolicy | null, loading: boolean): string {
|
||||
if (!policy) return loading ? "Loading..." : "Unavailable";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "Stored" : "Disabled";
|
||||
if (field.kind === "audit") return auditDetailLabel(policy.audit_detail_level);
|
||||
return daysLabel(policy[field.key as DayKey]);
|
||||
}
|
||||
|
||||
function renderFieldControl(
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
@@ -362,13 +438,15 @@ function renderFieldControl(
|
||||
return <RawJsonScopedSelect value={rawJsonValue(patchPolicy.store_raw_campaign_json)} parentStoresRawJson={parentPolicy?.store_raw_campaign_json ?? true} disabled={disabled} onChange={setRawJson} />;
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} disabled={disabled} onChange={setAuditDetail} />;
|
||||
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} parentValue={isSystem ? null : parentPolicy?.audit_detail_level ?? null} disabled={disabled} onChange={setAuditDetail} />;
|
||||
}
|
||||
const parentDayLimit = !isSystem && parentPolicy ? parentPolicy[field.key as DayKey] : null;
|
||||
return (
|
||||
<RetentionDaysField
|
||||
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
|
||||
disabled={disabled}
|
||||
placeholder={isSystem ? "Unlimited" : "Inherit"}
|
||||
max={parentDayLimit}
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
|
||||
/>
|
||||
);
|
||||
@@ -393,17 +471,32 @@ function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }:
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) {
|
||||
return <input type="number" min={0} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
|
||||
function RetentionDaysField({ value, disabled, placeholder, max, onChange }: { value?: number | null; disabled: boolean; placeholder: string; max?: number | null; onChange: (value: string) => void }) {
|
||||
function handleChange(nextValue: string) {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed === "" || max === null || max === undefined) {
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
onChange(Number.isFinite(parsed) && parsed > max ? String(max) : nextValue);
|
||||
}
|
||||
|
||||
return <input type="number" min={0} max={max ?? undefined} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => handleChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
const minimumOrder = parentValue ? auditDetailOrder[parentValue] : 0;
|
||||
function optionDisabled(option: PrivacyRetentionPolicy["audit_detail_level"]): boolean {
|
||||
return auditDetailOrder[option] < minimumOrder;
|
||||
}
|
||||
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
|
||||
{includeInherit && <option value="inherit">Inherit</option>}
|
||||
<option value="full">Full</option>
|
||||
<option value="redacted">Redacted</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="full" disabled={optionDisabled("full")}>Full</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>Redacted</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>Minimal</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -427,13 +520,6 @@ function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]):
|
||||
return "Minimal";
|
||||
}
|
||||
|
||||
function allowLowerSummary(allow: PrivacyRetentionLimitPermissions): string {
|
||||
const count = fieldDefinitions.filter((field) => allow[field.key] !== false).length;
|
||||
if (count === fieldDefinitions.length) return "Allowed";
|
||||
if (count === 0) return "Locked";
|
||||
return `${count}/${fieldDefinitions.length} allowed`;
|
||||
}
|
||||
|
||||
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
let count = 0;
|
||||
for (const field of fieldDefinitions) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -11,7 +11,7 @@ import { apiFetch } from "../../api/client";
|
||||
import { updateProfile } from "../../api/auth";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
@@ -49,7 +49,9 @@ export default function SettingsPage({
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canUseMailProfiles = hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles), [canUseMailProfiles]);
|
||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
|
||||
@@ -155,7 +157,7 @@ export default function SettingsPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "mail-profiles" && (
|
||||
{active === "mail-profiles" && MailProfileScopeManager && (
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
|
||||
@@ -3,6 +3,8 @@ export * from "./types";
|
||||
export * from "./api/client";
|
||||
export * from "./api/auth";
|
||||
export * from "./api/platform";
|
||||
export * from "./platform/modules";
|
||||
export * from "./platform/ModuleContext";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
export * from "./utils/fieldHelp";
|
||||
@@ -37,9 +39,11 @@ export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
||||
export { default as StatusBadge } from "./components/StatusBadge";
|
||||
export { default as Stepper } from "./components/Stepper";
|
||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||
export { UnsavedChangesProvider, useRegisterUnsavedChanges, useUnsavedChanges } from "./components/UnsavedChangesGuard";
|
||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
||||
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView } from "./components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Settings, Shield } from "lucide-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
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";
|
||||
|
||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||
|
||||
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
||||
return [...navItems]
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
|
||||
@@ -22,10 +25,25 @@ export default function IconRail({
|
||||
auth?: AuthInfo | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const visibleItems = visibleNavItems(auth, navItems);
|
||||
const items = hasAnyScope(auth, adminReadScopes)
|
||||
? [...visibleItems, { to: "/admin", label: "Admin", icon: Shield }]
|
||||
: visibleItems;
|
||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
|
||||
if (!currentRoot || location.pathname === currentRoot) return;
|
||||
const target = `${location.pathname}${location.search}${location.hash}`;
|
||||
setRememberedTargets((current) => {
|
||||
if (current[currentRoot] === target) return current;
|
||||
const next = { ...current, [currentRoot]: target };
|
||||
saveRememberedTargets(next);
|
||||
return next;
|
||||
});
|
||||
}, [location.hash, location.pathname, location.search, topLevelItems]);
|
||||
|
||||
return (
|
||||
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
|
||||
@@ -34,11 +52,15 @@ export default function IconRail({
|
||||
{!compact && (
|
||||
<>
|
||||
<nav className="icon-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={label}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
</NavLink>
|
||||
))}
|
||||
{items.map(({ to, label, icon: Icon }) => {
|
||||
const target = rememberedTargets[to] ?? to;
|
||||
const active = modulePathActive(location.pathname, to);
|
||||
return (
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={label}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="icon-rail-bottom">
|
||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
|
||||
@@ -50,3 +72,34 @@ export default function IconRail({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function modulePathActive(pathname: string, root: string): boolean {
|
||||
if (root === "/") return pathname === "/";
|
||||
return pathname === root || pathname.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
function loadRememberedTargets(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(MODULE_NAV_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter((entry): entry is [string, string] => {
|
||||
const [root, target] = entry;
|
||||
return typeof root === "string" && typeof target === "string" && root.startsWith("/") && target.startsWith(`${root}/`);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveRememberedTargets(targets: Record<string, string>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
||||
} catch {
|
||||
// Remembered navigation is a convenience only.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
|
||||
@@ -17,6 +18,7 @@ type Props = {
|
||||
|
||||
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [tenantOpen, setTenantOpen] = useState(false);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
@@ -66,7 +68,7 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
async function performLogout() {
|
||||
try {
|
||||
await logout(settings);
|
||||
} catch {
|
||||
@@ -76,11 +78,7 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
setAccountOpen(false);
|
||||
}
|
||||
|
||||
async function handleTenantSelect(tenant: AuthTenantMembership) {
|
||||
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
|
||||
setTenantOpen(false);
|
||||
return;
|
||||
}
|
||||
async function performTenantSelect(tenant: AuthTenantMembership) {
|
||||
setSwitchingTenantId(tenant.id);
|
||||
setTenantError("");
|
||||
try {
|
||||
@@ -97,6 +95,23 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
requestNavigation(() => { void performLogout(); });
|
||||
}
|
||||
|
||||
function handleTenantSelect(tenant: AuthTenantMembership) {
|
||||
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
|
||||
setTenantOpen(false);
|
||||
return;
|
||||
}
|
||||
requestNavigation(() => { void performTenantSelect(tenant); });
|
||||
}
|
||||
|
||||
function handleAccountSettings() {
|
||||
setAccountOpen(false);
|
||||
requestNavigation(() => navigate("/settings?section=profile"));
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="titlebar">
|
||||
{auth && activeTenant && showTenantControl && (
|
||||
@@ -152,7 +167,7 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
<strong>{auth.user.display_name || auth.user.email}</strong>
|
||||
<span>{auth.user.email}</span>
|
||||
</div>
|
||||
<button className="dropdown-item" onClick={() => { setAccountOpen(false); navigate("/settings?section=profile"); }}><Settings size={16} /> Account settings</button>
|
||||
<button className="dropdown-item" onClick={handleAccountSettings}><Settings size={16} /> Account settings</button>
|
||||
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
21
webui/src/platform/ModuleContext.tsx
Normal file
21
webui/src/platform/ModuleContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { moduleInstalled, uiCapability } from "./modules";
|
||||
|
||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||
|
||||
export function PlatformModulesProvider({ modules, children }: { modules: PlatformWebModule[]; children: ReactNode }) {
|
||||
return <PlatformModulesContext.Provider value={modules}>{children}</PlatformModulesContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePlatformModules(): PlatformWebModule[] {
|
||||
return useContext(PlatformModulesContext);
|
||||
}
|
||||
|
||||
export function usePlatformModuleInstalled(moduleId: string): boolean {
|
||||
return moduleInstalled(moduleId, usePlatformModules());
|
||||
}
|
||||
|
||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
||||
return uiCapability<T>(capabilityName, usePlatformModules());
|
||||
}
|
||||
27
webui/src/platform/moduleLogic.ts
Normal file
27
webui/src/platform/moduleLogic.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
const value = module.uiCapabilities?.[capabilityName];
|
||||
if (value !== undefined) return value as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[]): boolean {
|
||||
return uiCapability(capabilityName, modules) !== null;
|
||||
}
|
||||
|
||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[]): boolean {
|
||||
return modules.some((module) => module.id === moduleId);
|
||||
}
|
||||
|
||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[]): boolean {
|
||||
const module = modules.find((item) => item.id === moduleId);
|
||||
if (!module) return false;
|
||||
return Boolean(module.dependencies?.includes(dependencyId) || (module.optionalDependencies?.includes(dependencyId) && moduleInstalled(dependencyId, modules)));
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Activity, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Users, type LucideIcon } from "lucide-react";
|
||||
import campaignModule from "@govoplan/campaign-webui";
|
||||
import filesModule from "@govoplan/files-webui";
|
||||
import mailModule from "@govoplan/mail-webui";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapability as uiCapabilityForModules
|
||||
} from "./moduleLogic";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
export const shellNavItems: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
|
||||
];
|
||||
|
||||
const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule];
|
||||
const localModules: PlatformWebModule[] = installedWebModules;
|
||||
|
||||
const iconByName: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
@@ -47,6 +52,15 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
||||
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
||||
if (enabledNames.size === 0 || !module.runtimeUiCapabilities) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(module.runtimeUiCapabilities).filter(([name]) => enabledNames.has(name))
|
||||
);
|
||||
}
|
||||
|
||||
function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo): PlatformWebModule {
|
||||
const backendNav = info.frontend?.nav.length ? info.frontend.nav : info.nav;
|
||||
return {
|
||||
@@ -55,14 +69,18 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
version: info.version,
|
||||
dependencies: info.dependencies,
|
||||
optionalDependencies: info.optional_dependencies,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return legacyModules;
|
||||
if (!platformModules?.length) return localModules;
|
||||
|
||||
const localById = new Map(legacyModules.map((module) => [module.id, module]));
|
||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||
return platformModules
|
||||
.filter((module) => module.enabled)
|
||||
.map((module) => {
|
||||
@@ -72,14 +90,29 @@ export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[]
|
||||
.filter((module): module is PlatformWebModule => module !== null);
|
||||
}
|
||||
|
||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = legacyModules): boolean {
|
||||
return modules.some((module) => module.id === moduleId);
|
||||
|
||||
export function installedLocalWebModules(): PlatformWebModule[] {
|
||||
return localModules;
|
||||
}
|
||||
|
||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = legacyModules): boolean {
|
||||
const module = modules.find((item) => item.id === moduleId);
|
||||
if (!module) return false;
|
||||
return Boolean(module.dependencies?.includes(dependencyId) || (module.optionalDependencies?.includes(dependencyId) && moduleInstalled(dependencyId, modules)));
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null {
|
||||
return uiCapabilityForModules<T>(capabilityName, modules);
|
||||
}
|
||||
|
||||
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean {
|
||||
return hasUiCapabilityForModules(capabilityName, modules);
|
||||
}
|
||||
|
||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = localModules): boolean {
|
||||
return moduleInstalledForModules(moduleId, modules);
|
||||
}
|
||||
|
||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = localModules): boolean {
|
||||
return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules);
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return routeContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||
@@ -88,11 +121,7 @@ export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavIte
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = legacyModules): PlatformNavItem[] {
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
||||
return navItemsForModules(modules).filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||
@@ -100,7 +129,7 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
|
||||
});
|
||||
}
|
||||
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = legacyModules): string {
|
||||
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";
|
||||
|
||||
@@ -501,6 +501,83 @@
|
||||
.inline-help:focus-visible .inline-help-mark {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.policy-path-help {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.policy-path-help-line {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Shared policy table structure. Feature styles keep only layout-specific grid tracks. */
|
||||
.policy-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.policy-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.policy-row-header {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.policy-field-label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.policy-field-label strong,
|
||||
.policy-field-title {
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
.policy-field-label small,
|
||||
.policy-effective-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
.policy-control select,
|
||||
.policy-control input,
|
||||
.policy-row textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.policy-effective-cell,
|
||||
.policy-lower-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.policy-effective-value {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.policy-effective-note {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.policy-row,
|
||||
.policy-table.with-effective-column .policy-row,
|
||||
.policy-table.with-allow-column .policy-row,
|
||||
.policy-table.with-effective-column.with-allow-column .policy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.policy-row-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.inline-help-bubble {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
@@ -642,6 +719,9 @@
|
||||
.card-collapsible.is-collapsed .card-collapse-toggle svg {
|
||||
transform: none;
|
||||
}
|
||||
.card-collapsible.is-collapsed {
|
||||
align-self: start;
|
||||
}
|
||||
.card-collapse-region {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -794,7 +874,7 @@
|
||||
|
||||
.message-display-header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-display-header h3 {
|
||||
@@ -803,89 +883,291 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-header div {
|
||||
display: grid;
|
||||
grid-template-columns: 70px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
.message-display-fields {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-display-header span {
|
||||
.message-display-fields div,
|
||||
.message-display-headers dl div {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.message-display-fields dt,
|
||||
.message-display-fields dd,
|
||||
.message-display-headers dt,
|
||||
.message-display-headers dd {
|
||||
display: table-cell;
|
||||
padding: 7px 0px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.message-display-fields div:last-child dt,
|
||||
.message-display-fields div:last-child dd,
|
||||
.message-display-headers dl div:last-child dt,
|
||||
.message-display-headers dl div:last-child dd {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.message-display-fields dt,
|
||||
.message-display-headers dt {
|
||||
width: 96px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-display-header strong {
|
||||
.message-display-fields dd,
|
||||
.message-display-headers dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-body {
|
||||
.message-display-body-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-display-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-display-section-heading h4,
|
||||
.message-display-attachments h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-display-body-switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.message-display-body-switch button {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-display-body-switch button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.message-display-body-switch button.active {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.message-display-body,
|
||||
.message-display-html-frame {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
max-height: 420px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-display-body {
|
||||
padding: 12px;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-html-frame {
|
||||
height: 420px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.message-display-attachments {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 9px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-display-attachments h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-display-attachments div {
|
||||
.message-display-attachments-scroll {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
gap: 9px;
|
||||
max-height: min(360px, 38vh);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.message-display-attachment-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.message-display-attachment-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.message-display-attachments small {
|
||||
.message-display-attachment-row > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-display-attachment-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-display-attachment-row small,
|
||||
.message-display-attachment-archive header span,
|
||||
.message-display-attachment-archive header small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message-display-attachment-detail {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-attachment-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.message-display-attachment-meta span + span::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.message-display-attachment-archive {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.message-display-attachment-archive header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-display-attachment-archive header div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.message-display-attachment-archive header strong,
|
||||
.message-display-attachment-archive header small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.message-display-headers summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.message-display-headers dl {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
display: table;
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message-display-headers dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-display-headers dt {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-display-headers dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Shared SMTP/IMAP server settings panel. */
|
||||
.mail-server-settings-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.mail-server-segmented-control {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
justify-self: center;
|
||||
width: min(420px, 100%);
|
||||
overflow: hidden;
|
||||
border: 1px solid #c9c3b9;
|
||||
border-radius: 8px;
|
||||
background: #c9c3b9;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
}
|
||||
.mail-server-segmented-tab {
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||
}
|
||||
.mail-server-segmented-tab + .mail-server-segmented-tab {
|
||||
border-left: 1px solid #c9c3b9;
|
||||
}
|
||||
.mail-server-segmented-tab:first-child {
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
.mail-server-segmented-tab:last-child {
|
||||
border-radius: 0 7px 7px 0;
|
||||
}
|
||||
.mail-server-segmented-tab:hover,
|
||||
.mail-server-segmented-tab:focus-visible {
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
color: #4f4a43;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
}
|
||||
.mail-server-segmented-tab:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.mail-server-segmented-tab.is-active {
|
||||
background: var(--line);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.mail-server-segmented-tab.is-active:hover,
|
||||
.mail-server-segmented-tab.is-active:focus-visible {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.mail-server-settings-view {
|
||||
min-width: 0;
|
||||
}
|
||||
.mail-server-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
@@ -908,9 +1190,19 @@
|
||||
font-size: 14px;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
.form-grid.compact.mail-server-form-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.mail-server-form-grid {
|
||||
align-items: start;
|
||||
}
|
||||
.mail-server-field-heading {
|
||||
grid-column: 1 / -1;
|
||||
margin: 2px 0 -4px;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mail-server-field-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -919,10 +1211,22 @@
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.mail-server-plain-toggle-row {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.mail-server-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mail-server-folder-field {
|
||||
align-items: stretch;
|
||||
}
|
||||
.mail-server-folder-field .btn,
|
||||
.mail-server-folder-field .button {
|
||||
min-height: 36px;
|
||||
}
|
||||
.mail-server-folder-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -944,7 +1248,19 @@
|
||||
@media (max-width: 1000px) {
|
||||
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
|
||||
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
|
||||
.mail-server-segmented-control { width: 100%; }
|
||||
}
|
||||
|
||||
.unsaved-changes-dialog {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.unsaved-changes-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
|
||||
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
|
||||
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||||
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
|
||||
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
|
||||
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
|
||||
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
|
||||
|
||||
@@ -28,93 +28,31 @@
|
||||
}
|
||||
|
||||
|
||||
.retention-policy-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
.retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.retention-policy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
.retention-policy-table.with-effective-column .retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr) minmax(190px, 0.8fr);
|
||||
}
|
||||
|
||||
.retention-policy-table.with-allow-column .retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr) minmax(170px, 0.65fr);
|
||||
}
|
||||
|
||||
.retention-policy-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.retention-policy-row-header {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.retention-policy-field-label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retention-policy-field-label strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.retention-policy-field-label small {
|
||||
color: var(--muted);
|
||||
.retention-policy-table.with-effective-column.with-allow-column .retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(210px, 1fr) minmax(190px, 0.8fr) minmax(170px, 0.65fr);
|
||||
}
|
||||
|
||||
.retention-run-card .admin-json-preview {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.retention-policy-effective {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.retention-policy-effective-grid,
|
||||
.retention-policy-row,
|
||||
.retention-policy-table.with-allow-column .retention-policy-row {
|
||||
.retention-policy-table.with-effective-column .retention-policy-row,
|
||||
.retention-policy-table.with-allow-column .retention-policy-row,
|
||||
.retention-policy-table.with-effective-column.with-allow-column .retention-policy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ComponentType, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
|
||||
export type ApiSettings = {
|
||||
apiBaseUrl: string;
|
||||
@@ -74,12 +74,14 @@ export type CampaignWorkspaceSection =
|
||||
| "overview"
|
||||
| "campaign"
|
||||
| "global-settings"
|
||||
| "policies"
|
||||
| "fields"
|
||||
| "recipients"
|
||||
| "recipient-data"
|
||||
| "template"
|
||||
| "files"
|
||||
| "mail-settings"
|
||||
| "mail-policy"
|
||||
| "review"
|
||||
| "report"
|
||||
| "audit"
|
||||
@@ -139,6 +141,8 @@ export type PlatformRouteContribution = {
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
|
||||
export type PlatformWebModule = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -147,6 +151,174 @@ export type PlatformWebModule = {
|
||||
optionalDependencies?: string[];
|
||||
navItems?: PlatformNavItem[];
|
||||
routes?: PlatformRouteContribution[];
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
};
|
||||
|
||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type MailProfileTargetOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: string | null;
|
||||
};
|
||||
|
||||
export type MailTransportCredentials = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailTransportSettings = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailSecurity | string | null;
|
||||
timeout_seconds?: number | null;
|
||||
};
|
||||
|
||||
export type MailImapTransportSettings = MailTransportSettings & {
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerProfileCredentials = {
|
||||
smtp?: MailTransportCredentials | null;
|
||||
imap?: MailTransportCredentials | null;
|
||||
};
|
||||
|
||||
export type MailServerProfile = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
smtp: MailTransportSettings;
|
||||
imap?: MailImapTransportSettings | null;
|
||||
credentials?: MailServerProfileCredentials | null;
|
||||
smtp_password_configured?: boolean;
|
||||
imap_password_configured?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type MailCredentialPolicy = {
|
||||
inherit?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
allowed_profile_ids?: string[] | null;
|
||||
allow_user_profiles?: boolean | null;
|
||||
allow_group_profiles?: boolean | null;
|
||||
allow_campaign_profiles?: boolean | null;
|
||||
smtp_credentials?: MailCredentialPolicy | null;
|
||||
imap_credentials?: MailCredentialPolicy | null;
|
||||
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationInput = {
|
||||
smtpHost?: string | null;
|
||||
imapHost?: string | null;
|
||||
envelopeSender?: string | null;
|
||||
fromHeader?: string | null;
|
||||
recipientDomains?: Array<string | null | undefined> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationMessage = {
|
||||
key: MailProfilePatternKey;
|
||||
label: string;
|
||||
value: string;
|
||||
message: string;
|
||||
severity?: "warning" | "danger";
|
||||
};
|
||||
|
||||
export type MailPolicyValidationFn = (
|
||||
policy: MailProfilePolicy | null | undefined,
|
||||
input: MailPolicyValidationInput,
|
||||
) => MailPolicyValidationMessage[];
|
||||
|
||||
export type MailProfileScopeManagerProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: MailProfileScope;
|
||||
scopeId?: string | null;
|
||||
targetOptions?: MailProfileTargetOption[];
|
||||
targetLabel?: string;
|
||||
profileTitle?: string;
|
||||
policyTitle?: string;
|
||||
canWriteProfiles: boolean;
|
||||
canManageCredentials: boolean;
|
||||
canWritePolicy: boolean;
|
||||
};
|
||||
|
||||
export type MailProfilePolicyEditorProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: MailProfileScope;
|
||||
scopeId?: string | null;
|
||||
campaignId?: string | null;
|
||||
profiles: MailServerProfile[];
|
||||
ownerUserId?: string | null;
|
||||
ownerGroupId?: string | null;
|
||||
canWrite: boolean;
|
||||
locked?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onSaved?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type MailProfilesUiCapability = {
|
||||
MailProfileScopeManager: ComponentType<MailProfileScopeManagerProps>;
|
||||
MailProfilePolicyEditor: ComponentType<MailProfilePolicyEditorProps>;
|
||||
validateMailPolicy?: MailPolicyValidationFn;
|
||||
};
|
||||
|
||||
export type MailDevMailboxUiCapability = {
|
||||
enabled: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FilesFolderNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FilesFolderNode[];
|
||||
fileCount: number;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type FilesFileActionTarget = {
|
||||
spaceId: string;
|
||||
folderPath: string;
|
||||
};
|
||||
|
||||
export type FilesFolderTreeProps = {
|
||||
nodes: FilesFolderNode[];
|
||||
activeSpaceId: string;
|
||||
spaceId: string;
|
||||
currentFolder: string;
|
||||
dropTargetKey?: string;
|
||||
expandedKeys: Set<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FilesFileActionTarget) => void;
|
||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FilesFileActionTarget) => Promise<void>;
|
||||
onClearDropState?: () => void;
|
||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder?: () => void;
|
||||
dragDropEnabled?: boolean;
|
||||
contextMenuEnabled?: boolean;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export type FilesFileExplorerUiCapability = {
|
||||
FolderTree: ComponentType<FilesFolderTreeProps>;
|
||||
};
|
||||
|
||||
export type PlatformFrontendRouteInfo = {
|
||||
@@ -181,6 +353,7 @@ export type PlatformModuleInfo = {
|
||||
dependencies: string[];
|
||||
optional_dependencies: string[];
|
||||
enabled: boolean;
|
||||
runtime_ui_capabilities?: string[];
|
||||
nav: Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
|
||||
@@ -75,7 +75,6 @@ const FIELD_HELP_BY_LABEL: Record<string, string> = {
|
||||
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
|
||||
"Subdirs": "Search nested folders below the configured base directory.",
|
||||
"Can override": "Allow recipient-specific values to override the global value for this field.",
|
||||
"Enable IMAP": "Enable IMAP settings so the server can test login, list folders and optionally append sent copies.",
|
||||
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
|
||||
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
|
||||
};
|
||||
|
||||
9
webui/src/vite-env.d.ts
vendored
9
webui/src/vite-env.d.ts
vendored
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
|
||||
declare module "virtual:govoplan-installed-modules" {
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
const installedWebModules: PlatformWebModule[];
|
||||
export { installedWebModules };
|
||||
export default installedWebModules;
|
||||
}
|
||||
|
||||
198
webui/tests/mail-components.test.tsx
Normal file
198
webui/tests/mail-components.test.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
||||
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
|
||||
|
||||
function noop() {}
|
||||
|
||||
const savedPassword = renderToStaticMarkup(
|
||||
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||
);
|
||||
assert(savedPassword.includes('placeholder="Saved password configured"'), "saved password placeholder is rendered");
|
||||
assert(!savedPassword.includes("password-field-toggle"), "saved empty password does not show reveal button");
|
||||
|
||||
const typedPassword = renderToStaticMarkup(
|
||||
<PasswordField value="secret" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||
);
|
||||
assert(typedPassword.includes('aria-label="Show password"'), "typed password shows reveal button");
|
||||
assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class");
|
||||
|
||||
const settingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
|
||||
imap={{ host: "imap.example.org", port: 993, username: "sender", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
smtpPasswordSaved
|
||||
smtpSavedPasswordPlaceholder="Saved SMTP password"
|
||||
imapPasswordSaved
|
||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||
onTestSmtp={noop}
|
||||
onTestImap={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(settingsPanel.includes("Test SMTP"), "SMTP test action is rendered");
|
||||
assert(settingsPanel.includes("Server"), "server heading is rendered");
|
||||
assert(settingsPanel.includes("Credentials"), "credentials heading is rendered");
|
||||
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
||||
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
||||
|
||||
const imapSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="imap"
|
||||
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
|
||||
imap={{ host: "imap.example.org", port: 993, username: "sender", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
imapPasswordSaved
|
||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||
onTestImap={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(imapSettingsPanel.includes("Test IMAP"), "IMAP test action is rendered");
|
||||
assert(imapSettingsPanel.includes("Server"), "IMAP server heading is rendered");
|
||||
assert(imapSettingsPanel.includes("Credentials"), "IMAP credentials heading is rendered");
|
||||
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("Folders..."), "folder lookup action is not rendered in the IMAP tab");
|
||||
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
||||
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
||||
|
||||
const advancedSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="advanced"
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(advancedSettingsPanel.includes("Append target folder"), "append target folder is the advanced IMAP folder field");
|
||||
assert(advancedSettingsPanel.includes("Folders..."), "folder lookup action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes("Folders loaded"), "folder lookup result is rendered in Advanced");
|
||||
assert(advancedSettingsPanel.includes("Use detected folder"), "detected folder action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes('value="Sent"'), "append target folder uses IMAP sent folder value without append override");
|
||||
|
||||
const disabledAppendFolderPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="advanced"
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
append={{
|
||||
enabled: true,
|
||||
folder: "Sent",
|
||||
folderDisabled: true,
|
||||
onEnabledChange: noop,
|
||||
onFolderChange: noop
|
||||
}}
|
||||
/>
|
||||
);
|
||||
assert(!disabledAppendFolderPanel.includes("Folders..."), "folder lookup action is hidden when append target is disabled");
|
||||
assert(!disabledAppendFolderPanel.includes("Use detected folder"), "detected folder action is hidden when append target is disabled");
|
||||
|
||||
const defaultPortSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
/>
|
||||
);
|
||||
const defaultImapPortSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="imap"
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
/>
|
||||
);
|
||||
assert(defaultPortSettingsPanel.includes('value="587"'), "SMTP starttls default port is displayed");
|
||||
assert(defaultImapPortSettingsPanel.includes('value="993"'), "IMAP TLS default port is displayed");
|
||||
|
||||
const messageDisplay = renderToStaticMarkup(
|
||||
<MessageDisplayPanel
|
||||
title="Quarterly update"
|
||||
fields={[
|
||||
{ label: "From", value: "Sender <sender@example.org>" },
|
||||
{ label: "Cc", value: null },
|
||||
{ label: "Bcc", value: "Audit <audit@example.org>" }
|
||||
]}
|
||||
bodyText="Plain body"
|
||||
bodyHtml="<p><strong>HTML body</strong></p>"
|
||||
preferredBodyMode="html"
|
||||
attachments={[
|
||||
{ filename: "agenda.pdf", contentType: "application/pdf", sizeBytes: 2048 },
|
||||
{
|
||||
filename: "secret.pdf",
|
||||
contentType: "application/pdf",
|
||||
archiveGroup: "archive-1",
|
||||
archiveLabel: "recipient-files.zip",
|
||||
protected: true,
|
||||
protectionNote: 'Password-protected ZIP using local field "zip_password". Encryption: AES.'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
assert(messageDisplay.includes("Quarterly update"), "message subject is rendered");
|
||||
assert(messageDisplay.includes("Sender <sender@example.org>"), "sender field is rendered");
|
||||
assert(!messageDisplay.includes("<dt>Cc</dt>"), "empty optional CC field is hidden");
|
||||
assert(messageDisplay.includes("<dt>Bcc</dt>"), "present BCC field is rendered");
|
||||
assert(messageDisplay.includes("HTML"), "HTML body switch is rendered");
|
||||
assert(messageDisplay.includes("Text"), "text body switch is rendered");
|
||||
assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
|
||||
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
||||
assert(messageDisplay.includes("Password protected"), "ZIP protection badge is rendered");
|
||||
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
||||
65
webui/tests/module-capabilities.test.ts
Normal file
65
webui/tests/module-capabilities.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { PlatformWebModule } from "../src/types";
|
||||
import {
|
||||
hasUiCapability,
|
||||
moduleInstalled,
|
||||
moduleIntegrationEnabled,
|
||||
routeContributionsForModules,
|
||||
uiCapability
|
||||
} from "../src/platform/moduleLogic";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const access: PlatformWebModule = { id: "access", label: "Access", version: "test" };
|
||||
const filesCapability = { kind: "fileExplorer" };
|
||||
const mailCapability = { kind: "mailProfiles" };
|
||||
const files: PlatformWebModule = {
|
||||
id: "files",
|
||||
label: "Files",
|
||||
version: "test",
|
||||
routes: [{ path: "/files", order: 20, render: () => null }],
|
||||
uiCapabilities: { "files.fileExplorer": filesCapability }
|
||||
};
|
||||
const mail: PlatformWebModule = {
|
||||
id: "mail",
|
||||
label: "Mail",
|
||||
version: "test",
|
||||
routes: [{ path: "/mail", order: 30, render: () => null }],
|
||||
uiCapabilities: { "mail.profiles": mailCapability }
|
||||
};
|
||||
const campaigns: PlatformWebModule = {
|
||||
id: "campaigns",
|
||||
label: "Campaigns",
|
||||
version: "test",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["files", "mail"],
|
||||
routes: [{ path: "/campaigns", order: 10, render: () => null }]
|
||||
};
|
||||
|
||||
const cases: Array<{ name: string; modules: PlatformWebModule[]; files: boolean; mail: boolean }> = [
|
||||
{ name: "core-only", modules: [access], files: false, mail: false },
|
||||
{ name: "files-only", modules: [access, files], files: true, mail: false },
|
||||
{ name: "mail-only", modules: [access, mail], files: false, mail: true },
|
||||
{ name: "campaign-only", modules: [access, campaigns], files: false, mail: false },
|
||||
{ name: "campaign-files", modules: [access, campaigns, files], files: true, mail: false },
|
||||
{ name: "campaign-mail", modules: [access, campaigns, mail], files: false, mail: true },
|
||||
{ name: "full-product", modules: [access, campaigns, files, mail], files: true, mail: true }
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const campaignInstalled = moduleInstalled("campaigns", testCase.modules);
|
||||
assert(moduleInstalled("access", testCase.modules), `${testCase.name}: access should be installed`);
|
||||
assert(moduleInstalled("files", testCase.modules) === testCase.files, `${testCase.name}: files installed state`);
|
||||
assert(moduleInstalled("mail", testCase.modules) === testCase.mail, `${testCase.name}: mail installed state`);
|
||||
assert(hasUiCapability("files.fileExplorer", testCase.modules) === testCase.files, `${testCase.name}: files UI capability`);
|
||||
assert(hasUiCapability("mail.profiles", testCase.modules) === testCase.mail, `${testCase.name}: mail UI capability`);
|
||||
assert(moduleIntegrationEnabled("campaigns", "files", testCase.modules) === (campaignInstalled && testCase.files), `${testCase.name}: campaign files integration`);
|
||||
assert(moduleIntegrationEnabled("campaigns", "mail", testCase.modules) === (campaignInstalled && testCase.mail), `${testCase.name}: campaign mail integration`);
|
||||
}
|
||||
|
||||
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
|
||||
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
|
||||
|
||||
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");
|
||||
30
webui/tsconfig.component-tests.json
Normal file
30
webui/tsconfig.component-tests.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM"
|
||||
],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".component-test-build",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"tests/mail-components.test.tsx",
|
||||
"src/components/PasswordField.tsx",
|
||||
"src/components/MessageDisplayPanel.tsx",
|
||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||
"src/components/Button.tsx",
|
||||
"src/components/DismissibleAlert.tsx",
|
||||
"src/components/FormField.tsx",
|
||||
"src/components/ToggleSwitch.tsx"
|
||||
]
|
||||
}
|
||||
22
webui/tsconfig.module-tests.json
Normal file
22
webui/tsconfig.module-tests.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".module-test-build",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"tests/module-capabilities.test.ts",
|
||||
"src/platform/moduleLogic.ts",
|
||||
"src/types.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,17 +1,69 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const linkedModulePackages = [
|
||||
const require = createRequire(import.meta.url);
|
||||
const webuiRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const installedModulesVirtualId = "virtual:govoplan-installed-modules";
|
||||
const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
|
||||
|
||||
const defaultWebModulePackages = [
|
||||
"@govoplan/campaign-webui",
|
||||
"@govoplan/files-webui",
|
||||
"@govoplan/mail-webui"
|
||||
];
|
||||
|
||||
function configuredWebModulePackages(): string[] {
|
||||
const configured = process.env.GOVOPLAN_WEBUI_MODULE_PACKAGES;
|
||||
if (configured === undefined) return defaultWebModulePackages;
|
||||
return configured.split(",").map((specifier) => specifier.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function packageDirectory(specifier: string): string {
|
||||
const parts = specifier.startsWith("@") ? specifier.split("/") : [specifier];
|
||||
return join(webuiRoot, "node_modules", ...parts);
|
||||
}
|
||||
|
||||
function packageInstalled(specifier: string): boolean {
|
||||
if (existsSync(join(packageDirectory(specifier), "package.json"))) return true;
|
||||
try {
|
||||
require.resolve(specifier);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function availableWebModuleSpecifiers(): string[] {
|
||||
return configuredWebModulePackages().filter(packageInstalled);
|
||||
}
|
||||
|
||||
function govoplanInstalledModulesPlugin(): Plugin {
|
||||
return {
|
||||
name: "govoplan-installed-modules",
|
||||
resolveId(id: string) {
|
||||
if (id === installedModulesVirtualId) return resolvedInstalledModulesVirtualId;
|
||||
return null;
|
||||
},
|
||||
load(id: string) {
|
||||
if (id !== resolvedInstalledModulesVirtualId) return null;
|
||||
const imports = availableWebModuleSpecifiers();
|
||||
return [
|
||||
...imports.map((specifier, index) => `import module${index} from ${JSON.stringify(specifier)};`),
|
||||
`export const installedWebModules = [${imports.map((_specifier, index) => `module${index}`).join(", ")}];`,
|
||||
"export default installedWebModules;"
|
||||
].join("\n");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [govoplanInstalledModulesPlugin(), react()],
|
||||
optimizeDeps: {
|
||||
exclude: linkedModulePackages
|
||||
exclude: availableWebModuleSpecifiers()
|
||||
},
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
|
||||
Reference in New Issue
Block a user