- 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>
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
});
|
|
});
|