From 9be751c3818c697816af6b0a50347db8e3090590 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 21:46:38 +0800 Subject: [PATCH 1/8] docs: add CLAUDE.md and admin roles implementation plan Add project guidance for Claude Code, a detailed implementation plan for the admin role system, and simplified data flow diagrams. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 77 +++ docs/1-admin-roles-implementation-plan.md | 744 ++++++++++++++++++++++ docs/2-admin-roles-data-flow-diagrams.md | 209 ++++++ 3 files changed, 1030 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/1-admin-roles-implementation-plan.md create mode 100644 docs/2-admin-roles-data-flow-diagrams.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f9e39a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +UP CSI member application processing web app built with **SvelteKit 2** (Svelte 5), **TypeScript**, **Supabase** (auth + PostgreSQL), and **Google Drive API** (file storage). Uses **pnpm** as package manager. + +## Commands + +```bash +pnpm dev # Start dev server +pnpm build # Production build +pnpm preview # Preview production build + +pnpm fmt # Check formatting (Prettier) +pnpm fmt:fix # Auto-fix formatting +pnpm lint # Run all linters in parallel (html, css, js, svelte) +pnpm lint:js # ESLint only +pnpm lint:svelte # Svelte type checking only +``` + +## Architecture + +### Routing (SvelteKit file-based) + +- `/` — Dashboard/home +- `/login` — Google OAuth login (restricted to `@up.edu.ph` emails, checked against Supabase `whitelist` table) +- `/login/callback` — OAuth callback with domain + whitelist validation +- `/sigsheet` — Signature sheet feature (member grid with modals) +- `/consti-quiz` — Constitution quiz (multiple question types: radio, checkbox, short/long text) +- `/api/answers` — POST quiz answers (upsert pattern) +- `/api/get_gdrive_folder` — Create/get Google Drive folders +- `/api/upload` — Upload files to Google Drive + +### Auth Flow + +Supabase SSR OAuth with Google. Server hook (`hooks.server.ts`) creates a Supabase client per request with cookie management. Browser client uses `createBrowserClient()`. `safeGetSession()` helper for auth state. Layout's `onAuthStateChange` invalidates data on auth changes. + +### Data Layer + +- **Database**: Supabase PostgREST — tables include `sigsheet`, `constiquiz-sections`, `constiquiz-questions`, `constiquiz-options`, `constiquiz-answers`, `constiquiz-submissions`, `constiquiz-availability`, `profiles`, `whitelist`, `pic-folders` +- **File storage**: Google Drive API with JWT service account auth +- **Client state**: Svelte writable stores in `$lib/shared.ts` (`uuid`, `username`, `gdrive_folder_id`, `filledSigsheet`, `applicant_names_list`) +- **Data loading**: Parallel fetching with `Promise.all()` in `+layout.ts`, dependency tracking with `depends()` + +### Key Files + +- `src/hooks.server.ts` — Server hook creating Supabase client per request +- `src/lib/supabaseClient.js` — Browser-side Supabase client +- `src/lib/shared.ts` — Global Svelte stores +- `src/routes/+layout.ts` — Root data loader (auth, quiz data) +- `src/routes/consti-quiz/constiquiz-types.ts` — Quiz question type definitions (discriminated unions) + +### Environment Variables + +``` +PUBLIC_SUPABASE_URL +PUBLIC_SUPABASE_ANON_KEY +PUBLIC_SUPABASE_SERVICE_KEY +PUBLIC_GOOGLE_SERVICE_EMAIL +PUBLIC_GOOGLE_PRIVATE_KEY +``` + +## Code Conventions + +- **Svelte 5 runes**: Uses `$props()`, `$state()`, snippet types for component composition +- **TypeScript strict mode** with `noUncheckedIndexedAccess` and `noImplicitOverride` +- **Prettier**: 4-space indentation, 120 char width, single quotes, Tailwind class sorting plugin +- **Tailwind CSS**: Custom CSI brand colors defined in `tailwind.config.ts` (`csi-blue: #00C6D7`, `csi-black: #212121`, `csi-yellow: #F7CF2F`, plus committee colors) +- **ESLint**: No unused vars, no console (warning), prefer const + +## Deployment + +- Docker multi-stage build (Node.js Alpine), port 3000 +- CI runs on PRs/push to main: install → format check → lint → build +- Deploy triggers on push to `production` branch → builds Docker image → pushes to GitHub Container Registry diff --git a/docs/1-admin-roles-implementation-plan.md b/docs/1-admin-roles-implementation-plan.md new file mode 100644 index 0000000..5213df0 --- /dev/null +++ b/docs/1-admin-roles-implementation-plan.md @@ -0,0 +1,744 @@ +# Admin Roles Implementation Plan + +> **Scope:** Database, auth, and backend changes only. No frontend. +> **Goal:** Add an "admin" role so org leaders can view/check applicant data (quiz responses, sigsheet progress, profiles). + +--- + +## Current State Summary + +- **Auth:** Google OAuth → `@up.edu.ph` domain check → `whitelist` table check → session created. All users treated identically as applicants. +- **Database:** `profiles` table has no role column. No RLS policies on any table. +- **Backend:** Two API endpoints (`/api/get_gdrive_folder`, `/api/upload`) have no auth checks and use a browser-side Supabase client on the server. Other endpoints check `safeGetSession()` but have no role awareness. +- **Hooks:** `hooks.server.ts` has a commented-out `authGuard`. The `sequence()` only runs the `supabase` handle. +- **Service key:** `PUBLIC_SUPABASE_SERVICE_KEY` exists in CI env vars but is never imported in code. The `PUBLIC_` prefix is wrong — it would expose the key to the browser. + +--- + +## Phase 1: Database Schema Changes + +### 1.1 Add role to profiles + +Run this migration in Supabase SQL Editor (or via Supabase CLI migration): + +```sql +-- Create enum type for roles (extensible: add 'member' later with ALTER TYPE) +CREATE TYPE public.app_role AS ENUM ('applicant', 'admin'); + +-- Add role column — all existing users default to 'applicant' +ALTER TABLE public.profiles + ADD COLUMN role public.app_role NOT NULL DEFAULT 'applicant'; + +-- Index for fast role lookups (used on every request in hooks) +CREATE INDEX idx_profiles_role ON public.profiles (role); +``` + +**Design decision:** Role on `profiles` directly (not a separate table) because it's a 1:1 relationship with two values. If multi-role is needed later, migrate to a join table then. + +### 1.2 Seed admin users + +```sql +UPDATE public.profiles +SET role = 'admin' +WHERE id IN ( + SELECT id FROM auth.users + WHERE email IN ( + 'admin1@up.edu.ph', + 'admin2@up.edu.ph' + -- Replace with actual admin emails + ) +); +``` + +Ensure these emails are also in the `whitelist` table: + +```sql +INSERT INTO public.whitelist (email) +VALUES ('admin1@up.edu.ph'), ('admin2@up.edu.ph') +ON CONFLICT (email) DO NOTHING; +``` + +### 1.3 Verify + +```sql +SELECT p.id, u.email, p.role +FROM public.profiles p +JOIN auth.users u ON p.id = u.id +ORDER BY p.role, u.email; +``` + +--- + +## Phase 2: Server-Side Auth Infrastructure + +### 2.1 Rename service key env var + +The service key must NOT have the `PUBLIC_` prefix — SvelteKit exposes `PUBLIC_` vars to the browser. + +**`.env`:** Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` + +**`.github/workflows/deploy.yml`:** Update all references: +```yaml +# Change these lines: +PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} +# To: +SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} +``` + +**`.github/workflows/ci.yml`:** Same rename. Also update the GitHub Actions repo settings to use the new variable name (or move to a secret since it's a sensitive key). + +### 2.2 Create service-role Supabase client + +**New file: `src/lib/server/supabaseAdmin.ts`** + +```typescript +import { SUPABASE_SERVICE_KEY } from '$env/static/private'; +import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +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); +``` + +The `src/lib/server/` directory is a SvelteKit convention — files here can only be imported from server-side code (`+server.ts`, `+page.server.ts`, `hooks.server.ts`). This prevents accidental browser exposure. + +### 2.3 Create auth helper utilities + +**New file: `src/lib/server/auth.ts`** + +```typescript +import { error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; + +export type AppRole = 'applicant' | 'admin'; + +/** + * 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'; +} +``` + +--- + +## Phase 3: Auth Flow — Role Resolution in Hooks + +### 3.1 Update TypeScript types + +**File: `src/app.d.ts`** + +```typescript +import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; +import type { Database } from './database.types.ts'; +import type { AppRole } from '$lib/server/auth'; + +declare global { + namespace App { + interface Locals { + supabase: SupabaseClient; + 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; + } + } +} + +export {}; +``` + +### 3.2 Add authGuard to hooks + +**File: `src/hooks.server.ts`** + +Replace the commented-out `authGuard` and update the `sequence()` export: + +```typescript +import { type Handle } from '@sveltejs/kit'; +import { createServerClient } from '@supabase/ssr'; +import { sequence } from '@sveltejs/kit/hooks'; +import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; +import type { AppRole } from '$lib/server/auth'; + +const supabase: Handle = ({ event, resolve }) => { + // ... KEEP EXISTING CODE (lines 8-48) EXACTLY AS-IS ... +}; + +const authGuard: Handle = async ({ event, resolve }) => { + const { session, user } = await event.locals.safeGetSession(); + event.locals.session = session; + event.locals.user = user; + event.locals.userRole = null; + + if (user) { + const { data: profile } = await event.locals.supabase + .from('profiles') + .select('role') + .eq('id', user.id) + .single(); + + event.locals.userRole = (profile?.role as AppRole) ?? 'applicant'; + } + + return resolve(event); +}; + +export const handle: Handle = sequence(supabase, authGuard); +``` + +**What this does:** On every request, after the Supabase client is set up, fetch the user's role from `profiles` and attach it to `event.locals.userRole`. All downstream handlers (layout loads, API endpoints) can read `event.locals.userRole` without an extra DB query. + +### 3.3 Pass role to page data + +**File: `src/routes/+layout.server.ts`** + +```typescript +export const load = async ({ locals: { safeGetSession, userRole }, cookies }) => { + const { session } = await safeGetSession(); + return { + session, + cookies: cookies.getAll(), + userRole: userRole ?? null, + }; +}; +``` + +### 3.4 Login callback — no changes needed + +Admins log in via the same Google OAuth flow. They must be in the `whitelist` table. Their role is determined from `profiles.role`, not the login flow. + +### 3.5 Verify + +1. Add `console.log('userRole:', event.locals.userRole)` temporarily in the `authGuard` +2. Log in as a seeded admin → should print `'admin'` +3. Log in as a regular applicant → should print `'applicant'` +4. Access any page without logging in → should print `null` + +--- + +## Phase 4: Fix Existing Unprotected Endpoints + +Two endpoints currently have no auth checks and use the browser Supabase client singleton on the server. + +### 4.1 Fix `/api/get_gdrive_folder` + +**File: `src/routes/api/get_gdrive_folder/+server.js`** + +Changes: +1. Remove `import { supabase } from '$lib/supabaseClient';` +2. Add auth check using `locals.safeGetSession()` (or import `requireAuth` if converting to `.ts`) +3. Use `locals.supabase` instead of the browser singleton +4. Use `user.id` from auth instead of `uuid` from request body (prevents spoofing) + +```javascript +// Before: +import { supabase } from '$lib/supabaseClient'; +export async function POST({ request }) { + const { uuid, username } = await request.json(); + // ... uses uuid from body + +// After: +export async function POST({ request, locals }) { + const { user } = await locals.safeGetSession(); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uuid = user.id; // Use authenticated user's ID + const { username } = await request.json(); + // ... replace all `supabase.` calls with `locals.supabase.` +``` + +### 4.2 Fix `/api/upload` + +**File: `src/routes/api/upload/+server.js`** + +Same pattern: +1. Remove `import { supabase } from '../../../lib/supabaseClient';` +2. Add auth check +3. Use `locals.supabase` for DB operations +4. Use `user.id` instead of `formData.get('uuid')` + +```javascript +// Before: +import { supabase } from '../../../lib/supabaseClient'; +export async function POST({ request }) { + const formData = await request.formData(); + const uuid = formData.get('uuid'); + +// After: +export async function POST({ request, locals }) { + const { user } = await locals.safeGetSession(); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uuid = user.id; + const formData = await request.formData(); + // ... replace all `supabase.` calls with `locals.supabase.` +``` + +### 4.3 Verify + +- Call `POST /api/get_gdrive_folder` without a session cookie → expect 401 +- Call `POST /api/upload` without a session cookie → expect 401 +- Call both while logged in → should work as before + +--- + +## Phase 5: Admin API Endpoints + +All new endpoints go under `src/routes/api/admin/`. Every endpoint: +- Calls `requireRole(event, 'admin')` as the first line +- Uses `supabaseAdmin` (service-role client) to bypass RLS for cross-user reads +- Returns JSON + +### 5.1 List all applicant profiles + +**New file: `src/routes/api/admin/applicants/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + + const { data, error } = await supabaseAdmin + .from('profiles') + .select('id, username, full_name, avatar_url, role') + .eq('role', 'applicant'); + + if (error) { + return json({ error: error.message }, { status: 500 }); + } + + return json({ applicants: data }); +} +``` + +### 5.2 All quiz submissions with scores + +**New file: `src/routes/api/admin/quiz-results/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + + // Get all submissions with profile info + const { data: submissions, error: subError } = await supabaseAdmin + .from('constiquiz-submissions') + .select(` + submission_id, + submitted_at, + user_id, + profiles!inner ( username, full_name ) + `); + + if (subError) { + return json({ error: subError.message }, { status: 500 }); + } + + // Get total scores per submitted user + const userIds = submissions?.map(s => s.user_id).filter(Boolean) ?? []; + + if (userIds.length === 0) { + return json({ results: [] }); + } + + const { data: answers, error: ansError } = await supabaseAdmin + .from('constiquiz-answers') + .select('user_id, points') + .in('user_id', userIds); + + if (ansError) { + return json({ error: ansError.message }, { status: 500 }); + } + + // Aggregate scores + const scoreMap: Record = {}; + for (const a of answers ?? []) { + scoreMap[a.user_id] = (scoreMap[a.user_id] ?? 0) + (a.points ?? 0); + } + + const results = submissions?.map(s => ({ + submission_id: s.submission_id, + submitted_at: s.submitted_at, + user_id: s.user_id, + profile: s.profiles, + total_score: scoreMap[s.user_id] ?? 0, + })); + + return json({ results }); +} +``` + +### 5.3 Single applicant's detailed quiz answers + +**New file: `src/routes/api/admin/quiz-results/[userId]/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + const userId = event.params.userId; + + const [answersRes, profileRes, submissionRes] = await Promise.all([ + supabaseAdmin + .from('constiquiz-answers') + .select(` + answer_id, question_id, answer_text, option_id, points, is_checked, + question:constiquiz-questions!inner ( + title, point_value, type, + section:constiquiz-sections!inner ( title ) + ) + `) + .eq('user_id', userId), + supabaseAdmin + .from('profiles') + .select('id, username, full_name') + .eq('id', userId) + .single(), + supabaseAdmin + .from('constiquiz-submissions') + .select('submitted_at') + .eq('user_id', userId) + .maybeSingle(), + ]); + + if (answersRes.error) { + return json({ error: answersRes.error.message }, { status: 500 }); + } + + return json({ + profile: profileRes.data, + submitted_at: submissionRes.data?.submitted_at ?? null, + answers: answersRes.data, + }); +} +``` + +### 5.4 All applicants' sigsheet progress + +**New file: `src/routes/api/admin/sigsheet-progress/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + + // Get total member count for progress calculation + const { count: totalMembers } = await supabaseAdmin + .from('members') + .select('*', { count: 'exact', head: true }); + + const { data, error } = await supabaseAdmin + .from('sigsheet') + .select(` + sig_id, signed_at, question, answer, member_id, member_name, + applicant:profiles!inner ( id, username, full_name ) + `); + + if (error) { + return json({ error: error.message }, { status: 500 }); + } + + // Group by applicant + const byApplicant: Record = {}; + for (const row of data ?? []) { + const key = row.applicant.id; + if (!byApplicant[key]) { + byApplicant[key] = { profile: row.applicant, signatures: [], count: 0 }; + } + byApplicant[key].signatures.push({ + sig_id: row.sig_id, + signed_at: row.signed_at, + member_id: row.member_id, + member_name: row.member_name, + }); + byApplicant[key].count++; + } + + return json({ + total_members: totalMembers, + progress: Object.values(byApplicant), + }); +} +``` + +### Endpoint summary + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/admin/applicants` | List all applicant profiles | +| GET | `/api/admin/quiz-results` | All submissions with total scores | +| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | +| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | + +### Verify each endpoint + +For each: unauthenticated → 401, applicant → 403, admin → 200 with data. + +--- + +## Phase 6: Row Level Security (RLS) + +RLS is defense-in-depth. Even if application code has a bug, the database enforces access rules. The `supabaseAdmin` client (service role) bypasses RLS by design — admin endpoints still work. + +### 6.1 Create helper function + +```sql +CREATE OR REPLACE FUNCTION public.get_user_role() +RETURNS public.app_role +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT role FROM public.profiles WHERE id = auth.uid() +$$; +``` + +`SECURITY DEFINER` lets it read `profiles` even when RLS is enabled. `STABLE` allows caching within a transaction. + +### 6.2 Enable RLS on all tables + +```sql +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.whitelist ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-answers" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-submissions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-sections" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-questions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-options" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-availability" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.sigsheet ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."pic-folders" ENABLE ROW LEVEL SECURITY; +``` + +> **Note:** Table names with hyphens must be double-quoted in SQL. + +### 6.3 Policies — User-scoped tables + +**profiles:** +```sql +CREATE POLICY "Users can view own profile" + ON public.profiles FOR SELECT + USING (auth.uid() = id); + +CREATE POLICY "Users can update own profile" + ON public.profiles FOR UPDATE + USING (auth.uid() = id); + +CREATE POLICY "Admins can view all profiles" + ON public.profiles FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**constiquiz-answers:** +```sql +CREATE POLICY "Users can view own answers" + ON public."constiquiz-answers" FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own answers" + ON public."constiquiz-answers" FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own answers" + ON public."constiquiz-answers" FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Admins can view all answers" + ON public."constiquiz-answers" FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**constiquiz-submissions:** +```sql +CREATE POLICY "Users can view own submissions" + ON public."constiquiz-submissions" FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own submissions" + ON public."constiquiz-submissions" FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Admins can view all submissions" + ON public."constiquiz-submissions" FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**sigsheet:** +```sql +CREATE POLICY "Users can view own sigsheet" + ON public.sigsheet FOR SELECT + USING (auth.uid() = applicant_id); + +CREATE POLICY "Users can insert own sigsheet" + ON public.sigsheet FOR INSERT + WITH CHECK (auth.uid() = applicant_id); + +CREATE POLICY "Admins can view all sigsheet" + ON public.sigsheet FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**pic-folders:** +```sql +CREATE POLICY "Users can view own folder" + ON public."pic-folders" FOR SELECT + USING (auth.uid() = applicant_uuid); + +CREATE POLICY "Users can insert own folder" + ON public."pic-folders" FOR INSERT + WITH CHECK (auth.uid() = applicant_uuid); + +CREATE POLICY "Admins can view all folders" + ON public."pic-folders" FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +### 6.4 Policies — Read-only reference tables + +These are shared data all authenticated users can read: + +```sql +CREATE POLICY "Authenticated can view sections" + ON public."constiquiz-sections" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view questions" + ON public."constiquiz-questions" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view options" + ON public."constiquiz-options" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view availability" + ON public."constiquiz-availability" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view members" + ON public.members FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can check whitelist" + ON public.whitelist FOR SELECT + USING (auth.uid() IS NOT NULL); +``` + +### 6.5 Verify RLS + +1. Open browser DevTools as an applicant. Try querying another user's answers via the Supabase JS client → should return empty +2. Admin API endpoints (which use `supabaseAdmin` / service role) should still return all data +3. Applicant flow (quiz, sigsheet, upload) should still work normally — own data is accessible + +--- + +## File Change Summary + +### New files +| File | Purpose | +|------|---------| +| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | +| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | +| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | +| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | +| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | +| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | + +### Modified files +| File | Change | +|------|--------| +| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | +| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | +| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | +| `src/routes/api/get_gdrive_folder/+server.js` | Add auth check, use `locals.supabase` instead of browser client | +| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | +| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | +| `.github/workflows/deploy.yml` | Update env var name | +| `.github/workflows/ci.yml` | Update env var name | + +### Unchanged files +- `src/routes/login/callback/+server.js` — login flow stays the same +- `src/routes/+layout.ts` — frontend concern +- `src/lib/supabaseClient.js` — browser client stays (used by frontend) +- All Svelte components — frontend concern + +--- + +## Implementation Order + +Phases **must** be done in order due to dependencies: + +``` +Phase 1 (DB schema) + ↓ role column must exist +Phase 2 (auth infra) + ↓ helpers + admin client must exist +Phase 3 (hooks + types) + ↓ userRole must be on event.locals +Phase 4 (fix existing endpoints) ←── can be parallel with Phase 5 +Phase 5 (admin endpoints) ←── can be parallel with Phase 4 + ↓ +Phase 6 (RLS policies) +``` + +Phase 4 and 5 can be done in parallel by different team members since they touch different files. + +--- + +## Future: Adding "member" Role + +When the time comes: +1. `ALTER TYPE public.app_role ADD VALUE 'member';` +2. Add member-specific RLS policies +3. Add endpoints under `/api/member/` +4. The `requireRole()` and `isAdmin()` pattern extends naturally — add `isMember()` etc. diff --git a/docs/2-admin-roles-data-flow-diagrams.md b/docs/2-admin-roles-data-flow-diagrams.md new file mode 100644 index 0000000..0ea1b62 --- /dev/null +++ b/docs/2-admin-roles-data-flow-diagrams.md @@ -0,0 +1,209 @@ +# Admin Roles — System Overview Diagrams + +> Companion to `1-admin-roles-implementation-plan.md`. +> Diagrams use Mermaid syntax — rendered natively on GitHub. To use in Excalidraw, paste the code blocks into the "Mermaid to Excalidraw" feature (wand icon). + +--- + +## 1. How Sign-In Works + +Everyone signs in the same way. The system figures out your role after you log in. + +```mermaid +flowchart LR + A["User clicks Sign In"] --> B["Google login\n(@up.edu.ph only)"] + B --> C{"Email in\nwhitelist?"} + C -->|No| D["Rejected"] + C -->|Yes| E["Logged in"] + E --> F["System looks up\nrole from database"] + F --> G["Applicant"] + F --> H["Admin"] + + style D fill:#fce8e6,stroke:#d93025 + style G fill:#e8f4fd,stroke:#1a73e8 + style H fill:#fce8e6,stroke:#d93025 +``` + +--- + +## 2. What Each Role Can See and Do + +```mermaid +flowchart TB + subgraph NOT_LOGGED_IN["Not Logged In"] + N1["Login page only"] + end + + subgraph APPLICANT_ACCESS["Applicant"] + direction TB + A1["Take the constitution quiz"] + A2["View & save their own answers"] + A3["Collect signatures on sigsheet"] + A4["Upload files to Google Drive"] + end + + subgraph ADMIN_ACCESS["Admin"] + direction TB + B1["View all applicant profiles"] + B2["View all quiz submissions & scores"] + B3["View any applicant's detailed answers"] + B4["View sigsheet progress of all applicants"] + end + + style NOT_LOGGED_IN fill:#f5f5f5,stroke:#999 + style APPLICANT_ACCESS fill:#e8f4fd,stroke:#1a73e8 + style ADMIN_ACCESS fill:#fce8e6,stroke:#d93025 +``` + +Key difference: **applicants only see their own data**, **admins can see everyone's data**. + +--- + +## 3. How a Request Flows Through the System + +Every page visit or API call goes through the same pipeline. + +```mermaid +flowchart TD + A["User visits a page\nor calls an API"] --> B["Server checks:\nAre you logged in?"] + + B -->|Not logged in| C["Can only see\npublic pages"] + B -->|Logged in| D["Server looks up\nyour role"] + + D --> E{"What's your role?"} + + E -->|Applicant| F["Can access\napplicant features"] + E -->|Admin| G["Can access\nadmin features"] + + F --> H["Data is filtered:\nyou only see YOUR stuff"] + G --> I["Data is unfiltered:\nyou see ALL applicants' stuff"] + + style C fill:#f5f5f5,stroke:#999 + style F fill:#e8f4fd,stroke:#1a73e8 + style G fill:#fce8e6,stroke:#d93025 + style H fill:#e8f4fd,stroke:#1a73e8 + style I fill:#fce8e6,stroke:#d93025 +``` + +--- + +## 4. System Architecture Overview + +```mermaid +flowchart TB + subgraph USERS["Users"] + APPLICANT["Applicant\n(@up.edu.ph)"] + ADMIN["Admin\n(@up.edu.ph)"] + end + + subgraph APP["UP CSI App"] + LOGIN["Login\n(Google OAuth)"] + ROLE_CHECK["Role Check\n(on every request)"] + + subgraph APPLICANT_PAGES["Applicant Features"] + QUIZ["Constitution Quiz"] + SIG["Sigsheet"] + UPLOAD["File Upload"] + end + + subgraph ADMIN_PAGES["Admin Features"] + VIEW_PROFILES["View Applicant Profiles"] + VIEW_QUIZ["View Quiz Results & Scores"] + VIEW_SIG["View Sigsheet Progress"] + end + end + + subgraph SERVICES["External Services"] + GOOGLE["Google OAuth"] + GDRIVE["Google Drive\n(file storage)"] + SUPABASE["Supabase\n(database + auth)"] + end + + APPLICANT --> LOGIN + ADMIN --> LOGIN + LOGIN --> GOOGLE + GOOGLE --> ROLE_CHECK + + ROLE_CHECK -->|"role = applicant"| APPLICANT_PAGES + ROLE_CHECK -->|"role = admin"| ADMIN_PAGES + + QUIZ --> SUPABASE + SIG --> SUPABASE + UPLOAD --> GDRIVE + VIEW_PROFILES --> SUPABASE + VIEW_QUIZ --> SUPABASE + VIEW_SIG --> SUPABASE + + style APPLICANT fill:#e8f4fd,stroke:#1a73e8 + style ADMIN fill:#fce8e6,stroke:#d93025 + style APPLICANT_PAGES fill:#e8f4fd,stroke:#1a73e8 + style ADMIN_PAGES fill:#fce8e6,stroke:#d93025 +``` + +--- + +## 5. What Happens When Access is Denied + +```mermaid +flowchart TD + A["Someone tries to\naccess an admin page"] --> B{"Logged in?"} + + B -->|No| C["401: Please log in"] + B -->|Yes| D{"Role = admin?"} + + D -->|No, applicant| E["403: You don't have\npermission for this"] + D -->|Yes| F["200: Here's the data"] + + style C fill:#fce8e6,stroke:#d93025 + style E fill:#fef7e0,stroke:#f9ab00 + style F fill:#e6f4ea,stroke:#137333 +``` + +--- + +## 6. Two Layers of Protection + +The system protects data at two levels — even if one layer has a bug, the other catches it. + +```mermaid +flowchart TD + A["User makes a request"] --> B + + subgraph B["Layer 1: App Server"] + B1["Checks if you're logged in"] + B2["Checks if you have the right role"] + B1 --> B2 + end + + B --> C + + subgraph C["Layer 2: Database"] + C1["Row Level Security (RLS)"] + C2["Applicants can only\nread/write their own rows"] + C3["Admins can read all rows"] + C1 --> C2 + C1 --> C3 + end + + C --> D["Data returned"] + + style B fill:#e8f4fd,stroke:#1a73e8 + style C fill:#fef7e0,stroke:#f9ab00 + style D fill:#e6f4ea,stroke:#137333 +``` + +--- + +## 7. Data Ownership Summary + +| Data | Applicant can... | Admin can... | +|------|-----------------|--------------| +| Own profile | View, update | - | +| All profiles | - | View all | +| Own quiz answers | View, save, submit | - | +| All quiz answers | - | View all + scores | +| Own sigsheet entries | View, create | - | +| All sigsheet entries | - | View all + progress | +| Own GDrive folder | Create, upload to | - | +| Quiz questions/options | View (read-only) | View (read-only) | +| Members list | View (read-only) | View (read-only) | From bb1c60a057a83834d0c865fa412f5939c9d0c7ae Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 21:47:48 +0800 Subject: [PATCH 2/8] feat: add server-side auth helpers and admin Supabase client - requireAuth(), requireRole(), isAdmin() utilities in lib/server/auth.ts - Service-role Supabase client in lib/server/supabaseAdmin.ts (bypasses RLS) - AppRole type includes applicant, admin, withdrawn, inactive Co-Authored-By: Claude Opus 4.6 --- src/lib/server/auth.ts | 33 +++++++++++++++++++++++++++++++++ src/lib/server/supabaseAdmin.ts | 9 +++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/lib/server/auth.ts create mode 100644 src/lib/server/supabaseAdmin.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..dd54373 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,33 @@ +import { error } from '@sveltejs/kit'; +import type { RequestEvent } 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'; +} diff --git a/src/lib/server/supabaseAdmin.ts b/src/lib/server/supabaseAdmin.ts new file mode 100644 index 0000000..e4b7a05 --- /dev/null +++ b/src/lib/server/supabaseAdmin.ts @@ -0,0 +1,9 @@ +import { SUPABASE_SERVICE_KEY } from '$env/static/private'; +import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +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); From 124c1857b0cfbefc0d95c7324cdf1ade96e55346 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 21:47:55 +0800 Subject: [PATCH 3/8] fix: rename PUBLIC_SUPABASE_SERVICE_KEY to SUPABASE_SERVICE_KEY in CI/CD The PUBLIC_ prefix exposes the service key to the browser in SvelteKit. Renamed to SUPABASE_SERVICE_KEY and moved to secrets in deploy workflow. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 575d418..3dd31a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ 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 }} + SUPABASE_SERVICE_KEY: ${{ vars.SUPABASE_SERVICE_KEY }} PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }} PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a03196..a3292d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ 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 }} + SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }} PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }} @@ -59,7 +59,7 @@ 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 }} + SUPABASE_SERVICE_KEY=${{ env.SUPABASE_SERVICE_KEY }} PUBLIC_GOOGLE_SERVICE_EMAIL=${{ env.PUBLIC_GOOGLE_SERVICE_EMAIL }} PUBLIC_GOOGLE_PRIVATE_KEY=${{ env.PUBLIC_GOOGLE_PRIVATE_KEY }} EOF From 1a8a781a07fcb398e97a4da6bfc3774fa6eba490 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:07:37 +0800 Subject: [PATCH 4/8] chore: fix lint and formatting issues Co-Authored-By: Claude Opus 4.6 --- docs/1-admin-roles-implementation-plan.md | 89 ++++++++++++----------- docs/2-admin-roles-data-flow-diagrams.md | 22 +++--- src/lib/server/auth.ts | 3 +- src/lib/server/supabaseAdmin.ts | 2 +- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/docs/1-admin-roles-implementation-plan.md b/docs/1-admin-roles-implementation-plan.md index 5213df0..7476836 100644 --- a/docs/1-admin-roles-implementation-plan.md +++ b/docs/1-admin-roles-implementation-plan.md @@ -78,6 +78,7 @@ The service key must NOT have the `PUBLIC_` prefix — SvelteKit exposes `PUBLIC **`.env`:** Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` **`.github/workflows/deploy.yml`:** Update all references: + ```yaml # Change these lines: PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} @@ -255,6 +256,7 @@ Two endpoints currently have no auth checks and use the browser Supabase client **File: `src/routes/api/get_gdrive_folder/+server.js`** Changes: + 1. Remove `import { supabase } from '$lib/supabaseClient';` 2. Add auth check using `locals.safeGetSession()` (or import `requireAuth` if converting to `.ts`) 3. Use `locals.supabase` instead of the browser singleton @@ -286,6 +288,7 @@ export async function POST({ request, locals }) { **File: `src/routes/api/upload/+server.js`** Same pattern: + 1. Remove `import { supabase } from '../../../lib/supabaseClient';` 2. Add auth check 3. Use `locals.supabase` for DB operations @@ -323,6 +326,7 @@ export async function POST({ request, locals }) { ## Phase 5: Admin API Endpoints All new endpoints go under `src/routes/api/admin/`. Every endpoint: + - Calls `requireRole(event, 'admin')` as the first line - Uses `supabaseAdmin` (service-role client) to bypass RLS for cross-user reads - Returns JSON @@ -365,9 +369,7 @@ export async function GET(event) { requireRole(event, 'admin'); // Get all submissions with profile info - const { data: submissions, error: subError } = await supabaseAdmin - .from('constiquiz-submissions') - .select(` + const { data: submissions, error: subError } = await supabaseAdmin.from('constiquiz-submissions').select(` submission_id, submitted_at, user_id, @@ -428,24 +430,18 @@ export async function GET(event) { const [answersRes, profileRes, submissionRes] = await Promise.all([ supabaseAdmin .from('constiquiz-answers') - .select(` + .select( + ` answer_id, question_id, answer_text, option_id, points, is_checked, question:constiquiz-questions!inner ( title, point_value, type, section:constiquiz-sections!inner ( title ) ) - `) + `, + ) .eq('user_id', userId), - supabaseAdmin - .from('profiles') - .select('id, username, full_name') - .eq('id', userId) - .single(), - supabaseAdmin - .from('constiquiz-submissions') - .select('submitted_at') - .eq('user_id', userId) - .maybeSingle(), + supabaseAdmin.from('profiles').select('id, username, full_name').eq('id', userId).single(), + supabaseAdmin.from('constiquiz-submissions').select('submitted_at').eq('user_id', userId).maybeSingle(), ]); if (answersRes.error) { @@ -473,13 +469,9 @@ export async function GET(event) { requireRole(event, 'admin'); // Get total member count for progress calculation - const { count: totalMembers } = await supabaseAdmin - .from('members') - .select('*', { count: 'exact', head: true }); + const { count: totalMembers } = await supabaseAdmin.from('members').select('*', { count: 'exact', head: true }); - const { data, error } = await supabaseAdmin - .from('sigsheet') - .select(` + const { data, error } = await supabaseAdmin.from('sigsheet').select(` sig_id, signed_at, question, answer, member_id, member_name, applicant:profiles!inner ( id, username, full_name ) `); @@ -513,12 +505,12 @@ export async function GET(event) { ### Endpoint summary -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/api/admin/applicants` | List all applicant profiles | -| GET | `/api/admin/quiz-results` | All submissions with total scores | -| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | -| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | +| Method | Path | Purpose | +| ------ | ---------------------------------- | ------------------------------------------ | +| GET | `/api/admin/applicants` | List all applicant profiles | +| GET | `/api/admin/quiz-results` | All submissions with total scores | +| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | +| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | ### Verify each endpoint @@ -566,6 +558,7 @@ ALTER TABLE public."pic-folders" ENABLE ROW LEVEL SECURITY; ### 6.3 Policies — User-scoped tables **profiles:** + ```sql CREATE POLICY "Users can view own profile" ON public.profiles FOR SELECT @@ -581,6 +574,7 @@ CREATE POLICY "Admins can view all profiles" ``` **constiquiz-answers:** + ```sql CREATE POLICY "Users can view own answers" ON public."constiquiz-answers" FOR SELECT @@ -600,6 +594,7 @@ CREATE POLICY "Admins can view all answers" ``` **constiquiz-submissions:** + ```sql CREATE POLICY "Users can view own submissions" ON public."constiquiz-submissions" FOR SELECT @@ -615,6 +610,7 @@ CREATE POLICY "Admins can view all submissions" ``` **sigsheet:** + ```sql CREATE POLICY "Users can view own sigsheet" ON public.sigsheet FOR SELECT @@ -630,6 +626,7 @@ CREATE POLICY "Admins can view all sigsheet" ``` **pic-folders:** + ```sql CREATE POLICY "Users can view own folder" ON public."pic-folders" FOR SELECT @@ -685,28 +682,31 @@ CREATE POLICY "Authenticated can check whitelist" ## File Change Summary ### New files -| File | Purpose | -|------|---------| -| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | -| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | -| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | -| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | -| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | -| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | + +| File | Purpose | +| ------------------------------------------------------- | ---------------------------------------------------------------------- | +| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | +| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | +| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | +| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | +| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | +| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | ### Modified files -| File | Change | -|------|--------| -| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | -| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | -| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | + +| File | Change | +| --------------------------------------------- | --------------------------------------------------------------- | +| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | +| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | +| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | | `src/routes/api/get_gdrive_folder/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | -| `.github/workflows/deploy.yml` | Update env var name | -| `.github/workflows/ci.yml` | Update env var name | +| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | +| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | +| `.github/workflows/deploy.yml` | Update env var name | +| `.github/workflows/ci.yml` | Update env var name | ### Unchanged files + - `src/routes/login/callback/+server.js` — login flow stays the same - `src/routes/+layout.ts` — frontend concern - `src/lib/supabaseClient.js` — browser client stays (used by frontend) @@ -738,6 +738,7 @@ Phase 4 and 5 can be done in parallel by different team members since they touch ## Future: Adding "member" Role When the time comes: + 1. `ALTER TYPE public.app_role ADD VALUE 'member';` 2. Add member-specific RLS policies 3. Add endpoints under `/api/member/` diff --git a/docs/2-admin-roles-data-flow-diagrams.md b/docs/2-admin-roles-data-flow-diagrams.md index 0ea1b62..e6903ea 100644 --- a/docs/2-admin-roles-data-flow-diagrams.md +++ b/docs/2-admin-roles-data-flow-diagrams.md @@ -196,14 +196,14 @@ flowchart TD ## 7. Data Ownership Summary -| Data | Applicant can... | Admin can... | -|------|-----------------|--------------| -| Own profile | View, update | - | -| All profiles | - | View all | -| Own quiz answers | View, save, submit | - | -| All quiz answers | - | View all + scores | -| Own sigsheet entries | View, create | - | -| All sigsheet entries | - | View all + progress | -| Own GDrive folder | Create, upload to | - | -| Quiz questions/options | View (read-only) | View (read-only) | -| Members list | View (read-only) | View (read-only) | +| Data | Applicant can... | Admin can... | +| ---------------------- | ------------------ | ------------------- | +| Own profile | View, update | - | +| All profiles | - | View all | +| Own quiz answers | View, save, submit | - | +| All quiz answers | - | View all + scores | +| Own sigsheet entries | View, create | - | +| All sigsheet entries | - | View all + progress | +| Own GDrive folder | Create, upload to | - | +| Quiz questions/options | View (read-only) | View (read-only) | +| Members list | View (read-only) | View (read-only) | diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index dd54373..070064b 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,5 +1,4 @@ -import { error } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; +import { type RequestEvent, error } from '@sveltejs/kit'; export type AppRole = 'applicant' | 'admin' | 'withdrawn' | 'inactive'; diff --git a/src/lib/server/supabaseAdmin.ts b/src/lib/server/supabaseAdmin.ts index e4b7a05..08c38c7 100644 --- a/src/lib/server/supabaseAdmin.ts +++ b/src/lib/server/supabaseAdmin.ts @@ -1,5 +1,5 @@ -import { SUPABASE_SERVICE_KEY } from '$env/static/private'; import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { SUPABASE_SERVICE_KEY } from '$env/static/private'; import { createClient } from '@supabase/supabase-js'; /** From 32fa03629c88ea91a82c1e7859511a2f1743904d Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:13:56 +0800 Subject: [PATCH 5/8] feat: add userRole to App.Locals and App.PageData types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of Phase 3 — needed for auth helpers to type-check correctly. Co-Authored-By: Claude Opus 4.6 --- src/app.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app.d.ts b/src/app.d.ts index 20bce2d..20540b5 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,6 @@ import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; import type { Database } from './database.types.ts'; // import generated types +import type { AppRole } from '$lib/server/auth'; declare global { namespace App { @@ -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 {} From 61d8db1176365017c88d55aa785093df566dfd90 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:19:20 +0800 Subject: [PATCH 6/8] chore: fix import sort order in app.d.ts Co-Authored-By: Claude Opus 4.6 --- src/app.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.d.ts b/src/app.d.ts index 20540b5..004f5e9 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,6 @@ import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; -import type { Database } from './database.types.ts'; // import generated types import type { AppRole } from '$lib/server/auth'; +import type { Database } from './database.types.ts'; // import generated types declare global { namespace App { From 1247dbedb21c4cbb9c5599e2e55ed90d90a4eef2 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:23:46 +0800 Subject: [PATCH 7/8] chore: remove docs/ and CLAUDE.md from tracking Add both to .gitignore. Files kept locally but not in the repo. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + CLAUDE.md | 77 --- docs/1-admin-roles-implementation-plan.md | 745 ---------------------- docs/2-admin-roles-data-flow-diagrams.md | 209 ------ 4 files changed, 2 insertions(+), 1031 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 docs/1-admin-roles-implementation-plan.md delete mode 100644 docs/2-admin-roles-data-flow-diagrams.md diff --git a/.gitignore b/.gitignore index fb7f88c..a80deee 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.svelte-kit node_modules .env +docs/ +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f9e39a0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,77 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -UP CSI member application processing web app built with **SvelteKit 2** (Svelte 5), **TypeScript**, **Supabase** (auth + PostgreSQL), and **Google Drive API** (file storage). Uses **pnpm** as package manager. - -## Commands - -```bash -pnpm dev # Start dev server -pnpm build # Production build -pnpm preview # Preview production build - -pnpm fmt # Check formatting (Prettier) -pnpm fmt:fix # Auto-fix formatting -pnpm lint # Run all linters in parallel (html, css, js, svelte) -pnpm lint:js # ESLint only -pnpm lint:svelte # Svelte type checking only -``` - -## Architecture - -### Routing (SvelteKit file-based) - -- `/` — Dashboard/home -- `/login` — Google OAuth login (restricted to `@up.edu.ph` emails, checked against Supabase `whitelist` table) -- `/login/callback` — OAuth callback with domain + whitelist validation -- `/sigsheet` — Signature sheet feature (member grid with modals) -- `/consti-quiz` — Constitution quiz (multiple question types: radio, checkbox, short/long text) -- `/api/answers` — POST quiz answers (upsert pattern) -- `/api/get_gdrive_folder` — Create/get Google Drive folders -- `/api/upload` — Upload files to Google Drive - -### Auth Flow - -Supabase SSR OAuth with Google. Server hook (`hooks.server.ts`) creates a Supabase client per request with cookie management. Browser client uses `createBrowserClient()`. `safeGetSession()` helper for auth state. Layout's `onAuthStateChange` invalidates data on auth changes. - -### Data Layer - -- **Database**: Supabase PostgREST — tables include `sigsheet`, `constiquiz-sections`, `constiquiz-questions`, `constiquiz-options`, `constiquiz-answers`, `constiquiz-submissions`, `constiquiz-availability`, `profiles`, `whitelist`, `pic-folders` -- **File storage**: Google Drive API with JWT service account auth -- **Client state**: Svelte writable stores in `$lib/shared.ts` (`uuid`, `username`, `gdrive_folder_id`, `filledSigsheet`, `applicant_names_list`) -- **Data loading**: Parallel fetching with `Promise.all()` in `+layout.ts`, dependency tracking with `depends()` - -### Key Files - -- `src/hooks.server.ts` — Server hook creating Supabase client per request -- `src/lib/supabaseClient.js` — Browser-side Supabase client -- `src/lib/shared.ts` — Global Svelte stores -- `src/routes/+layout.ts` — Root data loader (auth, quiz data) -- `src/routes/consti-quiz/constiquiz-types.ts` — Quiz question type definitions (discriminated unions) - -### Environment Variables - -``` -PUBLIC_SUPABASE_URL -PUBLIC_SUPABASE_ANON_KEY -PUBLIC_SUPABASE_SERVICE_KEY -PUBLIC_GOOGLE_SERVICE_EMAIL -PUBLIC_GOOGLE_PRIVATE_KEY -``` - -## Code Conventions - -- **Svelte 5 runes**: Uses `$props()`, `$state()`, snippet types for component composition -- **TypeScript strict mode** with `noUncheckedIndexedAccess` and `noImplicitOverride` -- **Prettier**: 4-space indentation, 120 char width, single quotes, Tailwind class sorting plugin -- **Tailwind CSS**: Custom CSI brand colors defined in `tailwind.config.ts` (`csi-blue: #00C6D7`, `csi-black: #212121`, `csi-yellow: #F7CF2F`, plus committee colors) -- **ESLint**: No unused vars, no console (warning), prefer const - -## Deployment - -- Docker multi-stage build (Node.js Alpine), port 3000 -- CI runs on PRs/push to main: install → format check → lint → build -- Deploy triggers on push to `production` branch → builds Docker image → pushes to GitHub Container Registry diff --git a/docs/1-admin-roles-implementation-plan.md b/docs/1-admin-roles-implementation-plan.md deleted file mode 100644 index 7476836..0000000 --- a/docs/1-admin-roles-implementation-plan.md +++ /dev/null @@ -1,745 +0,0 @@ -# Admin Roles Implementation Plan - -> **Scope:** Database, auth, and backend changes only. No frontend. -> **Goal:** Add an "admin" role so org leaders can view/check applicant data (quiz responses, sigsheet progress, profiles). - ---- - -## Current State Summary - -- **Auth:** Google OAuth → `@up.edu.ph` domain check → `whitelist` table check → session created. All users treated identically as applicants. -- **Database:** `profiles` table has no role column. No RLS policies on any table. -- **Backend:** Two API endpoints (`/api/get_gdrive_folder`, `/api/upload`) have no auth checks and use a browser-side Supabase client on the server. Other endpoints check `safeGetSession()` but have no role awareness. -- **Hooks:** `hooks.server.ts` has a commented-out `authGuard`. The `sequence()` only runs the `supabase` handle. -- **Service key:** `PUBLIC_SUPABASE_SERVICE_KEY` exists in CI env vars but is never imported in code. The `PUBLIC_` prefix is wrong — it would expose the key to the browser. - ---- - -## Phase 1: Database Schema Changes - -### 1.1 Add role to profiles - -Run this migration in Supabase SQL Editor (or via Supabase CLI migration): - -```sql --- Create enum type for roles (extensible: add 'member' later with ALTER TYPE) -CREATE TYPE public.app_role AS ENUM ('applicant', 'admin'); - --- Add role column — all existing users default to 'applicant' -ALTER TABLE public.profiles - ADD COLUMN role public.app_role NOT NULL DEFAULT 'applicant'; - --- Index for fast role lookups (used on every request in hooks) -CREATE INDEX idx_profiles_role ON public.profiles (role); -``` - -**Design decision:** Role on `profiles` directly (not a separate table) because it's a 1:1 relationship with two values. If multi-role is needed later, migrate to a join table then. - -### 1.2 Seed admin users - -```sql -UPDATE public.profiles -SET role = 'admin' -WHERE id IN ( - SELECT id FROM auth.users - WHERE email IN ( - 'admin1@up.edu.ph', - 'admin2@up.edu.ph' - -- Replace with actual admin emails - ) -); -``` - -Ensure these emails are also in the `whitelist` table: - -```sql -INSERT INTO public.whitelist (email) -VALUES ('admin1@up.edu.ph'), ('admin2@up.edu.ph') -ON CONFLICT (email) DO NOTHING; -``` - -### 1.3 Verify - -```sql -SELECT p.id, u.email, p.role -FROM public.profiles p -JOIN auth.users u ON p.id = u.id -ORDER BY p.role, u.email; -``` - ---- - -## Phase 2: Server-Side Auth Infrastructure - -### 2.1 Rename service key env var - -The service key must NOT have the `PUBLIC_` prefix — SvelteKit exposes `PUBLIC_` vars to the browser. - -**`.env`:** Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` - -**`.github/workflows/deploy.yml`:** Update all references: - -```yaml -# Change these lines: -PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} -# To: -SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} -``` - -**`.github/workflows/ci.yml`:** Same rename. Also update the GitHub Actions repo settings to use the new variable name (or move to a secret since it's a sensitive key). - -### 2.2 Create service-role Supabase client - -**New file: `src/lib/server/supabaseAdmin.ts`** - -```typescript -import { SUPABASE_SERVICE_KEY } from '$env/static/private'; -import { PUBLIC_SUPABASE_URL } from '$env/static/public'; -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); -``` - -The `src/lib/server/` directory is a SvelteKit convention — files here can only be imported from server-side code (`+server.ts`, `+page.server.ts`, `hooks.server.ts`). This prevents accidental browser exposure. - -### 2.3 Create auth helper utilities - -**New file: `src/lib/server/auth.ts`** - -```typescript -import { error } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; - -export type AppRole = 'applicant' | 'admin'; - -/** - * 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'; -} -``` - ---- - -## Phase 3: Auth Flow — Role Resolution in Hooks - -### 3.1 Update TypeScript types - -**File: `src/app.d.ts`** - -```typescript -import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; -import type { Database } from './database.types.ts'; -import type { AppRole } from '$lib/server/auth'; - -declare global { - namespace App { - interface Locals { - supabase: SupabaseClient; - 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; - } - } -} - -export {}; -``` - -### 3.2 Add authGuard to hooks - -**File: `src/hooks.server.ts`** - -Replace the commented-out `authGuard` and update the `sequence()` export: - -```typescript -import { type Handle } from '@sveltejs/kit'; -import { createServerClient } from '@supabase/ssr'; -import { sequence } from '@sveltejs/kit/hooks'; -import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; -import type { AppRole } from '$lib/server/auth'; - -const supabase: Handle = ({ event, resolve }) => { - // ... KEEP EXISTING CODE (lines 8-48) EXACTLY AS-IS ... -}; - -const authGuard: Handle = async ({ event, resolve }) => { - const { session, user } = await event.locals.safeGetSession(); - event.locals.session = session; - event.locals.user = user; - event.locals.userRole = null; - - if (user) { - const { data: profile } = await event.locals.supabase - .from('profiles') - .select('role') - .eq('id', user.id) - .single(); - - event.locals.userRole = (profile?.role as AppRole) ?? 'applicant'; - } - - return resolve(event); -}; - -export const handle: Handle = sequence(supabase, authGuard); -``` - -**What this does:** On every request, after the Supabase client is set up, fetch the user's role from `profiles` and attach it to `event.locals.userRole`. All downstream handlers (layout loads, API endpoints) can read `event.locals.userRole` without an extra DB query. - -### 3.3 Pass role to page data - -**File: `src/routes/+layout.server.ts`** - -```typescript -export const load = async ({ locals: { safeGetSession, userRole }, cookies }) => { - const { session } = await safeGetSession(); - return { - session, - cookies: cookies.getAll(), - userRole: userRole ?? null, - }; -}; -``` - -### 3.4 Login callback — no changes needed - -Admins log in via the same Google OAuth flow. They must be in the `whitelist` table. Their role is determined from `profiles.role`, not the login flow. - -### 3.5 Verify - -1. Add `console.log('userRole:', event.locals.userRole)` temporarily in the `authGuard` -2. Log in as a seeded admin → should print `'admin'` -3. Log in as a regular applicant → should print `'applicant'` -4. Access any page without logging in → should print `null` - ---- - -## Phase 4: Fix Existing Unprotected Endpoints - -Two endpoints currently have no auth checks and use the browser Supabase client singleton on the server. - -### 4.1 Fix `/api/get_gdrive_folder` - -**File: `src/routes/api/get_gdrive_folder/+server.js`** - -Changes: - -1. Remove `import { supabase } from '$lib/supabaseClient';` -2. Add auth check using `locals.safeGetSession()` (or import `requireAuth` if converting to `.ts`) -3. Use `locals.supabase` instead of the browser singleton -4. Use `user.id` from auth instead of `uuid` from request body (prevents spoofing) - -```javascript -// Before: -import { supabase } from '$lib/supabaseClient'; -export async function POST({ request }) { - const { uuid, username } = await request.json(); - // ... uses uuid from body - -// After: -export async function POST({ request, locals }) { - const { user } = await locals.safeGetSession(); - if (!user) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } - const uuid = user.id; // Use authenticated user's ID - const { username } = await request.json(); - // ... replace all `supabase.` calls with `locals.supabase.` -``` - -### 4.2 Fix `/api/upload` - -**File: `src/routes/api/upload/+server.js`** - -Same pattern: - -1. Remove `import { supabase } from '../../../lib/supabaseClient';` -2. Add auth check -3. Use `locals.supabase` for DB operations -4. Use `user.id` instead of `formData.get('uuid')` - -```javascript -// Before: -import { supabase } from '../../../lib/supabaseClient'; -export async function POST({ request }) { - const formData = await request.formData(); - const uuid = formData.get('uuid'); - -// After: -export async function POST({ request, locals }) { - const { user } = await locals.safeGetSession(); - if (!user) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } - const uuid = user.id; - const formData = await request.formData(); - // ... replace all `supabase.` calls with `locals.supabase.` -``` - -### 4.3 Verify - -- Call `POST /api/get_gdrive_folder` without a session cookie → expect 401 -- Call `POST /api/upload` without a session cookie → expect 401 -- Call both while logged in → should work as before - ---- - -## Phase 5: Admin API Endpoints - -All new endpoints go under `src/routes/api/admin/`. Every endpoint: - -- Calls `requireRole(event, 'admin')` as the first line -- Uses `supabaseAdmin` (service-role client) to bypass RLS for cross-user reads -- Returns JSON - -### 5.1 List all applicant profiles - -**New file: `src/routes/api/admin/applicants/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - - const { data, error } = await supabaseAdmin - .from('profiles') - .select('id, username, full_name, avatar_url, role') - .eq('role', 'applicant'); - - if (error) { - return json({ error: error.message }, { status: 500 }); - } - - return json({ applicants: data }); -} -``` - -### 5.2 All quiz submissions with scores - -**New file: `src/routes/api/admin/quiz-results/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - - // Get all submissions with profile info - const { data: submissions, error: subError } = await supabaseAdmin.from('constiquiz-submissions').select(` - submission_id, - submitted_at, - user_id, - profiles!inner ( username, full_name ) - `); - - if (subError) { - return json({ error: subError.message }, { status: 500 }); - } - - // Get total scores per submitted user - const userIds = submissions?.map(s => s.user_id).filter(Boolean) ?? []; - - if (userIds.length === 0) { - return json({ results: [] }); - } - - const { data: answers, error: ansError } = await supabaseAdmin - .from('constiquiz-answers') - .select('user_id, points') - .in('user_id', userIds); - - if (ansError) { - return json({ error: ansError.message }, { status: 500 }); - } - - // Aggregate scores - const scoreMap: Record = {}; - for (const a of answers ?? []) { - scoreMap[a.user_id] = (scoreMap[a.user_id] ?? 0) + (a.points ?? 0); - } - - const results = submissions?.map(s => ({ - submission_id: s.submission_id, - submitted_at: s.submitted_at, - user_id: s.user_id, - profile: s.profiles, - total_score: scoreMap[s.user_id] ?? 0, - })); - - return json({ results }); -} -``` - -### 5.3 Single applicant's detailed quiz answers - -**New file: `src/routes/api/admin/quiz-results/[userId]/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - const userId = event.params.userId; - - const [answersRes, profileRes, submissionRes] = await Promise.all([ - supabaseAdmin - .from('constiquiz-answers') - .select( - ` - answer_id, question_id, answer_text, option_id, points, is_checked, - question:constiquiz-questions!inner ( - title, point_value, type, - section:constiquiz-sections!inner ( title ) - ) - `, - ) - .eq('user_id', userId), - supabaseAdmin.from('profiles').select('id, username, full_name').eq('id', userId).single(), - supabaseAdmin.from('constiquiz-submissions').select('submitted_at').eq('user_id', userId).maybeSingle(), - ]); - - if (answersRes.error) { - return json({ error: answersRes.error.message }, { status: 500 }); - } - - return json({ - profile: profileRes.data, - submitted_at: submissionRes.data?.submitted_at ?? null, - answers: answersRes.data, - }); -} -``` - -### 5.4 All applicants' sigsheet progress - -**New file: `src/routes/api/admin/sigsheet-progress/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - - // Get total member count for progress calculation - const { count: totalMembers } = await supabaseAdmin.from('members').select('*', { count: 'exact', head: true }); - - const { data, error } = await supabaseAdmin.from('sigsheet').select(` - sig_id, signed_at, question, answer, member_id, member_name, - applicant:profiles!inner ( id, username, full_name ) - `); - - if (error) { - return json({ error: error.message }, { status: 500 }); - } - - // Group by applicant - const byApplicant: Record = {}; - for (const row of data ?? []) { - const key = row.applicant.id; - if (!byApplicant[key]) { - byApplicant[key] = { profile: row.applicant, signatures: [], count: 0 }; - } - byApplicant[key].signatures.push({ - sig_id: row.sig_id, - signed_at: row.signed_at, - member_id: row.member_id, - member_name: row.member_name, - }); - byApplicant[key].count++; - } - - return json({ - total_members: totalMembers, - progress: Object.values(byApplicant), - }); -} -``` - -### Endpoint summary - -| Method | Path | Purpose | -| ------ | ---------------------------------- | ------------------------------------------ | -| GET | `/api/admin/applicants` | List all applicant profiles | -| GET | `/api/admin/quiz-results` | All submissions with total scores | -| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | -| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | - -### Verify each endpoint - -For each: unauthenticated → 401, applicant → 403, admin → 200 with data. - ---- - -## Phase 6: Row Level Security (RLS) - -RLS is defense-in-depth. Even if application code has a bug, the database enforces access rules. The `supabaseAdmin` client (service role) bypasses RLS by design — admin endpoints still work. - -### 6.1 Create helper function - -```sql -CREATE OR REPLACE FUNCTION public.get_user_role() -RETURNS public.app_role -LANGUAGE sql -STABLE -SECURITY DEFINER -AS $$ - SELECT role FROM public.profiles WHERE id = auth.uid() -$$; -``` - -`SECURITY DEFINER` lets it read `profiles` even when RLS is enabled. `STABLE` allows caching within a transaction. - -### 6.2 Enable RLS on all tables - -```sql -ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.whitelist ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-answers" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-submissions" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-sections" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-questions" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-options" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-availability" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.sigsheet ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.members ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."pic-folders" ENABLE ROW LEVEL SECURITY; -``` - -> **Note:** Table names with hyphens must be double-quoted in SQL. - -### 6.3 Policies — User-scoped tables - -**profiles:** - -```sql -CREATE POLICY "Users can view own profile" - ON public.profiles FOR SELECT - USING (auth.uid() = id); - -CREATE POLICY "Users can update own profile" - ON public.profiles FOR UPDATE - USING (auth.uid() = id); - -CREATE POLICY "Admins can view all profiles" - ON public.profiles FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**constiquiz-answers:** - -```sql -CREATE POLICY "Users can view own answers" - ON public."constiquiz-answers" FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own answers" - ON public."constiquiz-answers" FOR INSERT - WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update own answers" - ON public."constiquiz-answers" FOR UPDATE - USING (auth.uid() = user_id); - -CREATE POLICY "Admins can view all answers" - ON public."constiquiz-answers" FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**constiquiz-submissions:** - -```sql -CREATE POLICY "Users can view own submissions" - ON public."constiquiz-submissions" FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own submissions" - ON public."constiquiz-submissions" FOR INSERT - WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Admins can view all submissions" - ON public."constiquiz-submissions" FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**sigsheet:** - -```sql -CREATE POLICY "Users can view own sigsheet" - ON public.sigsheet FOR SELECT - USING (auth.uid() = applicant_id); - -CREATE POLICY "Users can insert own sigsheet" - ON public.sigsheet FOR INSERT - WITH CHECK (auth.uid() = applicant_id); - -CREATE POLICY "Admins can view all sigsheet" - ON public.sigsheet FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**pic-folders:** - -```sql -CREATE POLICY "Users can view own folder" - ON public."pic-folders" FOR SELECT - USING (auth.uid() = applicant_uuid); - -CREATE POLICY "Users can insert own folder" - ON public."pic-folders" FOR INSERT - WITH CHECK (auth.uid() = applicant_uuid); - -CREATE POLICY "Admins can view all folders" - ON public."pic-folders" FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -### 6.4 Policies — Read-only reference tables - -These are shared data all authenticated users can read: - -```sql -CREATE POLICY "Authenticated can view sections" - ON public."constiquiz-sections" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view questions" - ON public."constiquiz-questions" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view options" - ON public."constiquiz-options" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view availability" - ON public."constiquiz-availability" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view members" - ON public.members FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can check whitelist" - ON public.whitelist FOR SELECT - USING (auth.uid() IS NOT NULL); -``` - -### 6.5 Verify RLS - -1. Open browser DevTools as an applicant. Try querying another user's answers via the Supabase JS client → should return empty -2. Admin API endpoints (which use `supabaseAdmin` / service role) should still return all data -3. Applicant flow (quiz, sigsheet, upload) should still work normally — own data is accessible - ---- - -## File Change Summary - -### New files - -| File | Purpose | -| ------------------------------------------------------- | ---------------------------------------------------------------------- | -| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | -| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | -| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | -| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | -| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | -| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | - -### Modified files - -| File | Change | -| --------------------------------------------- | --------------------------------------------------------------- | -| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | -| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | -| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | -| `src/routes/api/get_gdrive_folder/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | -| `.github/workflows/deploy.yml` | Update env var name | -| `.github/workflows/ci.yml` | Update env var name | - -### Unchanged files - -- `src/routes/login/callback/+server.js` — login flow stays the same -- `src/routes/+layout.ts` — frontend concern -- `src/lib/supabaseClient.js` — browser client stays (used by frontend) -- All Svelte components — frontend concern - ---- - -## Implementation Order - -Phases **must** be done in order due to dependencies: - -``` -Phase 1 (DB schema) - ↓ role column must exist -Phase 2 (auth infra) - ↓ helpers + admin client must exist -Phase 3 (hooks + types) - ↓ userRole must be on event.locals -Phase 4 (fix existing endpoints) ←── can be parallel with Phase 5 -Phase 5 (admin endpoints) ←── can be parallel with Phase 4 - ↓ -Phase 6 (RLS policies) -``` - -Phase 4 and 5 can be done in parallel by different team members since they touch different files. - ---- - -## Future: Adding "member" Role - -When the time comes: - -1. `ALTER TYPE public.app_role ADD VALUE 'member';` -2. Add member-specific RLS policies -3. Add endpoints under `/api/member/` -4. The `requireRole()` and `isAdmin()` pattern extends naturally — add `isMember()` etc. diff --git a/docs/2-admin-roles-data-flow-diagrams.md b/docs/2-admin-roles-data-flow-diagrams.md deleted file mode 100644 index e6903ea..0000000 --- a/docs/2-admin-roles-data-flow-diagrams.md +++ /dev/null @@ -1,209 +0,0 @@ -# Admin Roles — System Overview Diagrams - -> Companion to `1-admin-roles-implementation-plan.md`. -> Diagrams use Mermaid syntax — rendered natively on GitHub. To use in Excalidraw, paste the code blocks into the "Mermaid to Excalidraw" feature (wand icon). - ---- - -## 1. How Sign-In Works - -Everyone signs in the same way. The system figures out your role after you log in. - -```mermaid -flowchart LR - A["User clicks Sign In"] --> B["Google login\n(@up.edu.ph only)"] - B --> C{"Email in\nwhitelist?"} - C -->|No| D["Rejected"] - C -->|Yes| E["Logged in"] - E --> F["System looks up\nrole from database"] - F --> G["Applicant"] - F --> H["Admin"] - - style D fill:#fce8e6,stroke:#d93025 - style G fill:#e8f4fd,stroke:#1a73e8 - style H fill:#fce8e6,stroke:#d93025 -``` - ---- - -## 2. What Each Role Can See and Do - -```mermaid -flowchart TB - subgraph NOT_LOGGED_IN["Not Logged In"] - N1["Login page only"] - end - - subgraph APPLICANT_ACCESS["Applicant"] - direction TB - A1["Take the constitution quiz"] - A2["View & save their own answers"] - A3["Collect signatures on sigsheet"] - A4["Upload files to Google Drive"] - end - - subgraph ADMIN_ACCESS["Admin"] - direction TB - B1["View all applicant profiles"] - B2["View all quiz submissions & scores"] - B3["View any applicant's detailed answers"] - B4["View sigsheet progress of all applicants"] - end - - style NOT_LOGGED_IN fill:#f5f5f5,stroke:#999 - style APPLICANT_ACCESS fill:#e8f4fd,stroke:#1a73e8 - style ADMIN_ACCESS fill:#fce8e6,stroke:#d93025 -``` - -Key difference: **applicants only see their own data**, **admins can see everyone's data**. - ---- - -## 3. How a Request Flows Through the System - -Every page visit or API call goes through the same pipeline. - -```mermaid -flowchart TD - A["User visits a page\nor calls an API"] --> B["Server checks:\nAre you logged in?"] - - B -->|Not logged in| C["Can only see\npublic pages"] - B -->|Logged in| D["Server looks up\nyour role"] - - D --> E{"What's your role?"} - - E -->|Applicant| F["Can access\napplicant features"] - E -->|Admin| G["Can access\nadmin features"] - - F --> H["Data is filtered:\nyou only see YOUR stuff"] - G --> I["Data is unfiltered:\nyou see ALL applicants' stuff"] - - style C fill:#f5f5f5,stroke:#999 - style F fill:#e8f4fd,stroke:#1a73e8 - style G fill:#fce8e6,stroke:#d93025 - style H fill:#e8f4fd,stroke:#1a73e8 - style I fill:#fce8e6,stroke:#d93025 -``` - ---- - -## 4. System Architecture Overview - -```mermaid -flowchart TB - subgraph USERS["Users"] - APPLICANT["Applicant\n(@up.edu.ph)"] - ADMIN["Admin\n(@up.edu.ph)"] - end - - subgraph APP["UP CSI App"] - LOGIN["Login\n(Google OAuth)"] - ROLE_CHECK["Role Check\n(on every request)"] - - subgraph APPLICANT_PAGES["Applicant Features"] - QUIZ["Constitution Quiz"] - SIG["Sigsheet"] - UPLOAD["File Upload"] - end - - subgraph ADMIN_PAGES["Admin Features"] - VIEW_PROFILES["View Applicant Profiles"] - VIEW_QUIZ["View Quiz Results & Scores"] - VIEW_SIG["View Sigsheet Progress"] - end - end - - subgraph SERVICES["External Services"] - GOOGLE["Google OAuth"] - GDRIVE["Google Drive\n(file storage)"] - SUPABASE["Supabase\n(database + auth)"] - end - - APPLICANT --> LOGIN - ADMIN --> LOGIN - LOGIN --> GOOGLE - GOOGLE --> ROLE_CHECK - - ROLE_CHECK -->|"role = applicant"| APPLICANT_PAGES - ROLE_CHECK -->|"role = admin"| ADMIN_PAGES - - QUIZ --> SUPABASE - SIG --> SUPABASE - UPLOAD --> GDRIVE - VIEW_PROFILES --> SUPABASE - VIEW_QUIZ --> SUPABASE - VIEW_SIG --> SUPABASE - - style APPLICANT fill:#e8f4fd,stroke:#1a73e8 - style ADMIN fill:#fce8e6,stroke:#d93025 - style APPLICANT_PAGES fill:#e8f4fd,stroke:#1a73e8 - style ADMIN_PAGES fill:#fce8e6,stroke:#d93025 -``` - ---- - -## 5. What Happens When Access is Denied - -```mermaid -flowchart TD - A["Someone tries to\naccess an admin page"] --> B{"Logged in?"} - - B -->|No| C["401: Please log in"] - B -->|Yes| D{"Role = admin?"} - - D -->|No, applicant| E["403: You don't have\npermission for this"] - D -->|Yes| F["200: Here's the data"] - - style C fill:#fce8e6,stroke:#d93025 - style E fill:#fef7e0,stroke:#f9ab00 - style F fill:#e6f4ea,stroke:#137333 -``` - ---- - -## 6. Two Layers of Protection - -The system protects data at two levels — even if one layer has a bug, the other catches it. - -```mermaid -flowchart TD - A["User makes a request"] --> B - - subgraph B["Layer 1: App Server"] - B1["Checks if you're logged in"] - B2["Checks if you have the right role"] - B1 --> B2 - end - - B --> C - - subgraph C["Layer 2: Database"] - C1["Row Level Security (RLS)"] - C2["Applicants can only\nread/write their own rows"] - C3["Admins can read all rows"] - C1 --> C2 - C1 --> C3 - end - - C --> D["Data returned"] - - style B fill:#e8f4fd,stroke:#1a73e8 - style C fill:#fef7e0,stroke:#f9ab00 - style D fill:#e6f4ea,stroke:#137333 -``` - ---- - -## 7. Data Ownership Summary - -| Data | Applicant can... | Admin can... | -| ---------------------- | ------------------ | ------------------- | -| Own profile | View, update | - | -| All profiles | - | View all | -| Own quiz answers | View, save, submit | - | -| All quiz answers | - | View all + scores | -| Own sigsheet entries | View, create | - | -| All sigsheet entries | - | View all + progress | -| Own GDrive folder | Create, upload to | - | -| Quiz questions/options | View (read-only) | View (read-only) | -| Members list | View (read-only) | View (read-only) | From 0b10fcb087f5ffbdb3a87a9ee1bae5ddf6655d98 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:25:28 +0800 Subject: [PATCH 8/8] chore: add tool config files to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a80deee..1d41d45 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ node_modules .env docs/ CLAUDE.md +.agents/ +.claude/ +skills-lock.json