From 8384fb7e07af53cac0a710aaa1292385978c509c Mon Sep 17 00:00:00 2001 From: Hardik Gupta Date: Sat, 22 Aug 2026 08:51:42 +0530 Subject: [PATCH] Allow attendance scanner account to access attendance page and APIs The sidebar showed attendance@fedkiit.com an Attendance link, but the page and APIs required ADMIN access. Add canMarkAttendance permission shared across the route and endpoints, and redirect the scanner account from /profile to /profile/attendance. --- app/(main)/profile/attendance/page.jsx | 9 +++++---- app/(main)/profile/page.jsx | 10 ++++++++-- app/api/form/attendance-events/route.ts | 5 +++-- app/api/form/attendance-stats/[id]/route.ts | 5 +++-- app/api/form/export-attendance/[id]/route.ts | 9 +++++---- app/api/form/markAttendance/route.ts | 7 ++++--- lib/auth/attendance.ts | 9 +++++++++ lib/auth/permissions.ts | 16 ++++++++++++++++ src/layouts/Profile/Sidebar/Sidebar.jsx | 5 +++-- 9 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 lib/auth/attendance.ts diff --git a/app/(main)/profile/attendance/page.jsx b/app/(main)/profile/attendance/page.jsx index 3ef9e6e..0bb0004 100644 --- a/app/(main)/profile/attendance/page.jsx +++ b/app/(main)/profile/attendance/page.jsx @@ -1,8 +1,8 @@ // Route entry — renders the component ported from // FED-Frontend/src/pages/AttendancePage/AttendancePage.jsx // -// Gated to ADMIN on the server. The sidebar only ever *showed* this link to -// admins, but that is presentation: `proxy.ts` guards /profile by checking for +// Gated to admins and the door-duty scanner on the server. The sidebar only +// ever *showed* this link to those users, but that is presentation: `proxy.ts` guards /profile by checking for // a valid session, not a role, so before this check any signed-in participant // who typed the path got a working scanner. Combined with the fact that a // participant can generate their own QR (that is the whole point of @@ -13,7 +13,8 @@ import { redirect } from "next/navigation"; -import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { canMarkAttendance } from "@/lib/auth/permissions"; import AttendancePage from "@/src/views/AttendancePage/AttendancePage"; export default async function Page() { @@ -22,7 +23,7 @@ export default async function Page() { // Matches what proxy.ts does for an anonymous request to a protected route, // so an expired session lands on the login page rather than a bare redirect. if (!user) redirect("/Login?next=/profile/attendance"); - if (!isAdmin(user)) redirect("/profile"); + if (!canMarkAttendance(user)) redirect("/profile"); return ; } diff --git a/app/(main)/profile/page.jsx b/app/(main)/profile/page.jsx index 625771f..ecd36d4 100644 --- a/app/(main)/profile/page.jsx +++ b/app/(main)/profile/page.jsx @@ -1,9 +1,15 @@ // Route entry — renders the component ported from // FED-Frontend/src/sections/Profile/General/ProfileView/ProfileView.jsx -"use client"; +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/auth/access"; +import { isAttendanceScanner } from "@/lib/auth/attendance"; import ProfileView from "@/src/sections/Profile/General/ProfileView/ProfileView"; -export default function Page() { +export default async function Page() { + const user = await getCurrentUser(); + if (user && isAttendanceScanner(user)) redirect("/profile/attendance"); + return ; } diff --git a/app/api/form/attendance-events/route.ts b/app/api/form/attendance-events/route.ts index 9d67a1f..13981a4 100644 --- a/app/api/form/attendance-events/route.ts +++ b/app/api/form/attendance-events/route.ts @@ -3,7 +3,8 @@ import { listAttendanceEvents, } from "@/lib/services/attendance"; import { expressError, handle, json } from "@/lib/api/express"; -import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { canMarkAttendance } from "@/lib/auth/permissions"; /** * GET /api/form/attendance-events @@ -15,7 +16,7 @@ export async function GET() { return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isAdmin(user)) return expressError(403, "Unauthorized"); + if (!canMarkAttendance(user)) return expressError(403, "Unauthorized"); const events = await listAttendanceEvents(); return json({ diff --git a/app/api/form/attendance-stats/[id]/route.ts b/app/api/form/attendance-stats/[id]/route.ts index 70df564..4780be4 100644 --- a/app/api/form/attendance-stats/[id]/route.ts +++ b/app/api/form/attendance-stats/[id]/route.ts @@ -1,6 +1,7 @@ import { getAttendanceStats } from "@/lib/services/attendance"; import { expressError, handle, json } from "@/lib/api/express"; -import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { canMarkAttendance } from "@/lib/auth/permissions"; /** * GET /api/form/attendance-stats/:id @@ -14,7 +15,7 @@ export async function GET( return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isAdmin(user)) return expressError(403, "Unauthorized"); + if (!canMarkAttendance(user)) return expressError(403, "Unauthorized"); const { id } = await ctx.params; const stats = await getAttendanceStats(id); diff --git a/app/api/form/export-attendance/[id]/route.ts b/app/api/form/export-attendance/[id]/route.ts index 3e2d4dc..09d9f7c 100644 --- a/app/api/form/export-attendance/[id]/route.ts +++ b/app/api/form/export-attendance/[id]/route.ts @@ -1,13 +1,14 @@ import { exportAttendance } from "@/lib/services/attendance"; import { expressError, handle } from "@/lib/api/express"; -import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { canMarkAttendance } from "@/lib/auth/permissions"; /** * GET /api/form/export-attendance/:id * Port of controllers/registration/exportAttendance. * - * Admin only, matching the route's `checkAccess("ADMIN")`. This previously - * accepted any club member, which handed the full attendee list of any event to + * Admin and door-duty scanner, matching the route's `checkAccess("ADMIN")` plus + * the dedicated attendance account. This previously accepted any club member, which handed the full attendee list of any event to * every executive rather than to admins alone. */ export async function GET( @@ -17,7 +18,7 @@ export async function GET( return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isAdmin(user)) return expressError(403, "Unauthorized"); + if (!canMarkAttendance(user)) return expressError(403, "Unauthorized"); const { id } = await ctx.params; const { filename, csv } = await exportAttendance(id); diff --git a/app/api/form/markAttendance/route.ts b/app/api/form/markAttendance/route.ts index c13e7d7..482e796 100644 --- a/app/api/form/markAttendance/route.ts +++ b/app/api/form/markAttendance/route.ts @@ -1,12 +1,13 @@ import { markAttendance } from "@/lib/services/attendance"; import { body, expressError, handle, json } from "@/lib/api/express"; -import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { canMarkAttendance } from "@/lib/auth/permissions"; /** * POST /api/form/markAttendance * Port of controllers/registration/markAttendance.js. * - * ADMIN only. This deliberately diverges from the Express route, which has its + * ADMIN and the dedicated door-duty account. This deliberately diverges from the Express route, which has its * `checkAccess` commented out entirely and so accepts unauthenticated calls. * * The QR token alone is not an access control: a participant can mint their own @@ -26,7 +27,7 @@ export async function POST(request: Request) { return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isAdmin(user)) return expressError(403, "Unauthorized"); + if (!canMarkAttendance(user)) return expressError(403, "Unauthorized"); const b = await body<{ formId?: string; token?: string }>(request); const result = await markAttendance({ formId: b.formId, token: b.token }); diff --git a/lib/auth/attendance.ts b/lib/auth/attendance.ts new file mode 100644 index 0000000..e282fd5 --- /dev/null +++ b/lib/auth/attendance.ts @@ -0,0 +1,9 @@ +/** Dedicated door-duty account — sidebar shows only the attendance scanner. */ +export const ATTENDANCE_SCANNER_EMAIL = "attendance@fedkiit.com"; + +export function isAttendanceScanner( + user: Pick<{ email: string }, "email"> | null | undefined, +): boolean { + if (!user) return false; + return user.email.toLowerCase() === ATTENDANCE_SCANNER_EMAIL.toLowerCase(); +} diff --git a/lib/auth/permissions.ts b/lib/auth/permissions.ts index 3b36219..0afdbcd 100644 --- a/lib/auth/permissions.ts +++ b/lib/auth/permissions.ts @@ -1,6 +1,8 @@ import "server-only"; import type { SafeUser } from "@/lib/auth/access"; +import { isAdmin } from "@/lib/auth/access"; +import { isAttendanceScanner } from "@/lib/auth/attendance"; import { FORM_ANALYTICS_ROLES } from "@/lib/auth/roles"; import { envList, getEnv } from "@/lib/env"; @@ -57,3 +59,17 @@ export function canViewFormAnalytics( ); return allowed.includes(user.email.toLowerCase()); } + +/** + * Who may open the attendance scanner and call its APIs. + * + * ADMIN plus the dedicated door-duty account. Both the page and every + * attendance endpoint use this so the sidebar cannot promise access the + * server will refuse. + */ +export function canMarkAttendance( + user: Pick | null | undefined, +): boolean { + if (!user) return false; + return isAdmin(user) || isAttendanceScanner(user); +} diff --git a/src/layouts/Profile/Sidebar/Sidebar.jsx b/src/layouts/Profile/Sidebar/Sidebar.jsx index d916339..904db3f 100644 --- a/src/layouts/Profile/Sidebar/Sidebar.jsx +++ b/src/layouts/Profile/Sidebar/Sidebar.jsx @@ -9,6 +9,7 @@ import { SiReacthookform } from "react-icons/si"; import { FaRegNewspaper, FaCertificate, FaChevronDown } from "react-icons/fa"; import { LuClipboardList } from "react-icons/lu"; import AuthContext from "../../../context/AuthContext"; +import { isAttendanceScanner } from "@/lib/auth/attendance"; import styles from "./styles/Sidebar.module.scss"; import defaultImg from "../../../assets/images/defaultImg.jpg"; @@ -33,7 +34,7 @@ const Sidebar = ({ activepage, handleChange }) => { const access = authCtx.user.access; const email = authCtx.user.email; // Assuming email is available in authCtx.user - if (email === "attendance@fedkiit.com") { + if (isAttendanceScanner({ email })) { setDesignation("Attendance Only"); } else if (access === "ADMIN") { setDesignation("Admin"); @@ -92,7 +93,7 @@ const Sidebar = ({ activepage, handleChange }) => { }; // Check if user is attendance-only - const isAttendanceOnly = authCtx.user.email === "attendance@fedkiit.com"; + const isAttendanceOnly = isAttendanceScanner(authCtx.user); const renderBlogMenu = () => { return (