Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ TRUSTED_ORIGIN_HOSTS=
# Previously the literal srex@fedkiit.com in the route.
FORM_ANALYTICS_ALLOWED_EMAILS=srex@fedkiit.com

# Addresses that may scan QRs and mark attendance regardless of role.
# Comma-separated. For shared door-scanning accounts that are plain USERs and
# should not be promoted to ADMIN just to run a scanner — an ADMIN can also
# edit and delete events, which a shared login has no business doing.
# e.g. FORM_ATTENDANCE_ALLOWED_EMAILS=attendance@fedkiit.com
FORM_ATTENDANCE_ALLOWED_EMAILS=

# 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.
Expand Down
12 changes: 5 additions & 7 deletions app/(main)/Blog/page.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
// Route entry — renders the component ported from
// FED-Frontend/src/pages/Blog/Blog.jsx
"use client";
// /Blog — content moved to /Insights, which shows blogs and socials together.
// Kept as a redirect so existing links and search results keep working.
import { redirect } from "next/navigation";

import Blog from "@/src/views/Blog/Blog";

export default function Page() {
return <Blog />;
export default function BlogPage() {
redirect("/Insights");
}
30 changes: 30 additions & 0 deletions app/(main)/Insights/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// /Insights — all social media posts and blogs on one page.
// Server page: SocialFeed and BlogFeed query Prisma directly.
import SocialFeed from "@/components/SocialFeed";
import BlogFeed from "@/components/BlogFeed";
import styles from "./page.module.scss";

export const metadata = {
title: "Insights | FED KIIT",
description:
"Latest social media posts and blogs from the Federation of Entrepreneurship Development, KIIT.",
};

export default function InsightsPage() {
return (
<main className={styles.page}>
<section id="socials" className={styles.section}>
<h2 className={styles.heading}>
Our <span>Socials</span>
</h2>
<SocialFeed />
</section>
<section id="blogs" className={styles.section}>
<h2 className={styles.heading}>
Our <span>Blogs</span>
</h2>
<BlogFeed />
</section>
</main>
);
}
23 changes: 23 additions & 0 deletions app/(main)/Insights/page.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.page {
width: 100%;
padding: 3rem 0 4rem;
}

.section {
padding: 1rem 0 2rem;
}

.heading {
text-align: center;
color: #fff;
font-size: 2rem;
font-weight: 700;
margin: 0 0 1rem;

span {
background: var(--primary, linear-gradient(135deg, #ffbe0b, #f42b03));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
12 changes: 5 additions & 7 deletions app/(main)/Social/page.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
// Route entry — renders the component ported from
// FED-Frontend/src/pages/Social/Social.jsx
"use client";
// /Social — content moved to /Insights, which shows blogs and socials together.
// Kept as a redirect so existing links and search results keep working.
import { redirect } from "next/navigation";

import Social from "@/src/views/Social/Social";

export default function Page() {
return <Social />;
export default function SocialPage() {
redirect("/Insights");
}
23 changes: 23 additions & 0 deletions app/(main)/Social/page.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.page {
width: 100%;
padding: 3rem 0 4rem;
}

.section {
padding: 1rem 0 2rem;
}

.heading {
text-align: center;
color: #fff;
font-size: 2rem;
font-weight: 700;
margin: 0 0 1rem;

span {
background: var(--primary, linear-gradient(135deg, #ffbe0b, #f42b03));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
23 changes: 15 additions & 8 deletions app/(main)/profile/attendance/page.jsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
// Route entry — renders the component ported from
// FED-Frontend/src/pages/AttendancePage/AttendancePage.jsx
//
// Gated to ADMIN on the server. The sidebar only ever *showed* this link to
// admins, but that is presentation: `proxy.ts` guards /profile by checking for
// a valid session, not a role, so before this check any signed-in participant
// who typed the path got a working scanner. Combined with the fact that a
// participant can generate their own QR (that is the whole point of
// QRCodeModal), it meant anyone could mark themselves present.
// Gated on the server. The sidebar only ever *showed* this link to admins, but
// that is presentation: `proxy.ts` guards /profile by checking for a valid
// session, not a role, so before this check any signed-in participant who typed
// the path got a working scanner. Combined with the fact that a participant can
// generate their own QR (that is the whole point of QRCodeModal), it meant
// anyone could mark themselves present.
//
// The gate was `isAdmin` at first, which closed that hole but also locked out
// attendance@fedkiit.com — a plain USER whose only purpose is scanning at the
// door. `canMarkAttendance` admits the senior-executive roles the Express
// author intended, plus any address in FORM_ATTENDANCE_ALLOWED_EMAILS, without
// handing a shared login the event editing that comes with ADMIN.
//
// The QR-issuing endpoint stays open to any signed-in user — participants must
// be able to produce their own code. Only scanning is restricted.

import { redirect } from "next/navigation";

import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import { getCurrentUser } from "@/lib/auth/access";
import { canMarkAttendance } from "@/lib/auth/permissions";
import AttendancePage from "@/src/views/AttendancePage/AttendancePage";

export default async function Page() {
Expand All @@ -22,7 +29,7 @@ export default async function Page() {
// Matches what proxy.ts does for an anonymous request to a protected route,
// so an expired session lands on the login page rather than a bare redirect.
if (!user) redirect("/Login?next=/profile/attendance");
if (!isAdmin(user)) redirect("/profile");
if (!canMarkAttendance(user)) redirect("/profile");

return <AttendancePage />;
}
20 changes: 20 additions & 0 deletions app/(main)/profile/social/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Social post management, inside the admin panel.
//
// Gated on the server, like /profile/attendance: `proxy.ts` only checks for a
// valid session, not a role, so without this any signed-in participant who
// typed the path would get the management UI. The API refuses them, but a
// screen full of controls that all fail is worse than not showing it.

import { redirect } from "next/navigation";

import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import SocialManagementAdminPage from "@/components/SocialManagementAdmin";

export default async function Page() {
const user = await getCurrentUser();

if (!user) redirect("/Login?next=/profile/social");
if (!isAdmin(user)) redirect("/profile");

return <SocialManagementAdminPage />;
}
11 changes: 7 additions & 4 deletions app/api/form/markAttendance/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { markAttendance } from "@/lib/services/attendance";
import { body, expressError, handle, json } from "@/lib/api/express";
import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import { getCurrentUser } from "@/lib/auth/access";
import { canMarkAttendance } from "@/lib/auth/permissions";

/**
* POST /api/form/markAttendance
* Port of controllers/registration/markAttendance.js.
*
* ADMIN only. This deliberately diverges from the Express route, which has its
* `checkAccess` commented out entirely and so accepts unauthenticated calls.
* Restricted to the roles the Express author listed in the `checkAccess` they
* left commented out, plus any address in FORM_ATTENDANCE_ALLOWED_EMAILS. This
* deliberately diverges from the Express route as shipped, which has that check
* commented out entirely and so accepts unauthenticated calls.
*
* The QR token alone is not an access control: a participant can mint their own
* through /api/form/attendanceCode — that endpoint exists so they can display
Expand All @@ -26,7 +29,7 @@ export async function POST(request: Request) {
return handle(async () => {
const user = await getCurrentUser();
if (!user) return expressError(401, "Token is required");
if (!isAdmin(user)) return expressError(403, "Unauthorized");
if (!canMarkAttendance(user)) return expressError(403, "Unauthorized");

const b = await body<{ formId?: string; token?: string }>(request);
const result = await markAttendance({ formId: b.formId, token: b.token });
Expand Down
100 changes: 100 additions & 0 deletions app/api/social-posts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/db";
import { handleRoute, ok, fail, readJson } from "@/lib/api/respond";
import { deriveEmbedUrl } from "@/lib/utils/embedUrl";
import type { Platform, UpdateSocialPostInput } from "@/lib/types/SocialPost";

const VALID_PLATFORMS: Platform[] = ["instagram", "linkedin"];

import { getCurrentUser, isAdmin } from "@/lib/auth/access";

/** ADMIN-only, from the signed-in session. See app/api/social-posts/route.ts. */
async function requireAdmin(): Promise<ReturnType<typeof fail> | null> {
const user = await getCurrentUser();
if (!user) return fail(401, "You must be signed in to do that");
if (!isAdmin(user)) return fail(403, "You do not have permission to do that");
return null;
}

/**
* PUT /api/social-posts/[id]
* Update an existing social post. ADMIN only.
*/
export async function PUT(
request: Request,
ctx: RouteContext<"/api/social-posts/[id]">,
) {
return handleRoute(async () => {
const denied = await requireAdmin();
if (denied) return denied;

const { id } = await ctx.params;
if (!/^[a-f\d]{24}$/i.test(id)) {
return fail(404, "Social post not found");
}

const existing = await prisma.socialPost.findUnique({ where: { id } });
if (!existing) return fail(404, "Social post not found");

const body = await readJson<UpdateSocialPostInput>(request);

const platform = body.platform ?? (existing.platform as Platform);
const url = body.url ?? existing.url;

if (body.platform && !VALID_PLATFORMS.includes(body.platform)) {
return fail(422, "Platform must be 'instagram' or 'linkedin'");
}

// Re-derive embed URL if platform or URL changed
let embedUrl = existing.embedUrl;
if (body.platform || body.url) {
const derived = deriveEmbedUrl(platform, url);
if (!derived) {
return fail(
422,
`Could not derive embed URL for platform "${platform}". Check the URL format.`,
);
}
embedUrl = derived;
}

const updated = await prisma.socialPost.update({
where: { id },
data: {
platform,
url: url.trim(),
embedUrl,
caption:
body.caption !== undefined ? body.caption?.trim() || null : undefined,
isVisible: body.isVisible,
},
});

return ok(updated, "Social post updated successfully");
});
}

/**
* DELETE /api/social-posts/[id]
* Delete a social post. ADMIN only.
*/
export async function DELETE(
request: Request,
ctx: RouteContext<"/api/social-posts/[id]">,
) {
return handleRoute(async () => {
const denied = await requireAdmin();
if (denied) return denied;

const { id } = await ctx.params;
if (!/^[a-f\d]{24}$/i.test(id)) {
return fail(404, "Social post not found");
}

const existing = await prisma.socialPost.findUnique({ where: { id } });
if (!existing) return fail(404, "Social post not found");

await prisma.socialPost.delete({ where: { id } });

return ok(null, "Social post deleted successfully");
});
}
46 changes: 46 additions & 0 deletions app/api/social-posts/[id]/visibility/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { prisma } from "@/lib/db";
import { handleRoute, ok, fail, readJson } from "@/lib/api/respond";

import { getCurrentUser, isAdmin } from "@/lib/auth/access";

/** ADMIN-only, from the signed-in session. See app/api/social-posts/route.ts. */
async function requireAdmin(): Promise<ReturnType<typeof fail> | null> {
const user = await getCurrentUser();
if (!user) return fail(401, "You must be signed in to do that");
if (!isAdmin(user)) return fail(403, "You do not have permission to do that");
return null;
}

/**
* PATCH /api/social-posts/[id]/visibility
* Toggle the visibility of a social post. ADMIN only.
*/
export async function PATCH(
request: Request,
ctx: RouteContext<"/api/social-posts/[id]/visibility">,
) {
return handleRoute(async () => {
const denied = await requireAdmin();
if (denied) return denied;

const { id } = await ctx.params;
if (!/^[a-f\d]{24}$/i.test(id)) {
return fail(404, "Social post not found");
}

const existing = await prisma.socialPost.findUnique({ where: { id } });
if (!existing) return fail(404, "Social post not found");

const body = await readJson<{ isVisible: boolean }>(request);
if (typeof body.isVisible !== "boolean") {
return fail(422, "isVisible must be a boolean");
}

const updated = await prisma.socialPost.update({
where: { id },
data: { isVisible: body.isVisible },
});

return ok(updated, "Social post visibility updated successfully");
});
}
Loading
Loading