diff --git a/.gitignore b/.gitignore index 612a23e3..fa7d4ed8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ tsconfig.tsbuildinfo +.env + .yarn/* !.yarn/releases !.yarn/plugins @@ -12,5 +14,4 @@ tsconfig.tsbuildinfo node_modules .DS_Store - .idea \ No newline at end of file diff --git a/README.md b/README.md index dfbd64ae..c5813664 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - [auth](/apps/auth/README.md): 동아리 계정을 이용한 로그인(OAuth) 시스템이에요. - [land](/apps/land/README.md): 외부인이 동아리에 대해 알아볼 수 있는 랜딩 페이지이에요. -- [member](/apps/member/README.md): 동아리원들을 위한 그룹웨어 시스템이에요. +- [play](/apps/member/README.md): 동아리 활동을 위한 그룹웨어 시스템이에요. ## Packages diff --git a/apps/auth/app/api/auth.ts b/apps/auth/app/api/auth.ts index ae6cc183..e81d10b6 100644 --- a/apps/auth/app/api/auth.ts +++ b/apps/auth/app/api/auth.ts @@ -1,3 +1,4 @@ +import { ServerResponse } from '@type/server'; import { END_POINTS } from '../constants/api'; import { server } from './server'; @@ -7,6 +8,17 @@ interface PostLoginBody { password: string; } +interface PostLoginResponse extends ServerResponse { + data: string | null; +} + +interface PostTwoFactorLoginResponse extends ServerResponse { + data: { + accessToken: string; + refreshToken: string; + }; +} + interface PostTwoFactorLoginBody { [key: string]: string; memberId: string; @@ -14,7 +26,7 @@ interface PostTwoFactorLoginBody { } export const postLogin = async (body: PostLoginBody) => { - const response = await server.post({ + const response = await server.post({ url: END_POINTS.LOGIN, body, }); @@ -26,7 +38,7 @@ export const postLogin = async (body: PostLoginBody) => { }; export const postTwoFactorLogin = async (body: PostTwoFactorLoginBody) => { - return await server.post({ + return await server.post({ url: END_POINTS.TWO_FACTOR_LOGIN, body, }); diff --git a/apps/auth/app/constants/api.ts b/apps/auth/app/constants/api.ts index 837ca0d8..d45f7695 100644 --- a/apps/auth/app/constants/api.ts +++ b/apps/auth/app/constants/api.ts @@ -16,8 +16,8 @@ export const ERROR_MESSAGE = { export const REDIRECT = (code: string) => { return ( { - 'clab.page': 'https://clab.page/login', - dev: 'http://localhost:6002/login', + 'clab.page': 'https://member.clab.page/auth', + dev: 'http://localhost:6002/auth', }[code] || 'https://clab.page' ); }; diff --git a/apps/auth/app/hooks/queries/useTwoFactorLoginMutation.ts b/apps/auth/app/hooks/queries/useTwoFactorLoginMutation.ts index cb9ff4e0..8ff45df7 100644 --- a/apps/auth/app/hooks/queries/useTwoFactorLoginMutation.ts +++ b/apps/auth/app/hooks/queries/useTwoFactorLoginMutation.ts @@ -1,14 +1,12 @@ import { useMutation } from '@tanstack/react-query'; import { AUTH_ATOM_DEFAULT, useSetAuthStore } from '@store/auth'; import { postTwoFactorLogin } from '@api/auth'; -import { useSearchParams } from 'next/navigation'; import { ERROR_MESSAGE, REDIRECT, SUCCESS_MESSAGE } from '@constants/api'; +import { useCode } from '@hooks/useCode'; export const useTwoFactorLoginMutation = () => { const setAuth = useSetAuthStore(); - const searchParams = useSearchParams(); - - const code = searchParams.get('code'); + const { code } = useCode(); const twoFactorLoginMutation = useMutation({ mutationFn: postTwoFactorLogin, diff --git a/apps/auth/app/types/server.ts b/apps/auth/app/types/server.ts new file mode 100644 index 00000000..e13c1496 --- /dev/null +++ b/apps/auth/app/types/server.ts @@ -0,0 +1,4 @@ +export interface ServerResponse { + success: boolean; + data: T; +} diff --git a/apps/auth/next.config.mjs b/apps/auth/next.config.mjs index 4678774e..297c9d7c 100644 --- a/apps/auth/next.config.mjs +++ b/apps/auth/next.config.mjs @@ -1,4 +1,8 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + experimental: { + missingSuspenseWithCSRBailout: false, + }, +}; export default nextConfig; diff --git a/apps/auth/package.json b/apps/auth/package.json index a6b15c40..dd287678 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -9,7 +9,7 @@ "lint": "next lint" }, "dependencies": { - "@gwansikk/server-chain": "^0.2.0", + "@gwansikk/server-chain": "^0.4.4", "@tanstack/react-query": "^5.17.19", "classnames": "^2.5.1", "next": "14.1.0", diff --git a/apps/member/.env.example b/apps/member/.env.example new file mode 100644 index 00000000..dae8ee51 --- /dev/null +++ b/apps/member/.env.example @@ -0,0 +1,2 @@ +VITE_MODE=환경모드 +VITE_API_BASE_URL=API주소 \ No newline at end of file diff --git a/apps/member/package.json b/apps/member/package.json index 2f50ef95..dd05cf8d 100644 --- a/apps/member/package.json +++ b/apps/member/package.json @@ -4,19 +4,20 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --port 6002", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { + "@gwansikk/server-chain": "^0.4.4", + "@tanstack/react-query": "^5.18.1", "classnames": "^2.5.1", "dayjs": "^1.11.10", "framer-motion": "^10.18.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", - "react-query": "^3.39.3", "react-router-dom": "^6.21.2", "recoil": "^0.7.7" }, diff --git a/apps/member/src/App.tsx b/apps/member/src/App.tsx index dedc4d61..c9ecc312 100644 --- a/apps/member/src/App.tsx +++ b/apps/member/src/App.tsx @@ -1,6 +1,29 @@ +import { useEffect, useState } from 'react'; +import { useToken } from '@hooks/common/useToken'; import AppRouter from '@router/AppRouter'; +import { useSetIsLoggedInStore } from '@store/auth'; +import { server } from '@api/server'; +import { authorization } from '@utils/api'; const App = () => { + const setIsLoggedIn = useSetIsLoggedInStore(); + const [accessToken, refreshToken] = useToken(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (accessToken && refreshToken) { + setIsLoggedIn(true); + server.setHeaders(authorization(accessToken)); + } else { + setIsLoggedIn(false); + } + setIsLoading(false); + }, [accessToken, refreshToken, setIsLoggedIn]); + + if (isLoading) { + return null; + } + return ; }; diff --git a/apps/member/src/api/.gitkeep b/apps/member/src/api/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/member/src/api/board.ts b/apps/member/src/api/board.ts new file mode 100644 index 00000000..34aaf591 --- /dev/null +++ b/apps/member/src/api/board.ts @@ -0,0 +1,14 @@ +import { PaginationType } from '@type/api'; +import { server } from './server'; +import { END_POINT } from '@constants/api'; +import type { BoardItem } from '@type/board'; +import { createPagination } from '@utils/api'; + +// 내가 작성한 커뮤니티 게시글 조회 +export const getMyBoards = async (page: number, size: number) => { + const { data } = await server.get>({ + url: createPagination(END_POINT.MY_BOARDS, page, size), + }); + + return data; +}; diff --git a/apps/member/src/api/book.ts b/apps/member/src/api/book.ts new file mode 100644 index 00000000..817f3161 --- /dev/null +++ b/apps/member/src/api/book.ts @@ -0,0 +1,16 @@ +import { PaginationType } from '@type/api'; +import { server } from './server'; +import { createPagination } from '@utils/api'; +import { END_POINT } from '@constants/api'; +import { BookItem } from '@type/book'; + +// 나의 대출내역 조회 +export const getMyBooks = async (page: number, size: number, id: number) => { + const { data } = await server.get>({ + url: createPagination(END_POINT.MY_BOOKS, page, size), + }); + const myLoanBooks = data.items.filter( + (book) => book.borrowerId === String(id), + ); + return myLoanBooks; +}; diff --git a/apps/member/src/api/comment.ts b/apps/member/src/api/comment.ts new file mode 100644 index 00000000..625962fa --- /dev/null +++ b/apps/member/src/api/comment.ts @@ -0,0 +1,14 @@ +import { PaginationType } from '@type/api'; +import { server } from './server'; +import { createPagination } from '@utils/api'; +import { END_POINT } from '@constants/api'; +import type { CommentItem } from '@type/comment'; + +// 나의 댓글 조회 +export const getMyComments = async (page: number, size: number) => { + const { data } = await server.get>({ + url: createPagination(END_POINT.MY_COMMENTS, page, size), + }); + + return data; +}; diff --git a/apps/member/src/api/interceptors.ts b/apps/member/src/api/interceptors.ts new file mode 100644 index 00000000..0f7b9598 --- /dev/null +++ b/apps/member/src/api/interceptors.ts @@ -0,0 +1,75 @@ +import { server } from './server'; +import { API_BASE_URL, END_POINT } from '@constants/api'; +import type { Interceptor } from '@gwansikk/server-chain'; +import type { BaseResponse, TokenType } from '@type/api'; +import { + authorization, + getAccessToken, + getRefreshToken, + removeTokens, + setTokens, +} from '@utils/api'; + +let reissueLock = false; + +const retryRequest = async ( + response: Response, + method: string, + delay = 300, +) => { + await new Promise((resolve) => setTimeout(resolve, delay)); // 재요청 딜레이 + + const accessToken = getAccessToken(); + return fetch(response.url, { + method: method, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); +}; + +export const tokenHandler: Interceptor = async (response, method) => { + const { status } = response; + + if (status === 401) { + const preRefreshToken = getRefreshToken(); + if (!preRefreshToken) return response; + + if (reissueLock) { + // 잠금이 걸려있는 경우, 토큰 갱신 될 때까지 재요청 + return retryRequest(response, method); + } else { + // 토큰 갱신 중복 요청 방지 + reissueLock = true; + } + + const tokenResponse = await fetch( + `${API_BASE_URL}${END_POINT.LOGIN_REISSUE}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${preRefreshToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const { success, data } = + (await tokenResponse.json()) as BaseResponse; + + if (success === true) { + const { accessToken, refreshToken } = data; + setTokens(accessToken, refreshToken); + server.setHeaders(authorization(accessToken)); + reissueLock = false; + return retryRequest(response, method, 0); + } else { + // 토큰 갱신에 실패한 경우 로그아웃 처리 + removeTokens(); + window.location.reload(); + } + } + + return response; +}; diff --git a/apps/member/src/api/member.ts b/apps/member/src/api/member.ts new file mode 100644 index 00000000..299f9109 --- /dev/null +++ b/apps/member/src/api/member.ts @@ -0,0 +1,19 @@ +import { server } from './server'; +import { END_POINT } from '@constants/api'; +import type { BaseResponse } from '@type/api'; +import type { MemberType } from '@type/member'; + +interface PatchUserInfoArgs { + id: string; + body: MemberType; +} + +// 내 정보 수정 +export const patchUserInfo = async ({ id, body }: PatchUserInfoArgs) => { + const { data } = await server.patch>({ + url: END_POINT.MY_INFO_EDIT(id), + body: body, + }); + + return data; +}; diff --git a/apps/member/src/api/notification.ts b/apps/member/src/api/notification.ts new file mode 100644 index 00000000..f5b5d216 --- /dev/null +++ b/apps/member/src/api/notification.ts @@ -0,0 +1,14 @@ +import { PaginationType } from '@type/api'; +import { server } from './server'; +import type { NotificationItem } from '@type/notification'; +import { createPagination } from '@utils/api'; +import { END_POINT } from '@constants/api'; + +// 나의 알림 조회 +export const getMyNotifications = async (page: number, size: number) => { + const { data } = await server.get>({ + url: createPagination(END_POINT.MY_NOTIFICATION, page, size), + }); + + return data; +}; diff --git a/apps/member/src/api/server.ts b/apps/member/src/api/server.ts new file mode 100644 index 00000000..a1dbb67e --- /dev/null +++ b/apps/member/src/api/server.ts @@ -0,0 +1,14 @@ +import ServerChain from '@gwansikk/server-chain'; +import { tokenHandler } from './interceptors'; +import { API_BASE_URL } from '@constants/api'; + +export const server = ServerChain({ + key: 'server', + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + interceptors: { + error: tokenHandler, + }, +}); diff --git a/apps/member/src/components/common/LoginButton/LoginButton.tsx b/apps/member/src/components/common/LoginButton/LoginButton.tsx new file mode 100644 index 00000000..1fc1e9d6 --- /dev/null +++ b/apps/member/src/components/common/LoginButton/LoginButton.tsx @@ -0,0 +1,21 @@ +import Image from '../Image/Image'; + +const LoginButton = () => { + const handleClick = () => { + window.location.href = 'https://auth.clab.page/?code=dev'; + }; + + return ( + + ); +}; + +export default LoginButton; diff --git a/apps/member/src/components/common/Nav/Nav.tsx b/apps/member/src/components/common/Nav/Nav.tsx index bcd9c287..812bde52 100644 --- a/apps/member/src/components/common/Nav/Nav.tsx +++ b/apps/member/src/components/common/Nav/Nav.tsx @@ -8,17 +8,14 @@ const Nav = () => { const onClickReady = () => { alert('준비중'); }; + return (