Automated signups use the registration-email set-password link
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user