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 @@ + + +
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 @@ + + ++ Choose a unique username to finalize your CubeIndex profile. +
+ + +Log in to your CubeIndex profile