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
|
// 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
@@ -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)]
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
<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" />
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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