diff --git a/app/layout.tsx b/app/layout.tsx index acc9ecc..75236df 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,9 +2,10 @@ import type { Metadata } from 'next'; import './globals.css'; import classNames from 'classnames'; import { pretendard } from '@/styles/fonts'; +import TanstackQueryProvider from '@/providers/TanstackQueryProvider'; export const metadata: Metadata = { - title: '역사의 고서', + title: '부마위키 | 역사의 고서', description: '우리의 손으로 써내려 나가는 역사의 고서, 부마위키', }; @@ -15,7 +16,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/app/oauth/_entities/api/axios.ts b/app/oauth/_entities/api/axios.ts new file mode 100644 index 0000000..4eae745 --- /dev/null +++ b/app/oauth/_entities/api/axios.ts @@ -0,0 +1,11 @@ +import { http, refreshTokenHeader } from '@/modules/services'; + +export const requestLogin = async (authCode: string) => { + const { data } = await http.post('/auth/oauth/bsm', {}, { headers: { authCode } }); + return data; +}; + +export const requestLogout = async () => { + const { data } = await http.delete('/auth/bsm/logout', refreshTokenHeader()); + return data; +}; diff --git a/app/oauth/_entities/api/mutation.ts b/app/oauth/_entities/api/mutation.ts new file mode 100644 index 0000000..6de5ba8 --- /dev/null +++ b/app/oauth/_entities/api/mutation.ts @@ -0,0 +1,26 @@ +import { useMutation } from '@tanstack/react-query'; +import { requestLogin, requestLogout } from './axios'; +import { Storage } from '@/modules/services'; +import { Bumawiki } from '@/modules/constants'; + +export const useLoginMutation = () => { + return useMutation({ + mutationFn: requestLogin, + onSuccess: ({ accessToken, refreshToken }) => { + Storage.setItem(Bumawiki.token.access, accessToken); + Storage.setItem(Bumawiki.token.refresh, refreshToken); + window.history.go(-2); + }, + }); +}; + +export const useLogoutMutation = () => { + return useMutation({ + mutationFn: requestLogout, + onSuccess: window.location.reload, + onSettled: () => { + Storage.delItem(Bumawiki.token.access); + Storage.delItem(Bumawiki.token.refresh); + }, + }); +}; diff --git a/app/oauth/_entities/model/index.ts b/app/oauth/_entities/model/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/app/oauth/_entities/model/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/app/oauth/_features/index.ts b/app/oauth/_features/index.ts new file mode 100644 index 0000000..08101bf --- /dev/null +++ b/app/oauth/_features/index.ts @@ -0,0 +1 @@ +export { AuthenticationPage } from './ui/AuthenticationPage'; diff --git a/app/oauth/_features/lib/withAuthentication.tsx b/app/oauth/_features/lib/withAuthentication.tsx new file mode 100644 index 0000000..4b4da32 --- /dev/null +++ b/app/oauth/_features/lib/withAuthentication.tsx @@ -0,0 +1,24 @@ +import { useLoginMutation } from '../../_entities/api/mutation'; +import { useMount } from '@/hooks/useMount'; +import { useSearchParams } from 'next/navigation'; +import React, { useEffect } from 'react'; + +export const withAuthentication =

(Component: React.ComponentType

) => { + const WrappedComponent: React.FC

= (props) => { + const { mutate: login } = useLoginMutation(); + const isMounted = useMount(); + const authCode = useSearchParams().get('code') || ''; + + useEffect(() => { + if (isMounted) login(authCode); + }, [isMounted, authCode, login]); + + return ; + }; + + WrappedComponent.displayName = `withAuthentication(${ + Component.displayName || Component.name || 'Component' + })`; + + return WrappedComponent; +}; diff --git a/app/oauth/_features/ui/AuthenticationPage.tsx b/app/oauth/_features/ui/AuthenticationPage.tsx new file mode 100644 index 0000000..1ef8688 --- /dev/null +++ b/app/oauth/_features/ui/AuthenticationPage.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { MoonLoader } from 'react-spinners'; +import { withAuthentication } from '../lib/withAuthentication'; + +export const AuthenticationPage = withAuthentication(() => { + return ( +

+ + 로그인 중... +
+ ); +}); diff --git a/app/oauth/page.tsx b/app/oauth/page.tsx new file mode 100644 index 0000000..22eab9d --- /dev/null +++ b/app/oauth/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import { AuthenticationPage } from './_features'; +import { Metadata, NextPage } from 'next'; + +export const metadata: Metadata = { + title: '부마위키 | 로그인', + description: '로그인 후 부마위키 문서에 직접 기여해보세요.', +}; + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; diff --git a/next.config.ts b/next.config.ts index 61ffa7b..f1d2ec5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,7 +12,7 @@ const nextConfig: NextConfig = { return [ { source: '/insert-proxy/:path*', - destination: 'https://buma.wiki/api/path*', + destination: 'https://buma.wiki/api/:path*', basePath: false, }, ]; diff --git a/package.json b/package.json index 7550284..47beac4 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,13 @@ "lint": "next lint" }, "dependencies": { + "@tanstack/react-query": "^5.62.12", + "axios": "^1.7.9", "classnames": "^2.5.1", "next": "15.1.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-spinners": "^0.15.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3ffac4..8129795 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.62.12 + version: 5.62.12(react@19.0.0) + axios: + specifier: ^1.7.9 + version: 1.7.9 classnames: specifier: ^2.5.1 version: 2.5.1 @@ -20,6 +26,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + react-spinners: + specifier: ^0.15.0 + version: 0.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -325,6 +334,14 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/query-core@5.62.12': + resolution: {integrity: sha512-6igFeBgymHkCxVgaEk+yiLwkMf9haui/EQLmI3o9CatOyDThEoFKe8toLWvWliZC/Jf+h7NwHi/zjfyLArr1ow==} + + '@tanstack/react-query@5.62.12': + resolution: {integrity: sha512-yt8p7l5MlHA3QCt6xF1Cu9dw1Anf93yTK+DMDJQ64h/mshAymVAtcwj8TpsyyBrZNWAAZvza/m76bnTSR79ZtQ==} + peerDependencies: + react: ^18 || ^19 + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -473,6 +490,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -481,6 +501,9 @@ packages: resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} engines: {node: '>=4'} + axios@1.7.9: + resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -557,6 +580,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -619,6 +646,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -840,6 +871,15 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -847,6 +887,10 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1148,6 +1192,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1346,6 +1398,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1361,6 +1416,12 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-spinners@0.15.0: + resolution: {integrity: sha512-ZO3/fNB9Qc+kgpG3SfdlMnvTX6LtLmTnOogb3W6sXIaU/kZ1ydEViPfZ06kSOaEsor58C/tzXw2wROGQu3X2pA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -1894,6 +1955,13 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/query-core@5.62.12': {} + + '@tanstack/react-query@5.62.12(react@19.0.0)': + dependencies: + '@tanstack/query-core': 5.62.12 + react: 19.0.0 + '@types/estree@1.0.6': {} '@types/json-schema@7.0.15': {} @@ -2091,12 +2159,22 @@ snapshots: ast-types-flow@0.0.8: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 axe-core@4.10.2: {} + axios@1.7.9: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -2182,6 +2260,10 @@ snapshots: color-string: 1.9.1 optional: true + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -2238,6 +2320,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + detect-libc@2.0.3: optional: true @@ -2609,6 +2693,8 @@ snapshots: flatted@3.3.2: {} + follow-redirects@1.15.9: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -2618,6 +2704,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true @@ -2930,6 +3022,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -3125,6 +3223,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -3136,6 +3236,11 @@ snapshots: react-is@16.13.1: {} + react-spinners@0.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react@19.0.0: {} read-cache@1.0.0: diff --git a/src/hooks/useMount.ts b/src/hooks/useMount.ts new file mode 100644 index 0000000..8df0d0b --- /dev/null +++ b/src/hooks/useMount.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react'; + +export const useMount = () => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + return isMounted; +}; diff --git a/src/modules/constants.ts b/src/modules/constants.ts new file mode 100644 index 0000000..b642561 --- /dev/null +++ b/src/modules/constants.ts @@ -0,0 +1,19 @@ +export const Bumawiki = { + error: { + img_400_1: 'IMG-400-1', + docs_404_1: 'DOCS-404-1', + docs_404_2: 'DOCS-404-2', + docs_403_1: 'DOCS-403-1', + docs_403_2: 'DOCS-403-2', + common_403_1: 'COMMON-403-1', + user_403_1: 'USER-403-1', + user_404_1: 'USER-404-1', + token_403_1: 'TOKEN-403-1', + token_403_2: 'TOKEN-403-2', + token_403_3: 'TOKEN-403-3', + } as const, + token: { + access: 'access_token', + refresh: 'refresh_token', + } as const, +}; diff --git a/src/modules/services.ts b/src/modules/services.ts new file mode 100644 index 0000000..adb1fc3 --- /dev/null +++ b/src/modules/services.ts @@ -0,0 +1,67 @@ +import axios from 'axios'; +import { Bumawiki } from './constants'; + +export type LocalStorageKey = 'access_token' | 'refresh_token'; + +export class Storage { + private static isWindowAvailable() { + return typeof window !== 'undefined'; + } + + static getItem(key: LocalStorageKey) { + if (this.isWindowAvailable()) return localStorage.getItem(key); + } + + static setItem(key: LocalStorageKey, value: string) { + if (!this.isWindowAvailable()) return; + localStorage.setItem(key, value); + } + + static delItem(key: LocalStorageKey) { + if (!this.isWindowAvailable) return; + localStorage.removeItem(key); + } + + static clear() { + if (this.isWindowAvailable()) localStorage.clear(); + } +} + +export const authorizationHeader = () => ({ + headers: { + Authorization: Storage.getItem(Bumawiki.token.access), + }, +}); + +export const refreshTokenHeader = () => ({ + headers: { + RefreshToken: Storage.getItem(Bumawiki.token.refresh), + }, +}); + +export const http = axios.create({ + baseURL: '/insert-proxy', + timeout: 10000, +}); + +http.interceptors.response.use( + (response) => response, + async (error) => { + const request = error.config; + const { code } = error.response.data; + const isAccessTokenExpiredError = code === Bumawiki.error.token_403_2; + + if (isAccessTokenExpiredError && !request.sent) { + request.sent = true; + request.headers.Authorization = await refresh(); + return http(request); + } + return Promise.reject(error); + }, +); + +const refresh = async () => { + const { data } = await http.put('/auth/refresh/access', {}, refreshTokenHeader()); + Storage.setItem(Bumawiki.token.access, data.accessToken); + return data.accessToken; +}; diff --git a/src/providers/TanstackQueryProvider.tsx b/src/providers/TanstackQueryProvider.tsx new file mode 100644 index 0000000..75c1bb4 --- /dev/null +++ b/src/providers/TanstackQueryProvider.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const generateQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchIntervalInBackground: false, + }, + }, + }); + +let browserQueryClient: QueryClient | undefined = undefined; + +const getQueryClient = () => { + if (typeof window === 'undefined') return generateQueryClient(); + if (!browserQueryClient) browserQueryClient = generateQueryClient(); + return browserQueryClient; +}; + +const TanstackQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const queryClient = getQueryClient(); + + return {children}; +}; +export default TanstackQueryProvider;