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)/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/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/(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/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/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" ? ( +