Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/admin/ambassadors/route.ts
Original file line number Diff line number Diff line change
@@ -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).
*
Expand All @@ -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 });
}
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/admin/referrals/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 });
}
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/admin/registrations/id-card/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,13 +8,14 @@ export const runtime = 'nodejs';

/**
* GET /api/admin/registrations/id-card?id=<registration uuid>
* 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 });
}
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/admin/registrations/payment-proof/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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';

export const runtime = 'nodejs';

/**
* GET /api/admin/registrations/payment-proof?id=<registration uuid>
* 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 });
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/admin/registrations/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -111,7 +111,7 @@ async function withReviewer(supabase: SupabaseClient, registrations: Record<stri
// GET all registrations (with optional filtering)
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 });
}
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/admin/seats/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import { verifyAdmin, verifyRegistrationStaff } from '@/lib/auth-server';
import { verifyAdmin, verifyRegistrationViewer } from '@/lib/auth-server';
import { readJsonObject } from '@/lib/security';
import { seatCountError } from '@/lib/seats';

Expand Down Expand Up @@ -35,11 +35,11 @@ const UNAVAILABLE =

/**
* GET /api/admin/seats
* The seat counter for the admin dashboard (admins and the registration team).
* The seat counter for the admin dashboard (admins, the registration team and viewers).
*/
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 });
}
Expand Down
15 changes: 11 additions & 4 deletions src/app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ export async function PATCH(req: NextRequest) {
);
}

// Allowed roles to be assigned via the web dashboard: only 'user', 'it-team', or 'registration-team'
const assignableRoles = ['user', 'it-team', 'registration-team'];
// Allowed roles to be assigned via the web dashboard: only 'user', 'it-team', 'registration-team' or 'viewer'
const assignableRoles = ['user', 'it-team', 'registration-team', 'viewer'];
if (typeof role !== 'string' || !assignableRoles.includes(role)) {
return NextResponse.json(
{ error: 'Invalid role. Only "user", "it-team", and "registration-team" can be assigned via the dashboard. Admin roles must be set directly in the database.' },
{ error: 'Invalid role. Only "user", "it-team", "registration-team" and "viewer" can be assigned via the dashboard. Admin roles must be set directly in the database.' },
{ status: 400 }
);
}
Expand All @@ -97,14 +97,21 @@ export async function PATCH(req: NextRequest) {
);
}

// Target user profile verified as non-admin -> 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() })
.eq('id', userId)
.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 });
Expand Down
Loading
Loading