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..9f5e6714 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) + .maybeSingle(); if (err) logError( @@ -131,7 +139,15 @@ const authGuard: Handle = async ({ event, resolve }) => { err, ); - const profile = profiles?.[0]; + if ( + (!profile || !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}`); @@ -140,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, "/"); } diff --git a/src/lib/components/helper_functions/addToEmailList.ts b/src/lib/components/helper_functions/addToEmailList.ts new file mode 100644 index 00000000..30175872 --- /dev/null +++ b/src/lib/components/helper_functions/addToEmailList.ts @@ -0,0 +1,34 @@ +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" }; + + try { + 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, + }), + signal: AbortSignal.timeout(5000), + }); + + 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 }; + } +} 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 @@ + + +
+ + + + + WCA Logo + + + + +
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..8920bb9a 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").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)/+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 {}; -}; 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..1931a97e --- /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, "username", "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/login/+page.server.ts b/src/routes/(auth)/auth/login/+page.server.ts index f5e6fbc6..23501214 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,45 @@ 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) - .single(); + .select("username, onboarded") + .eq("user_id", user.id) + .maybeSingle(); + + if (profileErr) + return fail(500, { form: { ...form, message: profileErr.message } }); + + if (!profile || !profile.onboarded) { + redirect(303, "/auth/complete-profile"); + } - if (profileErr) error(500, { message: profileErr.message }); + const redirect_to = url.searchParams.get("redirect_to"); + + if (redirect_to && !redirect_to.includes("\\")) { + 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("//") + ) { + redirect(303, `${target.pathname}${target.search}${target.hash}`); + } + } 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..063757b6 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,47 @@ ); 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 +126,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; diff --git a/src/routes/(auth)/auth/signup/+page.server.ts b/src/routes/(auth)/auth/signup/+page.server.ts index ebfdb9ec..b47ba2ab 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." }, + surveyForm: { + ...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..b7612bf4 100644 --- a/src/routes/(auth)/auth/signup/+page.svelte +++ b/src/routes/(auth)/auth/signup/+page.svelte @@ -4,26 +4,28 @@ 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, + submitting: accountSubmitting, + } = superForm( + untrack(() => data.accountForm), + { resetForm: false, - }), + onUpdate({ result }) { + if (result.type === "failure") { + resetTurnstile?.(); + } + }, + }, ); const { @@ -31,28 +33,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 +54,346 @@ 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 $accountMessage} +

+ {$accountMessage} +

+ {/if} + +
+ + {#if $accountErrors["cf-turnstile-response"]} + + {$accountErrors["cf-turnstile-response"]} + + {/if} +
- {#if $surveyMessage} -

- {$surveyMessage} -

+ - - - {/if} +
or with
- {#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} + + -

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..3618312e --- /dev/null +++ b/supabase/scripts/setup-wca-provider.js @@ -0,0 +1,43 @@ +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); + process.exitCode = 1; + } else { + console.log('WCA Custom Provider created successfully:', data); + } +} + +await registerWCAProvider().catch((error) => { + console.error('Unexpected error creating custom provider:', error); + process.exitCode = 1; +});