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
18 changes: 12 additions & 6 deletions app/(main)/profile/events/Analytics/[eventId]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,28 @@
// 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 }) {
const { eventId } = await 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 <EventStats onClosePath="/profile/events" />;
}
17 changes: 4 additions & 13 deletions app/api/form/getFormAnalytics/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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");
}

Expand Down
38 changes: 26 additions & 12 deletions lib/auth/permissions.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string>([
"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<string>(FORM_ANALYTICS_ROLES),
} satisfies Record<string, ReadonlySet<string>>;

export type Permission = keyof typeof PERMISSIONS;
Expand All @@ -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<SafeUser, "access" | "email"> | 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());
}
39 changes: 39 additions & 0 deletions lib/auth/roles.ts
Original file line number Diff line number Diff line change
@@ -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",
];
11 changes: 8 additions & 3 deletions src/authentication/SignUp/CompleteProfile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
15 changes: 5 additions & 10 deletions src/sections/Profile/General/EventsView/EventsView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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 () => {
Expand Down
Loading