diff --git a/apps/admin/package.json b/apps/admin/package.json index 6e7494e..bd379be 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -28,6 +28,7 @@ "@tanstack/react-router": "^1.104.1", "antd": "^5.24.1", "ky": "^1.7.5", + "lucide-react": "^0.462.0", "react": "catalog:react19", "react-dom": "catalog:react19", "tailwindcss": "^4.0.6" diff --git a/apps/admin/src/assets/.gitkeep b/apps/admin/src/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/admin/src/assets/logo.svg b/apps/admin/src/assets/logo.svg new file mode 100644 index 0000000..5fe7912 --- /dev/null +++ b/apps/admin/src/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/src/components/aside-navigation-menu.tsx b/apps/admin/src/components/aside-navigation-menu.tsx new file mode 100644 index 0000000..0699756 --- /dev/null +++ b/apps/admin/src/components/aside-navigation-menu.tsx @@ -0,0 +1,113 @@ +import { Link } from '@tanstack/react-router'; +import { Menu, type MenuProps } from 'antd'; +import { + Clipboard, + Clock, + FlaskConical, + GraduationCap, + Speech, + Users, +} from 'lucide-react'; + +import LOGO from '~/assets/logo.svg'; +import { useRefreshTokens } from '~/hooks/use-refresh-token'; +import { useTokenExpiration } from '~/hooks/use-token-expiration'; +import { authServices } from '~/utils/auth'; +import { formatExpireTime } from '~/utils/utils'; + +type MenuItem = Required['items'][number]; + +const items: MenuItem[] = [ + //TODO: Link 내부 url 변경 + { + key: 'user', + label: '회원 관리', + icon: , + }, + { + key: 'about', + label: '소개', + icon: , + children: [ + { key: 'dept', label: 학부 소개 }, + { key: 'club', label: 동아리 소개 }, + { key: 'contact', label: 찾아오시는 길 }, + ], + }, + { + key: 'professor', + label: 교수진 소개, + icon: , + }, + { + key: 'lab', + label: 연구실 소개, + icon: , + }, + { + key: 'board', + label: '게시판', + icon: , + children: [ + { key: 'notice', label: 공지사항 }, + { key: 'news', label: 학부 소식 }, + ], + }, +]; + +function AsideHeader() { + return ( + + + + AI컴퓨터공학부 + 관리자 시스템 + + + ); +} + +function AsideFooter() { + const { expireTime } = useTokenExpiration(); + const refreshMutation = useRefreshTokens(); + const { logout } = authServices(); + + const handleRefreshToken = () => { + refreshMutation.mutate(); + }; + + return ( + + + + {formatExpireTime(expireTime)} + + + + 시간연장 + + + 로그아웃 + + + + ); +} + +export default function AsideNavigationMenu() { + return ( + + ); +} diff --git a/apps/admin/src/hooks/use-auth.ts b/apps/admin/src/hooks/use-auth.ts deleted file mode 100644 index fd8690a..0000000 --- a/apps/admin/src/hooks/use-auth.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - ACCESS_TOKEN_KEY, - END_POINT, - REFRESH_TOKEN_KEY, -} from '~/constants/api'; - -import type { Tokens } from '~/hooks/use-sign-in'; -import { getToken, removeTokens } from '~/utils/api'; -import { http } from '~/utils/http'; -import { decodeJwt } from '~/utils/jwt'; - -const useAuth = () => { - const setTokens = (tokens: Tokens) => { - if (!tokens.accessToken || !tokens.refreshToken) { - console.log('토큰이 존재하지 않음'); - return; - } - - localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken); - - const decoded = decodeJwt(tokens.accessToken); - - if (!decoded || !decoded.exp) { - console.log('잘못된 jwt 토큰'); - return; - } - - const expiresIn = decoded.exp * 1000 - Date.now(); - - if (expiresIn <= 0) { - logout(); - return; - } - - setTimeout(() => { - logout(); - }, expiresIn); - }; - - const refreshTokens = async () => { - const [accessToken, refreshToken] = getToken(); - - if (!accessToken || !refreshToken) { - logout(); - return; - } - - try { - const newTokens = await http - .post(END_POINT.REISSUE, { json: { accessToken, refreshToken } }) - .json(); - setTokens(newTokens); - } catch (error) { - console.log(error); - logout(); - } - }; - - const logout = () => { - removeTokens(); - }; - - return { setTokens, logout, refreshTokens }; -}; - -export { useAuth }; diff --git a/apps/admin/src/hooks/use-refresh-token.ts b/apps/admin/src/hooks/use-refresh-token.ts new file mode 100644 index 0000000..d01fa40 --- /dev/null +++ b/apps/admin/src/hooks/use-refresh-token.ts @@ -0,0 +1,40 @@ +import { useMutation } from '@tanstack/react-query'; +import { END_POINT } from '~/constants/api'; +import { getToken } from '~/utils/api'; +import { authServices } from '~/utils/auth'; +import { authHttp } from '~/utils/http'; +import type { Tokens } from './use-sign-in'; + +interface RefreshToken { + refreshToken: string; +} + +const useRefreshTokens = () => { + const { setTokens, logout } = authServices(); + + const refreshMutation = useMutation({ + mutationFn: () => { + const [accessToken, refreshToken] = getToken(); + + if (!accessToken || !refreshToken) { + throw new Error('토큰이 존재하지 않습니다'); + } + + const response = authHttp.post(END_POINT.REISSUE, { + json: { refreshToken }, + }); + return response.json(); + }, + onSuccess: (newTokens) => { + setTokens(newTokens); + }, + onError: () => { + alert('오류가 발생하여 로그아웃합니다.'); + logout(); + }, + }); + + return refreshMutation; +}; + +export { type RefreshToken, useRefreshTokens }; diff --git a/apps/admin/src/hooks/use-sign-in.ts b/apps/admin/src/hooks/use-sign-in.ts index b220a87..e3c843e 100644 --- a/apps/admin/src/hooks/use-sign-in.ts +++ b/apps/admin/src/hooks/use-sign-in.ts @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { END_POINT } from '~/constants/api'; -import { useAuth } from '~/hooks/use-auth'; +import { authServices } from '~/utils/auth'; import { authHttp } from '~/utils/http'; interface SignInData { @@ -14,15 +15,16 @@ interface Tokens { } const useSignIn = () => { - const { setTokens } = useAuth(); + const { setTokens } = authServices(); + const navigate = useNavigate(); return useMutation({ mutationFn: (data: SignInData) => { return authHttp.post(END_POINT.SIGN_IN, { json: data }).json(); }, onSuccess: (token) => { - console.log('success : ', token); setTokens(token); + navigate({ to: '/main' }); }, }); }; diff --git a/apps/admin/src/hooks/use-token-expiration.ts b/apps/admin/src/hooks/use-token-expiration.ts new file mode 100644 index 0000000..039f94b --- /dev/null +++ b/apps/admin/src/hooks/use-token-expiration.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { getAccessToken } from '~/utils/api'; +import { authServices } from '~/utils/auth'; +import { decodeJwt } from '~/utils/jwt'; + +export const useTokenExpiration = () => { + const { logout } = authServices(); + const [expireTime, setExpireTime] = useState(null); + const accessToken = getAccessToken(); + + useEffect(() => { + if (accessToken) { + const decoded = decodeJwt(accessToken); + if (decoded?.exp) { + const expiresIn = decoded.exp * 1000 - Date.now(); + setExpireTime(expiresIn); + } + } + }, [accessToken]); + + useEffect(() => { + if (expireTime !== null && expireTime <= 0) { + logout(); + } else if (expireTime !== null && expireTime > 0) { + const timerId = setTimeout(() => { + setExpireTime(expireTime - 1000); + }, 1000); + return () => clearTimeout(timerId); + } + }, [expireTime, logout]); + + return { expireTime }; +}; diff --git a/apps/admin/src/routes/__root.tsx b/apps/admin/src/routes/__root.tsx index adae2c8..6857073 100644 --- a/apps/admin/src/routes/__root.tsx +++ b/apps/admin/src/routes/__root.tsx @@ -1,17 +1,35 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { Outlet, createRootRoute } from '@tanstack/react-router'; +import { Outlet, createRootRoute, useLocation } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/router-devtools'; +import { ConfigProvider } from 'antd'; +import AsideNavigationMenu from '~/components/aside-navigation-menu'; export const Route = createRootRoute({ component: App, }); function App() { + const location = useLocation(); + const isSigninPage = location.pathname === '/'; + return ( - <> - - - - > + + + {!isSigninPage && } + + + + + + + ); } diff --git a/apps/admin/src/routes/index.tsx b/apps/admin/src/routes/index.tsx index 2eaa7ac..1c295e3 100644 --- a/apps/admin/src/routes/index.tsx +++ b/apps/admin/src/routes/index.tsx @@ -32,8 +32,8 @@ function SignInPage() { }; return ( - - + + 로그인 diff --git a/apps/admin/src/routes/main/index.tsx b/apps/admin/src/routes/main/index.tsx new file mode 100644 index 0000000..1e11775 --- /dev/null +++ b/apps/admin/src/routes/main/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/main/')({ + component: MainPage, +}); + +function MainPage() { + return main; +} diff --git a/apps/admin/src/styles/globals.css b/apps/admin/src/styles/globals.css index 347fc3e..a971cf6 100644 --- a/apps/admin/src/styles/globals.css +++ b/apps/admin/src/styles/globals.css @@ -1,5 +1,20 @@ +@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/variable/pretendardvariable-dynamic-subset.css"); @import "tailwindcss"; +:root { + font-family: + "Pretendard Variable", Pretendard, -apple-system, + BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", + "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; + font-size: 16px; +} + .ProseMirror ul ul, .ProseMirror ol ol, .ProseMirror ul ol, diff --git a/apps/admin/src/utils/.gitkeep b/apps/admin/src/utils/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/admin/src/utils/auth.ts b/apps/admin/src/utils/auth.ts new file mode 100644 index 0000000..5071635 --- /dev/null +++ b/apps/admin/src/utils/auth.ts @@ -0,0 +1,36 @@ +import { useNavigate } from '@tanstack/react-router'; + +import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '~/constants/api'; +import { removeTokens } from '~/utils/api'; +import { decodeJwt } from '~/utils/jwt'; + +import type { Tokens } from '~/hooks/use-sign-in'; + +const authServices = () => { + const navigate = useNavigate(); + const setTokens = (tokens: Tokens) => { + if (!tokens.accessToken || !tokens.refreshToken) { + console.log('토큰이 존재하지 않습니다'); + return; + } + + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken); + + const decoded = decodeJwt(tokens.accessToken); + + if (!decoded || !decoded.exp) { + console.log('잘못된 토큰입니다.'); + return; + } + }; + + const logout = () => { + removeTokens(); + navigate({ to: '/' }); + }; + + return { setTokens, logout }; +}; + +export { authServices }; diff --git a/apps/admin/src/utils/utils.ts b/apps/admin/src/utils/utils.ts new file mode 100644 index 0000000..3d68bc8 --- /dev/null +++ b/apps/admin/src/utils/utils.ts @@ -0,0 +1,9 @@ +export const formatExpireTime = (times: number | null) => { + if (times === null || times <= 0) return null; + + const totalSeconds = Math.floor(times / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f61543..d14b465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: ky: specifier: ^1.7.5 version: 1.7.5 + lucide-react: + specifier: ^0.462.0 + version: 0.462.0(react@19.0.0) react: specifier: catalog:react19 version: 19.0.0 @@ -5672,9 +5675,9 @@ snapshots: '@storybook/addon-docs@8.4.2(@types/react@19.0.0)(storybook@8.4.2(prettier@3.5.1))(webpack-sources@3.2.3)': dependencies: '@mdx-js/react': 3.1.0(@types/react@19.0.0)(react@18.3.1) - '@storybook/blocks': 8.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1)) + '@storybook/blocks': 8.4.2(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1)) '@storybook/csf-plugin': 8.4.2(storybook@8.4.2(prettier@3.5.1))(webpack-sources@3.2.3) - '@storybook/react-dom-shim': 8.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1)) + '@storybook/react-dom-shim': 8.4.2(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) storybook: 8.4.2(prettier@3.5.1) @@ -5744,24 +5747,24 @@ snapshots: memoizerific: 1.11.3 storybook: 8.4.2(prettier@3.5.1) - '@storybook/blocks@8.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1))': + '@storybook/blocks@8.4.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.4.2(prettier@3.5.1))': dependencies: '@storybook/csf': 0.1.11 - '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@19.0.0) storybook: 8.4.2(prettier@3.5.1) ts-dedent: 2.2.0 optionalDependencies: - react: 18.3.1 + react: 19.0.0 react-dom: 18.3.1(react@18.3.1) - '@storybook/blocks@8.4.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.4.2(prettier@3.5.1))': + '@storybook/blocks@8.4.2(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1))': dependencies: '@storybook/csf': 0.1.11 - '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@19.0.0) + '@storybook/icons': 1.2.12(react-dom@18.3.1(react@19.0.0))(react@18.3.1) storybook: 8.4.2(prettier@3.5.1) ts-dedent: 2.2.0 optionalDependencies: - react: 19.0.0 + react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@storybook/builder-vite@8.4.2(storybook@8.4.2(prettier@3.5.1))(vite@5.4.9(@types/node@20.16.13)(lightningcss@1.29.1)(terser@5.36.0))(webpack-sources@3.2.3)': @@ -5811,14 +5814,14 @@ snapshots: '@storybook/global@5.0.0': {} - '@storybook/icons@1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@storybook/icons@1.2.12(react-dom@18.3.1(react@18.3.1))(react@19.0.0)': dependencies: - react: 18.3.1 + react: 19.0.0 react-dom: 18.3.1(react@18.3.1) - '@storybook/icons@1.2.12(react-dom@18.3.1(react@18.3.1))(react@19.0.0)': + '@storybook/icons@1.2.12(react-dom@18.3.1(react@19.0.0))(react@18.3.1)': dependencies: - react: 19.0.0 + react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@storybook/instrumenter@8.4.2(storybook@8.4.2(prettier@3.5.1))': @@ -5835,15 +5838,15 @@ snapshots: dependencies: storybook: 8.4.2(prettier@3.5.1) - '@storybook/react-dom-shim@8.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1))': + '@storybook/react-dom-shim@8.4.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.4.2(prettier@3.5.1))': dependencies: - react: 18.3.1 + react: 19.0.0 react-dom: 18.3.1(react@18.3.1) storybook: 8.4.2(prettier@3.5.1) - '@storybook/react-dom-shim@8.4.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.4.2(prettier@3.5.1))': + '@storybook/react-dom-shim@8.4.2(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(storybook@8.4.2(prettier@3.5.1))': dependencies: - react: 19.0.0 + react: 18.3.1 react-dom: 18.3.1(react@18.3.1) storybook: 8.4.2(prettier@3.5.1)
AI컴퓨터공학부
관리자 시스템