Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 15794e920e
commit 02564047e9
73 changed files with 4073 additions and 478 deletions

View File

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

View File

@@ -152,6 +152,7 @@ export type PolicySourceStep = {
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PrivacyRetentionPolicyScopeResponse = {

View File

@@ -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();

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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`;

View File

@@ -4,6 +4,7 @@ export type PolicySourcePathItem = string | {
label: string;
applied_fields?: string[];
appliedFields?: string[];
policy?: Record<string, unknown> | null;
};
export type PolicySourcePathProps = {

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

View File

@@ -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);

View File

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

View File

@@ -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";

View File

@@ -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

View File

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

View File

@@ -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)} />

View File

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

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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";

View File

@@ -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.
}
}

View File

@@ -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>
</>
) : (

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

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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