feat(admin): single Sales role, welcome-email password setup, Director=sales
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m24s

- 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:
2026-06-22 12:40:55 +02:00
parent 5b9560531e
commit 93989b1e1d
16 changed files with 593 additions and 156 deletions

View File

@@ -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 ─────────────────────────────────────────

View 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();
}
});
});

View File

@@ -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 () => {

View 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');
});
});

View 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');
});
});