Automated signups use the registration-email set-password link

This commit is contained in:
NeilClack
2026-09-10 14:08:56 -05:00
parent 84c905c9ff
commit 809ced6d8a
3 changed files with 48 additions and 87 deletions
@@ -1,9 +1,8 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend'; import { sendMail } from '$lib/server/resend';
import { env } from '$env/dynamic/private'; import { sendRegistrationInvite } from '$lib/server/registration-invite';
function generateLicenseKey(): string { function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase(); const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
@@ -21,46 +20,27 @@ export const POST: RequestHandler = async ({ request }) => {
let user = await prisma.user.findUnique({ where: { email } }); let user = await prisma.user.findUnique({ where: { email } });
if (!user) { if (!user) {
const tempPassword = crypto.randomUUID().replace(/-/g, '').slice(0, 20);
try { try {
await auth.api.signUpEmail({ user = await prisma.user.create({
body: {
email,
password: tempPassword,
name: name ?? email.split('@')[0]
}
});
user = await prisma.user.findUnique({ where: { email } });
const verification = await prisma.verification.findFirst({
where: {
identifier: email,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
const verifyUrl = verification
? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${env.BETTER_AUTH_URL}/login`;
await sendMail({
to: email,
subject: 'Your account is ready',
templateId: 'automated-user-registration',
data: { data: {
firstName: name ?? email.split('@')[0], id: crypto.randomUUID(),
tempPassword, name: name ?? email.split('@')[0],
verifyUrl, email,
loginUrl: `${env.BETTER_AUTH_URL}/login` // The onboarding flow vouches for the email address; the user
// still has to use the emailed link to set a password.
emailVerified: true
} }
}); });
} catch (err) { } catch (err) {
console.error('Failed to create user:', err); console.error('Failed to create user:', err);
error(500, 'Failed to create account'); error(500, 'Failed to create account');
} }
try {
await sendRegistrationInvite(email);
} catch (err) {
console.error('Failed to send registration email:', err);
}
} }
if (!user) { if (!user) {
@@ -1,8 +1,8 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend'; import { sendMail } from '$lib/server/resend';
import { sendRegistrationInvite } from '$lib/server/registration-invite';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { clientIp, rateLimited } from '$lib/server/v2-helpers'; import { clientIp, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit'; import { rateLimit } from '$lib/server/v2-rate-limit';
@@ -86,46 +86,27 @@ export const POST: RequestHandler = async ({ request, url }) => {
let user = await prisma.user.findUnique({ where: { email: cleanEmail } }); let user = await prisma.user.findUnique({ where: { email: cleanEmail } });
if (!user) { if (!user) {
const tempPassword = crypto.randomUUID().replace(/-/g, '').slice(0, 20);
try { try {
await auth.api.signUpEmail({ user = await prisma.user.create({
body: {
email: cleanEmail,
password: tempPassword,
name: displayName
}
});
user = await prisma.user.findUnique({ where: { email: cleanEmail } });
const verification = await prisma.verification.findFirst({
where: {
identifier: cleanEmail,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
const verifyUrl = verification
? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${env.BETTER_AUTH_URL}/login`;
await sendMail({
to: cleanEmail,
subject: 'Your account is ready',
templateId: 'automated-user-registration',
data: { data: {
firstName: displayName, id: crypto.randomUUID(),
tempPassword, name: displayName,
verifyUrl, email: cleanEmail,
loginUrl: `${env.BETTER_AUTH_URL}/login` // The onboarding flow vouches for the email address; the user
// still has to use the emailed link to set a password.
emailVerified: true
} }
}); });
} catch (err) { } catch (err) {
console.error('Failed to create user:', err); console.error('Failed to create user:', err);
error(500, 'Failed to create account'); error(500, 'Failed to create account');
} }
try {
await sendRegistrationInvite(cleanEmail);
} catch (err) {
console.error('Failed to send registration email:', err);
}
} }
if (!user) { if (!user) {
@@ -3,9 +3,9 @@ import { isHttpError } from '@sveltejs/kit';
import { computeSignature, buildCanonicalRequest, _clearNonces } from '$lib/server/v2-signature'; import { computeSignature, buildCanonicalRequest, _clearNonces } from '$lib/server/v2-signature';
import { _clearRateLimits } from '$lib/server/v2-rate-limit'; import { _clearRateLimits } from '$lib/server/v2-rate-limit';
const { authApi, sendMailMock } = vi.hoisted(() => ({ const { sendMailMock, sendRegistrationInviteMock } = vi.hoisted(() => ({
authApi: { signUpEmail: vi.fn() }, sendMailMock: vi.fn(),
sendMailMock: vi.fn() sendRegistrationInviteMock: vi.fn()
})); }));
vi.mock('$env/dynamic/private', () => ({ vi.mock('$env/dynamic/private', () => ({
@@ -15,8 +15,8 @@ vi.mock('$env/dynamic/private', () => ({
} }
})); }));
vi.mock('$lib/auth', () => ({ vi.mock('$lib/server/registration-invite', () => ({
auth: { api: authApi } sendRegistrationInvite: sendRegistrationInviteMock
})); }));
vi.mock('$lib/server/resend', () => ({ vi.mock('$lib/server/resend', () => ({
@@ -25,8 +25,7 @@ vi.mock('$lib/server/resend', () => ({
vi.mock('$lib/db', () => ({ vi.mock('$lib/db', () => ({
prisma: { prisma: {
user: { findUnique: vi.fn() }, user: { findUnique: vi.fn(), create: vi.fn() },
verification: { findFirst: vi.fn() },
licenseKey: { create: vi.fn() } licenseKey: { create: vi.fn() }
} }
})); }));
@@ -40,7 +39,7 @@ const SECRET = 'test-onboarding-secret';
const PATH = '/api/v2/automations/create/account'; const PATH = '/api/v2/automations/create/account';
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>; const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>; const mockUserCreate = prisma.user.create as ReturnType<typeof vi.fn>;
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>; const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
function signedOnboardingRequest(body: object, secret = SECRET): Request { function signedOnboardingRequest(body: object, secret = SECRET): Request {
@@ -101,23 +100,21 @@ describe('POST /api/v2/automations/create/account', () => {
message: 'Account and license key created', message: 'Account and license key created',
user_id: 'user-1' user_id: 'user-1'
}); });
expect(authApi.signUpEmail).not.toHaveBeenCalled();
expect(mockLicenseCreate).toHaveBeenCalledWith( expect(mockLicenseCreate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ active: true }) data: expect.objectContaining({ active: true })
}) })
); );
expect(sendMailMock).toHaveBeenCalledTimes(1); expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(sendRegistrationInviteMock).not.toHaveBeenCalled();
}); });
it('signs up a new user and sends registration + license emails', async () => { it('creates a new user and sends the registration + license emails', async () => {
mockUserFindUnique mockUserFindUnique.mockResolvedValue(null);
.mockResolvedValueOnce(null) mockUserCreate.mockResolvedValue({ id: 'user-2', email: 'new@example.com' });
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
mockVerification.mockResolvedValue({ value: 'verify-token' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-2' }); mockLicenseCreate.mockResolvedValue({ id: 'lic-2' });
sendMailMock.mockResolvedValue({}); sendMailMock.mockResolvedValue({});
authApi.signUpEmail.mockResolvedValue({}); sendRegistrationInviteMock.mockResolvedValue(undefined);
const res = await POST({ const res = await POST({
request: signedOnboardingRequest({ email: 'new@example.com' }), request: signedOnboardingRequest({ email: 'new@example.com' }),
@@ -130,12 +127,15 @@ describe('POST /api/v2/automations/create/account', () => {
message: 'Account and license key created', message: 'Account and license key created',
user_id: 'user-2' user_id: 'user-2'
}); });
expect(authApi.signUpEmail).toHaveBeenCalledWith( expect(mockUserCreate).toHaveBeenCalledWith({
expect.objectContaining({ data: expect.objectContaining({
body: expect.objectContaining({ email: 'new@example.com', name: 'new' }) email: 'new@example.com',
name: 'new',
emailVerified: true
}) })
); });
expect(sendMailMock).toHaveBeenCalledTimes(2); expect(sendRegistrationInviteMock).toHaveBeenCalledWith('new@example.com');
expect(sendMailMock).toHaveBeenCalledTimes(1);
}); });
it('rejects a request without a valid signature', async () => { it('rejects a request without a valid signature', async () => {