diff --git a/front/App.tsx b/front/App.tsx index e443289..2a83784 100644 --- a/front/App.tsx +++ b/front/App.tsx @@ -5,8 +5,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { StatusBar } from 'react-native'; import useThemeStorage from './src/hooks/useThemeStorage'; import { colors } from './src/constants/colors'; -import { useBookmarkStore } from './src/store/useBookmarkStore'; +// import { useBookmarkStore } from './src/store/useBookmarkStore'; import SplashScreen from 'react-native-splash-screen'; +import { useAuthStore } from './src/store/useAuthStore'; +import { initializeAuth } from './src/util/auth'; const queryClient = new QueryClient({ defaultOptions: { @@ -19,16 +21,19 @@ const queryClient = new QueryClient({ }, }); - function App(): React.JSX.Element { - const {theme} = useThemeStorage(); + const { theme } = useThemeStorage(); + const { setLoggedIn } = useAuthStore(); useEffect(() => { - useBookmarkStore.getState().loadBookmarks(); + initializeAuth(setLoggedIn); + // 북마크 로드와 함께 인증 초기화 + // useBookmarkStore.getState().loadBookmarks(); + setTimeout(() => { SplashScreen.hide(); }, 500); - }, []); + }, [setLoggedIn]); return ( diff --git a/front/src/navigation/RootStackNavigator.tsx b/front/src/navigation/RootStackNavigator.tsx index 33da757..50811d0 100644 --- a/front/src/navigation/RootStackNavigator.tsx +++ b/front/src/navigation/RootStackNavigator.tsx @@ -5,6 +5,7 @@ import LoginScreen from '../screens/oauth/LoginScreen'; import SelectCertificationScreen from '../screens/selectCertification/SelectCertificationScreen'; import HomeStackNavigator, { HomeStackParamList } from './HomeStackNavigator'; import { NavigatorScreenParams } from '@react-navigation/native'; +import { useAuthStore } from '../store/useAuthStore'; export type RootStackParamList = { AuthHome: undefined; @@ -16,12 +17,17 @@ export type RootStackParamList = { const Stack = createStackNavigator(); function RootStackNavigator() { + const { isLoggedIn } = useAuthStore(); + return ( - + {isLoggedIn ? ( + + ) : ( + + )} - ); } diff --git a/front/src/screens/bookmark/BookmarkScreen.tsx b/front/src/screens/bookmark/BookmarkScreen.tsx index 31317ce..a8da2a4 100644 --- a/front/src/screens/bookmark/BookmarkScreen.tsx +++ b/front/src/screens/bookmark/BookmarkScreen.tsx @@ -1,8 +1,7 @@ -import React from 'react'; +import React, { useState, useCallback } from 'react'; import {View, Text, ScrollView} from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useQuery } from '@tanstack/react-query'; import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { colors } from '../../constants/colors'; import { HomeStackParamList } from '../../navigation/HomeStackNavigator'; @@ -11,40 +10,41 @@ import { ScaledSheet } from 'react-native-size-matters'; import { fetchGet } from '../../util/api'; import { QuestionData } from '../components/QuestionItem'; import { useBookmarkStore } from '../../store/useBookmarkStore'; +import { useFocusEffect } from '@react-navigation/native'; function BookmarkScreen() { const { theme } = useThemeStore(); const styles = styling(theme); const navigation = useNavigation>(); const { bookmarks } = useBookmarkStore(); + const [bookmarkQuestions, setBookmarkQuestions] = useState([]); console.log(bookmarks); - - const { data: bookmarkQuestions } = useQuery({ - queryKey: ['bookmarkQuestions', bookmarks], - queryFn: () => fetchGet('exam/book-mark/question?certificationId=1'), - staleTime: 0, - gcTime: 0, - // refetchOnWindowFocus: true, - // refetchOnMount: true, - // refetchOnReconnect: true, - }); + // 북마크 질문들을 가져오는 함수 + const fetchBookmarkQuestions = useCallback(async () => { + try { + const data = await fetchGet('exam/book-mark/question?certificationId=1') as QuestionData[]; + setBookmarkQuestions(data); + } catch (error) { + console.error('북마크 질문을 가져오는데 실패했습니다:', error); + setBookmarkQuestions([]); + } + }, []); const handleQuestionSelect = (index: number) => { // 네비게이션을 사용하여 QuestionPager 화면으로 이동 navigation.navigate('QuestionPager', { questions: bookmarkQuestions, currentPage: index + 1, - handlePageChange: (page: number) => console.log('페이지 변경:', page), mode: 'bookmark', }); }; - // useFocusEffect( - // React.useCallback(() => { - // refetch(); - // }, [refetch]) - // ); + useFocusEffect( + React.useCallback(() => { + fetchBookmarkQuestions(); + }, [fetchBookmarkQuestions]) + ); console.log(bookmarkQuestions); return ( diff --git a/front/src/screens/components/Header.tsx b/front/src/screens/components/Header.tsx index bbb2b4f..55a8256 100644 --- a/front/src/screens/components/Header.tsx +++ b/front/src/screens/components/Header.tsx @@ -6,9 +6,9 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { colors } from '../../constants/colors'; -import { getEncryptStorage, UserKey } from '../../util/encryptStorage'; +import { getEncryptStorage, UserNameKey } from '../../util/encryptStorage'; import { RootStackParamList } from '../../navigation/RootStackNavigator'; - +import { useAuthStore } from '../../store/useAuthStore'; function Header() { const { theme } = useThemeStore(); // 현재 테마 가져오기 @@ -16,13 +16,14 @@ function Header() { const styles = styling(theme, insets); // 테마별 스타일 적용 const [userName, setUserName] = useState(null); // 사용자 이름을 저장할 상태 변수 const navigation = useNavigation>(); + const { isLoggedIn } = useAuthStore(); async function fetchUserName() { try { - const data = await getEncryptStorage(UserKey); + const data = await getEncryptStorage(UserNameKey); - if (data && data.username) { - const originalName = data.username; + if (data) { + const originalName = data; // '_'가 포함되어 있는지 확인하고, 포함되어 있다면 '_' 앞부분만 사용 const processedName = originalName.includes('_') ? originalName.split('_')[0] @@ -38,7 +39,7 @@ function Header() { useEffect(() => { fetchUserName(); - }, []); + }, [isLoggedIn]); const backToLoginScreen = () => { navigation.reset({ diff --git a/front/src/screens/components/QuestionItem.tsx b/front/src/screens/components/QuestionItem.tsx index 2a1bf11..49de2e7 100644 --- a/front/src/screens/components/QuestionItem.tsx +++ b/front/src/screens/components/QuestionItem.tsx @@ -6,7 +6,7 @@ import FontAwesomeIcons from 'react-native-vector-icons/FontAwesome'; import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { colors } from '../../constants/colors'; import { extractChoiceNumber, parseContent } from '../../constants/examParser'; -import { fetchPatch, fetchPost } from '../../util/api'; +import { fetchPost, fetchDelete } from '../../util/api'; import ChoiceItem from './ChoiceItem'; import { UserAnswer } from '../../screens/components/QuestionPagerScreen'; import { useBookmarkStore } from '../../store/useBookmarkStore'; @@ -91,23 +91,24 @@ const QuestionItem = React.memo(({ } }; - const bookmarks = useBookmarkStore((state) => state.bookmarks); - const toggleBookmark = useBookmarkStore((state) => state.toggleBookmark); const [loading, setLoading] = useState(false); + const bookmarks = useBookmarkStore((state) => state.bookmarks); const isBookmarked = bookmarks.includes(question.questionId); const handleStarPress = async () => { if (loading) {return;} setLoading(true); try { - // 서버에 patch 요청 - await fetchPatch(`exam/book-mark?questionId=${question.questionId}`); - // zustand 상태만 변경 (EncryptedStorage 저장은 store 내부에서 처리) - console.log(question.questionId); - toggleBookmark(question.questionId); + if (isBookmarked) { + // 북마크 제거 - DELETE 요청 + await fetchDelete('exam/book-mark', { questionId: question.questionId }); + } else { + // 북마크 추가 - POST 요청 + await fetchPost('exam/book-mark', { questionId: question.questionId }); + } } catch (e) { - Alert.alert('알림', '북마크를 추가할 수 없습니다.'); + Alert.alert('알림', '북마크 처리에 실패했습니다.'); } finally { setLoading(false); } diff --git a/front/src/screens/myPage/MyPageScreen.tsx b/front/src/screens/myPage/MyPageScreen.tsx index 3cd40ea..c4af4d0 100644 --- a/front/src/screens/myPage/MyPageScreen.tsx +++ b/front/src/screens/myPage/MyPageScreen.tsx @@ -2,49 +2,52 @@ import React, { useState } from 'react'; import { View, Text, Pressable, Alert } from 'react-native'; import { ScaledSheet } from 'react-native-size-matters'; import { useMutation } from '@tanstack/react-query'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { colors } from '../../constants/colors'; import { fetchPost, fetchDelete } from '../../util/api'; -import { RootStackParamList } from '../../navigation/RootStackNavigator'; -import { removeEncryptStorage, JwtKey, UserKey } from '../../util/encryptStorage'; +import { removeEncryptStorage, AccessKey, UserNameKey, UserNicknameKey, CertificationKey } from '../../util/encryptStorage'; import ThemeModal from './components/ThemeModal'; +import { useAuthStore } from '../../store/useAuthStore'; type FetchMethod = typeof fetchPost | typeof fetchDelete; type ApiRequest = { url: string; method: FetchMethod; + data?: any; }; -interface MyPageScreenProps { - -} - -function MyPageScreen({}: MyPageScreenProps) { +function MyPageScreen() { const { theme } = useThemeStore(); const styles = styling(theme); - const navigation = useNavigation>(); const [isModalVisible, setIsModalVisible] = useState(false); + const { setLoggedIn, isLoggedIn } = useAuthStore(); const apiMutation = useMutation({ mutationFn: (request: ApiRequest) => { - return request.method(request.url, {}); + return request.method(request.url, request.data); }, onSuccess: () => { - removeEncryptStorage(JwtKey); - removeEncryptStorage(UserKey); - navigation.reset({ - index: 0, - routes: [{ name: 'AuthHome' }], - }); + // 저장소에서 데이터 제거 + removeEncryptStorage(AccessKey); + removeEncryptStorage(UserNameKey); + removeEncryptStorage(UserNicknameKey); + removeEncryptStorage(CertificationKey); + + // 로그인 상태 변경 (이렇게 하면 RootStackNavigator가 자동으로 AuthHome을 렌더링) + setLoggedIn(false); }, - onError: () => { + onError: (error) => { + console.log('로그아웃 실패', error); }, }); const onLogout = () => { + if (!isLoggedIn) { + Alert.alert('알림', '로그인 상태가 아닙니다'); + return; + } + Alert.alert('로그아웃', '로그아웃하시겠습니까?', [ { text: '아니오', @@ -54,13 +57,19 @@ function MyPageScreen({}: MyPageScreenProps) { { text: '예', onPress: () => { - apiMutation.mutate({ url: 'user/logout', method: fetchPost }); + // 빈 객체라도 body로 전송 + apiMutation.mutate({ url: 'user/logout', method: fetchPost, data: {} }); }, }, ]); }; const onDelete = () => { + if (!isLoggedIn) { + Alert.alert('알림', '로그인 상태가 아닙니다'); + return; + } + Alert.alert('회원 탈퇴', '정말로 회원을 탈퇴하시겠습니까?', [ { text: '아니오', diff --git a/front/src/screens/oauth/AuthHomeScreen.tsx b/front/src/screens/oauth/AuthHomeScreen.tsx index 79e29e5..f9fa9c3 100644 --- a/front/src/screens/oauth/AuthHomeScreen.tsx +++ b/front/src/screens/oauth/AuthHomeScreen.tsx @@ -10,14 +10,16 @@ import SelectCertificationScreen from '../selectCertification/SelectCertificatio import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../../navigation/RootStackNavigator'; -import { getEncryptStorage, JwtKey } from '../../util/encryptStorage'; +import { AccessKey, getEncryptStorage } from '../../util/encryptStorage'; +import { useAuthStore } from '../../store/useAuthStore'; export interface LoginUserResponse { token: string; + refresh: string; user: { nickname: string; - userId: number; - userName: string; + provider: string; + username: string; } } @@ -26,27 +28,24 @@ function AuthHomeScreen() { const insets = useSafeAreaInsets(); const styles = styling(theme, insets); const [currentPage, setCurrentPage] = useState(0); + const { isLoggedIn } = useAuthStore(); const navigation = useNavigation>(); useEffect(() => { const checkJwt = async () => { - try { - const jwt = await getEncryptStorage(JwtKey); - if (jwt) { - // 토큰이 있으면 바로 홈 화면으로 이동 - // certifications 요청은 해당 화면에서 자동으로 이루어짐 - navigation.navigate('HomeStack'); - // certifications 요청이 실패하면 해당 화면의 오류 처리에서 - // 토큰 관련 오류 처리가 자동으로 이루어질 것임 - } - } catch (error) { - console.error('토큰 확인 오류:', error); + if (!isLoggedIn) { + return; // 로그아웃 상태면 체크하지 않음 + } + + const jwt = await getEncryptStorage(AccessKey); + if (jwt) { + navigation.navigate('HomeStack'); } }; checkJwt(); - }, [navigation]); + }, [navigation, isLoggedIn]); const handleLoginSuccess = useCallback(() => { setCurrentPage(1); diff --git a/front/src/screens/oauth/GoogleLogin.tsx b/front/src/screens/oauth/GoogleLogin.tsx index 39f9e0b..af31cdb 100644 --- a/front/src/screens/oauth/GoogleLogin.tsx +++ b/front/src/screens/oauth/GoogleLogin.tsx @@ -1,19 +1,20 @@ -import React from 'react'; -import { Text, Pressable } from 'react-native'; +import React, { useState } from 'react'; +import { Text, Pressable, Alert, View } from 'react-native'; import { ScaledSheet } from 'react-native-size-matters'; import { useMutation } from '@tanstack/react-query'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; -import { setEncryptStorage, JwtKey, UserKey } from '../../util/encryptStorage'; +import { setEncryptStorage, AccessKey, UserNameKey, UserProviderKey } from '../../util/encryptStorage'; import Config from 'react-native-config'; import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { colors } from '../../constants/colors'; import { fetchPost } from '../../util/api'; import { LoginUserResponse } from './AuthHomeScreen'; +import { useAuthStore } from '../../store/useAuthStore'; GoogleSignin.configure({ webClientId: Config.WEB_CLIENT_ID, iosClientId: Config.IOS_CLIENT_ID, - offlineAccess: false, + offlineAccess: true, }); type GoogleLoginProps = { @@ -32,6 +33,8 @@ function GoogleLogin( ) { const {theme} = useThemeStore(); const styles = styling(theme); + const { setLoggedIn } = useAuthStore(); + const [log, setLog] = useState(''); // 구글 로그인 정보를 백엔드로 전송하는 mutation const { mutate: sendTokensToBackend } = useMutation({ @@ -43,15 +46,19 @@ function GoogleLogin( return result; }, onSuccess: async (data) => { - console.log('구글 로그인 성공'); - if (data.token) { - await setEncryptStorage(JwtKey, data.token); - await setEncryptStorage(UserKey, data.user); - onLoginSuccess?.(); - } + console.log('구글 백엔드 서버 요청 성공'); + console.log(data); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '구글 백엔드 서버 요청 성공'); + await setEncryptStorage(AccessKey, data.token); + await setEncryptStorage(UserNameKey, data.user.username); + await setEncryptStorage(UserProviderKey, data.user.provider); + setLoggedIn(true); + onLoginSuccess?.(); }, - onError: () => { - console.log('구글 로그인 실패'); + onError: (data) => { + console.log('구글 백엔드 서버 요청 실패'); + console.log(data); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '구글 백엔드 서버 요청 실패' + data); }, }); @@ -59,24 +66,29 @@ function GoogleLogin( const { mutate: handleGoogleLogin } = useMutation({ mutationFn: async () => { await GoogleSignin.hasPlayServices(); - const userInfo = await GoogleSignin.signIn(); + await GoogleSignin.signIn(); const tokens = await GoogleSignin.getTokens(); return { - userInfo, tokens, }; }, - onSuccess: (loginData) => { - console.log('구글 로그인 성공'); - sendTokensToBackend(loginData); + onSuccess: (tokens) => { + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '구글 서버 요청 성공'); + sendTokensToBackend(tokens); + console.log(tokens); + console.log(tokens.tokens.accessToken); }, - onError: () => { - console.log('구글 로그인 실패'); + onError: (data) => { + console.log('구글 서버 요청 실패'); + console.log(data); + Alert.alert('구글 로그인 실패', '다시 시도해주세요'); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '구글 서버 요청 실패' + data); }, }); return ( + [ styles.button, @@ -86,11 +98,17 @@ function GoogleLogin( > 구글 계정으로 계속하기 + {log} + ); } const styling = (theme: themeMode) => ScaledSheet.create({ - + buttonContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, button: { width: '100%', padding: '15@ms0.3', diff --git a/front/src/screens/oauth/KakaoLogin.tsx b/front/src/screens/oauth/KakaoLogin.tsx index 6f1bd4c..da31756 100644 --- a/front/src/screens/oauth/KakaoLogin.tsx +++ b/front/src/screens/oauth/KakaoLogin.tsx @@ -1,13 +1,14 @@ -import React from 'react'; -import { Pressable, Text } from 'react-native'; +import React, { useState } from 'react'; +import { Alert, Pressable, Text, View } from 'react-native'; import { ScaledSheet } from 'react-native-size-matters'; import { login, KakaoOAuthToken } from '@react-native-seoul/kakao-login'; import { useMutation } from '@tanstack/react-query'; -import { setEncryptStorage, JwtKey, UserKey } from '../../util/encryptStorage'; +import { setEncryptStorage, AccessKey, UserNameKey, UserProviderKey } from '../../util/encryptStorage'; import { colors } from '../../constants/colors'; import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { fetchPost } from '../../util/api'; import { LoginUserResponse } from './AuthHomeScreen'; +import { useAuthStore } from '../../store/useAuthStore'; interface Props { onLoginSuccess?: () => void; @@ -16,7 +17,8 @@ interface Props { function KakaoLogin({ onLoginSuccess }: Props) { const {theme} = useThemeStore(); const styles = styling(theme); - + const { setLoggedIn } = useAuthStore(); + const [log, setLog] = useState(''); // 카카오 로그인 정보를 백엔드로 전송하는 mutation const { mutate: sendTokensToBackend } = useMutation({ @@ -28,13 +30,19 @@ function KakaoLogin({ onLoginSuccess }: Props) { return result; }, onSuccess: async (data) => { + console.log(data); + console.log('카카오 백엔드 서버 요청 성공'); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '카카오 백엔드 서버 요청 성공'); + await setEncryptStorage(AccessKey, data.token); + await setEncryptStorage(UserNameKey, data.user.username); + await setEncryptStorage(UserProviderKey, data.user.provider); + setLoggedIn(true); onLoginSuccess?.(); - - if (data.token) { - console.log('Login successful:', data); - await setEncryptStorage(JwtKey, data.token); - await setEncryptStorage(UserKey, data.user); - } + }, + onError: (data) => { + console.log('카카오 백엔드 서버 요청 실패'); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '카카오 백엔드 서버 요청 실패' + data); + Alert.alert('카카오 로그인 실패', '다시 시도해주세요'); }, }); @@ -45,21 +53,29 @@ function KakaoLogin({ onLoginSuccess }: Props) { return token; }, onSuccess: (loginData) => { + console.log('카카오 서버 요청 성공'); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '카카오 서버 요청 성공'); sendTokensToBackend(loginData); }, + onError: (data) => { + console.log('카카오 서버 요청 실패'); + setLog(prevLog => prevLog + (prevLog ? '\n' : '') + '카카오 서버 요청 실패' + data); + }, }); - return ( - [ - styles.button, - pressed && styles.buttonPressed, - ]} - onPress={() => handleKakaoLogin()} - > - 카카오 계정으로 계속하기 - + + [ + styles.button, + pressed && styles.buttonPressed, + ]} + onPress={() => handleKakaoLogin()} + > + 카카오 계정으로 계속하기 + + {log} + ); } diff --git a/front/src/screens/oauth/LoginScreen.tsx b/front/src/screens/oauth/LoginScreen.tsx index 6075917..710230f 100644 --- a/front/src/screens/oauth/LoginScreen.tsx +++ b/front/src/screens/oauth/LoginScreen.tsx @@ -5,7 +5,7 @@ import useThemeStore, { themeMode } from '../../store/useThemeStore'; import { colors } from '../../constants/colors'; import KakaoLogin from '../oauth/KakaoLogin'; import GoogleLogin from '../oauth/GoogleLogin'; -import { removeEncryptStorage, JwtKey, UserKey } from '../../util/encryptStorage'; +import { removeEncryptStorage, AccessKey, UserNameKey, UserNicknameKey } from '../../util/encryptStorage'; interface LoginPageProps { onLoginSuccess?: () => void; @@ -24,8 +24,9 @@ const LoginPage: React.FC = ({ onLoginSuccess, onNonLogin }) => const handleNonLogin = () => { if (onNonLogin) { - removeEncryptStorage(JwtKey); - removeEncryptStorage(UserKey); + removeEncryptStorage(AccessKey); + removeEncryptStorage(UserNameKey); + removeEncryptStorage(UserNicknameKey); onNonLogin(); } }; diff --git a/front/src/screens/selectCertification/SelectCertificationScreen.tsx b/front/src/screens/selectCertification/SelectCertificationScreen.tsx index fef3d9a..dac1c22 100644 --- a/front/src/screens/selectCertification/SelectCertificationScreen.tsx +++ b/front/src/screens/selectCertification/SelectCertificationScreen.tsx @@ -10,6 +10,7 @@ import { RootStackParamList } from '../../navigation/RootStackNavigator'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; import useCertificationStore from '../../store/useCertificationStore'; +import { useAuthStore } from '../../store/useAuthStore'; export interface Certification { certificationId: number; @@ -21,6 +22,7 @@ const SelectCertificationScreen = () => { const insets = useSafeAreaInsets(); const styles = styling(theme, insets); const navigation = useNavigation['navigation']>(); + const { isLoggedIn, setLoggedIn } = useAuthStore(); const [searchQuery, setSearchQuery] = useState(''); const { selectedCertifications, toggleCertification, loadCertifications } = useCertificationStore(); @@ -57,10 +59,16 @@ const SelectCertificationScreen = () => { }; const handleComplete = () => { - navigation.replace('HomeStack', { - screen: 'MainTab', - params: { certifications: selectedCertifications }, - }); + if (isLoggedIn) { + // 로그인 상태: HomeStack으로 이동 + navigation.replace('HomeStack', { + screen: 'MainTab', + params: { certifications: selectedCertifications }, + }); + } else { + // 비로그인 상태: 로그인 상태를 true로 설정 (이렇게 하면 RootStackNavigator가 HomeStack을 렌더링) + setLoggedIn(true); + } }; return ( diff --git a/front/src/screens/wrongQuestion/WrongQuestionScreen.tsx b/front/src/screens/wrongQuestion/WrongQuestionScreen.tsx index 2c3971c..8b3567b 100644 --- a/front/src/screens/wrongQuestion/WrongQuestionScreen.tsx +++ b/front/src/screens/wrongQuestion/WrongQuestionScreen.tsx @@ -10,30 +10,30 @@ import { QuestionData } from '../components/QuestionItem'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { HomeStackParamList } from '../../navigation/HomeStackNavigator'; +import { useFocusEffect } from '@react-navigation/native'; const WrongQuestionScreen = () => { const { theme } = useThemeStore(); const styles = styling(theme); const navigation = useNavigation>(); - const { data: wrongQuestions } = useQuery({ + const { data: wrongQuestions, refetch } = useQuery({ queryKey: ['wrongQuestions'], - queryFn: () => { - console.log('wrongQuestions fetch'); - return fetchGet('exam/wrong-questions?status=WRONG'); - }, - staleTime: 0, - gcTime: 0, - + queryFn: () => fetchGet('exam/wrong-questions?status=WRONG'), }); + useFocusEffect( + React.useCallback(() => { + refetch(); + }, [refetch]) + ); console.log(wrongQuestions); + // 문제 클릭 핸들러 const handleQuestionSelect = (index: number) => { navigation.navigate('QuestionPager', { questions: wrongQuestions, currentPage: index + 1, - handlePageChange: (page: number) => console.log('페이지 변경:', page), mode: 'wrongQuestion', }); }; diff --git a/front/src/store/useAuthStore.ts b/front/src/store/useAuthStore.ts new file mode 100644 index 0000000..89663fa --- /dev/null +++ b/front/src/store/useAuthStore.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand'; + +interface AuthState { + isLoggedIn: boolean; + setLoggedIn: (status: boolean) => void; +} + +export const useAuthStore = create((set) => ({ + isLoggedIn: false, + setLoggedIn: (status) => set({ isLoggedIn: status }), +})); diff --git a/front/src/store/useBookmarkStore.ts b/front/src/store/useBookmarkStore.ts index 67a2b88..765e1ad 100644 --- a/front/src/store/useBookmarkStore.ts +++ b/front/src/store/useBookmarkStore.ts @@ -1,46 +1,46 @@ // store/useBookmarkStore.ts import { create } from 'zustand'; -import { getEncryptStorage, setEncryptStorage, BookMarkKey } from '../util/encryptStorage'; +import { fetchGet } from '../util/api'; interface BookmarkState { bookmarks: number[]; - toggleBookmark: (id: number) => void; - setBookmarks: (arr: number[]) => void; - loadBookmarks: () => Promise; + isLoading: boolean; + fetchBookmarks: (certificationId?: number) => Promise; + addBookmarkLocally: (id: number) => void; + removeBookmarkLocally: (id: number) => void; + clearBookmarks: () => void; } export const useBookmarkStore = create((set) => ({ bookmarks: [], - toggleBookmark: (id: number) => { - set((state) => { - const exists = state.bookmarks.includes(id); - const newArr = exists - ? state.bookmarks.filter((i) => i !== id) - : [...state.bookmarks, id]; - // 상태 변경 후 EncryptedStorage에도 저장 - setEncryptStorage(BookMarkKey, newArr); - console.log('newArr',newArr); + isLoading: false, - return { bookmarks: newArr }; - }); + fetchBookmarks: async (certificationId = 1) => { + set({ isLoading: true }); + try { + const data = await fetchGet(`exam/book-mark/question?certificationId=${certificationId}`) as Array<{ questionId: number }>; + const bookmarkIds = data.map(item => item.questionId); + set({ bookmarks: bookmarkIds, isLoading: false }); + } catch (error) { + console.error('북마크 목록을 가져오는데 실패했습니다:', error); + set({ bookmarks: [], isLoading: false }); + } }, - setBookmarks: (arr: number[]) => { - set({ bookmarks: arr }); - setEncryptStorage(BookMarkKey, arr); + + // 서버 요청 성공 후 로컬 상태 업데이트용 + addBookmarkLocally: (id: number) => { + set((state) => ({ + bookmarks: state.bookmarks.includes(id) ? state.bookmarks : [...state.bookmarks, id], + })); }, - loadBookmarks: async () => { - const raw = await getEncryptStorage(BookMarkKey); - if (raw) { - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) {set({ bookmarks: parsed });} - else if (typeof parsed === 'number') {set({ bookmarks: [parsed] });} - else {set({ bookmarks: [] });} - } catch { - set({ bookmarks: [] }); - } - } else { - set({ bookmarks: [] }); - } + + removeBookmarkLocally: (id: number) => { + set((state) => ({ + bookmarks: state.bookmarks.filter(bookmarkId => bookmarkId !== id), + })); + }, + + clearBookmarks: () => { + set({ bookmarks: [] }); }, })); diff --git a/front/src/util/api.ts b/front/src/util/api.ts index 48c4f5a..d3067e8 100644 --- a/front/src/util/api.ts +++ b/front/src/util/api.ts @@ -1,47 +1,6 @@ -import { getEncryptStorage, JwtKey } from '../util/encryptStorage'; +import { fetchWithAutoRefresh } from './auth'; import Config from 'react-native-config'; -// 헤더 생성 함수 - 토큰이 있으면 포함, 없으면 기본 헤더만 반환 -const createHeaders = async (): Promise => { - const headers = new Headers({ - 'Content-Type': 'application/json', - }); - - try { - const token = await getEncryptStorage(JwtKey); - if (token) { - headers.append('Authorization', token); - } - } catch (error) { - console.log('토큰이 없거나 가져오는 중 오류 발생'); - } - - return headers; -}; - -type FetchOptions = RequestInit & { retryOn401?: boolean }; - -export async function fetchWithAutoRefresh( - url: string, - options: FetchOptions = {}, - createHeadersFn = createHeaders -): Promise { - const { retryOn401, ...fetchOptions } = options; // retryOn401만 분리 - let headers = await createHeadersFn(); - let response = await fetch(url, { ...fetchOptions, headers }); - - if (response.status === 401 && retryOn401 !== false) { - const newToken = await getEncryptStorage(JwtKey); - if (newToken) { - headers = await createHeadersFn(); - response = await fetch(url, { ...fetchOptions, headers }); - } else { - throw new Error('토큰 재발급 실패'); - } - } - return response; -} - // GET 요청 export const fetchGet = async (endpoint: string): Promise => { const url = `${Config.BASE_URL}${endpoint}`; @@ -52,11 +11,8 @@ export const fetchGet = async (endpoint: string): Promise => { // POST 요청 export const fetchPost = async (endpoint: string, data?: any): Promise => { - const headers = await createHeaders(); - const options: RequestInit = { method: 'POST', - headers, credentials: 'include', }; @@ -64,12 +20,11 @@ export const fetchPost = async (endpoint: string, data?: any): Promise(endpoint: string, data?: any): Promise(endpoint: string, data?: any): Promise => { - const headers = await createHeaders(); - const options: RequestInit = { method: 'PATCH', - headers, credentials: 'include', }; @@ -95,7 +47,7 @@ export const fetchPatch = async (endpoint: string, data?: any): Promise(endpoint: string, data?: any): Promise(endpoint: string, data: any): Promise => { - const headers = await createHeaders(); - - - const response = await fetch(`${Config.BASE_URL}${endpoint}`, { + const response = await fetchWithAutoRefresh(`${Config.BASE_URL}${endpoint}`, { method: 'PUT', - headers, credentials: 'include', body: JSON.stringify(data), }); @@ -131,12 +79,8 @@ export const fetchPut = async (endpoint: string, data: any): Promise => { // DELETE 요청 export const fetchDelete = async (endpoint: string, data?: any): Promise => { - const headers = await createHeaders(); - - const options: RequestInit = { method: 'DELETE', - headers, credentials: 'include', }; @@ -144,7 +88,7 @@ export const fetchDelete = async (endpoint: string, data?: any): Promise = options.body = JSON.stringify(data); } - const response = await fetch(`${Config.BASE_URL}${endpoint}`, options); + const response = await fetchWithAutoRefresh(`${Config.BASE_URL}${endpoint}`, options); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); @@ -155,11 +99,8 @@ export const fetchDelete = async (endpoint: string, data?: any): Promise = // POST 요청 export const fetchGPTPost = async (endpoint: string, data?: any): Promise => { - const headers = await createHeaders(); - const options: RequestInit = { method: 'POST', - headers, credentials: 'include', }; @@ -167,7 +108,7 @@ export const fetchGPTPost = async (endpoint: string, data?: any): Promise void) => { + try { + console.log('앱 시작 - 토큰 확인 중...'); + const token = await getEncryptStorage(AccessKey); + + if (token) { + console.log('저장된 토큰 발견 - 자동 로그인'); + setLoggedIn(true); + } else { + console.log('토큰 없음 - 로그인 필요'); + setLoggedIn(false); + } + } catch (error) { + console.error('인증 초기화 실패:', error); + setLoggedIn(false); + } +}; + +// 헤더 생성 함수 - 사전 토큰 갱신 포함 +export const createHeaders = async (): Promise => { + const headers = new Headers({ + 'Content-Type': 'application/json', + }); + + try { + const token = await ensureValidToken(); // 🔄 사전 갱신 로직 + if (token) { + headers.append('Authorization', `${token}`); + console.log('Authorization', `${token}`); + } + } catch (error) { + console.log('토큰 처리 중 오류 발생:', error); + } + + return headers; +}; + +type FetchOptions = RequestInit & { retryOn401?: boolean }; + +// 리프레시 토큰으로 새 액세스 토큰 발급받는 함수 +export const refreshAccessToken = async (): Promise => { + try { + const username = await getEncryptStorage(UserNameKey); + if (!username) { + return null; + } + + const response = await fetch(`${Config.BASE_URL}/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + }), + }); + + if (response.ok) { + const { accessToken } = await response.json(); + await setEncryptStorage(AccessKey, accessToken); // 새 토큰 저장 + return accessToken; + } else { + // 토큰 갱신 실패 시 로컬 데이터 정리 + await removeEncryptStorage(AccessKey); + await removeEncryptStorage(UserNameKey); + await removeEncryptStorage(UserNicknameKey); + return null; + } + + } catch (error) { + console.error('토큰 리프레시 실패:', error); + return null; + } +}; + +// JWT 토큰 디코딩해서 만료 시간 확인 +const isTokenExpiringSoon = (token: string, bufferMinutes: number = 5): boolean => { + try { + // Bearer 접두사 제거 후 JWT 디코딩 + const cleanToken = token.replace(/^Bearer\s+/, ''); + const payload = JSON.parse(atob(cleanToken.split('.')[1])); + const currentTime = Math.floor(Date.now() / 1000); // 현재 시간 (초) + const expirationTime = payload.exp; // 토큰 만료 시간 (초) + + // 만료 시간 - 버퍼 시간(기본 5분) 전에 갱신 + return (expirationTime - currentTime) < (bufferMinutes * 60); + } catch (error) { + console.error('토큰 파싱 실패:', error); + return true; // 파싱 실패 시 갱신 필요로 간주 + } +}; + +// 토큰 사전 갱신 함수 +const ensureValidToken = async (): Promise => { + try { + const currentToken = await getEncryptStorage(AccessKey); + + if (!currentToken) { + return null; // 토큰이 없으면 로그인 필요 + } + + // 토큰이 곧 만료되는지 확인 (5분 전에 미리 갱신) + if (isTokenExpiringSoon(currentToken, 5)) { + console.log('토큰이 곧 만료됩니다. 사전 갱신을 시도합니다.'); + const newToken = await refreshAccessToken(); + return newToken || currentToken; // 갱신 실패 시 기존 토큰 사용 + } + + return currentToken; // 아직 유효한 토큰 + } catch (error) { + console.error('토큰 확인 실패:', error); + return null; + } +}; + +// fetchWithAutoRefresh 수정 +export async function fetchWithAutoRefresh( + url: string, + options: FetchOptions = {}, + createHeadersFn = createHeaders +): Promise { + const { retryOn401, ...fetchOptions } = options; + let headers = await createHeadersFn(); // 이미 사전 갱신 완료 + let response = await fetch(url, { ...fetchOptions, headers }); + + // 그래도 401이 발생하면 한 번 더 시도 (fallback) + if (response.status === 401 && retryOn401 !== false) { + console.log('사전 갱신에도 불구하고 401 발생, 추가 갱신 시도'); + const newAccessToken = await refreshAccessToken(); + if (newAccessToken) { + headers = await createHeadersFn(); + response = await fetch(url, { ...fetchOptions, headers }); + } else { + throw new Error('토큰 재발급 실패 - 로그인이 필요합니다'); + } + } + return response; +} diff --git a/front/src/util/encryptStorage.ts b/front/src/util/encryptStorage.ts index c4c4b7d..20b9bb6 100644 --- a/front/src/util/encryptStorage.ts +++ b/front/src/util/encryptStorage.ts @@ -1,7 +1,9 @@ import EncryptedStorage from 'react-native-encrypted-storage'; -const JwtKey = 'user_jwt'; -const UserKey = 'user'; +const AccessKey = 'access_token'; +const UserNameKey = 'user_name'; +const UserProviderKey = 'user_provider'; +const UserNicknameKey = 'user_nickname'; const CertificationKey = 'certification'; const BookMarkKey = 'bookmarks'; @@ -23,4 +25,4 @@ const removeEncryptStorage = async ( key: string) => { } }; -export {setEncryptStorage, getEncryptStorage, removeEncryptStorage, JwtKey, UserKey, CertificationKey, BookMarkKey}; +export {setEncryptStorage, getEncryptStorage, removeEncryptStorage, AccessKey, UserNameKey, UserProviderKey, UserNicknameKey, CertificationKey, BookMarkKey };