diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..069df5b Binary files /dev/null and b/logo.png differ diff --git a/src/App.tsx b/src/App.tsx index 1d62b54..0091b10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import SettingsPage from "./features/settings/SettingsPage"; import AdminPage from "./features/admin/AdminPage"; import TemplatesPage from "./features/templates/TemplatesPage"; import FilesPage from "./features/files/FilesPage"; +import AddressBookPage from "./features/addressbook/AddressBookPage"; import PlaceholderPage from "./features/PlaceholderPage"; import PublicLandingPage from "./features/auth/PublicLandingPage"; @@ -104,6 +105,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/features/addressbook/AddressBookPage.tsx b/src/features/addressbook/AddressBookPage.tsx new file mode 100644 index 0000000..c905843 --- /dev/null +++ b/src/features/addressbook/AddressBookPage.tsx @@ -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 ( +
+
+
+
+
+ Address Book +

Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.

+
+
+ + +
+
+ +
+ {personalContacts.length}

Private contacts and remembered addresses.

+ {groupContacts.length}

Shared group address books and lists.

+ {tenantContacts.length}

Tenant directory and approved shared contacts.

+ Mock

CardDAV/LDAP/import connectors can be added later.

+
+ +
+ +
+ + + +
+
+ +
+ Choose addresses from personal, group or tenant source + Remember addresses used in campaigns after opt-in + Share selected contacts with a group + Use contacts in To, CC, BCC, sender and Reply-To fields +
+
+
+ + Manage sources}> +
+ + + + + + {allContacts.map((contact) => ( + + + + + + + + ))} + +
NameEmailScopeTagsStatus
{contact.name}{contact.email}{contact.source}{contact.tags}
+
+
+
+
+
+ ); +} + +function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) { + return ( +
+
+ {title} +

{description}

+
+ +
+ ); +} diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx index 9c48d89..b8b407b 100644 --- a/src/features/admin/AdminPage.tsx +++ b/src/features/admin/AdminPage.tsx @@ -4,27 +4,56 @@ import Button from "../../components/Button"; import PageTitle from "../../components/PageTitle"; 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 }[] = [ - { id: "overview", label: "Overview" }, +const adminSections: { id: AdminSection; label: string }[] = [ + { id: "system", label: "System" }, + { id: "tenants", label: "Tenants" } +]; + +const tenantSections: { id: AdminSection; label: string }[] = [ { id: "users", label: "Users" }, { id: "groups", label: "Groups" }, { id: "roles", label: "Roles" }, - { id: "tenants", label: "Tenants" }, { id: "api-keys", label: "API keys" }, - { id: "audit", label: "Audit" }, - { id: "system", label: "System" } + { id: "mail-servers", label: "Mail servers" }, + { id: "settings", label: "Settings" }, + { id: "audit", label: "Audit" } ]; +const sectionTitles: Record = { + 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() { const [active, setActive] = useState("overview"); + const heading = sectionTitles[active]; return (
); } -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 ( <>
-

Backend list endpoint pending.

-

Backend list endpoint pending.

- Create-only

Creation endpoint exists; listing/revocation UI pending.

- Available

Audit search can be wired next.

+ 2

System and tenant registry areas.

+ 7

Tenant administration areas prepared.

+ Mock

Layouts are ready for backend wiring.

+ Planned

Tenant and system filters will reuse audit endpoints.

- -
- User and invitation management - Group and campaign sharing permissions - Role assignment and tenant administration - API key lifecycle: create, label, revoke, rotate + +
+ {adminLinks.map((item) => ( + + ))}
); } -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
+
+
+ +
+ + + +
+
+
+ + ); +} + +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.

+
+ + + +
NameScopeOwnerStatusLast used
Development keyAutomationTenantLocal 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 changesGroup and role changesAPI key lifecycleTenant 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) => )} @@ -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.

-
-
{column}
- - -
NameScopeStatusLast used
Development keyAutomationLocal only
-
-
- ); -} - -function Audit() { - return ( - -

Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.

-
User changesRole changesAPI key lifecycleTenant settings
-
- ); -} - -function System() { - return ( -
- -
-
Backend
Check via Settings → Connection
-
Queue
Planned
-
Storage
Planned
-
Mail tests
Planned
-
-
-
-
- ); -} 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, ]} + title="Recipient profiles" + actions={[, ]} > - {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 && (
- +
- - - {fieldDefinitions.map((field) => )} + {entryAddressColumns.map((column) => )} + - {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 ( - - - - + + {entryAddressColumns.map((column) => ( + - {fieldDefinitions.map((field) => ( - - ))} - - - ); - })} + ))} + + + + ))}
#RecipientsAttachments{field.label || field.name}{column.label}Active
{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} + updateEntryAttachments(index, rules)} + addLabel={column.addLabel} + emptyText={column.emptyText} + onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)} /> - updateEntryField(index, field.name, value)} - /> -
+ updateEntry(index, (current) => ({ ...current, active: checked }))} + /> +
@@ -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}

+
+
+ + +
+
+ + {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 && ( +
+ + + + + + + {fieldDefinitions.map((field) => )} + + + + {inlineEntries.slice(0, 100).map((entry, index) => { + const fields = asRecord(entry.fields); + const attachments = normalizeAttachmentRules(entry.attachments); + return ( + + + + + {fieldDefinitions.map((field) => ( + + ))} + + ); + })} + +
#RecipientAttachments{field.label || field.name}
{index + 1} + + {firstRecipientEmail(entry) || "No To address"} + {extraRecipientCount(entry) > 0 && +{extraRecipientCount(entry)}} + + + updateEntryAttachments(index, rules)} + /> + + updateEntryField(index, field.name, value)} + /> +
+
+ )} +
+ +
+ +
+ +
+
+ ); +} + +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 (
-
-
- )} - - {active === "address-book" && ( -
- -
- - -
-
- -
- - - - - - - -
SourceStatusScope
Campaign-local addressesActiveCurrent campaign
Previously used addressesPlannedUser
External address booksPlannedUser / 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" }