feat(admin): single Sales role, welcome-email password setup, Director=sales
- Collapse the two sales roles in the create-user dropdown to one "Sales" (sales_manager relabelled). Hide super_admin + sales_agent from selection via NON_ASSIGNABLE_ROLE_NAMES; the form keeps a user's *current* role even if hidden so existing assignments stay editable. - Director becomes a senior-title twin of Sales: DIRECTOR_PERMISSIONS now equals SALES_MANAGER_PERMISSIONS (no admin/settings — Super-Admin only). Migration 0097 updates the existing global director row (idempotent, data-only; 0 users assigned on prod, so no blast radius). - Admin create-user defaults to emailing a set-password link instead of an inline password (manual entry still available via a toggle). createUserSchema: password optional + sendSetupEmail; createUser provisions with a throwaway password then triggers the set-password email. - New users get a dedicated, unique WELCOME email (crmWelcomeEmail), not the self-service "reset your password" email. A pending-welcome flag routes the shared better-auth sendResetPassword callback via account-setup-email.ts. - Phone confirmed already optional for staff accounts (no change needed). Tests: +welcome-routing, +create-user-setup; permission-matrix director block realigned to no-admin. 1662 vitest pass; tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { formatRole } from '@/lib/constants';
|
import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
const roles = rolesQuery.data?.data ?? [];
|
const roles = rolesQuery.data?.data ?? [];
|
||||||
|
// Hide retired/owner-only system roles from the picker, but always keep the
|
||||||
|
// role the user being edited already holds so their record stays editable.
|
||||||
|
const selectableRoles = roles.filter(
|
||||||
|
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
|
||||||
|
);
|
||||||
const [firstName, setFirstName] = useState(initialNames.first);
|
const [firstName, setFirstName] = useState(initialNames.first);
|
||||||
const [lastName, setLastName] = useState(initialNames.last);
|
const [lastName, setLastName] = useState(initialNames.last);
|
||||||
const [email, setEmail] = useState(user?.email ?? '');
|
const [email, setEmail] = useState(user?.email ?? '');
|
||||||
const [originalEmail] = useState(user?.email ?? '');
|
const [originalEmail] = useState(user?.email ?? '');
|
||||||
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
|
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
// New users: email them a set-password link by default rather than typing a
|
||||||
|
// password here. Toggle off to set one manually.
|
||||||
|
const [sendSetupEmail, setSendSetupEmail] = useState(true);
|
||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
||||||
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
||||||
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
email,
|
email,
|
||||||
password,
|
// Email mode omits the password entirely; manual mode sends it.
|
||||||
|
password: sendSetupEmail ? undefined : password,
|
||||||
|
sendSetupEmail,
|
||||||
displayName,
|
displayName,
|
||||||
phone: phoneE164 ?? undefined,
|
phone: phoneE164 ?? undefined,
|
||||||
roleId,
|
roleId,
|
||||||
@@ -250,18 +260,37 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label htmlFor="user-password">Password</Label>
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<Input
|
<div>
|
||||||
id="user-password"
|
<Label htmlFor="user-setup-email">Email a set-password link</Label>
|
||||||
type="password"
|
<p className="text-xs text-muted-foreground">
|
||||||
value={password}
|
The user gets an email to choose their own password. Turn off to set one
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
here instead.
|
||||||
placeholder="Min 12 characters"
|
</p>
|
||||||
minLength={12}
|
</div>
|
||||||
required
|
<Switch
|
||||||
/>
|
id="user-setup-email"
|
||||||
</div>
|
checked={sendSetupEmail}
|
||||||
|
onCheckedChange={setSendSetupEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!sendSetupEmail && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="user-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min 12 characters"
|
||||||
|
minLength={12}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
<SelectValue placeholder="Select a role" />
|
<SelectValue placeholder="Select a role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{roles.map((r) => (
|
{selectableRoles.map((r) => (
|
||||||
<SelectItem key={r.id} value={r.id}>
|
<SelectItem key={r.id} value={r.id}>
|
||||||
{formatRole(r.name)}
|
{formatRole(r.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
62
src/lib/auth/account-setup-email.ts
Normal file
62
src/lib/auth/account-setup-email.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
|
import { consumePendingWelcome } from './pending-welcome';
|
||||||
|
|
||||||
|
interface AuthBranding {
|
||||||
|
appName?: string | null;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
backgroundUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the email body for better-auth's `sendResetPassword` callback,
|
||||||
|
* choosing between two framings of the same set-password link:
|
||||||
|
*
|
||||||
|
* - a unique **welcome** email when the recipient was flagged by the admin
|
||||||
|
* "create user" flow (a brand-new user has nothing to reset), or
|
||||||
|
* - the standard **password-reset** email for genuine self-service resets.
|
||||||
|
*
|
||||||
|
* Pure aside from rendering — no SMTP, no DB — so the welcome-vs-reset routing
|
||||||
|
* is directly unit-testable.
|
||||||
|
*/
|
||||||
|
export async function buildAccountPasswordEmail(opts: {
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
url: string;
|
||||||
|
appName: string;
|
||||||
|
authBranding: AuthBranding | null;
|
||||||
|
}): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
const emailBranding: BrandingShell | null = opts.authBranding
|
||||||
|
? {
|
||||||
|
logoUrl: opts.authBranding.logoUrl ?? null,
|
||||||
|
backgroundUrl: opts.authBranding.backgroundUrl ?? null,
|
||||||
|
primaryColor: null,
|
||||||
|
emailHeaderHtml: null,
|
||||||
|
emailFooterHtml: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (consumePendingWelcome(opts.email)) {
|
||||||
|
const { crmWelcomeEmail } = await import('@/lib/email/templates/crm-welcome');
|
||||||
|
return crmWelcomeEmail(
|
||||||
|
{ link: opts.url, recipientName: opts.name ?? undefined, appName: opts.appName },
|
||||||
|
{ branding: emailBranding },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { renderShell, safeUrl } = await import('@/lib/email/shell');
|
||||||
|
const subject = `Reset your ${opts.appName} password`;
|
||||||
|
const safeName = (opts.name || 'there').replace(/[<>&]/g, '');
|
||||||
|
const body = `
|
||||||
|
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
||||||
|
<p style="margin-bottom:16px;">You requested a password reset for your ${opts.appName} account.</p>
|
||||||
|
<p style="margin-bottom:16px;">
|
||||||
|
<a href="${safeUrl(opts.url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||||
|
- the link expires in 1 hour.
|
||||||
|
</p>
|
||||||
|
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`;
|
||||||
|
const html = renderShell({ title: subject, body, branding: emailBranding });
|
||||||
|
const text = `Reset your password: ${opts.url}`;
|
||||||
|
return { subject, html, text };
|
||||||
|
}
|
||||||
@@ -77,42 +77,28 @@ function buildAuth() {
|
|||||||
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
||||||
// in dev.
|
// in dev.
|
||||||
sendResetPassword: async ({ user, url }) => {
|
sendResetPassword: async ({ user, url }) => {
|
||||||
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
|
const [{ sendEmail }, { resolveAuthShellBranding }, { buildAccountPasswordEmail }] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
import('@/lib/email'),
|
import('@/lib/email'),
|
||||||
import('@/lib/email/shell'),
|
|
||||||
import('@/lib/email/auth-shell-branding'),
|
import('@/lib/email/auth-shell-branding'),
|
||||||
|
import('@/lib/auth/account-setup-email'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const branding = await resolveAuthShellBranding();
|
const branding = await resolveAuthShellBranding();
|
||||||
const appName = branding?.appName ?? 'CRM';
|
const appName = branding?.appName ?? 'CRM';
|
||||||
const subject = `Reset your ${appName} password`;
|
|
||||||
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
|
|
||||||
const body = `
|
|
||||||
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
|
||||||
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
|
|
||||||
<p style="margin-bottom:16px;">
|
|
||||||
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
|
||||||
- the link expires in 1 hour.
|
|
||||||
</p>
|
|
||||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const html = renderShell({
|
// Admin-created users ride the same reset-token machinery but should
|
||||||
title: subject,
|
// receive a welcome email, not a "you requested a reset" one — the
|
||||||
body,
|
// create-user service marks them just before triggering this. The
|
||||||
branding: branding
|
// builder picks welcome-vs-reset and renders accordingly.
|
||||||
? {
|
const mail = await buildAccountPasswordEmail({
|
||||||
logoUrl: branding.logoUrl,
|
email: user.email,
|
||||||
backgroundUrl: branding.backgroundUrl,
|
name: user.name,
|
||||||
primaryColor: null,
|
url,
|
||||||
emailHeaderHtml: null,
|
appName,
|
||||||
emailFooterHtml: null,
|
authBranding: branding,
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
const text = `Reset your password: ${url}`;
|
await sendEmail(user.email, mail.subject, mail.html, undefined, mail.text);
|
||||||
await sendEmail(user.email, subject, html, undefined, text);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
29
src/lib/auth/pending-welcome.ts
Normal file
29
src/lib/auth/pending-welcome.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Bridges the admin "create user" flow to better-auth's single
|
||||||
|
* `sendResetPassword` callback.
|
||||||
|
*
|
||||||
|
* A brand-new admin-created user is provisioned with a throwaway password and
|
||||||
|
* then sent a set-password link via better-auth's password-reset machinery — but
|
||||||
|
* the *email* should read as a welcome, not a "you requested a reset". better-auth
|
||||||
|
* exposes only one `sendResetPassword` callback (no per-call context), so the
|
||||||
|
* create-user service marks the recipient here immediately before triggering the
|
||||||
|
* reset; the callback consumes the mark and renders the welcome email instead.
|
||||||
|
*
|
||||||
|
* Mark and consume happen in the same process within a single synchronous
|
||||||
|
* request flow (`createUser` awaits `requestPasswordReset`, which awaits the
|
||||||
|
* callback), so this module-level set is safe — it is never read across requests.
|
||||||
|
* Keyed by lowercased email; distinct recipients never collide.
|
||||||
|
*/
|
||||||
|
const pendingWelcome = new Set<string>();
|
||||||
|
|
||||||
|
export function markPendingWelcome(email: string): void {
|
||||||
|
pendingWelcome.add(email.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true (and clears the mark) when this email was flagged as a welcome. */
|
||||||
|
export function consumePendingWelcome(email: string): boolean {
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
const had = pendingWelcome.has(key);
|
||||||
|
pendingWelcome.delete(key);
|
||||||
|
return had;
|
||||||
|
}
|
||||||
@@ -332,13 +332,27 @@ export function formatSource(source: string | null | undefined): string | null {
|
|||||||
export const ROLE_LABELS: Record<string, string> = {
|
export const ROLE_LABELS: Record<string, string> = {
|
||||||
super_admin: 'Super Admin',
|
super_admin: 'Super Admin',
|
||||||
director: 'Director',
|
director: 'Director',
|
||||||
sales_manager: 'Sales Manager',
|
// Single sales role for the deployment — `sales_manager` is the full-access
|
||||||
|
// sales map, surfaced to users simply as "Sales". `sales_agent` is retired
|
||||||
|
// from selection (see NON_ASSIGNABLE_ROLE_NAMES) but keeps its label for any
|
||||||
|
// legacy assignment still rendered.
|
||||||
|
sales_manager: 'Sales',
|
||||||
sales_agent: 'Sales Agent',
|
sales_agent: 'Sales Agent',
|
||||||
finance_manager: 'Finance Manager',
|
finance_manager: 'Finance Manager',
|
||||||
viewer: 'Viewer',
|
viewer: 'Viewer',
|
||||||
residential_partner: 'Residential Partner',
|
residential_partner: 'Residential Partner',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System roles that must not appear as choices when assigning a role to a
|
||||||
|
* user. `super_admin` is platform-owner-only (minted via the invitation
|
||||||
|
* flow's isSuperAdmin gate, never the role dropdown); `sales_agent` is
|
||||||
|
* superseded by the single "Sales" role. The create/edit user form still
|
||||||
|
* surfaces a user's *current* role even if it's listed here, so existing
|
||||||
|
* assignments stay editable.
|
||||||
|
*/
|
||||||
|
export const NON_ASSIGNABLE_ROLE_NAMES = new Set(['super_admin', 'sales_agent']);
|
||||||
|
|
||||||
/** Returns the human label for a stored role name. Falls back to a
|
/** Returns the human label for a stored role name. Falls back to a
|
||||||
* Title-Case rendering for legacy / custom roles. */
|
* Title-Case rendering for legacy / custom roles. */
|
||||||
export function formatRole(role: string | null | undefined): string {
|
export function formatRole(role: string | null | undefined): string {
|
||||||
|
|||||||
14
src/lib/db/migrations/0097_director_role_full_sales.sql
Normal file
14
src/lib/db/migrations/0097_director_role_full_sales.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Director becomes a senior-title twin of the single "Sales" role: identical
|
||||||
|
-- capabilities, no admin/settings access (admin stays Super-Admin-only).
|
||||||
|
--
|
||||||
|
-- The bootstrap seed inserts system roles with ON CONFLICT DO NOTHING, so
|
||||||
|
-- editing DIRECTOR_PERMISSIONS in seed-permissions.ts only affects fresh seeds.
|
||||||
|
-- Existing deployments need this data update to bring the stored `director`
|
||||||
|
-- row in line. Idempotent: re-running simply re-copies the sales map.
|
||||||
|
UPDATE roles AS d
|
||||||
|
SET permissions = sm.permissions,
|
||||||
|
description = 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
|
||||||
|
updated_at = now()
|
||||||
|
FROM roles AS sm
|
||||||
|
WHERE d.name = 'director'
|
||||||
|
AND sm.name = 'sales_manager';
|
||||||
@@ -153,7 +153,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
|||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: 'director',
|
name: 'director',
|
||||||
description: 'Operational admin within assigned port(s). Can manage users and settings.',
|
description: 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
|
||||||
permissions: DIRECTOR_PERMISSIONS,
|
permissions: DIRECTOR_PERMISSIONS,
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
|
|||||||
@@ -98,92 +98,10 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
// DIRECTOR_PERMISSIONS is defined just below SALES_MANAGER_PERMISSIONS — it is a
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
// senior-title twin of the single "Sales" role with identical capabilities and
|
||||||
interests: {
|
// no admin/settings access (reserved for Super Admin). Kept there so it can
|
||||||
view: true,
|
// reference the sales map directly.
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
change_stage: true,
|
|
||||||
override_stage: true,
|
|
||||||
generate_eoi: true,
|
|
||||||
export: true,
|
|
||||||
},
|
|
||||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
|
||||||
documents: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
send_for_signing: true,
|
|
||||||
upload_signed: true,
|
|
||||||
delete: true,
|
|
||||||
manage_folders: true,
|
|
||||||
},
|
|
||||||
expenses: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
export: true,
|
|
||||||
scan_receipt: true,
|
|
||||||
},
|
|
||||||
invoices: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
send: true,
|
|
||||||
record_payment: true,
|
|
||||||
export: true,
|
|
||||||
},
|
|
||||||
payments: { view: true, record: true, delete: true },
|
|
||||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
|
||||||
email: { view: true, send: true, configure_account: true },
|
|
||||||
reminders: {
|
|
||||||
view_own: true,
|
|
||||||
view_all: true,
|
|
||||||
create: true,
|
|
||||||
edit_own: true,
|
|
||||||
edit_all: true,
|
|
||||||
assign_others: true,
|
|
||||||
},
|
|
||||||
calendar: { connect: true, view_events: true },
|
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
|
||||||
document_templates: { view: true, generate: true, manage: true },
|
|
||||||
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
|
|
||||||
companies: { view: true, create: true, edit: true, delete: true },
|
|
||||||
memberships: { view: true, manage: true },
|
|
||||||
tenancies: { view: true, manage: true, cancel: true },
|
|
||||||
admin: {
|
|
||||||
manage_users: true,
|
|
||||||
view_audit_log: true,
|
|
||||||
manage_settings: true,
|
|
||||||
manage_webhooks: true,
|
|
||||||
manage_reports: true,
|
|
||||||
manage_custom_fields: true,
|
|
||||||
manage_forms: true,
|
|
||||||
manage_tags: true,
|
|
||||||
system_backup: false,
|
|
||||||
permanently_delete_clients: false,
|
|
||||||
},
|
|
||||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
|
||||||
residential_interests: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
change_stage: true,
|
|
||||||
},
|
|
||||||
inquiries: {
|
|
||||||
view: true,
|
|
||||||
manage: true,
|
|
||||||
},
|
|
||||||
client_groups: {
|
|
||||||
view: true,
|
|
||||||
manage: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
||||||
@@ -272,6 +190,11 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Director is now a senior-title twin of the single "Sales" role: identical
|
||||||
|
// capabilities, no admin/settings access (admin stays Super-Admin-only). It
|
||||||
|
// remains a distinct, selectable role purely so the title can differ.
|
||||||
|
export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS;
|
||||||
|
|
||||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
||||||
interests: {
|
interests: {
|
||||||
|
|||||||
125
src/lib/email/templates/crm-welcome.tsx
Normal file
125
src/lib/email/templates/crm-welcome.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
brandingPrimaryColor,
|
||||||
|
emailStyle,
|
||||||
|
renderShell,
|
||||||
|
safeUrl,
|
||||||
|
type BrandingShell,
|
||||||
|
} from '@/lib/email/shell';
|
||||||
|
|
||||||
|
interface WelcomeData {
|
||||||
|
/** The set-password link (better-auth reset URL landing on /set-password). */
|
||||||
|
link: string;
|
||||||
|
recipientName?: string;
|
||||||
|
/** Human label of the role the account was created with (e.g. "Sales"). */
|
||||||
|
roleName?: string;
|
||||||
|
/** Full product / app name as branded — e.g. "Port Nimara CRM". Falls back
|
||||||
|
* to "Port Nimara CRM". Used verbatim (no " CRM" is appended). */
|
||||||
|
appName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderOpts {
|
||||||
|
branding?: BrandingShell | null;
|
||||||
|
subject?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WelcomeBody({
|
||||||
|
appName,
|
||||||
|
link,
|
||||||
|
recipientName,
|
||||||
|
roleName,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
appName: string;
|
||||||
|
link: string;
|
||||||
|
recipientName?: string;
|
||||||
|
roleName?: string;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
|
||||||
|
const roleClause = roleName ? ` with the ${roleName} role` : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={emailStyle.title(accent)}>Welcome to {appName}</Text>
|
||||||
|
<Text style={emailStyle.paragraph}>{greeting}</Text>
|
||||||
|
<Text style={emailStyle.paragraph}>
|
||||||
|
An account has been created for you in {appName}
|
||||||
|
{roleClause}. To get started, set your password using the button below — then sign in with
|
||||||
|
this email address.
|
||||||
|
</Text>
|
||||||
|
<div style={emailStyle.buttonRow}>
|
||||||
|
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
|
||||||
|
Set your password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Text style={emailStyle.signoff}>
|
||||||
|
See you inside,
|
||||||
|
<br />
|
||||||
|
<strong>The {appName} Team</strong>
|
||||||
|
</Text>
|
||||||
|
<Hr style={emailStyle.divider} />
|
||||||
|
<Text style={emailStyle.finePrint}>
|
||||||
|
For security this link will expire after a short while. If it's no longer valid, ask
|
||||||
|
your administrator to send you a new one.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If the button doesn't work, paste this link into your browser:
|
||||||
|
<br />
|
||||||
|
<Link
|
||||||
|
href={safeUrl(link)}
|
||||||
|
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcome / set-your-password email for an admin-created CRM user. Distinct from
|
||||||
|
* the self-service "Reset your password" email — a brand-new user has nothing to
|
||||||
|
* reset. Wraps the same better-auth reset link that the /set-password page
|
||||||
|
* consumes, but in onboarding framing.
|
||||||
|
*/
|
||||||
|
export async function crmWelcomeEmail(
|
||||||
|
data: WelcomeData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
const appName = data.appName ?? 'Port Nimara CRM';
|
||||||
|
const subject = overrides?.subject?.trim()
|
||||||
|
? overrides.subject
|
||||||
|
: `Welcome to ${appName} — set your password`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
|
const body = await render(
|
||||||
|
<WelcomeBody
|
||||||
|
appName={appName}
|
||||||
|
link={data.link}
|
||||||
|
recipientName={data.recipientName}
|
||||||
|
roleName={data.roleName}
|
||||||
|
accent={accent}
|
||||||
|
/>,
|
||||||
|
{ pretty: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Welcome to ${appName}`,
|
||||||
|
'',
|
||||||
|
`An account has been created for you${data.roleName ? ` with the ${data.roleName} role` : ''}.`,
|
||||||
|
`Set your password to get started: ${data.link}`,
|
||||||
|
'',
|
||||||
|
`For security this link will expire after a short while — if it's no longer valid, ask your administrator to send a new one.`,
|
||||||
|
'',
|
||||||
|
`See you inside,`,
|
||||||
|
`The ${appName} Team`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -153,11 +153,22 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
|||||||
});
|
});
|
||||||
if (!role) throw new ValidationError('Invalid role ID');
|
if (!role) throw new ValidationError('Invalid role ID');
|
||||||
|
|
||||||
|
// Two onboarding modes:
|
||||||
|
// - setup-email (default when no password is supplied): provision the
|
||||||
|
// account with a throwaway random password the admin never sees, then
|
||||||
|
// email the user a link to set their own. The /set-password page
|
||||||
|
// consumes the better-auth reset token.
|
||||||
|
// - manual: the admin typed a password inline; use it verbatim.
|
||||||
|
const useSetupEmail = data.sendSetupEmail ?? !data.password;
|
||||||
|
const initialPassword = useSetupEmail
|
||||||
|
? `${crypto.randomUUID()}${crypto.randomUUID()}`
|
||||||
|
: data.password!;
|
||||||
|
|
||||||
// Create Better Auth user
|
// Create Better Auth user
|
||||||
const authResult = await auth.api.signUpEmail({
|
const authResult = await auth.api.signUpEmail({
|
||||||
body: {
|
body: {
|
||||||
email: data.email.toLowerCase(),
|
email: data.email.toLowerCase(),
|
||||||
password: data.password,
|
password: initialPassword,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -199,6 +210,32 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
|||||||
severity: 'info',
|
severity: 'info',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup-email mode: dispatch a "set your password" link. Reuses better-auth's
|
||||||
|
// password-reset token, which the existing /set-password page consumes. Done
|
||||||
|
// after the role assignment so the user is fully provisioned the moment they
|
||||||
|
// set their password. A send failure must not roll back the created account —
|
||||||
|
// the admin can resend, or fall back to setting a password manually.
|
||||||
|
if (useSetupEmail) {
|
||||||
|
// Flag this recipient so the shared sendResetPassword callback renders the
|
||||||
|
// welcome email rather than the "you requested a reset" copy.
|
||||||
|
const { markPendingWelcome, consumePendingWelcome } =
|
||||||
|
await import('@/lib/auth/pending-welcome');
|
||||||
|
markPendingWelcome(data.email);
|
||||||
|
try {
|
||||||
|
await auth.api.requestPasswordReset({
|
||||||
|
body: { email: data.email.toLowerCase(), redirectTo: '/set-password' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Clear the flag if the reset never dispatched, so a later self-service
|
||||||
|
// reset for this address isn't mistaken for a welcome.
|
||||||
|
consumePendingWelcome(data.email);
|
||||||
|
logger.error(
|
||||||
|
{ err, userId: newUserId },
|
||||||
|
'createUser: failed to send welcome / set-password email (account was still created)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return getUser(newUserId, portId);
|
return getUser(newUserId, portId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const createUserSchema = z.object({
|
export const createUserSchema = z
|
||||||
email: z.string().email(),
|
.object({
|
||||||
name: z.string().min(1).max(200),
|
email: z.string().email(),
|
||||||
password: z.string().min(12),
|
name: z.string().min(1).max(200),
|
||||||
displayName: z.string().min(1).max(200),
|
/** Optional at creation: omit it to email the user a set-password link
|
||||||
firstName: z.string().min(1).max(200).nullable().optional(),
|
* instead (see `sendSetupEmail`). Required when `sendSetupEmail` is
|
||||||
lastName: z.string().min(1).max(200).nullable().optional(),
|
* explicitly false. */
|
||||||
phone: z.string().optional(),
|
password: z.string().min(12).optional(),
|
||||||
roleId: z.string().uuid(),
|
/** When true (the default when no password is supplied), the account is
|
||||||
residentialAccess: z.boolean().optional().default(false),
|
* provisioned and the new user is emailed a link to set their own
|
||||||
});
|
* password. When false, `password` must be supplied inline. */
|
||||||
|
sendSetupEmail: z.boolean().optional(),
|
||||||
|
displayName: z.string().min(1).max(200),
|
||||||
|
firstName: z.string().min(1).max(200).nullable().optional(),
|
||||||
|
lastName: z.string().min(1).max(200).nullable().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
roleId: z.string().uuid(),
|
||||||
|
residentialAccess: z.boolean().optional().default(false),
|
||||||
|
})
|
||||||
|
.refine((d) => d.sendSetupEmail !== false || (d.password?.length ?? 0) >= 12, {
|
||||||
|
message: 'A password (min 12 characters) is required when not sending a setup email',
|
||||||
|
path: ['password'],
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -660,15 +660,13 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Director - everything except system backup. */
|
/** Director - everything except system backup. */
|
||||||
|
/**
|
||||||
|
* Director is a senior-title twin of the single "Sales" role: identical
|
||||||
|
* capabilities, no admin/settings access (admin stays Super-Admin-only). Mirror
|
||||||
|
* the sales-manager map so the fixture tracks the real seeded role.
|
||||||
|
*/
|
||||||
export function makeDirectorPermissions(): RolePermissions {
|
export function makeDirectorPermissions(): RolePermissions {
|
||||||
return {
|
return makeSalesManagerPermissions();
|
||||||
...makeFullPermissions(),
|
|
||||||
admin: {
|
|
||||||
...makeFullPermissions().admin,
|
|
||||||
system_backup: false,
|
|
||||||
permanently_delete_clients: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Minimal valid CreateClientInput ─────────────────────────────────────────
|
// ─── Minimal valid CreateClientInput ─────────────────────────────────────────
|
||||||
|
|||||||
102
tests/integration/admin-create-user-setup-email.test.ts
Normal file
102
tests/integration/admin-create-user-setup-email.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* CM: admin user creation can defer the password to the new user.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - setup-email mode (default): no password is supplied at creation. The
|
||||||
|
* account is provisioned (profile + port role) and a set-password link is
|
||||||
|
* dispatched via better-auth. (The welcome-vs-reset framing of that email
|
||||||
|
* is covered by tests/unit/email/account-setup-email-routing.test.ts.)
|
||||||
|
* - manual mode: the admin supplies a password inline; no email is sent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { account, roles, user, userProfiles } from '@/lib/db/schema';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { createUser } from '@/lib/services/users.service';
|
||||||
|
import { makePort, makeAuditMeta } from '../helpers/factories';
|
||||||
|
|
||||||
|
describe('createUser - set-password email flow', () => {
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// user_port_roles are port-scoped and cleaned by global teardown; the
|
||||||
|
// auth user + profile + account rows are global, so purge them here.
|
||||||
|
for (const id of createdUserIds) {
|
||||||
|
await db.delete(account).where(eq(account.userId, id));
|
||||||
|
await db.delete(userProfiles).where(eq(userProfiles.userId, id));
|
||||||
|
await db.delete(user).where(eq(user.id, id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function salesRoleId(): Promise<string> {
|
||||||
|
const r = await db.query.roles.findFirst({ where: eq(roles.name, 'sales_manager') });
|
||||||
|
if (!r) throw new Error('sales_manager role not seeded — run pnpm db:seed');
|
||||||
|
return r.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('provisions the user without a password and emails a set-password link', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const roleId = await salesRoleId();
|
||||||
|
const resetSpy = vi
|
||||||
|
.spyOn(auth.api, 'requestPasswordReset')
|
||||||
|
.mockResolvedValue({ status: true } as never);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = `setup-test-${Date.now()}-a@example.test`;
|
||||||
|
const result = await createUser(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
name: 'Jane Doe',
|
||||||
|
displayName: 'Jane Doe',
|
||||||
|
roleId,
|
||||||
|
sendSetupEmail: true,
|
||||||
|
residentialAccess: false,
|
||||||
|
},
|
||||||
|
makeAuditMeta(),
|
||||||
|
);
|
||||||
|
createdUserIds.push(result.userId);
|
||||||
|
|
||||||
|
// Provisioned with the assigned role, ready to sign in once they set a password.
|
||||||
|
expect(result.role.name).toBe('sales_manager');
|
||||||
|
// A set-password email was dispatched to their address.
|
||||||
|
expect(resetSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resetSpy.mock.calls[0]?.[0]?.body?.email).toBe(email);
|
||||||
|
} finally {
|
||||||
|
resetSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the supplied password and sends no email in manual mode', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const roleId = await salesRoleId();
|
||||||
|
const resetSpy = vi
|
||||||
|
.spyOn(auth.api, 'requestPasswordReset')
|
||||||
|
.mockResolvedValue({ status: true } as never);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = `setup-test-${Date.now()}-b@example.test`;
|
||||||
|
const result = await createUser(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
name: 'John Roe',
|
||||||
|
displayName: 'John Roe',
|
||||||
|
roleId,
|
||||||
|
password: 'manual-secret-1234',
|
||||||
|
sendSetupEmail: false,
|
||||||
|
residentialAccess: false,
|
||||||
|
},
|
||||||
|
makeAuditMeta(),
|
||||||
|
);
|
||||||
|
createdUserIds.push(result.userId);
|
||||||
|
|
||||||
|
expect(resetSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
resetSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* - viewer can read but not write
|
* - viewer can read but not write
|
||||||
* - sales_agent can manage own clients/interests but not admin features
|
* - sales_agent can manage own clients/interests but not admin features
|
||||||
* - sales_manager has elevated but non-admin access
|
* - sales_manager has elevated but non-admin access
|
||||||
* - director has near-full access
|
* - director mirrors sales (full sales access, no admin)
|
||||||
* - deepMerge correctly applies port-level overrides
|
* - deepMerge correctly applies port-level overrides
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
@@ -190,17 +190,25 @@ describe('Permission Matrix - sales_manager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── director ─────────────────────────────────────────────────────────────────
|
// ─── director (senior-title twin of Sales: full sales, no admin) ──────────────
|
||||||
|
|
||||||
describe('Permission Matrix - director', () => {
|
describe('Permission Matrix - director', () => {
|
||||||
const ctx = makeCtx({ permissions: makeDirectorPermissions() });
|
const ctx = makeCtx({ permissions: makeDirectorPermissions() });
|
||||||
|
|
||||||
it('can manage webhooks', async () => {
|
it('has full sales access (create clients)', async () => {
|
||||||
expect(await checkPermission(ctx, 'admin', 'manage_webhooks')).toBe(200);
|
expect(await checkPermission(ctx, 'clients', 'create')).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can manage users', async () => {
|
it('can manage tags', async () => {
|
||||||
expect(await checkPermission(ctx, 'admin', 'manage_users')).toBe(200);
|
expect(await checkPermission(ctx, 'admin', 'manage_tags')).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot manage users', async () => {
|
||||||
|
expect(await checkPermission(ctx, 'admin', 'manage_users')).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot manage settings', async () => {
|
||||||
|
expect(await checkPermission(ctx, 'admin', 'manage_settings')).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cannot perform system_backup', async () => {
|
it('cannot perform system_backup', async () => {
|
||||||
|
|||||||
64
tests/unit/email/account-setup-email-routing.test.ts
Normal file
64
tests/unit/email/account-setup-email-routing.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* The shared better-auth sendResetPassword callback must send a unique WELCOME
|
||||||
|
* email to admin-created users (flagged via pending-welcome) and the standard
|
||||||
|
* RESET email to everyone else — same link, different framing.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAccountPasswordEmail } from '@/lib/auth/account-setup-email';
|
||||||
|
import { markPendingWelcome } from '@/lib/auth/pending-welcome';
|
||||||
|
|
||||||
|
const url = 'https://crm.example.com/set-password#token=tok';
|
||||||
|
|
||||||
|
describe('buildAccountPasswordEmail routing', () => {
|
||||||
|
it('sends a welcome email when the recipient was flagged by create-user', async () => {
|
||||||
|
const email = 'new-hire@example.test';
|
||||||
|
markPendingWelcome(email);
|
||||||
|
|
||||||
|
const mail = await buildAccountPasswordEmail({
|
||||||
|
email,
|
||||||
|
name: 'New Hire',
|
||||||
|
url,
|
||||||
|
appName: 'Port Nimara CRM',
|
||||||
|
authBranding: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mail.subject.toLowerCase()).toContain('welcome');
|
||||||
|
expect(mail.subject.toLowerCase()).not.toContain('reset');
|
||||||
|
expect(mail.html).toContain('tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the standard reset email for an unflagged self-service reset', async () => {
|
||||||
|
const mail = await buildAccountPasswordEmail({
|
||||||
|
email: 'existing@example.test',
|
||||||
|
name: 'Existing User',
|
||||||
|
url,
|
||||||
|
appName: 'Port Nimara CRM',
|
||||||
|
authBranding: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mail.subject.toLowerCase()).toContain('reset');
|
||||||
|
expect(mail.subject.toLowerCase()).not.toContain('welcome');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes the welcome flag (a second build for the same email is a reset)', async () => {
|
||||||
|
const email = 'once@example.test';
|
||||||
|
markPendingWelcome(email);
|
||||||
|
|
||||||
|
const first = await buildAccountPasswordEmail({
|
||||||
|
email,
|
||||||
|
url,
|
||||||
|
appName: 'Port Nimara CRM',
|
||||||
|
authBranding: null,
|
||||||
|
});
|
||||||
|
const second = await buildAccountPasswordEmail({
|
||||||
|
email,
|
||||||
|
url,
|
||||||
|
appName: 'Port Nimara CRM',
|
||||||
|
authBranding: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.subject.toLowerCase()).toContain('welcome');
|
||||||
|
expect(second.subject.toLowerCase()).toContain('reset');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/unit/email/crm-welcome-email.test.ts
Normal file
34
tests/unit/email/crm-welcome-email.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { crmWelcomeEmail } from '@/lib/email/templates/crm-welcome';
|
||||||
|
|
||||||
|
describe('crmWelcomeEmail', () => {
|
||||||
|
it('is a unique welcome email (not a password-reset) carrying the set-password link', async () => {
|
||||||
|
const link = 'https://crm.example.com/set-password#token=abc123';
|
||||||
|
const { subject, html, text } = await crmWelcomeEmail({
|
||||||
|
link,
|
||||||
|
recipientName: 'Jane Doe',
|
||||||
|
appName: 'Port Nimara CRM',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Distinct welcome framing, not the reset-password copy.
|
||||||
|
expect(subject.toLowerCase()).toContain('welcome');
|
||||||
|
expect(subject.toLowerCase()).not.toContain('reset');
|
||||||
|
// No accidental double "CRM CRM" when the app name already carries it.
|
||||||
|
expect(subject).not.toContain('CRM CRM');
|
||||||
|
|
||||||
|
// Greets the recipient and drives them to set their password.
|
||||||
|
expect(html).toContain('Jane Doe');
|
||||||
|
expect(html).toContain('set-password');
|
||||||
|
expect(html).toContain('abc123');
|
||||||
|
expect(text).toContain(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a generic greeting when no name is given', async () => {
|
||||||
|
const { html } = await crmWelcomeEmail({
|
||||||
|
link: 'https://crm.example.com/set-password#token=xyz',
|
||||||
|
appName: 'Port Nimara CRM',
|
||||||
|
});
|
||||||
|
expect(html.toLowerCase()).toContain('welcome');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user