diff --git a/docs/pass-experience.md b/docs/pass-experience.md index 9b0e8fd..503a1a9 100644 --- a/docs/pass-experience.md +++ b/docs/pass-experience.md @@ -1,6 +1,6 @@ # Registration, payment approval and INNO pass -The live flow is Google login → participant details → payment QR → screenshot and 12-digit UTR → pending team review → approved pass. College and roll number are entered by the participant. College is never inferred from their email. Financial approval always comes from the authenticated backend. +The live flow is Google login → participant details and college ID card photo → payment QR → screenshot and 12-digit UTR → pending team review → approved pass. College and roll number are entered by the participant. College is never inferred from their email. Financial approval always comes from the authenticated backend. ## Reviewer preview and the single off switch @@ -15,13 +15,14 @@ The live flow is Google login → participant details → payment QR → screens ## Deployment prerequisites -Apply `supabase/manual/pass_experience.sql` to the existing Supabase database. New installations can use the updated `supabase/schema.sql`. The idempotent migration creates an owner-authorized acknowledgement table and functions; it does not update financial status. Hosted database migration has not been performed by this code change. +Apply `supabase/manual/pass_experience.sql` and `supabase/manual/id_card_upload.sql` (college ID cards, section 19 of `supabase/schema.sql`) to the existing Supabase database. New installations can use the updated `supabase/schema.sql`. The idempotent migration creates an owner-authorized acknowledgement table and functions; it does not update financial status. Hosted database migration has not been performed by this code change. The existing entry-pass tables/functions and a valid `ENTRY_PASS_ENCRYPTION_KEY` are required for QR check-in. The pass itself and PDF remain available without a QR. The scanner continues to use the existing staff-authorized opaque-token lookup and atomic check-in functions, including duplicate-check-in protection. No default signing secret or simulated server approval is used. ## Pass behavior - `/api/pass` intentionally uses POST: first access can mint an owner's opaque QR token. It returns real name, college, registration ID and backend status. Tokens are only available for confirmed registrations with active passes. +- College ID card: `/api/pass` also says whether a pending or confirmed registration has its ID card (`idCard: 'uploaded' | 'pending'`, and the reason when staff asked for a new one). While it is pending, MY PASS shows an **ID card pending** box with an upload button above the status (pending) or above the ticket (confirmed; the QR stays usable), and the MY PASS button carries a gold dot. The upload goes to `POST /api/id-card`, which attaches it to the registration even after registrations close. Staff can't approve a pending registration without one (API and `trg_require_id_card_for_approval`), can view it from the admin list and the gate scanner, and can ask for a new one (`POST /api/admin/registrations/id-card`). The student can't replace an uploaded card themselves. - Visible registration/pass screens refresh on focus, visibility and a 15-second interval. Hidden tabs avoid polling. Preview records do not poll the live registration API. - Database claims elect one tab to show the celebration. A 30-second lease recovers abandoned claims. In-flight claims are shared across React remounts. - Acknowledgement happens after the pass has painted, or an explicit skip. Navigation cleanup acknowledges only a presented pass. Failed acknowledgements retain the claim and retry on focus/connection recovery. No local-storage value is the source of truth. diff --git a/src/app/api/admin/registrations/id-card/route.ts b/src/app/api/admin/registrations/id-card/route.ts new file mode 100644 index 0000000..c938f64 --- /dev/null +++ b/src/app/api/admin/registrations/id-card/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } 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'; + +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. + * 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); + if ('error' in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const id = new URL(req.url).searchParams.get('id'); + if (!isUuid(id)) { + return NextResponse.json({ error: 'Invalid registration id' }, { status: 400 }); + } + + const { data: registration, error } = await auth.supabase + .from('registrations') + .select('user_id, id_card_path') + .eq('id', id) + .maybeSingle(); + + if (error) { + console.error('/api/admin/registrations/id-card database error:', error); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (!registration?.id_card_path) { + return NextResponse.json({ error: 'No ID card uploaded for this registration' }, { status: 404 }); + } + + // Only ever sign an object inside the registrant's own folder, never an arbitrary path stored in the row. + if (!isIdCardPath(registration.id_card_path, registration.user_id)) { + return NextResponse.json({ error: 'Stored ID card is not a recognised upload' }, { status: 422 }); + } + const url = await signIdCardUrl(registration.id_card_path); + if (!url) { + return NextResponse.json({ error: 'Failed to load the ID card' }, { status: 500 }); + } + + return NextResponse.json( + { success: true, url, expiresAt: new Date(Date.now() + ID_CARD_URL_TTL_SECONDS * 1000).toISOString() }, + { headers: { 'Cache-Control': 'private, no-store' } } + ); + } catch (err: unknown) { + console.error('/api/admin/registrations/id-card error:', err); + return NextResponse.json({ error: 'Failed to load the ID card' }, { status: 500 }); + } +} + +/** + * POST /api/admin/registrations/id-card { registrationId, reason } + * Registration-Team/Admin only. Asks the student for a new ID card photo: clears id_card_path and stores the reason, + * so MY PASS shows "ID card pending" with it and a pending registration can't be approved until they upload again. + * The old photo stays in storage. Not for a rejected registration, one already checked in at the gate, or the + * caller's own. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const parsed = await readJsonObject(req); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + } + const { registrationId, reason } = parsed.value; + if (!isUuid(registrationId)) { + return NextResponse.json({ error: 'Invalid registrationId' }, { status: 400 }); + } + if (!isIdCardReuploadReason(reason)) { + return NextResponse.json({ error: 'Choose why a new ID card is needed.' }, { status: 400 }); + } + + const { supabase, user } = auth; + const { data: existing, error: fetchErr } = await supabase + .from('registrations') + .select('id, user_id, registration_id, status, id_card_path') + .eq('id', registrationId) + .maybeSingle(); + if (fetchErr) { + console.error('/api/admin/registrations/id-card database error:', fetchErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (!existing) { + return NextResponse.json({ error: 'Registration not found' }, { status: 404 }); + } + if (existing.user_id === user.id) { + return NextResponse.json( + { error: 'You cannot review your own registration. Ask another staff member.' }, + { status: 403 } + ); + } + if (existing.status === 'rejected') { + return NextResponse.json( + { error: `Registration ${existing.registration_id} is rejected: there is no ID card to ask for.` }, + { status: 400 } + ); + } + if (!existing.id_card_path) { + return NextResponse.json( + { error: `Registration ${existing.registration_id} has no ID card uploaded yet.` }, + { status: 409 } + ); + } + + const { data: checkin, error: checkinErr } = await supabase + .from('entry_checkins') + .select('id') + .eq('registration_id', existing.id) + .is('voided_at', null) + .limit(1) + .maybeSingle(); + if (checkinErr) { + console.error('/api/admin/registrations/id-card check-in lookup error:', checkinErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (checkin) { + return NextResponse.json( + { error: `Registration ${existing.registration_id} is already checked in at the gate.` }, + { status: 409 } + ); + } + + // Only clears the card staff looked at: a new one uploaded meanwhile is kept. + const { data: updated, error: updateErr } = await supabase + .from('registrations') + .update({ id_card_path: null, id_card_uploaded_at: null, id_card_reupload_reason: reason }) + .eq('id', existing.id) + .eq('id_card_path', existing.id_card_path) + .neq('status', 'rejected') + .select() + .maybeSingle(); + if (updateErr) { + console.error('/api/admin/registrations/id-card database error:', updateErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (!updated) { + return NextResponse.json( + { error: `Registration ${existing.registration_id} changed meanwhile. Reload and try again.` }, + { status: 409 } + ); + } + + return NextResponse.json({ success: true, registration: updated }); + } catch (err: unknown) { + console.error('/api/admin/registrations/id-card error:', err); + return NextResponse.json({ error: 'Failed to ask for a new ID card' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/registrations/route.ts b/src/app/api/admin/registrations/route.ts index bd49135..e875b8c 100644 --- a/src/app/api/admin/registrations/route.ts +++ b/src/app/api/admin/registrations/route.ts @@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import { verifyRegistrationStaff } 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'; export const runtime = 'nodejs'; @@ -256,7 +257,8 @@ export async function PATCH(req: NextRequest) { // Fetch existing registration to verify status const { data: existing, error: fetchErr } = await supabase .from('registrations') - .select('id, status, registration_id, user_id') + // '*' rather than naming id_card_path, so reviewing keeps working on a database without section 19 yet. + .select('*') .eq('id', registrationId) .maybeSingle(); @@ -285,6 +287,11 @@ export async function PATCH(req: NextRequest) { ); } + // No approval without the college ID card (also refused by trg_require_id_card_for_approval). Rejecting is fine. + if (status === 'confirmed' && 'id_card_path' in existing && !existing.id_card_path) { + return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 }); + } + const { data: updated, error: updateErr } = await supabase .from('registrations') .update({ @@ -301,6 +308,10 @@ export async function PATCH(req: NextRequest) { .maybeSingle(); if (updateErr) { + // A re-upload was asked for between the check above and this update. + if (isIdCardRequiredError(updateErr)) { + return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 }); + } console.error('/api/admin/registrations database error:', updateErr); return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); } diff --git a/src/app/api/id-card/route.ts b/src/app/api/id-card/route.ts new file mode 100644 index 0000000..5ca7450 --- /dev/null +++ b/src/app/api/id-card/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUser } from '@/lib/auth-server'; +import { getSupabaseAdmin } from '@/lib/supabase'; +import { uploadIdCard } from '@/lib/id-card-storage'; +import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; +import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections'; +import { + buildOwnedUploadPath, + createRateLimiter, + detectFileType, + formFile, + ID_CARD_MAX_BYTES, + ID_CARD_MIME_TYPES, + isIdCardFileType, + readFormLimited, +} from '@/lib/security'; + +export const runtime = 'nodejs'; + +const TOO_LARGE = 'That photo is larger than 2 MB. Please choose a smaller photo of your ID card.'; +const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact the registration helpdesk to change it.'; + +// Allow retries but stop bulk abuse of storage. +const idCardLimiter = createRateLimiter({ limit: 20, windowMs: 10 * 60 * 1000 }); + +/** + * POST /api/id-card + * Uploads a photo of the front of the caller's college ID card to the private `id-cards` bucket (supabase/schema.sql + * section 19). The file type is decided by its magic bytes and the object path is chosen here from the verified user + * id, exactly like /api/upload for payment screenshots. What happens next depends on the caller's registration: + * - none yet (or only rejected ones, with attempts left): the student is filling in the registration form. Only while + * registrations are open. The path is returned and submitted with the registration (/api/register checks it). + * - pending or confirmed without an ID card (registered before ID cards were asked for, or staff asked for a new + * one): the card is attached to it right away, even after registrations close. MY PASS uses this. + * - pending or confirmed with an ID card: refused, a card can't be swapped by the student. + */ +export async function POST(req: NextRequest) { + try { + const auth = await getAuthenticatedUser(req); + if (!auth) { + return NextResponse.json( + { error: 'Authentication required: please sign in before uploading files.' }, + { status: 401 } + ); + } + const userId = auth.user.id; + + if (!idCardLimiter.hit(userId)) { + return NextResponse.json( + { error: 'Too many uploads. Please wait a few minutes and try again.' }, + { status: 429, headers: { 'Retry-After': String(idCardLimiter.retryAfterSeconds(userId)) } } + ); + } + + // Which case this is, before reading the body (a closed form or a locked card needs no upload). + const client = getSupabaseAdmin(auth.token); + const [active, rejected] = await Promise.all([ + client.from('registrations').select('id, id_card_path').eq('user_id', userId).neq('status', 'rejected').limit(1).maybeSingle(), + client.from('registrations').select('id', { count: 'exact', head: true }).eq('user_id', userId).eq('status', 'rejected'), + ]); + if (active.error || rejected.error) { + console.error('ID card upload: registration lookup failed:', active.error || rejected.error); + return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 }); + } + const registration = active.data as { id: string; id_card_path: string | null } | null; + if (registration?.id_card_path) { + return NextResponse.json({ error: ALREADY_UPLOADED }, { status: 409 }); + } + if (!registration) { + if (!registrationsOpen()) return registrationsClosedResponse(); + if (attemptsLeft(rejected.count ?? 0) === 0) { + return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 409 }); + } + } + + const form = await readFormLimited(req, ID_CARD_MAX_BYTES + 64 * 1024); + if (!form.ok) { + return NextResponse.json({ error: form.status === 413 ? TOO_LARGE : form.error }, { status: form.status }); + } + const file = formFile(form.value, 'file'); + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + if (file.size > ID_CARD_MAX_BYTES) { + return NextResponse.json({ error: TOO_LARGE }, { status: 400 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const type = detectFileType(buffer); + if (!isIdCardFileType(type)) { + return NextResponse.json({ error: 'Upload a photo of your ID card (JPG, PNG or WebP).' }, { status: 400 }); + } + + // Server-chosen path `/.`: the client's filename never reaches storage. + const path = buildOwnedUploadPath(userId, type); + const upload = await uploadIdCard(userId, path, buffer, ID_CARD_MIME_TYPES[type]); + if (!upload.ok) { + console.error('ID card upload rejected by storage:', upload.error); + return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 }); + } + + if (!registration) { + // Registering now: only the storage path is returned (the bucket is private: there is no public URL). + return NextResponse.json({ success: true, attached: false, path, size: file.size }); + } + + // Only fills an empty slot, so two uploads at once can't both win and a card is never swapped. + const { data: attached, error: attachError } = await client + .from('registrations') + .update({ id_card_path: path, id_card_uploaded_at: new Date().toISOString(), id_card_reupload_reason: null }) + .eq('id', registration.id) + .eq('user_id', userId) + .neq('status', 'rejected') + .is('id_card_path', null) + .select('id') + .maybeSingle(); + if (attachError) { + console.error('ID card upload: attaching to the registration failed:', attachError); + return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 }); + } + if (!attached) { + return NextResponse.json({ error: ALREADY_UPLOADED }, { status: 409 }); + } + return NextResponse.json({ success: true, attached: true }); + } catch (error: unknown) { + console.error('ID card upload error:', error); + return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 }); + } +} diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index 292ed8d..b096e68 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -68,6 +68,11 @@ export async function POST(req: NextRequest) { pass.rejectionReason = details.data?.rejection_reason ?? null; pass.attemptsLeft = attemptsLeft(rejected.count ?? 0); } + if ((mine.registration_status === 'pending' || mine.registration_status === 'confirmed') && details.data) { + // The college ID card (section 19): MY PASS asks for it until it is uploaded. + pass.idCard = details.data.id_card_path ? 'uploaded' : 'pending'; + pass.idCardReuploadReason = details.data.id_card_path ? null : details.data.id_card_reupload_reason ?? null; + } if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) { return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } }); } diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index d1b71d9..5872b55 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -3,6 +3,7 @@ import type { SupabaseClient, User } from '@supabase/supabase-js'; import { getSupabaseAdmin } from '@/lib/supabase'; import { getAuthenticatedUser } from '@/lib/auth-server'; import { paymentProofExists } from '@/lib/payment-proofs'; +import { idCardExists } from '@/lib/id-card-storage'; import { ambassadorGuardError, findOwnAmbassador, resolveReferralCode, type Registrant } from '@/lib/ambassadors'; import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; import { @@ -243,9 +244,19 @@ export async function POST(req: NextRequest) { .maybeSingle(); if (utrOwner) return utrConflictResponse(adminClient, user.id); - // The proof (required for everyone) must be an object this user actually uploaded (checked with their own token, - // so Storage RLS only lets them see their own folder). - if (!(await paymentProofExists(token, input.payment_proof_path))) { + // The proof and the ID card (both required for everyone) must be objects this user actually uploaded (checked with + // their own token, so Storage RLS only lets them see their own folder). + const [proofFound, idCardFound] = await Promise.all([ + paymentProofExists(token, input.payment_proof_path), + idCardExists(token, input.id_card_path), + ]); + if (!idCardFound) { + return NextResponse.json( + { error: 'ID card photo not found. Please upload your college ID card again.', field: 'idfile' }, + { status: 400 } + ); + } + if (!proofFound) { return NextResponse.json( { error: 'Payment screenshot not found. Please re-upload your screenshot.', field: 'payfile' }, { status: 400 } @@ -288,6 +299,9 @@ export async function POST(req: NextRequest) { student_type: studentType, // Only the private storage object path is stored, never a URL or the image itself. payment_proof_path: input.payment_proof_path, + // Photo of the college ID card, in the private id-cards bucket (section 19 of supabase/schema.sql). + id_card_path: input.id_card_path, + id_card_uploaded_at: new Date().toISOString(), utr: input.utr, amount: REGISTRATION_FEE_INR, status: NEW_REGISTRATION_STATUS, diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 766f37b..9b423a4 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -25,7 +25,7 @@ export default function PrivacyPage() {

What we collect

  • From Google sign-in: your name, email address and profile photo. We never see your Google password.
  • -
  • When you register: your name, gender, college, enrolment or roll number and phone number.
  • +
  • When you register: your name, gender, college, enrolment or roll number and phone number, and a photo of the front of your college ID card that you upload.
  • For the fee: the UPI transaction ID (UTR) and the screenshot of your payment that you upload. We do not collect card, bank or UPI PIN details.
  • Referral codes: if you enter a friend's or a campus ambassador's code when you register, we record whose code it was.
  • @@ -39,7 +39,7 @@ export default function PrivacyPage() {

    Why we use it

    • To sign you in and keep you signed in.
    • -
    • To verify your payment and confirm or reject your registration.
    • +
    • To verify your payment and your college ID card, and confirm or reject your registration.
    • To issue your QR entry pass and check you in at the gate, once per registration.
    • To contact you about your registration or important changes to the fest.
    • To keep the site secure and stop abuse, such as fake or repeated registrations.
    • @@ -49,9 +49,9 @@ export default function PrivacyPage() {

      Who can see it

      • You can see your own profile, registration and entry pass after signing in.
      • -
      • The registration team sees registration details and payment screenshots to verify payments. Screenshots are kept in private storage and opened only through short-lived links.
      • +
      • The registration team sees registration details, payment screenshots and ID card photos to verify payments and identities. These files are kept in private storage and opened only through short-lived links.
      • Admins and the registration team also see which code each registrant entered, and each campus ambassador's name, email, phone number, college and the registrations their code brought in, to run the programme and contact them. Ambassadors see only their own code, never who used it.
      • -
      • Gate volunteers see your registration details when they scan your pass, to match them with your college ID.
      • +
      • Gate volunteers see your registration details and the ID card photo you uploaded when they scan your pass, to match them with your college ID.
      • Service providers that run the site for us: Google (sign-in, and Google Forms for ambassador applications), Supabase (database and file storage) and Vercel (hosting and visit statistics). They process data @@ -70,14 +70,14 @@ export default function PrivacyPage() {

        How long we keep it

        - We keep registration data, payment screenshots and check-in records only for as long as we need them to run + We keep registration data, payment screenshots, ID card photos and check-in records only for as long as we need them to run INNOVISION 2026, handle questions about payments or entry and settle the fest's accounts. After that we delete them.

        How we protect it

        Data is sent over encrypted connections. Access is limited by role: participants can read only their own records, - and only the registration team can see payment screenshots. Entry-pass codes are stored encrypted. No system is + and only the registration team can see payment screenshots and ID card photos. Entry-pass codes are stored encrypted. No system is perfectly secure, but we take reasonable steps to protect your data.

        diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index 6d026d5..caaa429 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -22,7 +22,7 @@ export default function TermsPage() {

        Accounts and registration

        • You sign in with your own Google account. One account can hold one registration.
        • -
        • Your details must be true and must match your college ID card, which you will be asked to show at the gate.
        • +
        • Your details must be true and must match your college ID card. You upload a photo of its front when you register, and show the card itself at the gate.
        • You are responsible for keeping your account secure and for activity under it.
        diff --git a/src/components/entry/EntryScanner.module.css b/src/components/entry/EntryScanner.module.css index 819c965..4e4af96 100644 --- a/src/components/entry/EntryScanner.module.css +++ b/src/components/entry/EntryScanner.module.css @@ -35,6 +35,8 @@ .details dt{font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;opacity:.8;align-self:center} .details dd{margin:0;font-weight:700;overflow-wrap:anywhere} .big{font-size:clamp(24px,7vw,34px)} +.idCard{display:block;width:100%;max-width:420px;max-height:34vh;object-fit:contain;border-radius:6px;background:rgba(0,0,0,.25)} +.idCardNote{margin:0;padding:10px 14px;border-radius:6px;background:rgba(0,0,0,.2);font-size:15px;font-weight:700;line-height:1.4} .actions{margin-top:auto;display:flex;flex-direction:column;gap:12px} .btn{appearance:none;border:0;cursor:pointer;min-height:64px;padding:0 24px;font:inherit;font-size:20px;font-weight:800;letter-spacing:.06em;border-radius:6px;touch-action:manipulation} diff --git a/src/components/entry/EntryScanner.tsx b/src/components/entry/EntryScanner.tsx index 8850108..6467859 100644 --- a/src/components/entry/EntryScanner.tsx +++ b/src/components/entry/EntryScanner.tsx @@ -608,6 +608,7 @@ function ResultScreen(p: ResultScreenProps) {

        VALID PASS

        Compare with the college ID card.

        +
        ))}
        @@ -1240,6 +1338,30 @@ export default function AdminDashboard({ + {/* College ID card filter (over the loaded list) */} + + )} {!reg.payment_proof_path && ( — )} + {/* College ID card (section 19): view it, ask for a new one, or wait for the student. */} + {reg.id_card_path ? ( + <> + + {reg.status !== 'rejected' && ( + + )} + + ) : idCardMissing(reg) ? ( + + ID PENDING + {reg.id_card_reupload_reason && ( + + New one asked: {idCardReuploadLabel(reg.id_card_reupload_reason)} + + )} + + ) : null} @@ -1544,9 +1738,9 @@ export default function AdminDashboard({
        + + +
        +
        +{u.name} + + + + +
        +{err ? ({err}) : null} + + ); +} + export default function AuthOverlay({ v }: { v: V }) { const dialog = useRef(null); useDialogFocus(v.authHidden === 'false', dialog, v.closeAuthH); @@ -164,6 +209,11 @@ export default function AuthOverlay({ v }: { v: V }) { {v.err.enrollment_no ? ({v.err.enrollment_no}) : null} +
        + COLLEGE ID CARD (FRONT) + + The front of your college ID card, with your name and college readable. Bring the same card to the gate. +