+ );
+}
diff --git a/src/components/entry/SessionRestore.tsx b/src/components/entry/SessionRestore.tsx
new file mode 100644
index 0000000..468ec0b
--- /dev/null
+++ b/src/components/entry/SessionRestore.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { useEffect } from 'react';
+import { fetchSessionFromDatabase } from '@/lib/supabase';
+import styles from './EntryScanner.module.css';
+
+const LOGIN_URL = '/?auth=login&required=scan'; // the home page returns to /admin/scan after login
+const FLAG = 'inv_scan_restore';
+
+/**
+ * Shown by /admin/scan when the access-token cookie has expired but a refresh token is still there (a phone left
+ * idle for over an hour). Refreshes the session once through /api/auth/session, which re-issues the cookies, then
+ * reloads; if that doesn't work, goes to the login screen. The flag stops a reload loop.
+ */
+export default function SessionRestore() {
+ useEffect(() => {
+ let alreadyTried = false;
+ try {
+ alreadyTried = sessionStorage.getItem(FLAG) === '1';
+ sessionStorage.setItem(FLAG, '1');
+ } catch {}
+ if (alreadyTried) {
+ window.location.replace(LOGIN_URL);
+ return;
+ }
+ fetchSessionFromDatabase()
+ .then((s) => {
+ if (s.tokens?.access_token) window.location.reload();
+ else window.location.replace(LOGIN_URL);
+ })
+ .catch(() => window.location.replace(LOGIN_URL));
+ }, []);
+
+ return (
+
+
+
+
Restoring your session…
+
+
+ );
+}
diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx
index 78e2007..92a8b0d 100644
--- a/src/components/innovision/AdminDashboard.tsx
+++ b/src/components/innovision/AdminDashboard.tsx
@@ -8,6 +8,7 @@ import { csvCell, GENDER_OPTIONS, safeHttpUrl } from '@/lib/validation';
const genderLabel = (value: string) => GENDER_OPTIONS.find((g) => g.value === value)?.label ?? value;
import EventsManager from './admin/EventsManager';
import GalleryManager from './admin/GalleryManager';
+import EntryManager from './admin/EntryManager';
import { httpErrorMessage } from '@/lib/api-errors';
interface AdminDashboardProps {
@@ -30,7 +31,7 @@ export default function AdminDashboard({
const canReviewRegistrations = isAdmin || isRegTeam;
const defaultTab = canReviewRegistrations ? 'registrations' : 'events';
- const [tab, setTab] = useState<'registrations' | 'events' | 'gallery' | 'users'>(defaultTab);
+ const [tab, setTab] = useState<'registrations' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab);
const [registrations, setRegistrations] = useState([]);
const [users, setUsers] = useState([]);
const [loadingRegs, setLoadingRegs] = useState(false);
@@ -201,6 +202,7 @@ export default function AdminDashboard({
// 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' && !canReviewRegistrations) activeTab = defaultTab;
if ((tab === 'events' || tab === 'gallery') && !isAdmin && !isItTeam) activeTab = defaultTab;
@@ -871,6 +873,49 @@ export default function AdminDashboard({
USERS & ROLES ({users.length})
)}
+
+ {isAdmin && (
+
+ )}
+
+ {/* Venue check-in scanner (registration team and admins; the page and its API check the role again). */}
+ {canReviewRegistrations && (
+
+ OPEN SCANNER ↗
+
+ )}
@@ -1375,6 +1420,11 @@ export default function AdminDashboard({
/>
)}
+ {/* TAB 5: VENUE ENTRY / CHECK-IN (Admin Only) */}
+ {activeTab === 'entry' && isAdmin && (
+
+ )}
+
{/* TAB 4: USERS DIRECTORY & ROLE MANAGEMENT (Admin Only) */}
{activeTab === 'users' && isAdmin && (
diff --git a/src/components/innovision/EntryPassCard.tsx b/src/components/innovision/EntryPassCard.tsx
new file mode 100644
index 0000000..6a19e82
--- /dev/null
+++ b/src/components/innovision/EntryPassCard.tsx
@@ -0,0 +1,220 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { getSupabase } from '@/lib/supabase';
+import { httpErrorMessage } from '@/lib/api-errors';
+
+/*
+ * The participant's QR entry pass (profile overlay). The QR holds only an opaque random token, fetched from
+ * POST /api/pass for the signed-in owner and kept in memory only (never in localStorage). Shown only while the
+ * registration is confirmed and the pass active; otherwise the status is explained instead.
+ */
+
+interface PassInfo {
+ registrationStatus: 'none' | 'pending' | 'confirmed' | 'rejected';
+ passStatus: 'none' | 'active' | 'revoked';
+ registrationId: string | null;
+ name: string | null;
+ checkedIn: boolean;
+ token: string | null;
+}
+
+type Load =
+ | { state: 'loading' }
+ | { state: 'error'; message: string }
+ | { state: 'ready'; pass: PassInfo; qr: string | null };
+
+const GOLD = 'oklch(0.8 0.12 85)';
+const box: React.CSSProperties = {
+ marginBottom: '18px',
+ padding: '16px',
+ background: 'rgba(236,232,223,0.06)',
+ border: '1px solid rgba(236,232,223,0.12)',
+};
+const label: React.CSSProperties = { margin: 0, fontSize: '12px', fontWeight: 700, letterSpacing: '.18em', color: GOLD };
+const text: React.CSSProperties = { margin: 0, fontSize: '14px', lineHeight: 1.5, color: 'rgba(236,232,223,0.8)' };
+
+async function fetchPass(): Promise {
+ let token: string | undefined;
+ try {
+ token = (await getSupabase().auth.getSession()).data.session?.access_token;
+ } catch {}
+ const res = await fetch('/api/pass', {
+ method: 'POST',
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ cache: 'no-store',
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok || !body.pass) throw new Error(httpErrorMessage(res) || body.error || 'Could not load your entry pass.');
+ return body.pass as PassInfo;
+}
+
+async function downloadPdf(pass: PassInfo, qr: string) {
+ const { jsPDF } = await import('jspdf');
+ const doc = new jsPDF({ unit: 'mm', format: 'a5' }); // 148 x 210
+ const W = 148;
+
+ doc.setFillColor(20, 19, 18);
+ doc.rect(0, 0, W, 210, 'F');
+ doc.setTextColor(220, 183, 106);
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(10);
+ doc.text('NIT ROURKELA · TECHNO-MANAGEMENT FEST', W / 2, 16, { align: 'center' });
+ doc.setTextColor(236, 232, 223);
+ doc.setFont('times', 'normal');
+ doc.setFontSize(30);
+ doc.text('Innovision 2026', W / 2, 30, { align: 'center' });
+ doc.setFont('times', 'italic');
+ doc.setFontSize(13);
+ doc.text('The Celestial Odyssey', W / 2, 38, { align: 'center' });
+
+ doc.setFillColor(236, 232, 223);
+ doc.rect(29, 48, 90, 90, 'F');
+ doc.addImage(qr, 'PNG', 32, 51, 84, 84);
+
+ doc.setTextColor(220, 183, 106);
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(9);
+ doc.text('ENTRY PASS', W / 2, 150, { align: 'center' });
+ doc.setTextColor(236, 232, 223);
+ doc.setFontSize(18);
+ doc.text(doc.splitTextToSize(pass.name || '', 120), W / 2, 160, { align: 'center' });
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(12);
+ doc.text(`Registration ID ${pass.registrationId ?? ''}`, W / 2, 174, { align: 'center' });
+
+ doc.setFontSize(9);
+ doc.setTextColor(200, 196, 188);
+ doc.text(
+ doc.splitTextToSize('Show this pass together with your college ID card at the registration desk. Valid for one check-in. Do not share this QR.', 118),
+ W / 2,
+ 188,
+ { align: 'center' }
+ );
+ doc.save(`Innovision-2026-entry-pass-${pass.registrationId ?? 'pass'}.pdf`);
+}
+
+export default function EntryPassCard() {
+ const [load, setLoad] = useState({ state: 'loading' });
+ const [attempt, setAttempt] = useState(0);
+ const [downloading, setDownloading] = useState(false);
+ const [downloadError, setDownloadError] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const pass = await fetchPass();
+ let qr: string | null = null;
+ if (pass.token) {
+ const QRCode = (await import('qrcode')).default;
+ qr = await QRCode.toDataURL(pass.token, { errorCorrectionLevel: 'M', margin: 2, width: 512 });
+ }
+ if (!cancelled) setLoad({ state: 'ready', pass, qr });
+ } catch (err) {
+ if (!cancelled) setLoad({ state: 'error', message: err instanceof Error ? err.message : 'Could not load your entry pass.' });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [attempt]);
+
+ if (load.state === 'loading') {
+ return (
+
+ );
+ }
+
+ const { pass, qr } = load;
+ if (pass.registrationStatus !== 'confirmed' || pass.passStatus !== 'active' || !qr) {
+ const message =
+ pass.registrationStatus === 'pending'
+ ? 'Your entry QR appears here once the Registration Team confirms your registration.'
+ : pass.registrationStatus === 'rejected'
+ ? 'There is no entry pass for a declined registration. Please contact the help desk.'
+ : pass.passStatus === 'revoked'
+ ? 'This entry pass was cancelled. Please contact the help desk.'
+ : 'Your entry pass is not available yet. Please check again later.';
+ return (
+
+
ENTRY PASS
+
{message}
+
+ );
+ }
+
+ return (
+
+
+
ENTRY PASS
+ {pass.checkedIn && (
+
+ CHECKED IN
+
+ )}
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URL, nothing to optimise */}
+
+
+ Show this QR together with your college ID card at the registration desk. Do not share it.
+
+
+ {downloadError &&
{downloadError}
}
+
+ );
+}
diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx
index 4ea4637..ffdb40a 100644
--- a/src/components/innovision/Innovision.tsx
+++ b/src/components/innovision/Innovision.tsx
@@ -366,11 +366,13 @@ export class Innovision extends Component {
if (!REGISTRATIONS_OPEN && (isOpenRegister || search.get('required') === 'register')) {
this.showRegistrationsSoon(true);
} else if (isAuthRequired) {
- // Remembered across the Google sign-in round trip, so the student lands on the form once signed in.
- if (search.get('required') === 'register') sessionStorage.setItem('inv_pending_action', 'register');
+ // Remembered across the Google sign-in round trip, so the student lands on the form once signed in, and a
+ // volunteer sent here by the check-in scanner ("Log in again") goes straight back to /admin/scan.
+ const required = search.get('required');
+ if (required === 'register' || required === 'scan') sessionStorage.setItem('inv_pending_action', required);
setTimeout(() => {
this.openAuth('login');
- this.toast('Please log in to register.');
+ this.toast(required === 'scan' ? 'Please log in again to continue scanning.' : 'Please log in to register.');
}, 1200);
} else if (isOpenRegister) {
// Waits for the session check, so a signed-in student isn't sent to log in first.
@@ -1754,6 +1756,13 @@ export class Innovision extends Component {
});
const pendingAction = typeof window !== 'undefined' ? sessionStorage.getItem('inv_pending_action') : null;
+ if (pendingAction === 'scan') {
+ // Back to the check-in scanner (a fixed path, never one taken from the URL). Removed first, so a signed-in user
+ // who isn't staff (bounced from /admin/scan to /) can't loop.
+ sessionStorage.removeItem('inv_pending_action');
+ this.leaveTo('/admin/scan', true);
+ return;
+ }
if (pendingAction === 'register') {
if (typeof window !== 'undefined') sessionStorage.removeItem('inv_pending_action');
if (registration) {
diff --git a/src/components/innovision/ProfileOverlay.tsx b/src/components/innovision/ProfileOverlay.tsx
index 36f9195..609235d 100644
--- a/src/components/innovision/ProfileOverlay.tsx
+++ b/src/components/innovision/ProfileOverlay.tsx
@@ -2,6 +2,7 @@
import React, { useState } from 'react';
import type { UserProfile, Registration } from '@/lib/supabase';
+import EntryPassCard from './EntryPassCard';
interface ProfileOverlayProps {
isOpen: boolean;
@@ -424,6 +425,8 @@ export default function ProfileOverlay({