From 12fbf8bf9416205d269445f826068ff4cf3f1cdf Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 22 May 2026 18:12:39 +0000 Subject: [PATCH 1/5] feat(fe): implement server side course authorization --- .../_components/CourseDetailTabs.tsx | 62 ++++++++++ .../app/admin/course/[courseId]/layout.tsx | 110 ++++++++---------- 2 files changed, 110 insertions(+), 62 deletions(-) create mode 100644 apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx diff --git a/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx b/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx new file mode 100644 index 0000000000..a7b9506d09 --- /dev/null +++ b/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx @@ -0,0 +1,62 @@ +'use client' + +import { cn } from '@/libs/utils' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +interface CourseDetailTabsProps { + courseId: string + courseCode: string + courseTitle: string +} + +export function CourseDetailTabs({ + courseId, + courseCode, + courseTitle +}: CourseDetailTabsProps) { + const pathname = usePathname() + + const tabs = [ + { name: 'Home', href: `/admin/course/${courseId}` }, + { name: 'Member', href: `/admin/course/${courseId}/user` }, + { name: 'Assignment', href: `/admin/course/${courseId}/assignment` }, + { name: 'Exercise', href: `/admin/course/${courseId}/exercise` } + ] + + const activeTabName = + tabs.find((tab) => pathname === tab.href)?.name || 'Home' + + return ( + <> +
+

{activeTabName}

+

+ [{courseCode}] {courseTitle} +

+
+ +
+ +
+ + ) +} diff --git a/apps/frontend/app/admin/course/[courseId]/layout.tsx b/apps/frontend/app/admin/course/[courseId]/layout.tsx index f736e36485..3d3e772e37 100644 --- a/apps/frontend/app/admin/course/[courseId]/layout.tsx +++ b/apps/frontend/app/admin/course/[courseId]/layout.tsx @@ -1,77 +1,63 @@ -'use client' +import { auth } from '@/libs/auth' +import { safeFetcherWithAuth } from '@/libs/utils' +import type { Course, JoinedCourse } from '@/types/type' +import { redirect } from 'next/navigation' +import { CourseDetailTabs } from './_components/CourseDetailTabs' -import { GET_COURSE } from '@/graphql/course/queries' -import { cn } from '@/libs/utils' -import { useQuery } from '@apollo/client' -import Link from 'next/link' -import { useParams, usePathname } from 'next/navigation' - -export default function CourseDetailLayout({ - children +export default async function CourseDetailLayout({ + children, + params }: { children: React.ReactNode + params: Promise<{ courseId: string }> }) { - const pathname = usePathname() - const params = useParams() - const courseId = params?.courseId as string - - const { data, loading } = useQuery(GET_COURSE, { - variables: { groupId: Number(courseId) }, - skip: !courseId - }) + const { courseId } = await params + const session = await auth() - const currentCourse = data?.getCourse + // Admin/SuperAdmin은 groupLeader 체크 없이 통과 (GroupLeaderGuard와 동일한 로직) + const isAdmin = session?.user?.role !== 'User' + if (!isAdmin) { + try { + const joinedCourses: JoinedCourse[] = await safeFetcherWithAuth + .get('course/joined') + .json() - const courseNum = currentCourse?.courseInfo?.courseNum - const classNum = currentCourse?.courseInfo?.classNum + const isGroupLeaderOfThisCourse = joinedCourses.some( + (course) => + course.id === Number(courseId) && course.isGroupLeader === true + ) - const courseCode = courseNum ? `${courseNum}-${classNum}` : courseId - const courseTitle = - currentCourse?.groupName || (loading ? '로딩 중...' : '과목 정보 없음') + if (!isGroupLeaderOfThisCourse) { + redirect('/') + } + } catch { + redirect('/') + } + } - const tabs = [ - { name: 'Home', href: `/admin/course/${courseId}` }, - { name: 'Member', href: `/admin/course/${courseId}/user` }, - { name: 'Assignment', href: `/admin/course/${courseId}/assignment` }, - { name: 'Exercise', href: `/admin/course/${courseId}/exercise` } - ] - - const activeTabName = - tabs.find((tab) => pathname === tab.href)?.name || 'Home' + // course 정보 fetch (이름, 과목코드 표시용) + let courseCode = courseId + let courseTitle = '' + try { + const course: Course = await safeFetcherWithAuth + .get(`course/${courseId}`) + .json() + const courseNum = course.courseInfo?.courseNum + const classNum = course.courseInfo?.classNum + courseCode = courseNum ? `${courseNum}-${classNum}` : courseId + courseTitle = course.groupName + } catch { + // fetch 실패 시 fallback: courseId를 코드로, 빈 제목 사용 + } return (
-
-

{activeTabName}

-

- [{courseCode}] {courseTitle} -

-
- -
-
- -
-
+
{children}
From 74d5aaa4fce41a9acd48920950cdeb49fd8884d1 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 22 May 2026 18:13:04 +0000 Subject: [PATCH 2/5] refactor(be): throw ForbiddenException instead of returning false in group-leader guard --- apps/backend/libs/auth/src/roles/group-leader.guard.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/backend/libs/auth/src/roles/group-leader.guard.ts b/apps/backend/libs/auth/src/roles/group-leader.guard.ts index 722cdefe84..7bc25deece 100644 --- a/apps/backend/libs/auth/src/roles/group-leader.guard.ts +++ b/apps/backend/libs/auth/src/roles/group-leader.guard.ts @@ -1,4 +1,5 @@ import { + ForbiddenException, Injectable, type CanActivate, type ExecutionContext @@ -54,6 +55,8 @@ export class GroupLeaderGuard implements CanActivate { if (isGroupLeader) { return true } - return false + throw new ForbiddenException( + 'You do not have permission to manage this course' + ) } } From 919121ede287dd15a1767049aecabf384aed92d2 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 22 May 2026 18:42:17 +0000 Subject: [PATCH 3/5] refactor(be): migrate course detail layout --- .devcontainer/devcontainer-lock.json | 24 ++++++ .../app/admin/course/[courseId]/layout.tsx | 74 +++++++++++-------- 2 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000000..2edb305a48 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,24 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/pnpm:2": { + "version": "2.0.5", + "resolved": "ghcr.io/devcontainers-extra/features/pnpm@sha256:694c2b6182435c9e9c06f6071728b087c1181b38c18cecf0defe2ab8c11cddd6", + "integrity": "sha256:694c2b6182435c9e9c06f6071728b087c1181b38c18cecf0defe2ab8c11cddd6" + }, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.3.4", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032" + }, + "ghcr.io/devcontainers/features/java:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/java@sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a", + "integrity": "sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a" + }, + "ghcr.io/devcontainers/features/terraform:1": { + "version": "1.4.3", + "resolved": "ghcr.io/devcontainers/features/terraform@sha256:503d0888b3a43a32335e08cd4477f8c4629404ad83f6a36f0e0fee7f567c06c7", + "integrity": "sha256:503d0888b3a43a32335e08cd4477f8c4629404ad83f6a36f0e0fee7f567c06c7" + } + } +} diff --git a/apps/frontend/app/admin/course/[courseId]/layout.tsx b/apps/frontend/app/admin/course/[courseId]/layout.tsx index 3d3e772e37..6e826e048e 100644 --- a/apps/frontend/app/admin/course/[courseId]/layout.tsx +++ b/apps/frontend/app/admin/course/[courseId]/layout.tsx @@ -1,9 +1,21 @@ import { auth } from '@/libs/auth' -import { safeFetcherWithAuth } from '@/libs/utils' -import type { Course, JoinedCourse } from '@/types/type' +import { adminBaseUrl } from '@/libs/constants' import { redirect } from 'next/navigation' import { CourseDetailTabs } from './_components/CourseDetailTabs' +const GET_COURSE_QUERY = ` + query GetCourse($groupId: Int!) { + getCourse(groupId: $groupId) { + id + groupName + courseInfo { + courseNum + classNum + } + } + } +` + export default async function CourseDetailLayout({ children, params @@ -14,42 +26,40 @@ export default async function CourseDetailLayout({ const { courseId } = await params const session = await auth() - // Admin/SuperAdmin은 groupLeader 체크 없이 통과 (GroupLeaderGuard와 동일한 로직) - const isAdmin = session?.user?.role !== 'User' - if (!isAdmin) { - try { - const joinedCourses: JoinedCourse[] = await safeFetcherWithAuth - .get('course/joined') - .json() - - const isGroupLeaderOfThisCourse = joinedCourses.some( - (course) => - course.id === Number(courseId) && course.isGroupLeader === true - ) - - if (!isGroupLeaderOfThisCourse) { - redirect('/') - } - } catch { - redirect('/') - } + if (!session) { + redirect('/') } - // course 정보 fetch (이름, 과목코드 표시용) - let courseCode = courseId - let courseTitle = '' + let json try { - const course: Course = await safeFetcherWithAuth - .get(`course/${courseId}`) - .json() - const courseNum = course.courseInfo?.courseNum - const classNum = course.courseInfo?.classNum - courseCode = courseNum ? `${courseNum}-${classNum}` : courseId - courseTitle = course.groupName + const res = await fetch(adminBaseUrl as string, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: session.token.accessToken + }, + body: JSON.stringify({ + query: GET_COURSE_QUERY, + variables: { groupId: Number(courseId) } + }) + }) + json = await res.json() } catch { - // fetch 실패 시 fallback: courseId를 코드로, 빈 제목 사용 + redirect('/') } + // If the user doesn't have permission, the backend's GroupLeaderGuard + // will throw a ForbiddenException and return errors here. + if (json.errors || !json.data?.getCourse) { + redirect('/') + } + + const course = json.data.getCourse + const courseNum = course.courseInfo?.courseNum + const classNum = course.courseInfo?.classNum + const courseCode = courseNum ? `${courseNum}-${classNum}` : courseId + const courseTitle = course.groupName || '' + return (
From 3bfd58243920dae11a598267aadaf430d3f87bef Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Mon, 25 May 2026 13:03:14 +0000 Subject: [PATCH 4/5] refactor(fe): qna components and implement new features --- .../(main)/_components/NewProblemCard.tsx | 22 +- .../(main)/_components/NewProblemCards.tsx | 33 +-- .../(main)/_components/ServiceCards.tsx | 208 +++++++------- .../admin/_components/ManagementSidebar.tsx | 67 +++-- .../_components/CourseDetailTabs.tsx | 45 ++- .../_components/CourseQnaAnsweredTab.tsx | 32 --- .../app/admin/course/[courseId]/layout.tsx | 2 +- .../app/admin/course/[courseId]/qna/page.tsx | 30 +- .../course/_components/QnaDetailModal.tsx | 266 ++++++++++++++++++ .../app/admin/course/_components/QnaTable.tsx | 108 +++++-- .../course/_components/QnaTableColumns.tsx | 100 ++++--- apps/frontend/components/AlertModal.tsx | 2 +- apps/frontend/graphql/qna/mutation.ts | 38 +++ apps/frontend/graphql/qna/queries.ts | 53 ++++ apps/frontend/public/icons/info.svg | 2 +- apps/frontend/public/icons/lock-gray.svg | 2 +- apps/frontend/types/type.ts | 6 +- 17 files changed, 696 insertions(+), 320 deletions(-) delete mode 100644 apps/frontend/app/admin/course/[courseId]/_components/CourseQnaAnsweredTab.tsx create mode 100644 apps/frontend/app/admin/course/_components/QnaDetailModal.tsx create mode 100644 apps/frontend/graphql/qna/mutation.ts create mode 100644 apps/frontend/graphql/qna/queries.ts diff --git a/apps/frontend/app/(client)/(main)/_components/NewProblemCard.tsx b/apps/frontend/app/(client)/(main)/_components/NewProblemCard.tsx index 6e0e7c766a..ae1b2d7cda 100644 --- a/apps/frontend/app/(client)/(main)/_components/NewProblemCard.tsx +++ b/apps/frontend/app/(client)/(main)/_components/NewProblemCard.tsx @@ -1,34 +1,28 @@ import { Badge } from '@/components/shadcn/badge' import { Card, CardContent } from '@/components/shadcn/card' -import RightArrow from '@/public/icons/arrow-right-narrow.svg' -import GrayRightArrowIcon from '@/public/icons/arrow-right.svg' +import GrayRightArrowIcon from '@/public/icons/arrow-right-gray.svg' import type { Problem } from '@/types/type' import Link from 'next/link' export function NewProblemCard({ problem }: { problem: Problem }) { return (
- - -
+ + +
Level {problem.difficulty.slice(-1)} -

+

{problem.title}

-
-

- Go to Problem +

+

+ 자세히 보기

-
-
- -
-
diff --git a/apps/frontend/app/(client)/(main)/_components/NewProblemCards.tsx b/apps/frontend/app/(client)/(main)/_components/NewProblemCards.tsx index a5a678a0f5..a920d4751d 100644 --- a/apps/frontend/app/(client)/(main)/_components/NewProblemCards.tsx +++ b/apps/frontend/app/(client)/(main)/_components/NewProblemCards.tsx @@ -21,39 +21,24 @@ export async function NewProblemCards() { problems.length > 0 && (
{/* Desktop View */} -
+
-

- PRACTICE WITH CODING PROBLEMS -

+

최신 코딩 문제를 연습해보세요

-
- - {problems.map((problem) => ( - - - - ))} - -
+ + {problems.map((problem) => ( + + + + ))} +
- - -
-

- Go to Problem -

-
- -
-
-
{/* Desktop View */}
diff --git a/apps/frontend/app/(client)/(main)/_components/ServiceCards.tsx b/apps/frontend/app/(client)/(main)/_components/ServiceCards.tsx index 2da1bbf3d7..df609aaec6 100644 --- a/apps/frontend/app/(client)/(main)/_components/ServiceCards.tsx +++ b/apps/frontend/app/(client)/(main)/_components/ServiceCards.tsx @@ -1,120 +1,114 @@ -'use client' +// 'use client' +// import { cn } from '@/libs/utils' +import GraduationIcon from '@/public/icons/graduation_blue.svg' +import LaptopCodingIcon from '@/public/icons/laptop-coding.svg' +import NotificationIcon from '@/public/icons/notification.svg' +import PrizeIcon from '@/public/icons/prize_blue.svg' -import Image from 'next/image' -import Link from 'next/link' +// import { useState } from 'react' -export function ServiceCards() { - return ( -
-
-

- SERVICE WE PROVIDE -

+const SERVICE_TABS = [ + 'NOTICE', + 'CONTEST', + 'PROBLEM', + 'COURSE', + 'STUDY' +] as const -
- -
- CONTEST -
- About Contest -
-

- About Contest -

-

- Professors and students can host coding contests, -
and rankings help enhance learning and motivation. -

-
- +type ServiceTab = (typeof SERVICE_TABS)[number] - -
- NOTICE -
- Stay Informed -
-

- Stay Informed -

-

- Stay updated with the latest news
and announcements. -

-
- +interface Feature { + title: string + desc: string + icon: React.ReactNode +} - -
- PROBLEM -
- Background pattern - Practice with Real problems -
-

- Practice with -
Real problems -

-

- Explore coding challenges -
by level and topic. -

-
- +const FEATURE_LIST: Record = { + NOTICE: [ + { + title: '공지사항', + desc: '최신 업데이트와 공지사항을 빠르게 확인할 수 있어요.', + icon: + } + ], + CONTEST: [ + { + title: '대회', + desc: '대회 개최와 참가를 통해 실력을 겨루고 성장해보세요.', + icon: + } + ], + PROBLEM: [ + { + title: '문제 풀이', + desc: '다양한 난이도와 주제별 문제를 풀며 실전 감각을 키울 수 있어요.', + icon: + } + ], + COURSE: [ + { + title: '강의 지원', + desc: '강의와 연계된 과제 및 실습으로 체계적으로 학습해보세요.', + icon: + } + ], + STUDY: [] +} + +const features = SERVICE_TABS.flatMap((tab) => FEATURE_LIST[tab]) - -
- COURSE +export function ServiceCards() { + // const [selectedTab, setSelectedTab] = useState('CONTEST') + // const features = FEATURE_LIST[selectedTab] + + return ( +
+
+
+

+ 코드당에는 어떤 기능이 있나요? +

+ + {/* +
+
+ {SERVICE_TABS.filter((tab) => FEATURE_LIST[tab].length > 0).map( + (tab) => ( + + ) + )}
- Learn with Courses -
-

- Learn with Courses +

+ */} +
+ +
+ {features.map((feature) => ( +
+

+ {feature.title}

-

- Access course-linked assignments and exercises. -
Learn through professor-curated problem. +

+ {feature.desc}

+ {feature.icon}
- + ))}
diff --git a/apps/frontend/app/admin/_components/ManagementSidebar.tsx b/apps/frontend/app/admin/_components/ManagementSidebar.tsx index bc8611a1cd..0713ec3693 100644 --- a/apps/frontend/app/admin/_components/ManagementSidebar.tsx +++ b/apps/frontend/app/admin/_components/ManagementSidebar.tsx @@ -39,39 +39,39 @@ interface NavItem { path: string icon: IconType | ComponentType<{ className: string }> } - -const getCourseNavItems = (courseId: string): NavItem[] => [ - { - name: 'Home', - path: `/admin/course/${courseId}` as const, - icon: HomeIcon - }, - // { - // name: 'Notice', - // path: `/admin/course/${courseId}/notice`, - // icon: FaBell - // }, - { - name: 'Member', - path: `/admin/course/${courseId}/user` as const, - icon: MemberIcon - }, - { - name: 'Assignment', - path: `/admin/course/${courseId}/assignment` as const, - icon: AssignmentIcon - }, - { - name: 'Exercise', - path: `/admin/course/${courseId}/exercise` as const, - icon: ExerciseIcon - }, - { - name: 'Q&A', - path: `/admin/course/${courseId}/qna` as const, - icon: ExerciseIcon - } -] +// Pending : 2중 사이드바 구조 철회 +// const getCourseNavItems = (courseId: string): NavItem[] => [ +// { +// name: 'Home', +// path: `/admin/course/${courseId}` as const, +// icon: HomeIcon +// }, +// // { +// // name: 'Notice', +// // path: `/admin/course/${courseId}/notice`, +// // icon: FaBell +// // }, +// { +// name: 'Member', +// path: `/admin/course/${courseId}/user` as const, +// icon: MemberIcon +// }, +// { +// name: 'Assignment', +// path: `/admin/course/${courseId}/assignment` as const, +// icon: AssignmentIcon +// }, +// { +// name: 'Exercise', +// path: `/admin/course/${courseId}/exercise` as const, +// icon: ExerciseIcon +// }, +// { +// name: 'Q&A', +// path: `/admin/course/${courseId}/qna` as const, +// icon: ExerciseIcon +// } +// ] interface SidebarLinkProps { item: NavItem @@ -119,7 +119,6 @@ export function ManagementSidebar({ session }: ManagementSidebarProps) { const [isMainSidebarExpanded, setIsMainSidebarExpanded] = useState(true) const [isAnimationComplete, setIsAnimationComplete] = useState(true) const [isCourseSidebarOpened, setIsCourseSidebarOpened] = useState(false) - const [isCourseSidebarExpanded, setIsCourseSidebarExpanded] = useState(true) const [selectedCourseId, setSelectedCourseId] = useState('') const [userPermissions, setUserPermissions] = useState({ canCreateCourse: false, diff --git a/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx b/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx index a7b9506d09..6555923a65 100644 --- a/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx +++ b/apps/frontend/app/admin/course/[courseId]/_components/CourseDetailTabs.tsx @@ -18,23 +18,46 @@ export function CourseDetailTabs({ const pathname = usePathname() const tabs = [ - { name: 'Home', href: `/admin/course/${courseId}` }, - { name: 'Member', href: `/admin/course/${courseId}/user` }, - { name: 'Assignment', href: `/admin/course/${courseId}/assignment` }, - { name: 'Exercise', href: `/admin/course/${courseId}/exercise` } + { name: 'Home', title: 'HOME', href: `/admin/course/${courseId}` }, + { + name: 'Member', + title: 'MEMBER', + description: + "Here's a list of the instructors and students of the course", + href: `/admin/course/${courseId}/user` + }, + { + name: 'Assignment', + title: 'ASSIGNMENT', + description: "Here's a assignment list you made", + href: `/admin/course/${courseId}/assignment` + }, + { + name: 'Exercise', + title: 'EXERCISE', + description: "Here's a exercise list you made", + href: `/admin/course/${courseId}/exercise` + }, + { + name: 'Q&A', + title: 'Question & Answer', + description: + 'Assignment와 Exercise 문제와 관련된 질문과 답변을 제공합니다.', + href: `/admin/course/${courseId}/qna` + } ] const activeTabName = - tabs.find((tab) => pathname === tab.href)?.name || 'Home' + tabs.find((tab) => pathname === tab.href)?.title || 'HOME' return ( <> -
-

{activeTabName}

-

- [{courseCode}] {courseTitle} -

-
+

{activeTabName}

+

+ {activeTabName === 'HOME' + ? `[${courseCode}] ${courseTitle}` + : tabs.find((tab) => tab.title === activeTabName)?.description} +