campaign sending prototype

This commit is contained in:
2026-06-24 16:20:37 +02:00
parent 44516a03aa
commit ce30b4d054
17 changed files with 1233 additions and 14 deletions

View File

@@ -0,0 +1,147 @@
import { Folder, FolderOpen } from "lucide-react";
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
export type ExplorerTreeNodeContext = {
depth: number;
active: boolean;
expanded: boolean;
hasChildren: boolean;
disabled: boolean;
};
type ExplorerTreeProps<T> = {
nodes: T[];
getNodeId: (node: T) => string;
getNodeLabel: (node: T) => string;
getNodeChildren: (node: T) => T[];
activeId?: string;
expandedIds: Set<string>;
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
disabled?: boolean;
depth?: number;
className?: string;
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
getNodeDraggable?: (node: T, context: ExplorerTreeNodeContext) => boolean;
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragStart?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragEnd?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragOver?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragLeave?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
};
export default function ExplorerTree<T>({
nodes,
getNodeId,
getNodeLabel,
getNodeChildren,
activeId = "",
expandedIds,
onOpen,
onToggle,
disabled = false,
depth = 1,
className = "",
renderToggleIcon,
renderNodeContent,
getNodeWrapClassName,
getNodeWrapStyle,
getNodeButtonClassName,
getNodeDraggable,
onContextMenu,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop
}: ExplorerTreeProps<T>) {
if (nodes.length === 0) return null;
return (
<div className={["explorer-tree-children", "file-tree-children", className].filter(Boolean).join(" ")}>
{nodes.map((node) => {
const nodeId = getNodeId(node);
const children = getNodeChildren(node);
const hasChildren = children.length > 0;
const expanded = expandedIds.has(nodeId);
const active = activeId === nodeId;
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
const draggable = getNodeDraggable?.(node, context) ?? false;
return (
<div key={nodeId}>
<div
className={[
"explorer-tree-node-wrap",
"file-tree-node-wrap",
active ? "is-active" : "",
getNodeWrapClassName?.(node, context) ?? ""
].filter(Boolean).join(" ")}
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
draggable={draggable && !disabled}
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
>
<button
type="button"
className="explorer-tree-toggle file-tree-toggle"
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle(node, context);
}}
disabled={disabled || !hasChildren}
aria-label={`${expanded ? "Collapse" : "Expand"} ${getNodeLabel(node)}`}
aria-expanded={hasChildren ? expanded : undefined}
>
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
</button>
<button
type="button"
className={["explorer-tree-node", "file-tree-node", getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
onClick={() => onOpen(node, context)}
disabled={disabled}
>
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
</button>
</div>
{hasChildren && expanded && (
<ExplorerTree
nodes={children}
getNodeId={getNodeId}
getNodeLabel={getNodeLabel}
getNodeChildren={getNodeChildren}
activeId={activeId}
expandedIds={expandedIds}
onOpen={onOpen}
onToggle={onToggle}
disabled={disabled}
depth={depth + 1}
renderToggleIcon={renderToggleIcon}
renderNodeContent={renderNodeContent}
getNodeWrapClassName={getNodeWrapClassName}
getNodeWrapStyle={getNodeWrapStyle}
getNodeButtonClassName={getNodeButtonClassName}
getNodeDraggable={getNodeDraggable}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
/>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { Paperclip } from "lucide-react";
export type MessageDisplayField = {
label: string;
value?: string | null;
};
export type MessageDisplayAttachment = {
id?: string | null;
filename?: string | null;
contentType: string;
sizeBytes: number;
};
type MessageDisplayPanelProps = {
title?: string | null;
fields?: MessageDisplayField[];
bodyText?: string | null;
bodyHtml?: string | null;
bodyPreview?: string | null;
headers?: Record<string, string>;
attachments?: MessageDisplayAttachment[];
emptyText?: string;
};
export default function MessageDisplayPanel({
title,
fields = [],
bodyText,
bodyHtml,
bodyPreview,
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) {
return <p className="muted">{emptyText}</p>;
}
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
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>
))}
</div>
<pre className="message-display-body">{body || "No readable body content."}</pre>
{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>
) : 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>
</div>
);
}
function formatBytes(value?: number | null): string {
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`;
}
function stripHtml(value: string): string {
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}

View File

@@ -0,0 +1,257 @@
import type { ReactNode } from "react";
import Button from "../Button";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
export type MailServerSmtpSettings = {
host?: string | null;
port?: string | number | null;
username?: string | null;
password?: string | null;
security?: MailServerSecurity | null;
timeout_seconds?: string | number | null;
};
export type MailServerImapSettings = MailServerSmtpSettings & {
enabled?: boolean;
sent_folder?: string | null;
};
export type MailServerConnectionTestResult = {
ok: boolean;
protocol?: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailServerSecurity | null;
message: string;
details?: Record<string, unknown> | null;
};
export type MailServerFolderLookupResult = {
ok: boolean;
protocol?: "imap";
host?: string | null;
port?: number | null;
security?: MailServerSecurity | null;
message: string;
detected_sent_folder?: string | null;
folders?: { name: string; flags?: string[] }[];
details?: Record<string, unknown> | null;
};
export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings;
imap: MailServerImapSettings;
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
onImapChange: (patch: Partial<MailServerImapSettings>) => void;
onImapEnabledChange?: (enabled: boolean) => void;
smtpDisabled?: boolean;
smtpCredentialDisabled?: boolean;
smtpActionDisabled?: boolean;
imapToggleDisabled?: boolean;
imapServerDisabled?: boolean;
imapCredentialDisabled?: boolean;
imapActionDisabled?: boolean;
smtpTitle?: ReactNode;
imapTitle?: ReactNode;
smtpTestLabel?: string;
imapTestLabel?: string;
folderLookupLabel?: string;
busyAction?: "smtp" | "imap" | "folders" | string | null;
onTestSmtp?: () => void;
onTestImap?: () => void;
onLookupFolders?: () => void;
smtpTestResult?: MailServerConnectionTestResult | null;
imapTestResult?: MailServerConnectionTestResult | null;
folderLookupResult?: MailServerFolderLookupResult | null;
onUseDetectedFolder?: () => void;
useDetectedFolderDisabled?: boolean;
mockToggle?: {
label?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
};
append?: {
enabled: boolean;
folder: string;
disabled?: boolean;
folderDisabled?: boolean;
onEnabledChange: (enabled: boolean) => void;
onFolderChange: (folder: string) => void;
};
disabled?: boolean;
className?: string;
floatingResults?: boolean;
};
const securityOptions = ["plain", "tls", "starttls"];
export default function MailServerSettingsPanel({
smtp,
imap,
onSmtpChange,
onImapChange,
onImapEnabledChange,
smtpDisabled = false,
smtpCredentialDisabled = smtpDisabled,
smtpActionDisabled = smtpDisabled,
imapToggleDisabled = false,
imapServerDisabled = false,
imapCredentialDisabled = imapServerDisabled,
imapActionDisabled = imapServerDisabled,
smtpTitle = "SMTP login",
imapTitle = "IMAP sent-folder append",
smtpTestLabel = "Test SMTP",
imapTestLabel = "Test IMAP",
folderLookupLabel = "Folders...",
busyAction = null,
onTestSmtp,
onTestImap,
onLookupFolders,
smtpTestResult = null,
imapTestResult = null,
folderLookupResult = null,
onUseDetectedFolder,
useDetectedFolderDisabled = false,
mockToggle,
append,
disabled = false,
className = "",
floatingResults = false
}: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
const smtpActionsDisabled = disabled || smtpActionDisabled;
const imapFieldsDisabled = disabled || imapServerDisabled;
const imapCredentialFieldsDisabled = disabled || imapCredentialDisabled;
const imapActionsDisabled = disabled || imapActionDisabled;
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"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></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>
<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>
)}
<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"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></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 && (
<>
<div className="mail-server-field-span mail-server-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>}
</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>
</div>
</div>
);
}
export function MailServerActionResult({ result, floating = false }: { result: MailServerConnectionTestResult | null | undefined; floating?: boolean }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating={floating}>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</DismissibleAlert>
);
}
export function MailServerFolderLookupResultView({
result,
disabled = false,
onUseDetected
}: {
result: MailServerFolderLookupResult | null | undefined;
disabled?: boolean;
onUseDetected?: () => void;
}) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
}
const folders = result.folders ?? [];
return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "-"}</strong></p>
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{folders.length > 0 && (
<div className="mail-server-folder-chip-list">
{folders.slice(0, 12).map((folder) => (
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{folders.length > 12 && <span className="mail-server-folder-chip">+{folders.length - 12} more</span>}
</div>
)}
</DismissibleAlert>
);
}
function SecuritySelect({ value, disabled, onChange }: { value: string; disabled: boolean; onChange: (value: MailServerSecurity) => void }) {
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
}
function stringValue(value: string | number | null | undefined, fallback = ""): string {
if (value === null || value === undefined) return fallback;
return String(value);
}

View File

@@ -1,3 +1,5 @@
import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime";
export function adminErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "The administrative operation failed.";
const message = error.message;
@@ -17,9 +19,7 @@ export function adminErrorMessage(error: unknown): string {
}
export function formatDateTime(value?: string | null): string {
if (!value) return "Never";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
return formatPlatformDateTime(value, { fallback: "Never" });
}
export function joinLabels(values: Array<{ name: string }>): string {

View File

@@ -8,6 +8,7 @@ export * from "./utils/permissions";
export * from "./utils/fieldHelp";
export * from "./utils/helpContext";
export * from "./utils/emailAddresses";
export * from "./utils/datetime";
export * from "./utils/arrayOrder";
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
@@ -19,12 +20,18 @@ export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as FormField } from "./components/FormField";
export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext } from "./components/ExplorerTree";
export { default as MetricCard } from "./components/MetricCard";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
export { default as StatusBadge } from "./components/StatusBadge";
export { default as Stepper } from "./components/Stepper";
export { default as ToggleSwitch } from "./components/ToggleSwitch";
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 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,4 +1,4 @@
import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, type LucideIcon } from "lucide-react";
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";
@@ -6,28 +6,41 @@ import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContri
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
export const shellNavItems: PlatformNavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, order: 10 }
{ to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
];
const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule];
const iconByName: Record<string, LucideIcon> = {
activity: Activity,
campaign: MailCheck,
campaign: Mails,
dashboard: LayoutDashboard,
file: Folder,
files: Folder,
folder: Folder,
form: Form,
mail: MailCheck,
mail: Mail,
reports: FileText,
users: Users
};
export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined {
if (!iconName) return undefined;
return iconByName[iconName];
}
function resolveNavItemIcon(item: PlatformNavItem): PlatformNavItem {
return {
...item,
icon: item.icon ?? iconComponentForName(item.iconName)
};
}
function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem {
return {
to: item.path,
label: item.label,
icon: item.icon ? iconByName[item.icon] : undefined,
iconName: item.icon ?? null,
allOf: item.required_all,
anyOf: item.required_any,
order: item.order
@@ -70,7 +83,9 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
}
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])]
.map(resolveNavItemIcon)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {

View File

@@ -627,3 +627,234 @@
opacity: 1;
background: rgba(255, 255, 255, 0.45);
}
/* Shared explorer tree and message display surfaces. */
.explorer-tree-children {
display: grid;
gap: 1px;
}
.explorer-tree-node-wrap {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 2px;
border-radius: 9px;
}
.explorer-tree-node,
.explorer-tree-toggle {
border: 0;
background: transparent;
color: var(--text);
cursor: pointer;
}
.explorer-tree-toggle {
display: grid;
place-items: center;
width: 26px;
height: 32px;
border-radius: 9px;
color: var(--muted);
}
.explorer-tree-toggle:disabled {
cursor: default;
opacity: .55;
}
.explorer-tree-node {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
padding: 8px 9px;
border-radius: 9px;
font: inherit;
text-align: left;
user-select: none;
}
.explorer-tree-node span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-tree-node-wrap:hover,
.explorer-tree-node-wrap:focus-within,
.explorer-tree-node-wrap.is-active {
background: rgba(13, 110, 253, .08);
outline: none;
}
.explorer-tree-node:focus-visible,
.explorer-tree-toggle:focus-visible {
outline: none;
}
.message-display-panel {
display: grid;
gap: 14px;
min-width: 0;
}
.message-display-header {
display: grid;
gap: 8px;
}
.message-display-header h3 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.message-display-header div {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 10px;
}
.message-display-header span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.message-display-header strong {
min-width: 0;
overflow-wrap: anywhere;
}
.message-display-body {
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);
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message-display-attachments {
display: grid;
gap: 8px;
}
.message-display-attachments h4 {
margin: 0;
}
.message-display-attachments div {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.message-display-attachments small {
color: var(--muted);
}
.message-display-headers dl {
display: grid;
gap: 7px;
max-height: 280px;
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-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 22px;
}
.mail-server-subsection {
display: grid;
align-content: start;
gap: 12px;
}
.mail-server-section-heading.split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mail-server-section-heading h3 {
margin: 0;
color: var(--text-strong);
font-size: 14px;
letter-spacing: .01em;
}
.mail-server-form-grid {
align-items: start;
}
.mail-server-field-span {
grid-column: 1 / -1;
}
.mail-server-toggle-row {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.mail-server-actions {
justify-content: flex-end;
margin-top: 12px;
}
.mail-server-folder-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mail-server-folder-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
border: 1px solid var(--line);
border-radius: 99px;
background: var(--panel-soft);
padding: 3px 9px;
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 1000px) {
.mail-server-settings-grid { grid-template-columns: 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; }
}

View File

@@ -112,9 +112,14 @@ export type CampaignListItem = {
};
export type PlatformIconName = string;
export type PlatformNavItem = {
to: string;
label: string;
/** Resolved by core. Modules should provide iconName, not imported icon components. */
iconName?: PlatformIconName | null;
/** Runtime-renderable icon, assigned by core after resolving iconName. */
icon?: ComponentType<{ size?: number }>;
anyOf?: string[];
allOf?: string[];

View File

@@ -0,0 +1,46 @@
const HAS_TIMEZONE_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
function normalizeServerDateTime(value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
return HAS_TIMEZONE_OFFSET.test(trimmed) ? trimmed : `${trimmed}Z`;
}
export function parseServerDateTime(value?: string | null): Date | null {
if (!value) return null;
const date = new Date(normalizeServerDateTime(value));
return Number.isNaN(date.getTime()) ? null : date;
}
export type FormatDateTimeOptions = Intl.DateTimeFormatOptions & {
fallback?: string;
};
export const defaultDateTimeFormatOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short"
};
export function formatDateTime(value?: string | null, options: FormatDateTimeOptions = {}): string {
const fallback = options.fallback ?? "—";
if (!value) return fallback;
const date = parseServerDateTime(value);
if (!date) return value;
const { fallback: _fallback, ...intlOptions } = options;
return date.toLocaleString(undefined, {
...defaultDateTimeFormatOptions,
...intlOptions
});
}
export function formatDateTimeFromDate(value: Date, options: FormatDateTimeOptions = {}): string {
const { fallback: _fallback, ...intlOptions } = options;
return value.toLocaleString(undefined, {
...defaultDateTimeFormatOptions,
...intlOptions
});
}

View File

@@ -6,7 +6,7 @@ const legacyTenantScopes = [
"recipients:read", "recipients:write", "recipients:import", "recipients:export",
"files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin",
"reports:read", "reports:export", "reports:send", "audit:read",
"mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:write", "mail_servers:manage_credentials",
"mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:mailbox_read", "mail_servers:write", "mail_servers:manage_credentials",
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
@@ -48,6 +48,7 @@ const legacyToModuleScopes: Record<string, string> = {
"mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test",
"mail_servers:mailbox_read": "mail:mailbox:read",
"mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage"
};

View File

@@ -2,8 +2,17 @@ import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const linkedModulePackages = [
"@govoplan/campaign-webui",
"@govoplan/files-webui",
"@govoplan/mail-webui"
];
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: linkedModulePackages
},
resolve: {
preserveSymlinks: true,
dedupe: ["react", "react-dom", "react-router-dom"],