diff --git a/.env.example b/.env.example index a0a5977..1b3ad2c 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,8 @@ GOOGLE_CLIENT_SECRET= RESEND_API_KEY= EMAIL_FROM= RESEND_TEMPLATE_ID= +# Optional. Resend template for the admin "set your password" invite email. +# Defaults to `registration-email`. Expected variables: +# `target_email_address`, `registration_link`. +RESEND_REGISTRATION_TEMPLATE_ID= API_V2_ONBOARDING_SECRET= \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ef53546..ba05183 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,8 +5,23 @@ import { sveltekitCookies } from 'better-auth/svelte-kit'; import { getRequestEvent } from '$app/server'; import { sendMail } from './server/resend'; import { sendVerificationEmail } from './server/email-verification'; +import { sendRegistrationEmail } from './server/registration-email'; import { env } from '$env/dynamic/private'; +/** + * An admin-created user is invited with a set-password link. That reuses the + * password-reset token flow but marks the request with `invite=1` so we send a + * welcome/registration email instead of a "you requested a reset" email. + */ +function isRegistrationInvite(url: string): boolean { + try { + const callbackURL = new URL(url).searchParams.get('callbackURL') ?? ''; + return callbackURL.includes('invite=1'); + } catch { + return false; + } +} + export const auth = betterAuth({ baseURL: env.BETTER_AUTH_URL, database: prismaAdapter(prisma, { @@ -27,6 +42,13 @@ export const auth = betterAuth({ enabled: true, requireEmailVerification: true, sendResetPassword: async ({ user, url }) => { + if (isRegistrationInvite(url)) { + void sendRegistrationEmail({ email: user.email, url }).catch((e) => { + console.error('sendRegistrationEmail template email failed:', e); + }); + return; + } + void sendMail({ to: user.email, templateId: 'password-reset-email', diff --git a/src/lib/server/registration-email.ts b/src/lib/server/registration-email.ts new file mode 100644 index 0000000..2fd50a2 --- /dev/null +++ b/src/lib/server/registration-email.ts @@ -0,0 +1,26 @@ +import { env } from '$env/dynamic/private'; +import { sendMail } from './resend'; + +/** + * Send the "finish your registration" email to a user created by an admin. + * + * The link points at Better Auth's reset-password callback, which redirects to + * `/reset-password?invite=1&token=...`. There the user sets a password, then + * logs in and is sent to `/profile/setup` to complete their profile. + * + * Uses a Resend-hosted template (`registration-email` by default, or + * `RESEND_REGISTRATION_TEMPLATE_ID`) with variables: + * - `target_email_address` + * - `registration_link` + */ +export async function sendRegistrationEmail(options: { email: string; url: string }) { + await sendMail({ + to: options.email, + subject: 'Welcome to Radiant Wave — set your password', + templateId: env.RESEND_REGISTRATION_TEMPLATE_ID || 'registration-email', + data: { + target_email_address: options.email, + registration_link: options.url + } + }); +} diff --git a/src/routes/(protected)/admin/users/+page.server.ts b/src/routes/(protected)/admin/users/+page.server.ts index 9f92420..c08fcae 100644 --- a/src/routes/(protected)/admin/users/+page.server.ts +++ b/src/routes/(protected)/admin/users/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { prisma } from '$lib/db'; +import { auth } from '$lib/auth'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -69,7 +70,11 @@ export const actions: Actions = { if (rawId) { await prisma.user.update({ where: { id: rawId }, data }); } else { - await prisma.user.create({ data: { id: crypto.randomUUID(), ...data } }); + // Admin-created accounts are trusted: mark the email verified so + // the invited user can sign in once they set a password. + await prisma.user.create({ + data: { id: crypto.randomUUID(), emailVerified: true, ...data } + }); } } catch (e: unknown) { if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') { @@ -89,6 +94,22 @@ export const actions: Actions = { throw e; } + if (!rawId) { + try { + // Send a registration email with a set-password link. This reuses + // Better Auth's reset-password token flow; `invite=1` tells the + // email hook to send the welcome/registration template instead. + await auth.api.requestPasswordReset({ + body: { + email: data.email, + redirectTo: '/reset-password?invite=1' + } + }); + } catch (err) { + console.error('Failed to send registration email:', err); + } + } + return { success: true, action: 'upsert' }; }, delete: async ({ request }) => { diff --git a/src/routes/(protected)/admin/users/page.server.spec.ts b/src/routes/(protected)/admin/users/page.server.spec.ts new file mode 100644 index 0000000..8ccfa8a --- /dev/null +++ b/src/routes/(protected)/admin/users/page.server.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createLocals, createFormRequest, mockUser } from '$lib/test-utils'; + +vi.mock('$lib/db', () => ({ + prisma: { + user: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn() + }, + userRole: { findMany: vi.fn() } + } +})); + +const { requestPasswordReset } = vi.hoisted(() => ({ + requestPasswordReset: vi.fn() +})); + +vi.mock('$lib/auth', () => ({ + auth: { api: { requestPasswordReset } } +})); + +import { prisma } from '$lib/db'; +import { actions } from './+page.server'; + +const userFindUnique = prisma.user.findUnique as ReturnType; +const userCreate = prisma.user.create as ReturnType; +const userUpdate = prisma.user.update as ReturnType; + +const invokeUpsert = (request: Request, locals: App.Locals) => + actions.upsert({ request, locals } as any); + +function upsertRequest(overrides: Record = {}) { + return createFormRequest({ + name: 'Jane Doe', + email: 'jane@example.com', + roleId: '4', + ...overrides + }); +} + +describe('users admin page server upsert', () => { + beforeEach(() => { + vi.clearAllMocks(); + userFindUnique.mockResolvedValue({ roleId: 1 }); + userCreate.mockResolvedValue({ id: 'new-user' }); + userUpdate.mockResolvedValue({ id: 'user-1' }); + requestPasswordReset.mockResolvedValue(undefined); + }); + + it('marks a created user verified and emails a set-password link', async () => { + const locals = createLocals(mockUser() as any); + const result = await invokeUpsert(upsertRequest(), locals); + + expect(result).toEqual({ success: true, action: 'upsert' }); + expect(userCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: 'jane@example.com', + roleId: 4, + emailVerified: true + }) + }); + expect(requestPasswordReset).toHaveBeenCalledWith({ + body: { + email: 'jane@example.com', + redirectTo: '/reset-password?invite=1' + } + }); + }); + + it('does not email a registration link when updating an existing user', async () => { + const locals = createLocals(mockUser() as any); + const result = await invokeUpsert(upsertRequest({ id: 'user-9' }), locals); + + expect(result).toEqual({ success: true, action: 'upsert' }); + expect(userUpdate).toHaveBeenCalled(); + expect(userCreate).not.toHaveBeenCalled(); + expect(requestPasswordReset).not.toHaveBeenCalled(); + }); + + it('still succeeds when the registration email fails to send', async () => { + requestPasswordReset.mockRejectedValue(new Error('resend down')); + + const locals = createLocals(mockUser() as any); + const result = await invokeUpsert(upsertRequest(), locals); + + expect(result).toEqual({ success: true, action: 'upsert' }); + expect(userCreate).toHaveBeenCalled(); + }); + + it('blocks assigning a role with more privileges than the actor', async () => { + userFindUnique.mockResolvedValue({ roleId: 2 }); + + const locals = createLocals(mockUser() as any); + const result = await invokeUpsert(upsertRequest({ roleId: '1' }), locals); + + expect(result).toEqual({ + success: false, + action: 'upsert', + error: 'You cannot assign a role with more privileges than your own.' + }); + expect(userCreate).not.toHaveBeenCalled(); + }); +});