diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index ffb9a601a..558486ffb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -6,8 +6,13 @@ on: pull_request: branches: [main, master] +permissions: + contents: read + issues: read + pull-requests: read + env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.VWC_GITHUB_TOKEN }} jobs: test: diff --git a/src/pages/admin/blog-images.tsx b/src/pages/admin/blog-images.tsx index 10dd1e889..a3bdb910e 100644 --- a/src/pages/admin/blog-images.tsx +++ b/src/pages/admin/blog-images.tsx @@ -1,11 +1,13 @@ /** * Admin page for managing blog images * Accessible at /admin/blog-images + * Protected by server-side authentication */ import React, { useEffect, useState } from 'react'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/router'; +import type { GetServerSideProps } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { options } from '@/pages/api/auth/options'; import BlogImageManager from '@/components/blog-image-manager'; import { getAllBlogImages, @@ -15,44 +17,17 @@ import { } from '@/lib/blog-images'; const BlogImagesAdminPage: React.FC = () => { - const { data: session, status } = useSession(); - const router = useRouter(); const [stats, setStats] = useState | null>(null); const [allImages, setAllImages] = useState([]); const [nonCloudinaryImages, setNonCloudinaryImages] = useState([]); const [activeTab, setActiveTab] = useState<'manager' | 'stats' | 'list'>('manager'); useEffect(() => { - if (status === 'loading') return; - - if (!session) { - router.push('/login'); - return; - } - - // Check if user is admin - if (session.user?.role !== 'ADMIN') { - router.push('/dashboard'); - return; - } - - // Load blog image data + // Load blog image data on mount setStats(getBlogImageStats()); setAllImages(getAllBlogImages()); setNonCloudinaryImages(getBlogsWithoutCloudinaryImages()); - }, [session, status, router]); - - if (status === 'loading') { - return ( -
-

Loading...

-
- ); - } - - if (!session || session.user?.role !== 'ADMIN') { - return null; - } + }, []); return (
@@ -290,4 +265,33 @@ const BlogImagesAdminPage: React.FC = () => { ); }; +export const getServerSideProps: GetServerSideProps = async (context) => { + // Check authentication + const session = await getServerSession(context.req, context.res, options); + + // Redirect if not authenticated + if (!session?.user) { + return { + redirect: { + destination: '/login?callbackUrl=/admin/blog-images', + permanent: false, + }, + }; + } + + // Check for ADMIN role + if (session.user.role !== 'ADMIN') { + return { + redirect: { + destination: '/', + permanent: false, + }, + }; + } + + return { + props: {}, + }; +}; + export default BlogImagesAdminPage; diff --git a/src/pages/admin/courses.tsx b/src/pages/admin/courses.tsx index 85d433cc8..569b1638b 100644 --- a/src/pages/admin/courses.tsx +++ b/src/pages/admin/courses.tsx @@ -1,27 +1,28 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import Link from "next/link"; -import { useSession } from "next-auth/react"; import Layout01 from "@layout/layout-01"; -import type { GetStaticProps, NextPage } from "next"; +import type { GetServerSideProps, NextPage } from "next"; +import { getServerSession } from "next-auth/next"; +import { options } from "@/pages/api/auth/options"; +import prisma from "@/lib/prisma"; import SEO from "@components/seo/page-seo"; import Breadcrumb from "@components/breadcrumb"; type Course = { id: string; title: string; - description: string; - instructor: string; - duration: string; - level: "Beginner" | "Intermediate" | "Advanced"; - enrollments: number; - status: "published" | "draft" | "archived"; + description: string | null; + difficulty: string; + category: string; + isPublished: boolean; createdAt: string; updatedAt: string; - modules: number; - lessons: number; + moduleCount: number; + enrollmentCount: number; }; type PageProps = { + courses: Course[]; layout?: { headerShadow: boolean; headerFluid: boolean; @@ -33,148 +34,56 @@ type PageWithLayout = NextPage & { Layout?: typeof Layout01; }; -const ADMIN_GITHUB_USERNAME = "jeromehardaway"; - -const AdminCoursesPage: PageWithLayout = () => { - const { data: session, status } = useSession(); - const [courses, setCourses] = useState([]); +const AdminCoursesPage: PageWithLayout = ({ courses: initialCourses }) => { const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState<"all" | "published" | "draft" | "archived">( - "all" - ); - const [levelFilter, setLevelFilter] = useState< - "all" | "Beginner" | "Intermediate" | "Advanced" + const [statusFilter, setStatusFilter] = useState<"all" | "published" | "draft">("all"); + const [difficultyFilter, setDifficultyFilter] = useState< + "all" | "BEGINNER" | "INTERMEDIATE" | "ADVANCED" >("all"); - useEffect(() => { - // TODO: Fetch real course data from API - // Mock data for demonstration - const mockCourses: Course[] = [ - { - id: "1", - title: "Introduction to Web Development", - description: "Learn the basics of HTML, CSS, and JavaScript", - instructor: "Jerome Hardaway", - duration: "8 weeks", - level: "Beginner", - enrollments: 47, - status: "published", - createdAt: "2025-07-01", - updatedAt: "2025-08-15", - modules: 4, - lessons: 24, - }, - { - id: "2", - title: "JavaScript Fundamentals", - description: "Deep dive into JavaScript programming concepts", - instructor: "Alex Thompson", - duration: "6 weeks", - level: "Intermediate", - enrollments: 32, - status: "published", - createdAt: "2025-07-15", - updatedAt: "2025-08-10", - modules: 3, - lessons: 18, - }, - { - id: "3", - title: "React Development Bootcamp", - description: "Build modern web applications with React", - instructor: "Sarah Chen", - duration: "12 weeks", - level: "Advanced", - enrollments: 23, - status: "draft", - createdAt: "2025-08-01", - updatedAt: "2025-08-28", - modules: 6, - lessons: 36, - }, - { - id: "4", - title: "Python for Veterans", - description: "Programming fundamentals with Python", - instructor: "Mike Rodriguez", - duration: "10 weeks", - level: "Beginner", - enrollments: 15, - status: "published", - createdAt: "2025-06-15", - updatedAt: "2025-08-20", - modules: 5, - lessons: 30, - }, - ]; - setCourses(mockCourses); - }, []); - - if (status === "loading") { - return ( -
-
-
-

Loading courses...

-
-
- ); - } - - // Check admin access - if (!session || session.user?.email !== `${ADMIN_GITHUB_USERNAME}@users.noreply.github.com`) { - return ( -
-
-

- Access Denied -

-

Administrator access required.

- - ← Back to Admin - -
-
- ); - } - // Filter courses - const filteredCourses = courses.filter((course) => { + const filteredCourses = initialCourses.filter((course) => { const matchesSearch = course.title.toLowerCase().includes(searchTerm.toLowerCase()) || - course.description.toLowerCase().includes(searchTerm.toLowerCase()) || - course.instructor.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesStatus = statusFilter === "all" || course.status === statusFilter; - const matchesLevel = levelFilter === "all" || course.level === levelFilter; - return matchesSearch && matchesStatus && matchesLevel; + (course.description?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false) || + course.category.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesStatus = + statusFilter === "all" || + (statusFilter === "published" && course.isPublished) || + (statusFilter === "draft" && !course.isPublished); + const matchesDifficulty = + difficultyFilter === "all" || course.difficulty === difficultyFilter; + return matchesSearch && matchesStatus && matchesDifficulty; }); - const getStatusBadge = (courseStatus: Course["status"]) => { - const styles = { - published: "tw-bg-gold-light/30 tw-text-gold-deep", - draft: "tw-bg-gold-bright tw-text-gold-deep", - archived: "tw-bg-gray-100 tw-text-gray-400", - }; + const getStatusBadge = (isPublished: boolean) => { + if (isPublished) { + return ( + + Published + + ); + } return ( - - {courseStatus.charAt(0).toUpperCase() + courseStatus.slice(1)} + + Draft ); }; - const getLevelBadge = (level: Course["level"]) => { + const getDifficultyBadge = (difficulty: string) => { const styles = { - Beginner: "tw-bg-navy-sky tw-text-blue-800", - Intermediate: "tw-bg-purple-100 tw-text-purple-800", - Advanced: "tw-bg-red-signal tw-text-red-dark", + BEGINNER: "tw-bg-green-100 tw-text-green-800", + INTERMEDIATE: "tw-bg-yellow-100 tw-text-yellow-800", + ADVANCED: "tw-bg-red-100 tw-text-red-800", }; return ( - {level} + {difficulty} ); }; @@ -234,7 +143,7 @@ const AdminCoursesPage: PageWithLayout = () => { id="search" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} - placeholder="Title, description, or instructor..." + placeholder="Title, description, or category..." className="tw-mt-1 tw-block tw-w-full tw-rounded-md tw-border tw-border-gray-300 tw-px-3 tw-py-2 tw-text-sm focus:tw-border-primary focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-primary" />
@@ -258,37 +167,36 @@ const AdminCoursesPage: PageWithLayout = () => { -
- {/* Level Filter */} + {/* Difficulty Filter */}
{/* Results Count */}
- Showing {filteredCourses.length} of {courses.length} courses + Showing {filteredCourses.length} of {initialCourses.length} courses
@@ -303,8 +211,8 @@ const AdminCoursesPage: PageWithLayout = () => { >
- {getStatusBadge(course.status)} - {getLevelBadge(course.level)} + {getStatusBadge(course.isPublished)} + {getDifficultyBadge(course.difficulty)}
@@ -295,19 +211,19 @@ const AdminUsersPage: PageWithLayout = () => { User - Military Info + Role - Courses + Military Info - Progress + Enrollments Status - Last Active + Joined Actions @@ -319,11 +235,11 @@ const AdminUsersPage: PageWithLayout = () => {
- {user.avatar ? ( + {user.image ? ( {user.name} ) : (
@@ -332,7 +248,7 @@ const AdminUsersPage: PageWithLayout = () => { )}
- {user.name} + {user.name || "No name"}
{user.email} @@ -341,13 +257,16 @@ const AdminUsersPage: PageWithLayout = () => {
- {user.militaryBranch && user.rank ? ( + {getRoleBadge(user.role)} + + + {user.branch && user.rank ? (
{user.rank}
- {user.militaryBranch} + {user.branch}
) : ( @@ -357,25 +276,13 @@ const AdminUsersPage: PageWithLayout = () => { )} - {user.enrollments} enrolled - - -
-
- {user.progress}% -
-
-
-
-
+ {user.enrollmentCount} - {getStatusBadge(user.status)} + {getStatusBadge(user.isActive)} - {new Date(user.lastActive).toLocaleDateString()} + {new Date(user.createdAt).toLocaleDateString()}
@@ -422,9 +329,72 @@ const AdminUsersPage: PageWithLayout = () => { AdminUsersPage.Layout = Layout01; -export const getStaticProps: GetStaticProps = () => { +export const getServerSideProps: GetServerSideProps = async (context) => { + // Check authentication + const session = await getServerSession(context.req, context.res, options); + + // Redirect if not authenticated + if (!session?.user) { + return { + redirect: { + destination: "/login?callbackUrl=/admin/users", + permanent: false, + }, + }; + } + + // Check for ADMIN role + if (session.user.role !== "ADMIN") { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + // Fetch all users with enrollment counts + const usersWithEnrollments = await prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + image: true, + role: true, + isActive: true, + branch: true, + rank: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + enrollments: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Transform data for component + const users: User[] = usersWithEnrollments.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + role: user.role, + isActive: user.isActive, + branch: user.branch, + rank: user.rank, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + enrollmentCount: user._count.enrollments, + })); + return { props: { + users, layout: { headerShadow: true, headerFluid: false, diff --git a/src/pages/courses/index.tsx b/src/pages/courses/index.tsx index 8cec7ad4d..521ad3658 100644 --- a/src/pages/courses/index.tsx +++ b/src/pages/courses/index.tsx @@ -13,6 +13,7 @@ type PageProps = { name: string | null; email: string; image: string | null; + role: string; }; layout?: { headerShadow: boolean; @@ -40,8 +41,8 @@ const CoursesIndex: PageWithLayout = ({ user }) => { {/* Header with Admin and User Menu */}
- {/* Admin Access Button (only for jeromehardaway) */} - {user.email === "jeromehardaway@users.noreply.github.com" && ( + {/* Admin Access Button (only for ADMIN role) */} + {user.role === "ADMIN" && ( = async (context) name: session.user.name || null, email: session.user.email || "", image: session.user.image || null, + role: session.user.role || "STUDENT", }, layout: { headerShadow: true,