Email a set-password link when an admin creates a user
This commit is contained in:
@@ -6,4 +6,8 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM=
|
EMAIL_FROM=
|
||||||
RESEND_TEMPLATE_ID=
|
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=
|
API_V2_ONBOARDING_SECRET=
|
||||||
@@ -5,8 +5,23 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
|
|||||||
import { getRequestEvent } from '$app/server';
|
import { getRequestEvent } from '$app/server';
|
||||||
import { sendMail } from './server/resend';
|
import { sendMail } from './server/resend';
|
||||||
import { sendVerificationEmail } from './server/email-verification';
|
import { sendVerificationEmail } from './server/email-verification';
|
||||||
|
import { sendRegistrationEmail } from './server/registration-email';
|
||||||
import { env } from '$env/dynamic/private';
|
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({
|
export const auth = betterAuth({
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
@@ -27,6 +42,13 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
requireEmailVerification: true,
|
requireEmailVerification: true,
|
||||||
sendResetPassword: async ({ user, url }) => {
|
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({
|
void sendMail({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
templateId: 'password-reset-email',
|
templateId: 'password-reset-email',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -69,7 +70,11 @@ export const actions: Actions = {
|
|||||||
if (rawId) {
|
if (rawId) {
|
||||||
await prisma.user.update({ where: { id: rawId }, data });
|
await prisma.user.update({ where: { id: rawId }, data });
|
||||||
} else {
|
} 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) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||||
@@ -89,6 +94,22 @@ export const actions: Actions = {
|
|||||||
throw e;
|
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' };
|
return { success: true, action: 'upsert' };
|
||||||
},
|
},
|
||||||
delete: async ({ request }) => {
|
delete: async ({ request }) => {
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
const userCreate = prisma.user.create as ReturnType<typeof vi.fn>;
|
||||||
|
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const invokeUpsert = (request: Request, locals: App.Locals) =>
|
||||||
|
actions.upsert({ request, locals } as any);
|
||||||
|
|
||||||
|
function upsertRequest(overrides: Record<string, string> = {}) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user