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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Profile" ADD COLUMN "ghl_contact_id" TEXT;
|
||||
@@ -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 {
|
||||
|
||||
+45
-22
@@ -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)],
|
||||
})
|
||||
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)]
|
||||
});
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
<a href="/dashboard" class="mx-2">Dashboard</a>
|
||||
<button
|
||||
onclick={async () => {
|
||||
authClient.signOut();
|
||||
await authClient.signOut();
|
||||
window.location.href = '/';
|
||||
}}>Log Out</button
|
||||
>
|
||||
</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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -40,8 +40,7 @@
|
||||
<span>Or</span>
|
||||
<button
|
||||
onclick={async () => {
|
||||
console.log('signing in with google');
|
||||
authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
|
||||
}}
|
||||
>
|
||||
<img src="light/web_light_sq_SI.svg" alt="Sign In with Google" />
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import type { Actions } from "./$types";
|
||||
import { auth } from "$lib/auth";
|
||||
import { fail, redirect } from "@sveltejs/kit";
|
||||
import type { Actions } from './$types';
|
||||
import { auth } from '$lib/auth';
|
||||
import { fail, isRedirect, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
const firstName = data.get('firstName') as string;
|
||||
const lastName = data.get('lastName') as string;
|
||||
const email = data.get('email') as string;
|
||||
const password = data.get('password') as string;
|
||||
const password2 = data.get('password2') as string;
|
||||
const firstName = data.get('firstName') as string;
|
||||
const lastName = data.get('lastName') as string;
|
||||
const email = data.get('email') as string;
|
||||
const password = data.get('password') as string;
|
||||
const password2 = data.get('password2') as string;
|
||||
|
||||
if (password != password2) {
|
||||
return fail(400, 'an email address is required!');
|
||||
}
|
||||
if (password != password2) {
|
||||
return fail(400, { error: 'passwords do not match' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: `${firstName} ${lastName}`,
|
||||
email: email,
|
||||
password: password
|
||||
}
|
||||
})
|
||||
try {
|
||||
const response = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: `${firstName} ${lastName}`,
|
||||
email: email,
|
||||
password: password
|
||||
}
|
||||
});
|
||||
|
||||
if (response.user) {
|
||||
throw redirect(303, '/account/setup');
|
||||
}
|
||||
} catch (err) {
|
||||
return fail(400, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.user) {
|
||||
throw redirect(303, '/account/setup');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRedirect(err)) throw err;
|
||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
||||
return fail(400, { error: message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import Window from '$lib/components/containers/Window.svelte';
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<Window>
|
||||
<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">
|
||||
<input type="text" name="firstName" placeholder="First 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>
|
||||
<button
|
||||
class="mx-auto"
|
||||
on:click={async () => {
|
||||
authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' });
|
||||
onclick={async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' });
|
||||
}}><img src="light/web_light_sq_SU.svg" alt="Sign Up with Google" /></button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user