Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0d6c58e
docs: add CLAUDE.md and admin roles implementation plan
tm-cj-salces Mar 11, 2026
415a6a2
feat: add server-side auth helpers and admin Supabase client
tm-cj-salces Mar 11, 2026
510be2a
fix: rename PUBLIC_SUPABASE_SERVICE_KEY to SUPABASE_SERVICE_KEY in CI/CD
tm-cj-salces Mar 11, 2026
a07dece
chore: fix lint and formatting issues
tm-cj-salces Mar 11, 2026
eedcb0d
feat: add userRole to App.Locals and App.PageData types
tm-cj-salces Mar 11, 2026
a64f1f3
chore: fix import sort order in app.d.ts
tm-cj-salces Mar 11, 2026
e9f272a
chore: remove docs/ and CLAUDE.md from tracking
tm-cj-salces Mar 11, 2026
f1cf68e
chore: add tool config files to .gitignore
tm-cj-salces Mar 11, 2026
3518817
feat: add authGuard hook and secure API endpoints
LigsQt Mar 14, 2026
a0f4922
fix: resolve eslint errors in hooks and API endpoints
LigsQt Mar 14, 2026
401abda
chore: fix code formatting
LigsQt Mar 19, 2026
c50cb3c
feat(auth): add admin-side API endpoints
SHIROKAMIQQ Mar 20, 2026
0a815b4
fix(security): move Google credentials to private env vars
tm-cj-salces Mar 20, 2026
830851a
fix(ci): use secrets for sensitive env vars
tm-cj-salces Mar 20, 2026
770f161
style: add user role and sidebar animation
tm-cj-salces Mar 20, 2026
20d4310
fix: wrap setInterval in $effect with cleanup to prevent memory leak
tm-cj-salces Mar 20, 2026
1574654
fix: properly track message timeout in SaveButton to prevent overlapp…
tm-cj-salces Mar 20, 2026
8d76ac0
refactor: replace local writable store with $state rune in Modal
tm-cj-salces Mar 20, 2026
a77a09e
perf: add keys to each blocks for efficient DOM updates
tm-cj-salces Mar 20, 2026
b6bfa7d
chore: remove debug console.log statements from prod
tm-cj-salces Mar 20, 2026
b557532
chore: run formatter
tm-cj-salces Mar 20, 2026
18db89a
refactor: synchronize $lib variables synchronously for child componen…
tm-cj-salces Mar 20, 2026
a785ad6
refactor: destructure session and userId from data and event.params f…
tm-cj-salces Mar 20, 2026
6f723cc
fix: patch production security issues in auth flow
tm-cj-salces Mar 20, 2026
aab833c
fix: use $derived for prop-derived values in Svelte components
tm-cj-salces Mar 20, 2026
268ac11
feat: add dev-only logger utility
tm-cj-salces Mar 20, 2026
190358e
chore: run linters and formatter
tm-cj-salces Mar 20, 2026
00b08c5
chore: run formatter
tm-cj-salces Mar 20, 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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ on:
env:
PUBLIC_SUPABASE_URL: ${{ vars.PUBLIC_SUPABASE_URL || 'http://dummy' }}
PUBLIC_SUPABASE_ANON_KEY: ${{ vars.PUBLIC_SUPABASE_ANON_KEY || 'anon-key' }}
PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }}
PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }}
PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }}
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
GOOGLE_SERVICE_EMAIL: ${{ secrets.GOOGLE_SERVICE_EMAIL }}
GOOGLE_PRIVATE_KEY: ${{ secrets.GOOGLE_PRIVATE_KEY }}

jobs:
build:
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ env:
# SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
PUBLIC_SUPABASE_URL: ${{ vars.PUBLIC_SUPABASE_URL || 'http://dummy' }}
PUBLIC_SUPABASE_ANON_KEY: ${{ vars.PUBLIC_SUPABASE_ANON_KEY || 'anon-key' }}
PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }}
PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }}
PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }}
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
GOOGLE_SERVICE_EMAIL: ${{ secrets.GOOGLE_SERVICE_EMAIL }}
GOOGLE_PRIVATE_KEY: ${{ secrets.GOOGLE_PRIVATE_KEY }}

# Custom environment used for the Container registry domain, and a name for the Docker image that this workflow builds.
REGISTRY: ghcr.io
Expand Down Expand Up @@ -59,9 +59,9 @@ jobs:
CONTAINER_PORT=${{ env.CONTAINER_PORT }}
PUBLIC_SUPABASE_URL=${{ env.PUBLIC_SUPABASE_URL }}
PUBLIC_SUPABASE_ANON_KEY=${{ env.PUBLIC_SUPABASE_ANON_KEY }}
PUBLIC_SUPABASE_SERVICE_KEY=${{ env.PUBLIC_SUPABASE_SERVICE_KEY }}
PUBLIC_GOOGLE_SERVICE_EMAIL=${{ env.PUBLIC_GOOGLE_SERVICE_EMAIL }}
PUBLIC_GOOGLE_PRIVATE_KEY=${{ env.PUBLIC_GOOGLE_PRIVATE_KEY }}
SUPABASE_SERVICE_KEY=${{ env.SUPABASE_SERVICE_KEY }}
GOOGLE_SERVICE_EMAIL=${{ env.GOOGLE_SERVICE_EMAIL }}
GOOGLE_PRIVATE_KEY=${{ env.GOOGLE_PRIVATE_KEY }}
EOF

# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
/.svelte-kit
node_modules
.env
docs/
CLAUDE.md
.agents/
.claude/
skills-lock.json
3 changes: 3 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Session, SupabaseClient, User } from '@supabase/supabase-js';
import type { AppRole } from '$lib/server/auth';
import type { Database } from './database.types.ts'; // import generated types

declare global {
Expand All @@ -9,9 +10,11 @@ declare global {
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
session: Session | null;
user: User | null;
userRole: AppRole | null;
}
interface PageData {
session: Session | null;
userRole: AppRole | null;
}
// interface PageState {}
// interface Platform {}
Expand Down
34 changes: 18 additions & 16 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AppRole } from '$lib/server/auth';
import { type Handle } from '@sveltejs/kit';
import { createServerClient } from '@supabase/ssr';
import { sequence } from '@sveltejs/kit/hooks';
Expand Down Expand Up @@ -48,20 +49,21 @@ const supabase: Handle = ({ event, resolve }) => {
});
};

// const authGuard: Handle = async ({ event, resolve }) => {
// const { session, user } = await event.locals.safeGetSession()
// event.locals.session = session
// event.locals.user = user
//
// if (!event.locals.session && event.url.pathname.startsWith('/private')) {
// redirect(303, '/login')
// }
//
// if (event.locals.session && event.url.pathname === '/login') {
// redirect(303, '/private')
// }
//
// return resolve(event)
// }
const authGuard: Handle = async ({ event, resolve }) => {
const { locals } = event;
const { session, user } = await locals.safeGetSession();
let userRole: AppRole | null = null;

export const handle: Handle = sequence(supabase);
if (user) {
const { data: profile } = await locals.supabase.from('profiles').select('role').eq('id', user.id).single();
userRole = (profile?.role as AppRole) ?? 'applicant';
}

locals.session = session;
locals.user = user;
locals.userRole = userRole;

return resolve(event);
};

export const handle: Handle = sequence(supabase, authGuard);
22 changes: 22 additions & 0 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { dev } from '$app/environment';

function debug(...args: unknown[]) {
if (dev) {
// eslint-disable-next-line no-console
console.log('[DEBUG]', ...args);
}
}

function warn(...args: unknown[]) {
if (dev) {
// eslint-disable-next-line no-console
console.warn('[WARN]', ...args);
}
}

function error(...args: unknown[]) {
// eslint-disable-next-line no-console
console.error(...args);
}

export const logger = { debug, warn, error };
32 changes: 32 additions & 0 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type RequestEvent, error } from '@sveltejs/kit';

export type AppRole = 'applicant' | 'admin' | 'withdrawn' | 'inactive';

/**
* Requires authenticated user. Throws 401 if not logged in.
*/
export function requireAuth(event: RequestEvent) {
const { user, session } = event.locals;
if (!session || !user) {
throw error(401, 'Authentication required');
}
return { user, session };
}

/**
* Requires a specific role. Throws 401 if not authenticated, 403 if wrong role.
*/
export function requireRole(event: RequestEvent, role: AppRole) {
const { user, session } = requireAuth(event);
if (event.locals.userRole !== role) {
throw error(403, 'Insufficient permissions');
}
return { user, session };
}

/**
* Check if current user is admin. Does not throw.
*/
export function isAdmin(event: RequestEvent): boolean {
return event.locals.userRole === 'admin';
}
9 changes: 9 additions & 0 deletions src/lib/server/supabaseAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_KEY } from '$env/static/private';
import { createClient } from '@supabase/supabase-js';

/**
* Service-role Supabase client. Bypasses RLS.
* ONLY use in server-side code for admin operations.
*/
export const supabaseAdmin = createClient(PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_KEY);
5 changes: 4 additions & 1 deletion src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
export const load = async ({ locals: { safeGetSession }, cookies }) => {
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals: { safeGetSession, userRole }, cookies }) => {
const { session } = await safeGetSession();
return {
session,
cookies: cookies.getAll(),
userRole: userRole ?? null,
};
};
32 changes: 21 additions & 11 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import { page } from '$app/state';

import { invalidate } from '$app/navigation';
import { logger } from '$lib/logger';
import { onMount } from 'svelte';

const { data, children } = $props();
const { session, supabase } = $derived(data);

// Sync $lib variables to data props
// Sync $lib variables to data props (must be synchronous so children can read in onMount)
if (data?.uuid) uuid.set(data.uuid);
if (data?.user?.user_metadata.full_name) username.set(data.user.user_metadata.full_name);
if (data?.filledSigsheet) filledSigsheet.set(data.filledSigsheet);
Expand All @@ -30,14 +31,12 @@

// Get applicant_names_list
onMount(async () => {
console.log('Fetching applicant names.');
const { data: app_data, error: app_error } = await supabase.from('profiles').select('full_name');
if (app_error) {
console.error('Error fetching profile names: ', app_error);
logger.error('Error fetching profile names: ', app_error);
} else if (app_data) {
applicant_names_list.set(app_data.map(row => row.full_name));
}
console.log(applicant_names_list);
});
</script>

Expand Down Expand Up @@ -65,13 +64,24 @@

<div class="flex w-full flex-row bg-[#161619]">
{#if page.url.pathname !== '/login/'}
{#if isNavBarOpen}
<div
class="fixed top-0 left-0 z-50 h-screen w-64 transform transition-transform duration-700 ease-in-out"
>
<NavBar user={data.user} bind:isNavBarOpen />
</div>
{/if}
<!-- Backdrop overlay -->
<div
class="fixed inset-0 z-40 bg-black/50 transition-opacity duration-300
{isNavBarOpen ? 'opacity-100' : 'pointer-events-none opacity-0'}"
role="button"
tabindex="-1"
onclick={() => (isNavBarOpen = false)}
onkeydown={e => {
if (e.key === 'Escape') isNavBarOpen = false;
}}
></div>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 z-50 h-screen w-64 transition-transform duration-300 ease-in-out
{isNavBarOpen ? 'translate-x-0' : '-translate-x-full'}"
>
<NavBar user={data.user} bind:isNavBarOpen />
</div>
{/if}

<div class="flex w-full justify-center">
Expand Down
45 changes: 23 additions & 22 deletions src/routes/+layout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Answer, ISection, Question } from './consti-quiz/constiquiz-types.ts';
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr';
import { logger } from '$lib/logger';

export async function load({ data, depends, fetch }) {
depends('supabase:auth');
Expand All @@ -22,16 +23,11 @@ export async function load({ data, depends, fetch }) {
},
});

const {
data: { session },
} = await supabase.auth.getSession();

const {
data: { user },
} = await supabase.auth.getUser();
const { session } = data;
const user = session?.user ?? null;

if (!user) {
console.error('Failed to fetch user.');
logger.error('Failed to fetch user.');
return {
session: session,
supabase: supabase,
Expand All @@ -43,13 +39,9 @@ export async function load({ data, depends, fetch }) {
}

const uuid = user.id;
console.log('Fetched uuid:', uuid);

const username = user.email?.split('@')[0] ?? '';
console.log('Fetched username:', username);

// Fetch filledSigsheet
console.log('Fetching sigsheet from Supabase with ID:', uuid);
let filledSigsheet: Set<number> = new Set();
try {
const { data: sigRows, error: sigError } = await supabase
Expand All @@ -60,13 +52,11 @@ export async function load({ data, depends, fetch }) {
if (sigError) throw sigError;

filledSigsheet = new Set(sigRows?.map(row => row.member_id) ?? []);
console.log(`Fetched filledSigsheet: sigsheet size ${filledSigsheet.size}`);
} catch (sigError) {
console.error('Error fetching sigsheet: ', sigError);
logger.error('Error fetching sigsheet: ', sigError);
}

// Fetch gdrive_folder_id
console.log('Fetching gdrive_folder_id for Applicant:', uuid);
let gdrive_folder_id: string = '';
try {
const response = await fetch('/api/get_gdrive_folder', {
Expand All @@ -79,13 +69,13 @@ export async function load({ data, depends, fetch }) {

if (!response.ok) {
const gDriveError = await response.json().catch(() => ({}));
console.error('Error fetching gdrive folder:', gDriveError);
logger.error('Error fetching gdrive folder:', gDriveError);
} else {
const folderData = await response.json();
gdrive_folder_id = folderData.folder_id ?? '';
}
} catch (gDriveError) {
console.error('Unexpected error fetching gdrive folder:', gDriveError);
logger.error('Unexpected error fetching gdrive folder:', gDriveError);
}

// Functions for fetching constiquiz
Expand All @@ -97,8 +87,7 @@ export async function load({ data, depends, fetch }) {
`);

if (error) {
// TODO: handle error
console.error(error);
logger.error(error);
throw error;
}

Expand All @@ -123,7 +112,7 @@ export async function load({ data, depends, fetch }) {
`);

if (error || !data) {
console.error(error);
logger.error(error);
throw error;
}

Expand All @@ -147,7 +136,7 @@ export async function load({ data, depends, fetch }) {
.eq('user_id', uuid);

if (error) {
console.error(error);
logger.error(error);
throw error;
}

Expand All @@ -157,5 +146,17 @@ export async function load({ data, depends, fetch }) {
// fetch in parallel for faster results
const [sections, questions, answers] = await Promise.all([fetchSections(), fetchQuestions(), fetchAnswers()]);

return { session, supabase, user, uuid, username, filledSigsheet, gdrive_folder_id, sections, questions, answers };
return {
session,
supabase,
user,
uuid,
username,
filledSigsheet,
gdrive_folder_id,
sections,
questions,
answers,
userRole: data.userRole,
};
}
Loading
Loading