From 2a6ba3810a58c9a34cf639a25882ca1e2b260ebe Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Mon, 10 Aug 2026 23:51:12 +0530 Subject: [PATCH] Restore analytics access for the president, VP and directors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Analytics page checked `isAdmin`, which was narrower than both the endpoint it guards and the Express-era route it replaced (`access !== "USER"` in App.jsx). EventsView offered a president the Analytics button and this line then redirected them to /profile when they clicked it. Both the page and the API now call `canViewFormAnalytics`, which holds the whole of the Express rule from controllers/forms/analytics.js: the role list, plus the address allowlist that moved into FORM_ANALYTICS_ALLOWED_EMAILS. Checked against the original controller over 18 roles and both email cases — no divergence. The role list had three hand-kept copies, which is how the button and the endpoint came to disagree in the first place. It is now one constant in lib/auth/roles.ts, imported by the permission set, the route and the button. DIRECTOR_SPONSORSHIP stays in the list although it is not an AccessTypes member and can never match; it was equally dead in Express, and dropping it would quietly change who qualifies. Also fixes four arguments passed one slot off to authCtx.login in CompleteProfile, which put `regForm` into `access`. Every account created through that path carried a role matching no list, so a member could be refused a permission their database record granted. --- .../events/Analytics/[eventId]/page.jsx | 18 ++++++--- app/api/form/getFormAnalytics/[id]/route.ts | 17 ++------ lib/auth/permissions.ts | 38 ++++++++++++------ lib/auth/roles.ts | 39 +++++++++++++++++++ src/authentication/SignUp/CompleteProfile.jsx | 11 ++++-- .../Profile/General/EventsView/EventsView.jsx | 15 +++---- 6 files changed, 94 insertions(+), 44 deletions(-) create mode 100644 lib/auth/roles.ts diff --git a/app/(main)/profile/events/Analytics/[eventId]/page.jsx b/app/(main)/profile/events/Analytics/[eventId]/page.jsx index cf8b4e9..be32803 100644 --- a/app/(main)/profile/events/Analytics/[eventId]/page.jsx +++ b/app/(main)/profile/events/Analytics/[eventId]/page.jsx @@ -6,14 +6,20 @@ // was written and exported from the modals barrel, but nothing ever mounted it // — so the button 404'd and the component was dead code. // -// Gated to ADMIN on the server, matching /profile/attendance: `proxy.ts` only -// checks for a valid session, not a role, so without this any signed-in -// participant who typed the path would see every registrant's email and -// payment screenshot. +// Gated on the server, because `proxy.ts` only checks for a valid session, not +// a role — without this any signed-in participant who typed the path would see +// every registrant's email and payment screenshot. +// +// The gate is `canViewFormAnalytics`, the same rule the endpoint this page +// calls enforces. It was `isAdmin` at first, which was narrower than both the +// API and the Express-era route (`access !== "USER"` in App.jsx) — so a +// president or director was shown the Analytics button by EventsView, and then +// bounced back to /profile by this line when they clicked it. import { redirect } from "next/navigation"; -import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { canViewFormAnalytics } from "@/lib/auth/permissions"; import EventStats from "@/src/features/Modals/Event/EventStats/EventStats"; export default async function Page({ params }) { @@ -21,7 +27,7 @@ export default async function Page({ params }) { const user = await getCurrentUser(); if (!user) redirect(`/Login?next=/profile/events/Analytics/${eventId}`); - if (!isAdmin(user)) redirect("/profile"); + if (!canViewFormAnalytics(user)) redirect("/profile"); return ; } diff --git a/app/api/form/getFormAnalytics/[id]/route.ts b/app/api/form/getFormAnalytics/[id]/route.ts index 0f2070d..f8d3318 100644 --- a/app/api/form/getFormAnalytics/[id]/route.ts +++ b/app/api/form/getFormAnalytics/[id]/route.ts @@ -1,8 +1,7 @@ import { prisma } from "@/lib/db"; import { expressError, handle, json } from "@/lib/api/express"; import { getCurrentUser } from "@/lib/auth/access"; -import { can } from "@/lib/auth/permissions"; -import { envList, getEnv } from "@/lib/env"; +import { canViewFormAnalytics } from "@/lib/auth/permissions"; /** * GET /api/form/getFormAnalytics/:id @@ -21,18 +20,10 @@ export async function GET( const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - // Role list lives in `lib/auth/permissions.ts`; the address allowlist that - // sits beside it is `FORM_ANALYTICS_ALLOWED_EMAILS` in the environment, - // both previously hardcoded here. - const allowedEmails = envList(getEnv().FORM_ANALYTICS_ALLOWED_EMAILS).map( - (entry) => entry.toLowerCase(), - ); - + // Role list and address allowlist both live in `lib/auth/permissions.ts`, + // shared with the page that renders EventStats so the two cannot disagree. // 401 rather than 403, matching `ApiError(status.UNAUTHORIZED, ...)`. - if ( - !can(user, "FORM_ANALYTICS_VIEW") && - !allowedEmails.includes(user.email.toLowerCase()) - ) { + if (!canViewFormAnalytics(user)) { return expressError(401, "Access Denied"); } diff --git a/lib/auth/permissions.ts b/lib/auth/permissions.ts index da7f91e..3b36219 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 { FORM_ANALYTICS_ROLES } from "@/lib/auth/roles"; +import { envList, getEnv } from "@/lib/env"; /** * Who may do what, declared once. @@ -15,18 +17,7 @@ import type { SafeUser } from "@/lib/auth/access"; */ export const PERMISSIONS = { /** Read a form's registration analytics (EventStats). */ - FORM_ANALYTICS_VIEW: new Set([ - "PRESIDENT", - "VICEPRESIDENT", - "DIRECTOR_CREATIVE", - "DIRECTOR_TECHNICAL", - "DIRECTOR_MARKETING", - "DIRECTOR_OPERATIONS", - // Not a member of the AccessTypes enum, so it can never match. Carried over - // from the Express controller, where it was equally dead; kept so the list - // stays a faithful copy rather than a silent narrowing. - "DIRECTOR_SPONSORSHIP", - ]), + FORM_ANALYTICS_VIEW: new Set(FORM_ANALYTICS_ROLES), } satisfies Record>; export type Permission = keyof typeof PERMISSIONS; @@ -43,3 +34,26 @@ export function can( if (user.access === "ADMIN") return true; return PERMISSIONS[permission].has(user.access); } + +/** + * The whole of the Express analytics rule, in one place: + * + * if (!allowedUsers.includes(req.user.access) && req.user.email != "srex@…") + * + * The address list moved to `FORM_ANALYTICS_ALLOWED_EMAILS` in the environment. + * + * Both the API route and the page that renders EventStats call this, so the + * page can no longer refuse someone the endpoint would have served — which is + * exactly what happened when the page checked `isAdmin` on its own. + */ +export function canViewFormAnalytics( + user: Pick | null | undefined, +): boolean { + if (!user) return false; + if (can(user, "FORM_ANALYTICS_VIEW")) return true; + + const allowed = envList(getEnv().FORM_ANALYTICS_ALLOWED_EMAILS).map((entry) => + entry.toLowerCase(), + ); + return allowed.includes(user.email.toLowerCase()); +} diff --git a/lib/auth/roles.ts b/lib/auth/roles.ts new file mode 100644 index 0000000..9b1c318 --- /dev/null +++ b/lib/auth/roles.ts @@ -0,0 +1,39 @@ +/** + * Role lists that both the server and the browser need. + * + * Deliberately free of `server-only` and of any Node import: the analytics + * button in `EventsView` has to ask the same question the API asks, and it asks + * it in the browser. When the two lists were written out separately they + * disagreed — the route would have served a president that the table never + * offered a button to. + */ + +/** + * May read a form's registration analytics. + * + * Copied from the Express controller's `allowedUsers` + * (FED-Backend/controllers/forms/analytics.js). ADMIN is not listed because + * `can()` grants it everything, matching `checkAccess`, where ADMIN always + * passed — but the browser-side list needs it spelled out, so it is added in + * `FORM_ANALYTICS_ROLES_CLIENT` below. + */ +export const FORM_ANALYTICS_ROLES = [ + "PRESIDENT", + "VICEPRESIDENT", + "DIRECTOR_CREATIVE", + "DIRECTOR_TECHNICAL", + "DIRECTOR_MARKETING", + "DIRECTOR_OPERATIONS", + // Not a member of the AccessTypes enum, so it can never match. Carried over + // from the Express controller, where it was equally dead; kept so the list + // stays a faithful copy rather than a silent narrowing. Note that this means + // DIRECTOR_PR_AND_FINANCE and DIRECTOR_HUMAN_RESOURCE have never had + // analytics access, on this stack or the previous one. + "DIRECTOR_SPONSORSHIP", +] as const; + +/** The same list with ADMIN spelled out, for client-side `includes()` checks. */ +export const FORM_ANALYTICS_ROLES_CLIENT: readonly string[] = [ + ...FORM_ANALYTICS_ROLES, + "ADMIN", +]; diff --git a/src/authentication/SignUp/CompleteProfile.jsx b/src/authentication/SignUp/CompleteProfile.jsx index 179bc30..0c33bc2 100644 --- a/src/authentication/SignUp/CompleteProfile.jsx +++ b/src/authentication/SignUp/CompleteProfile.jsx @@ -110,10 +110,15 @@ function CompleteProfile() { userObject.github, userObject.linkedin, userObject.designation, - userObject.regForm, - userObject.access, - userObject.editProfileCount, + // `access` is the 12th argument, not the 13th. These four were off + // by one against the signature, so `regForm` landed in `access` — + // leaving every account created here with a role that matches no + // entry in any role list, which is how a member could lose a + // permission their database record still granted them. "USER", + userObject.editProfileCount, + userObject.regForm, + userObject.blurhash || "", "someToken", SESSION_TTL_MS ); diff --git a/src/sections/Profile/General/EventsView/EventsView.jsx b/src/sections/Profile/General/EventsView/EventsView.jsx index 43c0f81..39e3904 100644 --- a/src/sections/Profile/General/EventsView/EventsView.jsx +++ b/src/sections/Profile/General/EventsView/EventsView.jsx @@ -6,6 +6,7 @@ import AuthContext from "../../../../context/AuthContext"; import { api } from "../../../../services"; import { ComponentLoading, MicroLoading } from "../../../../microInteraction"; +import { FORM_ANALYTICS_ROLES_CLIENT } from "@/lib/auth/roles"; import Link from "next/link"; const Events = () => { @@ -22,16 +23,10 @@ const Events = () => { const viewPath = "/Events"; const analyticsPath = "/profile/events/Analytics"; - const analyticsAccessRoles = [ - "PRESIDENT", - "VICEPRESIDENT", - "DIRECTOR_CREATIVE", - "DIRECTOR_TECHNICAL", - "DIRECTOR_MARKETING", - "DIRECTOR_OPERATIONS", - "DIRECTOR_SPONSORSHIP", - "ADMIN", - ]; + // Shared with the API route and the Analytics page, so the button cannot be + // offered to someone the server will turn away — or withheld from someone it + // would have served. + const analyticsAccessRoles = FORM_ANALYTICS_ROLES_CLIENT; useEffect(() => { const fetchEventsData = async () => {