From ebc0629d438d35742f4da100a57673e5cb4ce397 Mon Sep 17 00:00:00 2001 From: Saterz Date: Thu, 25 Jun 2026 18:13:34 +0100 Subject: [PATCH 01/18] feat: add scope support for the signout route through the url params --- src/routes/(auth)/auth/logout/+server.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/routes/(auth)/auth/logout/+server.ts b/src/routes/(auth)/auth/logout/+server.ts index f4b3a2ea..37a07ba3 100644 --- a/src/routes/(auth)/auth/logout/+server.ts +++ b/src/routes/(auth)/auth/logout/+server.ts @@ -1,7 +1,20 @@ import { redirect } from "@sveltejs/kit"; +import { SIGN_OUT_SCOPES, type SignOutScope } from "@supabase/supabase-js"; +import { logError } from "$lib/server/logError.js"; -export const GET = async ({ locals: { supabase } }) => { - await supabase.auth.signOut(); +export const GET = async ({ locals: { supabase, log }, url }) => { + const scope = url.searchParams.get("scope"); + + if (scope && !SIGN_OUT_SCOPES.includes(scope as SignOutScope)) { + throw logError( + 400, + "The scope is not correct", + log, + new Error("The scope is not correct"), + ); + } + + await supabase.auth.signOut({ scope: (scope ?? undefined) as SignOutScope }); redirect(307, "/"); }; From d79f741f442215d884697ff9dd5b34a057ce66b4 Mon Sep 17 00:00:00 2001 From: Saterz Date: Sun, 28 Jun 2026 19:33:23 +0100 Subject: [PATCH 02/18] fix: add guard for empty scope string --- src/routes/(auth)/auth/logout/+server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/(auth)/auth/logout/+server.ts b/src/routes/(auth)/auth/logout/+server.ts index 37a07ba3..05c99e40 100644 --- a/src/routes/(auth)/auth/logout/+server.ts +++ b/src/routes/(auth)/auth/logout/+server.ts @@ -5,7 +5,10 @@ import { logError } from "$lib/server/logError.js"; export const GET = async ({ locals: { supabase, log }, url }) => { const scope = url.searchParams.get("scope"); - if (scope && !SIGN_OUT_SCOPES.includes(scope as SignOutScope)) { + if ( + (scope && !SIGN_OUT_SCOPES.includes(scope as SignOutScope)) || + scope === "" + ) { throw logError( 400, "The scope is not correct", From f14bcf455c7f1ec87ad41ffee64e1197708011c9 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 10:32:54 +0100 Subject: [PATCH 03/18] feat(auth): implement OAuth login for Google and WCA, enhance Discord login error handling - Added Google OAuth login functionality in `src/routes/(auth)/auth/google/+server.ts`. - Introduced WCA OAuth login functionality in `src/routes/(auth)/auth/wca/+server.ts`. - Improved error handling for Discord login in `src/routes/(auth)/auth/discord/+server.ts`. - Updated signup process to streamline account creation and profile completion in `src/routes/(auth)/auth/signup/+page.server.ts` and `src/routes/(auth)/auth/signup/+page.svelte`. - Refactored profile creation SQL migration to enforce constraints and add triggers for automatic profile creation upon user registration. - Adjusted Supabase configuration for external authentication providers. --- .env.example | 18 + src/hooks.server.ts | 26 +- .../helper_functions/addToEmailList.ts | 28 + .../layout/ExternalAuthProviders.svelte | 27 + .../validation/{signup.ts => auth.ts} | 75 +- src/routes/(auth)/auth/callback/+server.ts | 40 +- .../auth/complete-profile/+page.server.ts | 83 ++ .../(auth)/auth/complete-profile/+page.svelte | 80 ++ src/routes/(auth)/auth/confirm/+server.ts | 64 +- src/routes/(auth)/auth/discord/+server.ts | 14 +- src/routes/(auth)/auth/google/+server.ts | 21 + src/routes/(auth)/auth/signup/+page.server.ts | 190 +---- src/routes/(auth)/auth/signup/+page.svelte | 768 ++++++++---------- src/routes/(auth)/auth/wca/+server.ts | 21 + supabase/config.toml | 20 +- .../20260629085141_profile-creation.sql | 46 ++ supabase/scripts/setup-wca-provider.js | 39 + 17 files changed, 836 insertions(+), 724 deletions(-) create mode 100644 src/lib/components/helper_functions/addToEmailList.ts create mode 100644 src/lib/components/layout/ExternalAuthProviders.svelte rename src/lib/components/validation/{signup.ts => auth.ts} (57%) create mode 100644 src/routes/(auth)/auth/complete-profile/+page.server.ts create mode 100644 src/routes/(auth)/auth/complete-profile/+page.svelte create mode 100644 src/routes/(auth)/auth/google/+server.ts create mode 100644 src/routes/(auth)/auth/wca/+server.ts create mode 100644 supabase/migrations/20260629085141_profile-creation.sql create mode 100644 supabase/scripts/setup-wca-provider.js diff --git a/.env.example b/.env.example index b2aa343a..73eb0fdf 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ PUBLIC_SUPABASE_URL= # Supabase anonymous key used by the client to authenticate requests PUBLIC_SUPABASE_PUBLISHABLE_KEY= +# Supabase service role key used on the server to bypass Row Level Security (RLS) +SUPABASE_SECRET_KEY= + # Pino log level for server-side logging (debug, info, warn, error) LOG_LEVEL=info @@ -15,3 +18,18 @@ PUBLIC_TURNSTILE_SITE_KEY= # The autofill service URL for the cube submission page AUTOFILL_SERVICE_URL= + +# Brevo API key used on the server to authenticate requests +BREVO_API_KEY= + +# Google OAuth Client ID and Secret used for local testing +SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID= +SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET= + +# Discord OAuth Client ID and Secret used for local testing +SUPABASE_AUTH_EXTERNAL_DISCORD_CLIENT_ID= +SUPABASE_AUTH_EXTERNAL_DISCORD_CLIENT_SECRET= + +# WCA OAuth Client ID and Secret used for local testing +SUPABASE_AUTH_EXTERNAL_WCA_CLIENT_ID= +SUPABASE_AUTH_EXTERNAL_WCA_CLIENT_SECRET= diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 18e91744..c9f849fa 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -54,7 +54,14 @@ const supabase: Handle = async ({ event, resolve }) => { event.cookies.set(name, value, { ...options, path: "/" }), ); if (headers && Object.keys(headers).length > 0) { - event.setHeaders(headers); + // Supabase sometimes sets multiple "Cache-Control" headers in one request. The try block makes it fail silently instead of giveing a 500 error + try { + event.setHeaders(headers); + } catch (error) { + event.locals.log.warn( + `An error occured while setting header: ${error}`, + ); + } } }, }, @@ -118,10 +125,11 @@ const authGuard: Handle = async ({ event, resolve }) => { return resolve(event); } - const { data: profiles, error: err } = await event.locals.supabase + const { data: profile, error: err } = await event.locals.supabase .from("profiles") - .select("id, username, role") - .eq("user_id", user?.id); + .select("id, username, role, onboarded") + .eq("user_id", user.id) + .single(); if (err) logError( @@ -131,7 +139,15 @@ const authGuard: Handle = async ({ event, resolve }) => { err, ); - const profile = profiles?.[0]; + if ( + !profile.onboarded && + !event.url.pathname.startsWith("/auth/complete-profile") && + !event.url.pathname.startsWith("/auth/logout") && + !event.url.pathname.startsWith("/auth/callback") && + !event.url.pathname.startsWith("/auth/confirm") + ) { + redirect(303, "/auth/complete-profile"); + } if (event.url.pathname === "/auth") { redirect(303, `/user/${profile?.id}`); diff --git a/src/lib/components/helper_functions/addToEmailList.ts b/src/lib/components/helper_functions/addToEmailList.ts new file mode 100644 index 00000000..6045815d --- /dev/null +++ b/src/lib/components/helper_functions/addToEmailList.ts @@ -0,0 +1,28 @@ +import { BREVO_API_KEY } from "$env/static/private"; + +export async function addToEmailList( + email: string | undefined, + display_name: string, +): Promise<{ success: boolean; error?: string | undefined }> { + if (!email) return { success: false, error: "Email undefined" }; + + const res = await fetch("https://api.brevo.com/v3/contacts", { + method: "POST", + headers: { + "api-key": BREVO_API_KEY, + "content-type": "application/json", + }, + body: JSON.stringify({ + email, + listIds: [10], + attributes: { + FIRSTNAME: display_name, + }, + updateEnabled: true, + }), + }); + + if (res.ok) return { success: true }; + const error = await res.text(); + return { success: false, error }; +} diff --git a/src/lib/components/layout/ExternalAuthProviders.svelte b/src/lib/components/layout/ExternalAuthProviders.svelte new file mode 100644 index 00000000..4d69f306 --- /dev/null +++ b/src/lib/components/layout/ExternalAuthProviders.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/lib/components/validation/signup.ts b/src/lib/components/validation/auth.ts similarity index 57% rename from src/lib/components/validation/signup.ts rename to src/lib/components/validation/auth.ts index dfee2f7e..904db403 100644 --- a/src/lib/components/validation/signup.ts +++ b/src/lib/components/validation/auth.ts @@ -1,17 +1,30 @@ -// src/lib/validation/signup.ts import { z } from "zod/v4"; -const usernameRegex = /^[a-z0-9._]{3,}$/; +export const USERNAME_REGEX = /^[a-z0-9._]{3,}$/; + +const email = z.email("Please enter a valid email address").trim().nonempty(); +const password = z + .string() + .min(8, "Password must be at least 8 characters") + .nonempty(); +const confirmPassword = z.string().nonempty(); +const turnstile = z.string().nonempty("Please complete the Captcha"); + +export const loginSchema = z.object({ + email, + password, + "cf-turnstile-response": turnstile, +}); export const accountSchema = z .object({ - email: z.email("Please enter a valid email address").trim(), - password: z.string().min(8, "Password must be at least 8 characters"), - confirmPassword: z.string(), + email, + password, + confirmPassword, acceptTOS: z.boolean().refine((v) => v === true, { message: "You must accept the Terms of Service", }), - "cf-turnstile-response": z.string().nonempty("Please complete the Captcha"), + "cf-turnstile-response": turnstile, }) .check((data) => { if (data.value.password !== data.value.confirmPassword) { @@ -24,25 +37,6 @@ export const accountSchema = z } }); -export const profileSchema = z.object({ - display_name: z - .string() - .trim() - .min(4, "The display name must have more than 3 characters"), - username: z - .string() - .trim() - .transform((s) => s.toLowerCase()) - .refine((s) => usernameRegex.test(s), { - message: - "Please enter a username with at least 3 characters, using only lowercase a-z, digits 0-9, dot (.) or underscore (_).", - }), - avatar: z - .instanceof(File, { message: "Please upload a file." }) - .refine((f) => f.size < 2 * 1024 * 1024, "Max 2 MB upload size.") - .optional(), -}); - export const surveySchema = z.object({ discovered_via: z.enum([ "friend", @@ -66,3 +60,34 @@ export const surveySchema = z.object({ .min(1, "Select at least one feature that interests you"), other_text: z.string().trim().max(500).optional(), }); + +export const completeProfileSchema = z.object({ + username: z + .string() + .trim() + .transform((s) => s.toLowerCase()) + .refine((s) => USERNAME_REGEX.test(s), { + message: + "Please enter a username with at least 3 characters, using only lowercase a-z, digits 0-9, dot (.) or underscore (_).", + }), + display_name: z + .string() + .trim() + .min(4, "The display name must have more than 3 characters"), +}); + +export const resetPasswordSchema = z + .object({ + password, + confirmPassword, + }) + .check((data) => { + if (data.value.password !== data.value.confirmPassword) { + data.issues.push({ + code: "custom", + path: ["confirmPassword"], + input: data.value.confirmPassword, + message: "Passwords do not match", + }); + } + }); diff --git a/src/routes/(auth)/auth/callback/+server.ts b/src/routes/(auth)/auth/callback/+server.ts index 63b585b2..1202d799 100644 --- a/src/routes/(auth)/auth/callback/+server.ts +++ b/src/routes/(auth)/auth/callback/+server.ts @@ -2,13 +2,13 @@ import type { RequestHandler } from "./$types"; import { redirect } from "@sveltejs/kit"; import { logError } from "$lib/server/logError"; -export const GET: RequestHandler = async ({ url, locals }) => { - const { supabase, log } = locals; - const code = url.searchParams.get("code") as string; - const next = url.searchParams.get("next") ?? "/"; - +export const GET: RequestHandler = async ({ + url, + locals: { supabase, log }, +}) => { + const code = url.searchParams.get("code"); if (!code) { - return logError( + logError( 500, "Authorization code is missing.", log, @@ -18,12 +18,12 @@ export const GET: RequestHandler = async ({ url, locals }) => { const { data, error: err } = await supabase.auth.exchangeCodeForSession(code); if (err) { - return logError(500, "Authentication failed", log, err); + logError(500, "Authentication failed", log, err); } const { user } = data; if (!user) { - return logError( + logError( 500, "User data is missing.", log, @@ -31,29 +31,19 @@ export const GET: RequestHandler = async ({ url, locals }) => { ); } - // Check if a profile already exists for this user const { data: existingProfile, error: profileFetchError } = await supabase .from("profiles") - .select("id") + .select("onboarded") .eq("user_id", user.id) - .single(); + .maybeSingle(); - if (!profileFetchError && existingProfile) { - // Profile exists, just redirect - const redirectTo = next.startsWith("/") ? next : `/${next}`; - throw redirect(303, redirectTo); + if (profileFetchError) { + logError(500, "Failed to fetch profile", log, profileFetchError); } - const userId = data.user?.id; - - const { error: profileError } = await supabase.from("profiles").insert({ - user_id: userId, - verified: true, - }); - - if (profileError) { - return logError(500, "Failed to create profile", log, profileError); + if (existingProfile?.onboarded) { + redirect(303, "/dashboard"); } - throw redirect(303, `${url.origin}/auth/signup?step=profile`); + redirect(303, "/auth/complete-profile"); }; diff --git a/src/routes/(auth)/auth/complete-profile/+page.server.ts b/src/routes/(auth)/auth/complete-profile/+page.server.ts new file mode 100644 index 00000000..25934084 --- /dev/null +++ b/src/routes/(auth)/auth/complete-profile/+page.server.ts @@ -0,0 +1,83 @@ +import { fail, redirect } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import { zod4 } from "sveltekit-superforms/adapters"; +import { superValidate, setError } from "sveltekit-superforms"; +import { completeProfileSchema } from "$lib/components/validation/auth"; +import { addToEmailList } from "$lib/components/helper_functions/addToEmailList"; +import { logError } from "$lib/server/logError"; + +export const load: PageServerLoad = async ({ + locals: { user, supabase, log }, +}) => { + if (!user) { + redirect(303, "/auth/login"); + } + + const { data: profile, error: profileErr } = await supabase + .from("profiles") + .select("username, onboarded") + .eq("user_id", user.id) + .maybeSingle(); + + if (profileErr) { + logError(500, "Failed to fetch error", log, profileErr); + } + + if (profile?.onboarded && profile?.username) { + redirect(303, "/dashboard"); + } + + return { + form: await superValidate(zod4(completeProfileSchema)), + meta: { + title: "Complete your profile", + noindex: true, + }, + }; +}; + +export const actions: Actions = { + default: async ({ request, locals: { user, supabase, log } }) => { + if (!user) { + return fail(401, { message: "Unauthorized" }); + } + + const form = await superValidate(request, zod4(completeProfileSchema)); + if (!form.valid) { + return fail(400, { form }); + } + + const { display_name, username } = form.data; + + const { error: profileUpdateError } = await supabase + .from("profiles") + .update({ + username, + display_name, + onboarded: true, + }) + .eq("user_id", user.id); + + if (profileUpdateError?.code === "23505") { + return setError(form, "display_name", "This username is already taken."); + } + + if (profileUpdateError) { + log.error( + { error: profileUpdateError }, + "Failed to update user profile row", + ); + return fail(500, { form, message: profileUpdateError.message }); + } + + const addToEmailListResponse = await addToEmailList( + user.email, + display_name, + ); + if (!addToEmailListResponse.success) { + log.warn(`Failed to add user to list: ${addToEmailListResponse.error}`); + } + + redirect(303, "/auth/signup?step=survey"); + }, +}; diff --git a/src/routes/(auth)/auth/complete-profile/+page.svelte b/src/routes/(auth)/auth/complete-profile/+page.svelte new file mode 100644 index 00000000..3f442690 --- /dev/null +++ b/src/routes/(auth)/auth/complete-profile/+page.svelte @@ -0,0 +1,80 @@ + + +
+
+

Almost there!

+

+ Choose a unique username to finalize your CubeIndex profile. +

+ +
+
+ Display Name + + {#if $errors.display_name} + + {$errors.display_name} + + {/if} +
+ +
+ Username + +

+ You won't be able to change it in the future! Choose wisely! +

+ {#if $errors.username} + + {$errors.username} + + {/if} +
+ + {#if $message} +

+ {$message} +

+ {/if} + + +
+
+
diff --git a/src/routes/(auth)/auth/confirm/+server.ts b/src/routes/(auth)/auth/confirm/+server.ts index 9310b824..ed9fa32f 100644 --- a/src/routes/(auth)/auth/confirm/+server.ts +++ b/src/routes/(auth)/auth/confirm/+server.ts @@ -6,70 +6,54 @@ export const GET: RequestHandler = async ({ url, locals }) => { const { supabase, log } = locals; const code = url.searchParams.get("code"); if (!code) { - return logError( + logError( 400, - "Verification link is invalid", + "Missing code parameter", log, new Error("Missing code parameter"), ); } - // 1) Exchange the code for a session (sets cookies via the server client) - // For @supabase/auth-helpers-sveltekit this accepts a plain string `code`. - // If you use the client directly from supabase-js, it’s `{ code }`. const { data, error: authErr } = await supabase.auth.exchangeCodeForSession(code); + if (authErr) { - return logError(500, "Verification failed", log, authErr); + logError(500, "Failed to exchange code for session", log, authErr); } - const user = data?.user; - if (!user?.id) { - return logError( - 500, - "Verification failed", - log, - new Error("User missing after verification"), - ); - } + const user = data.user; - // 2) Do we already have a profile? - // Use maybeSingle() so “no rows” isn’t an error. const { data: existingProfile, error: profileErr } = await supabase .from("profiles") - .select("username") + .select("username, onboarded") .eq("user_id", user.id) .maybeSingle(); if (profileErr) { - return logError(500, "Failed to check profile", log, profileErr); + logError(500, "Failed to check profile", log, profileErr); } - // 3) If a profile exists, mark it verified (idempotent) and route smartly. - if (existingProfile) { - const { error: updateErr } = await supabase - .from("profiles") - .update({ verified: true }) - .eq("user_id", user.id); - if (updateErr) { - return logError(500, "Failed to update profile", log, updateErr); - } + if (!existingProfile) { + logError( + 400, + "No existing profile was found", + log, + new Error("No existing profile was found"), + ); + } - // If username is missing, continue onboarding instead of /user/null - if (!existingProfile.username) { - throw redirect(303, "/auth/signup?step=profile"); - } + const { error: updateErr } = await supabase + .from("profiles") + .update({ verified: true }) + .eq("user_id", user.id); - throw redirect(303, `/user/${existingProfile.username}`); + if (updateErr) { + logError(500, "Failed to update profile", log, updateErr); } - // 4) No profile yet — create a minimal verified record, then continue onboarding. - const { error: createErr } = await supabase - .from("profiles") - .insert({ user_id: user.id, verified: true }); - if (createErr) { - return logError(500, "Failed to create profile", log, createErr); + if (!existingProfile.onboarded) { + redirect(303, "/auth/complete-profile"); } - throw redirect(303, "/auth/signup?step=profile"); + redirect(303, `/user/${existingProfile.username}`); }; diff --git a/src/routes/(auth)/auth/discord/+server.ts b/src/routes/(auth)/auth/discord/+server.ts index 3a375097..b627084c 100644 --- a/src/routes/(auth)/auth/discord/+server.ts +++ b/src/routes/(auth)/auth/discord/+server.ts @@ -1,17 +1,21 @@ +import { logError } from "$lib/server/logError"; import type { RequestHandler } from "./$types"; import { redirect } from "@sveltejs/kit"; -export const GET: RequestHandler = async ({ url, locals: { supabase } }) => { - const { data } = await supabase.auth.signInWithOAuth({ +export const GET: RequestHandler = async ({ + url, + locals: { supabase, log }, +}) => { + const { data, error } = await supabase.auth.signInWithOAuth({ provider: "discord", options: { redirectTo: `${url.origin}/auth/callback`, }, }); - if (data.url) { - redirect(307, data.url); + if (error) { + logError(500, "Failed to initiate Discord login", log, error); } - redirect(307, "/auth/error"); + redirect(307, data.url); }; diff --git a/src/routes/(auth)/auth/google/+server.ts b/src/routes/(auth)/auth/google/+server.ts new file mode 100644 index 00000000..779c8ef4 --- /dev/null +++ b/src/routes/(auth)/auth/google/+server.ts @@ -0,0 +1,21 @@ +import { logError } from "$lib/server/logError"; +import type { RequestHandler } from "./$types"; +import { redirect } from "@sveltejs/kit"; + +export const GET: RequestHandler = async ({ + url, + locals: { supabase, log }, +}) => { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${url.origin}/auth/callback`, + }, + }); + + if (error) { + logError(500, "Failed to initiate Google login", log, error); + } + + redirect(307, data.url); +}; diff --git a/src/routes/(auth)/auth/signup/+page.server.ts b/src/routes/(auth)/auth/signup/+page.server.ts index ebfdb9ec..1ec0566f 100644 --- a/src/routes/(auth)/auth/signup/+page.server.ts +++ b/src/routes/(auth)/auth/signup/+page.server.ts @@ -2,27 +2,21 @@ import { fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import { zod4 } from "sveltekit-superforms/adapters"; import { superValidate } from "sveltekit-superforms"; -import { - accountSchema, - profileSchema, - surveySchema, -} from "$lib/components/validation/signup"; -import { withFiles } from "sveltekit-superforms"; +import { accountSchema, surveySchema } from "$lib/components/validation/auth"; import { setError } from "sveltekit-superforms"; import { TURNSTILE_SECRET_KEY } from "$env/static/private"; import { validateTurnstileToken } from "$lib/components/helper_functions/validateTurnstileToken"; +import { resolve } from "$app/paths"; export const load: PageServerLoad = async ({ url }) => { const step = (url.searchParams.get("step") ?? "account") as | "account" - | "profile" | "survey" | "done"; return { step, accountForm: await superValidate(zod4(accountSchema)), - profileForm: await superValidate(zod4(profileSchema)), surveyForm: await superValidate(zod4(surveySchema)), meta: { title: "Signup - CubeIndex", @@ -33,7 +27,7 @@ export const load: PageServerLoad = async ({ url }) => { }; export const actions: Actions = { - createAccount: async ({ request, locals: { supabase }, url }) => { + createAccount: async ({ request, locals: { supabase, log }, url }) => { const form = await superValidate(request, zod4(accountSchema)); if (!form.valid) return fail(400, { accountForm: form }); @@ -55,177 +49,34 @@ export const actions: Actions = { const { error: err } = await supabase.auth.signUp({ email, password, - options: { emailRedirectTo: `${url.origin}/auth/confirm` }, + options: { + emailRedirectTo: `${url.origin}/auth/confirm`, + }, }); - if (err) + if (err) { + log.error(err); return fail(500, { accountForm: { ...form, message: err.message } }); - return { - accountForm: { - ...form, - message: "Please verify your email to continue with your signup.", - }, - }; - }, - - createProfile: async ({ request, locals: { supabase }, url, fetch }) => { - const form = await superValidate(request, zod4(profileSchema)); - if (!form.valid) return withFiles(fail(400, { profileForm: form })); - - // Supabase user - const { - data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user) { - return withFiles( - fail(401, { - profileForm: { - ...form, - message: `Failed to retrieve user data: ${ - userErr?.message ?? "No user found." - }`, - }, - }), - ); - } - - const file = form.data.avatar ?? null; - - let avatarUrl: string = ""; - - if (file && file.size > 0) { - // 1) Send to your hardened processor (validates magic bytes, size, pixels, re-encodes to WebP) - const imgFd = new FormData(); - imgFd.set("file", file); - imgFd.set("type", "avatar"); - const resp = await fetch(`/api/user/avatar`, { - method: "POST", - body: imgFd, - }); - if (!resp.ok) { - const errText = await resp.text(); - form.message = - "Avatar processing failed: " + errText || resp.statusText; - return withFiles(fail(400, { profileForm: form })); - } - const processed = new Uint8Array(await resp.arrayBuffer()); - - // 2) Upload normalized bytes to Storage with a fixed path & content type - const path = `${user.id}/avatar.webp`; - const { error: upErr } = await supabase.storage - .from("avatars") - .upload(path, processed, { - upsert: true, - contentType: "image/webp", // fixed, known good - cacheControl: "public, max-age=31536000, immutable", - }); - if (upErr) - return withFiles( - fail(400, { - profileForm: { - ...form, - message: "Avatar upload failed: " + upErr.message, - }, - }), - ); - - // 3) Get a URL: public bucket -> getPublicUrl; private -> createSignedUrl - const { data } = supabase.storage.from("avatars").getPublicUrl(path); - avatarUrl = data.publicUrl; - } - - const { display_name, username } = form.data; - - const { error: upsertError } = await supabase - .from("profiles") - .update({ - username, - display_name, - profile_picture: avatarUrl, - onboarded: true, - }) - .eq("user_id", user.id); - - if ( - upsertError?.message === - 'insert or update on table "profiles" violates foreign key constraint "profiles_user_id_fkey"' - ) { - return withFiles( - fail(400, { - profileForm: { - ...form, - message: - "An account with this email already exists. Please log in or use a different email address.", - }, - }), - ); } - if ( - upsertError?.message === - 'duplicate key value violates unique constraint "profiles_username_key"' - ) { - return withFiles( - fail(400, { - profileForm: { - ...form, - errors: { username: ["This username is already taken."] }, - }, - }), - ); - } - - if (upsertError) { - return withFiles( - fail(400, { - profileForm: { ...form, message: upsertError.message }, - }), - ); - } - - const metadataDisplayName = - user.user_metadata?.full_name?.trim() || undefined; - - const payload = { - email: user.email || "", - display_name: - display_name ?? - username ?? - metadataDisplayName ?? - user.email?.split("@")[0] ?? - "User", - }; - - await fetch( - "https://spsqaktodgqnqbkgilxp.supabase.co/functions/v1/brevo-sync-contact", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - - // Next: survey step - throw redirect(303, `${url.pathname}?step=survey`); + redirect(303, resolve("/auth/complete-profile")); }, - submitSurvey: async ({ request, locals: { supabase }, url }) => { + submitSurvey: async ({ request, locals: { supabase, user }, url }) => { const form = await superValidate(request, zod4(surveySchema)); - if (!form.valid) return fail(400, { surveyForm: form }); - - const { - data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user) { + if (!user) { return fail(401, { - surveyForm: { ...form, message: "You must be signed in to continue." }, + profileForm: { + ...form, + message: `Authenticated user not found`, + }, }); } - // Persist preferences (create a table like user_onboarding) + if (!form.valid) return fail(400, { surveyForm: form }); + const { discovered_via, other_text } = form.data; const interested_features = JSON.stringify(form.data.interested_features); + const { error: insErr } = await supabase.from("user_onboarding").insert({ user_id: user.id, discovered_via, @@ -234,10 +85,9 @@ export const actions: Actions = { }); if (insErr) { - return fail(400, { surveyForm: { ...form, message: insErr.message } }); + return fail(500, { surveyForm: { ...form, message: insErr.message } }); } - // Done - throw redirect(303, `${url.pathname}?step=done`); + redirect(303, `${url.pathname}?step=done`); }, }; diff --git a/src/routes/(auth)/auth/signup/+page.svelte b/src/routes/(auth)/auth/signup/+page.svelte index c4ba65c7..38b0d47a 100644 --- a/src/routes/(auth)/auth/signup/+page.svelte +++ b/src/routes/(auth)/auth/signup/+page.svelte @@ -4,26 +4,27 @@ import { passwordStrength } from "$lib/components/helper_functions/passwordStrength"; import { Turnstile } from "svelte-turnstile"; import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public"; + import ExternalAuthProviders from "$lib/components/layout/ExternalAuthProviders.svelte"; + import { untrack } from "svelte"; let { data } = $props(); - // Three forms (one per step) const { form: account, errors: accountErrors, enhance: enhanceAccount, message: accountMessage, - } = $derived(superForm(data.accountForm, { resetForm: false })); - - const { - form: profile, - errors: profileErrors, - enhance: enhanceProfile, - message: profileMessage, - } = $derived( - superForm(data.profileForm, { + constraints: accountConstraints, + } = superForm( + untrack(() => data.accountForm), + { resetForm: false, - }), + onUpdate({ result }) { + if (result.type === "failure") { + resetTurnstile?.(); + } + }, + }, ); const { @@ -31,28 +32,20 @@ errors: surveyErrors, enhance: enhanceSurvey, message: surveyMessage, - } = $derived( - superForm(data.surveyForm, { dataType: "json", resetForm: false }), + } = superForm( + untrack(() => data.surveyForm), + { dataType: "json", resetForm: false }, ); const step = $derived(data.step); let showPassword = $state(false); - let pwScore: 0 | 1 | 2 | 3 | 4 = $state(0); - let pwLabel = $state("Very weak"); - let pwSuggestions: string[] = $state([]); - $effect(() => { - const s = passwordStrength($account.password ?? ""); - pwScore = s.score; - pwLabel = s.label; - pwSuggestions = s.suggestions; - }); + const pwStrength = $derived(passwordStrength($account.password)); const steps = [ { key: "account", label: "Account" }, - { key: "profile", label: "Profile" }, - { key: "survey", label: "Preferences" }, + { key: "survey", label: "Survey" }, { key: "done", label: "Done" }, ] as const; @@ -60,462 +53,337 @@ const isDone = (k: string) => steps.findIndex((s) => s.key === step) > steps.findIndex((s) => s.key === k); - - -
-
- -
-
-

Join CubeIndex

-

- Create a free account to start tracking your collection -

- -
    - {#each steps as s, index (index)} -
  1. - {s.label} -
  2. - {/each} -
+ let resetTurnstile: (() => void) | undefined = $state(); + - {#if step === "account"} - -
+
+
+

Join CubeIndex

+

+ Create a free account to start tracking your collection +

+ + +
    + {#each steps as s, index (index)} +
  1. - -
    - - {#if $accountErrors.email} - - {$accountErrors.email} - - {/if} -
    - - -
    - - {#if $accountErrors.password} - - {$accountErrors.password} - - {/if} - - -
    -
    -
    = 3} - >
    -
    -
    {pwLabel}
    - {#if pwSuggestions.length} -
      - {#each pwSuggestions.slice(0, 3) as s, index (index)}
    • - {s} -
    • {/each} -
    - {/if} -
    -
    - - -
    - - {#if $accountErrors.confirmPassword} - {$accountErrors.confirmPassword} - {/if} -
    + {s.label} +
  2. + {/each} +
+ + {#if step === "account"} + +
+ Email + + {#if $accountErrors.email} + + {$accountErrors.email} + + {/if} +
- -
+
+ Password +
-
- I accept the - Terms of Service - and - Privacy Policy -
-
- {#if $accountMessage} -

- {$accountMessage} -

- {/if} - - - -
- - {#if $accountErrors["cf-turnstile-response"]} - - {$accountErrors["cf-turnstile-response"]} - - {/if} -
- -
or
- - - - Sign Up with Discord - - - {/if} - - {#if step === "profile"} - -
-
- - {#if $profileErrors.display_name} - - {$profileErrors.display_name} - - {/if} -
- -
-
- -
- - {#if $profileErrors.avatar} - - {$profileErrors.avatar} - - {/if} -
- - {#if $profileMessage} -

- {$profileMessage} -

+ {#if $accountErrors.password} + + {$accountErrors.password} + {/if} - -
- {/if} - - {#if step === "survey"} - -
-
- - {#if $surveyErrors.discovered_via} - - {$surveyErrors.discovered_via} - - {/if} -
- -
-
+ +
+ Confirm Password + + {#if $accountErrors.confirmPassword} + + {$accountErrors.confirmPassword} + + {/if} +
+ +
+ +
+ I accept the + Terms of Service + and + Privacy Policy
- - {#if $surveyMessage} -

- {$surveyMessage} -

+
+ {#if $accountMessage} +

+ {$accountMessage} +

+ {/if} + +
+ + {#if $accountErrors["cf-turnstile-response"]} + + {$accountErrors["cf-turnstile-response"]} + {/if} +
- - - {/if} + - {#if step === "done"} -
-

You're all set!

-

- Check your email to verify your account. Once verified, you can - start tracking your collection, compare shops, and get price-drop - alerts on Discord. -

- Start Collecting! -
- {/if} +
or with
+ + + -

Already have an account? - Log in + + Log in +

-
-
+ {:else if step === "survey"} +
+
+ + {#if $surveyErrors.discovered_via} + + {$surveyErrors.discovered_via} + + {/if} +
- -
+ + +
+
diff --git a/src/routes/(auth)/auth/wca/+server.ts b/src/routes/(auth)/auth/wca/+server.ts new file mode 100644 index 00000000..11199ce7 --- /dev/null +++ b/src/routes/(auth)/auth/wca/+server.ts @@ -0,0 +1,21 @@ +import { logError } from "$lib/server/logError"; +import type { RequestHandler } from "./$types"; +import { redirect } from "@sveltejs/kit"; + +export const GET: RequestHandler = async ({ + url, + locals: { supabase, log }, +}) => { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: "custom:wca", + options: { + redirectTo: `${url.origin}/auth/callback`, + }, + }); + + if (error) { + logError(500, "Failed to initiate WCA login", log, error); + } + + redirect(307, data.url); +}; diff --git a/supabase/config.toml b/supabase/config.toml index d690d64c..bdd9ada7 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -28,7 +28,7 @@ port = 54322 shadow_port = 54320 # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. -major_version = 15.8 +major_version = 15 [db.pooler] enabled = false @@ -87,7 +87,7 @@ openai_api_key = "env(OPENAI_API_KEY)" # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they # are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] +[local_smtp] enabled = true # Port to use for the email testing server web interface. port = 54324 @@ -117,9 +117,9 @@ file_size_limit = "50MiB" enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://127.0.0.1:3000" +site_url = "http://localhost:5173" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] +additional_redirect_urls = ["http://localhost:5174"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # If disabled, the refresh token will never expire. @@ -271,6 +271,18 @@ url = "" # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. skip_nonce_check = false +[auth.external.google] +enabled = true +client_id = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID)" +secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET)" +skip_nonce_check = false + +[auth.external.discord] +enabled = true +client_id = "env(SUPABASE_AUTH_EXTERNAL_DISCORD_CLIENT_ID)" +secret = "env(SUPABASE_AUTH_EXTERNAL_DISCORD_CLIENT_SECRET)" +skip_nonce_check = false + # Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. # You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. [auth.web3.solana] diff --git a/supabase/migrations/20260629085141_profile-creation.sql b/supabase/migrations/20260629085141_profile-creation.sql new file mode 100644 index 00000000..005ae449 --- /dev/null +++ b/supabase/migrations/20260629085141_profile-creation.sql @@ -0,0 +1,46 @@ +alter table "public"."brands" drop constraint "brands_added_by_id_fkey"; + +alter table "public"."cube_models" drop constraint "cube_models_verified_by_id_fkey"; + +alter table "public"."brands" alter column "added_by_id" set default '898d0e3a-3465-4c25-9b9f-b498b9884d1d'::uuid; + +alter table "public"."cube_models" alter column "verified_by_id" set default '898d0e3a-3465-4c25-9b9f-b498b9884d1d'::uuid; + +alter table "public"."profiles" alter column "display_name" set not null; + +alter table "public"."profiles" alter column "username" set not null; + +alter table "public"."profiles" add constraint "profiles_display_name_check" CHECK ((length(display_name) > 3)) not valid; + +alter table "public"."profiles" validate constraint "profiles_display_name_check"; + +alter table "public"."brands" add constraint "brands_added_by_id_fkey" FOREIGN KEY (added_by_id) REFERENCES public.profiles(user_id) ON UPDATE CASCADE ON DELETE SET DEFAULT not valid; + +alter table "public"."brands" validate constraint "brands_added_by_id_fkey"; + +alter table "public"."cube_models" add constraint "cube_models_verified_by_id_fkey" FOREIGN KEY (verified_by_id) REFERENCES public.profiles(user_id) ON UPDATE CASCADE ON DELETE SET DEFAULT not valid; + +alter table "public"."cube_models" validate constraint "cube_models_verified_by_id_fkey"; + +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO '' +AS $function$ +begin + insert into public.profiles (user_id, display_name, username, profile_picture) + values (new.id, 'Username', concat('user_', replace(new.id::text, '-', '')), COALESCE( + NEW.raw_user_meta_data->>'avatar_url', + NEW.raw_user_meta_data->>'picture', + NEW.raw_user_meta_data->>'avatar', + '' + )); + return new; +end; +$function$ +; + +CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); diff --git a/supabase/scripts/setup-wca-provider.js b/supabase/scripts/setup-wca-provider.js new file mode 100644 index 00000000..466d9b1c --- /dev/null +++ b/supabase/scripts/setup-wca-provider.js @@ -0,0 +1,39 @@ +import { createClient } from '@supabase/supabase-js'; + +process.loadEnvFile(".env.local") + +const supabaseUrl = process.env.PUBLIC_SUPABASE_URL +const supabaseSecretKey = process.env.SUPABASE_SECRET_KEY + +if (!supabaseUrl && !supabaseSecretKey) { + throw new Error("Please specify the Supabase url and secret key") +} + +const supabase = createClient(supabaseUrl, supabaseSecretKey); + +const wcaClientId = process.env.SUPABASE_AUTH_EXTERNAL_WCA_CLIENT_ID +const wcaClientSecret = process.env.SUPABASE_AUTH_EXTERNAL_WCA_CLIENT_SECRET + +if (!wcaClientId || !wcaClientSecret) { + throw new Error("Please specify the WCA client id and secret key") +} + +async function registerWCAProvider() { + const { data, error } = await supabase.auth.admin.customProviders.createProvider({ + provider_type: 'oidc', + identifier: 'custom:wca', + name: 'WCA', + client_id: wcaClientId, + client_secret: wcaClientSecret, + issuer: 'https://www.worldcubeassociation.org', + scopes: ['public', 'openid', 'profile', 'email'], + }) + + if (error) { + console.error('Error creating custom provider:', error); + } else { + console.log('WCA Custom Provider created successfully:', data); + } +} + +registerWCAProvider(); From cb9ebbe572bdf87c9335d94fc1d4fc06e31ef00a Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 10:33:22 +0100 Subject: [PATCH 04/18] feat(auth): refactor login flow to use centralized login schema and improve error handling --- src/routes/(auth)/auth/login/+page.server.ts | 40 +++++---- src/routes/(auth)/auth/login/+page.svelte | 91 +++++++++++--------- 2 files changed, 72 insertions(+), 59 deletions(-) diff --git a/src/routes/(auth)/auth/login/+page.server.ts b/src/routes/(auth)/auth/login/+page.server.ts index f5e6fbc6..89fc6f40 100644 --- a/src/routes/(auth)/auth/login/+page.server.ts +++ b/src/routes/(auth)/auth/login/+page.server.ts @@ -1,20 +1,14 @@ -import { error, fail, redirect } from "@sveltejs/kit"; +import { fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; -import { z } from "zod/v4"; import { setError, superValidate } from "sveltekit-superforms"; import { zod4 } from "sveltekit-superforms/adapters"; import { TURNSTILE_SECRET_KEY } from "$env/static/private"; import { validateTurnstileToken } from "$lib/components/helper_functions/validateTurnstileToken"; - -const schema = z.object({ - email: z.email().nonempty(), - password: z.string().nonempty(), - "cf-turnstile-response": z.string().nonempty("Please complete the Captcha"), -}); +import { loginSchema } from "$lib/components/validation/auth"; export const load = (async () => { return { - form: await superValidate(zod4(schema)), + form: await superValidate(zod4(loginSchema)), meta: { title: "Login - CubeIndex", description: @@ -24,9 +18,9 @@ export const load = (async () => { }) satisfies PageServerLoad; export const actions: Actions = { - default: async ({ request, locals: { supabase } }) => { - const form = await superValidate(request, zod4(schema)); - if (!form.valid) return fail(400, { profileForm: form }); + default: async ({ request, locals: { supabase }, url }) => { + const form = await superValidate(request, zod4(loginSchema)); + if (!form.valid) return fail(400, { form }); const { success } = await validateTurnstileToken( form.data["cf-turnstile-response"], @@ -51,16 +45,30 @@ export const actions: Actions = { password, }); - if (err) error(500, { message: err.message }); - if (!user) error(500, { message: "User not returned by Supabase" }); + if (err) return fail(500, { form: { ...form, message: err.message } }); + if (!user) + return fail(500, { + form: { ...form, message: "User not returned by Supabase" }, + }); const { data: profile, error: profileErr } = await supabase .from("profiles") .select("username") - .eq("user_id", user?.id) + .eq("user_id", user.id) .single(); - if (profileErr) error(500, { message: profileErr.message }); + if (profileErr) + return fail(500, { form: { ...form, message: profileErr.message } }); + + const redirect_to = url.searchParams.get("redirect_to"); + + if ( + redirect_to && + redirect_to.startsWith("/") && + !redirect_to.startsWith("//") + ) { + redirect(303, redirect_to); + } redirect(303, `/user/${profile.username}`); }, diff --git a/src/routes/(auth)/auth/login/+page.svelte b/src/routes/(auth)/auth/login/+page.svelte index 404dcd9b..16d67f43 100644 --- a/src/routes/(auth)/auth/login/+page.svelte +++ b/src/routes/(auth)/auth/login/+page.svelte @@ -3,35 +3,45 @@ import { Turnstile } from "svelte-turnstile"; import { superForm } from "sveltekit-superforms"; import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public"; - import { page } from "$app/state"; - - const supabase = page.data.supabase; + import ExternalAuthProviders from "$lib/components/layout/ExternalAuthProviders.svelte"; + import { untrack } from "svelte"; const { data } = $props(); - - const { form, errors, delayed, enhance, message, isTainted, tainted } = - $derived( - superForm(data.form, { - onError({ result }) { - $message = result.error.message || "Unknown error"; - }, - delayMs: 500, - timeoutMs: 8000, - }), - ); + const { supabase } = $derived(data); + + const { + form, + errors, + delayed, + enhance, + message, + isTainted, + tainted, + constraints, + submitting, + } = superForm( + untrack(() => data.form), + { + onUpdate({ result }) { + if (result.type === "failure") { + resetTurnstile?.(); + } + }, + delayMs: 500, + timeoutMs: 8000, + }, + ); let showPassword = $state(false); let resetError: string = $state(""); let resetMessage: string = $state(""); - let isSubmitting = $state(false); async function resetPassword(e: Event) { e.preventDefault(); resetError = ""; resetMessage = ""; if (!$form.email) { - resetError = "Please enter an email"; - return; + return (resetError = "Please enter an email"); } const { error: err } = await supabase.auth.resetPasswordForEmail( $form.email, @@ -41,46 +51,46 @@ ); if (err) { - resetError = err.message; - return; + return (resetError = err.message); } resetMessage = "Check your email to reset your password"; } + + let resetTurnstile: (() => void) | undefined = $state();
-
+

Welcome Back

Log in to your CubeIndex profile

-
- +
+ Email {#if $errors.email} {$errors.email} {/if} -
+ -
- +
+ Password
+

Forgot your password? @@ -115,7 +125,7 @@ - {#if message} -

{message}

- {/if} - {#if errorMsg} -

{errorMsg}

+ {#if $message} +

{$message}

{/if}

- Remembered it? Back to Login + Back to Login +

diff --git a/src/routes/(auth)/auth/reset/+page.ts b/src/routes/(auth)/auth/reset/+page.ts deleted file mode 100644 index 2f05463b..00000000 --- a/src/routes/(auth)/auth/reset/+page.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PageLoad } from "./$types"; - -export const load = (async () => { - return { meta: { title: "Reset Password - CubeIndex", noindex: true } }; -}) satisfies PageLoad; From 8f7fa3c447bc70f5744a9ecfbf50713579e77655 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 10:35:00 +0100 Subject: [PATCH 06/18] feat(auth): streamline user retrieval in resend handler by using locals --- src/routes/(auth)/auth/resend/+server.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/routes/(auth)/auth/resend/+server.ts b/src/routes/(auth)/auth/resend/+server.ts index 11511438..d38da554 100644 --- a/src/routes/(auth)/auth/resend/+server.ts +++ b/src/routes/(auth)/auth/resend/+server.ts @@ -1,12 +1,11 @@ import { redirect } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; -export const GET: RequestHandler = async ({ locals: { supabase }, url }) => { - const { - data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user?.email) +export const GET: RequestHandler = async ({ + locals: { supabase, user }, + url, +}) => { + if (!user?.email) return new Response(JSON.stringify({ message: "Not signed in." }), { status: 400, }); From 0ee084b2c3b8724e227603fd588309923feffa39 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 10:35:07 +0100 Subject: [PATCH 07/18] feat(auth): remove unused load function from layout server --- src/routes/(auth)/+layout.server.ts | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 src/routes/(auth)/+layout.server.ts diff --git a/src/routes/(auth)/+layout.server.ts b/src/routes/(auth)/+layout.server.ts deleted file mode 100644 index 7b039895..00000000 --- a/src/routes/(auth)/+layout.server.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const load = async () => { - return {}; -}; From 7077e5f21ed3f1a7a150b91b65d706203d1332cd Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 10:58:46 +0100 Subject: [PATCH 08/18] fix(auth): remove trim from email validation --- src/lib/components/validation/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/validation/auth.ts b/src/lib/components/validation/auth.ts index 904db403..8920bb9a 100644 --- a/src/lib/components/validation/auth.ts +++ b/src/lib/components/validation/auth.ts @@ -2,7 +2,7 @@ import { z } from "zod/v4"; export const USERNAME_REGEX = /^[a-z0-9._]{3,}$/; -const email = z.email("Please enter a valid email address").trim().nonempty(); +const email = z.email("Please enter a valid email address").nonempty(); const password = z .string() .min(8, "Password must be at least 8 characters") From c1d7c60e34b2721dd878109542c7a7e7daa82175 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:04:02 +0100 Subject: [PATCH 09/18] fix(auth): correct error message for username conflict in profile update --- src/routes/(auth)/auth/complete-profile/+page.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(auth)/auth/complete-profile/+page.server.ts b/src/routes/(auth)/auth/complete-profile/+page.server.ts index 25934084..1931a97e 100644 --- a/src/routes/(auth)/auth/complete-profile/+page.server.ts +++ b/src/routes/(auth)/auth/complete-profile/+page.server.ts @@ -59,7 +59,7 @@ export const actions: Actions = { .eq("user_id", user.id); if (profileUpdateError?.code === "23505") { - return setError(form, "display_name", "This username is already taken."); + return setError(form, "username", "This username is already taken."); } if (profileUpdateError) { From 890301d2e53db53f1319213bebed76b8d15304bc Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:17:52 +0100 Subject: [PATCH 10/18] fix(auth): improve redirect logic for login flow --- src/routes/(auth)/auth/login/+page.server.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/routes/(auth)/auth/login/+page.server.ts b/src/routes/(auth)/auth/login/+page.server.ts index 89fc6f40..48863e54 100644 --- a/src/routes/(auth)/auth/login/+page.server.ts +++ b/src/routes/(auth)/auth/login/+page.server.ts @@ -62,12 +62,15 @@ export const actions: Actions = { const redirect_to = url.searchParams.get("redirect_to"); - if ( - redirect_to && - redirect_to.startsWith("/") && - !redirect_to.startsWith("//") - ) { - redirect(303, redirect_to); + if (redirect_to && !redirect_to.includes("\\")) { + const target = new URL(redirect_to, url.origin); + if ( + target.origin === url.origin && + redirect_to.startsWith("/") && + !redirect_to.startsWith("//") + ) { + redirect(303, `${target.pathname}${target.search}${target.hash}`); + } } redirect(303, `/user/${profile.username}`); From 6993acf402eefe0952eef7402debcae502829872 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:19:11 +0100 Subject: [PATCH 11/18] fix(auth): bind password input to form state in login component --- src/routes/(auth)/auth/login/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/(auth)/auth/login/+page.svelte b/src/routes/(auth)/auth/login/+page.svelte index 16d67f43..063757b6 100644 --- a/src/routes/(auth)/auth/login/+page.svelte +++ b/src/routes/(auth)/auth/login/+page.svelte @@ -89,6 +89,7 @@ From 33c57ecbd0c3308a16d59d626854de5998dbf6fa Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:20:44 +0100 Subject: [PATCH 12/18] fix(auth): correct form key in error response for survey submission --- src/routes/(auth)/auth/signup/+page.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(auth)/auth/signup/+page.server.ts b/src/routes/(auth)/auth/signup/+page.server.ts index 1ec0566f..b47ba2ab 100644 --- a/src/routes/(auth)/auth/signup/+page.server.ts +++ b/src/routes/(auth)/auth/signup/+page.server.ts @@ -65,7 +65,7 @@ export const actions: Actions = { const form = await superValidate(request, zod4(surveySchema)); if (!user) { return fail(401, { - profileForm: { + surveyForm: { ...form, message: `Authenticated user not found`, }, From d6fd3eb0ffff9ec7783384826a09ddcce8864436 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:29:23 +0100 Subject: [PATCH 13/18] fix(auth): disable submit button and show loading state during account creation --- src/routes/(auth)/auth/signup/+page.svelte | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/routes/(auth)/auth/signup/+page.svelte b/src/routes/(auth)/auth/signup/+page.svelte index 38b0d47a..b7612bf4 100644 --- a/src/routes/(auth)/auth/signup/+page.svelte +++ b/src/routes/(auth)/auth/signup/+page.svelte @@ -15,6 +15,7 @@ enhance: enhanceAccount, message: accountMessage, constraints: accountConstraints, + submitting: accountSubmitting, } = superForm( untrack(() => data.accountForm), { @@ -210,8 +211,17 @@ {/if} -
or with
From 5ba9e5ba60688aaad2bfbfca3721860f7fa8c834 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:29:32 +0100 Subject: [PATCH 14/18] fix(auth): correct condition for Supabase URL and secret key validation --- supabase/scripts/setup-wca-provider.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/supabase/scripts/setup-wca-provider.js b/supabase/scripts/setup-wca-provider.js index 466d9b1c..3618312e 100644 --- a/supabase/scripts/setup-wca-provider.js +++ b/supabase/scripts/setup-wca-provider.js @@ -5,7 +5,7 @@ process.loadEnvFile(".env.local") const supabaseUrl = process.env.PUBLIC_SUPABASE_URL const supabaseSecretKey = process.env.SUPABASE_SECRET_KEY -if (!supabaseUrl && !supabaseSecretKey) { +if (!supabaseUrl || !supabaseSecretKey) { throw new Error("Please specify the Supabase url and secret key") } @@ -31,9 +31,13 @@ async function registerWCAProvider() { if (error) { console.error('Error creating custom provider:', error); + process.exitCode = 1; } else { console.log('WCA Custom Provider created successfully:', data); } } -registerWCAProvider(); +await registerWCAProvider().catch((error) => { + console.error('Unexpected error creating custom provider:', error); + process.exitCode = 1; +}); From b35679e00bca6a0c5dae4e6dd57b25096f0a08f0 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:34:31 +0100 Subject: [PATCH 15/18] fix(auth): include onboarding check in login action and adjust profile query --- src/routes/(auth)/auth/login/+page.server.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/routes/(auth)/auth/login/+page.server.ts b/src/routes/(auth)/auth/login/+page.server.ts index 48863e54..ad6e0ad7 100644 --- a/src/routes/(auth)/auth/login/+page.server.ts +++ b/src/routes/(auth)/auth/login/+page.server.ts @@ -53,13 +53,17 @@ export const actions: Actions = { const { data: profile, error: profileErr } = await supabase .from("profiles") - .select("username") + .select("username, onboarded") .eq("user_id", user.id) - .single(); + .maybeSingle(); if (profileErr) return fail(500, { form: { ...form, message: profileErr.message } }); + if (!profile || !profile.onboarded) { + redirect(303, "/auth/complete-profile"); + } + const redirect_to = url.searchParams.get("redirect_to"); if (redirect_to && !redirect_to.includes("\\")) { From 73b77163d77a2c1c734c4aef9fffa192ed14ef8c Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 11:40:48 +0100 Subject: [PATCH 16/18] fix(email): refactor addToEmailList function for improved error handling and readability --- .../helper_functions/addToEmailList.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/lib/components/helper_functions/addToEmailList.ts b/src/lib/components/helper_functions/addToEmailList.ts index 6045815d..30175872 100644 --- a/src/lib/components/helper_functions/addToEmailList.ts +++ b/src/lib/components/helper_functions/addToEmailList.ts @@ -6,23 +6,29 @@ export async function addToEmailList( ): Promise<{ success: boolean; error?: string | undefined }> { if (!email) return { success: false, error: "Email undefined" }; - const res = await fetch("https://api.brevo.com/v3/contacts", { - method: "POST", - headers: { - "api-key": BREVO_API_KEY, - "content-type": "application/json", - }, - body: JSON.stringify({ - email, - listIds: [10], - attributes: { - FIRSTNAME: display_name, + try { + const res = await fetch("https://api.brevo.com/v3/contacts", { + method: "POST", + headers: { + "api-key": BREVO_API_KEY, + "content-type": "application/json", }, - updateEnabled: true, - }), - }); + body: JSON.stringify({ + email, + listIds: [10], + attributes: { + FIRSTNAME: display_name, + }, + updateEnabled: true, + }), + signal: AbortSignal.timeout(5000), + }); - if (res.ok) return { success: true }; - const error = await res.text(); - return { success: false, error }; + if (res.ok) return { success: true }; + const error = await res.text(); + return { success: false, error }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + return { success: false, error }; + } } From b64afd7ea17087037a4c109eddfe60d4dd35d8c2 Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 12:10:44 +0100 Subject: [PATCH 17/18] fix(auth): update profile query to handle optional profile data and improve onboarding check --- src/hooks.server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c9f849fa..9f5e6714 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -129,7 +129,7 @@ const authGuard: Handle = async ({ event, resolve }) => { .from("profiles") .select("id, username, role, onboarded") .eq("user_id", user.id) - .single(); + .maybeSingle(); if (err) logError( @@ -140,7 +140,7 @@ const authGuard: Handle = async ({ event, resolve }) => { ); if ( - !profile.onboarded && + (!profile || !profile.onboarded) && !event.url.pathname.startsWith("/auth/complete-profile") && !event.url.pathname.startsWith("/auth/logout") && !event.url.pathname.startsWith("/auth/callback") && @@ -156,7 +156,7 @@ const authGuard: Handle = async ({ event, resolve }) => { if (event.url.pathname === "/") { redirect(303, "/dashboard"); } - if (event.url.pathname.startsWith("/staff") && profile.role === "User") { + if (event.url.pathname.startsWith("/staff") && profile?.role === "User") { redirect(303, "/"); } From cd676afe82c4af25cb67746be282cbd8bf918b4a Mon Sep 17 00:00:00 2001 From: Saterz Date: Mon, 29 Jun 2026 12:10:52 +0100 Subject: [PATCH 18/18] fix(auth): handle malformed redirect URLs in login action --- src/routes/(auth)/auth/login/+page.server.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/routes/(auth)/auth/login/+page.server.ts b/src/routes/(auth)/auth/login/+page.server.ts index ad6e0ad7..23501214 100644 --- a/src/routes/(auth)/auth/login/+page.server.ts +++ b/src/routes/(auth)/auth/login/+page.server.ts @@ -67,8 +67,16 @@ export const actions: Actions = { const redirect_to = url.searchParams.get("redirect_to"); if (redirect_to && !redirect_to.includes("\\")) { - const target = new URL(redirect_to, url.origin); + let target: URL | null = null; + + try { + target = new URL(redirect_to, url.origin); + } catch { + // Suppress exception if the URL is malformed. + } + if ( + target && target.origin === url.origin && redirect_to.startsWith("/") && !redirect_to.startsWith("//")