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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user