diff --git a/components/login-card.tsx b/components/login-card.tsx index 49c512a..b010301 100644 --- a/components/login-card.tsx +++ b/components/login-card.tsx @@ -9,7 +9,6 @@ import { useRouter } from "next/navigation" import { adminLogin, LoginFailedError, NetworkError } from "@/lib/api/admin" import { enterExam, AuthError } from "@/lib/api/auth" import { isMasterAdmin, saveAuthInfo } from "@/lib/auth/utils" -import { setCookie } from "@/lib/auth/cookie-utils" import { useExamSessionStore } from "@/lib/stores/exam-session-store" type TabType = "user" | "admin" @@ -28,7 +27,6 @@ export default function LoginCard() { const [userError, setUserError] = useState(""); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); - const setSession = useExamSessionStore((state: { setSession: (examId: number, participantId: number, tokenLimit?: number) => void }) => state.setSession); const handleClick = async () => { @@ -55,17 +53,18 @@ export default function LoginCard() { phone: phoneNumber.trim(), }); - // examId와 participantId를 전역 상태에 저장 + // examId, participantId, accessToken을 한 번에 업데이트 (중간 상태로 소켓 훅이 null 토큰으로 마운트되는 것을 방지) + // STOMP용 accessToken은 Zustand에 저장 (HttpOnly 쿠키는 JS 접근 불가) const examId = response.exam?.id; const participantId = response.participant?.id; - if (examId && participantId) { - setSession(examId, participantId, response.session?.tokenLimit); - } - - // accessToken 저장 - if (response.accessToken) { - setCookie('user_access_token', response.accessToken); + if (examId && participantId && response.accessToken) { + useExamSessionStore.setState({ + examId, + participantId, + tokenLimit: response.session?.tokenLimit ?? 20000, + accessToken: response.accessToken, + }); } // ✅ API 성공 시에만 대기 화면으로 이동 diff --git a/components/logs-content.tsx b/components/logs-content.tsx index 2764336..f571097 100644 --- a/components/logs-content.tsx +++ b/components/logs-content.tsx @@ -45,24 +45,7 @@ export function LogsContent() { const [statusFilter, setStatusFilter] = useState("전체 상태") const [isDropdownOpen, setIsDropdownOpen] = useState(false) - // 추후 로그 조원을 위한 API 연동 가능 (현재는 API 부재로 빈 상태) - /* - useEffect(() => { - const fetchLogs = async () => { - setIsLoading(true); - try { - const response = await fetch('/api/admin/logs'); - const data = await response.json(); - if (data.result) setLogs(data.result); - } catch (e) { - console.error("Failed to fetch logs", e); - } finally { - setIsLoading(false); - } - }; - fetchLogs(); - }, []); - */ + // 로그 API가 준비되면 컴포넌트에서 직접 fetch하지 말고 lib/api 레이어로 연결한다. const filteredLogs = logs.filter((log: LogEntry) => { const matchesSearch = diff --git a/components/problems-content.tsx b/components/problems-content.tsx index 4f161fa..fd3b877 100644 --- a/components/problems-content.tsx +++ b/components/problems-content.tsx @@ -1,8 +1,8 @@ "use client" import { useState, useMemo, useEffect } from "react" -import { getCookie } from "@/lib/auth/cookie-utils" import { Search, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react" +import { getProblems, type AdminProblem } from "@/lib/api/admin" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Switch } from "@/components/ui/switch" @@ -35,55 +35,43 @@ export function ProblemsContent() { const pageSize = 8 const fetchProblems = async () => { - setIsLoading(true); + setIsLoading(true) try { - const token = getCookie('admin_access_token'); - const response = await fetch('/api/admin/problems', { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - const data = await response.json(); - - if (data.code === 'COMMON200' && data.result) { - const mapped: Problem[] = data.result.map((p: any) => { - // Difficulty 매핑 - let difficulty: "쉬움" | "중간" | "어려움" = "쉬움"; - if (p.difficulty === 'MEDIUM') difficulty = "중간"; - if (p.difficulty === 'HARD') difficulty = "어려움"; + const adminProblems = await getProblems() + const mapped: Problem[] = adminProblems.map((problem: AdminProblem) => { + let difficulty: "쉬움" | "중간" | "어려움" = "쉬움" + if (problem.difficulty === "MEDIUM") difficulty = "중간" + if (problem.difficulty === "HARD") difficulty = "어려움" - // Tags 파싱 (JSON String) - let tags: string[] = []; - try { - if (p.tags) { - tags = typeof p.tags === 'string' ? JSON.parse(p.tags) : p.tags; - } - } catch (e) { - console.error("Failed to parse tags:", p.tags); + let tags: string[] = [] + try { + if (problem.tags) { + tags = typeof problem.tags === "string" ? JSON.parse(problem.tags) : problem.tags } + } catch { + console.error("Failed to parse tags:", problem.tags) + } - return { - id: String(p.id), - title: p.title, - version: "v1.0", // BE에 버전 정보가 없으면 기본값 - difficulty, - available: p.status === 'ACTIVE', - tags - }; - }); - setProblems(mapped); - } + return { + id: String(problem.id), + title: problem.title, + version: "v1.0", + difficulty, + available: problem.status === "PUBLISHED" || problem.status === "ACTIVE", + tags, + } + }) + setProblems(mapped) } catch (error) { - console.error("Failed to fetch problems:", error); + console.error("Failed to fetch problems:", error) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } useEffect(() => { - fetchProblems(); - }, []); + fetchProblems() + }, []) const filteredProblems = useMemo(() => { let result = problems diff --git a/components/results-content.tsx b/components/results-content.tsx index 0254b43..687d4c9 100644 --- a/components/results-content.tsx +++ b/components/results-content.tsx @@ -1,22 +1,9 @@ "use client" import { useState, useEffect } from "react" -import { getCookie } from "@/lib/auth/cookie-utils" import Link from "next/link" import { Search, Download, Eye, X, CheckCircle, ChevronLeft, ChevronRight } from "lucide-react" - -interface ResultEntry { - id: string - entryCode: string - total: number - completed: number -} - -interface Toast { - id: string - title: string - description: string -} +import { getExams, type Exam } from "@/lib/api/admin" interface ResultEntry { id: string @@ -48,36 +35,26 @@ export function ResultsContent() { } const fetchResults = async () => { - setIsLoading(true); + setIsLoading(true) try { - const token = getCookie('admin_access_token'); - const response = await fetch('/api/admin/exams', { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - const data = await response.json(); - - if (data.code === 'COMMON200' && data.result) { - const mapped: ResultEntry[] = data.result.map((exam: any) => ({ - id: String(exam.id), - entryCode: exam.title, - total: exam.participantCount || 0, - completed: exam.status === 'COMPLETED' ? (exam.participantCount || 0) : 0 // 실제 완료 인원은 별도 API 필요 - })); - setResults(mapped); - } + const exams = await getExams() + const mapped: ResultEntry[] = exams.map((exam: Exam) => ({ + id: String(exam.id), + entryCode: exam.title, + total: exam.participantCount, + completed: exam.completedCount, + })) + setResults(mapped) } catch (error) { - console.error("Failed to fetch results:", error); + console.error("Failed to fetch results:", error) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } useEffect(() => { - fetchResults(); - }, []); + fetchResults() + }, []) const removeToast = (id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)) @@ -91,7 +68,7 @@ export function ResultsContent() { } const filteredResults = results.filter((result) => result.entryCode.toLowerCase().includes(searchQuery.toLowerCase())) - + const totalPages = Math.ceil(filteredResults.length / pageSize) const startIndex = (currentPage - 1) * pageSize const endIndex = currentPage * pageSize diff --git a/components/test-sessions-content.tsx b/components/test-sessions-content.tsx index 069627b..695a45f 100644 --- a/components/test-sessions-content.tsx +++ b/components/test-sessions-content.tsx @@ -1,7 +1,6 @@ "use client" import { useState, useEffect } from "react" -import { getCookie } from "@/lib/auth/cookie-utils" import { Eye, Trash2, MoreHorizontal, ChevronLeft, ChevronRight } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" @@ -19,7 +18,7 @@ import { import { ScrollArea } from "@/components/ui/scroll-area" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" -import { useRouter } from "next/navigation"; +import { deleteExam, getBoard, getExams, type Exam, type ExamineeBoardEntry } from "@/lib/api/admin"; export interface TestSession { id: number @@ -61,69 +60,53 @@ export function TestSessionsContent({ onViewDetails }: TestSessionsContentProps) // 1. 시험 목록 조회 (Exams) const fetchExams = async () => { - setIsLoading(true); + setIsLoading(true) try { - const token = getCookie('admin_access_token'); - const response = await fetch('/api/admin/exams', { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - const data = await response.json(); - - if (data.code === 'COMMON200' && data.result) { - const mapped: TestSession[] = data.result.map((exam: any) => ({ - id: exam.id, - sessionId: exam.title, // 제목을 세션 ID로 표시 - createdBy: "Admin", // BE 상에 생성자 이름 정보가 부족할 경우 고정값 - createdAt: exam.startTime ? exam.startTime.split('T')[0] : "-", - status: exam.status === 'RUNNING' ? 'Active' : 'Completed', - participants: exam.participantCount || 0 - })); - setTestSessions(mapped); - } + const exams = await getExams() + const mapped: TestSession[] = exams.map((exam: Exam) => ({ + id: exam.id, + sessionId: exam.title, + createdBy: "Admin", + createdAt: exam.startsAt ? exam.startsAt.split("T")[0] : "-", + status: ["RUNNING", "IN_PROGRESS"].includes(exam.state) ? "Active" : "Completed", + participants: exam.participantCount, + })) + setTestSessions(mapped) } catch (error) { - console.error("Failed to fetch exams:", error); + console.error("Failed to fetch exams:", error) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } // 2. 특정 시험의 참가자 현황 조회 (Board) const fetchParticipants = async (examId: number) => { - setIsParticipantsLoading(true); + setIsParticipantsLoading(true) try { - const token = getCookie('admin_access_token'); - const response = await fetch(`/api/admin/board?examId=${examId}`, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - const data = await response.json(); - - if (data.code === 'COMMON200' && data.result) { - const mapped: Participant[] = data.result.map((p: any) => ({ - id: p.examParticipantId, - name: p.name, - phoneNumber: p.phoneMasked, - connectionStatus: p.state === 'ENTRANCE' ? "Connected" : "Disconnected", - submissionStatus: p.submitted ? "Submitted" : (p.state === 'ENTRANCE' ? "In Progress" : "Not Started"), - tokenUsage: p.tokenUsed || 0 - })); - setParticipants(mapped); - } + const board = await getBoard(examId) + const mapped: Participant[] = board.map((participant: ExamineeBoardEntry) => ({ + id: participant.examParticipantId, + name: participant.name, + phoneNumber: participant.phoneMasked, + connectionStatus: participant.state === "ENTRANCE" ? "Connected" : "Disconnected", + submissionStatus: participant.submitted + ? "Submitted" + : participant.state === "ENTRANCE" + ? "In Progress" + : "Not Started", + tokenUsage: participant.tokenUsed || 0, + })) + setParticipants(mapped) } catch (error) { - console.error("Failed to fetch participants:", error); + console.error("Failed to fetch participants:", error) } finally { - setIsParticipantsLoading(false); + setIsParticipantsLoading(false) } - }; + } useEffect(() => { - fetchExams(); - }, []); + fetchExams() + }, []) const filteredSessions = statusFilter === "All" ? testSessions : testSessions.filter((session) => session.status === statusFilter) @@ -148,18 +131,10 @@ export function TestSessionsContent({ onViewDetails }: TestSessionsContentProps) if (!selectedSession) return try { - const token = getCookie('admin_access_token'); - const response = await fetch(`/api/admin/exams/${selectedSession.id}`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - if (response.ok) { - setTestSessions((prev) => prev.filter((session) => session.id !== selectedSession.id)) - } + await deleteExam(selectedSession.id) + setTestSessions((prev) => prev.filter((session) => session.id !== selectedSession.id)) } catch (error) { - console.error("Failed to delete exam:", error); + console.error("Failed to delete exam:", error) } finally { setIsDeleteSessionOpen(false) setSelectedSession(null) @@ -787,4 +762,3 @@ export function TestSessionsContent({ onViewDetails }: TestSessionsContentProps) ) } export default TestSessionsContent; - diff --git a/hooks/use-chat-socket.tsx b/hooks/use-chat-socket.tsx index c4581d5..5a1e713 100644 --- a/hooks/use-chat-socket.tsx +++ b/hooks/use-chat-socket.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Client } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; -import { getCookie } from '@/lib/auth/cookie-utils'; +import { useExamSessionStore } from '@/lib/stores/exam-session-store'; interface Message { id: number; @@ -39,17 +39,25 @@ export function useChatSocket( ) { const [isConnected, setIsConnected] = useState(false); const stompClientRef = useRef(null); + // 셀렉터로 구독: accessToken 변경 시 effect 재실행 → 로그인 후 소켓 연결 보장 + const accessToken = useExamSessionStore((state) => state.accessToken); + + // 콜백을 ref로 관리: 최신 함수 참조를 유지하면서 STOMP 재연결을 방지 + const onMessageReceivedRef = useRef(onMessageReceived); + const onErrorRef = useRef(onError); + useEffect(() => { onMessageReceivedRef.current = onMessageReceived; }, [onMessageReceived]); + useEffect(() => { onErrorRef.current = onError; }, [onError]); useEffect(() => { // STOMP CONNECT 시 JWT를 헤더로 전달해 서버 Principal(userId) 설정을 가능하게 함 // BE의 StompPrincipalInterceptor가 이 토큰을 파싱해 participantId를 Principal로 등록 // → convertAndSendToUser(participantId, "/queue/chat", response) 라우팅이 정상 동작 - const token = getCookie('user_access_token'); + const token = accessToken; // 만료된 토큰으로 연결 시 Principal이 설정되지 않아 convertAndSendToUser가 무음 실패함 if (!token || isTokenExpired(token)) { console.warn('[STOMP Chat] JWT 토큰이 없거나 만료됨 - 재로그인 필요'); - onError?.('세션이 만료되었습니다. 다시 로그인해주세요.'); + onErrorRef.current?.('세션이 만료되었습니다. 다시 로그인해주세요.'); return; } @@ -76,9 +84,9 @@ export function useChatSocket( try { const err = JSON.parse(message.body); console.error('[STOMP Chat] 서버 에러:', err.message); - onError?.(err.message ?? 'AI 응답 처리 중 오류가 발생했습니다.'); + onErrorRef.current?.(err.message ?? 'AI 응답 처리 중 오류가 발생했습니다.'); } catch { - onError?.('AI 응답 처리 중 오류가 발생했습니다.'); + onErrorRef.current?.('AI 응답 처리 중 오류가 발생했습니다.'); } }); @@ -95,7 +103,7 @@ export function useChatSocket( role: normalizedRole, content: response.content, }; - onMessageReceived(newMessage, response); + onMessageReceivedRef.current(newMessage, response); } catch (err) { console.error('[STOMP Chat] 메시지 처리 실패:', err, message.body); } @@ -120,7 +128,7 @@ export function useChatSocket( stompClientRef.current.deactivate(); } }; - }, [examId, participantId, onMessageReceived]); + }, [examId, participantId, accessToken]); // 콜백은 ref로 관리하므로 deps에서 제거; accessToken 변경 시 재연결 const sendMessage = useCallback((content: string, turn: number) => { if (stompClientRef.current && isConnected) { diff --git a/hooks/use-exam-socket.tsx b/hooks/use-exam-socket.tsx index 7d07429..b94f257 100644 --- a/hooks/use-exam-socket.tsx +++ b/hooks/use-exam-socket.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Client } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; -import { getCookie } from '@/lib/auth/cookie-utils'; +import { useExamSessionStore } from '@/lib/stores/exam-session-store'; export type ExamState = 'WAITING' | 'RUNNING' | 'ENDED'; @@ -42,7 +42,7 @@ export function useExamSocket( useEffect(() => { if (!examId) return; - const token = getCookie('user_access_token'); + const token = useExamSessionStore.getState().accessToken; const socket = new SockJS(`${API_BASE_URL}/ws`); const client = new Client({ diff --git a/lib/api/admin.ts b/lib/api/admin.ts index 4d09e58..a648f0a 100644 --- a/lib/api/admin.ts +++ b/lib/api/admin.ts @@ -1,5 +1,4 @@ // Admin API 호출 함수들 -import { getCookie, removeCookie } from '../auth/cookie-utils'; // 커스텀 에러 클래스: 로그인 실패와 네트워크 에러를 구분 export class LoginFailedError extends Error { @@ -27,16 +26,9 @@ function getApiBaseUrl(): string { return baseUrl; } -// Authorization 헤더 가져오기 +// Authorization 헤더 가져오기 (HttpOnly 쿠키가 자동 전송되므로 헤더 불필요) function getAuthHeaders(): HeadersInit { - const token = getCookie('admin_access_token'); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - return headers; + return { 'Content-Type': 'application/json' }; } // BaseResponse 타입 @@ -131,6 +123,15 @@ export interface ChangeAdminPasswordRequest { newPassword: string; } +export interface AdminProblem { + id: number; + title: string; + difficulty: 'EASY' | 'MEDIUM' | 'HARD'; + tags: string | string[] | null; + status: string; + createdAt: string; +} + /** * 관리자 로그인 API 호출 */ @@ -150,7 +151,7 @@ export async function adminLogin(request: AdminLoginRequest): Promise { + const apiBaseUrl = getApiBaseUrl(); + const url = `${apiBaseUrl}/api/admin/problems`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + credentials: 'include', + }); + + const data: BaseResponse = await response.json(); + + if (!response.ok || data.code !== 'COMMON200' || !data.result) { + throw new LoginFailedError(data.message || '문제 목록 조회에 실패했습니다.', response.status, data.code); + } + + return data.result; + } catch (error) { + if (error instanceof LoginFailedError) { + throw error; + } + + if (error instanceof TypeError && error.message.includes('fetch')) { + throw new NetworkError('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } + + throw new NetworkError('문제 목록 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + } +} + /** * 모든 관리자 조회 API 호출 (마스터 전용) */ @@ -229,7 +264,7 @@ export async function getAllAdmins(): Promise { const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -336,7 +371,7 @@ export async function updateAdminNumber( method: 'PATCH', headers: getAuthHeaders(), body: JSON.stringify(request), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -442,7 +477,7 @@ export async function issueAdminNumber(request: AdminNumberIssueRequest): Promis method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(request), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -544,7 +579,7 @@ export async function signUpAdmin(request: AdminSignupRequest): Promise { 'Content-Type': 'application/json', }, body: JSON.stringify(request), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -641,7 +676,7 @@ export async function logoutAdmin(): Promise { const response = await fetch(url, { method: 'POST', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -665,8 +700,7 @@ export async function logoutAdmin(): Promise { console.warn('[Admin Logout] 네트워크 오류 또는 예상치 못한 오류:', error); } } finally { - // 항상 프론트엔드 세션 정리 - removeCookie('admin_access_token'); + // admin_access_token HttpOnly 쿠키는 백엔드 logout 응답에서 삭제됨 } } @@ -686,7 +720,7 @@ export async function getMe(): Promise { const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -784,7 +818,7 @@ export async function changeAdminPassword(request: ChangeAdminPasswordRequest): method: 'PATCH', headers: getAuthHeaders(), body: JSON.stringify(request), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -908,6 +942,8 @@ export interface Exam { endsAt: string; // ISO 8601 형식 version: number; createdBy: number; + participantCount: number; + completedCount: number; entryCode?: string; // 입장 코드 (선택적) } @@ -950,7 +986,7 @@ export async function createExam(request: CreateExamRequest): Promise { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(request), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1050,7 +1086,7 @@ export async function getExams(): Promise { const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1150,7 +1186,7 @@ export async function createEntryCode(request: CreateEntryCodeRequest): Promise< method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(request), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1250,7 +1286,7 @@ export async function deleteExam(examId: number): Promise { const response = await fetch(url, { method: 'DELETE', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1348,7 +1384,7 @@ export async function startExam(examId: number): Promise { const response = await fetch(url, { method: 'POST', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1448,7 +1484,7 @@ export async function endExam(examId: number): Promise { const response = await fetch(url, { method: 'POST', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1555,7 +1591,7 @@ export async function getEntryCodes(examId: number, isActive?: boolean): Promise const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (isDev) { @@ -1676,7 +1712,7 @@ export async function extendExam(examId: number, minutes: number): Promise method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ minutes }), - credentials: 'omit', + credentials: 'include', }); if (!response.ok) { @@ -1699,7 +1735,7 @@ export async function updateEntryCode(code: string, isActive: boolean): Promise< method: 'PATCH', headers: getAuthHeaders(), body: JSON.stringify({ isActive }), - credentials: 'omit', + credentials: 'include', }); const data: BaseResponse = await response.json(); @@ -1720,7 +1756,7 @@ export async function deleteEntryCode(code: string): Promise { const response = await fetch(url, { method: 'DELETE', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); if (!response.ok) { @@ -1742,7 +1778,7 @@ export async function getMetrics(examId: number): Promise { const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); const data: BaseResponse = await response.json(); @@ -1763,7 +1799,7 @@ export async function getBoard(examId: number): Promise { const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), - credentials: 'omit', + credentials: 'include', }); const data: BaseResponse = await response.json(); @@ -1774,81 +1810,5 @@ export async function getBoard(examId: number): Promise { } // ─── SSE 채점 결과 스트리밍 ──────────────────────────────────────────────────── - -export interface ScoringEvent { - type: 'case_result' | 'final_score'; - data: unknown; -} - -/** - * 채점 결과 SSE 스트리밍 구독 - * GET /api/admin/submissions/{submissionId}/stream - * - * @returns cleanup 함수 (구독 해제 시 호출) - */ -export function streamScoringResult( - submissionId: number, - onEvent: (event: ScoringEvent) => void, - onError?: (err: Event) => void, - onDone?: () => void -): () => void { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; - const token = getCookie('admin_access_token'); - - // SSE는 EventSource를 사용하며, 커스텀 헤더 지원이 없으므로 쿼리 파라미터로 토큰 전달 - // 서버 측에서 쿼리 파라미터 토큰을 허용해야 함. 불가 시 fetch + ReadableStream으로 대체 - const url = `${apiBaseUrl}/api/admin/submissions/${submissionId}/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`; - - // fetch + ReadableStream 방식 (Authorization 헤더 지원) - let aborted = false; - const controller = new AbortController(); - - fetch(url.replace(/\?token=.*/, ''), { - headers: { - Authorization: token ? `Bearer ${token}` : '', - Accept: 'text/event-stream', - }, - signal: controller.signal, - }).then(async (response) => { - if (!response.body) { - onError?.(new Event('no body')); - return; - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (!aborted) { - const { done, value } = await reader.read(); - if (done) { - onDone?.(); - break; - } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - if (line.startsWith('data:')) { - try { - const parsed = JSON.parse(line.slice(5).trim()); - onEvent(parsed); - } catch { - // non-JSON data line 무시 - } - } else if (line === '' && buffer === '') { - // keep-alive 빈 라인 - } - } - } - }).catch((err) => { - if (!aborted) { - onError?.(err); - } - }); - - return () => { - aborted = true; - controller.abort(); - }; -} +// streamScoringResult는 lib/api/submissions.ts 에서 정의·export됩니다. +// 이전에 이 파일에 존재하던 구버전 구현은 submissions.ts 의 콜백 기반 버전으로 통합되었습니다. diff --git a/lib/api/chat.ts b/lib/api/chat.ts index 6ea8886..49a05de 100644 --- a/lib/api/chat.ts +++ b/lib/api/chat.ts @@ -1,5 +1,4 @@ // Chat API 호출 함수들 -import { getCookie } from '../auth/cookie-utils'; // 커스텀 에러 클래스 export class ChatError extends Error { @@ -27,16 +26,8 @@ function getApiBaseUrl(): string { return baseUrl; } -// Authorization 헤더 가져오기 (사용자 토큰) function getUserAuthHeaders(): HeadersInit { - const token = getCookie('user_access_token'); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - return headers; + return { 'Content-Type': 'application/json' }; } // BaseResponse 타입 diff --git a/lib/api/exams.ts b/lib/api/exams.ts index 52a7689..a18e4fb 100644 --- a/lib/api/exams.ts +++ b/lib/api/exams.ts @@ -1,5 +1,4 @@ // Exam API 호출 함수들 -import { getCookie } from '../auth/cookie-utils'; // 클라이언트 컴포넌트에서 환경 변수 접근을 위한 헬퍼 함수 function getApiBaseUrl(): string { @@ -7,16 +6,8 @@ function getApiBaseUrl(): string { return baseUrl; } -// Authorization 헤더 가져오기 (사용자 토큰) function getUserAuthHeaders(): HeadersInit { - const token = getCookie('user_access_token'); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - return headers; + return { 'Content-Type': 'application/json' }; } // ─── 문제 배정 응답 타입 ─────────────────────────────────────────────────────── diff --git a/lib/api/submissions.ts b/lib/api/submissions.ts index 965f234..18cfd22 100644 --- a/lib/api/submissions.ts +++ b/lib/api/submissions.ts @@ -1,24 +1,11 @@ // Submission API 호출 함수들 -import { getCookie } from '../auth/cookie-utils'; - -function getAdminAuthHeaders(): HeadersInit { - const token = getCookie('admin_access_token'); - return { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; -} function getApiBaseUrl(): string { return process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; } function getUserAuthHeaders(): HeadersInit { - const token = getCookie('user_access_token'); - return { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; + return { 'Content-Type': 'application/json' }; } export interface BaseResponse { @@ -181,16 +168,12 @@ export function streamScoringResult( const controller = new AbortController(); const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; const url = `${apiBaseUrl}/api/admin/submissions/${submissionId}/stream`; - const token = getCookie('admin_access_token'); async function connect() { try { const response = await fetch(url, { method: 'GET', - headers: { - Accept: 'text/event-stream', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, + headers: { Accept: 'text/event-stream' }, credentials: 'include', signal: controller.signal, }); diff --git a/lib/auth/utils.ts b/lib/auth/utils.ts index 42a26e7..2f2b4ac 100644 --- a/lib/auth/utils.ts +++ b/lib/auth/utils.ts @@ -1,5 +1,6 @@ // 인증 관련 유틸리티 함수들 import { setCookie, getCookie, removeCookie } from './cookie-utils'; +// admin_access_token은 백엔드가 HttpOnly 쿠키로 직접 발급 — JS에서 저장/삭제 불필요 // 마스터 관리자 번호 상수 export const MASTER_ADMIN_NUMBER = "MASTER-0001"; @@ -38,14 +39,14 @@ export function isSystemMasterAdmin(admin: { adminNumber?: string | null }): boo /** * 인증 정보를 쿠키에 저장 + * 주의: admin_access_token은 백엔드가 HttpOnly 쿠키로 발급하므로 여기서 저장하지 않음 */ export function saveAuthInfo( - accessToken: string, + _accessToken: string, adminNumber: string, role?: string, email?: string ): void { - setCookie('admin_access_token', accessToken); setCookie('admin_number', adminNumber); if (role) { setCookie('admin_role', role); @@ -57,6 +58,7 @@ export function saveAuthInfo( /** * 쿠키에서 인증 정보 가져오기 + * 주의: accessToken은 HttpOnly 쿠키로 JS 접근 불가 → null 반환 */ export function getAuthInfo(): { accessToken: string | null; @@ -65,7 +67,7 @@ export function getAuthInfo(): { email: string | null; } { return { - accessToken: getCookie('admin_access_token'), + accessToken: null, // HttpOnly 쿠키 — JS에서 읽기 불가 adminNumber: getCookie('admin_number'), role: getCookie('admin_role'), email: getCookie('admin_email'), @@ -74,9 +76,9 @@ export function getAuthInfo(): { /** * 쿠키에서 인증 정보 삭제 + * 주의: admin_access_token은 백엔드 logout 엔드포인트가 HttpOnly 쿠키를 삭제 */ export function clearAuthInfo(): void { - removeCookie('admin_access_token'); removeCookie('admin_number'); removeCookie('admin_role'); removeCookie('admin_email'); diff --git a/lib/stores/exam-session-store.ts b/lib/stores/exam-session-store.ts index c5b48e3..39c20bd 100644 --- a/lib/stores/exam-session-store.ts +++ b/lib/stores/exam-session-store.ts @@ -4,7 +4,9 @@ interface ExamSessionState { examId: number | null; participantId: number | null; tokenLimit: number; + accessToken: string | null; setSession: (examId: number, participantId: number, tokenLimit?: number) => void; + setAccessToken: (token: string) => void; clearSession: () => void; } @@ -12,7 +14,9 @@ export const useExamSessionStore = create((set) => ({ examId: null, participantId: null, tokenLimit: 20000, + accessToken: null, setSession: (examId, participantId, tokenLimit = 20000) => set({ examId, participantId, tokenLimit }), - clearSession: () => set({ examId: null, participantId: null, tokenLimit: 20000 }), + setAccessToken: (token) => set({ accessToken: token }), + clearSession: () => set({ examId: null, participantId: null, tokenLimit: 20000, accessToken: null }), })); diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.