From 4d4b2a1497f0a8f981d7d138cd039cdcfd136287 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 7 Jul 2026 08:30:27 -0500 Subject: [PATCH] fix auth: register redirect, password reset flow, GHL contact ID, session handling - Fix register redirect bug where successful signup returned 400 instead of redirecting to /account/setup (use isRedirect() to re-throw redirects) - Add error display to register page and fix Svelte 5 syntax (on:click) - Add await to Google sign-in on login page and register page - Fix logout to await signOut and redirect to / - Implement full password reset flow: - Add sendResetPassword callback to auth.ts config - Create forgot-password page with email submission - Create reset-password page with one-time token validation - Guard reset page: redirect to / if no valid token in URL - Add ghlContactId field to Profile model for future Go High Level integration - Add Prisma migration for ghl_contact_id column - Update register test to expect redirect instead of broken 400 --- .../migration.sql | 2 + prisma/schema.prisma | 15 +++-- src/lib/auth.ts | 67 +++++++++++++------ src/lib/components/ui/topBar/TopBar.svelte | 3 +- src/routes/forgot-password/+page.server.ts | 27 ++++++++ src/routes/forgot-password/+page.svelte | 42 ++++++++++++ src/routes/login/+page.svelte | 3 +- src/routes/register/+page.server.ts | 60 +++++++++-------- src/routes/register/+page.svelte | 12 +++- src/routes/register/page.server.spec.ts | 12 ++-- src/routes/reset-password/+page.server.ts | 59 ++++++++++++++++ src/routes/reset-password/+page.svelte | 40 +++++++++++ 12 files changed, 271 insertions(+), 71 deletions(-) create mode 100644 prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql create mode 100644 src/routes/forgot-password/+page.server.ts create mode 100644 src/routes/forgot-password/+page.svelte diff --git a/prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql b/prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql new file mode 100644 index 0000000..0ec3afd --- /dev/null +++ b/prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "ghl_contact_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 523790d..56ca350 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,13 +36,14 @@ model User { // I chose to create a Profile table here because honestly, I didn't want to waste // a bunch of time trying to figure out how to customize the default Better-Auth model. model Profile { - id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - firstName String - lastName String - phoneNumber String - addresses Address[] + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + firstName String + lastName String + phoneNumber String + ghlContactId String? @map("ghl_contact_id") + addresses Address[] } model Address { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1af3605..028e5ca 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,24 +1,47 @@ -import { betterAuth } from "better-auth"; -import { prismaAdapter } from "better-auth/adapters/prisma"; -import { prisma } from "$lib/db"; -import { sveltekitCookies } from "better-auth/svelte-kit"; -import { getRequestEvent } from "$app/server"; - +import { betterAuth } from 'better-auth'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { prisma } from '$lib/db'; +import { sveltekitCookies } from 'better-auth/svelte-kit'; +import { getRequestEvent } from '$app/server'; +import { sendMail } from './server/resend'; export const auth = betterAuth({ - baseURL: process.env.BETTER_AUTH_URL, - database: prismaAdapter(prisma, { - provider: "postgresql", - }), - emailAndPassword: { - enabled: true, - }, - socialProviders: { - google: { - prompt: "select_account", - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, - } - }, - plugins: [sveltekitCookies(getRequestEvent)], -}) \ No newline at end of file + baseURL: process.env.BETTER_AUTH_URL, + database: prismaAdapter(prisma, { + provider: 'postgresql' + }), + emailVerification: { + sendVerificationEmail: async ({ user }) => { + void sendMail({ + to: user.email, + templateId: 'email-verification-email', + data: { + target_email_address: user.email, + verification_link: 'https://google.com' + } + }); + } + }, + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + sendResetPassword: async ({ user, url }) => { + void sendMail({ + to: user.email, + templateId: 'password-reset-email', + data: { + target_email_address: user.email, + reset_link: url + } + }); + } + }, + socialProviders: { + google: { + prompt: 'select_account', + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string + } + }, + plugins: [sveltekitCookies(getRequestEvent)] +}); diff --git a/src/lib/components/ui/topBar/TopBar.svelte b/src/lib/components/ui/topBar/TopBar.svelte index 5f04ed9..2c9a7cc 100644 --- a/src/lib/components/ui/topBar/TopBar.svelte +++ b/src/lib/components/ui/topBar/TopBar.svelte @@ -17,7 +17,8 @@ Dashboard diff --git a/src/routes/forgot-password/+page.server.ts b/src/routes/forgot-password/+page.server.ts new file mode 100644 index 0000000..96582c9 --- /dev/null +++ b/src/routes/forgot-password/+page.server.ts @@ -0,0 +1,27 @@ +import type { Actions } from './$types'; +import { auth } from '$lib/auth'; +import { fail } from '@sveltejs/kit'; + +export const actions: Actions = { + default: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email') as string; + + if (!email) { + return fail(400, { error: 'Email is required.' }); + } + + try { + await auth.api.requestPasswordReset({ + body: { + email, + redirectTo: '/reset-password' + } + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to send reset email.'; + return fail(400, { error: message }); + } + } +}; diff --git a/src/routes/forgot-password/+page.svelte b/src/routes/forgot-password/+page.svelte new file mode 100644 index 0000000..f1cfc30 --- /dev/null +++ b/src/routes/forgot-password/+page.svelte @@ -0,0 +1,42 @@ + + +
+

Forgot Password

+ + {#if form?.success} +
+ If an account with that email exists, we've sent a password reset link. +
+ {/if} + + {#if form?.error && !form?.success} +
+ {form.error} +
+ {/if} + +
+ + +
+ + + Back to Login + +
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0c3e5d4..502c8d2 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -40,8 +40,7 @@ Or diff --git a/src/routes/register/page.server.spec.ts b/src/routes/register/page.server.spec.ts index 03757df..b7b1c1b 100644 --- a/src/routes/register/page.server.spec.ts +++ b/src/routes/register/page.server.spec.ts @@ -41,12 +41,7 @@ describe('register page server action', () => { expect(signUpEmail).not.toHaveBeenCalled(); }); - // BUG: The `throw redirect(303, '/account/setup')` on line 29 is inside a - // try/catch that catches it and returns `fail(400, err)`. The redirect never - // propagates. Compare with account/setup/+page.server.ts which correctly - // uses `isRedirect()` to re-throw. This test documents the current (broken) - // behaviour — a successful signup should redirect, not fail. - it('returns fail(400) on successful signup instead of redirecting (bug)', async () => { + it('redirects to /account/setup on successful signup', async () => { signUpEmail.mockResolvedValue({ user: { id: 'user-1' } }); const request = createFormRequest({ @@ -57,8 +52,9 @@ describe('register page server action', () => { password2: 'secret123' }); - const result = await invokeDefault(request); - expect(isActionFailure(result)).toBe(true); + await expect(actions.default({ request } as any)).rejects.toMatchObject({ + status: 303 + }); }); it('returns fail(400) when signUpEmail throws', async () => { diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts index e69de29..efe4d56 100644 --- a/src/routes/reset-password/+page.server.ts +++ b/src/routes/reset-password/+page.server.ts @@ -0,0 +1,59 @@ +import type { PageServerLoad, Actions } from './$types'; +import { auth } from '$lib/auth'; +import { prisma } from '$lib/db'; +import { fail, redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ url }) => { + const token = url.searchParams.get('token'); + + if (!token) { + throw redirect(302, '/'); + } + + const verification = await prisma.verification.findFirst({ + where: { + value: token, + expiresAt: { gt: new Date() } + } + }); + + if (!verification) { + throw redirect(302, '/'); + } +}; + +export const actions: Actions = { + default: async ({ request, url }) => { + const token = url.searchParams.get('token'); + + if (!token) { + return fail(400, { error: 'Invalid or missing reset token.' }); + } + + const data = await request.formData(); + const password = data.get('password') as string; + const password2 = data.get('password2') as string; + + if (!password || password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters.' }); + } + + if (password !== password2) { + return fail(400, { error: 'Passwords do not match.' }); + } + + try { + await auth.api.resetPassword({ + body: { + newPassword: password, + token + } + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to reset password.'; + return fail(400, { error: message }); + } + + throw redirect(303, '/login'); + } +}; diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte index e69de29..6e7d5da 100644 --- a/src/routes/reset-password/+page.svelte +++ b/src/routes/reset-password/+page.svelte @@ -0,0 +1,40 @@ + + +
+

Reset Password

+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + + + + + +