Structural changes

This commit is contained in:
2026-06-10 15:30:45 +02:00
parent 7de516c5e3
commit fcc46b06fe
16 changed files with 825 additions and 347 deletions

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@@ -11,6 +11,7 @@ import SettingsPage from "./features/settings/SettingsPage";
import AdminPage from "./features/admin/AdminPage"; import AdminPage from "./features/admin/AdminPage";
import TemplatesPage from "./features/templates/TemplatesPage"; import TemplatesPage from "./features/templates/TemplatesPage";
import FilesPage from "./features/files/FilesPage"; import FilesPage from "./features/files/FilesPage";
import AddressBookPage from "./features/addressbook/AddressBookPage";
import PlaceholderPage from "./features/PlaceholderPage"; import PlaceholderPage from "./features/PlaceholderPage";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
@@ -104,6 +105,7 @@ export default function App() {
<Route path="/campaigns/:campaignId/*" element={<CampaignWorkspace settings={settings} />} /> <Route path="/campaigns/:campaignId/*" element={<CampaignWorkspace settings={settings} />} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/files" element={<FilesPage />} /> <Route path="/files" element={<FilesPage />} />
<Route path="/address-book" element={<AddressBookPage />} />
<Route path="/reports" element={<PlaceholderPage title="Reports" />} /> <Route path="/reports" element={<PlaceholderPage title="Reports" />} />
<Route path="/settings" element={<SettingsPage settings={settings} onSettingsChange={updateSettings} />} /> <Route path="/settings" element={<SettingsPage settings={settings} onSettingsChange={updateSettings} />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />

View File

@@ -0,0 +1,100 @@
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
const personalContacts = [
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
];
const groupContacts = [
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
];
const tenantContacts = [
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
];
export default function AddressBookPage() {
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
return (
<div className="workspace module-workspace module-workspace-single">
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Address Book</PageTitle>
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Add contact</Button>
</div>
</div>
<div className="metric-grid">
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card>
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card>
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card>
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book scopes">
<div className="address-book-scope-list">
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
</div>
</Card>
<Card title="Planned address actions">
<div className="placeholder-stack">
<span>Choose addresses from personal, group or tenant source</span>
<span>Remember addresses used in campaigns after opt-in</span>
<span>Share selected contacts with a group</span>
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
</div>
</Card>
</div>
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}>
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead>
<tr><th>Name</th><th>Email</th><th>Scope</th><th>Tags</th><th>Status</th></tr>
</thead>
<tbody>
{allContacts.map((contact) => (
<tr key={`${contact.source}-${contact.email}`}>
<td><strong>{contact.name}</strong></td>
<td>{contact.email}</td>
<td>{contact.source}</td>
<td>{contact.tags}</td>
<td><StatusBadge status="mock" /></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
</section>
</div>
);
}
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
return (
<div className="address-book-scope-card">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<StatusBadge status={status} />
</div>
);
}

View File

@@ -4,27 +4,56 @@ import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle"; import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge"; import StatusBadge from "../../components/StatusBadge";
type AdminSection = "overview" | "users" | "groups" | "roles" | "tenants" | "api-keys" | "audit" | "system"; type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit";
const sections: { id: AdminSection; label: string }[] = [ const adminSections: { id: AdminSection; label: string }[] = [
{ id: "overview", label: "Overview" }, { id: "system", label: "System" },
{ id: "tenants", label: "Tenants" }
];
const tenantSections: { id: AdminSection; label: string }[] = [
{ id: "users", label: "Users" }, { id: "users", label: "Users" },
{ id: "groups", label: "Groups" }, { id: "groups", label: "Groups" },
{ id: "roles", label: "Roles" }, { id: "roles", label: "Roles" },
{ id: "tenants", label: "Tenants" },
{ id: "api-keys", label: "API keys" }, { id: "api-keys", label: "API keys" },
{ id: "audit", label: "Audit" }, { id: "mail-servers", label: "Mail servers" },
{ id: "system", label: "System" } { id: "settings", label: "Settings" },
{ id: "audit", label: "Audit" }
]; ];
const sectionTitles: Record<AdminSection, { title: string; description: string }> = {
overview: { title: "Overview", description: "Administrative entry point for system-wide and tenant-scoped management." },
system: { title: "System", description: "Instance-wide health, workers, storage and diagnostics." },
tenants: { title: "Tenants", description: "Create and maintain tenant spaces for separated campaign work." },
users: { title: "Users", description: "Tenant users, invitations and account status." },
groups: { title: "Groups", description: "Tenant groups for ownership, sharing and operational workflows." },
roles: { title: "Roles", description: "Role definitions and permission assignments inside the active tenant." },
"api-keys": { title: "API keys", description: "Tenant-scoped API keys for automation and integrations." },
"mail-servers": { title: "Mail servers", description: "Reusable tenant SMTP/IMAP profiles and sending infrastructure." },
settings: { title: "Settings", description: "Tenant-level defaults, limits and policy configuration." },
audit: { title: "Audit", description: "Administrative audit trail for tenant and system changes." }
};
export default function AdminPage() { export default function AdminPage() {
const [active, setActive] = useState<AdminSection>("overview"); const [active, setActive] = useState<AdminSection>("overview");
const heading = sectionTitles[active];
return ( return (
<div className="workspace module-workspace"> <div className="workspace module-workspace">
<aside className="section-sidebar"> <aside className="section-sidebar">
<div className="section-title">ADMIN</div> <button className={`section-link ${active === "overview" ? "active" : ""}`} onClick={() => setActive("overview")}>
{sections.map((section) => ( Overview
</button>
<div className="section-title section-title-lower">ADMIN</div>
{adminSections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label}
</button>
))}
<div className="section-title section-title-lower">TENANT</div>
{tenantSections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}> <button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label} {section.label}
</button> </button>
@@ -34,51 +63,164 @@ export default function AdminPage() {
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle>Administration</PageTitle> <PageTitle>Admin · {heading.title}</PageTitle>
<p>Tenant, user, role and operational administration surfaces.</p> <p>{heading.description}</p>
</div> </div>
<div className="button-row compact-actions"><Button disabled>Refresh</Button></div> <div className="button-row compact-actions"><Button disabled>Refresh</Button></div>
</div> </div>
{active === "overview" && <Overview />} {active === "overview" && <AdminOverview onSelect={setActive} />}
{active === "users" && <PlaceholderAdminTable title="Users" columns={["User", "Tenant admin", "Status", "Last activity"]} rows={["admin@example.local|Yes|Active|Development seed"]} action="Create user" />}
{active === "groups" && <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Status"]} rows={["Default administrators|1|All campaigns|Seed data"]} action="Create group" />}
{active === "roles" && <PlaceholderAdminTable title="Roles and permissions" columns={["Role", "Permissions", "Scope", "Status"]} rows={["Owner|All current permissions|Tenant|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Planned"]} action="Create role" />}
{active === "tenants" && <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Status"]} rows={["Default|default|1|Active"]} action="Create tenant" />}
{active === "api-keys" && <ApiKeys />}
{active === "audit" && <Audit />}
{active === "system" && <System />} {active === "system" && <System />}
{active === "tenants" && <Tenants />}
{active === "users" && <Users />}
{active === "groups" && <Groups />}
{active === "roles" && <Roles />}
{active === "api-keys" && <ApiKeys />}
{active === "mail-servers" && <MailServers />}
{active === "settings" && <TenantSettings />}
{active === "audit" && <Audit />}
</div> </div>
</section> </section>
</div> </div>
); );
} }
function Overview() { function AdminOverview({ onSelect }: { onSelect: (section: AdminSection) => void }) {
const adminLinks: { id: AdminSection; title: string; text: string }[] = [
{ id: "system", title: "System", text: "Health, workers, storage and diagnostics." },
{ id: "tenants", title: "Tenants", text: "Separated tenant spaces and instance-level tenant lifecycle." },
{ id: "users", title: "Users", text: "Tenant users, invitations and account state." },
{ id: "groups", title: "Groups", text: "Ownership, sharing and operational groups." },
{ id: "roles", title: "Roles", text: "Permissions, assignments and tenant roles." },
{ id: "api-keys", title: "API keys", text: "Automation keys and integration access." },
{ id: "mail-servers", title: "Mail servers", text: "Reusable tenant SMTP and IMAP profiles." },
{ id: "settings", title: "Tenant settings", text: "Defaults, limits and policies for the active tenant." },
{ id: "audit", title: "Audit", text: "Administrative audit trail and later filters." }
];
return ( return (
<> <>
<div className="metric-grid"> <div className="metric-grid">
<Card title="Users"><strong className="module-big-number"></strong><p className="muted">Backend list endpoint pending.</p></Card> <Card title="System scope"><strong className="module-big-number">2</strong><p className="muted">System and tenant registry areas.</p></Card>
<Card title="Groups"><strong className="module-big-number"></strong><p className="muted">Backend list endpoint pending.</p></Card> <Card title="Tenant scope"><strong className="module-big-number">7</strong><p className="muted">Tenant administration areas prepared.</p></Card>
<Card title="API keys"><strong className="module-big-number">Create-only</strong><p className="muted">Creation endpoint exists; listing/revocation UI pending.</p></Card> <Card title="Production state"><strong className="module-big-number">Mock</strong><p className="muted">Layouts are ready for backend wiring.</p></Card>
<Card title="Audit"><strong className="module-big-number">Available</strong><p className="muted">Audit search can be wired next.</p></Card> <Card title="Audit"><strong className="module-big-number">Planned</strong><p className="muted">Tenant and system filters will reuse audit endpoints.</p></Card>
</div> </div>
<Card title="Administration roadmap"> <Card title="Administration areas">
<div className="placeholder-stack"> <div className="admin-overview-grid">
<span>User and invitation management</span> {adminLinks.map((item) => (
<span>Group and campaign sharing permissions</span> <button key={item.id} className="admin-overview-link" onClick={() => onSelect(item.id)}>
<span>Role assignment and tenant administration</span> <strong>{item.title}</strong>
<span>API key lifecycle: create, label, revoke, rotate</span> <span>{item.text}</span>
</button>
))}
</div> </div>
</Card> </Card>
</> </>
); );
} }
function PlaceholderAdminTable({ title, columns, rows, action }: { title: string; columns: string[]; rows: string[]; action: string }) { function System() {
return (
<>
<div className="metric-grid">
<Card title="Backend"><strong className="module-big-number">Connected</strong><p className="muted">Connection status is currently checked in Settings Connection.</p></Card>
<Card title="Queue"><strong className="module-big-number">Planned</strong><p className="muted">Worker and queue telemetry will be wired to backend endpoints.</p></Card>
<Card title="Storage"><strong className="module-big-number">Planned</strong><p className="muted">Garage/S3 storage browser and capacity checks will appear here.</p></Card>
<Card title="Audit"><strong className="module-big-number">Available</strong><p className="muted">System-wide filters are mocked until the admin audit API is finalized.</p></Card>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="System health">
<dl className="detail-list compact-detail-list">
<div><dt>API</dt><dd>Use health endpoint</dd></div>
<div><dt>Database</dt><dd>Planned status check</dd></div>
<div><dt>Redis</dt><dd>Planned status check</dd></div>
<div><dt>Workers</dt><dd>Planned status check</dd></div>
</dl>
</Card>
<Card title="Operational actions">
<div className="button-row compact-actions stacked-actions">
<Button disabled>Run health check</Button>
<Button disabled>View worker status</Button>
<Button disabled>Export diagnostics</Button>
</div>
</Card>
</div>
</>
);
}
function Tenants() {
return <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Storage", "Status"]} rows={["Default|default|1|Local dev|Active"]} action="Create tenant" note="Tenant administration is system-wide. Backend list, create and suspension endpoints can be wired here later." />;
}
function Users() {
return <PlaceholderAdminTable title="Users" columns={["User", "Groups", "Roles", "Status", "Last activity"]} rows={["admin@example.local|Default administrators|Owner|Active|Development seed"]} action="Invite user" note="Tenant users should support invitations, activation state, group membership and role assignments." />;
}
function Groups() {
return <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Default role", "Status"]} rows={["Default administrators|1|All campaigns|Owner|Seed data"]} action="Create group" note="Groups should later become the main unit for campaign ownership, sharing and storage spaces." />;
}
function Roles() {
return <PlaceholderAdminTable title="Roles" columns={["Role", "Permissions", "Scope", "Assignable", "Status"]} rows={["Owner|All current permissions|Tenant|No|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Yes|Planned"]} action="Create role" note="Role definitions are mocked until permission discovery and assignment endpoints are available." />;
}
function ApiKeys() {
return (
<Card title="API keys" actions={<Button disabled>Create API key</Button>}>
<p className="muted">The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.</p>
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Name</th><th>Scope</th><th>Owner</th><th>Status</th><th>Last used</th></tr></thead>
<tbody><tr><td>Development key</td><td>Automation</td><td>Tenant</td><td><StatusBadge status="dev" /></td><td>Local only</td></tr></tbody>
</table>
</div>
</Card>
);
}
function MailServers() {
return <PlaceholderAdminTable title="Mail servers" columns={["Profile", "SMTP", "IMAP", "Default", "Status"]} rows={["Campaign-local settings|Configured per campaign|Optional per campaign|No|Current behavior", "Tenant default SMTP|Planned|Planned|Planned|Mock"]} action="Add mail server" note="Tenant mail server profiles can later prefill campaign Server settings while campaigns remain self-contained." />;
}
function TenantSettings() {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Tenant defaults">
<dl className="detail-list compact-detail-list">
<div><dt>Default sender policy</dt><dd>Campaign-local for now</dd></div>
<div><dt>Default attachment behavior</dt><dd>Ask before build/send</dd></div>
<div><dt>Default retention</dt><dd>Not configured</dd></div>
<div><dt>Default locale</dt><dd>Browser/application default</dd></div>
</dl>
</Card>
<Card title="Policy switches">
<div className="placeholder-stack">
<span>Require API key for queueing campaigns</span>
<span>Require validation before sending</span>
<span>Require audit lock before final send</span>
<span>Allow campaign-local SMTP overrides</span>
</div>
</Card>
</div>
);
}
function Audit() {
return (
<Card title="Administrative audit">
<p className="muted">Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.</p>
<div className="placeholder-stack"><span>User changes</span><span>Group and role changes</span><span>API key lifecycle</span><span>Tenant and mail server settings</span></div>
</Card>
);
}
function PlaceholderAdminTable({ title, columns, rows, action, note }: { title: string; columns: string[]; rows: string[]; action: string; note?: string }) {
return ( return (
<Card title={title} actions={<Button disabled>{action}</Button>}> <Card title={title} actions={<Button disabled>{action}</Button>}>
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div> <div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
{note && <p className="muted">{note}</p>}
<div className="app-table-wrap compact-table-wrap"> <div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table"> <table className="app-table module-table">
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead> <thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
@@ -90,42 +232,3 @@ function PlaceholderAdminTable({ title, columns, rows, action }: { title: string
</Card> </Card>
); );
} }
function ApiKeys() {
return (
<Card title="API keys" actions={<Button disabled>Create API key</Button>}>
<p className="muted">The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.</p>
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Name</th><th>Scope</th><th>Status</th><th>Last used</th></tr></thead>
<tbody><tr><td>Development key</td><td>Automation</td><td><StatusBadge status="dev" /></td><td>Local only</td></tr></tbody>
</table>
</div>
</Card>
);
}
function Audit() {
return (
<Card title="Administrative audit">
<p className="muted">Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.</p>
<div className="placeholder-stack"><span>User changes</span><span>Role changes</span><span>API key lifecycle</span><span>Tenant settings</span></div>
</Card>
);
}
function System() {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="System health">
<dl className="detail-list compact-detail-list">
<div><dt>Backend</dt><dd>Check via Settings Connection</dd></div>
<div><dt>Queue</dt><dd>Planned</dd></div>
<div><dt>Storage</dt><dd>Planned</dd></div>
<div><dt>Mail tests</dt><dd>Planned</dd></div>
</dl>
</Card>
<Card title="Operational actions"><div className="button-row compact-actions stacked-actions"><Button disabled>Run health check</Button><Button disabled>View worker status</Button><Button disabled>Export diagnostics</Button></div></Card>
</div>
);
}

View File

@@ -175,7 +175,11 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
<div className="related-link-grid"> <div className="related-link-grid">
<Link to="../recipients" className="related-link-card"> <Link to="../recipients" className="related-link-card">
<strong>Recipients</strong> <strong>Recipients</strong>
<span>Recipient rows, global recipient headers and recipient-specific header overrides.</span> <span>Recipient address profiles, global recipient headers and header overrides.</span>
</Link>
<Link to="../recipient-data" className="related-link-card">
<strong>Recipient data</strong>
<span>Maintain recipient-specific field values and attachment rules.</span>
</Link> </Link>
<Link to="../global-settings" className="related-link-card"> <Link to="../global-settings" className="related-link-card">
<strong>Global settings</strong> <strong>Global settings</strong>

View File

@@ -6,6 +6,7 @@ import CampaignDataPage from "./CampaignDataPage";
import CampaignFieldsPage from "./CampaignFieldsPage"; import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage"; import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage"; import RecipientDataPage from "./RecipientDataPage";
import RecipientDetailsPage from "./RecipientDetailsPage";
import TemplateDataPage from "./TemplateDataPage"; import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage"; import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage"; import MailSettingsPage from "./MailSettingsPage";
@@ -25,6 +26,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
"global-settings": "global-settings", "global-settings": "global-settings",
fields: "fields", fields: "fields",
recipients: "recipients", recipients: "recipients",
"recipient-data": "recipient-data",
template: "template", template: "template",
files: "files", files: "files",
"mail-settings": "mail-settings", "mail-settings": "mail-settings",
@@ -66,6 +68,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
<Route path="data" element={<CampaignDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="data" element={<CampaignDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} /> <Route path="attachments" element={<Navigate to="../files" replace />} />
@@ -100,6 +103,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
if (section === "global-settings" || section === "settings") return "global-settings"; if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields"; if (section === "fields") return "fields";
if (section === "recipients") return "recipients"; if (section === "recipients") return "recipients";
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
if (section === "template") return "template"; if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files"; if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings"; if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";

View File

@@ -9,11 +9,9 @@ import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput"; import EmailAddressInput from "../../components/email/EmailAddressInput";
import { autosaveCampaignVersion } from "../../api/campaigns"; import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import { import {
addressesFromValue, addressesFromValue,
collectCampaignAddressSuggestions, collectCampaignAddressSuggestions,
@@ -24,13 +22,16 @@ const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." }, { key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." }, { key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." } { key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
]; ] as const;
type FieldDefinition = { type RecipientAddressKey = "to" | "cc" | "bcc";
name: string;
type EntryAddressColumn = {
key: RecipientAddressKey | "from" | "reply_to";
label: string; label: string;
type: string; allowMultiple: boolean;
can_override: boolean; addLabel: string;
emptyText: string;
}; };
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
@@ -47,21 +48,20 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const entries = asRecord(displayDraft.entries); const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord); const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source); const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => {
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : attachmentBasePaths;
}, [attachmentBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]); const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const globalRecipientValues: Record<string, MailboxAddress[]> = { const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to), to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc), cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc) bcc: addressesFromValue(recipientsSection.bcc)
}; };
const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc"); const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc"); const columns: EntryAddressColumn[] = [{ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address." }];
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC." });
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC." });
if (getBool(recipientsSection, "allow_individual_from")) columns.push({ key: "from", label: "Sender", allowMultiple: false, addLabel: "Set sender", emptyText: "Uses default sender." });
if (getBool(recipientsSection, "allow_individual_reply_to")) columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: false, addLabel: "Set Reply-To", emptyText: "Uses global Reply-To." });
return columns;
}, [recipientsSection]);
useEffect(() => { useEffect(() => {
if (!version) return; if (!version) return;
@@ -87,10 +87,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true, active: true,
to: [], to: [],
cc: [],
bcc: [],
name: "", name: "",
email: "", email: "",
fields: {}, from: {},
attachments: [] reply_to: []
}; };
replaceInlineEntries([...inlineEntries, newEntry]); replaceInlineEntries([...inlineEntries, newEntry]);
} }
@@ -100,9 +102,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(nextEntries); replaceInlineEntries(nextEntries);
} }
function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) { function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
updateEntry(index, (entry) => { updateEntry(index, (entry) => {
const nextEntry = { ...entry, [key]: addresses }; if (key === "from") {
return { ...entry, from: addresses[0] ?? { name: "", email: "" } };
}
const nextEntry = { ...entry, [key]: key === "reply_to" ? addresses.slice(0, 1) : addresses };
if (key === "to") { if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" }; const address = addresses[0] ?? { name: "", email: "" };
return { return {
@@ -115,20 +120,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
}); });
} }
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function removeEntry(index: number) { function removeEntry(index: number) {
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index)); replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
} }
@@ -142,7 +133,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, { const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft, campaign_json: draft,
current_flow: "manual", current_flow: "manual",
current_step: "recipient-data", current_step: "recipients",
workflow_state: "editing", workflow_state: "editing",
is_complete: false is_complete: false
}); });
@@ -160,7 +151,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
useRegisterCampaignUnsavedChanges(dirty && !locked ? { useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved recipient changes", title: "Unsaved recipient changes",
message: "Recipients or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.", message: "Recipient addresses or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveRecipients("manual"), onSave: () => saveRecipients("manual"),
onDiscard: () => setDirty(false) onDiscard: () => setDirty(false)
} : null); } : null);
@@ -213,113 +204,53 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</Card> </Card>
<Card <Card
title="Recipients" title="Recipient profiles"
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]} actions={[<Button key="import" disabled>Import</Button>, <Button key="add" variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
> >
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>} {inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && ( {inlineEntries.length === 0 && Boolean(source.type) && (
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div> <div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
)} )}
{inlineEntries.length > 0 && ( {inlineEntries.length > 0 && (
<div className="app-table-wrap recipient-table-wrap"> <div className="app-table-wrap recipient-table-wrap">
<table className="app-table recipient-table recipient-editor-table"> <table className="app-table recipient-table recipient-address-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Recipients</th> {entryAddressColumns.map((column) => <th key={column.key}>{column.label}</th>)}
<th>Attachments</th> <th>Active</th>
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
<th aria-label="Actions"></th> <th aria-label="Actions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{inlineEntries.slice(0, 100).map((entry, index) => { {inlineEntries.slice(0, 100).map((entry, index) => (
const to = addressesFromValue(entry.to);
const cc = addressesFromValue(entry.cc);
const bcc = addressesFromValue(entry.bcc);
const fields = asRecord(entry.fields);
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<tr key={String(entry.id || index)}> <tr key={String(entry.id || index)}>
<td className="mono-small">{index + 1}</td> <td className="mono-small">{index + 1}</td>
<td className="recipient-cell"> {entryAddressColumns.map((column) => (
<div className="recipient-address-stack"> <td key={column.key}>
<div className="recipient-address-line">
<span className="recipient-address-label">To</span>
<EmailAddressInput <EmailAddressInput
value={to.length ? to : fallbackRecipientAddress(entry)} value={getEntryAddresses(entry, column.key)}
suggestions={addressSuggestions} suggestions={addressSuggestions}
allowMultiple={false} allowMultiple={column.allowMultiple}
compact compact
disabled={locked} disabled={locked}
addLabel={to.length ? "Replace" : "Add"} addLabel={column.addLabel}
emptyText="No recipient address." emptyText={column.emptyText}
onChange={(addresses) => updateEntryAddressList(index, "to", addresses)} onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
/> />
</div> </td>
{allowIndividualCc && ( ))}
<div className="recipient-address-line"> <td>
<span className="recipient-address-label">CC</span>
<EmailAddressInput
value={cc}
suggestions={addressSuggestions}
allowMultiple
compact
disabled={locked}
addLabel="Add CC"
emptyText="No CC."
onChange={(addresses) => updateEntryAddressList(index, "cc", addresses)}
/>
</div>
)}
{allowIndividualBcc && (
<div className="recipient-address-line">
<span className="recipient-address-label">BCC</span>
<EmailAddressInput
value={bcc}
suggestions={addressSuggestions}
allowMultiple
compact
disabled={locked}
addLabel="Add BCC"
emptyText="No BCC."
onChange={(addresses) => updateEntryAddressList(index, "bcc", addresses)}
/>
</div>
)}
<ToggleSwitch <ToggleSwitch
label="Active" label="Active"
checked={entry.active !== false} checked={entry.active !== false}
disabled={locked} disabled={locked}
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
/> />
</div>
</td> </td>
<td> <td className="table-action-cell"><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
disabled={locked}
basePaths={individualAttachmentBasePaths}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
</td>
{fieldDefinitions.map((field) => (
<td key={field.name}>
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
</td>
))}
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
</tr> </tr>
); ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -335,20 +266,14 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
); );
} }
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] { function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
return asArray(draft?.fields) if (key === "to") {
.map((field) => asRecord(field)) const explicit = addressesFromValue(entry.to);
.map((field) => ({ return explicit.length ? explicit : fallbackRecipientAddress(entry);
name: getText(field, "name") || getText(field, "id"), }
label: getText(field, "label"), if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
type: normalizeFieldType(getText(field, "type", "string")), if (key === "reply_to") return addressesFromValue(entry.reply_to).slice(0, 1);
can_override: getBool(field, "can_override", true) return addressesFromValue(entry[key]);
}))
.filter((field) => Boolean(field.name));
}
function normalizeFieldType(value: string): string {
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
} }
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] { function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
@@ -356,30 +281,6 @@ function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddres
return direct?.email ? [direct] : []; return direct?.email ? [direct] : [];
} }
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", true)
}];
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({ ...rule }));
}
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string { function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean)); const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred; if (!existing.has(preferred)) return preferred;

View File

@@ -0,0 +1,248 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import { addressesFromValue } from "../../utils/emailAddresses";
type FieldDefinition = {
name: string;
label: string;
type: string;
can_override: boolean;
};
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => {
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : attachmentBasePaths;
}, [attachmentBasePaths]);
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
async function saveRecipientData(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "recipient-data",
workflow_state: "editing",
is_complete: false
});
setDraft(ensureCampaignDraft(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved recipient data changes",
message: "Recipient field values or attachment rules have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveRecipientData("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipient data</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveRecipientData("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Recipient field values and attachments">
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && (
<div className="alert info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</div>
)}
{inlineEntries.length > 0 && (
<div className="app-table-wrap recipient-table-wrap recipient-data-table-wrap">
<table className="app-table recipient-table recipient-data-table">
<thead>
<tr>
<th>#</th>
<th>Recipient</th>
<th>Attachments</th>
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
</tr>
</thead>
<tbody>
{inlineEntries.slice(0, 100).map((entry, index) => {
const fields = asRecord(entry.fields);
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<tr key={String(entry.id || index)}>
<td className="mono-small recipient-index-cell">{index + 1}</td>
<td className="recipient-data-identity-cell">
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
</Link>
</td>
<td>
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
disabled={locked}
basePaths={individualAttachmentBasePaths}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
</td>
{fieldDefinitions.map((field) => (
<td key={field.name}>
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveRecipientData("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
return asArray(draft?.fields)
.map((field) => asRecord(field))
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
function normalizeFieldType(value: string): string {
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
}
function firstRecipientEmail(entry: Record<string, unknown>): string {
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
}
function extraRecipientCount(entry: Record<string, unknown>): number {
const count = addressesFromValue(entry.to).length;
return Math.max(0, count - 1);
}
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", true)
}];
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({ ...rule }));
}

View File

@@ -7,23 +7,23 @@ import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
type SettingsSection = "connection" | "mail-accounts" | "address-book" | "storage" | "retention" | "notifications"; type SettingsSection = "interface" | "workspace" | "local-connection" | "notifications";
const sections: { id: SettingsSection; label: string }[] = [ const sections: { id: SettingsSection; label: string }[] = [
{ id: "connection", label: "Connection" }, { id: "interface", label: "Interface" },
{ id: "mail-accounts", label: "Mail accounts" }, { id: "workspace", label: "Workspace" },
{ id: "address-book", label: "Address book" }, { id: "local-connection", label: "Local connection" },
{ id: "storage", label: "Storage" },
{ id: "retention", label: "Retention" },
{ id: "notifications", label: "Notifications" } { id: "notifications", label: "Notifications" }
]; ];
export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) { export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) {
const [active, setActive] = useState<SettingsSection>("connection"); const [active, setActive] = useState<SettingsSection>("interface");
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(""); const [testResult, setTestResult] = useState("");
const [rememberAddresses, setRememberAddresses] = useState(false); const [compactTables, setCompactTables] = useState(false);
const [addressBookSync, setAddressBookSync] = useState(false); const [showHelpHints, setShowHelpHints] = useState(true);
const [reduceMotion, setReduceMotion] = useState(false);
const [stickySections, setStickySections] = useState(true);
async function testConnection() { async function testConnection() {
setTesting(true); setTesting(true);
@@ -41,7 +41,7 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
return ( return (
<div className="workspace module-workspace"> <div className="workspace module-workspace">
<aside className="section-sidebar"> <aside className="section-sidebar">
<div className="section-title">SETTINGS</div> <div className="section-title">UI SETTINGS</div>
{sections.map((section) => ( {sections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}> <button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label} {section.label}
@@ -53,13 +53,78 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle>Settings</PageTitle> <PageTitle>Settings</PageTitle>
<p>Personal, local and tenant-level settings for the WebUI.</p> <p>Personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.</p>
</div> </div>
</div> </div>
{active === "connection" && ( {active === "interface" && (
<div className="dashboard-grid settings-dashboard-grid"> <div className="dashboard-grid settings-dashboard-grid">
<Card title="API connection / automation key"> <Card title="Interface preferences">
<div className="form-grid">
<ToggleSwitch
label="Compact tables"
help="Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally."
checked={compactTables}
onChange={setCompactTables}
/>
<ToggleSwitch
label="Show inline help hints"
help="Controls contextual UI help markers once persisted user preferences are available."
checked={showHelpHints}
onChange={setShowHelpHints}
/>
<ToggleSwitch
label="Reduce motion"
help="Prepared preference for users who prefer fewer animations."
checked={reduceMotion}
onChange={setReduceMotion}
/>
</div>
</Card>
<Card title="Theme and language">
<dl className="detail-list compact-detail-list">
<div><dt>Theme</dt><dd>System default for now</dd></div>
<div><dt>Accent color</dt><dd>Default brand accent</dd></div>
<div><dt>Language</dt><dd>Browser/application default</dd></div>
<div><dt>Density</dt><dd>{compactTables ? "Compact preview" : "Comfortable"}</dd></div>
</dl>
</Card>
</div>
)}
{active === "workspace" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Campaign workspace">
<div className="form-grid">
<ToggleSwitch
label="Sticky section sidebars"
help="Keeps campaign and admin section navigation in view while scrolling."
checked={stickySections}
onChange={setStickySections}
/>
<ToggleSwitch
label="Keep page shell visible while loading"
help="The current UI already keeps the section shell visible and overlays loading indicators during refresh."
checked
disabled
onChange={() => undefined}
/>
</div>
</Card>
<Card title="Editor behavior">
<div className="placeholder-stack">
<span>Manual save with unsaved-change guard</span>
<span>Readable chooser fields for file/path selection</span>
<span>Field-type-aware recipient data inputs</span>
<span>Template placeholder chips and preview overlays</span>
</div>
</Card>
</div>
)}
{active === "local-connection" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Local API connection">
<div className="form-grid"> <div className="form-grid">
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend."> <FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" /> <input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
@@ -79,106 +144,26 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div> <div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div> <div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
</dl> </dl>
<p className="muted small-note">Logout and tenant switching are handled in the title bar. More session-management controls can be added when backend endpoints exist.</p> <p className="muted small-note">Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.</p>
</Card> </Card>
</div> </div>
)} )}
{active === "mail-accounts" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Reusable mail accounts">
<p className="muted">Campaigns can currently keep SMTP/IMAP data in their working draft. Later, reusable encrypted mail accounts should live here and be shared per user, group or tenant.</p>
<div className="placeholder-stack">
<span>Personal SMTP/IMAP accounts</span>
<span>Group sender identities</span>
<span>Tenant-wide defaults</span>
</div>
</Card>
<Card title="Planned account actions">
<div className="button-row compact-actions stacked-actions">
<Button disabled>Add mail account</Button>
<Button disabled>Test selected account</Button>
<Button disabled>Share with group</Button>
</div>
</Card>
</div>
)}
{active === "address-book" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book and suggestions">
<div className="form-grid">
<ToggleSwitch
label="Remember previously used addresses"
help="Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only."
checked={rememberAddresses}
onChange={setRememberAddresses}
/>
<ToggleSwitch
label="External address-book sync"
help="Placeholder for CardDAV/Google/LDAP-style address sources. No external address book is connected yet."
checked={addressBookSync}
onChange={setAddressBookSync}
/>
</div>
</Card>
<Card title="Address sources">
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Source</th><th>Status</th><th>Scope</th></tr></thead>
<tbody>
<tr><td>Campaign-local addresses</td><td>Active</td><td>Current campaign</td></tr>
<tr><td>Previously used addresses</td><td>Planned</td><td>User</td></tr>
<tr><td>External address books</td><td>Planned</td><td>User / tenant</td></tr>
</tbody>
</table>
</div>
</Card>
</div>
)}
{active === "storage" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Storage backends">
<p className="muted">Campaign files are still represented by paths and draft JSON. This area is prepared for Garage/S3-backed tenant, group and campaign storage.</p>
<div className="placeholder-stack">
<span>Local development storage</span>
<span>Garage/S3 tenant bucket</span>
<span>Per-campaign file area</span>
</div>
</Card>
<Card title="Quotas and cleanup">
<dl className="detail-list compact-detail-list">
<div><dt>User quota</dt><dd>Planned</dd></div>
<div><dt>Campaign file quota</dt><dd>Planned</dd></div>
<div><dt>Orphan cleanup</dt><dd>Planned</dd></div>
</dl>
</Card>
</div>
)}
{active === "retention" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Draft and version retention">
<p className="muted">Old editable versions can later be pruned unless they have been sent, partially sent or locked for audit. This needs backend support before destructive actions are exposed.</p>
<div className="placeholder-stack">
<span>Prune unsent autosave drafts</span>
<span>Keep locked/sent versions</span>
<span>Export audit-safe campaign package</span>
</div>
</Card>
<Card title="Backup hooks"><p className="muted">Backup settings for campaign JSON, reports and audit data will be added once storage and retention backends are implemented.</p></Card>
</div>
)}
{active === "notifications" && ( {active === "notifications" && (
<div className="dashboard-grid settings-dashboard-grid"> <div className="dashboard-grid settings-dashboard-grid">
<Card title="Notification preferences"> <Card title="Notification preferences">
<p className="muted">Prepared for later background notifications: queue complete, send failures, IMAP append failures and report delivery.</p> <p className="muted">Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.</p>
<div className="placeholder-stack"> <div className="placeholder-stack">
<span>In-app notifications</span> <span>In-app completion notices</span>
<span>Email summary after campaign completion</span> <span>Email summary preferences</span>
<span>Failure alerts</span> <span>Failure and warning alerts</span>
</div>
</Card>
<Card title="Quiet UI mode">
<div className="placeholder-stack">
<span>Mute non-critical banners</span>
<span>Batch repetitive notices</span>
<span>Keep validation and send warnings visible</span>
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -29,6 +29,7 @@ const campaignRouteLabels: Record<string, string> = {
"global-settings": "Global settings", "global-settings": "Global settings",
fields: "Fields", fields: "Fields",
recipients: "Recipients", recipients: "Recipients",
"recipient-data": "Recipient data",
template: "Template", template: "Template",
files: "Attachments", files: "Attachments",
attachments: "Attachments", attachments: "Attachments",
@@ -50,6 +51,7 @@ const topLevelRouteLabels: Record<string, string> = {
dashboard: "Dashboard", dashboard: "Dashboard",
templates: "Templates", templates: "Templates",
files: "Files", files: "Files",
"address-book": "Address Book",
reports: "Reports", reports: "Reports",
settings: "Settings", settings: "Settings",
admin: "Admin", admin: "Admin",

View File

@@ -1,13 +1,13 @@
import { Form, FileText, Folder, LayoutDashboard, MailCheck, Settings, Shield, Users } from "lucide-react"; import { FileText, Folder, Form, LayoutDashboard, MailCheck, Settings, Shield, Users } from "lucide-react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
const items = [ const items = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/campaigns", label: "Campaigns", icon: MailCheck }, { to: "/campaigns", label: "Campaigns", icon: MailCheck },
{ to: "/templates", label: "Templates", icon: Form },
{ to: "/files", label: "Files", icon: Folder }, { to: "/files", label: "Files", icon: Folder },
{ to: "/address-book", label: "Address Book", icon: Users },
{ to: "/templates", label: "Templates", icon: Form },
{ to: "/reports", label: "Reports", icon: FileText }, { to: "/reports", label: "Reports", icon: FileText },
{ to: "/settings", label: "Settings", icon: Settings },
{ to: "/admin", label: "Admin", icon: Shield } { to: "/admin", label: "Admin", icon: Shield }
]; ];
@@ -26,7 +26,9 @@ export default function IconRail({ compact = false }: { compact?: boolean }) {
))} ))}
</nav> </nav>
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
<Users size={18} /> <NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
<Settings size={20} />
</NavLink>
</div> </div>
</> </>
)} )}

View File

@@ -5,6 +5,7 @@ const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "fields", label: "Fields" }, { id: "fields", label: "Fields" },
{ id: "files", label: "Attachments" }, { id: "files", label: "Attachments" },
{ id: "recipients", label: "Recipients" }, { id: "recipients", label: "Recipients" },
{ id: "recipient-data", label: "Recipient data" },
{ id: "template", label: "Template" }, { id: "template", label: "Template" },
]; ];

View File

@@ -918,3 +918,116 @@
cursor: not-allowed; cursor: not-allowed;
opacity: 1; opacity: 1;
} }
/* Recipient/profile split pages. */
.recipient-address-table th:nth-child(1),
.recipient-address-table td:nth-child(1) { width: 42px; }
.recipient-address-table th:last-child,
.recipient-address-table td:last-child { width: 123px; }
.recipient-address-table td { vertical-align: top; }
.recipient-address-table .email-address-input { min-width: 220px; }
.recipient-address-table .toggle-switch { white-space: nowrap; }
.recipient-data-table th:nth-child(1),
.recipient-data-table td:nth-child(1) { min-width: 240px; }
.recipient-data-table th:nth-child(2),
.recipient-data-table td:nth-child(2) { min-width: 192px; }
.recipient-data-table td { vertical-align: top; }
.recipient-data-identity-cell { white-space: normal !important; }
.recipient-data-identity {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 260px;
color: var(--ink);
text-decoration: none;
}
.recipient-data-identity:hover .recipient-data-address {
text-decoration: underline;
}
.recipient-data-number,
.recipient-extra-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
font-size: 11px;
font-weight: 800;
padding: 0 8px;
white-space: nowrap;
}
.recipient-data-address {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Recipient data split: align the identifier column with the recipient profile table. */
.recipient-data-table th:nth-child(1),
.recipient-data-table td:nth-child(1) { min-width: 72px; width: 72px; }
.recipient-data-table th:nth-child(2),
.recipient-data-table td:nth-child(2) { min-width: 240px; }
.recipient-data-table th:nth-child(3),
.recipient-data-table td:nth-child(3) { min-width: 192px; }
.recipient-index-cell { white-space: nowrap; }
/* Admin overview and address book mock module. */
.admin-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.admin-overview-link {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
text-align: left;
cursor: pointer;
color: var(--text);
}
.admin-overview-link:hover {
background: #fff;
border-color: var(--line-dark);
}
.admin-overview-link strong {
display: block;
color: var(--text-strong);
margin-bottom: 5px;
}
.admin-overview-link span {
display: block;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.module-workspace-single {
grid-template-columns: minmax(0, 1fr);
}
.address-book-scope-list {
display: grid;
gap: 10px;
}
.address-book-scope-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
}
.address-book-scope-card strong {
display: block;
color: var(--text-strong);
}
.address-book-scope-card p {
margin: 5px 0 0;
color: var(--muted);
}

View File

@@ -140,3 +140,12 @@
.page-heading.split { align-items: flex-start; flex-direction: column; } .page-heading.split { align-items: flex-start; flex-direction: column; }
.summary-grid, .detail-list div { grid-template-columns: 1fr; } .summary-grid, .detail-list div { grid-template-columns: 1fr; }
} }
/* Side rail: settings lives as the bottom utility entry. */
.icon-rail-bottom {
width: 100%;
padding: 12px 0 20px;
}
.icon-rail-bottom .icon-nav-item {
width: 100%;
}

View File

@@ -55,11 +55,12 @@ export type LoginResponse = AuthInfo & {
export type NavSection = export type NavSection =
| "dashboard" | "dashboard"
| "campaigns" | "campaigns"
| "templates"
| "files" | "files"
| "address-book"
| "templates"
| "reports" | "reports"
| "settings" | "admin"
| "admin"; | "settings";
export type CampaignWorkspaceSection = export type CampaignWorkspaceSection =
| "overview" | "overview"
@@ -67,6 +68,7 @@ export type CampaignWorkspaceSection =
| "global-settings" | "global-settings"
| "fields" | "fields"
| "recipients" | "recipients"
| "recipient-data"
| "template" | "template"
| "files" | "files"
| "mail-settings" | "mail-settings"

View File

@@ -12,6 +12,7 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
files: { id: "campaign.attachments", title: "Attachments" }, files: { id: "campaign.attachments", title: "Attachments" },
attachments: { id: "campaign.attachments", title: "Attachments" }, attachments: { id: "campaign.attachments", title: "Attachments" },
recipients: { id: "campaign.recipients", title: "Recipients" }, recipients: { id: "campaign.recipients", title: "Recipients" },
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
"mail-settings": { id: "campaign.server-settings", title: "Server settings" }, "mail-settings": { id: "campaign.server-settings", title: "Server settings" },
"server-settings": { id: "campaign.server-settings", title: "Server settings" }, "server-settings": { id: "campaign.server-settings", title: "Server settings" },
mail: { id: "campaign.server-settings", title: "Server settings" }, mail: { id: "campaign.server-settings", title: "Server settings" },
@@ -30,6 +31,7 @@ const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
campaigns: { id: "campaigns.list", title: "Campaigns" }, campaigns: { id: "campaigns.list", title: "Campaigns" },
templates: { id: "templates.list", title: "Templates" }, templates: { id: "templates.list", title: "Templates" },
files: { id: "files.list", title: "Files" }, files: { id: "files.list", title: "Files" },
"address-book": { id: "address-book.list", title: "Address Book" },
reports: { id: "reports.list", title: "Reports" }, reports: { id: "reports.list", title: "Reports" },
settings: { id: "app.settings", title: "Settings" }, settings: { id: "app.settings", title: "Settings" },
admin: { id: "app.admin", title: "Admin" } admin: { id: "app.admin", title: "Admin" }