Massive multi-area push driven by docs/admin-ux-backlog.md. Every byte
path now goes through getStorageBackend() so signed EOIs, contracts,
brochures, berth PDFs, files, avatars, branding logos, and DB backups
all work identically on S3 and filesystem backends.
USER SETTINGS (rebuild)
- Country + Timezone selectors with cross-defaulting
- Browser-detected timezone banner ("Looks like you're in Europe/Paris…")
- Email change with verification flow (user_email_changes table,
OLD-address cancel link + NEW-address confirm link)
+ EMAIL_CHANGE_INSTANT=true dev shortcut
- Password reset triggered via better-auth requestPasswordReset
- Profile photo upload + crop (square 256×256) via shared
<ImageCropperDialog> + /api/v1/me/avatar
BRANDING
- Shared <ImageCropperDialog> using react-easy-crop
- Logo upload + crop in /admin/branding (writes via
/api/v1/admin/settings/image -> storage backend)
- Email header/footer HTML defaults injectable via "Insert default"
- SettingsFormCard new field types: timezone (combobox), image-upload
STORAGE ADMIN OVERHAUL
- S3 config form FIRST, swap action SECOND
- Test connection before any switch
- Two-button switch: "Switch + migrate" vs "Switch only" with
warning modals
- runMigration() honours skipMigration flag
- /api/ready + system-monitoring health check use the active
storage backend instead of always probing MinIO
- Filesystem backend already had full feature parity — verified
BACKUP MANAGEMENT (real)
- New backup_jobs table (id / status / trigger / size / storage_path)
- runBackup() service spawns pg_dump --format=custom, streams to
active storage backend via getStorageBackend().put()
- /admin/backup page: trigger, history, download .dump for restore
- Super-admin gated
AI ADMIN PANEL
- /admin/ai consolidates master switch + monthly token cap +
provider credentials
- Per-feature settings (OCR, berth-PDF parser, recommender)
linked from the same page
ONBOARDING WIZARD
- /admin/onboarding now real with auto-checked steps
- Reads each setting key + lists endpoint (roles/users/tags) to
decide completion
- Manual checkboxes for steps without an auto-detect signal
- Progress bar + Mark done/Mark incomplete buttons
- State persisted in system_settings.onboarding_manual_status
RESIDENTIAL PARITY (full)
- New residential_client_notes + residential_interest_notes tables
(mirror marina-side shape)
- Polymorphic notes.service.ts extended (verifyParent, listForEntity,
create, update, delete) for residential_clients/_interests
- <NotesList> component accepts the new entity types
- 4 new note endpoints (GET/POST/PATCH/DELETE for clients + interests)
- 2 new activity endpoints (residential clients + interests)
- residential-client-tabs.tsx + residential-interest-tabs.tsx use
DetailLayout (Overview / Interests / Notes / Activity)
- residential-client-detail-header.tsx mirrors marina-side strip
- useBreadcrumbHint wired into both detail components
- Configurable Assigned-to dropdown (residential_interests.view perm)
CONFIGURABLE RESIDENTIAL STAGES
- residential-stages.service.ts with list / save / orphan-check
- /api/v1/residential/stages GET/PUT
- /admin/residential-stages admin UI with reassign-on-remove modal
- Validators relaxed from z.enum to z.string
DOCUMENSO PHASE 1
- Schema: document_signers.invited_at / opened_at /
last_reminder_sent_at / signing_token (+ idx_ds_signing_token)
- Schema: documents.completion_cc_emails (text[]) +
auto_reminder_interval_days (int)
- transformSigningUrl() now maps SignerRole -> URL segment via
ROLE_TO_URL_SEGMENT (approver->cc, witness->witness) — fixes
Risk #5 where approver invites landed on /sign/error
- POST /api/v1/documents/[id]/send-invitation with auto-pick of
next pending signer
- Per-port settings: documenso_developer_label / _approver_label
+ documenso_developer_user_id / _approver_user_id (Phase 7
Project Director RBAC binding fields)
ADMIN UX RAPID-FIRE
- Sidebar collapse removed (always-expanded design)
- Audit log: input sizes (h-9), date pickers w-44, action cell
sub-label so single-row entries aren't blank
- Sales email config: token list <details> + tooltips on
threshold + body fields
- Custom Settings card: long-form description
- Reminder digest timezone uses TimezoneCombobox
- Port form: currency dropdown (10 common currencies) + timezone
combobox + brand color picker
- Permissions count badge opens modal with granted/denied per
resource
- Role names display-normalized via prettifyRoleName
- Tag form: native input type=color
- Custom Fields page: amber heads-up about non-integration
- Settings manager: select field type + fallthrough_policy as dropdown
- Storage admin S3 fields ship as proper password + boolean
LIST PAGES
- Residential client list: clickable email/phone (mailto/tel/wa.me)
- Residential interests + Documents Hub search inputs sized h-9
CURRENCY API
- scripts/test-currency-api.ts verifies live Frankfurter fetch
-> DB upsert -> getRate -> convert. Inverse-rate drift <=0.001
TESTS
- 1185/1185 vitest passing
- tsc clean
- eslint 0 errors (16 pre-existing warnings)
Note: WEBSITE_INTAKE_SECRET added to .env.example but committed
separately due to pre-commit hook policy on .env* files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Sales send-from config card (Phase 7 §5.9).
|
|
*
|
|
* Lives on /[portSlug]/admin/email below the existing noreply transport
|
|
* card. Lets per-port admins configure the SMTP/IMAP creds + body templates
|
|
* that the document-sends flow uses.
|
|
*
|
|
* §14.10 enforcement: passwords are write-only. The GET endpoint never
|
|
* returns the decrypted value — only a `*PassIsSet` boolean. Empty
|
|
* password input means "leave unchanged"; explicit `null` sent over the
|
|
* wire means "clear".
|
|
*/
|
|
import { useEffect, useState } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
interface SalesConfigResponse {
|
|
data: {
|
|
email: {
|
|
fromAddress: string;
|
|
smtpHost: string | null;
|
|
smtpPort: number;
|
|
smtpSecure: boolean;
|
|
smtpUser: string | null;
|
|
authMethod: string;
|
|
smtpPassIsSet: boolean;
|
|
isUsable: boolean;
|
|
};
|
|
imap: {
|
|
imapHost: string | null;
|
|
imapPort: number;
|
|
imapUser: string | null;
|
|
imapPassIsSet: boolean;
|
|
isUsable: boolean;
|
|
};
|
|
content: {
|
|
noreplyFromAddress: string;
|
|
templateBerthPdfBody: string;
|
|
templateBrochureBody: string;
|
|
brochureMaxUploadMb: number;
|
|
emailAttachThresholdMb: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface FormState {
|
|
fromAddress: string;
|
|
smtpHost: string;
|
|
smtpPort: number | '';
|
|
smtpSecure: boolean;
|
|
smtpUser: string;
|
|
smtpPass: string; // empty = unchanged
|
|
imapHost: string;
|
|
imapPort: number | '';
|
|
imapUser: string;
|
|
imapPass: string;
|
|
noreplyFromAddress: string;
|
|
templateBerthPdfBody: string;
|
|
templateBrochureBody: string;
|
|
brochureMaxUploadMb: number | '';
|
|
emailAttachThresholdMb: number | '';
|
|
}
|
|
|
|
const EMPTY_FORM: FormState = {
|
|
fromAddress: '',
|
|
smtpHost: '',
|
|
smtpPort: 587,
|
|
smtpSecure: false,
|
|
smtpUser: '',
|
|
smtpPass: '',
|
|
imapHost: '',
|
|
imapPort: 993,
|
|
imapUser: '',
|
|
imapPass: '',
|
|
noreplyFromAddress: '',
|
|
templateBerthPdfBody: '',
|
|
templateBrochureBody: '',
|
|
brochureMaxUploadMb: 50,
|
|
emailAttachThresholdMb: 15,
|
|
};
|
|
|
|
export function SalesEmailConfigCard() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [smtpPassSet, setSmtpPassSet] = useState(false);
|
|
const [imapPassSet, setImapPassSet] = useState(false);
|
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
|
|
|
async function refresh() {
|
|
setLoading(true);
|
|
try {
|
|
const res: SalesConfigResponse = await apiFetch('/api/v1/admin/email/sales-config');
|
|
setSmtpPassSet(res.data.email.smtpPassIsSet);
|
|
setImapPassSet(res.data.imap.imapPassIsSet);
|
|
setForm({
|
|
fromAddress: res.data.email.fromAddress,
|
|
smtpHost: res.data.email.smtpHost ?? '',
|
|
smtpPort: res.data.email.smtpPort,
|
|
smtpSecure: res.data.email.smtpSecure,
|
|
smtpUser: res.data.email.smtpUser ?? '',
|
|
smtpPass: '',
|
|
imapHost: res.data.imap.imapHost ?? '',
|
|
imapPort: res.data.imap.imapPort,
|
|
imapUser: res.data.imap.imapUser ?? '',
|
|
imapPass: '',
|
|
noreplyFromAddress: res.data.content.noreplyFromAddress,
|
|
templateBerthPdfBody: res.data.content.templateBerthPdfBody,
|
|
templateBrochureBody: res.data.content.templateBrochureBody,
|
|
brochureMaxUploadMb: res.data.content.brochureMaxUploadMb,
|
|
emailAttachThresholdMb: res.data.content.emailAttachThresholdMb,
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, []);
|
|
|
|
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
fromAddress: form.fromAddress || null,
|
|
smtpHost: form.smtpHost || null,
|
|
smtpPort: typeof form.smtpPort === 'number' ? form.smtpPort : null,
|
|
smtpSecure: form.smtpSecure,
|
|
smtpUser: form.smtpUser || null,
|
|
imapHost: form.imapHost || null,
|
|
imapPort: typeof form.imapPort === 'number' ? form.imapPort : null,
|
|
imapUser: form.imapUser || null,
|
|
noreplyFromAddress: form.noreplyFromAddress || null,
|
|
templateBerthPdfBody: form.templateBerthPdfBody,
|
|
templateBrochureBody: form.templateBrochureBody,
|
|
brochureMaxUploadMb:
|
|
typeof form.brochureMaxUploadMb === 'number' ? form.brochureMaxUploadMb : null,
|
|
emailAttachThresholdMb:
|
|
typeof form.emailAttachThresholdMb === 'number' ? form.emailAttachThresholdMb : null,
|
|
};
|
|
// Only send password fields when the user actually typed something.
|
|
if (form.smtpPass !== '') payload.smtpPass = form.smtpPass;
|
|
if (form.imapPass !== '') payload.imapPass = form.imapPass;
|
|
|
|
await apiFetch('/api/v1/admin/email/sales-config', { method: 'PATCH', body: payload });
|
|
toast.success('Sales email settings saved');
|
|
await refresh();
|
|
} catch (err) {
|
|
toastError(err);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading sales email config…
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sales send-from account</CardTitle>
|
|
<CardDescription>
|
|
SMTP credentials for human-touch outbound (brochures + per-berth PDFs). IMAP creds
|
|
enable the bounce monitor — leave blank to disable bounce-rejection banners. Passwords
|
|
are encrypted at rest and never returned by the API.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<Field label="From address" id="sef-from">
|
|
<Input
|
|
id="sef-from"
|
|
type="email"
|
|
value={form.fromAddress}
|
|
onChange={(e) => update('fromAddress', e.target.value)}
|
|
placeholder="sales@portnimara.com"
|
|
/>
|
|
</Field>
|
|
<Field label="SMTP host" id="sef-smtp-host">
|
|
<Input
|
|
id="sef-smtp-host"
|
|
value={form.smtpHost}
|
|
onChange={(e) => update('smtpHost', e.target.value)}
|
|
placeholder="smtp.gmail.com"
|
|
/>
|
|
</Field>
|
|
<Field label="SMTP port" id="sef-smtp-port">
|
|
<Input
|
|
id="sef-smtp-port"
|
|
type="number"
|
|
value={form.smtpPort}
|
|
onChange={(e) =>
|
|
update('smtpPort', e.target.value === '' ? '' : Number(e.target.value))
|
|
}
|
|
/>
|
|
</Field>
|
|
<div className="flex items-end justify-between gap-2">
|
|
<Label htmlFor="sef-smtp-secure" className="text-sm">
|
|
SSL (true=465, false=STARTTLS on 587)
|
|
</Label>
|
|
<Switch
|
|
id="sef-smtp-secure"
|
|
checked={form.smtpSecure}
|
|
onCheckedChange={(v) => update('smtpSecure', v)}
|
|
/>
|
|
</div>
|
|
<Field label="SMTP username" id="sef-smtp-user">
|
|
<Input
|
|
id="sef-smtp-user"
|
|
value={form.smtpUser}
|
|
onChange={(e) => update('smtpUser', e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={`SMTP password ${smtpPassSet ? '(stored — leave blank to keep)' : ''}`}
|
|
id="sef-smtp-pass"
|
|
>
|
|
<Input
|
|
id="sef-smtp-pass"
|
|
type="password"
|
|
value={form.smtpPass}
|
|
onChange={(e) => update('smtpPass', e.target.value)}
|
|
placeholder={smtpPassSet ? '••••••••' : 'app password'}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Bounce monitor (IMAP)</CardTitle>
|
|
<CardDescription>
|
|
Required only for the async-bounce banner (§14.9). Same provider account as SMTP in most
|
|
setups.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 md:grid-cols-2">
|
|
<Field label="IMAP host" id="sef-imap-host">
|
|
<Input
|
|
id="sef-imap-host"
|
|
value={form.imapHost}
|
|
onChange={(e) => update('imapHost', e.target.value)}
|
|
placeholder="imap.gmail.com"
|
|
/>
|
|
</Field>
|
|
<Field label="IMAP port" id="sef-imap-port">
|
|
<Input
|
|
id="sef-imap-port"
|
|
type="number"
|
|
value={form.imapPort}
|
|
onChange={(e) =>
|
|
update('imapPort', e.target.value === '' ? '' : Number(e.target.value))
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="IMAP username" id="sef-imap-user">
|
|
<Input
|
|
id="sef-imap-user"
|
|
value={form.imapUser}
|
|
onChange={(e) => update('imapUser', e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={`IMAP password ${imapPassSet ? '(stored — leave blank to keep)' : ''}`}
|
|
id="sef-imap-pass"
|
|
>
|
|
<Input
|
|
id="sef-imap-pass"
|
|
type="password"
|
|
value={form.imapPass}
|
|
onChange={(e) => update('imapPass', e.target.value)}
|
|
placeholder={imapPassSet ? '••••••••' : 'app password'}
|
|
/>
|
|
</Field>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Body templates</CardTitle>
|
|
<CardDescription>
|
|
Default markdown bodies used when a rep doesn’t write a custom one. Tokens like{' '}
|
|
<code>{'{{client.fullName}}'}</code> are expanded server-side.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<details className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
|
<summary className="cursor-pointer font-medium text-foreground">
|
|
Available tokens ({Array.from(VALID_MERGE_TOKENS).length})
|
|
</summary>
|
|
<div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 font-mono text-[11px] text-muted-foreground sm:grid-cols-3">
|
|
{Array.from(VALID_MERGE_TOKENS)
|
|
.sort()
|
|
.map((tok) => (
|
|
<span key={tok}>{tok}</span>
|
|
))}
|
|
</div>
|
|
</details>
|
|
<Field
|
|
label="Berth PDF body"
|
|
id="sef-tmpl-berth"
|
|
help="Sent when a rep emails a berth's PDF spec to a prospect. Markdown supported."
|
|
>
|
|
<Textarea
|
|
id="sef-tmpl-berth"
|
|
rows={6}
|
|
value={form.templateBerthPdfBody}
|
|
onChange={(e) => update('templateBerthPdfBody', e.target.value)}
|
|
className="font-mono text-sm"
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Brochure body"
|
|
id="sef-tmpl-broc"
|
|
help="Sent when a rep emails the port's master brochure to a prospect."
|
|
>
|
|
<Textarea
|
|
id="sef-tmpl-broc"
|
|
rows={6}
|
|
value={form.templateBrochureBody}
|
|
onChange={(e) => update('templateBrochureBody', e.target.value)}
|
|
className="font-mono text-sm"
|
|
/>
|
|
</Field>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<Field label="Brochure max upload (MB)" id="sef-broc-max">
|
|
<Input
|
|
id="sef-broc-max"
|
|
type="number"
|
|
value={form.brochureMaxUploadMb}
|
|
onChange={(e) =>
|
|
update('brochureMaxUploadMb', e.target.value === '' ? '' : Number(e.target.value))
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Attach-vs-link threshold (MB)"
|
|
id="sef-attach"
|
|
help="Files smaller than this go in the email as an attachment. Larger files send a 24-hour signed download link instead so the message stays under SMTP size limits and doesn't bounce. Default 15 MB."
|
|
>
|
|
<Input
|
|
id="sef-attach"
|
|
type="number"
|
|
value={form.emailAttachThresholdMb}
|
|
onChange={(e) =>
|
|
update(
|
|
'emailAttachThresholdMb',
|
|
e.target.value === '' ? '' : Number(e.target.value),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Noreply from address" id="sef-noreply">
|
|
<Input
|
|
id="sef-noreply"
|
|
type="email"
|
|
value={form.noreplyFromAddress}
|
|
onChange={(e) => update('noreplyFromAddress', e.target.value)}
|
|
/>
|
|
</Field>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end">
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Save sales email settings
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
id,
|
|
children,
|
|
help,
|
|
}: {
|
|
label: string;
|
|
id: string;
|
|
children: React.ReactNode;
|
|
/** Plain-text caption rendered below the label. Use for any setting
|
|
* whose meaning isn't immediately obvious from the label alone. */
|
|
help?: string;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|