diff --git a/apps/service/src/apis/authMiddleware.ts b/apps/service/src/apis/authMiddleware.ts new file mode 100644 index 00000000..992f40d4 --- /dev/null +++ b/apps/service/src/apis/authMiddleware.ts @@ -0,0 +1,68 @@ +'use client'; +import { Middleware } from 'openapi-fetch'; + +import { getAccessToken, setAccessToken } from '@/contexts/AuthContext'; + +const UNPROTECTED_ROUTES = ['/api/v1/auth/admin/login']; + +const reissueToken = async () => { + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/reissue`, { + method: 'GET', + credentials: 'include', + }); + + if (!response.ok) throw new Error('Token reissue failed'); + + const data = await response.json(); + const accessToken = data.data.accessToken; + setAccessToken(accessToken); + return accessToken; + } catch (error) { + console.error('Reissue failed:', error); + setAccessToken(null); + window.location.href = '/login'; + return null; + } +}; + +const authMiddleware: Middleware = { + async onRequest({ schemaPath, request }: { schemaPath: string; request: Request }) { + if (UNPROTECTED_ROUTES.some((pathname) => schemaPath.startsWith(pathname))) { + return undefined; + } + + let accessToken = getAccessToken(); + + if (!accessToken) { + accessToken = await reissueToken(); + + if (!accessToken) { + console.error('Access token reissue failed. Logging out...'); + return request; + } + } + + request.headers.set('Authorization', `Bearer ${accessToken}`); + return request; + }, + + async onResponse({ request, response }) { + if (response.status === 401) { + console.warn('Access token expired. Attempting reissue...'); + + const newAccessToken = await reissueToken(); + + if (!newAccessToken) { + console.error('Reissue failed. Logging out...'); + return response; + } + + request.headers.set('Authorization', `Bearer ${newAccessToken}`); + return fetch(request); + } + return response; + }, +}; + +export default authMiddleware; diff --git a/apps/service/src/apis/client.ts b/apps/service/src/apis/client.ts index 0f4bbb6f..d1f33cd4 100644 --- a/apps/service/src/apis/client.ts +++ b/apps/service/src/apis/client.ts @@ -4,9 +4,6 @@ import createClient from 'openapi-react-query'; export const client = createFetchClient({ baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL, - headers: { - Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`, - }, }); export const TanstackQueryClient = createClient(client); diff --git a/apps/service/src/apis/controller/auth/index.ts b/apps/service/src/apis/controller/auth/index.ts new file mode 100644 index 00000000..38107e44 --- /dev/null +++ b/apps/service/src/apis/controller/auth/index.ts @@ -0,0 +1,3 @@ +import postLogin from './postLogin'; + +export { postLogin }; diff --git a/apps/service/src/apis/controller/auth/postLogin.ts b/apps/service/src/apis/controller/auth/postLogin.ts new file mode 100644 index 00000000..fd73e233 --- /dev/null +++ b/apps/service/src/apis/controller/auth/postLogin.ts @@ -0,0 +1,13 @@ +import { client } from '@apis'; + +const postLogin = async (email: string, password: string) => { + return await client.POST('/api/v1/auth/admin/login', { + body: { + email, + password, + }, + credentials: 'include', + }); +}; + +export default postLogin; diff --git a/apps/service/src/apis/controller/commentary/getCommentary.ts b/apps/service/src/apis/controller/commentary/getCommentary.ts index 1d42e75e..4268b4d0 100644 --- a/apps/service/src/apis/controller/commentary/getCommentary.ts +++ b/apps/service/src/apis/controller/commentary/getCommentary.ts @@ -6,14 +6,22 @@ type GetCommentaryProps = { }; const getCommentary = ({ publishId, problemId }: GetCommentaryProps) => { - return TanstackQueryClient.useQuery('get', '/api/v1/client/commentary', { - params: { - query: { - publishId: Number(publishId), - problemId: Number(problemId), + return TanstackQueryClient.useQuery( + 'get', + '/api/v1/client/commentary', + { + params: { + query: { + publishId: Number(publishId), + problemId: Number(problemId), + }, }, }, - }); + { + staleTime: Infinity, + gcTime: Infinity, + } + ); }; export default getCommentary; diff --git a/apps/service/src/apis/controller/home/getHomeFeed.ts b/apps/service/src/apis/controller/home/getHomeFeed.ts index 01588e91..a5323c6d 100644 --- a/apps/service/src/apis/controller/home/getHomeFeed.ts +++ b/apps/service/src/apis/controller/home/getHomeFeed.ts @@ -1,8 +1,15 @@ -import { client } from '@/apis/client'; +import { TanstackQueryClient } from '@apis'; -const getHomeFeed = async () => { - const { data } = await client.GET('/api/v1/client/home-feed'); - return data?.data; +const getHomeFeed = () => { + return TanstackQueryClient.useQuery( + 'get', + '/api/v1/client/home-feed', + {}, + { + staleTime: Infinity, + gcTime: Infinity, + } + ); }; export default getHomeFeed; diff --git a/apps/service/src/apis/controller/problem/getChildData.ts b/apps/service/src/apis/controller/problem/getChildData.ts index d4e4f6e2..b8751239 100644 --- a/apps/service/src/apis/controller/problem/getChildData.ts +++ b/apps/service/src/apis/controller/problem/getChildData.ts @@ -11,6 +11,10 @@ const getChildData = (publishId: string, problemId: string) => { problemId: Number(problemId), }, }, + }, + { + staleTime: Infinity, + gcTime: Infinity, } ); }; diff --git a/apps/service/src/apis/controller/problem/getChildProblemById.ts b/apps/service/src/apis/controller/problem/getChildProblemById.ts index 76924968..5b15bbfe 100644 --- a/apps/service/src/apis/controller/problem/getChildProblemById.ts +++ b/apps/service/src/apis/controller/problem/getChildProblemById.ts @@ -12,6 +12,10 @@ const getChildProblemById = (publishId: string, problemId: string, childProblemI childProblemId: Number(childProblemId), }, }, + }, + { + staleTime: Infinity, + gcTime: Infinity, } ); }; diff --git a/apps/service/src/apis/controller/problem/getProblemAll.ts b/apps/service/src/apis/controller/problem/getProblemAll.ts index bd49e118..0c7ca1a0 100644 --- a/apps/service/src/apis/controller/problem/getProblemAll.ts +++ b/apps/service/src/apis/controller/problem/getProblemAll.ts @@ -6,14 +6,22 @@ type GetProblemAllProps = { }; const getProblemAll = ({ year, month }: GetProblemAllProps) => { - return TanstackQueryClient.useQuery('get', '/api/v1/client/problem/all/{year}/{month}', { - params: { - path: { - year, - month, + return TanstackQueryClient.useQuery( + 'get', + '/api/v1/client/problem/all/{year}/{month}', + { + params: { + path: { + year, + month, + }, }, }, - }); + { + staleTime: Infinity, + gcTime: Infinity, + } + ); }; export default getProblemAll; diff --git a/apps/service/src/apis/controller/problem/getProblemById.ts b/apps/service/src/apis/controller/problem/getProblemById.ts index ac9012cc..b73d70d3 100644 --- a/apps/service/src/apis/controller/problem/getProblemById.ts +++ b/apps/service/src/apis/controller/problem/getProblemById.ts @@ -1,14 +1,22 @@ import { TanstackQueryClient } from '@apis'; const getProblemById = (publishId: string, problemId: string) => { - return TanstackQueryClient.useQuery('get', '/api/v1/client/problem/{publishId}/{problemId}', { - params: { - path: { - publishId: Number(publishId), - problemId: Number(problemId), + return TanstackQueryClient.useQuery( + 'get', + '/api/v1/client/problem/{publishId}/{problemId}', + { + params: { + path: { + publishId: Number(publishId), + problemId: Number(problemId), + }, }, }, - }); + { + staleTime: Infinity, + gcTime: Infinity, + } + ); }; export default getProblemById; diff --git a/apps/service/src/apis/controller/problem/getProblemThumbnail.ts b/apps/service/src/apis/controller/problem/getProblemThumbnail.ts index 8a9268fa..cc9fd7bd 100644 --- a/apps/service/src/apis/controller/problem/getProblemThumbnail.ts +++ b/apps/service/src/apis/controller/problem/getProblemThumbnail.ts @@ -1,15 +1,22 @@ -import { client } from '@apis'; +import { TanstackQueryClient } from '@apis'; -const getProblemThumbnail = async (publishId: string, problemId: string) => { - const { data } = await client.GET('/api/v1/client/problem/thumbnail/{publishId}/{problemId}', { - params: { - path: { - publishId: Number(publishId), - problemId: Number(problemId), +const getProblemThumbnail = (publishId: string, problemId: string) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/v1/client/problem/thumbnail/{publishId}/{problemId}', + { + params: { + path: { + publishId: Number(publishId), + problemId: Number(problemId), + }, }, }, - }); - return data?.data ?? {}; + { + staleTime: Infinity, + gcTime: Infinity, + } + ); }; export default getProblemThumbnail; diff --git a/apps/service/src/apis/controller/problem/getProblemsByPublishId.ts b/apps/service/src/apis/controller/problem/getProblemsByPublishId.ts index 213ca8ac..7d919fa4 100644 --- a/apps/service/src/apis/controller/problem/getProblemsByPublishId.ts +++ b/apps/service/src/apis/controller/problem/getProblemsByPublishId.ts @@ -1,14 +1,21 @@ -import { client } from '@apis'; +import { TanstackQueryClient } from '@apis'; -const getProblemsByPublishId = async (publishId: string) => { - const { data } = await client.GET('/api/v1/client/problem/{publishId}', { - params: { - path: { - publishId: Number(publishId), +const getProblemsByPublishId = (publishId: string) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/v1/client/problem/{publishId}', + { + params: { + path: { + publishId: Number(publishId), + }, }, }, - }); - return data?.data ?? {}; + { + staleTime: Infinity, + gcTime: Infinity, + } + ); }; export default getProblemsByPublishId; diff --git a/apps/service/src/apis/index.ts b/apps/service/src/apis/index.ts index 7d140860..275d3b9f 100644 --- a/apps/service/src/apis/index.ts +++ b/apps/service/src/apis/index.ts @@ -3,6 +3,7 @@ import { client, TanstackQueryClient } from './client'; export { client, TanstackQueryClient }; // controllers +export * from './controller/auth'; export * from './controller/home'; export * from './controller/problem'; export * from './controller/commentary'; diff --git a/apps/service/src/app/(home)/page.tsx b/apps/service/src/app/(home)/page.tsx index 9524860c..0d7d7345 100644 --- a/apps/service/src/app/(home)/page.tsx +++ b/apps/service/src/app/(home)/page.tsx @@ -1,3 +1,4 @@ +'use client'; import Link from 'next/link'; import { Button } from '@components'; import { IcSearch } from '@svg'; @@ -13,8 +14,9 @@ import { WeekProgress, } from '@/components/home'; -const Page = async () => { - const homeFeedData = await getHomeFeed(); +const Page = () => { + const { data } = getHomeFeed(); + const homeFeedData = data?.data; const dailyProgresses = homeFeedData?.dailyProgresses; const problemSets = homeFeedData?.problemSets; diff --git a/apps/service/src/app/login/page.tsx b/apps/service/src/app/login/page.tsx index 2a84987e..0ac3336f 100644 --- a/apps/service/src/app/login/page.tsx +++ b/apps/service/src/app/login/page.tsx @@ -1,8 +1,35 @@ +'use client'; +import { postLogin } from '@apis'; +import { Button, Input } from '@components'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { useRouter } from 'next/navigation'; + import { LogoLogin } from '@/assets/svg/logo'; -import { AppleButton } from '@/components/login'; -import { KakaoButton } from '@/components/login'; +import { setAccessToken } from '@/contexts/AuthContext'; + +interface LoginType { + email: string; + password: string; +} const Page = () => { + const router = useRouter(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const onSubmitLogin: SubmitHandler = async (formData) => { + const { data } = await postLogin(formData.email, formData.password); + + const { accessToken } = data?.data || {}; + if (accessToken) { + setAccessToken(accessToken); + router.push('/'); + } + }; + return (
@@ -14,11 +41,41 @@ const Page = () => {

포인터

-
+ {/*

포인터는 태블릿의 스플릿뷰를 권장해요

-
+
*/} +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} + +
); }; diff --git a/apps/service/src/app/problem/list/[publishId]/page.tsx b/apps/service/src/app/problem/list/[publishId]/page.tsx index dfbb55b9..186af3af 100644 --- a/apps/service/src/app/problem/list/[publishId]/page.tsx +++ b/apps/service/src/app/problem/list/[publishId]/page.tsx @@ -1,12 +1,16 @@ +'use client'; import { Header } from '@components'; import { getProblemsByPublishId } from '@apis'; import dayjs from 'dayjs'; +import { useParams } from 'next/navigation'; import { ProblemStatusCard } from '@/components/problem'; -const Page = async ({ params }: { params: Promise<{ publishId: string }> }) => { - const { publishId } = await params; - const { date, problems, title } = await getProblemsByPublishId(publishId); +const Page = () => { + const { publishId } = useParams<{ publishId: string }>(); + + const { data } = getProblemsByPublishId(publishId); + const { date, problems, title } = data?.data ?? {}; const publishDate = dayjs(date).format('MM월 DD일'); return ( diff --git a/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx b/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx index dedd8bbd..424536e9 100644 --- a/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx +++ b/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx @@ -73,28 +73,10 @@ const Page = () => { const isSolved = status === 'CORRECT' || status === 'RETRY_CORRECT'; const isSubmitted = status === 'CORRECT' || status === 'RETRY_CORRECT' || status === 'INCORRECT'; - const handleInvalidateQuery = () => { - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions( - 'get', - '/api/v1/client/problem/{publishId}/{problemId}/{childProblemId}', - { - params: { - path: { - publishId: Number(publishId), - problemId: Number(problemId), - childProblemId: Number(childProblemId), - }, - }, - } - ).queryKey, - }); - }; - const handleSubmitAnswer: SubmitHandler<{ answer: string }> = async ({ answer }) => { const { data } = await putChildProblemSubmit(publishId, childProblemId, answer); const resultData = data?.data; - handleInvalidateQuery(); + queryClient.invalidateQueries(); setResult(resultData); if (resultData) { @@ -109,7 +91,7 @@ const Page = () => { const handleSkip = async () => { await putChildProblemSkip(publishId, childProblemId); - handleInvalidateQuery(); + queryClient.invalidateQueries(); onNext(); }; diff --git a/apps/service/src/app/problem/solve/[publishId]/[problemId]/main-problem/page.tsx b/apps/service/src/app/problem/solve/[publishId]/[problemId]/main-problem/page.tsx index 4fb58d99..4ea14d11 100644 --- a/apps/service/src/app/problem/solve/[publishId]/[problemId]/main-problem/page.tsx +++ b/apps/service/src/app/problem/solve/[publishId]/[problemId]/main-problem/page.tsx @@ -70,27 +70,11 @@ const Page = () => { : `새끼 문제 ${number}-${childProblemLength}번`; const nextButtonLabel = '해설 보기'; - const handleInvalidateQuery = () => { - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions( - 'get', - '/api/v1/client/problem/{publishId}/{problemId}', - { - params: { - path: { - publishId: Number(publishId), - problemId: Number(problemId), - }, - }, - } - ).queryKey, - }); - }; - const handleSubmitAnswer: SubmitHandler<{ answer: string }> = async ({ answer }) => { const { data } = await putProblemSubmit(publishId, problemId, answer); const resultData = data?.data; - handleInvalidateQuery(); + queryClient.invalidateQueries(); + setResult(resultData); if (resultData) { openModal(); diff --git a/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx b/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx index 95a497c1..62ef1485 100644 --- a/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx +++ b/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx @@ -1,15 +1,15 @@ +'use client'; import { getProblemThumbnail } from '@apis'; import { ProgressHeader, TimeTag } from '@components'; +import { useParams } from 'next/navigation'; import SolveButtonsClient from './SolveButtonsClient'; -const Page = async ({ params }: { params: Promise<{ publishId: string; problemId: string }> }) => { - const { publishId, problemId } = await params; +const Page = () => { + const { publishId, problemId } = useParams<{ publishId: string; problemId: string }>(); - const { number, imageUrl, recommendedMinute, recommendedSecond } = await getProblemThumbnail( - publishId, - problemId - ); + const { data } = getProblemThumbnail(publishId, problemId); + const { number, imageUrl, recommendedMinute, recommendedSecond } = data?.data ?? {}; return ( <> diff --git a/apps/service/src/app/providers.tsx b/apps/service/src/app/providers.tsx index 65b702e1..7a6ebc6d 100644 --- a/apps/service/src/app/providers.tsx +++ b/apps/service/src/app/providers.tsx @@ -1,17 +1,24 @@ 'use client'; +import { client } from '@apis'; +import { AuthProvider } from '@contexts'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import type * as React from 'react'; +import authMiddleware from '@/apis/authMiddleware'; + export default function Providers({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient(); + client.use(authMiddleware); return ( - - {children} -
- -
-
+ + + {children} +
+ +
+
+
); } diff --git a/apps/service/src/components/home/ProblemSwiper/ProblemSwiper.tsx b/apps/service/src/components/home/ProblemSwiper/ProblemSwiper.tsx index 7ead50d1..5374fe23 100644 --- a/apps/service/src/components/home/ProblemSwiper/ProblemSwiper.tsx +++ b/apps/service/src/components/home/ProblemSwiper/ProblemSwiper.tsx @@ -45,10 +45,15 @@ const ProblemSwiper = ({ problemSets }: ProblemSwiperProps) => { const initialSlide = problemSets.findIndex( (problem) => problem.date === dayjs().format('YYYY-MM-DD') ); + + if (initialSlide === -1) { + return null; + } + return ( { switch (status) { case 'CORRECT': case 'RETRY_CORRECT': - return '완료'; case 'INCORRECT': + return '완료'; + case 'IN_PROGRESS': return '진행중'; + case 'NOT_STARTED': default: return '미완료'; } @@ -22,9 +24,11 @@ const answerStatusColor = (status: string) => { switch (status) { case 'CORRECT': case 'RETRY_CORRECT': - return 'green'; case 'INCORRECT': + return 'green'; + case 'IN_PROGRESS': return 'red'; + case 'NOT_STARTED': default: return 'gray'; } diff --git a/apps/service/src/contexts/AuthContext.tsx b/apps/service/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..f36450b6 --- /dev/null +++ b/apps/service/src/contexts/AuthContext.tsx @@ -0,0 +1,36 @@ +'use client'; +import { createContext, useState, ReactNode } from 'react'; + +export interface AuthContextType { + accessToken: string | null; + setAccessToken: (token: string | null) => void; + name: string; + setName: (name: string) => void; +} + +const tokenStore = { + accessToken: null as string | null, + setAccessToken: (_: string | null) => {}, +}; + +export const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [accessToken, setAccessTokenState] = useState(null); + const [name, setNameState] = useState(''); + + tokenStore.accessToken = accessToken; + tokenStore.setAccessToken = setAccessTokenState; + + const contextValue = { + accessToken, + setAccessToken: setAccessTokenState, + name, + setName: setNameState, + }; + + return {children}; +}; + +export const getAccessToken = () => tokenStore.accessToken; +export const setAccessToken = (token: string | null) => tokenStore.setAccessToken(token); diff --git a/apps/service/src/contexts/index.ts b/apps/service/src/contexts/index.ts index 85107256..68a1cda5 100644 --- a/apps/service/src/contexts/index.ts +++ b/apps/service/src/contexts/index.ts @@ -1,2 +1,3 @@ export * from './ReportContext'; export * from './ProblemContext'; +export { AuthContext, AuthProvider } from './AuthContext'; diff --git a/apps/service/src/hooks/auth/index.ts b/apps/service/src/hooks/auth/index.ts new file mode 100644 index 00000000..34ee6a6b --- /dev/null +++ b/apps/service/src/hooks/auth/index.ts @@ -0,0 +1,3 @@ +import useAuth from './useAuth'; + +export { useAuth }; diff --git a/apps/service/src/hooks/auth/useAuth.ts b/apps/service/src/hooks/auth/useAuth.ts new file mode 100644 index 00000000..41ddfb69 --- /dev/null +++ b/apps/service/src/hooks/auth/useAuth.ts @@ -0,0 +1,13 @@ +'use client'; +import { useContext } from 'react'; +import { AuthContext } from '@contexts'; + +const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export default useAuth;