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
This commit is contained in:
NeilClack
2026-07-07 08:30:27 -05:00
parent f566866a23
commit 4d4b2a1497
12 changed files with 271 additions and 71 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Profile" ADD COLUMN "ghl_contact_id" TEXT;
+8 -7
View File
@@ -36,13 +36,14 @@ model User {
// I chose to create a Profile table here because honestly, I didn't want to waste // 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. // a bunch of time trying to figure out how to customize the default Better-Auth model.
model Profile { model Profile {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
firstName String firstName String
lastName String lastName String
phoneNumber String phoneNumber String
addresses Address[] ghlContactId String? @map("ghl_contact_id")
addresses Address[]
} }
model Address { model Address {
+45 -22
View File
@@ -1,24 +1,47 @@
import { betterAuth } from "better-auth"; import { betterAuth } from 'better-auth';
import { prismaAdapter } from "better-auth/adapters/prisma"; import { prismaAdapter } from 'better-auth/adapters/prisma';
import { prisma } from "$lib/db"; import { prisma } from '$lib/db';
import { sveltekitCookies } from "better-auth/svelte-kit"; import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from "$app/server"; import { getRequestEvent } from '$app/server';
import { sendMail } from './server/resend';
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL, baseURL: process.env.BETTER_AUTH_URL,
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql", provider: 'postgresql'
}), }),
emailAndPassword: { emailVerification: {
enabled: true, sendVerificationEmail: async ({ user }) => {
}, void sendMail({
socialProviders: { to: user.email,
google: { templateId: 'email-verification-email',
prompt: "select_account", data: {
clientId: process.env.GOOGLE_CLIENT_ID as string, target_email_address: user.email,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, verification_link: 'https://google.com'
} }
}, });
plugins: [sveltekitCookies(getRequestEvent)], }
}) },
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)]
});
+2 -1
View File
@@ -17,7 +17,8 @@
<a href="/dashboard" class="mx-2">Dashboard</a> <a href="/dashboard" class="mx-2">Dashboard</a>
<button <button
onclick={async () => { onclick={async () => {
authClient.signOut(); await authClient.signOut();
window.location.href = '/';
}}>Log Out</button }}>Log Out</button
> >
</div> </div>
@@ -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 });
}
}
};
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<div class="mx-auto mt-12 max-w-md">
<h1 class="mb-6 text-2xl font-semibold text-gray-900">Forgot Password</h1>
{#if form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-4 text-green-700">
If an account with that email exists, we've sent a password reset link.
</div>
{/if}
{#if form?.error && !form?.success}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<form method="POST" class="flex flex-col gap-4">
<input
type="email"
name="email"
placeholder="Email Address"
required
class="rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Send Reset Link
</button>
</form>
<a href={resolve('/login')} class="mt-4 block text-sm text-blue-600 hover:text-blue-800">
Back to Login
</a>
</div>
+1 -2
View File
@@ -40,8 +40,7 @@
<span>Or</span> <span>Or</span>
<button <button
onclick={async () => { onclick={async () => {
console.log('signing in with google'); await authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
}} }}
> >
<img src="light/web_light_sq_SI.svg" alt="Sign In with Google" /> <img src="light/web_light_sq_SI.svg" alt="Sign In with Google" />
+31 -29
View File
@@ -1,35 +1,37 @@
import type { Actions } from "./$types"; import type { Actions } from './$types';
import { auth } from "$lib/auth"; import { auth } from '$lib/auth';
import { fail, redirect } from "@sveltejs/kit"; import { fail, isRedirect, redirect } from '@sveltejs/kit';
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async ({ request }) => {
const data = await request.formData(); const data = await request.formData();
const firstName = data.get('firstName') as string; const firstName = data.get('firstName') as string;
const lastName = data.get('lastName') as string; const lastName = data.get('lastName') as string;
const email = data.get('email') as string; const email = data.get('email') as string;
const password = data.get('password') as string; const password = data.get('password') as string;
const password2 = data.get('password2') as string; const password2 = data.get('password2') as string;
if (password != password2) { if (password != password2) {
return fail(400, 'an email address is required!'); return fail(400, { error: 'passwords do not match' });
} }
try { try {
const response = await auth.api.signUpEmail({ const response = await auth.api.signUpEmail({
body: { body: {
name: `${firstName} ${lastName}`, name: `${firstName} ${lastName}`,
email: email, email: email,
password: password password: password
} }
}) });
if (response.user) { if (response.user) {
throw redirect(303, '/account/setup'); throw redirect(303, '/account/setup');
} }
} catch (err) { } catch (err) {
return fail(400, err); if (isRedirect(err)) throw err;
} const message = err instanceof Error ? err.message : 'An unexpected error occurred';
} return fail(400, { error: message });
} }
}
};
+10 -2
View File
@@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import Window from '$lib/components/containers/Window.svelte'; import Window from '$lib/components/containers/Window.svelte';
import { authClient } from '$lib/auth-client'; import { authClient } from '$lib/auth-client';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script> </script>
<Window> <Window>
<div class="flex flex-col items-start justify-center"> <div class="flex flex-col items-start justify-center">
{#if form?.error}
<div class="mb-4 w-full rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<form method="POST" class="flex flex-col items-start"> <form method="POST" class="flex flex-col items-start">
<input type="text" name="firstName" placeholder="First Name" required class="mb-2" /> <input type="text" name="firstName" placeholder="First Name" required class="mb-2" />
<input type="text" name="lastName" placeholder="Last Name" required class="mb-2" /> <input type="text" name="lastName" placeholder="Last Name" required class="mb-2" />
@@ -18,8 +26,8 @@
<p class=" mx-auto my-6 text-center">Or</p> <p class=" mx-auto my-6 text-center">Or</p>
<button <button
class="mx-auto" class="mx-auto"
on:click={async () => { onclick={async () => {
authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' }); await authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' });
}}><img src="light/web_light_sq_SU.svg" alt="Sign Up with Google" /></button }}><img src="light/web_light_sq_SU.svg" alt="Sign Up with Google" /></button
> >
</div> </div>
+4 -8
View File
@@ -41,12 +41,7 @@ describe('register page server action', () => {
expect(signUpEmail).not.toHaveBeenCalled(); expect(signUpEmail).not.toHaveBeenCalled();
}); });
// BUG: The `throw redirect(303, '/account/setup')` on line 29 is inside a it('redirects to /account/setup on successful signup', async () => {
// 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 () => {
signUpEmail.mockResolvedValue({ user: { id: 'user-1' } }); signUpEmail.mockResolvedValue({ user: { id: 'user-1' } });
const request = createFormRequest({ const request = createFormRequest({
@@ -57,8 +52,9 @@ describe('register page server action', () => {
password2: 'secret123' password2: 'secret123'
}); });
const result = await invokeDefault(request); await expect(actions.default({ request } as any)).rejects.toMatchObject({
expect(isActionFailure(result)).toBe(true); status: 303
});
}); });
it('returns fail(400) when signUpEmail throws', async () => { it('returns fail(400) when signUpEmail throws', async () => {
+59
View File
@@ -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');
}
};
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<div class="mx-auto mt-12 max-w-md">
<h1 class="mb-6 text-2xl font-semibold text-gray-900">Reset Password</h1>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<form method="POST" class="flex flex-col gap-4">
<input
type="password"
name="password"
placeholder="New Password"
required
minlength="8"
class="rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<input
type="password"
name="password2"
placeholder="Confirm New Password"
required
minlength="8"
class="rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Reset Password
</button>
</form>
</div>