Add shared registration invite helper and use it for admin-created users

This commit is contained in:
NeilClack
2026-09-10 14:08:51 -05:00
parent ae8d661364
commit 84c905c9ff
3 changed files with 28 additions and 23 deletions
+18
View File
@@ -0,0 +1,18 @@
import { auth } from '$lib/auth';
/**
* Email an invited user a link to set their password and finish their profile.
*
* Reuses Better Auth's password-reset token flow; the `invite=1` marker makes
* the reset-password hook send the `registration-email` template instead of the
* usual "you requested a reset" email. The link lands on /reset-password, and
* after setting a password the user logs in and is sent to /profile/setup.
*/
export async function sendRegistrationInvite(email: string): Promise<void> {
await auth.api.requestPasswordReset({
body: {
email,
redirectTo: '/reset-password?invite=1'
}
});
}
@@ -1,6 +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 { sendRegistrationInvite } from '$lib/server/registration-invite';
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';
@@ -96,15 +96,7 @@ export const actions: Actions = {
if (!rawId) { if (!rawId) {
try { try {
// Send a registration email with a set-password link. This reuses await sendRegistrationInvite(data.email);
// 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) { } catch (err) {
console.error('Failed to send registration email:', err); console.error('Failed to send registration email:', err);
} }
@@ -14,12 +14,12 @@ vi.mock('$lib/db', () => ({
} }
})); }));
const { requestPasswordReset } = vi.hoisted(() => ({ const { sendRegistrationInvite } = vi.hoisted(() => ({
requestPasswordReset: vi.fn() sendRegistrationInvite: vi.fn()
})); }));
vi.mock('$lib/auth', () => ({ vi.mock('$lib/server/registration-invite', () => ({
auth: { api: { requestPasswordReset } } sendRegistrationInvite
})); }));
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
@@ -47,7 +47,7 @@ describe('users admin page server upsert', () => {
userFindUnique.mockResolvedValue({ roleId: 1 }); userFindUnique.mockResolvedValue({ roleId: 1 });
userCreate.mockResolvedValue({ id: 'new-user' }); userCreate.mockResolvedValue({ id: 'new-user' });
userUpdate.mockResolvedValue({ id: 'user-1' }); userUpdate.mockResolvedValue({ id: 'user-1' });
requestPasswordReset.mockResolvedValue(undefined); sendRegistrationInvite.mockResolvedValue(undefined);
}); });
it('marks a created user verified and emails a set-password link', async () => { it('marks a created user verified and emails a set-password link', async () => {
@@ -62,12 +62,7 @@ describe('users admin page server upsert', () => {
emailVerified: true emailVerified: true
}) })
}); });
expect(requestPasswordReset).toHaveBeenCalledWith({ expect(sendRegistrationInvite).toHaveBeenCalledWith('jane@example.com');
body: {
email: 'jane@example.com',
redirectTo: '/reset-password?invite=1'
}
});
}); });
it('does not email a registration link when updating an existing user', async () => { it('does not email a registration link when updating an existing user', async () => {
@@ -77,11 +72,11 @@ describe('users admin page server upsert', () => {
expect(result).toEqual({ success: true, action: 'upsert' }); expect(result).toEqual({ success: true, action: 'upsert' });
expect(userUpdate).toHaveBeenCalled(); expect(userUpdate).toHaveBeenCalled();
expect(userCreate).not.toHaveBeenCalled(); expect(userCreate).not.toHaveBeenCalled();
expect(requestPasswordReset).not.toHaveBeenCalled(); expect(sendRegistrationInvite).not.toHaveBeenCalled();
}); });
it('still succeeds when the registration email fails to send', async () => { it('still succeeds when the registration email fails to send', async () => {
requestPasswordReset.mockRejectedValue(new Error('resend down')); sendRegistrationInvite.mockRejectedValue(new Error('resend down'));
const locals = createLocals(mockUser() as any); const locals = createLocals(mockUser() as any);
const result = await invokeUpsert(upsertRequest(), locals); const result = await invokeUpsert(upsertRequest(), locals);