From c7e24535a8b11ddb4498f8b2f84f802d160cad60 Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Sat, 31 Oct 2026 14:35:05 +0530 Subject: [PATCH] add viewer role --- README.md | 8 ++- src/app/api/admin/ambassadors/route.ts | 8 +-- src/app/api/admin/referrals/route.ts | 6 +- .../api/admin/registrations/id-card/route.ts | 7 ++- .../registrations/payment-proof/route.ts | 7 ++- src/app/api/admin/registrations/route.ts | 4 +- src/app/api/admin/seats/route.ts | 6 +- src/app/api/admin/users/route.ts | 15 +++-- src/components/innovision/AdminDashboard.tsx | 58 +++++++++++-------- src/components/innovision/Innovision.tsx | 4 +- src/components/innovision/ProfileOverlay.tsx | 8 ++- .../innovision/admin/AmbassadorsManager.tsx | 32 +++++----- src/lib/auth-server.ts | 35 +++++++++-- src/lib/supabase.ts | 2 +- supabase/manual/add_viewer_role.sql | 34 +++++++++++ supabase/schema.sql | 19 ++++-- 16 files changed, 176 insertions(+), 77 deletions(-) create mode 100644 supabase/manual/add_viewer_role.sql diff --git a/README.md b/README.md index 68b6bb8..83710b4 100644 --- a/README.md +++ b/README.md @@ -240,10 +240,12 @@ Staff open the dashboard from their profile on the site. Access depends on `prof | ------------------- | ------------------------------------------------------------------------ | | `user` | Register for the fest and view their pass. | | `registration-team` | Review registrations and payment proofs, approve or reject them. | +| `viewer` | See what the registration team sees and export it, but change nothing. | | `it-team` | Manage events and gallery photos. | | `admin` | Everything above, plus managing users' roles. | -Admins can switch users between `user`, `it-team` and `registration-team`. The `admin` role itself can only be granted +Admins can switch users between `user`, `it-team`, `registration-team` and `viewer` (run +`supabase/manual/add_viewer_role.sql` once before giving anyone `viewer`). The `admin` role itself can only be granted directly in the database: ```sql @@ -263,8 +265,8 @@ update public.profiles set role = 'admin' where email = 'someone@example.com'; | `/api/auth/logout` | POST | Signed-in user | | `/api/register` | GET, POST | Signed-in user | | `/api/upload` | POST | Signed-in user (payment proof) | -| `/api/admin/registrations` | GET, PATCH | Admin, registration team | -| `/api/admin/registrations/payment-proof` | GET | Admin, registration team | +| `/api/admin/registrations` | GET, PATCH | Admin, registration team (GET: also viewer) | +| `/api/admin/registrations/payment-proof` | GET | Admin, registration team, viewer | | `/api/admin/events` | GET, POST, PATCH, DELETE | Admin, IT team | | `/api/admin/gallery` | GET, POST, PATCH, DELETE | Admin, IT team | | `/api/admin/users` | GET, PATCH | Admin | diff --git a/src/app/api/admin/ambassadors/route.ts b/src/app/api/admin/ambassadors/route.ts index e2499ad..9f7ed5c 100644 --- a/src/app/api/admin/ambassadors/route.ts +++ b/src/app/api/admin/ambassadors/route.ts @@ -1,12 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { verifyRegistrationStaff, verifyRegistrationViewer } from '@/lib/auth-server'; import { isUuid, readJsonObject } from '@/lib/security'; export const runtime = 'nodejs'; /** - * Campus ambassadors, for admins and the registration team only (verifyRegistrationStaff): the AMBASSADORS tab of the - * admin dashboard is the programme's only portal. Students become ambassadors themselves (GET MY CODE, POST + * Campus ambassadors, for admins and the registration team only (verifyRegistrationStaff; the read-only 'viewer' role + * may GET, verifyRegistrationViewer): the AMBASSADORS tab of the admin dashboard is the programme's only portal. Students become ambassadors themselves (GET MY CODE, POST * /api/ambassador/code); staff watch the counts, contact them and revoke a code that is abused. Everything runs as the * service role: no client role can read or write the table or the leaderboard view (supabase/schema.sql, section 16). * @@ -32,7 +32,7 @@ const dbError = (where: string, error: unknown) => { export async function GET(req: NextRequest) { try { - const auth = await verifyRegistrationStaff(req); + const auth = await verifyRegistrationViewer(req); if ('error' in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } diff --git a/src/app/api/admin/referrals/route.ts b/src/app/api/admin/referrals/route.ts index 7d39ba0..abfce4c 100644 --- a/src/app/api/admin/referrals/route.ts +++ b/src/app/api/admin/referrals/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { verifyRegistrationViewer } from '@/lib/auth-server'; import { sanitizeFilterValue } from '@/lib/security'; export const runtime = 'nodejs'; @@ -8,13 +8,13 @@ const REFERRALS_PAGE_SIZE = 30; /** * GET /api/admin/referrals?page=1&q= - * Referral leaderboard for admins and the registration team: one row per registrant whose code was used by at least + * Referral leaderboard for admins, the registration team and viewers: one row per registrant whose code was used by at least * one confirmed or pending registration (public.referral_leaderboard, supabase/schema.sql section 14), most confirmed * referrals first. Paged on the server, REFERRALS_PAGE_SIZE rows per page; `q` searches name, email, college and code. */ export async function GET(req: NextRequest) { try { - const auth = await verifyRegistrationStaff(req); + const auth = await verifyRegistrationViewer(req); if ('error' in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } diff --git a/src/app/api/admin/registrations/id-card/route.ts b/src/app/api/admin/registrations/id-card/route.ts index c938f64..1577bd4 100644 --- a/src/app/api/admin/registrations/id-card/route.ts +++ b/src/app/api/admin/registrations/id-card/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { verifyRegistrationStaff, verifyRegistrationViewer } from '@/lib/auth-server'; import { isIdCardPath, isUuid, readJsonObject } from '@/lib/security'; import { ID_CARD_URL_TTL_SECONDS, signIdCardUrl } from '@/lib/id-card-storage'; import { isIdCardReuploadReason } from '@/lib/id-cards'; @@ -8,13 +8,14 @@ export const runtime = 'nodejs'; /** * GET /api/admin/registrations/id-card?id= - * Registration-Team/Admin only. Returns a short-lived signed URL for the registration's private college ID card photo. + * Registration-Team/Admin/Viewer only. Returns a short-lived signed URL for the registration's private college ID card + * photo. * Staff have no Storage read access of their own: this route is the only way to view a card (the admin list and the * gate scanner both use it). */ export async function GET(req: NextRequest) { try { - const auth = await verifyRegistrationStaff(req); + const auth = await verifyRegistrationViewer(req); if ('error' in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } diff --git a/src/app/api/admin/registrations/payment-proof/route.ts b/src/app/api/admin/registrations/payment-proof/route.ts index 2208c8c..7039a70 100644 --- a/src/app/api/admin/registrations/payment-proof/route.ts +++ b/src/app/api/admin/registrations/payment-proof/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { verifyRegistrationViewer } from '@/lib/auth-server'; import { isPaymentProofPath, isUuid, PAYMENT_PROOF_URL_TTL_SECONDS } from '@/lib/security'; import { signPaymentProofUrl } from '@/lib/payment-proofs'; @@ -7,12 +7,13 @@ export const runtime = 'nodejs'; /** * GET /api/admin/registrations/payment-proof?id= - * Registration-Team/Admin only. Returns a short-lived signed URL for the registration's private payment screenshot. + * Registration-Team/Admin/Viewer only. Returns a short-lived signed URL for the registration's private payment + * screenshot. * Staff have no Storage read access of their own: this route is the only way to view a proof. */ export async function GET(req: NextRequest) { try { - const auth = await verifyRegistrationStaff(req); + const auth = await verifyRegistrationViewer(req); if ('error' in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } diff --git a/src/app/api/admin/registrations/route.ts b/src/app/api/admin/registrations/route.ts index e875b8c..b4eb4da 100644 --- a/src/app/api/admin/registrations/route.ts +++ b/src/app/api/admin/registrations/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { verifyRegistrationStaff, verifyRegistrationViewer } from '@/lib/auth-server'; import { isUuid, readJsonObject, sanitizeFilterValue } from '@/lib/security'; import { isRejectionReason } from '@/lib/rejections'; import { ID_CARD_REQUIRED_ERROR, isIdCardRequiredError } from '@/lib/id-cards'; @@ -111,7 +111,7 @@ async function withReviewer(supabase: SupabaseClient, registrations: Record execute role change to 'user', 'it-team', or 'registration-team' + // Target user profile verified as non-admin -> execute role change to 'user', 'it-team', 'registration-team' or 'viewer' const { data: updated, error } = await supabase .from('profiles') .update({ role, updated_at: new Date().toISOString() }) @@ -105,6 +105,13 @@ export async function PATCH(req: NextRequest) { .select() .maybeSingle(); + // 23514: the role CHECK of a database without supabase/manual/add_viewer_role.sql refuses 'viewer'. + if (error?.code === '23514' && role === 'viewer') { + return NextResponse.json( + { error: 'The viewer role is not set up yet. Run supabase/manual/add_viewer_role.sql in Supabase.' }, + { status: 500 } + ); + } if (error) { console.error('/api/admin/users database error:', error); return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index 69576e8..0bf91a0 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -19,6 +19,9 @@ import { ID_CARD_REUPLOAD_REASONS, idCardReuploadLabel, type IdCardReuploadReaso /** A registration that still needs its college ID card (supabase/schema.sql section 19); rejected ones never do. */ const idCardMissing = (r: Registration) => r.status !== 'rejected' && !r.id_card_path; +/** Roles an admin can give from the USERS tab (/api/admin/users); 'admin' is set in the database only. */ +type AssignableRole = 'user' | 'it-team' | 'registration-team' | 'viewer'; + interface AdminDashboardProps { isOpen: boolean; onClose: () => void; @@ -33,12 +36,16 @@ export default function AdminDashboard({ const isAdmin = currentUser.role === 'admin'; const isItTeam = currentUser.role === 'it-team'; const isRegTeam = currentUser.role === 'registration-team'; - const isStaff = isAdmin || isItTeam || isRegTeam; - // Registration review (list, payment proofs, approve/reject) is Admin + Registration-Team only, matching - // verifyRegistrationStaff on the server. IT-Team manages events and gallery and never requests registration data. + const isViewer = currentUser.role === 'viewer'; + const isStaff = isAdmin || isItTeam || isRegTeam || isViewer; + // Registration review (approve/reject, asking for a new ID card, editing ambassadors) is Admin + Registration-Team + // only, matching verifyRegistrationStaff on the server. Viewers see the same data (list, payment proofs, ID cards, + // exports; verifyRegistrationViewer) but change nothing. IT-Team manages events and gallery and never requests + // registration data. const canReviewRegistrations = isAdmin || isRegTeam; + const canViewRegistrations = canReviewRegistrations || isViewer; - const defaultTab = canReviewRegistrations ? 'registrations' : 'events'; + const defaultTab = canViewRegistrations ? 'registrations' : 'events'; const [tab, setTab] = useState<'registrations' | 'referrals' | 'ambassadors' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab); const [registrations, setRegistrations] = useState([]); const [users, setUsers] = useState([]); @@ -130,7 +137,7 @@ export default function AdminDashboard({ // Payment screenshots and ID cards live in private buckets: ask the server for a short-lived signed URL (staff only). const [proofBusyId, setProofBusyId] = useState(null); const viewUpload = async (reg: Registration, kind: 'payment-proof' | 'id-card') => { - if (!reg.id || !canReviewRegistrations) return; + if (!reg.id || !canViewRegistrations) return; const what = kind === 'id-card' ? 'ID card' : 'payment screenshot'; try { setProofBusyId(`${reg.id}:${kind}`); @@ -151,7 +158,7 @@ export default function AdminDashboard({ // Fetch registrations const fetchRegistrations = useCallback(async () => { - if (!canReviewRegistrations) return; + if (!canViewRegistrations) return; try { setLoadingRegs(true); setErrorMsg(''); @@ -181,7 +188,7 @@ export default function AdminDashboard({ } finally { setLoadingRegs(false); } - }, [statusFilter, typeFilter, searchReg, canReviewRegistrations]); + }, [statusFilter, typeFilter, searchReg, canViewRegistrations]); // Fetch users (Admin only: IT-Team cannot access users) const fetchUsers = useCallback(async () => { @@ -214,17 +221,17 @@ export default function AdminDashboard({ useEffect(() => { if (!isOpen) return; const timer = setTimeout(() => { - if (canReviewRegistrations) fetchRegistrations(); + if (canViewRegistrations) fetchRegistrations(); if (isAdmin) fetchUsers(); }, 0); return () => clearTimeout(timer); - }, [isOpen, fetchRegistrations, fetchUsers, isAdmin, canReviewRegistrations]); + }, [isOpen, fetchRegistrations, fetchUsers, isAdmin, canViewRegistrations]); // Active tab: 'users' is strictly admin-only; staff have their own tabs let activeTab = tab; if (tab === 'users' && !isAdmin) activeTab = defaultTab; if (tab === 'entry' && !isAdmin) activeTab = defaultTab; - if ((tab === 'registrations' || tab === 'referrals' || tab === 'ambassadors') && !canReviewRegistrations) activeTab = defaultTab; + if ((tab === 'registrations' || tab === 'referrals' || tab === 'ambassadors') && !canViewRegistrations) activeTab = defaultTab; if ((tab === 'events' || tab === 'gallery') && !isAdmin && !isItTeam) activeTab = defaultTab; if (!isOpen || !isStaff) return null; @@ -347,8 +354,8 @@ export default function AdminDashboard({ } }; - // Handle Role Change using Modal / Toast (Only 'user', 'it-team', 'registration-team'; Admin role is DB only) - const handleChangeRole = (userId: string, newRole: 'user' | 'it-team' | 'registration-team', userEmail: string) => { + // Handle Role Change using Modal / Toast (Only 'user', 'it-team', 'registration-team', 'viewer'; Admin role is DB only) + const handleChangeRole = (userId: string, newRole: AssignableRole, userEmail: string) => { setConfirmModal({ isOpen: true, title: 'Update User Role', @@ -359,7 +366,7 @@ export default function AdminDashboard({ }); }; - const executeChangeRole = async (userId: string, newRole: 'user' | 'it-team' | 'registration-team', userEmail: string) => { + const executeChangeRole = async (userId: string, newRole: AssignableRole, userEmail: string) => { try { setRoleBusyId(userId); const token = await getAuthToken(); @@ -976,7 +983,7 @@ export default function AdminDashboard({ {/* Navigation Tabs */}