Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,29 @@ PORT=3000
# which would have published a sitemap full of localhost URLs.
NEXT_PUBLIC_SITE_URL=https://www.fedkiit.com

# --- Tunables ---------------------------------------------------------------
# All optional. Every default below reproduces the previous hardcoded value, so
# leaving them unset changes nothing.

# One-time passwords. NEXT_PUBLIC so OtpInput draws exactly this many boxes —
# one variable for both sides means the code length and the UI cannot desync.
NEXT_PUBLIC_OTP_LENGTH=4
OTP_VALIDITY_MINUTES=15

# Extra hosts allowed to appear in an Origin header when building an emailed
# invite link. Comma-separated. The canonical site host, localhost and 127.0.0.1
# are always trusted; this is for staging and preview deployments.
# e.g. TRUSTED_ORIGIN_HOSTS=staging.fedkiit.com,fed-frontend.vercel.app
TRUSTED_ORIGIN_HOSTS=

# Addresses that may read form analytics regardless of role. Comma-separated.
# Previously the literal srex@fedkiit.com in the route.
FORM_ANALYTICS_ALLOWED_EMAILS=srex@fedkiit.com

# Calendar month (1-12) the academic year rolls over in. Used to derive the year
# of study from a KIIT roll number, so a 2022 intake stays 4th Year until July
# 2026 rather than being promoted every 1 January.
ACADEMIC_YEAR_START_MONTH=7

LOG_REQ=false
DEBUG=false
851 changes: 851 additions & 0 deletions MIGRATION.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions app/(auth)/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
* Auth layout — the `AuthLayout` component from App.jsx.
* No navbar, no footer, just the `.authpage` wrapper.
*
* App.jsx also guarded these five routes with
* `authCtx.isLoggedIn ? <Navigate /> : <Page />`. That is deliberately *not*
* reproduced as a wrapper here. Two of the pages sign the user in and then
* navigate themselves (SignUp and CompleteProfile both go to "/"), and a
* layout-level guard reacting to `isLoggedIn` cancels their in-flight
* `router.push` before it commits — measured: the push never reached
* `history`. The redirect therefore lives in each component that needs it,
* using the `shouldNavigate` state they already carried, and `proxy.ts` covers
* a signed-in visitor arriving at one of these URLs.
*
* No Suspense boundary here: wrapping children made prerendered pages emit
* their markup twice (once inside the streamed boundary, once outside). Pages
* that read search params declare their own boundary.
Expand Down
9 changes: 5 additions & 4 deletions app/(main)/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { usePathname } from "next/navigation";

import Navbar from "@/src/layouts/Navbar/Navbar";
import Footer from "@/src/layouts/Footer/Footer";
import Chatbot from "@/src/components/Chatbot/Chatbot";

/**
* Main site layout — the `MainLayout` component from App.jsx.
*
* Same structure: Navbar, a `.page` wrapper that gains `.omega-page` on the
* Omega route, then Footer. The global Chatbot sits alongside it, as it did at
* the top of `App()`.
* Omega route, then Footer.
*
* The Chatbot is *not* here. App.jsx renders it above `<Routes>`, so it appears
* on every route including Login, SignUp and the OTP screens; mounting it in
* this layout hid it on all of those. It now lives in the root layout.
*
* Deliberately no Suspense boundary here. Wrapping `{children}` made every
* prerendered page ship its content twice — once inside the streamed boundary
Expand All @@ -25,7 +27,6 @@ export default function MainLayout({ children }) {

return (
<div>
<Chatbot />
<Navbar />
<div className={`page ${isOmegaPage ? "omega-page" : ""}`}>
{children}
Expand Down
51 changes: 39 additions & 12 deletions app/api/auth/changePassword/route.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,53 @@
import { prisma } from "@/lib/db";
import { body, expressError, handle, json } from "@/lib/api/express";
import { enforceRateLimit, RATE_LIMITS } from "@/lib/api/rate-limit";
import { hashPassword } from "@/lib/auth/password";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { verifyOtp } from "@/lib/services/otp";

/**
* POST /api/auth/changePassword
* Port of controllers/auth/changePassword.js — completes the reset flow.
*
* Body is `{ newPassword, confirmPassword, otp, email }`, which is what
* `OtpInput.jsx` posts. An earlier version of this route read `password`
* instead of `newPassword`, so a correct code was rejected with
* "Email, otp and password are required" before it was ever checked.
*
* Public, as in Express: the route's `checkAccess('USER','MEMBER','ADMIN')` runs
* with no `verifyToken` ahead of it, so the middleware takes its `email` from
* the body and looks the account up itself. Knowing the address is the entry
* requirement; the OTP is what actually authorises the change.
*/
export async function POST(request: Request) {
return handle(async () => {
const { email, otp, password } = await body<{
email?: string;
const { newPassword, confirmPassword, otp, email } = await body<{
newPassword?: string;
confirmPassword?: string;
otp?: string;
password?: string;
email?: string;
}>(request);

if (!email || !otp || !password) {
return expressError(400, "Email, otp and password are required");
if (!newPassword || !confirmPassword || !otp || !email) {
return expressError(400, "Missing fields.");
}

if (newPassword !== confirmPassword) {
return expressError(
409,
"Conflict : New Password and confirm Password did not match!!",
);
}

const address = email.trim().toLowerCase();

await enforceRateLimit({ ...RATE_LIMITS.passwordReset, subject: address });

// `checkAccess` answered 404 here when the address was unknown.
const user = await prisma.user.findUnique({
where: { email: address },
select: { id: true },
select: { id: true, password: true },
});

// Same message whether or not the account exists.
if (!user) return expressError(400, "That code is not correct");
if (!user) return expressError(404, "User not found!");

await verifyOtp({
email: address,
Expand All @@ -39,11 +56,21 @@ export async function POST(request: Request) {
consume: true,
});

if (await verifyPassword(newPassword, user.password)) {
return expressError(
400,
"New password cannot be same as the old password ! Instead try login",
);
}

await prisma.user.update({
where: { id: user.id },
data: { password: await hashPassword(password) },
data: { password: await hashPassword(newPassword) },
});

return json({ message: "Password changed successfully" }, 200);
return json({
status: "OK",
message: "Password has been changed successfully !!",
});
});
}
40 changes: 28 additions & 12 deletions app/api/auth/googleAuth/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,46 @@ import { googleAuth } from "@/lib/services/auth";
* POST /api/auth/googleAuth
* Port of controllers/auth/google/googleAuthentication.js.
*
* Accepts either `credential` or `token` — GoogleLogin.jsx and GoogleSignup.jsx
* each send a different key.
* The body is `{ access_token }`. `GoogleLogin.jsx` and `GoogleSignup.jsx` both
* use `useGoogleLogin`'s implicit flow and post that key — as they did against
* Express. This route previously looked for `credential` / `token` / `tokenId`
* and matched none of them, so every Google sign-in was rejected with a 400
* before Google was ever contacted.
*
* `token` and `credential` stay accepted as aliases so a caller that already
* adopted either keeps working.
*/
export async function POST(request: Request) {
return handle(async () => {
const payload = await body<{
access_token?: string;
credential?: string;
token?: string;
tokenId?: string;
}>(request);

const credential = payload.credential || payload.token || payload.tokenId;
if (!credential) return expressError(400, "Google credential is required");
const accessToken =
payload.access_token || payload.token || payload.credential || payload.tokenId;
if (!accessToken) {
return expressError(400, "Missing fields: access_token");
}

await enforceRateLimit(RATE_LIMITS.login);

const result = await googleAuth(credential);
const result = await googleAuth(accessToken);

return json({
message: result.isNewUser ? "User created successfully" : "LOGGED IN",
user: result.user,
token: result.token,
isNewUser: result.isNewUser,
needsProfile: result.needsProfile,
});
// 201 when the account was just created, 200 otherwise; the message is
// "LOGGED IN" either way, matching the controller. GoogleSignup.jsx keys its
// toast off the status, not the message.
return json(
{
message: "LOGGED IN",
user: result.user,
token: result.token,
isNewUser: result.isNewUser,
needsProfile: result.needsProfile,
},
result.isNewUser ? 201 : 200,
);
});
}
12 changes: 11 additions & 1 deletion app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createSessionToken, setSessionCookie } from "@/lib/auth/session";
import { consumeOtp, verifyOtp } from "@/lib/services/otp";
import { sendMail } from "@/lib/email/mailer";
import { welcomeEmail } from "@/lib/email/templates";
import { normalizeYear } from "@/lib/academic";

/**
* POST /api/auth/register
Expand Down Expand Up @@ -59,7 +60,16 @@ export async function POST(request: Request) {
rollNumber: data.rollNumber || null,
school: data.school || null,
college: data.college || null,
year: data.year || null,
// Exactly what the user selected — never inferred. Anyone can sign up
// with a personal address, so the roll number beside this field is not
// guaranteed to be a KIIT one, and guessing from it would quietly stamp
// a wrong year on the account. Lateral entry breaks the inference even
// when the roll number *is* real: a 2025 LE student sits in 2nd year
// with the 2024 batch.
//
// `normalizeYear` only folds spelling ("3rd Year" -> "3rd"); it never
// invents a value.
year: normalizeYear(data.year),
contactNo: data.contactNo || null,
whatsappNo: data.whatsappNo || null,
img: data.img || null,
Expand Down
2 changes: 1 addition & 1 deletion app/api/blog/createBlog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function POST(request: Request) {
let image = text("image");
const file = form.get("image");
if (file instanceof File && file.size > 0) {
const result = await uploadImage(file, "BlogImages", 1200, 800);
const result = await uploadImage(file, "BlogImages");
image = result?.secure_url ?? image;
}

Expand Down
2 changes: 1 addition & 1 deletion app/api/blog/updateBlog/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function PUT(

const file = form.get("image");
if (file instanceof File && file.size > 0) {
const result = await uploadImage(file, "BlogImages", 1200, 800);
const result = await uploadImage(file, "BlogImages");
if (result) data.image = result.secure_url;
} else {
const imageUrl = text("image");
Expand Down
38 changes: 18 additions & 20 deletions app/api/form/addForm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ import { uploadImage } from "@/lib/services/upload";
* fields, and assembles the same `info` blob the original wrote, so existing
* documents and new ones stay structurally identical.
*/
const FORM_IMAGE_W = 1000;
const FORM_IMAGE_H = 1000;
const QR_IMAGE_W = 500;
const QR_IMAGE_H = 500;
/**
* Upload dimensions come from `lib/config/images.ts`.
*
* They were literals here and in `editForm`, and the two had drifted: Express
* declares `QrImageWidth = 400, QrImageHeight = 150` in both controllers, but
* `addForm` passed them into `uploadimage(path, folder, height, width)` in the
* wrong order, so it uploaded QR media at 150x400 while `editForm` used
* 400x150. Centralising picks the declared intent, which is what `editForm`
* already did — so this call changes, deliberately, to match.
*/

export async function POST(request: Request) {
return handle(async () => {
Expand Down Expand Up @@ -55,23 +61,13 @@ export async function POST(request: Request) {

const eventImg = form.get("eventImg");
if (eventImg instanceof File && eventImg.size > 0) {
const result = await uploadImage(
eventImg,
"FormImages",
FORM_IMAGE_W,
FORM_IMAGE_H,
);
const result = await uploadImage(eventImg, "FormImages");
info.eventImg = result?.secure_url ?? null;
}

const media = form.get("media");
if (media instanceof File && media.size > 0) {
const result = await uploadImage(
media,
"QRMediaImages",
QR_IMAGE_W,
QR_IMAGE_H,
);
const result = await uploadImage(media, "QRMediaImages");
(info.receiverDetails as { media: string | null }).media =
result?.secure_url ?? null;
}
Expand All @@ -96,9 +92,11 @@ export async function POST(request: Request) {
// Drop the cached listing so the new event shows up immediately.
revalidatePath("/Events");

return json(
{ success: true, message: "Form added successfully", form: created },
201,
);
// 200 and this wording are what the Express controller returned.
return json({
success: true,
message: "Form created successfully",
form: created,
});
});
}
16 changes: 10 additions & 6 deletions app/api/form/attendanceCode/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ import { expressError, handle, json } from "@/lib/api/express";
import { getCurrentUser } from "@/lib/auth/access";

/**
* GET /api/form/attendanceCode/:id
* Port of controllers/registration/getAttendanceCode — the value the
* attendee's QR code encodes.
* GET /api/form/attendanceCode/:id?teamCode=
* Port of controllers/registration/getAttendanceCode.
*
* Responds `{ message, attendanceToken }` at the top level: QRCodeModal reads
* `response.data.attendanceToken` and encodes it straight into the QR image.
* `attendanceToken` is a signed JWT expiring in 20 minutes, not the attendance
* record's id.
*/
export async function GET(
_request: Request,
request: Request,
ctx: RouteContext<"/api/form/attendanceCode/[id]">,
) {
return handle(async () => {
const user = await getCurrentUser();
if (!user) return expressError(401, "Token is required");

const { id } = await ctx.params;
const data = await getAttendanceCode(id, user);
const teamCode = new URL(request.url).searchParams.get("teamCode");

return json({ success: true, data });
return json(await getAttendanceCode(id, user, teamCode));
});
}
19 changes: 16 additions & 3 deletions app/api/form/createTeam/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,22 @@ export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return expressError(401, "Token is required");

const b = await body<Record<string, string>>(request);
const data = await createTeam({ user, formId: b.formId ?? b._id, teamName: b.teamName ?? '' });
const b = await body<{ formId?: string; teamName?: string }>(request);
if (!b.formId || !b.teamName) {
return expressError(400, "Form ID and team name are required");
}

return json({ success: true, message: "Team created successfully", data });
const data = await createTeam({
user,
formId: b.formId,
teamName: b.teamName,
});

// TeamlessState.jsx shows this verbatim, so it names the team as before.
return json({
success: true,
message: `Team "${data.teamName}" created successfully!`,
data,
});
});
}
Loading
Loading