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 */}
Promise;
+ /** The 'viewer' role: lists, registrations and the export only, no edit / revoke / restore / delete. */
+ readOnly?: boolean;
}
interface Ambassador {
@@ -105,7 +107,7 @@ const RESPONSIVE_CSS = `
const shortDate = (iso: string) => new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
const statusColor = (s: string) => (s === 'confirmed' ? 'oklch(0.8 0.14 150)' : s === 'rejected' ? BAD : GOLD);
-export default function AmbassadorsManager({ getAuthToken }: AmbassadorsManagerProps) {
+export default function AmbassadorsManager({ getAuthToken, readOnly = false }: AmbassadorsManagerProps) {
const [rows, setRows] = useState([]);
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(true);
@@ -339,18 +341,22 @@ export default function AmbassadorsManager({ getAuthToken }: AmbassadorsManagerP
void toggleOpen(a)} aria-expanded={openId === a.id}>
{openId === a.id ? 'HIDE' : 'REGISTRATIONS'}
- setEditing({ id: a.id, name: a.name, college: a.college ?? '' })}>EDIT
- {revoked ? (
- void patch(a.id, { action: 'restore' }, `${a.name}'s code works again.`)}>RESTORE
- ) : (
- (isAsking(a.id, 'revoke') ? void patch(a.id, { action: 'revoke' }, `${a.name}'s code was revoked.`) : ask(a.id, 'revoke'))}>
- {isAsking(a.id, 'revoke') ? 'CONFIRM REVOKE?' : 'REVOKE'}
-
- )}
- {a.confirmed_registrations + a.pending_registrations === 0 && (
- (isAsking(a.id, 'delete') ? void remove(a) : ask(a.id, 'delete'))}>
- {isAsking(a.id, 'delete') ? 'CONFIRM DELETE?' : 'DELETE'}
-
+ {!readOnly && (
+ <>
+ setEditing({ id: a.id, name: a.name, college: a.college ?? '' })}>EDIT
+ {revoked ? (
+ void patch(a.id, { action: 'restore' }, `${a.name}'s code works again.`)}>RESTORE
+ ) : (
+ (isAsking(a.id, 'revoke') ? void patch(a.id, { action: 'revoke' }, `${a.name}'s code was revoked.`) : ask(a.id, 'revoke'))}>
+ {isAsking(a.id, 'revoke') ? 'CONFIRM REVOKE?' : 'REVOKE'}
+
+ )}
+ {a.confirmed_registrations + a.pending_registrations === 0 && (
+ (isAsking(a.id, 'delete') ? void remove(a) : ask(a.id, 'delete'))}>
+ {isAsking(a.id, 'delete') ? 'CONFIRM DELETE?' : 'DELETE'}
+
+ )}
+ >
)}
diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts
index 6d3459a..d60d41f 100644
--- a/src/lib/auth-server.ts
+++ b/src/lib/auth-server.ts
@@ -1,9 +1,11 @@
import { getSupabaseAdmin } from '@/lib/supabase';
import type { SupabaseClient, User } from '@supabase/supabase-js';
+export type StaffRole = 'admin' | 'it-team' | 'registration-team' | 'viewer';
+
export interface StaffAuthSuccess {
user: User;
- role: 'admin' | 'it-team' | 'registration-team';
+ role: StaffRole;
supabase: SupabaseClient;
/** The caller's validated access token, for calls that must run as the caller (getSupabaseAsUser). */
token: string;
@@ -109,7 +111,7 @@ export async function verifyEventStaff(req: AuthSource): Promise {
+ const auth = await getAuthenticatedUser(req);
+ if (!auth) {
+ return { error: 'Unauthorized: Session missing or expired. Please sign in again.', status: 401 };
+ }
+
+ const supabase = getSupabaseAdmin(auth.token);
+ const { data: profile, error: profErr } = await supabase
+ .from('profiles')
+ .select('role')
+ .eq('id', auth.user.id)
+ .maybeSingle();
+
+ if (profErr || !profile || !['admin', 'registration-team', 'viewer'].includes(profile.role)) {
+ return { error: 'Forbidden: Admin, Registration-Team or Viewer authorization required.', status: 403 };
+ }
+
+ return { user: auth.user, role: profile.role as StaffRole, supabase, token: auth.token };
}
/** Like verifyEventStaff / verifyRegistrationStaff, but only lets the 'admin' role through. */
@@ -154,7 +181,7 @@ export async function verifyAdmin(req: AuthSource): Promise {
return { error: 'Forbidden: Superadmin access required to modify user roles', status: 403 };
}
- return { user: auth.user, role: profile.role as 'admin' | 'it-team' | 'registration-team', supabase, token: auth.token };
+ return { user: auth.user, role: profile.role as StaffRole, supabase, token: auth.token };
}
export { isValidGoogleDriveUrl } from '@/lib/validation';
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index 9150442..cb0232f 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -16,7 +16,7 @@ export interface UserProfile {
avatar_url: string;
phone: string | null;
student_type: 'internal' | 'external';
- role: 'user' | 'it-team' | 'registration-team' | 'admin';
+ role: 'user' | 'it-team' | 'registration-team' | 'viewer' | 'admin';
enrollment_no?: string | null;
created_at?: string;
updated_at?: string;
diff --git a/supabase/manual/add_viewer_role.sql b/supabase/manual/add_viewer_role.sql
new file mode 100644
index 0000000..8ddfc19
--- /dev/null
+++ b/supabase/manual/add_viewer_role.sql
@@ -0,0 +1,34 @@
+-- ==============================================================================
+-- MIGRATION (run once in the Supabase SQL editor): read-only 'viewer' role.
+--
+-- Why: some organisers need to see what the registration team sees (every registration, payment screenshots, ID
+-- cards, the CSV export, seats, referrals and campus ambassadors) without being able to approve, reject or change
+-- anything. Admins assign the role from the USERS tab of the admin dashboard.
+-- This updates sections 1, 3, 5 and 9 of supabase/schema.sql: 'viewer' in the profiles role CHECK, the
+-- can_view_registrations helper, and the registrations SELECT policy. The UPDATE policy stays on is_registration_staff,
+-- so a viewer can't review a registration even through the Supabase API.
+-- No existing row changes.
+--
+-- Run it BEFORE assigning the role to anyone (until then the role CHECK refuses 'viewer'). Deploying the code first is
+-- fine: nobody has the role yet.
+-- Kept out of supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run.
+-- ==============================================================================
+
+ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_role_check;
+ALTER TABLE public.profiles ADD CONSTRAINT profiles_role_check
+ CHECK (role IN ('user', 'it-team', 'registration-team', 'viewer', 'admin'));
+
+-- Who may READ every registration: the registration team and admins, plus the read-only 'viewer' role. Writes stay on
+-- is_registration_staff, so a viewer can look at registrations but never review them.
+CREATE OR REPLACE FUNCTION public.can_view_registrations(uid UUID) RETURNS BOOLEAN AS $$
+ SELECT EXISTS (
+ SELECT 1 FROM public.profiles WHERE id = uid AND uid = auth.uid() AND role IN ('admin', 'registration-team', 'viewer')
+ );
+$$ LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public;
+
+REVOKE EXECUTE ON FUNCTION public.can_view_registrations(UUID) FROM PUBLIC, anon;
+GRANT EXECUTE ON FUNCTION public.can_view_registrations(UUID) TO authenticated;
+
+DROP POLICY IF EXISTS "Users can view own registrations or staff can view all" ON public.registrations;
+CREATE POLICY "Users can view own registrations or staff can view all" ON public.registrations FOR
+SELECT TO authenticated USING (user_id = auth.uid() OR public.can_view_registrations(auth.uid()));
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 46e06a7..e0d3c4b 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
avatar_url TEXT,
phone TEXT,
student_type TEXT DEFAULT 'external' CHECK (student_type IN ('internal', 'external')),
- role TEXT DEFAULT 'user' CHECK (role IN ('user', 'it-team', 'registration-team', 'admin')),
+ role TEXT DEFAULT 'user' CHECK (role IN ('user', 'it-team', 'registration-team', 'viewer', 'admin')),
enrollment_no TEXT,
created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL
@@ -127,7 +127,7 @@ BEGIN
-- EXISTS never updates it), which would refuse every assignment of the new role. Same list as the table definition.
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_role_check;
ALTER TABLE public.profiles ADD CONSTRAINT profiles_role_check
- CHECK (role IN ('user', 'it-team', 'registration-team', 'admin'));
+ CHECK (role IN ('user', 'it-team', 'registration-team', 'viewer', 'admin'));
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_full_name_check;
ALTER TABLE public.profiles ADD CONSTRAINT profiles_full_name_check
CHECK (full_name IS NULL OR char_length(full_name) <= 120) NOT VALID;
@@ -218,6 +218,14 @@ CREATE OR REPLACE FUNCTION public.is_registration_staff(uid UUID) RETURNS BOOLEA
);
$$ LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public;
+-- Who may READ every registration: the registration team and admins, plus the read-only 'viewer' role. Writes stay on
+-- is_registration_staff, so a viewer can look at registrations but never review them.
+CREATE OR REPLACE FUNCTION public.can_view_registrations(uid UUID) RETURNS BOOLEAN AS $$
+ SELECT EXISTS (
+ SELECT 1 FROM public.profiles WHERE id = uid AND uid = auth.uid() AND role IN ('admin', 'registration-team', 'viewer')
+ );
+$$ LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public;
+
-- 4. AUTO-HANDLE NEW SIGNUPS FROM GOOGLE AUTH
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER AS $$
@@ -355,7 +363,7 @@ CREATE TRIGGER trg_protect_profile_fields
-- Registrations Policies
DROP POLICY IF EXISTS "Users can view own registrations or staff can view all" ON public.registrations;
CREATE POLICY "Users can view own registrations or staff can view all" ON public.registrations FOR
-SELECT TO authenticated USING (user_id = auth.uid() OR public.is_registration_staff(auth.uid()));
+SELECT TO authenticated USING (user_id = auth.uid() OR public.can_view_registrations(auth.uid()));
-- No direct INSERT for end users: every registration is created by /api/register (service role), which applies the
-- rules the database can't (proof object really uploaded by the same user, rate limits). The INSERT privilege itself is revoked in section 11; the insert trigger below stays as defense in depth.
@@ -583,7 +591,8 @@ DELETE TO authenticated USING (public.is_event_staff(auth.uid()));
-- 9. FUNCTION PRIVILEGES
-- The role-helper functions are SECURITY DEFINER; don't let anonymous callers probe arbitrary users' roles via RPC.
--- (authenticated keeps EXECUTE because the RLS policies above call is_admin / is_event_staff / is_registration_staff.)
+-- (authenticated keeps EXECUTE because the RLS policies above call is_admin / is_event_staff / is_registration_staff /
+-- can_view_registrations.)
-- is_staff() was split into is_event_staff() and is_registration_staff(). Databases created before the split still
-- have it, and the oldest version answers for ANY uid with EXECUTE left to anon (anyone could probe who is staff).
-- No policy above uses it any more, so remove it; if something added outside this script still depends on it, keep
@@ -599,9 +608,11 @@ REVOKE EXECUTE ON FUNCTION public.get_user_role(UUID) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.is_admin(UUID) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.is_event_staff(UUID) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.is_registration_staff(UUID) FROM PUBLIC, anon;
+REVOKE EXECUTE ON FUNCTION public.can_view_registrations(UUID) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.is_admin(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_event_staff(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_registration_staff(UUID) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.can_view_registrations(UUID) TO authenticated;
-- get_user_role isn't used by any policy or by the app.
REVOKE EXECUTE ON FUNCTION public.get_user_role(UUID) FROM authenticated;