Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ebc0629
feat: add scope support for the signout route through the url params
Saterz Jun 25, 2026
d79f741
fix: add guard for empty scope string
Saterz Jun 28, 2026
f14bcf4
feat(auth): implement OAuth login for Google and WCA, enhance Discord…
Saterz Jun 29, 2026
cb9ebbe
feat(auth): refactor login flow to use centralized login schema and i…
Saterz Jun 29, 2026
b7114e1
feat(auth): upgrade password reset functionality with form validation…
Saterz Jun 29, 2026
8f7fa3c
feat(auth): streamline user retrieval in resend handler by using locals
Saterz Jun 29, 2026
0ee084b
feat(auth): remove unused load function from layout server
Saterz Jun 29, 2026
13cfbcc
Merge branch 'main' into feat/rework-signup-flow
Saterz Jun 29, 2026
7077e5f
fix(auth): remove trim from email validation
Saterz Jun 29, 2026
c1d7c60
fix(auth): correct error message for username conflict in profile update
Saterz Jun 29, 2026
890301d
fix(auth): improve redirect logic for login flow
Saterz Jun 29, 2026
6993acf
fix(auth): bind password input to form state in login component
Saterz Jun 29, 2026
33c57ec
fix(auth): correct form key in error response for survey submission
Saterz Jun 29, 2026
d6fd3eb
fix(auth): disable submit button and show loading state during accoun…
Saterz Jun 29, 2026
5ba9e5b
fix(auth): correct condition for Supabase URL and secret key validation
Saterz Jun 29, 2026
b35679e
fix(auth): include onboarding check in login action and adjust profil…
Saterz Jun 29, 2026
73b7716
fix(email): refactor addToEmailList function for improved error handl…
Saterz Jun 29, 2026
b64afd7
fix(auth): update profile query to handle optional profile data and i…
Saterz Jun 29, 2026
cd676af
fix(auth): handle malformed redirect URLs in login action
Saterz Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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=
28 changes: 22 additions & 6 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}
}
},
},
Expand Down Expand Up @@ -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(
Expand All @@ -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}`);
Expand All @@ -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, "/");
}

Expand Down
34 changes: 34 additions & 0 deletions src/lib/components/helper_functions/addToEmailList.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
27 changes: 27 additions & 0 deletions src/lib/components/layout/ExternalAuthProviders.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script>
import { resolve } from "$app/paths";
</script>

<div class="w-full flex justify-center gap-2">
<a
href={resolve("/(auth)/auth/google")}
class="btn btn-lg sm:btn-xl bg-white text-black"
aria-label="Google"
>
<i class="fa-brands fa-google text-2xl"></i>
</a>
<a
href={resolve("/(auth)/auth/wca")}
class="btn btn-lg sm:btn-xl bg-red-700 text-white"
aria-label="WCA"
>
<img src="/icons/WCA Logo.svg" alt="WCA Logo" class="size-8" />
</a>
<a
href={resolve("/(auth)/auth/discord")}
class="btn btn-lg sm:btn-xl bg-[#5865F2] text-white"
aria-label="Discord"
>
<i class="fa-brands fa-discord text-2xl"></i>
</a>
</div>
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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",
Expand All @@ -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",
});
}
});
3 changes: 0 additions & 3 deletions src/routes/(auth)/+layout.server.ts

This file was deleted.

40 changes: 15 additions & 25 deletions src/routes/(auth)/auth/callback/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,42 +18,32 @@ 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,
new Error("User not returned after authentication"),
);
}

// 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");
};
Loading
Loading