From 93989b1e1d8f0fadccedab14faf47337702dfef3 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 22 Jun 2026 12:40:55 +0200 Subject: [PATCH] feat(admin): single Sales role, welcome-email password setup, Director=sales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/components/admin/users/user-form.tsx | 59 ++++++--- src/lib/auth/account-setup-email.ts | 62 +++++++++ src/lib/auth/index.ts | 40 ++---- src/lib/auth/pending-welcome.ts | 29 ++++ src/lib/constants.ts | 16 ++- .../0097_director_role_full_sales.sql | 14 ++ src/lib/db/seed-bootstrap.ts | 2 +- src/lib/db/seed-permissions.ts | 95 ++----------- src/lib/email/templates/crm-welcome.tsx | 125 ++++++++++++++++++ src/lib/services/users.service.ts | 39 +++++- src/lib/validators/users.ts | 34 +++-- tests/helpers/factories.ts | 14 +- .../admin-create-user-setup-email.test.ts | 102 ++++++++++++++ tests/integration/permission-matrix.test.ts | 20 ++- .../email/account-setup-email-routing.test.ts | 64 +++++++++ tests/unit/email/crm-welcome-email.test.ts | 34 +++++ 16 files changed, 593 insertions(+), 156 deletions(-) create mode 100644 src/lib/auth/account-setup-email.ts create mode 100644 src/lib/auth/pending-welcome.ts create mode 100644 src/lib/db/migrations/0097_director_role_full_sales.sql create mode 100644 src/lib/email/templates/crm-welcome.tsx create mode 100644 tests/integration/admin-create-user-setup-email.test.ts create mode 100644 tests/unit/email/account-setup-email-routing.test.ts create mode 100644 tests/unit/email/crm-welcome-email.test.ts diff --git a/src/components/admin/users/user-form.tsx b/src/components/admin/users/user-form.tsx index b7826f2e..ccb19d44 100644 --- a/src/components/admin/users/user-form.tsx +++ b/src/components/admin/users/user-form.tsx @@ -29,7 +29,7 @@ import { } from '@/components/ui/alert-dialog'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { apiFetch } from '@/lib/api/client'; -import { formatRole } from '@/lib/constants'; +import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants'; interface Role { id: string; @@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { enabled: open, }); 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 [lastName, setLastName] = useState(initialNames.last); const [email, setEmail] = useState(user?.email ?? ''); const [originalEmail] = useState(user?.email ?? ''); const [emailConfirmOpen, setEmailConfirmOpen] = useState(false); 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 [phoneValue, setPhoneValue] = useState( user?.phone ? { e164: user.phone, country: 'US' } : null, @@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { firstName: firstName || null, lastName: lastName || null, email, - password, + // Email mode omits the password entirely; manual mode sends it. + password: sendSetupEmail ? undefined : password, + sendSetupEmail, displayName, phone: phoneE164 ?? undefined, roleId, @@ -250,18 +260,37 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { {!isEdit && ( -
- - setPassword(e.target.value)} - placeholder="Min 12 characters" - minLength={12} - required - /> -
+ <> +
+
+ +

+ The user gets an email to choose their own password. Turn off to set one + here instead. +

+
+ +
+ + {!sendSetupEmail && ( +
+ + setPassword(e.target.value)} + placeholder="Min 12 characters" + minLength={12} + required + /> +
+ )} + )}
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { - {roles.map((r) => ( + {selectableRoles.map((r) => ( {formatRole(r.name)} diff --git a/src/lib/auth/account-setup-email.ts b/src/lib/auth/account-setup-email.ts new file mode 100644 index 00000000..44a01970 --- /dev/null +++ b/src/lib/auth/account-setup-email.ts @@ -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 = ` +

Hi ${safeName},

+

You requested a password reset for your ${opts.appName} account.

+

+ Click here to set a new password + - the link expires in 1 hour. +

+

If you didn't request this, you can safely ignore this email.

+ `; + const html = renderShell({ title: subject, body, branding: emailBranding }); + const text = `Reset your password: ${opts.url}`; + return { subject, html, text }; +} diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 76f28648..cfa4551d 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -77,42 +77,28 @@ function buildAuth() { // through the shared SMTP infra so EMAIL_REDIRECT_TO honours it // in dev. sendResetPassword: async ({ user, url }) => { - const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] = + const [{ sendEmail }, { resolveAuthShellBranding }, { buildAccountPasswordEmail }] = await Promise.all([ import('@/lib/email'), - import('@/lib/email/shell'), import('@/lib/email/auth-shell-branding'), + import('@/lib/auth/account-setup-email'), ]); const branding = await resolveAuthShellBranding(); const appName = branding?.appName ?? 'CRM'; - const subject = `Reset your ${appName} password`; - const safeName = (user.name || 'there').replace(/[<>&]/g, ''); - const body = ` -

Hi ${safeName},

-

You requested a password reset for your ${appName} account.

-

- Click here to set a new password - - the link expires in 1 hour. -

-

If you didn't request this, you can safely ignore this email.

- `; - const html = renderShell({ - title: subject, - body, - branding: branding - ? { - logoUrl: branding.logoUrl, - backgroundUrl: branding.backgroundUrl, - primaryColor: null, - emailHeaderHtml: null, - emailFooterHtml: null, - } - : null, + // Admin-created users ride the same reset-token machinery but should + // receive a welcome email, not a "you requested a reset" one — the + // create-user service marks them just before triggering this. The + // builder picks welcome-vs-reset and renders accordingly. + const mail = await buildAccountPasswordEmail({ + email: user.email, + name: user.name, + url, + appName, + authBranding: branding, }); - const text = `Reset your password: ${url}`; - await sendEmail(user.email, subject, html, undefined, text); + await sendEmail(user.email, mail.subject, mail.html, undefined, mail.text); }, }, diff --git a/src/lib/auth/pending-welcome.ts b/src/lib/auth/pending-welcome.ts new file mode 100644 index 00000000..860f9abf --- /dev/null +++ b/src/lib/auth/pending-welcome.ts @@ -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(); + +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; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index b8da38ab..37df6648 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -332,13 +332,27 @@ export function formatSource(source: string | null | undefined): string | null { export const ROLE_LABELS: Record = { super_admin: 'Super Admin', 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', finance_manager: 'Finance Manager', viewer: 'Viewer', 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 * Title-Case rendering for legacy / custom roles. */ export function formatRole(role: string | null | undefined): string { diff --git a/src/lib/db/migrations/0097_director_role_full_sales.sql b/src/lib/db/migrations/0097_director_role_full_sales.sql new file mode 100644 index 00000000..7a0ff726 --- /dev/null +++ b/src/lib/db/migrations/0097_director_role_full_sales.sql @@ -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'; diff --git a/src/lib/db/seed-bootstrap.ts b/src/lib/db/seed-bootstrap.ts index d3f852ae..4ffb9a8a 100644 --- a/src/lib/db/seed-bootstrap.ts +++ b/src/lib/db/seed-bootstrap.ts @@ -153,7 +153,7 @@ export async function seedBootstrap(): Promise { { id: crypto.randomUUID(), 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, isGlobal: true, isSystem: true, diff --git a/src/lib/db/seed-permissions.ts b/src/lib/db/seed-permissions.ts index f0df1c80..5b2e71bc 100644 --- a/src/lib/db/seed-permissions.ts +++ b/src/lib/db/seed-permissions.ts @@ -98,92 +98,10 @@ export const ALL_PERMISSIONS: RolePermissions = { }, }; -export const DIRECTOR_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { - view: true, - 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, - }, -}; +// DIRECTOR_PERMISSIONS is defined just below SALES_MANAGER_PERMISSIONS — it is a +// senior-title twin of the single "Sales" role with identical capabilities and +// no admin/settings access (reserved for Super Admin). Kept there so it can +// reference the sales map directly. export const SALES_MANAGER_PERMISSIONS: RolePermissions = { 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 = { clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, interests: { diff --git a/src/lib/email/templates/crm-welcome.tsx b/src/lib/email/templates/crm-welcome.tsx new file mode 100644 index 00000000..337a1c97 --- /dev/null +++ b/src/lib/email/templates/crm-welcome.tsx @@ -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 ( + <> + Welcome to {appName} + {greeting} + + 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. + +
+ +
+ + See you inside, +
+ The {appName} Team +
+
+ + 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. +
+
+ If the button doesn't work, paste this link into your browser: +
+ + {link} + +
+ + ); +} + +/** + * 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( + , + { 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, + }; +} diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts index 84d672e0..c23e5e18 100644 --- a/src/lib/services/users.service.ts +++ b/src/lib/services/users.service.ts @@ -153,11 +153,22 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au }); 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 const authResult = await auth.api.signUpEmail({ body: { email: data.email.toLowerCase(), - password: data.password, + password: initialPassword, name: data.name, }, }); @@ -199,6 +210,32 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au 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); } diff --git a/src/lib/validators/users.ts b/src/lib/validators/users.ts index de8d1a66..27a2fe60 100644 --- a/src/lib/validators/users.ts +++ b/src/lib/validators/users.ts @@ -1,16 +1,28 @@ import { z } from 'zod'; -export const createUserSchema = z.object({ - email: z.string().email(), - name: z.string().min(1).max(200), - password: z.string().min(12), - 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), -}); +export const createUserSchema = z + .object({ + email: z.string().email(), + name: z.string().min(1).max(200), + /** Optional at creation: omit it to email the user a set-password link + * instead (see `sendSetupEmail`). Required when `sendSetupEmail` is + * explicitly false. */ + password: z.string().min(12).optional(), + /** When true (the default when no password is supplied), the account is + * 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; diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index f4f1ba48..cf622730 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -660,15 +660,13 @@ export function makeSalesManagerPermissions(): RolePermissions { } /** 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 { - return { - ...makeFullPermissions(), - admin: { - ...makeFullPermissions().admin, - system_backup: false, - permanently_delete_clients: false, - }, - }; + return makeSalesManagerPermissions(); } // ─── Minimal valid CreateClientInput ───────────────────────────────────────── diff --git a/tests/integration/admin-create-user-setup-email.test.ts b/tests/integration/admin-create-user-setup-email.test.ts new file mode 100644 index 00000000..4bba1756 --- /dev/null +++ b/tests/integration/admin-create-user-setup-email.test.ts @@ -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 { + 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(); + } + }); +}); diff --git a/tests/integration/permission-matrix.test.ts b/tests/integration/permission-matrix.test.ts index 8d996edc..374d49ad 100644 --- a/tests/integration/permission-matrix.test.ts +++ b/tests/integration/permission-matrix.test.ts @@ -9,7 +9,7 @@ * - viewer can read but not write * - sales_agent can manage own clients/interests but not admin features * - 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 */ 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', () => { const ctx = makeCtx({ permissions: makeDirectorPermissions() }); - it('can manage webhooks', async () => { - expect(await checkPermission(ctx, 'admin', 'manage_webhooks')).toBe(200); + it('has full sales access (create clients)', async () => { + expect(await checkPermission(ctx, 'clients', 'create')).toBe(200); }); - it('can manage users', async () => { - expect(await checkPermission(ctx, 'admin', 'manage_users')).toBe(200); + it('can manage tags', async () => { + 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 () => { diff --git a/tests/unit/email/account-setup-email-routing.test.ts b/tests/unit/email/account-setup-email-routing.test.ts new file mode 100644 index 00000000..cfd283b3 --- /dev/null +++ b/tests/unit/email/account-setup-email-routing.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/email/crm-welcome-email.test.ts b/tests/unit/email/crm-welcome-email.test.ts new file mode 100644 index 00000000..3dc99826 --- /dev/null +++ b/tests/unit/email/crm-welcome-email.test.ts @@ -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'); + }); +});