-
User and invitation management
-
Group and campaign sharing permissions
-
Role assignment and tenant administration
-
API key lifecycle: create, label, revoke, rotate
+
+
+ {adminLinks.map((item) => (
+ onSelect(item.id)}>
+ {item.title}
+ {item.text}
+
+ ))}
>
);
}
-function PlaceholderAdminTable({ title, columns, rows, action }: { title: string; columns: string[]; rows: string[]; action: string }) {
+function System() {
+ return (
+ <>
+
+
Connected Connection status is currently checked in Settings → Connection.
+
Planned Worker and queue telemetry will be wired to backend endpoints.
+
Planned Garage/S3 storage browser and capacity checks will appear here.
+
Available System-wide filters are mocked until the admin audit API is finalized.
+
+
+
+
+
API Use health endpoint
+
Database Planned status check
+
Redis Planned status check
+
Workers Planned status check
+
+
+
+
+ Run health check
+ View worker status
+ Export diagnostics
+
+
+
+ >
+ );
+}
+
+function Tenants() {
+ return
;
+}
+
+function Users() {
+ return
;
+}
+
+function Groups() {
+ return
;
+}
+
+function Roles() {
+ return
;
+}
+
+function ApiKeys() {
+ return (
+
Create API key}>
+ The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.
+
+
+ Name Scope Owner Status Last used
+ Development key Automation Tenant Local only
+
+
+
+ );
+}
+
+function MailServers() {
+ return
;
+}
+
+function TenantSettings() {
+ return (
+
+
+
+
Default sender policy Campaign-local for now
+
Default attachment behavior Ask before build/send
+
Default retention Not configured
+
Default locale Browser/application default
+
+
+
+
+ Require API key for queueing campaigns
+ Require validation before sending
+ Require audit lock before final send
+ Allow campaign-local SMTP overrides
+
+
+
+ );
+}
+
+function Audit() {
+ return (
+
+ Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.
+ User changes Group and role changes API key lifecycle Tenant and mail server settings
+
+ );
+}
+
+function PlaceholderAdminTable({ title, columns, rows, action, note }: { title: string; columns: string[]; rows: string[]; action: string; note?: string }) {
return (
{action}}>
This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.
+ {note && {note}
}
{columns.map((column) => {column} )}
@@ -90,42 +232,3 @@ function PlaceholderAdminTable({ title, columns, rows, action }: { title: string
);
}
-
-function ApiKeys() {
- return (
- Create API key}>
- The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.
-
-
- Name Scope Status Last used
- Development key Automation Local only
-
-
-
- );
-}
-
-function Audit() {
- return (
-
- Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.
- User changes Role changes API key lifecycle Tenant settings
-
- );
-}
-
-function System() {
- return (
-
-
-
-
Backend Check via Settings → Connection
-
Queue Planned
-
Storage Planned
-
Mail tests Planned
-
-
-
Run health check View worker status Export diagnostics
-
- );
-}
diff --git a/src/features/campaigns/CampaignDataPage.tsx b/src/features/campaigns/CampaignDataPage.tsx
index 1e87768..23695dd 100644
--- a/src/features/campaigns/CampaignDataPage.tsx
+++ b/src/features/campaigns/CampaignDataPage.tsx
@@ -175,7 +175,11 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
Recipients
-
Recipient rows, global recipient headers and recipient-specific header overrides.
+
Recipient address profiles, global recipient headers and header overrides.
+
+
+
Recipient data
+
Maintain recipient-specific field values and attachment rules.
Global settings
diff --git a/src/features/campaigns/CampaignWorkspace.tsx b/src/features/campaigns/CampaignWorkspace.tsx
index cbc864e..26b581f 100644
--- a/src/features/campaigns/CampaignWorkspace.tsx
+++ b/src/features/campaigns/CampaignWorkspace.tsx
@@ -6,6 +6,7 @@ import CampaignDataPage from "./CampaignDataPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
+import RecipientDetailsPage from "./RecipientDetailsPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
@@ -25,6 +26,7 @@ const sectionPaths: Record
= {
"global-settings": "global-settings",
fields: "fields",
recipients: "recipients",
+ "recipient-data": "recipient-data",
template: "template",
files: "files",
"mail-settings": "mail-settings",
@@ -66,6 +68,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
} />
} />
} />
+ } />
} />
} />
} />
@@ -100,6 +103,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";
+ if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx
index de4f6b0..bed2b77 100644
--- a/src/features/campaigns/RecipientDataPage.tsx
+++ b/src/features/campaigns/RecipientDataPage.tsx
@@ -9,11 +9,9 @@ import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { autosaveCampaignVersion } from "../../api/campaigns";
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 { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
-import FieldValueInput from "./components/FieldValueInput";
-import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import {
addressesFromValue,
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: "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." }
-];
+] as const;
-type FieldDefinition = {
- name: string;
+type RecipientAddressKey = "to" | "cc" | "bcc";
+
+type EntryAddressColumn = {
+ key: RecipientAddressKey | "from" | "reply_to";
label: string;
- type: string;
- can_override: boolean;
+ allowMultiple: boolean;
+ addLabel: string;
+ emptyText: 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 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]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const globalRecipientValues: Record = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
- const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc");
- const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc");
+ const entryAddressColumns = useMemo(() => {
+ 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(() => {
if (!version) return;
@@ -87,10 +87,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [],
+ cc: [],
+ bcc: [],
name: "",
email: "",
- fields: {},
- attachments: []
+ from: {},
+ reply_to: []
};
replaceInlineEntries([...inlineEntries, newEntry]);
}
@@ -100,9 +102,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(nextEntries);
}
- function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) {
+ function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
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") {
const address = addresses[0] ?? { name: "", email: "" };
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) {
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, {
campaign_json: draft,
current_flow: "manual",
- current_step: "recipient-data",
+ current_step: "recipients",
workflow_state: "editing",
is_complete: false
});
@@ -160,7 +151,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
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"),
onDiscard: () => setDirty(false)
} : null);
@@ -213,113 +204,53 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
Import, Add recipient ]}
+ title="Recipient profiles"
+ actions={[Import , Add recipient ]}
>
- {inlineEntries.length === 0 && !source.type && No recipient data is stored in the current version yet.
}
+ {inlineEntries.length === 0 && !source.type && No recipient profiles are stored in the current version yet.
}
{inlineEntries.length === 0 && Boolean(source.type) && (
This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.
)}
{inlineEntries.length > 0 && (
-
+
#
- Recipients
- Attachments
- {fieldDefinitions.map((field) => {field.label || field.name} )}
+ {entryAddressColumns.map((column) => {column.label} )}
+ Active
- {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 (
-
- {index + 1}
-
-
-
- To
- updateEntryAddressList(index, "to", addresses)}
- />
-
- {allowIndividualCc && (
-
- CC
- updateEntryAddressList(index, "cc", addresses)}
- />
-
- )}
- {allowIndividualBcc && (
-
- BCC
- updateEntryAddressList(index, "bcc", addresses)}
- />
-
- )}
-
updateEntry(index, (current) => ({ ...current, active: checked }))}
- />
-
-
-
- (
+
+ {index + 1}
+ {entryAddressColumns.map((column) => (
+
+ updateEntryAttachments(index, rules)}
+ addLabel={column.addLabel}
+ emptyText={column.emptyText}
+ onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
/>
- {fieldDefinitions.map((field) => (
-
- updateEntryField(index, field.name, value)}
- />
-
- ))}
- removeEntry(index)} disabled={locked}>Remove
-
- );
- })}
+ ))}
+
+ updateEntry(index, (current) => ({ ...current, active: checked }))}
+ />
+
+ removeEntry(index)} disabled={locked}>Remove
+
+ ))}
@@ -335,20 +266,14 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
);
}
-function getDraftFields(draft: Record | 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 getEntryAddresses(entry: Record, key: EntryAddressColumn["key"]): MailboxAddress[] {
+ if (key === "to") {
+ const explicit = addressesFromValue(entry.to);
+ return explicit.length ? explicit : fallbackRecipientAddress(entry);
+ }
+ if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
+ if (key === "reply_to") return addressesFromValue(entry.reply_to).slice(0, 1);
+ return addressesFromValue(entry[key]);
}
function fallbackRecipientAddress(entry: Record): MailboxAddress[] {
@@ -356,30 +281,6 @@ function fallbackRecipientAddress(entry: Record): MailboxAddres
return direct?.email ? [direct] : [];
}
-function normalizeAttachmentBasePaths(value: unknown, attachments: Record): 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[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred;
diff --git a/src/features/campaigns/RecipientDetailsPage.tsx b/src/features/campaigns/RecipientDetailsPage.tsx
new file mode 100644
index 0000000..b9b7060
--- /dev/null
+++ b/src/features/campaigns/RecipientDetailsPage.tsx
@@ -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 | 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[]) {
+ patch(["entries", "inline"], nextEntries);
+ }
+
+ function updateEntry(index: number, updater: (entry: Record) => Record) {
+ 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 {
+ 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 (
+
+
+
+
Recipient data
+
Version {version ? `#${version.version_number}` : "—"} · {saveState}
+
+
+ Reload
+ saveRecipientData("manual")} disabled={!dirty || locked || !draft}>Save
+
+
+
+ {error &&
{error}
}
+ {localError &&
{localError}
}
+ {locked &&
This version is read-only. {versionLockReason(version)}
}
+
+
+ <>
+
+ {inlineEntries.length === 0 && !source.type && No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.
}
+ {inlineEntries.length === 0 && Boolean(source.type) && (
+ This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.
+ )}
+ {inlineEntries.length > 0 && (
+
+
+
+
+ #
+ Recipient
+ Attachments
+ {fieldDefinitions.map((field) => {field.label || field.name} )}
+
+
+
+ {inlineEntries.slice(0, 100).map((entry, index) => {
+ const fields = asRecord(entry.fields);
+ const attachments = normalizeAttachmentRules(entry.attachments);
+ return (
+
+ {index + 1}
+
+
+ {firstRecipientEmail(entry) || "No To address"}
+ {extraRecipientCount(entry) > 0 && +{extraRecipientCount(entry)} }
+
+
+
+ updateEntryAttachments(index, rules)}
+ />
+
+ {fieldDefinitions.map((field) => (
+
+ updateEntryField(index, field.name, value)}
+ />
+
+ ))}
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+ saveRecipientData("manual")} disabled={!dirty || locked}>Save
+
+ >
+
+
+ );
+}
+
+function getDraftFields(draft: Record | 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 {
+ return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
+}
+
+function extraRecipientCount(entry: Record): number {
+ const count = addressesFromValue(entry.to).length;
+ return Math.max(0, count - 1);
+}
+
+function normalizeAttachmentBasePaths(value: unknown, attachments: Record): 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 }));
+}
diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx
index e5f8a78..f9cb076 100644
--- a/src/features/settings/SettingsPage.tsx
+++ b/src/features/settings/SettingsPage.tsx
@@ -7,23 +7,23 @@ import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
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 }[] = [
- { id: "connection", label: "Connection" },
- { id: "mail-accounts", label: "Mail accounts" },
- { id: "address-book", label: "Address book" },
- { id: "storage", label: "Storage" },
- { id: "retention", label: "Retention" },
+ { id: "interface", label: "Interface" },
+ { id: "workspace", label: "Workspace" },
+ { id: "local-connection", label: "Local connection" },
{ id: "notifications", label: "Notifications" }
];
export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) {
- const [active, setActive] = useState("connection");
+ const [active, setActive] = useState("interface");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
- const [rememberAddresses, setRememberAddresses] = useState(false);
- const [addressBookSync, setAddressBookSync] = useState(false);
+ const [compactTables, setCompactTables] = useState(false);
+ const [showHelpHints, setShowHelpHints] = useState(true);
+ const [reduceMotion, setReduceMotion] = useState(false);
+ const [stickySections, setStickySections] = useState(true);
async function testConnection() {
setTesting(true);
@@ -41,7 +41,7 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
return (
- SETTINGS
+ UI SETTINGS
{sections.map((section) => (
setActive(section.id)}>
{section.label}
@@ -53,13 +53,78 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
Settings
-
Personal, local and tenant-level settings for the WebUI.
+
Personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.
- {active === "connection" && (
+ {active === "interface" && (
-
+
+
+
+
+
+
+
+
+
+
Theme System default for now
+
Accent color Default brand accent
+
Language Browser/application default
+
Density {compactTables ? "Compact preview" : "Comfortable"}
+
+
+
+ )}
+
+ {active === "workspace" && (
+
+
+
+
+ undefined}
+ />
+
+
+
+
+ Manual save with unsaved-change guard
+ Readable chooser fields for file/path selection
+ Field-type-aware recipient data inputs
+ Template placeholder chips and preview overlays
+
+
+
+ )}
+
+ {active === "local-connection" && (
+
+
onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
@@ -79,106 +144,26 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
Automation key {settings.apiKey ? "Configured" : "Not configured"}
Backend mode {settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}
- Logout and tenant switching are handled in the title bar. More session-management controls can be added when backend endpoints exist.
+ Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.
)}
- {active === "mail-accounts" && (
-
-
- 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.
-
- Personal SMTP/IMAP accounts
- Group sender identities
- Tenant-wide defaults
-
-
-
-
- Add mail account
- Test selected account
- Share with group…
-
-
-
- )}
-
- {active === "address-book" && (
-
-
-
-
-
-
-
-
-
-
- Source Status Scope
-
- Campaign-local addresses Active Current campaign
- Previously used addresses Planned User
- External address books Planned User / tenant
-
-
-
-
-
- )}
-
- {active === "storage" && (
-
-
- Campaign files are still represented by paths and draft JSON. This area is prepared for Garage/S3-backed tenant, group and campaign storage.
-
- Local development storage
- Garage/S3 tenant bucket
- Per-campaign file area
-
-
-
-
-
User quota Planned
-
Campaign file quota Planned
-
Orphan cleanup Planned
-
-
-
- )}
-
- {active === "retention" && (
-
-
- 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.
-
- Prune unsent autosave drafts
- Keep locked/sent versions
- Export audit-safe campaign package
-
-
-
Backup settings for campaign JSON, reports and audit data will be added once storage and retention backends are implemented.
-
- )}
-
{active === "notifications" && (
- Prepared for later background notifications: queue complete, send failures, IMAP append failures and report delivery.
+ Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.
- In-app notifications
- Email summary after campaign completion
- Failure alerts
+ In-app completion notices
+ Email summary preferences
+ Failure and warning alerts
+
+
+
+
+ Mute non-critical banners
+ Batch repetitive notices
+ Keep validation and send warnings visible
diff --git a/src/layout/BreadcrumbBar.tsx b/src/layout/BreadcrumbBar.tsx
index 0bc1167..88c7ad5 100644
--- a/src/layout/BreadcrumbBar.tsx
+++ b/src/layout/BreadcrumbBar.tsx
@@ -29,6 +29,7 @@ const campaignRouteLabels: Record = {
"global-settings": "Global settings",
fields: "Fields",
recipients: "Recipients",
+ "recipient-data": "Recipient data",
template: "Template",
files: "Attachments",
attachments: "Attachments",
@@ -50,6 +51,7 @@ const topLevelRouteLabels: Record = {
dashboard: "Dashboard",
templates: "Templates",
files: "Files",
+ "address-book": "Address Book",
reports: "Reports",
settings: "Settings",
admin: "Admin",
diff --git a/src/layout/IconRail.tsx b/src/layout/IconRail.tsx
index e860eac..d0ed6fb 100644
--- a/src/layout/IconRail.tsx
+++ b/src/layout/IconRail.tsx
@@ -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";
const items = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/campaigns", label: "Campaigns", icon: MailCheck },
- { to: "/templates", label: "Templates", icon: Form },
{ 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: "/settings", label: "Settings", icon: Settings },
{ to: "/admin", label: "Admin", icon: Shield }
];
@@ -26,7 +26,9 @@ export default function IconRail({ compact = false }: { compact?: boolean }) {
))}
-
+ `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
+
+
>
)}
diff --git a/src/layout/SectionSidebar.tsx b/src/layout/SectionSidebar.tsx
index 761cf18..87116ef 100644
--- a/src/layout/SectionSidebar.tsx
+++ b/src/layout/SectionSidebar.tsx
@@ -5,6 +5,7 @@ const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "fields", label: "Fields" },
{ id: "files", label: "Attachments" },
{ id: "recipients", label: "Recipients" },
+ { id: "recipient-data", label: "Recipient data" },
{ id: "template", label: "Template" },
];
diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css
index d102d1d..ff6815f 100644
--- a/src/styles/campaign-workspace.css
+++ b/src/styles/campaign-workspace.css
@@ -918,3 +918,116 @@
cursor: not-allowed;
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);
+}
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 1f5f584..dd2ecb5 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -140,3 +140,12 @@
.page-heading.split { align-items: flex-start; flex-direction: column; }
.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%;
+}
diff --git a/src/types.ts b/src/types.ts
index e5c3ed1..4356b16 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -55,11 +55,12 @@ export type LoginResponse = AuthInfo & {
export type NavSection =
| "dashboard"
| "campaigns"
- | "templates"
| "files"
+ | "address-book"
+ | "templates"
| "reports"
- | "settings"
- | "admin";
+ | "admin"
+ | "settings";
export type CampaignWorkspaceSection =
| "overview"
@@ -67,6 +68,7 @@ export type CampaignWorkspaceSection =
| "global-settings"
| "fields"
| "recipients"
+ | "recipient-data"
| "template"
| "files"
| "mail-settings"
diff --git a/src/utils/helpContext.ts b/src/utils/helpContext.ts
index 43ece80..6e38a3f 100644
--- a/src/utils/helpContext.ts
+++ b/src/utils/helpContext.ts
@@ -12,6 +12,7 @@ const campaignSectionContexts: Record> = {
files: { id: "campaign.attachments", title: "Attachments" },
attachments: { id: "campaign.attachments", title: "Attachments" },
recipients: { id: "campaign.recipients", title: "Recipients" },
+ "recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
"mail-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" },
@@ -30,6 +31,7 @@ const topLevelContexts: Record> = {
campaigns: { id: "campaigns.list", title: "Campaigns" },
templates: { id: "templates.list", title: "Templates" },
files: { id: "files.list", title: "Files" },
+ "address-book": { id: "address-book.list", title: "Address Book" },
reports: { id: "reports.list", title: "Reports" },
settings: { id: "app.settings", title: "Settings" },
admin: { id: "app.admin", title: "Admin" }