Beta - #12
Conversation
A correct login showed "Login successful" and then sat on /Login forever.
App.jsx never navigated from inside Login.jsx — it called authCtx.login(...)
and let the route table react:
<Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} />
App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only
runs on a server request, which a client-side sign-in never makes.
The redirect belongs in the components rather than in a layout wrapper. A guard
in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong:
SignUp and CompleteProfile sign the user in and then navigate themselves to
"/", and a layout guard cancels that in-flight router.push before it commits.
Measured on the signup flow, the push never reached history at all and a new
account landed on /profile instead of "/". No delay fixes that reliably, since
the push only commits once its RSC payload arrives.
Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the
shouldNavigate/navigatePath state and the effect that acts on it — dead in the
original precisely because the route table did the job. Setting
setShouldNavigate(true) after authCtx.login(...) brings it to life, with no
cross-component race. SendOtp.jsx already did this and is unchanged.
postAuthRedirect() resolves the destination the way LoginRedirect did, plus the
?next= proxy.ts appends. That value comes off the query string and is therefore
attacker-supplied, so anything that is not a plain internal path is discarded.
Verified in the browser by driving the real forms with the API stubbed:
Login /Login -> /profile
Login /Login?next=/Events -> /Events
Login /Login?next=//example.com/phish -> /profile, origin preserved
Login blocked page -> login -> back to it, prevPage cleared
Signup /SignUp -> /, matching the original
Login stale localStorage, no cookie -> login form, one bounce, no loop
Also verified every auth route against the running server, signed out and
signed in: all five auth pages, all seven /profile pages, the lowercase casing
redirects, a tampered cookie (rejected and cleared), and the seven auth
endpoints on an empty body.
Two pre-existing gaps are documented in MIGRATION.md rather than silently
changed: /ForgotPassword submits natively (identical in the original), and
/profile/members and /profile/BlogForm are not access-gated in the UI.
typecheck clean, eslint 0 errors, build 68 routes.
* Fix the redirect after sign-in
A correct login showed "Login successful" and then sat on /Login forever.
App.jsx never navigated from inside Login.jsx — it called authCtx.login(...)
and let the route table react:
<Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} />
App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only
runs on a server request, which a client-side sign-in never makes.
The redirect belongs in the components rather than in a layout wrapper. A guard
in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong:
SignUp and CompleteProfile sign the user in and then navigate themselves to
"/", and a layout guard cancels that in-flight router.push before it commits.
Measured on the signup flow, the push never reached history at all and a new
account landed on /profile instead of "/". No delay fixes that reliably, since
the push only commits once its RSC payload arrives.
Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the
shouldNavigate/navigatePath state and the effect that acts on it — dead in the
original precisely because the route table did the job. Setting
setShouldNavigate(true) after authCtx.login(...) brings it to life, with no
cross-component race. SendOtp.jsx already did this and is unchanged.
postAuthRedirect() resolves the destination the way LoginRedirect did, plus the
?next= proxy.ts appends. That value comes off the query string and is therefore
attacker-supplied, so anything that is not a plain internal path is discarded.
Verified in the browser by driving the real forms with the API stubbed:
Login /Login -> /profile
Login /Login?next=/Events -> /Events
Login /Login?next=//example.com/phish -> /profile, origin preserved
Login blocked page -> login -> back to it, prevPage cleared
Signup /SignUp -> /, matching the original
Login stale localStorage, no cookie -> login form, one bounce, no loop
Also verified every auth route against the running server, signed out and
signed in: all five auth pages, all seven /profile pages, the lowercase casing
redirects, a tampered cookie (rejected and cleared), and the seven auth
endpoints on an empty body.
Two pre-existing gaps are documented in MIGRATION.md rather than silently
changed: /ForgotPassword submits natively (identical in the original), and
/profile/members and /profile/BlogForm are not access-gated in the UI.
typecheck clean, eslint 0 errors, build 68 routes.
* Fix the redirect after sign-in (#3) (#4)
A correct login showed "Login successful" and then sat on /Login forever.
App.jsx never navigated from inside Login.jsx — it called authCtx.login(...)
and let the route table react:
<Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} />
App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only
runs on a server request, which a client-side sign-in never makes.
The redirect belongs in the components rather than in a layout wrapper. A guard
in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong:
SignUp and CompleteProfile sign the user in and then navigate themselves to
"/", and a layout guard cancels that in-flight router.push before it commits.
Measured on the signup flow, the push never reached history at all and a new
account landed on /profile instead of "/". No delay fixes that reliably, since
the push only commits once its RSC payload arrives.
Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the
shouldNavigate/navigatePath state and the effect that acts on it — dead in the
original precisely because the route table did the job. Setting
setShouldNavigate(true) after authCtx.login(...) brings it to life, with no
cross-component race. SendOtp.jsx already did this and is unchanged.
postAuthRedirect() resolves the destination the way LoginRedirect did, plus the
?next= proxy.ts appends. That value comes off the query string and is therefore
attacker-supplied, so anything that is not a plain internal path is discarded.
Verified in the browser by driving the real forms with the API stubbed:
Login /Login -> /profile
Login /Login?next=/Events -> /Events
Login /Login?next=//example.com/phish -> /profile, origin preserved
Login blocked page -> login -> back to it, prevPage cleared
Signup /SignUp -> /, matching the original
Login stale localStorage, no cookie -> login form, one bounce, no loop
Also verified every auth route against the running server, signed out and
signed in: all five auth pages, all seven /profile pages, the lowercase casing
redirects, a tampered cookie (rejected and cleared), and the seven auth
endpoints on an empty body.
Two pre-existing gaps are documented in MIGRATION.md rather than silently
changed: /ForgotPassword submits natively (identical in the original), and
/profile/members and /profile/BlogForm are not access-gated in the UI.
typecheck clean, eslint 0 errors, build 68 routes.
Co-authored-by: Krishna-Das20 <krishnadas2806@gmail.com>
* Halve the client bundle and fix the SSR hydration errors
The landing page shipped 2.3 MB of JavaScript. Home.jsx imported its four
sections from the `sections` barrel, which also re-exports `sections/Profile` —
the whole admin panel. All of them are client components, so the bundler pulled
that entire graph onto the landing page: certificate tooling, admin tables, the
avatar editor, event analytics. The `features` barrel did the same for
LiveEventPopup. Under Vite this cost nothing noticeable; under Next, where each
route gets its own bundle, a barrel import silently undoes the code splitting.
Importing those four components directly, leaving the barrels in place for
other call sites:
/ 2317 KB -> 1082 KB
/Events 2063 KB -> 1082 KB
/Team 2014 KB -> 1082 KB
/Login 1248 KB -> 920 KB
Over the wire the landing page is now 327 KB of JS; locally TTFB 38ms,
DOMContentLoaded 135ms, load 536ms.
EventCard wrapped its meta row in a <p> containing div.price, which contained
another <p>. Client-rendered that was invisible, because React builds the DOM
node by node and nothing reparents it. Server-rendered it is real markup, so
the parser closed the <p> early and div.price came out a sibling instead of a
child — a different layout, reported as a hydration mismatch on every card. The
wrapper is a <div className={style.meta}> and the two `.eventname p` rules list
`.eventname .meta` alongside, so computed styles are unchanged: verified in the
browser at font-size 14.4px, display flex, align-items center, margin-top 1.6px
with div.price a real child, console clean across 580 cards.
Also: darken() -> color.adjust() in VerifyCertificate.module.scss, confirmed
identical by compiling both and diffing (rgb(80%, 43.2941176471%, 0%) either
way), which clears the Dart Sass deprecation warnings; and <html> now carries
data-scroll-behavior="smooth" to acknowledge the globals.scss rule.
The <Fit /> warnings are left alone — react-fit is a transitive dependency of
react-date-picker and uses the `warning` package, a no-op in production.
typecheck clean, eslint 0 errors, build 68 routes, all pages 200, auth route
matrix unchanged.
* Fix the remaining SSR hydration errors, and add an audit that finds them
Four components wrapped block-level content in a <p>: EventCard and EventModal
(<p> -> div.price -> <p>), Hero (<p> -> <span> -> <h3>) and Social (<p> ->
div.fed). Client-rendered under Vite none of it mattered, because React builds
the DOM node by node and nothing reparents an existing tree. Server-rendered it
is real markup, so the parser closes the <p> at the first block child and the
content lands as a sibling — a different layout, reported as a hydration
mismatch. EventCard was fixed in 9a1073e; this covers the other three.
Each wrapper is a <div> carrying a class listed alongside the original `p`
selector, so computed styles are unchanged. Verified in the browser:
EventModal .meta 14.4px / flex / center / 1.6px / #fff
Hero .tagline 39.2px / 700 / #fff
Social .content 40px / 600 / #fff / center
Hero keeps its <h3> rather than downgrading it to a span — the wrapper changed
instead, so the heading still counts as a heading. Social's `styles.content`
had no rule in the stylesheet at all, so the className resolved to undefined
and the element was styled purely by `.text p`; `.content` now exists and
carries those declarations.
Fixing Social surfaced a second, unrelated mismatch on that page:
react-social-media-embed mints a fresh UUID per render into both `id` and
`className`, and the embed sizes come from useDimensions(), which measures 0 on
the server. Both embeds now load via next/dynamic with ssr: false — nothing is
lost, since the visible post is drawn by Instagram's and LinkedIn's own scripts
after mount. Same four embeds, same 1674px page height, console clean.
npm run audit:nesting keeps this from recurring. It walks a tag stack through
every JSX file and reports anything the HTML parser would reparent, skipping
comments and string/regex literals — without that a comment mentioning <p>, or
`.replace(/<a\s[^>]*>/gi, '')`, is read as markup. It was validated against the
pre-fix files: it reports all four real cases and nothing for the two files that
produced false positives. Current tree: 153 files, zero findings.
typecheck clean, eslint 0 errors, build 68 routes, no Sass deprecations.
* Fix the admin calendar, and restore Express parity for forms and events
The calendar on the admin form page had no styling at all. The original
index.scss imported three vendor stylesheets and only one was carried over, so
react-date-picker's popup — which it draws with react-calendar — came out as an
unstyled column of numbers. Confirmed by grepping the emitted CSS: zero
.react-calendar rules shipped, now 50. In the browser the popup measures
350x248 with a #a0a096 border, react-calendar's own defaults.
Re-reading every form/event controller against its port turned up more:
getFormAnalytics returned the wrong shape entirely. EventStats.jsx reads
response.data.form.formAnalytics, .form.info and .yearCounts; the port returned
{ success, message, data }, so response.data.form was undefined and the admin
analytics panel threw. It now returns { message, form, yearCounts }, rebuilds
the yearCounts histogram, and restores the 404 for a form nobody has registered
to. Access is the controller's own allowlist (ADMIN/PRESIDENT/VICEPRESIDENT/
DIRECTOR_*) plus the srex@fedkiit.com escape hatch, answering 401 "Access
Denied", not the isMember test the port had invented.
The attendance flow did not work. The QR carries a signed JWT: attendanceCode
must return { message, attendanceToken } with a 20-minute expiry, and
markAttendance takes { formId, token } and verifies it. The port returned the
raw record id under a different key, so QRCodeModal generated no QR, and
markAttendance rejected the scanned JWT for not being a 24-character id. Both
now match, including the formId binding that stops one event's QR checking
someone in at another, and the ?teamCode= branch. Verified both directions: a
token minted by the original's jsonwebtoken call verifies with jose here, and
vice versa; tampered tokens are rejected.
Access levels corrected both ways: export-attendance is ADMIN again (was any
club member, which handed every executive the full attendee list), while
markAttendance drops to signed-in, matching the Express route whose checkAccess
is commented out — the door volunteer signs in as a plain USER, so requiring
member access locked the door staff out.
Cloudinary dimensions were wrong: addForm/editForm resize FormImages at
h350.67 x w196.37 and QR media at h400 x w150 and h150 x w400 respectively, not
the 1000x1000 / 500x500 the port used. The two QR rows are transposed against
each other in the original and are reproduced per call site rather than
reconciled. addForm also returns 200 "Form created successfully" and editForm
"Form info and sections updated successfully".
One divergence kept deliberately: Express's addForm computes
Boolean(isPublic) || false over a multipart field, and Boolean("false") is
true, so every event created through the admin form was public,
registration-closed and past regardless of the toggles. editForm already used
=== "true". The port uses editForm's form in both so the switches work;
restoring byte-parity would re-break them. Documented in MIGRATION.md along
with checkAccess("USER") being stricter in Express than here.
typecheck clean, eslint 0 errors, build 68 routes, nesting audit clean.
* Send 4-digit OTPs again, so they fit the four boxes on screen
The password-reset email carried a 6-digit code while every OTP screen renders
four boxes, so the code could not be entered at all. The reset flow and signup
share components/OtpInput, so both were affected.
The UI was not at fault — it is four boxes in the original too, character for
character. This port had raised OTP_LENGTH from 4 to 6 as hardening, without a
consumer that could accept six.
Reverted to 4, matching generateOtp(4, false, false, false). The argument for 6
does not hold here: 10,000 combinations are brute-forceable against the Express
backend, which had no throttling at all, but RATE_LIMITS.passwordReset allows 6
attempts per 15 minutes and a code expires after 15, so an attacker gets at most
6 guesses out of 10,000 before the code stops existing. The rest of the OTP
hardening is untouched: SHA-256 digest at rest, constant-time comparison,
single-use, and expiry derived from createdAt rather than a setTimeout that
never fires on a serverless host.
Codes issued before this change are 6 digits and cannot be entered; they expire
on their own within 15 minutes.
typecheck clean, eslint 0 errors, build 68 routes.
* Restore the global button reset, and put the Chatbot back on every route
The disabled "Resend OTP" button rendered with a grey box. Measured against the
original running side by side, it had background-color rgba(19,1,1,0.3) and a
2px outset border where the original has rgba(0,0,0,0) and 0px none — the
user-agent's disabled-button chrome showing through.
The cause is structural, not a mistranslation. TeamCard.module.scss has a
top-level bare `button { }` rule; Vite does not hash element selectors in CSS
Modules and bundles every module into one stylesheet for the SPA, so that rule
was live on every page of the original. Next code-splits CSS per route, so once
ported it only loaded where TeamCard did — /Team and /profile/members — and
buttons everywhere else lost their reset.
Declared in app/globals.scss, which reproduces the original cascade. At
specificity 0-0-1 every component's own class rules still win: verified by
diffing all three buttons on /Login between the two apps, identical background,
font-size, margins and box sizes, and the resend button on /otp now matches the
original on every property (transparent, 0px none, 14.4px, 16px margin-top,
126x19).
Ten other top-level :global(...) rules sit in the same trap and are listed in
MIGRATION.md. They are left alone deliberately — three are :global(*) and would
move layout on every page, so they want reviewing one at a time rather than
hoisting wholesale.
Separately, App.jsx renders <Chatbot /> above <Routes>, so it is on every route.
The port mounted it in the (main) layout, hiding it on /Login, /SignUp, /otp,
/ForgotPassword and /completeProfile. Moved to the root layout, inside Providers
since it reads AuthContext. Confirmed in the server-rendered HTML for all six
routes, and the toggle measures 72x72 on /Login in both apps.
typecheck clean, eslint 0 errors, build 68 routes.
* Fix the password reset, and two more request-body contract breaks
/otp rejected a correct code with "Email, otp and password are required".
OtpInput.jsx posts { newPassword, confirmPassword, otp, email }, matching
Express; this handler destructured `password`, so the check failed before the
code was ever looked at.
Restored the rest of that controller's contract too, which had been simplified
away: 400 "Missing fields." when any of the four is absent, 409 on a
confirm-password mismatch, 404 "User not found!" (Express's checkAccess looks
the account up by the body's email, which is also why the endpoint works without
a session), 400 when the new password equals the old one, and
200 { status: "OK", message: "Password has been changed successfully !!" }.
This was the third contract break found by report rather than by testing, so
npm run audit:contracts now walks every api.*() call in the components, resolves
it to its Route Handler and flags payload keys the handler never reads. It found
two more live ones:
renameTeam UI sends newTeamName, handler read teamName
sendJoinRequest UI sends teamRegistrationId, handler read teamCode
Every rename failed on an empty name and every join request was rejected. Both
now match Express, including sendJoinRequest identifying the target by
registration row id — the value searchTeams already returns to the UI as
teamRegistrationId — with the explicit team.formId !== formId check that a
global row id makes necessary.
The audit also surfaced that the admin certificate tooling calls five endpoints
that do not exist here (getEvent, getEventByFormId, createOrganisationEvent,
sendBatchMails, sendCertViaEmail), out of eleven missing overall. That is a gap
rather than a regression — it was never built — and is documented in
MIGRATION.md rather than fixed in passing, since the certificate flow already
carries a deliberate architectural deviation.
typecheck clean, eslint 0 errors, build 68 routes.
* Fix useSearchParams destructuring, which crashed two pages
React Router returns [params, setParams]; Next returns the params object itself.
Two components outside the team feature kept the array destructuring:
EventForm.jsx crashed opening the event registration form
VerifyCertificate.jsx crashed opening certificate verification
ReadonlyURLSearchParams is iterable, so destructuring it as an array does not
throw — it quietly yields the first [key, value] entry, or undefined when the
URL has no query string. The next .get() then fails with "Cannot read
properties of undefined (reading 'get')".
EventForm is the one that hid: it reads searchParams.get("teamCode") during
render, but ProtectedRoute redirects signed-out visitors first, so an anonymous
smoke test returns 200 and only a signed-in user reaches the crash.
Swept the tree for the rest of the React Router surface (useNavigate,
useLocation, Outlet, Navigate, to=): only comments remain.
TeamManagement.jsx has the same bug and is fixed on feature/team-member-fix,
where the rest of the team work lives.
* Add a health endpoint for production monitoring
GET|HEAD /api/health returns 200 when every dependency answers and 503 when one
does not. There was no equivalent in the Express backend, so uptime monitors had
to be pointed at a real endpoint like /api/form/getAllForms — which reads the
whole forms collection on every poll and still answers 200 when the database is
unreachable but the response is cached.
The database check uses MongoDB's `ping`, the cheapest command the driver
exposes: it touches no collection and reads no documents, so polling costs
nothing.
It is bounded at 3s. A hung TCP connection to Atlas does not reject — it waits
for the driver's own much longer timeout — and a probe that hangs reads to a
monitor as a timeout rather than a clean 503.
The endpoint is unauthenticated (proxy.ts already excludes /api/), so the error
body carries only "unreachable" or the timeout message. Driver errors can embed
the connection string, which holds the Atlas password; the detail goes to the
server log instead.
HEAD is exported explicitly because Next does not derive it from GET for route
handlers, and a monitor configured for HEAD would otherwise get 405 and report
the site as down.
force-dynamic + no-store: a health check answering from build output or a cache
would report "ok" for a process that is not actually serving.
Verified both paths against the real database: 200 with a 36ms ping, and 503 in
3.03s with DATABASE_URL pointed at an unreachable host (HEAD likewise 503).
* Rebuild team management against the Express controllers
The five components were already ported line for line; the backend behind them
was not. It had been written against a different data model, so most of the page
did not work.
The model: a team is one formRegistration row holding every member's address in
regTeamMemEmails and every member's answers in value, with userId as the leader.
Leaving or being removed lifts a person's entries out of that row into their own
UNAFFILIATED row, so they stay registered and can join or start another team.
The port assumed a row per member sharing a teamCode, earliest row as leader.
That assumption produced most of the faults:
teamDetails looked up by userId, so only the leader could load the page.
Confirmed on live data: the ownership query returns NULL for a
real member of team "ABC"; the membership query finds it.
teamDetails returned registrationId/regTeamMemEmails/isLeader, while the UI
reads eventTitle, leaderEmail, max/minTeamSize,
isRegistrationClosed, isEventPast and data.isTeamless — and
members lacked college and year.
teamDetails had no UNAFFILIATED branch, so TeamlessState — the entire
create/join flow — could never render.
searchTeams returned a flat array of {teamName,teamCode,size,maxSize,isFull}
and read ?q=, where the picker reads data.data.teams with
teamRegistrationId/teamSize/maxTeamSize/leaderName/
spotsRemaining/hasPendingRequest and sends ?search=. The list
rendered empty and the search box filtered nothing.
leaveTeam keyed on userId with no leader/member distinction: a member
could not leave, answers were not carried across, and the
tracker's regTeamNames was never released.
removeTeamMember assumed row-per-member, so removal failed and no email went
out.
Also restored: leaveTeam blocks a leader who still has members; the
closed-registration guard compares the flags as the strings "true"/"false" the
data actually stores; joinTeam returns eventId (falling back to formId when
relatedEvent is absent or the literal "null"); joinRequestUpdates returns
pendingCount; the removed-member email is sent, following
emailTemplates/removedMember.html. Every success message matches the original,
since the components put response.data.message straight into a toast.
Verified against the live database, read paths only: teamDetails as leader and
as member return identical full payloads, the teamless branch returns
isTeamless with the event's limits, searchTeams returns data.teams with every
field the picker needs and ?search= filters correctly, inviteLink and
joinRequestUpdates match.
The mutations are deliberately not exercised: this database holds real
registrations and running them would rewrite other people's teams. They are
matched to the controllers line by line and typecheck clean, but want a
run-through on a scratch database before release.
typecheck clean, eslint 0 errors, build 68 routes, audit:contracts clean for
every team endpoint.
* Fix the useSearchParams destructuring that crashed the team page
TeamManagement.jsx carried React Router's [params, setParams] destructuring.
Next returns the params object itself, so opening any team page failed with
"Cannot read properties of undefined (reading 'get')".
ReadonlyURLSearchParams is iterable, so destructuring it as an array does not
throw — it quietly yields the first [key, value] entry, or undefined when the
URL has no query string. The next .get() is what fails.
TeamManagement also cleaned the URL after an email-redirect toast by mutating
the params and calling the setter. Next's object is read-only with no setter,
so that is now a copy plus router.replace(..., { scroll: false }) — the toast
still does not re-fire on refresh and no history entry is added.
EventForm.jsx and VerifyCertificate.jsx had the same bug from the base
migration; they are fixed on auth-redirect-fix since neither is team work.
Verified in the browser against live data: the team page renders in full for a
non-leader member (team, code, 2/3 members with year and college, "You" marker,
Leave Team) and for the leader (invite panel, share link, per-member remove);
?toast=joined&name=... is consumed and stripped from the URL; console clean on
both.
* Only build invite links from a trusted origin
inviteLink is built from the request rather than hardcoded, so production
traffic already produced production URLs — the localhost in a dev screenshot is
dev's own origin. But the origin was taken verbatim, and these URLs go into
email: the team invitation, and the accept/reject buttons sent to a team leader.
Origin and Host are set by the caller. Reflected unchecked, anyone able to
create a team could have FED KIIT send a message, from its own address,
containing a link to a domain they chose. Measured before the change, a request
carrying `Origin: https://evil.example` came back with
`https://evil.example/Events/.../Form?teamCode=...`.
An origin is now trusted when it matches NEXT_PUBLIC_SITE_URL, or is localhost
so a developer's copied link still works locally; anything else falls back to
NEXT_PUBLIC_SITE_URL. After the change:
Origin: https://www.fedkiit.com -> https://www.fedkiit.com/Events/...
Origin: http://localhost:3999 -> http://localhost:3999/Events/...
Origin: https://evil.example -> falls back to the real host
Host: evil.example -> https://www.fedkiit.com/Events/...
The Express controller reflected these headers
(req.headers.origin || process.env.FRONTEND_URL || "https://fedkiit.com"), so
this is a deliberate deviation, noted in MIGRATION.md.
typecheck clean, eslint 0 errors, build 68 routes.
* Carry team invite links through sign-in and sign-up
Clicking an invite while signed out sent you to the login page and then dropped
you on the team-finding page instead of joining the team.
The auto-join was never the problem — it was ported and works. The destination
was thrown away during authentication, in four places:
Login.jsx "Sign Up" link overwrote prevPage with /Login, discarding the
invite ProtectedRoute had just saved
SignUP.jsx router.push("/") after signing up
GoogleSignup.jsx cleared prevPage, then resolved to /profile
CompleteProfile.jsx router.push("/")
So the very case an invite exists for — someone without an account — lost it
before signing up. All four now resolve through postAuthRedirect(), which takes
a fallback argument so the signup screens still default to "/" as before.
Measured through the real UI with the login and join calls stubbed, so nothing
was written to the database:
open invite signed out -> /Login, prevPage = /Events/<id>/Form?teamCode=...
click "Sign Up" -> prevPage unchanged (previously became "/Login")
sign in -> POST /api/auth/login
POST /api/form/joinTeam {formId, teamCode}
-> /Events/<id>/team
Email and WhatsApp links carry the same ?teamCode=, so both behave identically.
A brand-new account still fills in the event's registration form first: team
membership is a formRegistration row, so there is nothing to move onto a team
until the person has registered, and that form is where required details and
payment are collected. The invite is carried through it — EventForm passes the
code to PreviewForm, which joins the team as soon as registration succeeds.
typecheck clean, eslint 0 errors, build 68 routes.
* Tell people when an invite link's auto-join fails
PreviewForm joins the invited team the moment registration succeeds. When that
join was rejected it only logged to the console and fell through to
router.push("/Events") — the person ended up on the events listing, registered
but teamless, with nothing on screen explaining that the invite had not been
honoured.
Two paths were silent, not one. Besides the catch, a 200 carrying
success: false also fell through untouched. Both now redirect to
/Events/<formId>/team?toast=join_failed&reason=<api message>
The team page is the right destination: they are registered by then, just
unaffiliated, so TeamManagement renders TeamlessState — the team search. They
land on the screen that lets them recover instead of the events listing.
The reason is forwarded rather than hard-coded, because the same path catches
four different rejections from joinTeam: "This team is full", "Invalid team
code", "Registration is closed for this event" and "You are already in a team".
TeamManagement appends "You can join another team below." and strips reason from
the URL alongside toast and name, so the toast does not re-fire on refresh.
Also dropped a dead `const eventId` in the success branch — it was computed and
never read; the redirect uses form.id directly.
The same silent fall-through is in FED-Frontend PreviewForm.jsx:191-194, so the
behaviour is inherited, not a port defect.
Not exercised against the database: joinTeam is a mutation and the configured
Atlas instance holds real registrations. eslint 0 errors, build clean.
* Fix four team-mutation defects found by running them
The mutations had only ever been matched to the controllers by eye. Running them
end to end against the database with two real accounts found four defects.
joinTeam failed outright with a 500. formRegistration carries
@@unique([formId, teamCode]): the model is one row per TEAM, holding the whole
roster in regTeamMemEmails, not one row per member. The port stamped the team's
code onto the joiner's own row, colliding with the team row on that constraint,
so every join died with a Prisma P2002. Joining is a merge — the joiner's email
and value entry move onto the team row and their solo row is deleted. This is
what the entire invite-link flow depends on, so that flow could not have worked.
respondJoinRequest failed the same way for the same reason, so a leader
accepting from their email got the generic error page and the request stayed
PENDING for ever. It also now handles the requester having joined elsewhere in
the meantime, and a team that filled up, as expiries rather than errors.
renameTeam had no leader check at all — any member could rename the team. The
controller looks the row up by membership and compares its owner to the caller;
this loaded the caller's own row and compared nothing. Also missing: the
registration-closed check, the "name unchanged" no-op, and the tracker's name
swap, so a rename left the old name reserved for ever.
Team codes were built from a slug of the team name (CLAUDETE-7130) instead of
the controller's <2-letter event code>-<3-digit index>-<4 digits> (AR-003-8793).
Every code already in the database uses the latter and people share them by
hand, so the wrong shape makes a valid code look fake.
Two messages were wrong with them: createTeam used a commented-out v1 string
from addRegistration.js rather than the live one, and renameTeam invented its
own. Both now match the controllers.
One deliberate deviation kept: the controller lets a teamless registrant rename
their own UNAFFILIATED placeholder, producing a named "team" with a SOLO- code
and absent from the tracker. Guarded here.
Verified on the "Team Test" event with two accounts belonging to the repo owner,
restored to their original state afterwards; no team containing anyone else was
touched. 20/20 asserted steps pass across createTeam, duplicate name,
searchTeams, joinTeam (valid/invalid/already-joined), renameTeam
(leader/non-leader/unchanged), inviteTeamMember, removeTeamMember, leaveTeam
(member and leader-last), sendJoinRequest and respondJoinRequest (accept and
replay). Both accounts end TEAMLESS with no leftover PENDING requests.
typecheck clean, eslint 0 errors, build 68 routes.
* Distinguish renaming a team from replacing one
A pending join request survived the dissolution of the team it pointed at, and
came back to life the moment that leader created their next team. A leader could
disband "ABC", create "BCD", and the person who asked to join ABC would be pulled
into BCD without ever agreeing to it.
teamRegistrationId cannot tell the two apart: the registration row is reused, so
its id survives a rename and a disband alike. Confirmed against the database
rather than assumed — after a disband and a fresh createTeam, the request's
teamRegistrationId still equals the new team's row id.
teamCode is the discriminator. A rename leaves it untouched; disbanding resets it
to a SOLO- code and the next createTeam mints a fresh one. teamJoinRequest now
pins the code at the time of asking, and acceptance compares it:
renames ABC -> BCD code unchanged request accepted, link works
disbands ABC, creates BCD code new request auto-expired, link 404
The invite-link path already had the right semantics, since those links carry
?teamCode=, but it was verified rather than taken on trust.
The field is optional: rows written before it existed carry no code, and a
request that cannot be verified is treated as stale. There were 45 requests in
the database and none pending when this shipped, so nothing was grandfathered.
MongoDB needs no migration for an optional scalar with no index — prisma
generate is enough, no db push against production.
Verified on the live database, 9/9 assertions across all three cases, plus the
14-step lifecycle re-run to confirm nothing regressed. Both test accounts end
TEAMLESS with no leftover PENDING requests. typecheck clean, eslint 0 errors,
build 68 routes.
* Regenerate the Prisma client automatically when the schema changes
The generated client lives in node_modules/.prisma/client, which is gitignored,
so it never travels with a commit. @prisma/client has its own postinstall hook,
but npm only fires that during an install — and a schema change on its own does
not touch package.json, so nobody has a reason to reinstall.
That combination is the trap: pulling a branch that changed schema.prisma leaves
a client that no longer matches it, the app starts perfectly, and the mismatch
only surfaces as "Unknown argument" on whichever request happens to touch the new
field. npm run build and npm run typecheck do fail loudly, but npm run dev does
not type-check up front, so the usual workflow hides it. The teamCode field added
for the rename-vs-disband fix is the first change with this hazard.
scripts/ensure-prisma.mjs runs the check where it cannot be skipped: with-env.mjs
calls it, and dev, build and start all go through that.
It regenerates only when the schema actually changed, comparing a SHA-256 of
schema.prisma against a stamp written beside the generated client. Generating
unconditionally would add seconds to every dev start; hashing one file is
imperceptible, so the common case is free. The stamp sits inside the client
directory deliberately — deleting node_modules takes it along, so a wiped install
cannot look up to date — and is written only after a successful generate, so a
failure retries instead of marking a client that was never produced as current.
postinstall runs the same script with --optional, which tolerates a missing CLI:
prisma is a devDependency, so npm install --omit=dev on a deploy host
legitimately has none and failing there would break the install. A dev server
without one is not legitimate, so that path leaves the flag off and fails.
npm run prisma:generate is the manual escape hatch.
package-lock records hasInstallScript for the new hook, and npm re-resolved the
Prisma engines from dev to devOptional.
Verified all three paths: no stamp generates; unchanged schema is silent with no
generate spawned; a changed schema is detected and regenerated, both standalone
and through a real npm run dev, which then served /api/health 200. npm install
still exits 0. eslint clean, typecheck clean, build 68 routes.
* Fix Google sign-in, which rejected every request
Two defects stacked, both introduced by this port.
The body field never matched. GoogleLogin.jsx and GoogleSignup.jsx post
{ access_token }, as they always did against Express. The route read
credential || token || tokenId, matched none of them, and returned 400 "Google
credential is required" before Google was contacted at all. Google sign-in and
sign-up therefore failed for everyone, every time.
The token type was wrong underneath. The route handed the value to
OAuth2Client.verifyIdToken, which expects an ID token (a JWT). Neither component
produces one: both call useGoogleLogin with no flow option, which is the implicit
flow, and its response carries an opaque access token. Verifying it locally is
not possible — it is not a JWT and carries no claims. Handing it back to Google
is what establishes whose it is, which is what the controller does, via
oauth2/v3/userinfo.
Matched now, with one deliberate difference: the token goes in an Authorization
Bearer header rather than the query string. Same endpoint and response, but a
credential in a URL ends up in proxy and server logs.
Also restored from the controller and previously missing: the display name is
built from given_name + family_name before falling back to name, and an
hd === "kiit.ac.in" account gets college, rollNumber, year and school derived
from the roll number. That derivation was checked against the controller's
inline version across six roll numbers spanning 1st year to Passout — identical
on all six.
Error mapping: Google answering 401/403 becomes a 401, since a bad token is the
caller's problem and not a server fault; any other non-OK becomes 502; a
transport failure becomes 503. Previously an unhandled throw would have surfaced
as a 500.
Status codes follow the controller: 201 when the account was just created, 200
otherwise, message "LOGGED IN" in both cases. getEnv and the google-auth-library
import are dropped — the access-token flow needs no client id server-side.
Verified against the live Google endpoint: empty body gives 400 "Missing fields:
access_token"; an invalid access_token gives 401, proving the request now
reaches Google instead of being turned away at the door. A full popup sign-in
needs a browser and was not automated.
typecheck clean, eslint 0 errors, build 68 routes.
* Fixed all the comments above namely, image dimensions in one file, role centralization, form analytics access, otp length and validity, trusted origins, Preview Form.
---------
Co-authored-by: Ansh Raj <146376691+AnshRaj112@users.noreply.github.com>
…expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 44 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (68)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…le data (#15) * FED KIIT Clone in Next.js Pixel-for-pixel port of FED-Frontend (React 18 + Vite) and FED-Backend (Express) into a single Next.js 16 App Router application, running against the same MongoDB. The original SCSS modules are carried over verbatim (84 stylesheets, 24 image assets) and components keep their original markup and class names, which is what makes the replica indistinguishable from the original rather than merely similar. URLs keep the original casing (/Events, /Team, /Login). Verified against both apps running side by side: - /Events and /Team render at identical total page height (6109px, 9461px) with matching element counts and computed styles - All five public API responses are byte-identical to the Express server - typecheck clean, eslint 0 errors, build 68 routes, npm audit 0 vulns Includes all 48 Express endpoints, ported as Route Handlers preserving the original JSON contracts. Security fixes (npm audit 44 -> 0): - xlsx moved to the official SheetJS distribution (prototype pollution) - react-share-social removed for a local react-share component (-530 pkgs) - postcss and sharp pinned to patched versions via overrides - JWT_SECRET was 4 characters; replaced with 64 bytes of CSPRNG entropy, which rotates existing sessions - BCRYPT_SALT_ROUNDS held a bcrypt salt string rather than a cost factor - contact endpoint now validates email format and message length See MIGRATION.md for the full record, including known issues. * home page * Fix: redirect after sign-in on the auth pages The original never navigated from inside Login.jsx — it called authCtx.login(...) and let the React Router table react: <Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} /> App Router routes are files, so nothing observed isLoggedIn, and proxy.ts only runs on a server request — which a client-side sign-in never makes. A correct login showed "Login successful" and then sat on /Login forever. AuthRouteGuard reinstates the guard for all five auth routes, honouring both the original sessionStorage.prevPage and the ?next= the proxy appends. It rejects protocol-relative return URLs, and cannot ping-pong with the proxy when localStorage holds a session the cookie no longer backs. * Move the post-sign-in redirect into the auth components The layout-level guard added in 1b17715 was the wrong shape. SignUp and CompleteProfile sign the user in and then navigate themselves to "/", and a guard reacting to isLoggedIn cancels that in-flight router.push before it commits — measured on the signup flow, the push never reached history and a new account landed on /profile instead of "/". No delay fixes that reliably, since the push only commits once its RSC payload arrives. Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the shouldNavigate/navigatePath state and the effect that acts on it — dead in the original precisely because the route table did the job. Setting setShouldNavigate(true) after authCtx.login(...) brings it to life, with no cross-component race. SendOtp.jsx already did this and is unchanged. postAuthRedirect() resolves the destination the way LoginRedirect did, plus the ?next= proxy.ts appends, discarding anything that is not a plain internal path. Verified against the running server, signed out and signed in: all five auth pages, all seven /profile pages, casing redirects, a tampered cookie (rejected and cleared), and the seven auth endpoints on an empty body. Two pre-existing gaps documented in MIGRATION.md rather than silently changed: /ForgotPassword submits natively (identical in the original), and /profile/members and /profile/BlogForm are not access-gated in the UI. * Fix the redirect after sign-in A correct login showed "Login successful" and then sat on /Login forever. App.jsx never navigated from inside Login.jsx — it called authCtx.login(...) and let the route table react: <Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} /> App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only runs on a server request, which a client-side sign-in never makes. The redirect belongs in the components rather than in a layout wrapper. A guard in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong: SignUp and CompleteProfile sign the user in and then navigate themselves to "/", and a layout guard cancels that in-flight router.push before it commits. Measured on the signup flow, the push never reached history at all and a new account landed on /profile instead of "/". No delay fixes that reliably, since the push only commits once its RSC payload arrives. Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the shouldNavigate/navigatePath state and the effect that acts on it — dead in the original precisely because the route table did the job. Setting setShouldNavigate(true) after authCtx.login(...) brings it to life, with no cross-component race. SendOtp.jsx already did this and is unchanged. postAuthRedirect() resolves the destination the way LoginRedirect did, plus the ?next= proxy.ts appends. That value comes off the query string and is therefore attacker-supplied, so anything that is not a plain internal path is discarded. Verified in the browser by driving the real forms with the API stubbed: Login /Login -> /profile Login /Login?next=/Events -> /Events Login /Login?next=//example.com/phish -> /profile, origin preserved Login blocked page -> login -> back to it, prevPage cleared Signup /SignUp -> /, matching the original Login stale localStorage, no cookie -> login form, one bounce, no loop Also verified every auth route against the running server, signed out and signed in: all five auth pages, all seven /profile pages, the lowercase casing redirects, a tampered cookie (rejected and cleared), and the seven auth endpoints on an empty body. Two pre-existing gaps are documented in MIGRATION.md rather than silently changed: /ForgotPassword submits natively (identical in the original), and /profile/members and /profile/BlogForm are not access-gated in the UI. typecheck clean, eslint 0 errors, build 68 routes. * Halve the client bundle and fix the SSR hydration errors The landing page shipped 2.3 MB of JavaScript. Home.jsx imported its four sections from the `sections` barrel, which also re-exports `sections/Profile` — the whole admin panel. All of them are client components, so the bundler pulled that entire graph onto the landing page: certificate tooling, admin tables, the avatar editor, event analytics. The `features` barrel did the same for LiveEventPopup. Under Vite this cost nothing noticeable; under Next, where each route gets its own bundle, a barrel import silently undoes the code splitting. Importing those four components directly, leaving the barrels in place for other call sites: / 2317 KB -> 1082 KB /Events 2063 KB -> 1082 KB /Team 2014 KB -> 1082 KB /Login 1248 KB -> 920 KB Over the wire the landing page is now 327 KB of JS; locally TTFB 38ms, DOMContentLoaded 135ms, load 536ms. EventCard wrapped its meta row in a <p> containing div.price, which contained another <p>. Client-rendered that was invisible, because React builds the DOM node by node and nothing reparents it. Server-rendered it is real markup, so the parser closed the <p> early and div.price came out a sibling instead of a child — a different layout, reported as a hydration mismatch on every card. The wrapper is a <div className={style.meta}> and the two `.eventname p` rules list `.eventname .meta` alongside, so computed styles are unchanged: verified in the browser at font-size 14.4px, display flex, align-items center, margin-top 1.6px with div.price a real child, console clean across 580 cards. Also: darken() -> color.adjust() in VerifyCertificate.module.scss, confirmed identical by compiling both and diffing (rgb(80%, 43.2941176471%, 0%) either way), which clears the Dart Sass deprecation warnings; and <html> now carries data-scroll-behavior="smooth" to acknowledge the globals.scss rule. The <Fit /> warnings are left alone — react-fit is a transitive dependency of react-date-picker and uses the `warning` package, a no-op in production. typecheck clean, eslint 0 errors, build 68 routes, all pages 200, auth route matrix unchanged. * Fix the remaining SSR hydration errors, and add an audit that finds them Four components wrapped block-level content in a <p>: EventCard and EventModal (<p> -> div.price -> <p>), Hero (<p> -> <span> -> <h3>) and Social (<p> -> div.fed). Client-rendered under Vite none of it mattered, because React builds the DOM node by node and nothing reparents an existing tree. Server-rendered it is real markup, so the parser closes the <p> at the first block child and the content lands as a sibling — a different layout, reported as a hydration mismatch. EventCard was fixed in 9a1073e; this covers the other three. Each wrapper is a <div> carrying a class listed alongside the original `p` selector, so computed styles are unchanged. Verified in the browser: EventModal .meta 14.4px / flex / center / 1.6px / #fff Hero .tagline 39.2px / 700 / #fff Social .content 40px / 600 / #fff / center Hero keeps its <h3> rather than downgrading it to a span — the wrapper changed instead, so the heading still counts as a heading. Social's `styles.content` had no rule in the stylesheet at all, so the className resolved to undefined and the element was styled purely by `.text p`; `.content` now exists and carries those declarations. Fixing Social surfaced a second, unrelated mismatch on that page: react-social-media-embed mints a fresh UUID per render into both `id` and `className`, and the embed sizes come from useDimensions(), which measures 0 on the server. Both embeds now load via next/dynamic with ssr: false — nothing is lost, since the visible post is drawn by Instagram's and LinkedIn's own scripts after mount. Same four embeds, same 1674px page height, console clean. npm run audit:nesting keeps this from recurring. It walks a tag stack through every JSX file and reports anything the HTML parser would reparent, skipping comments and string/regex literals — without that a comment mentioning <p>, or `.replace(/<a\s[^>]*>/gi, '')`, is read as markup. It was validated against the pre-fix files: it reports all four real cases and nothing for the two files that produced false positives. Current tree: 153 files, zero findings. typecheck clean, eslint 0 errors, build 68 routes, no Sass deprecations. * Fix the admin calendar, and restore Express parity for forms and events The calendar on the admin form page had no styling at all. The original index.scss imported three vendor stylesheets and only one was carried over, so react-date-picker's popup — which it draws with react-calendar — came out as an unstyled column of numbers. Confirmed by grepping the emitted CSS: zero .react-calendar rules shipped, now 50. In the browser the popup measures 350x248 with a #a0a096 border, react-calendar's own defaults. Re-reading every form/event controller against its port turned up more: getFormAnalytics returned the wrong shape entirely. EventStats.jsx reads response.data.form.formAnalytics, .form.info and .yearCounts; the port returned { success, message, data }, so response.data.form was undefined and the admin analytics panel threw. It now returns { message, form, yearCounts }, rebuilds the yearCounts histogram, and restores the 404 for a form nobody has registered to. Access is the controller's own allowlist (ADMIN/PRESIDENT/VICEPRESIDENT/ DIRECTOR_*) plus the srex@fedkiit.com escape hatch, answering 401 "Access Denied", not the isMember test the port had invented. The attendance flow did not work. The QR carries a signed JWT: attendanceCode must return { message, attendanceToken } with a 20-minute expiry, and markAttendance takes { formId, token } and verifies it. The port returned the raw record id under a different key, so QRCodeModal generated no QR, and markAttendance rejected the scanned JWT for not being a 24-character id. Both now match, including the formId binding that stops one event's QR checking someone in at another, and the ?teamCode= branch. Verified both directions: a token minted by the original's jsonwebtoken call verifies with jose here, and vice versa; tampered tokens are rejected. Access levels corrected both ways: export-attendance is ADMIN again (was any club member, which handed every executive the full attendee list), while markAttendance drops to signed-in, matching the Express route whose checkAccess is commented out — the door volunteer signs in as a plain USER, so requiring member access locked the door staff out. Cloudinary dimensions were wrong: addForm/editForm resize FormImages at h350.67 x w196.37 and QR media at h400 x w150 and h150 x w400 respectively, not the 1000x1000 / 500x500 the port used. The two QR rows are transposed against each other in the original and are reproduced per call site rather than reconciled. addForm also returns 200 "Form created successfully" and editForm "Form info and sections updated successfully". One divergence kept deliberately: Express's addForm computes Boolean(isPublic) || false over a multipart field, and Boolean("false") is true, so every event created through the admin form was public, registration-closed and past regardless of the toggles. editForm already used === "true". The port uses editForm's form in both so the switches work; restoring byte-parity would re-break them. Documented in MIGRATION.md along with checkAccess("USER") being stricter in Express than here. typecheck clean, eslint 0 errors, build 68 routes, nesting audit clean. * Revamp UI system and add certificate APIs Introduces a broad UI refresh across auth, events, team, profile, blog, chatbot, and modal surfaces using shared design tokens, updated layouts, and improved accessibility/interaction patterns (including a reusable CloseButton and new event artwork/disclosure components). Adds certificate backend support with new routes (`createOrganisationEvent`, `getEventByFormId`, `myCertificates`) and matching service logic, plus related flow fixes (team management query cleanup, event/certificate lookup behavior) and `prisma generate` on postinstall. * Restore and commit all local modifications and files after merge * fix qr payload * duplicate handling * attendance qr issues fixed * Home Page Revamped with Navbar * Send 4-digit OTPs again, so they fit the four boxes on screen The password-reset email carried a 6-digit code while every OTP screen renders four boxes, so the code could not be entered at all. The reset flow and signup share components/OtpInput, so both were affected. The UI was not at fault — it is four boxes in the original too, character for character. This port had raised OTP_LENGTH from 4 to 6 as hardening, without a consumer that could accept six. Reverted to 4, matching generateOtp(4, false, false, false). The argument for 6 does not hold here: 10,000 combinations are brute-forceable against the Express backend, which had no throttling at all, but RATE_LIMITS.passwordReset allows 6 attempts per 15 minutes and a code expires after 15, so an attacker gets at most 6 guesses out of 10,000 before the code stops existing. The rest of the OTP hardening is untouched: SHA-256 digest at rest, constant-time comparison, single-use, and expiry derived from createdAt rather than a setTimeout that never fires on a serverless host. Codes issued before this change are 6 digits and cannot be entered; they expire on their own within 15 minutes. typecheck clean, eslint 0 errors, build 68 routes. * Restore the global button reset, and put the Chatbot back on every route The disabled "Resend OTP" button rendered with a grey box. Measured against the original running side by side, it had background-color rgba(19,1,1,0.3) and a 2px outset border where the original has rgba(0,0,0,0) and 0px none — the user-agent's disabled-button chrome showing through. The cause is structural, not a mistranslation. TeamCard.module.scss has a top-level bare `button { }` rule; Vite does not hash element selectors in CSS Modules and bundles every module into one stylesheet for the SPA, so that rule was live on every page of the original. Next code-splits CSS per route, so once ported it only loaded where TeamCard did — /Team and /profile/members — and buttons everywhere else lost their reset. Declared in app/globals.scss, which reproduces the original cascade. At specificity 0-0-1 every component's own class rules still win: verified by diffing all three buttons on /Login between the two apps, identical background, font-size, margins and box sizes, and the resend button on /otp now matches the original on every property (transparent, 0px none, 14.4px, 16px margin-top, 126x19). Ten other top-level :global(...) rules sit in the same trap and are listed in MIGRATION.md. They are left alone deliberately — three are :global(*) and would move layout on every page, so they want reviewing one at a time rather than hoisting wholesale. Separately, App.jsx renders <Chatbot /> above <Routes>, so it is on every route. The port mounted it in the (main) layout, hiding it on /Login, /SignUp, /otp, /ForgotPassword and /completeProfile. Moved to the root layout, inside Providers since it reads AuthContext. Confirmed in the server-rendered HTML for all six routes, and the toggle measures 72x72 on /Login in both apps. typecheck clean, eslint 0 errors, build 68 routes. * Fix the password reset, and two more request-body contract breaks /otp rejected a correct code with "Email, otp and password are required". OtpInput.jsx posts { newPassword, confirmPassword, otp, email }, matching Express; this handler destructured `password`, so the check failed before the code was ever looked at. Restored the rest of that controller's contract too, which had been simplified away: 400 "Missing fields." when any of the four is absent, 409 on a confirm-password mismatch, 404 "User not found!" (Express's checkAccess looks the account up by the body's email, which is also why the endpoint works without a session), 400 when the new password equals the old one, and 200 { status: "OK", message: "Password has been changed successfully !!" }. This was the third contract break found by report rather than by testing, so npm run audit:contracts now walks every api.*() call in the components, resolves it to its Route Handler and flags payload keys the handler never reads. It found two more live ones: renameTeam UI sends newTeamName, handler read teamName sendJoinRequest UI sends teamRegistrationId, handler read teamCode Every rename failed on an empty name and every join request was rejected. Both now match Express, including sendJoinRequest identifying the target by registration row id — the value searchTeams already returns to the UI as teamRegistrationId — with the explicit team.formId !== formId check that a global row id makes necessary. The audit also surfaced that the admin certificate tooling calls five endpoints that do not exist here (getEvent, getEventByFormId, createOrganisationEvent, sendBatchMails, sendCertViaEmail), out of eleven missing overall. That is a gap rather than a regression — it was never built — and is documented in MIGRATION.md rather than fixed in passing, since the certificate flow already carries a deliberate architectural deviation. typecheck clean, eslint 0 errors, build 68 routes. * Fix useSearchParams destructuring, which crashed two pages React Router returns [params, setParams]; Next returns the params object itself. Two components outside the team feature kept the array destructuring: EventForm.jsx crashed opening the event registration form VerifyCertificate.jsx crashed opening certificate verification ReadonlyURLSearchParams is iterable, so destructuring it as an array does not throw — it quietly yields the first [key, value] entry, or undefined when the URL has no query string. The next .get() then fails with "Cannot read properties of undefined (reading 'get')". EventForm is the one that hid: it reads searchParams.get("teamCode") during render, but ProtectedRoute redirects signed-out visitors first, so an anonymous smoke test returns 200 and only a signed-in user reaches the crash. Swept the tree for the rest of the React Router surface (useNavigate, useLocation, Outlet, Navigate, to=): only comments remain. TeamManagement.jsx has the same bug and is fixed on feature/team-member-fix, where the rest of the team work lives. * Add a health endpoint for production monitoring GET|HEAD /api/health returns 200 when every dependency answers and 503 when one does not. There was no equivalent in the Express backend, so uptime monitors had to be pointed at a real endpoint like /api/form/getAllForms — which reads the whole forms collection on every poll and still answers 200 when the database is unreachable but the response is cached. The database check uses MongoDB's `ping`, the cheapest command the driver exposes: it touches no collection and reads no documents, so polling costs nothing. It is bounded at 3s. A hung TCP connection to Atlas does not reject — it waits for the driver's own much longer timeout — and a probe that hangs reads to a monitor as a timeout rather than a clean 503. The endpoint is unauthenticated (proxy.ts already excludes /api/), so the error body carries only "unreachable" or the timeout message. Driver errors can embed the connection string, which holds the Atlas password; the detail goes to the server log instead. HEAD is exported explicitly because Next does not derive it from GET for route handlers, and a monitor configured for HEAD would otherwise get 405 and report the site as down. force-dynamic + no-store: a health check answering from build output or a cache would report "ok" for a process that is not actually serving. Verified both paths against the real database: 200 with a 36ms ping, and 503 in 3.03s with DATABASE_URL pointed at an unreachable host (HEAD likewise 503). * Rebuild team management against the Express controllers The five components were already ported line for line; the backend behind them was not. It had been written against a different data model, so most of the page did not work. The model: a team is one formRegistration row holding every member's address in regTeamMemEmails and every member's answers in value, with userId as the leader. Leaving or being removed lifts a person's entries out of that row into their own UNAFFILIATED row, so they stay registered and can join or start another team. The port assumed a row per member sharing a teamCode, earliest row as leader. That assumption produced most of the faults: teamDetails looked up by userId, so only the leader could load the page. Confirmed on live data: the ownership query returns NULL for a real member of team "ABC"; the membership query finds it. teamDetails returned registrationId/regTeamMemEmails/isLeader, while the UI reads eventTitle, leaderEmail, max/minTeamSize, isRegistrationClosed, isEventPast and data.isTeamless — and members lacked college and year. teamDetails had no UNAFFILIATED branch, so TeamlessState — the entire create/join flow — could never render. searchTeams returned a flat array of {teamName,teamCode,size,maxSize,isFull} and read ?q=, where the picker reads data.data.teams with teamRegistrationId/teamSize/maxTeamSize/leaderName/ spotsRemaining/hasPendingRequest and sends ?search=. The list rendered empty and the search box filtered nothing. leaveTeam keyed on userId with no leader/member distinction: a member could not leave, answers were not carried across, and the tracker's regTeamNames was never released. removeTeamMember assumed row-per-member, so removal failed and no email went out. Also restored: leaveTeam blocks a leader who still has members; the closed-registration guard compares the flags as the strings "true"/"false" the data actually stores; joinTeam returns eventId (falling back to formId when relatedEvent is absent or the literal "null"); joinRequestUpdates returns pendingCount; the removed-member email is sent, following emailTemplates/removedMember.html. Every success message matches the original, since the components put response.data.message straight into a toast. Verified against the live database, read paths only: teamDetails as leader and as member return identical full payloads, the teamless branch returns isTeamless with the event's limits, searchTeams returns data.teams with every field the picker needs and ?search= filters correctly, inviteLink and joinRequestUpdates match. The mutations are deliberately not exercised: this database holds real registrations and running them would rewrite other people's teams. They are matched to the controllers line by line and typecheck clean, but want a run-through on a scratch database before release. typecheck clean, eslint 0 errors, build 68 routes, audit:contracts clean for every team endpoint. * Fix the useSearchParams destructuring that crashed the team page TeamManagement.jsx carried React Router's [params, setParams] destructuring. Next returns the params object itself, so opening any team page failed with "Cannot read properties of undefined (reading 'get')". ReadonlyURLSearchParams is iterable, so destructuring it as an array does not throw — it quietly yields the first [key, value] entry, or undefined when the URL has no query string. The next .get() is what fails. TeamManagement also cleaned the URL after an email-redirect toast by mutating the params and calling the setter. Next's object is read-only with no setter, so that is now a copy plus router.replace(..., { scroll: false }) — the toast still does not re-fire on refresh and no history entry is added. EventForm.jsx and VerifyCertificate.jsx had the same bug from the base migration; they are fixed on auth-redirect-fix since neither is team work. Verified in the browser against live data: the team page renders in full for a non-leader member (team, code, 2/3 members with year and college, "You" marker, Leave Team) and for the leader (invite panel, share link, per-member remove); ?toast=joined&name=... is consumed and stripped from the URL; console clean on both. * Only build invite links from a trusted origin inviteLink is built from the request rather than hardcoded, so production traffic already produced production URLs — the localhost in a dev screenshot is dev's own origin. But the origin was taken verbatim, and these URLs go into email: the team invitation, and the accept/reject buttons sent to a team leader. Origin and Host are set by the caller. Reflected unchecked, anyone able to create a team could have FED KIIT send a message, from its own address, containing a link to a domain they chose. Measured before the change, a request carrying `Origin: https://evil.example` came back with `https://evil.example/Events/.../Form?teamCode=...`. An origin is now trusted when it matches NEXT_PUBLIC_SITE_URL, or is localhost so a developer's copied link still works locally; anything else falls back to NEXT_PUBLIC_SITE_URL. After the change: Origin: https://www.fedkiit.com -> https://www.fedkiit.com/Events/... Origin: http://localhost:3999 -> http://localhost:3999/Events/... Origin: https://evil.example -> falls back to the real host Host: evil.example -> https://www.fedkiit.com/Events/... The Express controller reflected these headers (req.headers.origin || process.env.FRONTEND_URL || "https://fedkiit.com"), so this is a deliberate deviation, noted in MIGRATION.md. typecheck clean, eslint 0 errors, build 68 routes. * Carry team invite links through sign-in and sign-up Clicking an invite while signed out sent you to the login page and then dropped you on the team-finding page instead of joining the team. The auto-join was never the problem — it was ported and works. The destination was thrown away during authentication, in four places: Login.jsx "Sign Up" link overwrote prevPage with /Login, discarding the invite ProtectedRoute had just saved SignUP.jsx router.push("/") after signing up GoogleSignup.jsx cleared prevPage, then resolved to /profile CompleteProfile.jsx router.push("/") So the very case an invite exists for — someone without an account — lost it before signing up. All four now resolve through postAuthRedirect(), which takes a fallback argument so the signup screens still default to "/" as before. Measured through the real UI with the login and join calls stubbed, so nothing was written to the database: open invite signed out -> /Login, prevPage = /Events/<id>/Form?teamCode=... click "Sign Up" -> prevPage unchanged (previously became "/Login") sign in -> POST /api/auth/login POST /api/form/joinTeam {formId, teamCode} -> /Events/<id>/team Email and WhatsApp links carry the same ?teamCode=, so both behave identically. A brand-new account still fills in the event's registration form first: team membership is a formRegistration row, so there is nothing to move onto a team until the person has registered, and that form is where required details and payment are collected. The invite is carried through it — EventForm passes the code to PreviewForm, which joins the team as soon as registration succeeds. typecheck clean, eslint 0 errors, build 68 routes. * Tell people when an invite link's auto-join fails PreviewForm joins the invited team the moment registration succeeds. When that join was rejected it only logged to the console and fell through to router.push("/Events") — the person ended up on the events listing, registered but teamless, with nothing on screen explaining that the invite had not been honoured. Two paths were silent, not one. Besides the catch, a 200 carrying success: false also fell through untouched. Both now redirect to /Events/<formId>/team?toast=join_failed&reason=<api message> The team page is the right destination: they are registered by then, just unaffiliated, so TeamManagement renders TeamlessState — the team search. They land on the screen that lets them recover instead of the events listing. The reason is forwarded rather than hard-coded, because the same path catches four different rejections from joinTeam: "This team is full", "Invalid team code", "Registration is closed for this event" and "You are already in a team". TeamManagement appends "You can join another team below." and strips reason from the URL alongside toast and name, so the toast does not re-fire on refresh. Also dropped a dead `const eventId` in the success branch — it was computed and never read; the redirect uses form.id directly. The same silent fall-through is in FED-Frontend PreviewForm.jsx:191-194, so the behaviour is inherited, not a port defect. Not exercised against the database: joinTeam is a mutation and the configured Atlas instance holds real registrations. eslint 0 errors, build clean. * Fix four team-mutation defects found by running them The mutations had only ever been matched to the controllers by eye. Running them end to end against the database with two real accounts found four defects. joinTeam failed outright with a 500. formRegistration carries @@unique([formId, teamCode]): the model is one row per TEAM, holding the whole roster in regTeamMemEmails, not one row per member. The port stamped the team's code onto the joiner's own row, colliding with the team row on that constraint, so every join died with a Prisma P2002. Joining is a merge — the joiner's email and value entry move onto the team row and their solo row is deleted. This is what the entire invite-link flow depends on, so that flow could not have worked. respondJoinRequest failed the same way for the same reason, so a leader accepting from their email got the generic error page and the request stayed PENDING for ever. It also now handles the requester having joined elsewhere in the meantime, and a team that filled up, as expiries rather than errors. renameTeam had no leader check at all — any member could rename the team. The controller looks the row up by membership and compares its owner to the caller; this loaded the caller's own row and compared nothing. Also missing: the registration-closed check, the "name unchanged" no-op, and the tracker's name swap, so a rename left the old name reserved for ever. Team codes were built from a slug of the team name (CLAUDETE-7130) instead of the controller's <2-letter event code>-<3-digit index>-<4 digits> (AR-003-8793). Every code already in the database uses the latter and people share them by hand, so the wrong shape makes a valid code look fake. Two messages were wrong with them: createTeam used a commented-out v1 string from addRegistration.js rather than the live one, and renameTeam invented its own. Both now match the controllers. One deliberate deviation kept: the controller lets a teamless registrant rename their own UNAFFILIATED placeholder, producing a named "team" with a SOLO- code and absent from the tracker. Guarded here. Verified on the "Team Test" event with two accounts belonging to the repo owner, restored to their original state afterwards; no team containing anyone else was touched. 20/20 asserted steps pass across createTeam, duplicate name, searchTeams, joinTeam (valid/invalid/already-joined), renameTeam (leader/non-leader/unchanged), inviteTeamMember, removeTeamMember, leaveTeam (member and leader-last), sendJoinRequest and respondJoinRequest (accept and replay). Both accounts end TEAMLESS with no leftover PENDING requests. typecheck clean, eslint 0 errors, build 68 routes. * Distinguish renaming a team from replacing one A pending join request survived the dissolution of the team it pointed at, and came back to life the moment that leader created their next team. A leader could disband "ABC", create "BCD", and the person who asked to join ABC would be pulled into BCD without ever agreeing to it. teamRegistrationId cannot tell the two apart: the registration row is reused, so its id survives a rename and a disband alike. Confirmed against the database rather than assumed — after a disband and a fresh createTeam, the request's teamRegistrationId still equals the new team's row id. teamCode is the discriminator. A rename leaves it untouched; disbanding resets it to a SOLO- code and the next createTeam mints a fresh one. teamJoinRequest now pins the code at the time of asking, and acceptance compares it: renames ABC -> BCD code unchanged request accepted, link works disbands ABC, creates BCD code new request auto-expired, link 404 The invite-link path already had the right semantics, since those links carry ?teamCode=, but it was verified rather than taken on trust. The field is optional: rows written before it existed carry no code, and a request that cannot be verified is treated as stale. There were 45 requests in the database and none pending when this shipped, so nothing was grandfathered. MongoDB needs no migration for an optional scalar with no index — prisma generate is enough, no db push against production. Verified on the live database, 9/9 assertions across all three cases, plus the 14-step lifecycle re-run to confirm nothing regressed. Both test accounts end TEAMLESS with no leftover PENDING requests. typecheck clean, eslint 0 errors, build 68 routes. * Regenerate the Prisma client automatically when the schema changes The generated client lives in node_modules/.prisma/client, which is gitignored, so it never travels with a commit. @prisma/client has its own postinstall hook, but npm only fires that during an install — and a schema change on its own does not touch package.json, so nobody has a reason to reinstall. That combination is the trap: pulling a branch that changed schema.prisma leaves a client that no longer matches it, the app starts perfectly, and the mismatch only surfaces as "Unknown argument" on whichever request happens to touch the new field. npm run build and npm run typecheck do fail loudly, but npm run dev does not type-check up front, so the usual workflow hides it. The teamCode field added for the rename-vs-disband fix is the first change with this hazard. scripts/ensure-prisma.mjs runs the check where it cannot be skipped: with-env.mjs calls it, and dev, build and start all go through that. It regenerates only when the schema actually changed, comparing a SHA-256 of schema.prisma against a stamp written beside the generated client. Generating unconditionally would add seconds to every dev start; hashing one file is imperceptible, so the common case is free. The stamp sits inside the client directory deliberately — deleting node_modules takes it along, so a wiped install cannot look up to date — and is written only after a successful generate, so a failure retries instead of marking a client that was never produced as current. postinstall runs the same script with --optional, which tolerates a missing CLI: prisma is a devDependency, so npm install --omit=dev on a deploy host legitimately has none and failing there would break the install. A dev server without one is not legitimate, so that path leaves the flag off and fails. npm run prisma:generate is the manual escape hatch. package-lock records hasInstallScript for the new hook, and npm re-resolved the Prisma engines from dev to devOptional. Verified all three paths: no stamp generates; unchanged schema is silent with no generate spawned; a changed schema is detected and regenerated, both standalone and through a real npm run dev, which then served /api/health 200. npm install still exits 0. eslint clean, typecheck clean, build 68 routes. * Fix Google sign-in, which rejected every request Two defects stacked, both introduced by this port. The body field never matched. GoogleLogin.jsx and GoogleSignup.jsx post { access_token }, as they always did against Express. The route read credential || token || tokenId, matched none of them, and returned 400 "Google credential is required" before Google was contacted at all. Google sign-in and sign-up therefore failed for everyone, every time. The token type was wrong underneath. The route handed the value to OAuth2Client.verifyIdToken, which expects an ID token (a JWT). Neither component produces one: both call useGoogleLogin with no flow option, which is the implicit flow, and its response carries an opaque access token. Verifying it locally is not possible — it is not a JWT and carries no claims. Handing it back to Google is what establishes whose it is, which is what the controller does, via oauth2/v3/userinfo. Matched now, with one deliberate difference: the token goes in an Authorization Bearer header rather than the query string. Same endpoint and response, but a credential in a URL ends up in proxy and server logs. Also restored from the controller and previously missing: the display name is built from given_name + family_name before falling back to name, and an hd === "kiit.ac.in" account gets college, rollNumber, year and school derived from the roll number. That derivation was checked against the controller's inline version across six roll numbers spanning 1st year to Passout — identical on all six. Error mapping: Google answering 401/403 becomes a 401, since a bad token is the caller's problem and not a server fault; any other non-OK becomes 502; a transport failure becomes 503. Previously an unhandled throw would have surfaced as a 500. Status codes follow the controller: 201 when the account was just created, 200 otherwise, message "LOGGED IN" in both cases. getEnv and the google-auth-library import are dropped — the access-token flow needs no client id server-side. Verified against the live Google endpoint: empty body gives 400 "Missing fields: access_token"; an invalid access_token gives 401, proving the request now reaches Google instead of being turned away at the door. A full popup sign-in needs a browser and was not automated. typecheck clean, eslint 0 errors, build 68 routes. * parallax effect and footer updated * Fixed all the comments above namely, image dimensions in one file, role centralization, form analytics access, otp length and validity, trusted origins, Preview Form. * Beta (#12) * Fix the redirect after sign-in (#3) A correct login showed "Login successful" and then sat on /Login forever. App.jsx never navigated from inside Login.jsx — it called authCtx.login(...) and let the route table react: <Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} /> App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only runs on a server request, which a client-side sign-in never makes. The redirect belongs in the components rather than in a layout wrapper. A guard in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong: SignUp and CompleteProfile sign the user in and then navigate themselves to "/", and a layout guard cancels that in-flight router.push before it commits. Measured on the signup flow, the push never reached history at all and a new account landed on /profile instead of "/". No delay fixes that reliably, since the push only commits once its RSC payload arrives. Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the shouldNavigate/navigatePath state and the effect that acts on it — dead in the original precisely because the route table did the job. Setting setShouldNavigate(true) after authCtx.login(...) brings it to life, with no cross-component race. SendOtp.jsx already did this and is unchanged. postAuthRedirect() resolves the destination the way LoginRedirect did, plus the ?next= proxy.ts appends. That value comes off the query string and is therefore attacker-supplied, so anything that is not a plain internal path is discarded. Verified in the browser by driving the real forms with the API stubbed: Login /Login -> /profile Login /Login?next=/Events -> /Events Login /Login?next=//example.com/phish -> /profile, origin preserved Login blocked page -> login -> back to it, prevPage cleared Signup /SignUp -> /, matching the original Login stale localStorage, no cookie -> login form, one bounce, no loop Also verified every auth route against the running server, signed out and signed in: all five auth pages, all seven /profile pages, the lowercase casing redirects, a tampered cookie (rejected and cleared), and the seven auth endpoints on an empty body. Two pre-existing gaps are documented in MIGRATION.md rather than silently changed: /ForgotPassword submits natively (identical in the original), and /profile/members and /profile/BlogForm are not access-gated in the UI. typecheck clean, eslint 0 errors, build 68 routes. * Team management, Google sign-in, and a round of migration fixes (#8) * Fix the redirect after sign-in A correct login showed "Login successful" and then sat on /Login forever. App.jsx never navigated from inside Login.jsx — it called authCtx.login(...) and let the route table react: <Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} /> App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only runs on a server request, which a client-side sign-in never makes. The redirect belongs in the components rather than in a layout wrapper. A guard in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong: SignUp and CompleteProfile sign the user in and then navigate themselves to "/", and a layout guard cancels that in-flight router.push before it commits. Measured on the signup flow, the push never reached history at all and a new account landed on /profile instead of "/". No delay fixes that reliably, since the push only commits once its RSC payload arrives. Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the shouldNavigate/navigatePath state and the effect that acts on it — dead in the original precisely because the route table did the job. Setting setShouldNavigate(true) after authCtx.login(...) brings it to life, with no cross-component race. SendOtp.jsx already did this and is unchanged. postAuthRedirect() resolves the destination the way LoginRedirect did, plus the ?next= proxy.ts appends. That value comes off the query string and is therefore attacker-supplied, so anything that is not a plain internal path is discarded. Verified in the browser by driving the real forms with the API stubbed: Login /Login -> /profile Login /Login?next=/Events -> /Events Login /Login?next=//example.com/phish -> /profile, origin preserved Login blocked page -> login -> back to it, prevPage cleared Signup /SignUp -> /, matching the original Login stale localStorage, no cookie -> login form, one bounce, no loop Also verified every auth route against the running server, signed out and signed in: all five auth pages, all seven /profile pages, the lowercase casing redirects, a tampered cookie (rejected and cleared), and the seven auth endpoints on an empty body. Two pre-existing gaps are documented in MIGRATION.md rather than silently changed: /ForgotPassword submits natively (identical in the original), and /profile/members and /profile/BlogForm are not access-gated in the UI. typecheck clean, eslint 0 errors, build 68 routes. * Fix the redirect after sign-in (#3) (#4) A correct login showed "Login successful" and then sat on /Login forever. App.jsx never navigated from inside Login.jsx — it called authCtx.login(...) and let the route table react: <Route path="/Login" element={authCtx.isLoggedIn ? <LoginRedirect /> : <Login />} /> App Router routes are files, so nothing observes isLoggedIn, and proxy.ts only runs on a server request, which a client-side sign-in never makes. The redirect belongs in the components rather than in a layout wrapper. A guard in app/(auth)/layout.jsx reacting to isLoggedIn was tried first and is wrong: SignUp and CompleteProfile sign the user in and then navigate themselves to "/", and a layout guard cancels that in-flight router.push before it commits. Measured on the signup flow, the push never reached history at all and a new account landed on /profile instead of "/". No delay fixes that reliably, since the push only commits once its RSC payload arrives. Login.jsx, GoogleLogin.jsx and GoogleSignup.jsx already carry the shouldNavigate/navigatePath state and the effect that acts on it — dead in the original precisely because the route table did the job. Setting setShouldNavigate(true) after authCtx.login(...) brings it to life, with no cross-component race. SendOtp.jsx already did this and is unchanged. postAuthRedirect() resolves the destination the way LoginRedirect did, plus the ?next= proxy.ts appends. That value comes off the query string and is therefore attacker-supplied, so anything that is not a plain internal path is discarded. Verified in the browser by driving the real forms with the API stubbed: Login /Login -> /profile Login /Login?next=/Events -> /Events Login /Login?next=//example.com/phish -> /profile, origin preserved Login blocked page -> login -> back to it, prevPage cleared Signup /SignUp -> /, matching the original Login stale localStorage, no cookie -> login form, one bounce, no loop Also verified every auth route against the running server, signed out and signed in: all five auth pages, all seven /profile pages, the lowercase casing redirects, a tampered cookie (rejected and cleared), and the seven auth endpoints on an empty body. Two pre-existing gaps are documented in MIGRATION.md rather than silently changed: /ForgotPassword submits natively (identical in the original), and /profile/members and /profile/BlogForm are not access-gated in the UI. typecheck clean, eslint 0 errors, build 68 routes. Co-authored-by: Krishna-Das20 <krishnadas2806@gmail.com> * Halve the client bundle and fix the SSR hydration errors The landing page shipped 2.3 MB of JavaScript. Home.jsx imported its four sections from the `sections` barrel, which also re-exports `sections/Profile` — the whole admin panel. All of them are client components, so the bundler pulled that entire graph onto the landing page: certificate tooling, admin tables, the avatar editor, event analytics. The `features` barrel did the same for LiveEventPopup. Under Vite this cost nothing noticeable; under Next, where each route gets its own bundle, a barrel import silently undoes the code splitting. Importing those four components directly, leaving the barrels in place for other call sites: / 2317 KB -> 1082 KB /Events 2063 KB -> 1082 KB /Team 2014 KB -> 1082 KB /Login 1248 KB -> 920 KB Over the wire the landing page is now 327 KB of JS; locally TTFB 38ms, DOMContentLoaded 135ms, load 536ms. EventCard wrapped its meta row in a <p> containing div.price, which contained another <p>. Client-rendered that was invisible, because React builds the DOM node by node and nothing reparents it. Server-rendered it is real markup, so the parser closed the <p> early and div.price came out a sibling instead of a child — a different layout, reported as a hydration mismatch on every card. The wrapper is a <div className={style.meta}> and the two `.eventname p` rules list `.eventname .meta` alongside, so computed styles are unchanged: verified in the browser at font-size 14.4px, display flex, align-items center, margin-top 1.6px with div.price a real child, console clean across 580 cards. Also: darken() -> color.adjust() in VerifyCertificate.module.scss, confirmed identical by compiling both and diffing (rgb(80%, 43.2941176471%, 0%) either way), which clears the Dart Sass deprecation warnings; and <html> now carries data-scroll-behavior="smooth" to acknowledge the globals.scss rule. The <Fit /> warnings are left alone — react-fit is a transitive dependency of react-date-picker and uses the `warning` package, a no-op in production. typecheck clean, eslint 0 errors, build 68 routes, all pages 200, auth route matrix unchanged. * Fix the remaining SSR hydration errors, and add an audit that finds them Four components wrapped block-level content in a <p>: EventCard and EventModal (<p> -> div.price -> <p>), Hero (<p> -> <span> -> <h3>) and Social (<p> -> div.fed). Client-rendered under Vite none of it mattered, because React builds the DOM node by node and nothing reparents an existing tree. Server-rendered it is real markup, so the parser closes the <p> at the first block child and the content lands as a sibling — a different layout, reported as a hydration mismatch. EventCard was fixed in 9a1073e; this covers the other three. Each wrapper is a <div> carrying a class listed alongside the original `p` selector, so computed styles are unchanged. Verified in the browser: EventModal .meta 14.4px / flex / center / 1.6px / #fff Hero .tagline 39.2px / 700 / #fff Social .content 40px / 600 / #fff / center Hero keeps its <h3> rather than downgrading it to a span — the wrapper changed instead, so the heading still counts as a heading. Social's `styles.content` had no rule in the stylesheet at all, so the className resolved to undefined and the element was styled purely by `.text p`; `.content` now exists and carries those declarations. Fixing Social surfaced a second, unrelated mismatch on that page: react-social-media-embed mints a fresh UUID per render into both `id` and `className`, and the embed sizes come from useDimensions(), which measures 0 on the server. Both embeds now load via next/dynamic with ssr: false — nothing is lost, since the visible post is drawn by Instagram's and LinkedIn's own scripts after mount. Same four embeds, same 1674px page height, console clean. npm run audit:nesting keeps this from recurring. It walks a tag stack through every JSX file and reports anything the HTML parser would reparent, skipping comments and string/regex literals — without that a comment mentioning <p>, or `.replace(/<a\s[^>]*>/gi, '')`, is read as markup. It was validated against the pre-fix files: it reports all four real cases and nothing for the two files that produced false positives. Current tree: 153 files, zero findings. typecheck clean, eslint 0 errors, build 68 routes, no Sass deprecations. * Fix the admin calendar, and restore Express parity for forms and events The calendar on the admin form page had no styling at all. The original index.scss imported three vendor stylesheets and only one was carried over, so react-date-picker's popup — which it draws with react-calendar — came out as an unstyled column of numbers. Confirmed by grepping the emitted CSS: zero .react-calendar rules shipped, now 50. In the browser the popup measures 350x248 with a #a0a096 border, react-calendar's own defaults. Re-reading every form/event controller against its port turned up more: getFormAnalytics returned the wrong shape entirely. EventStats.jsx reads response.data.form.formAnalytics, .form.info and .yearCounts; the port returned { success, message, data }, so response.data.form was undefined and the admin analytics panel threw. It now returns { message, form, yearCounts }, rebuilds the yearCounts histogram, and restores the 404 for a form nobody has registered to. Access is the controller's own allowlist (ADMIN/PRESIDENT/VICEPRESIDENT/ DIRECTOR_*) plus the srex@fedkiit.com escape hatch, answering 401 "Access Denied", not the isMember test the port had invented. The attendance flow did not work. The QR carries a signed JWT: attendanceCode must return { message, attendanceToken } with a 20-minute expiry, and markAttendance takes { formId, token } and verifies it. The port returned the raw record id under a different key, so QRCodeModal generated no QR, and markAttendance rejected the scanned JWT for not being a 24-character id. Both now match, including the formId binding that stops one event's QR checking someone in at another, and the ?teamCode= branch. Verified both directions: a token minted by the original's jsonwebtoken call verifies with jose here, and vice versa; tampered tokens are rejected. Access levels corrected both ways: export-attendance is ADMIN again (was any club member, which handed every executive the full attendee list), while markAttendance drops to signed-in, matching the Express route whose checkAccess is commented out — the door volunteer signs in as a plain USER, so requiring member access locked the door staff out. Cloudinary dimensions were wrong: addForm/editForm resize FormImages at h350.67 x w196.37 and QR media at h400 x w150 and h150 x w400 respectively, not the 1000x1000 / 500x500 the port used. The two QR rows are transposed against each other in the original and are reproduced per call site rather than reconciled. addForm also returns 200 "Form created successfully" and editForm "Form info and sections updated successfully". One divergence kept deliberately: Express's addForm computes Boolean(isPublic) || false over a multipart field, and Boolean("false") is true, so every event created through the admin form was public, registration-closed and past regardless of the toggles. editForm already used === "true". The port uses editForm's form in both so the switches work; restoring byte-parity would re-break them. Documented in MIGRATION.md along with checkAccess("USER") being stricter in Express than here. typecheck clean, eslint 0 errors, build 68 routes, nesting audit clean. * Send 4-digit OTPs again, so they fit the four boxes on screen The password-reset email carried a 6-digit code while every OTP screen renders four boxes, so the code could not be entered at all. The reset flow and signup share components/OtpInput, so both were affected. The UI was not at fault — it is four boxes in the original too, character for character. This port had raised OTP_LENGTH from 4 to 6 as hardening, without a consumer that could accept six. Reverted to 4, matching generateOtp(4, false, false, false). The argument for 6 does not hold here: 10,000 combinations are brute-forceable against the Express backend, which had no throttling at all, but RATE_LIMITS.passwordReset allows 6 attempts per 15 minutes and a code expires after 15, so an attacker gets at most 6 guesses out of 10,000 before the code stops existing. The rest of the OTP hardening is untouched: SHA-256 digest at rest, constant-time comparison, single-use, and expiry derived from createdAt rather than a setTimeout that never fires on a serverless host. Codes issued before this change are 6 digits and cannot be entered; they expire on their own within 15 minutes. typecheck clean, eslint 0 errors, build 68 routes. * Restore the global button reset, and put the Chatbot back on every route The disabled "Resend OTP" button rendered with a grey box. Measured against the original running side by side, it had background-color rgba(19,1,1,0.3) and a 2px outset border where the original has rgba(0,0,0,0) and 0px none — the user-agent's disabled-button chrome showing through. The cause is structural, not a mistranslation. TeamCard.module.scss has a top-level bare `button { }` rule; Vite does not hash element selectors in CSS Modules and bundles every module into one stylesheet for the SPA, so that rule was live on every page of the original. Next code-splits CSS per route, so once ported it only loaded where TeamCard did — /Team and /profile/members — and buttons everywhere else lost their reset. Declared in app/globals.scss, which reproduces the original cascade. At specificity 0-0-1 every component's own class rules still win: verified by diffing all three buttons on /Login between the two apps, identical background, font-size, margins and box sizes, and the resend button on /otp now matches the original on every property (transparent, 0px none, 14.4px, 16px margin-top, 126x19). Ten other top-level :global(...) rules sit in the same trap and are listed in MIGRATION.md. They are left alone deliberately — three are :global(*) and would move layout on every page, so they want reviewing one at a time rather than hoisting wholesale. Separately, App.jsx renders <Chatbot /> above <Routes>, so it is on every route. The port mounted it in the (main) layout, hiding it on /Login, /SignUp, /otp, /ForgotPassword and /completeProfile. Moved to the root layout, inside Providers since it reads AuthContext. Confirmed in the server-rendered HTML for all six routes, and the toggle measures 72x72 on /Login in both apps. typecheck clean, eslint 0 errors, build 68 routes. * Fix the password reset, and two more request-body contract breaks /otp rejected a correct code with "Email, otp and password are required". OtpInput.jsx posts { newPassword, confirmPassword, otp, email }, matching Express; this handler destructured `password`, so the check failed before the code was ever looked at. Restored the rest of that controller's contract too, which had been simplified away: 400 "Missing fields." when any of the four is absent, 409 on a confirm-password mismatch, 404 "User not found!" (Express's checkAccess looks the account up by the body's email, which is also why the endpoint works without a session), 400 when the new password equals the old one, and 200 { status: "OK", message: "Password has been changed successfully !!" }. This was the third contract break found by report rather than by testing, so npm run audit:contracts now walks every api.*() call in the components, resolves it to its Route Handler and flags payload keys the handler never reads. It found two more live ones: renameTeam UI sends newTeamName, handler read teamName sendJoinRequest UI sends teamRegistrationId, handler read teamCode Every rename failed on an empty name and every join request was rejected. Both now match Express, including sendJoinRequest identifying the target by registration row id — the value searchTeams already returns to the UI as teamRegistrationId — with the explicit team.formId !== formId check that a global row id makes necessary. The audit also surfaced that the admin certificate tooling calls five endpoints that do not exist here (getEvent, getEventByFormId, createOrganisationEvent, sendBatchMails, sendCertViaEmail), out of eleven missing overall. That is a gap rather than a regression — it was never built — and is documented in MIGRATION.md rather than fixed in passing, since the certificate flow already carries a deliberate architectural deviation. typecheck clean, eslint 0 errors, build 68 routes. * Fix useSearchParams destructuring, which crashed two pages React Router returns [params, setParams]; Next returns the params object itself. Two components outside the team feature kept the array destructuring: EventForm.jsx crashed opening the event registration form VerifyCertificate.jsx crashed opening certificate verification ReadonlyURLSearchParams is iterable, so destructuring it as an array does not throw — it quietly yields the first [key, value] entry, or undefined when the URL has no query string. The next .get() then fails with "Cannot read properties of undefined (reading 'get')". EventForm is the one that hid: it reads searchParams.get("teamCode") during render, but ProtectedRoute redirects signed-out visitors first, so an anonymous smoke test returns 200 and only a signed-in user reaches the crash. Swept the tree for the rest of the React Router surface (useNavigate, useLocation, Outlet, Navigate, to=): only comments remain. TeamManagement.jsx has the same bug and is fixed on feature/team-member-fix, where the rest of the team work lives. * Add a health endpoint for production monitoring GET|HEAD /api/health returns 200 when every dependency answers and 503 when one does not. There was no equivalent in the Express backend, so uptime monitors had to be pointed at a real endpoint like /api/form/getAllForms — which reads the whole forms collection on every poll and still answers 200 when the database is unreachable but the response is cached. The database check uses MongoDB's `ping`, the cheapest command the driver exposes: it touches no collection and reads no documents, so polling costs nothing. It is bounded at 3s. A hung TCP connection to Atlas does not reject — it waits for the driver's own much longer timeout — and a probe that hangs reads to a monitor as a timeout rather than a clean 503. The endpoint is unauthenticated (proxy.ts already excludes /api/), so the error body carries only "unreachable" or the timeout message. Driver errors can embed the connection string, which holds the Atlas password; the detail goes to the server log instead. HEAD is exported explicitly because Next does not derive it from GET for route handlers, and a monitor configured for HEAD would otherwise get 405 and report the site as down. force-dynamic + no-store: a health check answering from build output or a cache would report "ok" for a process that is not actually serving. Verified both paths against the real database: 200 with a 36ms ping, and 503 in 3.03s with DATABASE_URL pointed at an unreachable host (HEAD likewise 503). * Rebuild team management against the Express controllers The five components were already ported line for line; the backend behind them was not. It had been written against a different data model, so most of the page did not work. The model: a team is one formRegistration row holding every member's address in regTeamMemEmails and every member's answers in value, with userId as the leader. Leaving or being removed lifts a person's entries out of that row into their own UNAFFILIATED row, so they stay registered and can join or start another team. The port assumed a row per member sharing a teamCode, earliest row as leader. That assumption produced most of the faults: teamDetails looked up by userId, so only the leader could load the page. Confirmed on live data: the ownership query returns NULL for a real member of team "ABC"; the membership query finds it. teamDetails returned registrationId/regTeamMemEmails/isLeader, while the UI reads eventTitle, leaderEmail, max/minTeamSize, isRegistrationClosed, isEventPast and data.isTeamless — and members lacked college and year. teamDetails had no UNAFFILIATED branch, so TeamlessState — the entire create/join flow …
Takes the new work from satyampandey-1105:my-changes — the /Insights page, the social-posts CRUD API, the management panel and the SocialPost model — without merging the branch itself. It was cut from Beta (fed-tech#12) and is 86 commits behind, so a real merge conflicted in next.config.ts and tried to restore src/layouts/Navbar/Navbar.tsx, a file since deleted. Taking the paths instead avoids reconciling two histories at all. Replaces the x-admin-secret header with the session check every other admin route uses. The header was a stopgap for a contributor without an admin account, but it authenticated nobody in particular: one shared password, no record of who changed a post, and the browser had to hold the secret to send it — kept in sessionStorage and a cookie that was neither httpOnly nor Secure, readable by any script on the page for a day. The session cookie is httpOnly and already identifies the person. /profile/social is gated server-side like /profile/attendance, so a signed-in participant who types the path does not get a screen of controls that all fail. The unfiltered GET is now ADMIN-only. It returns posts an admin has deliberately hidden, so leaving it open meant unpublishing a post still left it readable to anyone who dropped the query string. ?visible=true stays public; the /Insights page does not use the route either way, since SocialFeed reads Prisma directly. Insights is reachable from the navbar, which said "Insights" but pointed at /Blog. /Blog and /Social now redirect there, as do the lowercase forms, so existing links keep working. Verified against a running server: anonymous 401 on every mutating route, signed-in non-admin 403, admin full create/toggle/delete. The old secret header no longer grants anything. Not taken: the bcryptjs removal from serverExternalPackages, an em-dash corrupted to a replacement character, a tsconfig include pointing at a global.d.ts the branch does not contain, the seed script, and 942 lines of AI planning notes under .agents/ and .kilo/.
No description provided.