diff --git a/FE/src/pages/Interview.jsx b/FE/src/pages/Interview.jsx index 5ebfc01..3381595 100644 --- a/FE/src/pages/Interview.jsx +++ b/FE/src/pages/Interview.jsx @@ -1,7 +1,8 @@ /* ========================================================= - Interview.jsx (update v4) + Interview.jsx (Enhanced v6 - 인증 강화 & 난이도 선택) ========================================================= */ import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from 'react-router-dom'; import Header from '../partials/Header'; import Footer from '../partials/Footer'; import { @@ -16,23 +17,26 @@ import { resumeExists, listCompanies, listPositions, + checkAuthStatus, } from '../utils/api'; export default function Interview() { + const navigate = useNavigate(); + /* -------------------------------------------------- local state -------------------------------------------------- */ - const [interview, setInterview] = useState(null); // 전체 세션 정보 - const [question, setQuestion] = useState(null); // 현재 질문 - const [seconds, setSeconds] = useState(0); // 타이머(질문별) - const [totalSec, setTotalSec] = useState(0); // 세션 전체 타임 - const [isCounting, setIsCounting] = useState(false); // 카운트 활성화 여부 - const [isRecording, setIsRecording] = useState(false); // MediaRecorder 진행중 - const [audioBlob, setAudioBlob] = useState(null); // 녹음된 오디오 - const [isSubmitting, setIsSubmitting] = useState(false); // 답변 제출중 - const [summary, setSummary] = useState(null); // 마지막 요약뷰용 데이터 - const mediaRef = useRef(null); // MediaRecorder 인스턴스 - const chunks = useRef([]); + const [interview, setInterview] = useState(null); + const [question, setQuestion] = useState(null); + const [seconds, setSeconds] = useState(0); + const [totalSec, setTotalSec] = useState(0); + const [isCounting, setIsCounting] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [audioBlob, setAudioBlob] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [summary, setSummary] = useState(null); + const mediaRef = useRef(null); + const chunks = useRef([]); const timerRef = useRef(null); // 면접 설정 상태 @@ -44,36 +48,63 @@ export default function Interview() { const [hasResume, setHasResume] = useState(false); const [loadingSetup, setLoadingSetup] = useState(true); const [questionCount, setQuestionCount] = useState(5); + const [selectedDifficulty, setSelectedDifficulty] = useState(3); // 새로 추가 const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [authStatus, setAuthStatus] = useState(null); + + /* -------------------------------------------------- + 인증 상태 확인 + -------------------------------------------------- */ + useEffect(() => { + const auth = checkAuthStatus(); + if (!auth.isAuthenticated) { + console.log('인증되지 않음 - 로그인 페이지로 이동'); + navigate('/signin'); + return; + } + + setAuthStatus(auth); + console.log('인증 확인됨:', auth); + }, [navigate]); /* -------------------------------------------------- 초기 데이터 로드 -------------------------------------------------- */ useEffect(() => { + if (!authStatus?.isAuthenticated) return; + const loadInitialData = async () => { try { + console.log('초기 데이터 로드 시작...'); + // 이력서 존재 여부 확인 const { data: existsData } = await resumeExists(); setHasResume(existsData.data); + console.log('이력서 존재 여부:', existsData.data); if (existsData.data) { // 이력서가 있으면 ID 가져오기 const { data: resumeData } = await getMyResume(); setResumeId(resumeData.data.id); + console.log('내 이력서 ID:', resumeData.data.id); } // 회사 목록 가져오기 const { data: companiesData } = await listCompanies(); setCompanies(companiesData.data || []); + console.log('회사 목록:', companiesData.data?.length, '개'); } catch (err) { console.error("초기 데이터 로드 실패:", err); + if (err.response?.status === 401) { + navigate('/signin'); + } } finally { setLoadingSetup(false); } }; loadInitialData(); - }, []); + }, [authStatus, navigate]); /* -------------------------------------------------- 회사 선택 시 포지션 로드 @@ -87,8 +118,12 @@ export default function Interview() { try { const { data } = await listPositions(companyId); setPositions(data.data || []); + console.log('포지션 목록:', data.data?.length, '개'); } catch (err) { console.error("포지션 로드 실패:", err); + if (err.response?.status === 401) { + navigate('/signin'); + } } } }; @@ -119,7 +154,7 @@ export default function Interview() { }, []); /* -------------------------------------------------- - Interview 시작 + 면접 시작 (난이도 포함) -------------------------------------------------- */ const handleStart = async () => { if (!selectedPosition) { @@ -127,32 +162,51 @@ export default function Interview() { return; } + if (!resumeId) { + alert("이력서를 먼저 작성해주세요."); + return; + } + try { - // 면접 세션 생성 + // 난이도가 포함된 면접 세션 생성 const createParams = { resumeId: resumeId, positionId: Number(selectedPosition), - title: "모의 면접 연습", + title: `모의 면접 연습 (난이도 ${selectedDifficulty}단계)`, + description: `${selectedDifficulty}단계 난이도의 모의 면접 연습`, type: "TEXT", mode: "PRACTICE", useAI: false, - questionCount: questionCount + questionCount: questionCount, + difficultyLevel: selectedDifficulty, // 사용자가 선택한 난이도 + expectedDurationMinutes: questionCount * 5, + public: false }; + console.log('면접 생성 요청:', createParams); const { data } = await createInterview(createParams); - setInterview(data.data); + const createdInterview = data.data || data; + setInterview(createdInterview); + console.log('생성된 면접:', createdInterview); // 면접 시작 상태로 변경 - await startInterview(data.data.id); + await startInterview(createdInterview.id); + console.log('면접 시작됨:', createdInterview.id); // 첫 번째 질문 가져오기 - const { data: questionData } = await getNextQuestion(data.data.id); - setQuestion(questionData.data); + const { data: questionData } = await getNextQuestion(createdInterview.id); + const firstQuestion = questionData.data || questionData; + setQuestion(firstQuestion); setCurrentQuestionIndex(1); + console.log('첫 번째 질문:', firstQuestion); } catch (err) { console.error("면접 시작 실패:", err); - alert("면접을 시작할 수 없습니다."); + if (err.response?.status === 401) { + navigate('/signin'); + return; + } + alert("면접을 시작할 수 없습니다: " + (err.response?.data?.message || err.message)); } }; @@ -192,7 +246,7 @@ export default function Interview() { if (mediaRef.current && isRecording) { mediaRef.current.stop(); setIsRecording(false); - stopTimer(); // 녹음 종료 시 타이머 정지 + stopTimer(); } }; @@ -205,24 +259,26 @@ export default function Interview() { setIsSubmitting(true); try { - // FormData로 오디오 파일 전송 const formData = new FormData(); formData.append('file', audioBlob, 'answer.webm'); + console.log('답변 제출 중:', question.id); await uploadAudioAnswer(question.id, formData); + console.log('답변 제출 완료'); - // 다음 질문으로 이동 또는 면접 종료 if (currentQuestionIndex >= questionCount) { - // 마지막 질문이었으면 면접 종료 await handleCompleteInterview(); } else { - // 다음 질문 가져오기 await fetchNextQuestion(); } } catch (err) { console.error("답변 제출 실패:", err); - alert("답변 제출에 실패했습니다."); + if (err.response?.status === 401) { + navigate('/signin'); + return; + } + alert("답변 제출에 실패했습니다: " + (err.response?.data?.message || err.message)); setIsSubmitting(false); } finally { setAudioBlob(null); @@ -234,21 +290,27 @@ export default function Interview() { -------------------------------------------------- */ const fetchNextQuestion = async () => { try { + console.log('다음 질문 요청:', interview.id); const { data } = await getNextQuestion(interview.id); + const nextQuestion = data.data || data; - if (data.data) { - setQuestion(data.data); + if (nextQuestion) { + setQuestion(nextQuestion); setCurrentQuestionIndex(prev => prev + 1); resetQuestionTimer(); setIsSubmitting(false); + console.log('다음 질문:', nextQuestion); } } catch (err) { + console.error("다음 질문 가져오기 오류:", err); if (err.response?.status === 410) { - // 410 Gone - 더 이상 질문이 없음 + console.log('더 이상 질문이 없음 - 면접 종료'); await handleCompleteInterview(); + } else if (err.response?.status === 401) { + navigate('/signin'); } else { - console.error("다음 질문 가져오기 실패:", err); setIsSubmitting(false); + alert("다음 질문을 가져올 수 없습니다."); } } }; @@ -258,23 +320,29 @@ export default function Interview() { -------------------------------------------------- */ const handleCompleteInterview = async () => { try { - // 타이머 정지 stopTimer(); setIsSubmitting(true); - // 면접 상태를 완료로 변경 + console.log('면접 종료 처리 시작:', interview.id); + await completeInterview(interview.id); + console.log('면접 완료 상태 변경됨'); - // 총 소요 시간 업데이트 await updateInterviewTime(interview.id, { timeInSeconds: totalSec }); + console.log('면접 시간 업데이트됨:', totalSec, '초'); - // 모든 질문과 답변 가져오기 const { data } = await getInterviewQuestions(interview.id); - setSummary(data.data); + const allQuestions = data.data || data; + setSummary(allQuestions); setQuestion(null); + console.log('면접 결과 요약:', allQuestions); } catch (err) { console.error("면접 종료 처리 실패:", err); + if (err.response?.status === 401) { + navigate('/signin'); + return; + } alert("면접 종료 처리 중 오류가 발생했습니다."); } finally { setIsSubmitting(false); @@ -292,10 +360,20 @@ export default function Interview() { }; }, []); - /* -------------------------------------------------- - JSX 렌더링 - -------------------------------------------------- */ - + if (!authStatus?.isAuthenticated) { + return ( +
+
+
+
+

로그인이 필요합니다.

+
+
+
+ ); + } + return (
@@ -305,13 +383,20 @@ export default function Interview() {
+ {/* 사용자 정보 표시 */} +
+

+ 사용자: {authStatus.userEmail} (ID: {authStatus.userId}) +

+
+ {/* 면접 시작 전 */} {!interview && !summary && (
-

모의 면접 연습

+

🎯 모의 면접 연습

{loadingSetup ? ( -
로딩 중...
+
설정을 불러오는 중...
) : !hasResume ? (

이력서를 먼저 작성해주세요.

@@ -359,53 +444,67 @@ export default function Interview() {
+ {/* 난이도 선택 */} +
+ +
+ {[1, 2, 3, 4, 5].map(level => ( + + ))} +
+

+ 선택한 난이도에 맞는 질문들로 면접이 구성됩니다. +

+
+ {/* 질문 개수 선택 */}
- - - + {[5, 10, 15].map(count => ( + + ))}
+

+ 예상 소요 시간: {questionCount * 3}~{questionCount * 5}분 +

{/* 시작 버튼 */}
)} @@ -416,10 +515,21 @@ export default function Interview() { {interview && question && !summary && (
{/* 진행 상황 */} -
-

- 질문 {currentQuestionIndex} / {questionCount} -

+
+
+
+

+ 질문 {currentQuestionIndex} / {questionCount} +

+

난이도 {selectedDifficulty}단계

+
+
+
+
+
{/* 타이머 */} @@ -435,25 +545,34 @@ export default function Interview() {
{/* 질문 카드 */} -
-

- 질문 {question.sequence || currentQuestionIndex} -

-

{question.content}

+
+
+

+ 질문 {question.sequence || currentQuestionIndex} +

+ + {selectedDifficulty}단계 + +
+

+ {question.content} +

{/* 상태 메시지 */} {isSubmitting && ( -

- 🤖 답변을 제출하고 있습니다... -

+
+

+ 🤖 답변을 제출하고 있습니다... +

+
)} {/* 컨트롤 버튼 */}
{!isRecording && !audioBlob && ( +
)} diff --git a/FE/src/pages/Questions.jsx b/FE/src/pages/Questions.jsx index 9561efb..adf8781 100644 --- a/FE/src/pages/Questions.jsx +++ b/FE/src/pages/Questions.jsx @@ -15,11 +15,11 @@ const CATEGORY_OPTS = [ ]; const DIFF_OPTS = [ { id: 'all', label: '난이도 전체' }, - { id: 1, label: '1' }, - { id: 2, label: '2' }, - { id: 3, label: '3' }, - { id: 4, label: '4' }, - { id: 5, label: '5' }, + { id: 1, label: '1단계' }, + { id: 2, label: '2단계' }, + { id: 3, label: '3단계' }, + { id: 4, label: '4단계' }, + { id: 5, label: '5단계' }, ]; const TYPE_OPTS = [ { id: 'all', label: '타입 전체' }, @@ -34,55 +34,128 @@ function Questions() { const nav = useNavigate(); /* ==== 필터 상태 ==== */ - const [keyword, setKeyword] = useState(''); - const [category, setCategory] = useState('all'); + const [keyword, setKeyword] = useState(''); + const [category, setCategory] = useState('all'); const [difficulty, setDifficulty] = useState('all'); - const [qType, setQType] = useState('all'); - const [onlyFav, setOnlyFav] = useState(false); + const [qType, setQType] = useState('all'); + const [onlyFav, setOnlyFav] = useState(false); /* ==== 데이터 ==== */ - const [questions, setQuestions] = useState([]); - const [loading, setLoading] = useState(true); + const [questions, setQuestions] = useState([]); + const [loading, setLoading] = useState(true); const [expandedQuestions, setExpandedQuestions] = useState(new Set()); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userInfo, setUserInfo] = useState(null); /* ==== 모달 상태 ==== */ - const [showModal, setShowModal] = useState(false); - const [companies, setCompanies] = useState([]); - const [positions, setPositions] = useState([]); - const [companyId, setCompanyId] = useState(''); - const [positionId, setPositionId] = useState(''); - const [questionCount, setQuestionCount] = useState(5); - const [modalBusy, setModalBusy] = useState(false); + const [showModal, setShowModal] = useState(false); + const [companies, setCompanies] = useState([]); + const [positions, setPositions] = useState([]); + const [companyId, setCompanyId] = useState(''); + const [positionId, setPositionId] = useState(''); + const [questionCount, setQuestionCount] = useState(5); + const [selectedDifficulty, setSelectedDifficulty] = useState(3); // 새로 추가 + const [modalBusy, setModalBusy] = useState(false); + + /* ── 간단한 인증 확인 (localStorage 기반) ── */ + useEffect(() => { + const checkAuth = () => { + const token = localStorage.getItem('accessToken') || localStorage.getItem('token'); + const userId = localStorage.getItem('userId'); + const userEmail = localStorage.getItem('userEmail'); + + if (token && userId) { + setIsAuthenticated(true); + setUserInfo({ + id: userId, + email: userEmail, + token: token + }); + console.log('인증 확인됨 - 사용자 ID:', userId); + } else { + console.log('인증 정보 없음 - 로그인 페이지로 이동'); + nav('/signin'); + } + }; + + checkAuth(); + }, [nav]); /* ── 질문 검색 ── */ const fetchQuestions = async () => { + if (!isAuthenticated) return; + setLoading(true); try { - const params = { - keyword: keyword || undefined, - category: category !== 'all' ? category : undefined, - difficultyLevel: difficulty !== 'all' ? difficulty : undefined, - type: qType !== 'all' ? qType : undefined, - size: 100, - }; - const { data } = await api.get('/api/interviews/questions/search', { params }); - setQuestions(data.data?.content || []); + let questionData = []; + + if (onlyFav) { + // 즐겨찾기 질문만 가져오기 + try { + const { data } = await api.get('/api/interviews/questions/favorites'); + questionData = data.data || []; + console.log('즐겨찾기 질문:', questionData); + } catch (err) { + if (err.response?.status === 401) { + console.log('인증 만료 - 로그인 페이지로 이동'); + localStorage.clear(); + nav('/signin'); + return; + } + throw err; + } + } else { + // 일반 질문 검색 + const params = { + keyword: keyword || undefined, + category: category !== 'all' ? category : undefined, + difficultyLevel: difficulty !== 'all' ? difficulty : undefined, + type: qType !== 'all' ? qType : undefined, + size: 100, + }; + + try { + const { data } = await api.get('/api/interviews/questions/search', { params }); + questionData = data.data?.content || []; + console.log('검색된 질문:', questionData); + } catch (err) { + if (err.response?.status === 401) { + console.log('인증 만료 - 로그인 페이지로 이동'); + localStorage.clear(); + nav('/signin'); + return; + } + throw err; + } + } + + setQuestions(questionData); } catch (e) { - console.error(e); + console.error('질문 검색 오류:', e); alert('질문을 불러오지 못했습니다.'); } finally { setLoading(false); } }; - useEffect(() => { fetchQuestions(); }, [keyword, category, difficulty, qType]); // eslint-disable-line - /* ── 즐겨찾기 ── */ + useEffect(() => { + if (isAuthenticated) { + fetchQuestions(); + } + }, [keyword, category, difficulty, qType, onlyFav, isAuthenticated]); + + /* ── 즐겨찾기 토글 ── */ const toggleFav = async (id) => { try { await api.post(`/api/interviews/questions/${id}/favorite`); setQuestions(prev => prev.map(q => (q.id === id ? { ...q, isFavorite: !q.isFavorite } : q))); } catch (e) { - console.error(e); + console.error('즐겨찾기 변경 오류:', e); + if (e.response?.status === 401) { + localStorage.clear(); + nav('/signin'); + return; + } alert('즐겨찾기 변경 실패'); } }; @@ -108,7 +181,12 @@ function Questions() { const { data } = await api.get('/api/companies'); setCompanies(data.data || []); } catch (e) { - console.error(e); + console.error('회사 목록 로드 오류:', e); + if (e.response?.status === 401) { + localStorage.clear(); + nav('/signin'); + return; + } alert('회사 목록을 가져올 수 없습니다.'); } }; @@ -122,12 +200,17 @@ function Questions() { const { data } = await api.get(`/api/companies/${id}/positions`); setPositions(data.data || []); } catch (e) { - console.error(e); + console.error('포지션 목록 로드 오류:', e); + if (e.response?.status === 401) { + localStorage.clear(); + nav('/signin'); + return; + } alert('포지션 목록을 가져올 수 없습니다.'); } }; - /* ── AI 질문 생성 ── */ + /* ── AI 질문 생성 (난이도 선택 기능 추가) ── */ const generateQuestions = async () => { if (!positionId) return alert('포지션을 선택하세요.'); @@ -138,11 +221,16 @@ function Questions() { try { const resumeRes = await api.get('/api/resume'); myResumeId = resumeRes.data.data.id; + console.log('내 이력서 ID:', myResumeId); } catch (e) { if (e.response?.status === 404) { alert('이력서를 먼저 작성해주세요.'); setModalBusy(false); return; + } else if (e.response?.status === 401) { + localStorage.clear(); + nav('/signin'); + return; } throw e; } @@ -151,37 +239,38 @@ function Questions() { const pos = positions.find(p => p.id === Number(positionId)); const positionName = pos?.title || pos?.name || ''; - /* 3) AI를 사용한 면접 세션 생성 (질문 자동 생성) */ + /* 3) 난이도가 포함된 AI 면접 세션 생성 */ const createInterviewRes = await api.post('/api/interviews', { resumeId: myResumeId, positionId: Number(positionId), - title: `AI 생성 질문 - ${positionName} - ${new Date().toLocaleDateString()}`, - description: `${positionName} 포지션을 위한 맞춤형 면접 질문`, + title: `AI 생성 질문 - ${positionName} (난이도 ${selectedDifficulty}) - ${new Date().toLocaleDateString()}`, + description: `${positionName} 포지션을 위한 맞춤형 면접 질문 (난이도 ${selectedDifficulty}단계)`, type: "TEXT", mode: "PRACTICE", - useAI: true, // AI 사용하여 질문 자동 생성 + useAI: true, questionCount: questionCount, - difficultyLevel: 3, - categoryFilter: "기술면접" + difficultyLevel: selectedDifficulty, // 사용자가 선택한 난이도 사용 + categoryFilter: category !== 'all' ? category : "기술면접", + expectedDurationMinutes: questionCount * 5, // 질문당 5분 예상 + public: false }); - const interviewId = createInterviewRes.data.data.id; + const interviewId = createInterviewRes.data.data?.id || createInterviewRes.data.id; + console.log('생성된 면접 ID:', interviewId); /* 4) 생성된 면접의 질문들 확인 */ const { data: questionsData } = await api.get(`/api/interviews/${interviewId}/questions`); if (questionsData.data && questionsData.data.length > 0) { - alert(`${questionsData.data.length}개의 면접 질문이 생성되었습니다!`); - // 질문 목록 새로고침 + alert(`${questionsData.data.length}개의 맞춤형 면접 질문이 생성되었습니다! (난이도 ${selectedDifficulty}단계)`); await fetchQuestions(); } else { - // 질문이 자동 생성되지 않았다면, 기존 질문 풀에서 가져오기 alert('AI 질문 생성 중... 잠시만 기다려주세요.'); - // 카테고리와 난이도에 맞는 질문 검색 + // 선택된 난이도로 기본 질문 검색 const searchParams = { - category: '기술면접', - difficultyLevel: 3, + category: category !== 'all' ? category : '기술면접', + difficultyLevel: selectedDifficulty, size: questionCount }; @@ -190,10 +279,10 @@ function Questions() { }); if (searchData.data?.content && searchData.data.content.length > 0) { - alert(`${searchData.data.content.length}개의 관련 질문을 찾았습니다!`); + alert(`${searchData.data.content.length}개의 관련 질문을 찾았습니다! (난이도 ${selectedDifficulty}단계)`); await fetchQuestions(); } else { - alert('적합한 질문을 찾을 수 없습니다. 다른 조건으로 시도해주세요.'); + alert('적합한 질문을 찾을 수 없습니다.'); } } @@ -201,35 +290,39 @@ function Questions() { setCompanyId(''); setPositionId(''); setQuestionCount(5); + setSelectedDifficulty(3); } catch (e) { console.error('질문 생성 오류:', e); - if (e.response?.status === 404) { + if (e.response?.status === 401) { + localStorage.clear(); + nav('/signin'); + return; + } else if (e.response?.status === 404) { alert('이력서를 먼저 작성해주세요.'); } else { - // 대체 방안: 기존 질문 풀에서 랜덤하게 가져오기 - try { - const { data: searchData } = await api.get('/api/interviews/questions/search', { - params: { size: questionCount } - }); - - if (searchData.data?.content && searchData.data.content.length > 0) { - alert(`기본 질문 ${searchData.data.content.length}개를 준비했습니다!`); - await fetchQuestions(); - setShowModal(false); - } else { - alert('질문 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); - } - } catch (searchErr) { - alert('질문 생성에 실패했습니다.'); - } + alert('질문 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); } } finally { setModalBusy(false); } }; - /* ── 화면에 보여줄 질문 after fav 필터 ── */ - const list = questions.filter(q => (onlyFav ? q.isFavorite : true)); + /* ── 화면에 보여줄 질문 ── */ + const list = questions; + + if (!isAuthenticated) { + return ( +
+
+
+
+

로그인이 필요합니다.

+
+
+
+
+ ); + } /* ─────── JSX ─────── */ return ( @@ -238,7 +331,12 @@ function Questions() {
-

예상 질문 리스트

+
+

예상 질문 리스트

+
+ 사용자: {userInfo?.email} (ID: {userInfo?.id}) +
+
{/* 필터 & 새 질문 생성 버튼 */}
@@ -296,6 +394,7 @@ function Questions() {
{q.category} 난이도 {q.difficultyLevel} + {q.type} {q.answer && ( + ))} +
+

+ 1단계: 기초 | 2단계: 초급 | 3단계: 중급 | 4단계: 고급 | 5단계: 전문가 +

+
+ {/* 질문 개수 */}
@@ -422,6 +545,7 @@ function Questions() { setCompanyId(''); setPositionId(''); setQuestionCount(5); + setSelectedDifficulty(3); }} disabled={modalBusy} > @@ -432,7 +556,7 @@ function Questions() { onClick={generateQuestions} disabled={modalBusy || !positionId} > - {modalBusy ? '생성 중...' : '질문 생성'} + {modalBusy ? '생성 중...' : `${selectedDifficulty}단계 질문 생성`}
diff --git a/FE/src/pages/SignIn.jsx b/FE/src/pages/SignIn.jsx index 41dcc54..839bff0 100644 --- a/FE/src/pages/SignIn.jsx +++ b/FE/src/pages/SignIn.jsx @@ -18,12 +18,12 @@ function SignIn() { // 로그인 상태 확인 (새로고침 시에도 유지) useEffect(() => { - const token = localStorage.getItem('token'); + const accessToken = localStorage.getItem('accessToken') || localStorage.getItem('token'); const storedUserId = localStorage.getItem('userId'); - if (token && storedUserId) { + if (accessToken && storedUserId) { setIsLoggedIn(true); setUserId(storedUserId); - setToken(token); + setToken(accessToken); } }, []); @@ -56,26 +56,34 @@ function SignIn() { if (!res.ok) throw new Error('로그인 실패'); const data = await res.json(); - console.log('로그인 응답 전체 데이터:', data); // 전체 응답 데이터 확인 - console.log('로그인 응답 data 필드:', data.data); // data 필드 확인 + console.log('로그인 응답 전체 데이터:', data); - const loginData = data.data; // ← 실제 로그인 정보 - console.log('loginData 구조:', loginData); // loginData 구조 확인 + const loginData = data.data; + console.log('loginData 구조:', loginData); - // 토큰, 사용자 ID, 이메일 저장 - localStorage.setItem('token', loginData.accessToken); - localStorage.setItem('userId', loginData.user.id); + // 토큰과 사용자 정보 저장 (두 키 모두에 저장하여 호환성 확보) + const accessToken = loginData.accessToken; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('token', accessToken); // 기존 코드 호환성 + localStorage.setItem('userId', loginData.user.id.toString()); localStorage.setItem('userEmail', email); + + // refreshToken이 있다면 저장 + if (loginData.refreshToken) { + localStorage.setItem('refreshToken', loginData.refreshToken); + } - console.log('localStorage에서 id:', localStorage.getItem('token')); - console.log('localStorage에서 token:', localStorage.getItem('token')); // 저장 후 확인 + console.log('저장된 토큰:', localStorage.getItem('accessToken')); + console.log('저장된 사용자 ID:', localStorage.getItem('userId')); setIsLoggedIn(true); - setUserId(loginData.user.id); + setUserId(loginData.user.id.toString()); setError(''); - // 로그인 성공 후 - navigate('/'); // 메인 화면으로 이동 + + // 로그인 성공 후 메인 화면으로 이동 + navigate('/'); } catch (err) { + console.error('로그인 오류:', err); setError('이메일 또는 비밀번호가 올바르지 않습니다.'); } }; @@ -87,21 +95,38 @@ function SignIn() { credentials: 'include', headers: { 'accept': '*/*', + 'Authorization': `Bearer ${localStorage.getItem('accessToken') || localStorage.getItem('token')}` }, }); + if (res.ok) { - // 로컬 스토리지에서 토큰, 사용자 ID, 이메일 제거 + // 모든 저장된 인증 정보 제거 + localStorage.removeItem('accessToken'); localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); localStorage.removeItem('userId'); localStorage.removeItem('userEmail'); setIsLoggedIn(false); setUserId(''); + setToken(''); alert('로그아웃 되었습니다.'); navigate('/signin'); } } catch (err) { - alert('로그아웃에 실패했습니다.'); + console.error('로그아웃 오류:', err); + // 로그아웃 실패해도 로컬 정보는 정리 + localStorage.removeItem('accessToken'); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userId'); + localStorage.removeItem('userEmail'); + + setIsLoggedIn(false); + setUserId(''); + setToken(''); + alert('로그아웃 처리되었습니다.'); + navigate('/signin'); } }; @@ -171,7 +196,6 @@ function SignIn() { 로그인 유지 -
diff --git a/FE/src/utils/api.jsx b/FE/src/utils/api.jsx index a5f3622..e8a56e9 100644 --- a/FE/src/utils/api.jsx +++ b/FE/src/utils/api.jsx @@ -10,87 +10,208 @@ const api = axios.create({ }); /* ------------------------------------------------------------------ */ -/* 공통 인터셉터 – Access-Token 자동 부착 & 401 → refresh 로직 */ +/* 향상된 인증 인터셉터 */ /* ------------------------------------------------------------------ */ api.interceptors.request.use((cfg) => { - const token = localStorage.getItem('accessToken'); - if (token) cfg.headers.Authorization = `Bearer ${token}`; + // 토큰 확인 및 자동 부착 + const token = localStorage.getItem('accessToken') || localStorage.getItem('token'); + if (token) { + cfg.headers.Authorization = `Bearer ${token}`; + console.log('API 요청에 토큰 부착:', cfg.url); + } else { + console.warn('토큰이 없습니다:', cfg.url); + } return cfg; }); api.interceptors.response.use( (res) => res, async (err) => { - if ( - err.response?.status === 401 && - !err.config._retry && - localStorage.getItem('refreshToken') - ) { - err.config._retry = true; - try { - const { data } = await axios.post(`${API_BASE_URL}/api/auth/refresh`, { - refreshToken: localStorage.getItem('refreshToken'), - }); - const accessToken = data.data.accessToken; - localStorage.setItem('accessToken', accessToken); - err.config.headers.Authorization = `Bearer ${accessToken}`; - return api(err.config); - } catch (_) { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - window.location.href = '/signin'; + const originalRequest = err.config; + + // 401 오류 처리 + if (err.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + const refreshToken = localStorage.getItem('refreshToken'); + + if (refreshToken) { + try { + console.log('토큰 갱신 시도...'); + const { data } = await axios.post(`${API_BASE_URL}/api/auth/refresh`, { + refreshToken: refreshToken, + }); + + const newAccessToken = data.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + localStorage.setItem('token', newAccessToken); // 호환성 + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + console.log('토큰 갱신 성공'); + return api(originalRequest); + } catch (refreshError) { + console.error('토큰 갱신 실패:', refreshError); + } } + + // 토큰 갱신 실패 또는 리프레시 토큰 없음 + console.log('인증 실패 - 로그인 필요'); + localStorage.clear(); + window.location.href = '/signin'; } + return Promise.reject(err); }, ); /* ------------------------------------------------------------------ */ -/* Interview */ +/* 인증 상태 확인 유틸리티 */ /* ------------------------------------------------------------------ */ -export const createInterview = (payload) => - api.post('/api/interviews', payload); // 인증 토큰으로 사용자 식별 +export const checkAuthStatus = () => { + const token = localStorage.getItem('accessToken') || localStorage.getItem('token'); + const userId = localStorage.getItem('userId'); + + return { + isAuthenticated: !!(token && userId), + token, + userId, + userEmail: localStorage.getItem('userEmail') + }; +}; + +export const clearAuthData = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userId'); + localStorage.removeItem('userEmail'); +}; -export const startInterview = (interviewId) => - api.post(`/api/interviews/${interviewId}/start`); - -export const getInterviewQuestions = (interviewId) => - api.get(`/api/interviews/${interviewId}/questions`); - -export const getNextQuestion = (interviewId) => - api.get(`/api/interviews/${interviewId}/next-question`); - -export const submitAnswer = (questionId, body) => - api.post(`/api/interviews/questions/${questionId}/answer`, body); - -export const uploadAudioAnswer = (questionId, formData) => - api.post(`/api/interviews/questions/${questionId}/answer/audio`, formData, { +/* ------------------------------------------------------------------ */ +/* Interview API */ +/* ------------------------------------------------------------------ */ +export const createInterview = (payload) => { + console.log('Creating interview with payload:', payload); + return api.post('/api/interviews', payload); +}; + +export const startInterview = (interviewId) => { + console.log('Starting interview:', interviewId); + return api.post(`/api/interviews/${interviewId}/start`); +}; + +export const getInterviewQuestions = (interviewId) => { + console.log('Getting interview questions for:', interviewId); + return api.get(`/api/interviews/${interviewId}/questions`); +}; + +export const getNextQuestion = (interviewId) => { + console.log('Getting next question for interview:', interviewId); + return api.get(`/api/interviews/${interviewId}/next-question`); +}; + +export const submitAnswer = (questionId, body) => { + console.log('Submitting answer for question:', questionId, body); + return api.post(`/api/interviews/questions/${questionId}/answer`, body); +}; + +export const uploadAudioAnswer = (questionId, formData) => { + console.log('Uploading audio answer for question:', questionId); + return api.post(`/api/interviews/questions/${questionId}/answer/audio`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); +}; -export const completeInterview = (interviewId) => - api.post(`/api/interviews/${interviewId}/complete`); +export const completeInterview = (interviewId) => { + console.log('Completing interview:', interviewId); + return api.post(`/api/interviews/${interviewId}/complete`); +}; -export const updateInterviewTime = (interviewId, timeData) => - api.post(`/api/interviews/${interviewId}/time`, timeData); +export const updateInterviewTime = (interviewId, timeData) => { + console.log('Updating interview time:', interviewId, timeData); + return api.post(`/api/interviews/${interviewId}/time`, timeData); +}; /* ------------------------------------------------------------------ */ -/* Question 검색 & 즐겨찾기 */ +/* Question 검색 & 즐겨찾기 (인증 필수) */ /* ------------------------------------------------------------------ */ -export const searchQuestions = (params) => - api.get('/api/interviews/questions/search', { params }); +export const searchQuestions = (params) => { + console.log('Searching questions with params:', params); + const authStatus = checkAuthStatus(); + if (!authStatus.isAuthenticated) { + throw new Error('Authentication required'); + } + return api.get('/api/interviews/questions/search', { params }); +}; + +export const toggleFavoriteQuestion = (questionId) => { + console.log('Toggling favorite for question:', questionId); + const authStatus = checkAuthStatus(); + if (!authStatus.isAuthenticated) { + throw new Error('Authentication required'); + } + return api.post(`/api/interviews/questions/${questionId}/favorite`); +}; + +export const getFavoriteQuestions = () => { + console.log('Getting favorite questions'); + const authStatus = checkAuthStatus(); + if (!authStatus.isAuthenticated) { + throw new Error('Authentication required'); + } + return api.get('/api/interviews/questions/favorites'); +}; -export const toggleFavoriteQuestion = (questionId) => - api.post(`/api/interviews/questions/${questionId}/favorite`); +/* ------------------------------------------------------------------ */ +/* Resume & Position */ +/* ------------------------------------------------------------------ */ +export const getMyResume = () => { + console.log('Getting my resume'); + return api.get('/api/resume'); +}; + +export const resumeExists = () => { + console.log('Checking if resume exists'); + return api.get('/api/resume/exists'); +}; + +export const listCompanies = () => { + console.log('Getting companies list'); + return api.get('/api/companies'); +}; + +export const listPositions = (companyId) => { + console.log('Getting positions for company:', companyId); + return api.get(`/api/companies/${companyId}/positions`); +}; /* ------------------------------------------------------------------ */ -/* Resume & Position (면접 생성 시 필수 ID) */ +/* Auth 관련 (대체 방법) */ /* ------------------------------------------------------------------ */ -export const getMyResume = () => api.get('/api/resume'); // 존재 시 200 -export const resumeExists = () => api.get('/api/resume/exists'); // true / false +export const getCurrentUser = () => { + console.log('Getting current user via localStorage'); + const authStatus = checkAuthStatus(); + + if (!authStatus.isAuthenticated) { + throw new Error('User not authenticated'); + } + + // localStorage 기반으로 사용자 정보 반환 + return Promise.resolve({ + data: { + data: { + id: authStatus.userId, + email: authStatus.userEmail, + isAuthenticated: true + } + } + }); +}; -export const listCompanies = () => api.get('/api/companies'); // 선택지 표시용 -export const listPositions = (companyId) => - api.get(`/api/companies/${companyId}/positions`); +// 실제 서버 API 호출 (작동하는 경우) +export const getCurrentUserFromAPI = () => { + console.log('Getting current user from API'); + return api.get('/api/auth/me'); +}; export default api; \ No newline at end of file