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