Repository: kgu-developers/aics-client
Pull request #83: feat(admin): login api 연결
Author: login: noahyunjun
Labels: name: enhancement
description: New feature or request
Created: 2025-03-04T15:15:58Z | Merged: 2025-03-10T14:14:06Z

## Summary

- #81 

admin login api를 연결했습니다.

## Tasks

* 5173 port -> 3001 port 변경
* ky기반 api 통신 add
* 기존 로그아웃 타임 계산 방식 변경 > jwt페이로드 사용


## To Reviewer

현재 리디렉션할 페이지가 없어서 console에만 success 메세지에 토큰 받아오는것만 띄워놨습니다

## Screenshot

### success message
<img width="1616" alt="스크린샷 2025-03-04 22 24 18" src="https://github.com/user-attachments/assets/f4015e15-9dc5-44cb-b9f2-9a72694211cf" />

### network
<img width="676" alt="스크린샷 2025-03-04 22 25 10" src="https://github.com/user-attachments/assets/4ff74c2b-21a6-463c-be33-a5c7835200bb" />



Diff:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index abe1426d..2f9bd446 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,5 @@
 {
-	"editor.defaultFormatter": "biomejs.biome",
-	"editor.formatOnSave": true,
-	"editor.mouseWheelZoom": true
+  "editor.defaultFormatter": "biomejs.biome",
+  "editor.formatOnSave": true,
+  "editor.mouseWheelZoom": true
 }
diff --git a/apps/admin/src/constants/api.ts b/apps/admin/src/constants/api.ts
new file mode 100644
index 00000000..72d051e0
--- /dev/null
+++ b/apps/admin/src/constants/api.ts
@@ -0,0 +1,48 @@
+/**
+ * 추후 삭제 예정
+ */
+const MOCK_BASE_URL = 'http://58.238.255.245:8080/api/v1';
+
+const MOCK_END_POINT = {
+  HEROES: `${MOCK_BASE_URL}/main/hero-images`,
+  RECENT_NEWS: `${MOCK_BASE_URL}/main/recent-news`,
+  RECENT_NOTICES: `${MOCK_BASE_URL}/main/recent-notices`,
+  LABS: `${MOCK_BASE_URL}/labs`,
+  DEPT: `${MOCK_BASE_URL}/dept`,
+  PROFESSORS: `${MOCK_BASE_URL}/professors`,
+  CLUB: `${MOCK_BASE_URL}/about/club`,
+  CONTACT: `${MOCK_BASE_URL}/about/contact`,
+  MY_PROFILE: `${MOCK_BASE_URL}/my`,
+  BOARD: `${MOCK_BASE_URL}/board`,
+  BOARD_DETAIL: (id: string) => `${MOCK_BASE_URL}/posts/${id}`,
+} as const;
+
+const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL;
+const AUTH_BASE_URL = import.meta.env.VITE_PUBLIC_AUTH_API_URL;
+
+const END_POINT = {
+  POST: 'posts',
+  POST_DETAIL: (id: string) => `posts/${id}`,
+  CAROUSEL: 'carousels',
+  LABS: 'labs',
+  SIGN_IN: 'login',
+  REISSUE: 'reissue',
+  PROFESSORS: 'professors',
+  CLUBS: 'clubs',
+  MY_PROFILE: 'users/my',
+  EDIT_MY_PROFILE: 'users',
+  CHANGE_PASSWORD: 'users/password',
+} as const;
+
+const ACCESS_TOKEN_KEY = 'accessToken';
+const REFRESH_TOKEN_KEY = 'refreshToken';
+
+export {
+  MOCK_BASE_URL,
+  API_BASE_URL,
+  AUTH_BASE_URL,
+  MOCK_END_POINT,
+  END_POINT,
+  ACCESS_TOKEN_KEY,
+  REFRESH_TOKEN_KEY,
+};
diff --git a/apps/admin/src/hooks/use-auth.ts b/apps/admin/src/hooks/use-auth.ts
new file mode 100644
index 00000000..fd8690a5
--- /dev/null
+++ b/apps/admin/src/hooks/use-auth.ts
@@ -0,0 +1,67 @@
+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<Tokens>();
+      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-sign-in.ts b/apps/admin/src/hooks/use-sign-in.ts
new file mode 100644
index 00000000..b220a87e
--- /dev/null
+++ b/apps/admin/src/hooks/use-sign-in.ts
@@ -0,0 +1,30 @@
+import { useMutation } from '@tanstack/react-query';
+import { END_POINT } from '~/constants/api';
+import { useAuth } from '~/hooks/use-auth';
+import { authHttp } from '~/utils/http';
+
+interface SignInData {
+  userId: string;
+  password: string;
+}
+
+interface Tokens {
+  accessToken: string;
+  refreshToken: string;
+}
+
+const useSignIn = () => {
+  const { setTokens } = useAuth();
+
+  return useMutation({
+    mutationFn: (data: SignInData) => {
+      return authHttp.post(END_POINT.SIGN_IN, { json: data }).json<Tokens>();
+    },
+    onSuccess: (token) => {
+      console.log('success : ', token);
+      setTokens(token);
+    },
+  });
+};
+
+export { type Tokens, useSignIn };
diff --git a/apps/admin/src/routes/index.tsx b/apps/admin/src/routes/index.tsx
index 01ac06a1..2eaa7ac9 100644
--- a/apps/admin/src/routes/index.tsx
+++ b/apps/admin/src/routes/index.tsx
@@ -1,24 +1,29 @@
 import { LockOutlined, UserOutlined } from '@ant-design/icons';
 import { createFileRoute } from '@tanstack/react-router';
 import { Button, Card, Form, Input, Typography } from 'antd';
+import { useSignIn } from '~/hooks/use-sign-in';
 
 export const Route = createFileRoute('/')({
   component: SignInPage,
 });
 
 interface FormValues {
-  username: string;
+  userId: string;
   password: string;
 }
 
 function SignInPage() {
+  const [form] = Form.useForm();
+  const signInMutation = useSignIn();
+
   const onFinish = (values: FormValues) => {
-    //Todo: 로그인 API 연동
-    console.log('Received values:', values);
+    signInMutation.mutate(values, {
+      onError: (error) => {
+        console.error('로그인 실패:', error);
+      },
+    });
   };
 
-  const [form] = Form.useForm();
-
   const removeSpace = (fieldName: keyof FormValues) => {
     return (e: React.ChangeEvent<HTMLInputElement>) => {
       const newValue = e.target.value.replace(/\s/g, '');
@@ -37,17 +42,17 @@ function SignInPage() {
           name="login"
           onFinish={onFinish}
           layout="vertical"
-          initialValues={{ username: '', password: '' }}
+          initialValues={{ userId: '', password: '' }}
         >
           <Form.Item
             label="아이디"
-            name="username"
+            name="userId"
             rules={[{ required: true, message: '아이디를 입력하세요!' }]}
           >
             <Input
               prefix={<UserOutlined />}
               placeholder="아이디 입력"
-              onChange={removeSpace('username')}
+              onChange={removeSpace('userId')}
             />
           </Form.Item>
 
@@ -63,6 +68,12 @@ function SignInPage() {
             />
           </Form.Item>
 
+          {signInMutation.isError && (
+            <p className="text-red-500 text-center">
+              아이디 또는 비밀번호를 확인하세요.
+            </p>
+          )}
+
           <Form.Item>
             <Button type="primary" htmlType="submit" className="w-full">
               로그인
@@ -73,3 +84,5 @@ function SignInPage() {
     </div>
   );
 }
+
+export default SignInPage;
diff --git a/apps/admin/src/utils/api.ts b/apps/admin/src/utils/api.ts
new file mode 100644
index 00000000..515a0dcb
--- /dev/null
+++ b/apps/admin/src/utils/api.ts
@@ -0,0 +1,36 @@
+import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '~/constants/api';
+
+function getAccessToken(): string | null {
+  if (typeof window === 'undefined') {
+    return null;
+  }
+  return localStorage.getItem(ACCESS_TOKEN_KEY);
+}
+
+function getRefreshToken(): string | null {
+  if (typeof window === 'undefined') {
+    return null;
+  }
+  return localStorage.getItem(REFRESH_TOKEN_KEY);
+}
+
+function removeTokens() {
+  if (typeof window === 'undefined') {
+    return;
+  }
+  localStorage.removeItem(ACCESS_TOKEN_KEY);
+  localStorage.removeItem(REFRESH_TOKEN_KEY);
+}
+
+function getToken() {
+  const accessToken = getAccessToken();
+  const refreshToken = getRefreshToken();
+
+  if (!accessToken || !refreshToken) {
+    removeTokens();
+  }
+
+  return [accessToken, refreshToken] as const;
+}
+
+export { getAccessToken, getRefreshToken, removeTokens, getToken };
diff --git a/apps/admin/src/utils/http.ts b/apps/admin/src/utils/http.ts
new file mode 100644
index 00000000..67e654c2
--- /dev/null
+++ b/apps/admin/src/utils/http.ts
@@ -0,0 +1,31 @@
+import ky from 'ky';
+import { API_BASE_URL, AUTH_BASE_URL } from '~/constants/api';
+import { getAccessToken } from './api';
+
+export const http = ky.create({
+  prefixUrl: API_BASE_URL,
+  headers: {
+    'Content-Type': 'application/json',
+  },
+  credentials: 'include',
+  timeout: 10000,
+  hooks: {
+    beforeRequest: [
+      (request) => {
+        const accessToken = getAccessToken();
+        if (accessToken) {
+          request.headers.set('Authorization', `Bearer ${accessToken}`);
+        }
+      },
+    ],
+  },
+});
+
+export const authHttp = ky.create({
+  prefixUrl: AUTH_BASE_URL,
+  headers: {
+    'Content-Type': 'application/json',
+  },
+  credentials: 'include',
+  timeout: 10000,
+});
diff --git a/apps/admin/src/utils/jwt.ts b/apps/admin/src/utils/jwt.ts
new file mode 100644
index 00000000..572e7243
--- /dev/null
+++ b/apps/admin/src/utils/jwt.ts
@@ -0,0 +1,16 @@
+export const decodeJwt = (token: string) => {
+  try {
+    const base64Url = token.split('.')[1] ?? '';
+    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+    const jsonPayload = decodeURIComponent(
+      atob(base64)
+        .split('')
+        .map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
+        .join(''),
+    );
+    return JSON.parse(jsonPayload);
+  } catch (error) {
+    console.log(error);
+    return null;
+  }
+};
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
index 0262fd68..3b9e2708 100644
--- a/apps/admin/vite.config.ts
+++ b/apps/admin/vite.config.ts
@@ -3,10 +3,12 @@ import tailwindcss from '@tailwindcss/vite';
 import react from '@vitejs/plugin-react';
 import { defineConfig } from 'vite';
 
-// https://vitejs.dev/config/
 export default defineConfig({
   plugins: [react(), tailwindcss()],
   resolve: {
     alias: { '~': path.resolve(__dirname, 'src') },
   },
+  server: {
+    port: 3001,
+  },
 });


Reviews:
user: login: SunwooJaeho
body: LGTM!!! 고생하셨습니다🥰
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-04T15:20:56Z
user: login: m2na7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T15:26:47Z
user: login: gwansikk
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T16:06:43Z
user: login: gwansikk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T00:32:02Z
user: login: SunwooJaeho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T11:01:51Z
user: login: noahyunjun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:28:31Z
user: login: noahyunjun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:29:06Z
user: login: noahyunjun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:30:10Z
user: login: noahyunjun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:30:27Z
user: login: gwansikk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T04:13:49Z
user: login: noahyunjun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T14:03:09Z
user: login: SunwooJaeho
body: LGTM😀
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T06:29:19Z
user: login: noahyunjun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T06:33:59Z
user: login: SunwooJaeho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T06:37:29Z
user: login: gwansikk
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T13:43:28Z

Review comments:
diff_hunk: @@ -0,0 +1,25 @@
+import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../constants/api';
path: apps/admin/src/utils/api.ts
user: login: m2na7
body: 절대경로로 바꿔주는게 어떨까요 ?!
created_at: 2025-03-04T15:24:45Z
author_association: MEMBER
diff_hunk: @@ -7,6 +7,6 @@
       "~/*": ["./src/*"]
     }
   },
-  "include": ["src"],
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
path: apps/admin/tsconfig.json
user: login: m2na7
body: 해당부분 수정하신 이유가 있나요 ?
created_at: 2025-03-04T15:26:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+// 추후 삭제 예정
path: apps/admin/src/constants/api.ts
user: login: gwansikk
body: ```suggestion
/**
 * 추후 삭제 예정
 */
```
created_at: 2025-03-04T15:50:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+// 추후 삭제 예정
+const MOCK_BASE_URL = 'http://58.238.255.245:8080/api/v1';
+
+const MOCK_END_POINT = {
+  HEROES: `${MOCK_BASE_URL}/main/hero-images`,
+  RECENT_NEWS: `${MOCK_BASE_URL}/main/recent-news`,
+  RECENT_NOTICES: `${MOCK_BASE_URL}/main/recent-notices`,
+  LABS: `${MOCK_BASE_URL}/labs`,
+  DEPT: `${MOCK_BASE_URL}/dept`,
+  PROFESSORS: `${MOCK_BASE_URL}/professors`,
+  CLUB: `${MOCK_BASE_URL}/about/club`,
+  CONTACT: `${MOCK_BASE_URL}/about/contact`,
+  MY_PROFILE: `${MOCK_BASE_URL}/my`,
+  BOARD: `${MOCK_BASE_URL}/board`,
+  BOARD_DETAIL: (id: string) => `${MOCK_BASE_URL}/posts/${id}`,
+} as const;
+
+const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL;
+const AUTH_BASE_URL = import.meta.env.VITE_PUBLIC_AUTH_API_URL;
+
+const END_POINT = {
+  POST: `${API_BASE_URL}/posts`,
+  POST_DETAIL: (id: string) => `${API_BASE_URL}/posts/${id}`,
+  CAROUSEL: `${API_BASE_URL}/carousels`,
+  LABS: `${API_BASE_URL}/labs`,
+  SIGN_IN: `${AUTH_BASE_URL}/login`,
+  REISSUE: `${AUTH_BASE_URL}/reissue`,
+  PROFESSORS: `${API_BASE_URL}/professors`,
+  CLUBS: `${API_BASE_URL}/clubs`,
+  MY_PROFILE: `${API_BASE_URL}/users/my`,
+  EDIT_MY_PROFILE: `${API_BASE_URL}/users`,
+  CHANGE_PASSWORD: `${API_BASE_URL}/users/password`,
path: apps/admin/src/constants/api.ts
user: login: gwansikk
body: Base URL은 Ky 인스턴스에 적용하면 더 쉽게 관리할 수 있을거 같아요
created_at: 2025-03-04T15:52:09Z
author_association: CONTRIBUTOR
path: apps/admin/src/hooks/use-token.ts
user: login: gwansikk
body: 훅으로 따로 선언한 이유가 있나요? 해당 훅은 React 내장 Hook을 사용하고 있지 않습니다. 리액트 라이프사이클과 관련 없는 단순 함수라고 생각합니다.

ref: https://ko.react.dev/learn/reusing-logic-with-custom-hooks
created_at: 2025-03-04T15:55:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+import { useMutation } from '@tanstack/react-query';
+import { END_POINT } from '~/constants/api';
+import { http } from '~/utils/http';
+import { useAuth } from './use-auth';
+
+interface SignInData {
+  userId: string;
+  password: string;
+}
+
+interface Tokens {
+  accessToken: string;
+  refreshToken: string;
+}
+
+const useSignIn = () => {
+  const { setTokens } = useAuth();
+
+  return useMutation({
+    mutationFn: async (data: SignInData) => {
+      return await http.post(END_POINT.SIGN_IN, { json: data }).json<Tokens>();
+    },
path: apps/admin/src/hooks/use-sign-in.ts
user: login: gwansikk
body: ```suggestion
    mutationFn: (data: SignInData) => {
      return http.post(END_POINT.SIGN_IN, { json: data }).json<Tokens>();
    },
```
created_at: 2025-03-04T15:56:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+import { useMutation } from '@tanstack/react-query';
+import {
+  ACCESS_TOKEN_KEY,
+  END_POINT,
+  REFRESH_TOKEN_KEY,
+} from '~/constants/api';
+
+import type { Tokens } from '~/hooks/use-sign-in';
+import { useToken } from './use-token';
+
+import { removeTokens } from '~/utils/api';
+import { http } from '~/utils/http';
+
+const JWT_EXPIRY_TIME = 1800 * 1000 - 60 * 1000;
+
+const useAuth = () => {
+  const [accessToken, refreshToken] = useToken();
+
+  const silentRefresh = useMutation({
+    mutationFn: async () => {
+      if (!accessToken || !refreshToken) {
+        throw new Error('Tokens are missing');
+      }
+
+      return await http
path: apps/admin/src/hooks/use-auth.ts
user: login: gwansikk
body: ```suggestion
  const silentRefresh = useMutation({
    mutationFn: () => {
      if (!accessToken || !refreshToken) {
        throw new Error('Tokens are missing');
      }

      return http
```
created_at: 2025-03-04T15:56:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+import { useMutation } from '@tanstack/react-query';
+import {
+  ACCESS_TOKEN_KEY,
+  END_POINT,
+  REFRESH_TOKEN_KEY,
+} from '~/constants/api';
+
+import type { Tokens } from '~/hooks/use-sign-in';
+import { useToken } from './use-token';
+
+import { removeTokens } from '~/utils/api';
+import { http } from '~/utils/http';
+
+const JWT_EXPIRY_TIME = 1800 * 1000 - 60 * 1000;
+
+const useAuth = () => {
+  const [accessToken, refreshToken] = useToken();
+
+  const silentRefresh = useMutation({
+    mutationFn: async () => {
+      if (!accessToken || !refreshToken) {
+        throw new Error('Tokens are missing');
+      }
+
+      return await http
+        .post(END_POINT.REISSUE, { json: { accessToken, refreshToken } })
+        .json<Tokens>();
+    },
+    onSuccess: (tokens) => {
+      setTokens(tokens);
+    },
+    onError: () => {
+      alert('⚠️ 세션이 만료되어 로그아웃합니다.');
+      logout();
+    },
+  });
+
+  const setTokens = (tokens: Tokens) => {
+    localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
+    localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
+
+    setTimeout(() => {
+      silentRefresh.mutate();
+    }, JWT_EXPIRY_TIME);
path: apps/admin/src/hooks/use-auth.ts
user: login: gwansikk
body: JWT 페이로드에는 만료시간이 포함되어 있어요. 해당 값을 이용하여 동적으로 계산하면 어떨까요?

ref: https://jwt.io/
created_at: 2025-03-05T00:32:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+import { useMutation } from '@tanstack/react-query';
+import {
+  ACCESS_TOKEN_KEY,
+  END_POINT,
+  REFRESH_TOKEN_KEY,
+} from '~/constants/api';
+
+import type { Tokens } from '~/hooks/use-sign-in';
+import { useToken } from './use-token';
+
+import { removeTokens } from '~/utils/api';
+import { http } from '~/utils/http';
+
+const JWT_EXPIRY_TIME = 1800 * 1000 - 60 * 1000;
+
+const useAuth = () => {
+  const [accessToken, refreshToken] = useToken();
+
+  const silentRefresh = useMutation({
+    mutationFn: async () => {
+      if (!accessToken || !refreshToken) {
+        throw new Error('Tokens are missing');
+      }
+
+      return await http
+        .post(END_POINT.REISSUE, { json: { accessToken, refreshToken } })
+        .json<Tokens>();
+    },
+    onSuccess: (tokens) => {
+      setTokens(tokens);
+    },
+    onError: () => {
+      alert('⚠️ 세션이 만료되어 로그아웃합니다.');
+      logout();
+    },
+  });
+
+  const setTokens = (tokens: Tokens) => {
+    localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
+    localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
+
+    setTimeout(() => {
+      silentRefresh.mutate();
+    }, JWT_EXPIRY_TIME);
path: apps/admin/src/hooks/use-auth.ts
user: login: SunwooJaeho
body: @noahyunjun 혹시 해당 사항 적용하실 계획이시라면 community에도 적용 부탁드려도 될까요?
created_at: 2025-03-05T11:01:51Z
author_association: MEMBER
path: apps/admin/src/hooks/use-token.ts
user: login: noahyunjun
body: community코드를 참고하여 admin도 구성을 했는데, 동일하게 hook으로 선언했습니다.
피드백 주신걸 확인해보니 내장 훅도 없고 일반 유틸 함수처럼 동작하는게 맞네요.
수정하겠습니다.

created_at: 2025-03-05T13:28:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+import { useMutation } from '@tanstack/react-query';
+import {
+  ACCESS_TOKEN_KEY,
+  END_POINT,
+  REFRESH_TOKEN_KEY,
+} from '~/constants/api';
+
+import type { Tokens } from '~/hooks/use-sign-in';
+import { useToken } from './use-token';
+
+import { removeTokens } from '~/utils/api';
+import { http } from '~/utils/http';
+
+const JWT_EXPIRY_TIME = 1800 * 1000 - 60 * 1000;
+
+const useAuth = () => {
+  const [accessToken, refreshToken] = useToken();
+
+  const silentRefresh = useMutation({
+    mutationFn: async () => {
+      if (!accessToken || !refreshToken) {
+        throw new Error('Tokens are missing');
+      }
+
+      return await http
+        .post(END_POINT.REISSUE, { json: { accessToken, refreshToken } })
+        .json<Tokens>();
+    },
+    onSuccess: (tokens) => {
+      setTokens(tokens);
+    },
+    onError: () => {
+      alert('⚠️ 세션이 만료되어 로그아웃합니다.');
+      logout();
+    },
+  });
+
+  const setTokens = (tokens: Tokens) => {
+    localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
+    localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
+
+    setTimeout(() => {
+      silentRefresh.mutate();
+    }, JWT_EXPIRY_TIME);
path: apps/admin/src/hooks/use-auth.ts
user: login: noahyunjun
body: JWT 페이로드 학습후에 admin과 community 두곳에 적용 하겠습니다.
created_at: 2025-03-05T13:29:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,6 +7,6 @@
       "~/*": ["./src/*"]
     }
   },
-  "include": ["src"],
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
path: apps/admin/tsconfig.json
user: login: noahyunjun
body: env파일이 인식이 안되는 오류가 지속되어 community코드를 참고하다보니 섞여들어가게 되었습니다.
next가 아닌, vite환경이기 때문에 이 부분도 수정하겠습니다.
created_at: 2025-03-05T13:30:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../constants/api';
path: apps/admin/src/utils/api.ts
user: login: noahyunjun
body: 좋네요! 절대경로로 수정하겠습니다.
created_at: 2025-03-05T13:30:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import ky from 'ky';
+import { getAccessToken } from './api';
+
+export const http = ky.create({
+  headers: {
+    'Content-Type': 'application/json',
+  },
+  credentials: 'include',
+  timeout: 10000,
+  hooks: {
+    beforeRequest: [
+      (request) => {
+        const accessToken = getAccessToken();
+        if (accessToken) {
+          request.headers.set('Authorization', `Bearer ${accessToken}`);
+        }
+      },
+    ],
+  },
+});
+
+export const request = async <T>(
path: apps/admin/src/utils/http.ts
user: login: gwansikk
body: ```suggestion
/**
 * @deprecated http 유틸리티를 사용하세요.
 */
export const request = async <T>(
```

request 래핑은 기존 기존 레거시 호환하기 위함인가요??

created_at: 2025-03-07T04:13:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import ky from 'ky';
+import { getAccessToken } from './api';
+
+export const http = ky.create({
+  headers: {
+    'Content-Type': 'application/json',
+  },
+  credentials: 'include',
+  timeout: 10000,
+  hooks: {
+    beforeRequest: [
+      (request) => {
+        const accessToken = getAccessToken();
+        if (accessToken) {
+          request.headers.set('Authorization', `Bearer ${accessToken}`);
+        }
+      },
+    ],
+  },
+});
+
+export const request = async <T>(
path: apps/admin/src/utils/http.ts
user: login: noahyunjun
body: 레거시 호환은 아니고 제가 테스트 하는 과정에서 생긴 불필요 코드입니다.
http 유틸리티만 사용하도록 변경하겠습니다.
created_at: 2025-03-07T14:03:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+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) => {
+    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);
+  };
path: apps/admin/src/hooks/use-auth.ts
user: login: SunwooJaeho
body: 기존 코드는 만료시간에 가까워지면 토큰을 리프레쉬하였는데, 로그아웃을 하는 것으로 변경하신 이유가 궁금합니다!
created_at: 2025-03-10T06:19:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+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) => {
+    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);
+  };
path: apps/admin/src/hooks/use-auth.ts
user: login: noahyunjun
body: 자동 토큰 리프레쉬도 좋지만, jwt페이로드에 넘어오는 시간값이 그럼 무의미 하다고 생각이 들어서 로그아웃으로 변경했습니다.
admin의 경우 모든 권한이 있기때문에 자동 로그아웃으로 토큰 만료가 필요하다고 생각이 들었습니다.

refresh함수를 선언함으로써 admin의 경우에는 '연장' 버튼을 사용하여 토큰 재활성화 하는 방식으로 가면 어떨까 싶어서 변경했습니다.
created_at: 2025-03-10T06:33:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+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) => {
+    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);
+  };
path: apps/admin/src/hooks/use-auth.ts
user: login: SunwooJaeho
body: 저도 생각해보니 admin의 경우에는 직접 갱신을 하는 것이 더 좋을 듯 하네요!! 감사합니다👍👍
created_at: 2025-03-10T06:37:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,48 @@
+/**
+ * 추후 삭제 예정
+ */
+const MOCK_BASE_URL = 'http://58.238.255.245:8080/api/v1';
+
+const MOCK_END_POINT = {
+  HEROES: `${MOCK_BASE_URL}/main/hero-images`,
+  RECENT_NEWS: `${MOCK_BASE_URL}/main/recent-news`,
+  RECENT_NOTICES: `${MOCK_BASE_URL}/main/recent-notices`,
+  LABS: `${MOCK_BASE_URL}/labs`,
+  DEPT: `${MOCK_BASE_URL}/dept`,
+  PROFESSORS: `${MOCK_BASE_URL}/professors`,
+  CLUB: `${MOCK_BASE_URL}/about/club`,
+  CONTACT: `${MOCK_BASE_URL}/about/contact`,
+  MY_PROFILE: `${MOCK_BASE_URL}/my`,
+  BOARD: `${MOCK_BASE_URL}/board`,
+  BOARD_DETAIL: (id: string) => `${MOCK_BASE_URL}/posts/${id}`,
+} as const;
+
+const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL;
+const AUTH_BASE_URL = import.meta.env.VITE_PUBLIC_AUTH_API_URL;
+
+const END_POINT = {
+  POST: 'posts',
+  POST_DETAIL: (id: string) => `posts/${id}`,
+  CAROUSEL: 'carousels',
+  LABS: 'labs',
+  SIGN_IN: 'login',
+  REISSUE: 'reissue',
+  PROFESSORS: 'professors',
+  CLUBS: 'clubs',
+  MY_PROFILE: 'users/my',
+  EDIT_MY_PROFILE: 'users',
+  CHANGE_PASSWORD: 'users/password',
+};
path: apps/admin/src/constants/api.ts
user: login: gwansikk
body: ```suggestion
} as const;
```

created_at: 2025-03-10T13:38:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+/**
+ * 추후 삭제 예정
+ */
+const MOCK_BASE_URL = 'http://58.238.255.245:8080/api/v1';
path: apps/admin/src/constants/api.ts
user: login: gwansikk
body: 🤔
created_at: 2025-03-10T13:43:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-04T15:16:02Z
body: [vc]: #jWiGqB5SV9EoE+OXLSOa3su9eZPLP8plTQaf2QpgF3U=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJhaWNzLWNsaWVudCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2NvbW11bml0eSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJhaWNzLWNsaWVudC1naXQtZmVhdHVyZS1hZG1pbi1sb2dpbi1jb24tZmFkOGM1LXdvbnRvcnktNzM3NDRlODMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vd29udG9yeS03Mzc0NGU4My9haWNzLWNsaWVudC9IRFlQa011S2pMM2szUXplc3VncFBBTW1nTjFrIiwicHJldmlld1VybCI6ImFpY3MtY2xpZW50LWdpdC1mZWF0dXJlLWFkbWluLWxvZ2luLWNvbi1mYWQ4YzUtd29udG9yeS03Mzc0NGU4My52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoiYWljcy1hZG1pbiIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2FkbWluIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImFpY3MtYWRtaW4tZ2l0LWZlYXR1cmUtYWRtaW4tbG9naW4tY29ubmVjdC1hcGktd29udG9yeS03Mzc0NGU4My52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS93b250b3J5LTczNzQ0ZTgzL2FpY3MtYWRtaW4vOU1CQzdaa0dOeXZSbXBEejVtVGtWYWYyeTNmYiIsInByZXZpZXdVcmwiOiJhaWNzLWFkbWluLWdpdC1mZWF0dXJlLWFkbWluLWxvZ2luLWNvbm5lY3QtYXBpLXdvbnRvcnktNzM3NDRlODMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **aics-admin** | ✅ Ready ([Inspect](https://vercel.com/wontory-73744e83/aics-admin/9MBC7ZkGNyvRmpDz5mTkVaf2y3fb)) | [Visit Preview](https://aics-admin-git-feature-admin-login-connect-api-wontory-73744e83.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/aics-admin-git-feature-admin-login-connect-api-wontory-73744e83.vercel.app?via=pr-comment-feedback-link) | Mar 10, 2025 1:44pm |
| **aics-client** | ✅ Ready ([Inspect](https://vercel.com/wontory-73744e83/aics-client/HDYPkMuKjL3k3QzesugpPAMmgN1k)) | [Visit Preview](https://aics-client-git-feature-admin-login-con-fad8c5-wontory-73744e83.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/aics-client-git-feature-admin-login-con-fad8c5-wontory-73744e83.vercel.app?via=pr-comment-feedback-link) | Mar 10, 2025 1:44pm |



author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-03-04T15:16:08Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=kgu-developers/aics-client&utm_content=83):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR