From 223f8851f2fdd96b1a032470245b97db67964fd4 Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Tue, 11 Aug 2026 19:43:29 +0530 Subject: [PATCH 1/2] Let the attendance account scan without making it an admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restricting markAttendance to ADMIN closed a real hole — a participant can mint their own QR through /api/form/attendanceCode and post it back to mark themselves present — but it also locked out attendance@fedkiit.com, which is a plain USER and exists only to run a scanner at the door. Since that shipped, nobody has been able to scan. Promoting the account to ADMIN would fix it and also hand a shared login the ability to edit and delete events and export payment proofs, so instead the check becomes a permission of its own. FORM_ATTENDANCE_MARK holds the six senior-executive roles the Express author wrote and then commented out in routes/api/forms/formRoutes.js, and FORM_ATTENDANCE_ALLOWED_EMAILS names addresses that qualify regardless of role, the way FORM_ANALYTICS_ALLOWED_EMAILS already did. The account stays a USER, so it gains the scanner page and markAttendance and nothing else: addForm, editForm and deleteForm still ask isAdmin. Verified across eight role and address combinations. export-attendance stays ADMIN-only. It includes payment proof screenshots, which is a wider disclosure than scanning and a separate decision. --- .env.example | 7 ++++ app/(main)/profile/attendance/page.jsx | 23 +++++++----- app/api/form/markAttendance/route.ts | 11 +++--- lib/auth/permissions.ts | 50 ++++++++++++++++++++++---- lib/auth/roles.ts | 22 ++++++++++++ lib/env.ts | 9 +++++ 6 files changed, 104 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index c40fddd..68446a0 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/(main)/profile/attendance/page.jsx b/app/(main)/profile/attendance/page.jsx index 3ef9e6e..caa1d4f 100644 --- a/app/(main)/profile/attendance/page.jsx +++ b/app/(main)/profile/attendance/page.jsx @@ -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() { @@ -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 ; } diff --git a/app/api/form/markAttendance/route.ts b/app/api/form/markAttendance/route.ts index c13e7d7..faa900f 100644 --- a/app/api/form/markAttendance/route.ts +++ b/app/api/form/markAttendance/route.ts @@ -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 @@ -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 }); diff --git a/lib/auth/permissions.ts b/lib/auth/permissions.ts index 3b36219..672f81d 100644 --- a/lib/auth/permissions.ts +++ b/lib/auth/permissions.ts @@ -1,7 +1,7 @@ import "server-only"; import type { SafeUser } from "@/lib/auth/access"; -import { FORM_ANALYTICS_ROLES } from "@/lib/auth/roles"; +import { FORM_ANALYTICS_ROLES, FORM_ATTENDANCE_ROLES } from "@/lib/auth/roles"; import { envList, getEnv } from "@/lib/env"; /** @@ -18,6 +18,9 @@ import { envList, getEnv } from "@/lib/env"; export const PERMISSIONS = { /** Read a form's registration analytics (EventStats). */ FORM_ANALYTICS_VIEW: new Set(FORM_ANALYTICS_ROLES), + + /** Scan a QR and mark a registrant present. */ + FORM_ATTENDANCE_MARK: new Set(FORM_ATTENDANCE_ROLES), } satisfies Record>; export type Permission = keyof typeof PERMISSIONS; @@ -35,6 +38,25 @@ export function can( return PERMISSIONS[permission].has(user.access); } +/** + * Role check, or failing that an address on a configured allowlist. + * + * The allowlist exists for accounts whose role cannot express what they are + * for: a shared scanning login is a plain USER, and promoting it to ADMIN to + * let it scan would also let it edit and delete events. + */ +function canByRoleOrEmail( + user: Pick | null | undefined, + permission: Permission, + configured: string | undefined, +): boolean { + if (!user) return false; + if (can(user, permission)) return true; + + const allowed = envList(configured).map((entry) => entry.toLowerCase()); + return allowed.includes(user.email.toLowerCase()); +} + /** * The whole of the Express analytics rule, in one place: * @@ -49,11 +71,27 @@ export function can( export function canViewFormAnalytics( user: Pick | null | undefined, ): boolean { - if (!user) return false; - if (can(user, "FORM_ANALYTICS_VIEW")) return true; + return canByRoleOrEmail( + user, + "FORM_ANALYTICS_VIEW", + getEnv().FORM_ANALYTICS_ALLOWED_EMAILS, + ); +} - const allowed = envList(getEnv().FORM_ANALYTICS_ALLOWED_EMAILS).map((entry) => - entry.toLowerCase(), +/** + * May scan a QR and mark someone present. + * + * Grants the scanner page and `markAttendance`, and nothing else — an account + * listed in `FORM_ATTENDANCE_ALLOWED_EMAILS` still cannot add, edit or delete + * an event, because those routes ask `isAdmin` and this does not change what + * role the account holds. + */ +export function canMarkAttendance( + user: Pick | null | undefined, +): boolean { + return canByRoleOrEmail( + user, + "FORM_ATTENDANCE_MARK", + getEnv().FORM_ATTENDANCE_ALLOWED_EMAILS, ); - return allowed.includes(user.email.toLowerCase()); } diff --git a/lib/auth/roles.ts b/lib/auth/roles.ts index 9b1c318..6abdbeb 100644 --- a/lib/auth/roles.ts +++ b/lib/auth/roles.ts @@ -37,3 +37,25 @@ export const FORM_ANALYTICS_ROLES_CLIENT: readonly string[] = [ ...FORM_ANALYTICS_ROLES, "ADMIN", ]; + +/** + * May scan a QR and mark someone present. + * + * This is the list the Express author wrote and then commented out, in + * FED-Backend/routes/api/forms/formRoutes.js — `markAttendance` shipped with no + * check at all, so any signed-in participant could mark themselves present with + * a QR they generated themselves. Restricting it to ADMIN closed that, but also + * locked out the attendance@fedkiit.com service account, whose whole purpose is + * scanning. The intended list is restored here instead. + * + * Deliberately narrower than "any club member": marking attendance decides who + * is eligible for a certificate. + */ +export const FORM_ATTENDANCE_ROLES = [ + "SENIOR_EXECUTIVE_TECHNICAL", + "SENIOR_EXECUTIVE_CREATIVE", + "SENIOR_EXECUTIVE_MARKETING", + "SENIOR_EXECUTIVE_OPERATIONS", + "SENIOR_EXECUTIVE_PR_AND_FINANCE", + "SENIOR_EXECUTIVE_HUMAN_RESOURCE", +] as const; diff --git a/lib/env.ts b/lib/env.ts index cead718..2b4e2bd 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -69,6 +69,15 @@ const schema = z.object({ */ FORM_ANALYTICS_ALLOWED_EMAILS: z.string().optional(), + /** + * Addresses allowed to scan QRs and mark attendance regardless of role. + * Comma-separated. Intended for shared scanning accounts such as + * attendance@fedkiit.com, which is a plain USER and would otherwise need + * full ADMIN — and with it the ability to edit and delete events — just to + * run a scanner at the door. + */ + FORM_ATTENDANCE_ALLOWED_EMAILS: z.string().optional(), + /** * Calendar month the academic year rolls over in, 1-12. July by default: * a student admitted in 2022 is in their 4th year until July 2026, not until From 2eb481dd9e896950d80f5a952d71d462b8b7f962 Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Wed, 12 Aug 2026 16:23:56 +0530 Subject: [PATCH 2/2] Merge the Insights page and social post management from PR #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (#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/. --- app/(main)/Blog/page.jsx | 12 +- app/(main)/Insights/page.jsx | 30 ++ app/(main)/Insights/page.module.scss | 23 ++ app/(main)/Social/page.jsx | 12 +- app/(main)/Social/page.module.scss | 23 ++ app/(main)/profile/social/page.jsx | 20 ++ app/api/social-posts/[id]/route.ts | 100 +++++++ app/api/social-posts/[id]/visibility/route.ts | 46 +++ app/api/social-posts/route.ts | 96 +++++++ app/components/Navbar.tsx | 2 +- app/sitemap.ts | 3 +- components/BlogFeed.module.scss | 75 +++++ components/BlogFeed.tsx | 49 ++++ components/SocialFeed.module.scss | 57 ++++ components/SocialFeed.tsx | 43 +++ components/SocialManagementAdmin.tsx | 262 ++++++++++++++++++ lib/types/SocialPost.ts | 30 ++ lib/utils/embedUrl.ts | 76 +++++ next.config.ts | 7 + prisma/schema.prisma | 13 + proxy.ts | 5 +- src/layouts/Profile/Sidebar/Sidebar.jsx | 21 ++ 22 files changed, 986 insertions(+), 19 deletions(-) create mode 100644 app/(main)/Insights/page.jsx create mode 100644 app/(main)/Insights/page.module.scss create mode 100644 app/(main)/Social/page.module.scss create mode 100644 app/(main)/profile/social/page.jsx create mode 100644 app/api/social-posts/[id]/route.ts create mode 100644 app/api/social-posts/[id]/visibility/route.ts create mode 100644 app/api/social-posts/route.ts create mode 100644 components/BlogFeed.module.scss create mode 100644 components/BlogFeed.tsx create mode 100644 components/SocialFeed.module.scss create mode 100644 components/SocialFeed.tsx create mode 100644 components/SocialManagementAdmin.tsx create mode 100644 lib/types/SocialPost.ts create mode 100644 lib/utils/embedUrl.ts diff --git a/app/(main)/Blog/page.jsx b/app/(main)/Blog/page.jsx index b8cb8b6..9055f97 100644 --- a/app/(main)/Blog/page.jsx +++ b/app/(main)/Blog/page.jsx @@ -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 ; +export default function BlogPage() { + redirect("/Insights"); } diff --git a/app/(main)/Insights/page.jsx b/app/(main)/Insights/page.jsx new file mode 100644 index 0000000..16ec32f --- /dev/null +++ b/app/(main)/Insights/page.jsx @@ -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 ( +
+
+

+ Our Socials +

+ +
+
+

+ Our Blogs +

+ +
+
+ ); +} diff --git a/app/(main)/Insights/page.module.scss b/app/(main)/Insights/page.module.scss new file mode 100644 index 0000000..92886ff --- /dev/null +++ b/app/(main)/Insights/page.module.scss @@ -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; + } +} diff --git a/app/(main)/Social/page.jsx b/app/(main)/Social/page.jsx index 0602695..eff552d 100644 --- a/app/(main)/Social/page.jsx +++ b/app/(main)/Social/page.jsx @@ -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 ; +export default function SocialPage() { + redirect("/Insights"); } diff --git a/app/(main)/Social/page.module.scss b/app/(main)/Social/page.module.scss new file mode 100644 index 0000000..92886ff --- /dev/null +++ b/app/(main)/Social/page.module.scss @@ -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; + } +} diff --git a/app/(main)/profile/social/page.jsx b/app/(main)/profile/social/page.jsx new file mode 100644 index 0000000..c8b5511 --- /dev/null +++ b/app/(main)/profile/social/page.jsx @@ -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 ; +} diff --git a/app/api/social-posts/[id]/route.ts b/app/api/social-posts/[id]/route.ts new file mode 100644 index 0000000..0b23977 --- /dev/null +++ b/app/api/social-posts/[id]/route.ts @@ -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 | 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(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"); + }); +} diff --git a/app/api/social-posts/[id]/visibility/route.ts b/app/api/social-posts/[id]/visibility/route.ts new file mode 100644 index 0000000..745a1e5 --- /dev/null +++ b/app/api/social-posts/[id]/visibility/route.ts @@ -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 | 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"); + }); +} diff --git a/app/api/social-posts/route.ts b/app/api/social-posts/route.ts new file mode 100644 index 0000000..f906fb7 --- /dev/null +++ b/app/api/social-posts/route.ts @@ -0,0 +1,96 @@ +import { prisma } from "@/lib/db"; +import { handleRoute, ok, fail, readJson } from "@/lib/api/respond"; +import { deriveEmbedUrl } from "@/lib/utils/embedUrl"; +import { getCurrentUser, isAdmin } from "@/lib/auth/access"; +import type { Platform, CreateSocialPostInput } from "@/lib/types/SocialPost"; + +const VALID_PLATFORMS: Platform[] = ["instagram", "linkedin"]; + +/** + * Mutating routes are ADMIN-only, decided from the signed-in session like + * every other admin route here. + * + * This replaced an `x-admin-secret` header checked against a shared password. + * That was a reasonable stopgap for a contributor without an admin account, + * but it authenticated nobody in particular: one password for everyone, no + * record of who changed a post, and the browser had to hold the secret to send + * it. The session cookie is httpOnly and already identifies the person. + */ +async function requireAdmin(): Promise | 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; +} + +/** + * GET /api/social-posts + * + * `?visible=true` returns the published posts and is open to anyone. The + * unfiltered list also contains posts an admin has deliberately hidden, so it + * is ADMIN-only — otherwise unpublishing a post would still leave it readable + * to anyone who removed the query string. + * + * The public /Insights page does not use this route at all; SocialFeed reads + * Prisma directly as a server component. + */ +export async function GET(request: Request) { + return handleRoute(async () => { + const { searchParams } = new URL(request.url); + const visibleOnly = searchParams.get("visible") === "true"; + + if (!visibleOnly) { + const denied = await requireAdmin(); + if (denied) return denied; + } + + const where = visibleOnly ? { isVisible: true } : {}; + + const posts = await prisma.socialPost.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return ok(posts, "Social posts fetched successfully"); + }); +} + +/** + * POST /api/social-posts + * Create a new social post. ADMIN only. + */ +export async function POST(request: Request) { + return handleRoute(async () => { + const denied = await requireAdmin(); + if (denied) return denied; + + const body = await readJson(request); + + if (!body.platform || !VALID_PLATFORMS.includes(body.platform)) { + return fail(422, "Platform must be 'instagram' or 'linkedin'"); + } + if (!body.url || typeof body.url !== "string") { + return fail(422, "A valid URL is required"); + } + + const embedUrl = deriveEmbedUrl(body.platform, body.url); + if (!embedUrl) { + return fail( + 422, + `Could not derive embed URL for platform "${body.platform}". Check the URL format.`, + ); + } + + const post = await prisma.socialPost.create({ + data: { + platform: body.platform, + url: body.url.trim(), + embedUrl, + caption: body.caption?.trim() || null, + isVisible: body.isVisible ?? true, + }, + }); + + return ok(post, "Social post created successfully"); + }); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 49de2fd..a45640a 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -14,7 +14,7 @@ const navLinks = [ { href: "/", label: "Home" }, { href: "/Events", label: "Events" }, { href: "/Team", label: "Team" }, - { href: "/Blog", label: "Insights" }, + { href: "/Insights", label: "Insights" }, ]; export default function Navbar() { diff --git a/app/sitemap.ts b/app/sitemap.ts index b26d21c..889577b 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -24,8 +24,7 @@ export default async function sitemap(): Promise { { path: "/events/past", changeFrequency: "monthly", priority: 0.6 }, { path: "/team", changeFrequency: "monthly", priority: 0.8 }, { path: "/alumni", changeFrequency: "monthly", priority: 0.5 }, - { path: "/blog", changeFrequency: "weekly", priority: 0.7 }, - { path: "/social", changeFrequency: "monthly", priority: 0.4 }, + { path: "/Insights", changeFrequency: "weekly", priority: 0.7 }, { path: "/privacy-policy", changeFrequency: "yearly", priority: 0.2 }, { path: "/terms-and-conditions", diff --git a/components/BlogFeed.module.scss b/components/BlogFeed.module.scss new file mode 100644 index 0000000..db7fa64 --- /dev/null +++ b/components/BlogFeed.module.scss @@ -0,0 +1,75 @@ +// SCSS module for BlogFeed. Tailwind is not loaded in this project. + +.feed { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + gap: 2rem; + width: 100%; + max-width: 80rem; + margin: 0 auto; + padding: 2rem 1rem; + box-sizing: border-box; +} + +.card { + flex: 1 1 280px; + min-width: 280px; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border: 1px solid #27272a; + border-radius: 0.75rem; + background: rgba(24, 24, 27, 0.5); + text-decoration: none; + transition: border-color 0.2s ease; + + &:hover { + border-color: #52525b; + } +} + +.image { + width: 100%; + height: 12rem; + object-fit: cover; + border-radius: 0.5rem; +} + +.title { + font-size: 1.125rem; + font-weight: 600; + color: #fff; + margin: 0; +} + +.summary { + font-size: 0.875rem; + color: #9ca3af; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.date { + font-size: 0.75rem; + color: #6b7280; + margin: 0; +} + +.empty { + text-align: center; + color: #9ca3af; + padding: 3rem 0; +} + +@media (max-width: 700px) { + .card { + max-width: 100%; + } +} diff --git a/components/BlogFeed.tsx b/components/BlogFeed.tsx new file mode 100644 index 0000000..ddf219c --- /dev/null +++ b/components/BlogFeed.tsx @@ -0,0 +1,49 @@ +import { prisma } from "@/lib/db"; +import styles from "./BlogFeed.module.scss"; + +// Server component: public blogs as cards in side-by-side columns. +// Styled via SCSS module — Tailwind is not loaded in this project. +export default async function BlogFeed() { + const blogs = await prisma.blog.findMany({ + where: { visibility: "public" }, + orderBy: { date: "desc" }, + }); + + if (blogs.length === 0) { + return

No blogs to show yet.

; + } + + return ( +
+ {blogs.map((blog) => ( + + {blog.image && ( + // eslint-disable-next-line @next/next/no-img-element + {blog.title + )} +

{blog.title || "Untitled Blog"}

+ {blog.summary &&

{blog.summary}

} +

+ {blog.date + ? new Date(blog.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }) + : ""} +

+
+ ))} +
+ ); +} diff --git a/components/SocialFeed.module.scss b/components/SocialFeed.module.scss new file mode 100644 index 0000000..654e3c6 --- /dev/null +++ b/components/SocialFeed.module.scss @@ -0,0 +1,57 @@ +// SCSS module for SocialFeed. +// This project does NOT load Tailwind (see app/globals.scss), so all layout +// must come from CSS modules like this one. + +.feed { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; // tops of all columns line up + gap: 2rem; + width: 100%; + max-width: 80rem; + margin: 0 auto; + padding: 2rem 1rem; + box-sizing: border-box; +} + +.card { + flex: 1 1 280px; + min-width: 280px; + max-width: 400px; + display: flex; + flex-direction: column; // each card stacks its content vertically + gap: 0.75rem; +} + +.embed { + width: 100%; + border: 1px solid #27272a; + border-radius: 0.75rem; + min-height: 540px; +} + +.embedLinkedin { + composes: embed; + min-height: 400px; + background: #fff; +} + +.caption { + font-size: 0.875rem; + color: #9ca3af; + padding: 0 0.25rem; + margin: 0; +} + +.empty { + text-align: center; + color: #9ca3af; + padding: 3rem 0; +} + +@media (max-width: 700px) { + .card { + max-width: 100%; + } +} diff --git a/components/SocialFeed.tsx b/components/SocialFeed.tsx new file mode 100644 index 0000000..64d6bcb --- /dev/null +++ b/components/SocialFeed.tsx @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/db"; +import styles from "./SocialFeed.module.scss"; + +// Server component: pulls visible social posts from the DB and lays them out +// in side-by-side columns (flex-wrap); each card stacks vertically. +// Styled via SCSS module — Tailwind is not loaded in this project. +export default async function SocialFeed() { + const posts = await prisma.socialPost.findMany({ + where: { isVisible: true }, + orderBy: { createdAt: "desc" }, + }); + + if (posts.length === 0) { + return

No social posts to show yet.

; + } + + return ( +
+ {posts.map((post) => ( +
+ {post.platform === "instagram" ? ( +