Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a1af2d4
chore: 라우트 path 상수 추가
Oct 13, 2025
7e414df
feat: router 경로 추가
Oct 13, 2025
1a50170
feat: api 로그인의 Callback 페이지 추가
Oct 13, 2025
bbf1fba
chore: 회원 가입 button 추가
Oct 13, 2025
518f673
feat: 동아리 운영진 회원가입 페이지 추가
Oct 13, 2025
8b3c0f7
feat: 동아리 관리자 회원가입 api 함수 추가
Oct 13, 2025
65ef832
feat: 회원가입 from 입력 Type 추가
Oct 13, 2025
d9c7db2
feat: 관리자 등록 페이지 Header title 추가
Oct 14, 2025
a2a1b3a
refactor: 관리자 페이지 component 분리
Oct 14, 2025
423de87
feat: 관리자 등록페이지 route 추가
Oct 14, 2025
012070d
refactor: 관리자 등록페이지 form 컴포넌트 분리
Oct 14, 2025
1fa9b28
chore: import/order
Oct 14, 2025
0e9761b
chore: 오타수정(SING->SIGN)
Oct 14, 2025
ce9808e
chore: Button type 변경
Oct 14, 2025
35a217a
chore: 불필요한 @medial 제거
Oct 14, 2025
ecbeede
chore: API path 로컬환경 -> 배포환경으로 설정
Oct 14, 2025
84ae31b
chore: 주석 수정
Oct 14, 2025
09e8a79
chore: refreshToken 관련 주석 수정
Oct 14, 2025
89cd48b
chore: 중복된 toast 알림 제거
Oct 14, 2025
8514849
chore: 로그인 입력 필드 주석처리
Oct 14, 2025
3e354c9
chore: 로그인 부분 제거
Oct 14, 2025
44ccd1c
chore: import/order 적용
Oct 14, 2025
31ee8ac
chore: error nam 수정
Oct 14, 2025
26dc7ab
chore : catch 문 내부에서 error 출력
Oct 14, 2025
ddbf011
feat: 공통 에러 응답 interface 추가
Oct 16, 2025
4c751d1
feat: 관리자 회원가입 api 추가
Oct 16, 2025
4ddcf0e
feat: 회원가입을 위한 임시 토큰 추가
Oct 16, 2025
efa9b9f
feat: 카카오 콜백 (=redirect) 페이지추가 작업 반영
Oct 16, 2025
9868180
feat: Axios 인스턴스 생성 함수 추가
Oct 16, 2025
15dac1a
Merge branch 'develop' of https://github.com/kakao-tech-campus-3rd-st…
Oct 16, 2025
4b43dac
chore: import/order 적용
Oct 16, 2025
91557e4
chore: api path 로컬 -> 배포 환경 적용
Oct 16, 2025
4505c99
chore : setTime 제거, onAutoClose 적용
Oct 16, 2025
2f2eeaf
chore: catch문 내부의 any타입 제거
Oct 16, 2025
e3e78de
refactor: token 저장 위치 변경
Oct 17, 2025
00059ac
refactor: Callback 페이지의 API Layer 분리
Oct 17, 2025
d6de32c
refactor: accessToken 저장 부분 분리
Oct 17, 2025
d8b2fa2
refactor: utils 함수를 통해 token을 저장하고 가져오도록 수정
Oct 17, 2025
fbbed2a
chore: import/order 적용
Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/pages/admin/ClubDetailEdit/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,8 @@ export const ClubDetailEditPage = () => {
color: 'white',
},
duration: 1000,
onAutoClose: () => navigate(`/clubs/${clubId}`),
});

setTimeout(() => {
navigate(`/clubs/${clubId}`);
}, 1000);
})
.catch(() => {
toast.error('수정 실패!', {
Expand Down
58 changes: 36 additions & 22 deletions src/pages/admin/Login/KakaoCallback.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,55 @@
import axios from 'axios';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { LoadingSpinner } from '@/shared/components/LoadingSpinner';
import { apiInstance } from './api/initInstance';
import type { ErrorResponse } from '../Signup/type/error';
import type { AxiosError, AxiosResponse } from 'axios';

interface LoginSuccessResponse {
status: 'LOGIN_SUCCESS';
accessToken: string;
refreshToken: string;
}

interface RegistrationRequiredResponse {
status: 'REGISTRATION_REQUIRED';
temporaryToken: string;
}

type LoginResponse = LoginSuccessResponse | RegistrationRequiredResponse;

export const KakaoCallback = () => {
const navigate = useNavigate();

useEffect(() => {
const code = new URL(window.location.href).searchParams.get('code');
if (!code) return;

console.log(code);
if (!code) {
navigate('/login');
return;
}

const fetchToken = async () => {
try {
const res = axios.post(`${import.meta.env.VITE_API_BASE_URL}/auth/kakao/login`, {
const res: AxiosResponse<LoginResponse> = await apiInstance.post('/auth/kakao/login', {
authorizationCode: code,
});
console.log('응답res ', res);

// CASE 1) 기존 회원

// 1-1. accessToken, refreshToken 발급
// localStorage.setItem('accessToken', res.data.accessToken);
// localStorage.setItem('refreshToken ', res.data.refreshToken)- (수정전)
// refreshToken은 httpOnly 관리(수정후)
// ------------------------------------------------------------
// 2-2 main 페이지 이동
// navigate('/'); // 로그인 후 홈으로 이동

// CASE 2) 기존 회원
// 2-1. 임시 토큰
// 2-2. navigate('/signup')
} catch (error) {
console.log('error:', error);

switch (res.data.status) {
case 'LOGIN_SUCCESS':
localStorage.setItem('accessToken', res.data.accessToken);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

accessTokenlocalStorage에 저장하고 있습니다. 하지만 src/pages/admin/Signup/api/signup.ts에서는 sessionStorage에 저장하고 있어 토큰 저장 방식에 일관성이 없습니다. 애플리케이션의 세션 관리 정책에 따라 localStorage 또는 sessionStorage로 통일하는 것이 좋습니다.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Inconsistent token storage breaks authentication.

Line 40 stores accessToken in localStorage, but src/pages/admin/Signup/api/signup.ts (Line 21) uses sessionStorage. This inconsistency will cause authentication failures when tokens are retrieved from the wrong storage location.

Standardize on one storage mechanism. If the app should persist across browser sessions, use localStorage consistently:

 // In src/pages/admin/Signup/api/signup.ts, Line 21
-    sessionStorage.setItem('accessToken', response.data.accessToken);
+    localStorage.setItem('accessToken', response.data.accessToken);

Or if sessions should end when the browser closes, use sessionStorage consistently:

-            localStorage.setItem('accessToken', res.data.accessToken);
+            sessionStorage.setItem('accessToken', res.data.accessToken);

Also applies to: 21-21

🤖 Prompt for AI Agents
In src/pages/admin/Login/KakaoCallback.tsx around line 40, the code stores
accessToken in localStorage while src/pages/admin/Signup/api/signup.ts (line 21)
uses sessionStorage; change the storage call here from
localStorage.setItem('accessToken', ...) to
sessionStorage.setItem('accessToken', ...) (or alternatively change the signup
file if you decide to standardize on localStorage) and then search the repo for
other accessToken/session token reads/writes and make them consistent to the
chosen storage mechanism.

navigate('/');
break;
Comment on lines 38 to 41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

LoginSuccessResponserefreshToken이 포함되어 있지만, 저장되지 않고 있습니다. 애플리케이션이 리프레시 토큰을 사용하여 세션을 유지하도록 설계되었다면, refreshTokenlocalStorage나 안전한 쿠키 등에 저장해야 합니다.

Suggested change
case 'LOGIN_SUCCESS':
localStorage.setItem('accessToken', res.data.accessToken);
navigate('/');
break;
case 'LOGIN_SUCCESS':
localStorage.setItem('accessToken', res.data.accessToken);
localStorage.setItem('refreshToken', res.data.refreshToken);
navigate('/');
break;

case 'REGISTRATION_REQUIRED':
localStorage.setItem('temporaryToken', res.data.temporaryToken);
navigate('/signup');
break;
}
} catch (e) {
const error = e as AxiosError<ErrorResponse>;
return new Error(error.response?.data.message);
}
Comment on lines 47 to 50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

catch 블록의 에러 처리가 올바르지 않습니다. useEffect 내부의 비동기 함수에서 new Error()를 반환해도 아무런 효과가 없으며, 에러 발생 시 사용자는 로딩 화면에 계속 머물게 됩니다. 에러를 사용자에게 알리고 로그인 페이지 등으로 리디렉션해야 합니다.

      } catch (e) {
        const error = e as AxiosError<ErrorResponse>;
        // TODO: 사용자에게 에러 메시지 표시 (e.g., toast)
        console.error(error.response?.data.message ?? '로그인 중 오류가 발생했습니다.');
        navigate('/login');
      }

};

fetchToken();
}, [navigate]);

Expand Down
20 changes: 20 additions & 0 deletions src/pages/admin/Login/api/initInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import axios from 'axios';
import type { AxiosInstance, CreateAxiosDefaults } from 'axios';

const initInstance = (config: CreateAxiosDefaults): AxiosInstance => {
const instance = axios.create({
timeout: 10000,
headers: {
'Content-Type': 'application/json',
...config.headers,
},
// TODO 0. interceptor 적용지점(동아리 운영자)
...config,
});

return instance;
};

export const apiInstance = initInstance({
baseURL: import.meta.env.VITE_API_BASE_URL,
});
46 changes: 35 additions & 11 deletions src/pages/admin/Signup/api/signup.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import axios, { AxiosError, type AxiosResponse } from 'axios';
import { apiInstance } from '../../Login/api/initInstance';
import type { ErrorResponse } from '../type/error';
import type { SignupFormInputs } from '../type/signup';

export const postSignupForm = async (formData: SignupFormInputs): Promise<SignupFormInputs> => {
const url = `${import.meta.env.VITE_API_BASE_URL}/auth/register`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
export interface RegisterSuccessResponse {
status: 'REGISTER_SUCCESS';
accessToken: string;
refreshToken: string;
}

if (!response.ok) throw new Error('회원 가입 양식을 제출하지 못했습니다.');
return await response.json();
export const postSignupForm = async (formData: SignupFormInputs, tempToken: string) => {
try {
const response: AxiosResponse<RegisterSuccessResponse> = await apiInstance.post(
'/auth/register',
formData,
{
headers: { Authorization: `Bearer ${tempToken}` },
},
);
sessionStorage.setItem('accessToken', response.data.accessToken);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

RegisterSuccessResponserefreshToken이 포함되어 있지만 저장하고 있지 않습니다. accessTokensessionStorage에 저장하고 있습니다. 애플리케이션이 리프레시 토큰을 사용하여 세션을 관리한다면 refreshToken도 함께 저장해야 합니다.

    sessionStorage.setItem('accessToken', response.data.accessToken);
    sessionStorage.setItem('refreshToken', response.data.refreshToken);

} catch (e: unknown) {
if (axios.isAxiosError(e)) {
const error = e as AxiosError<ErrorResponse>;
const status = error.response?.status;
const detailMsg = error.response?.data.detail;
switch (status) {
case 400:
throw new Error(`입력 오류: ${detailMsg}`);
case 401:
throw new Error(`권한 오류: ${detailMsg}`);
case 409:
throw new Error(`중복 오류: ${detailMsg}`);
default:
throw new Error(`알 수 없는 오류: ${e.message}`);
}
}
}
Comment on lines 25 to 42
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Non-Axios errors are not propagated.

The error handling only catches Axios errors. Network failures, timeouts, or other non-Axios errors will fall through silently without notifying the caller.

Apply this diff to ensure all errors are properly propagated:

       }
+      throw new Error('알 수 없는 오류가 발생했습니다.');
     }
   }
 };
🤖 Prompt for AI Agents
In src/pages/admin/Signup/api/signup.ts around lines 22 to 38, the catch block
only handles Axios errors and silently swallows non-Axios exceptions; update the
catch to rethrow non-Axios errors so callers are notified: after the
axios.isAxiosError branch, add a fallback that throws the original error (or
converts unknown to an Error via new Error(String(e))) so network failures,
timeouts, or other exceptions are propagated; keep existing Axios-specific
switch logic intact.

};
33 changes: 19 additions & 14 deletions src/pages/admin/Signup/components/SignupForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,33 @@ export const SignupForm = () => {
phoneNumber: '',
},
});

const { errors, isSubmitting } = methods.formState;

const onSubmit = async (signupFormValue: SignupFormInputs) => {
const temporaryToken = localStorage.getItem('temporaryToken');

if (!temporaryToken) {
toast.error('회원가입을 위한 토큰이 존재하지 않습니다.');
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove temporaryToken after successful signup.

The guard logic correctly prevents submission when the token is missing. However, after successful signup (when postSignupForm completes and stores the accessToken in sessionStorage), the temporaryToken should be removed from localStorage to prevent reuse and maintain clean state.

Add cleanup after line 34:

     try {
       await postSignupForm(signupFormValue, temporaryToken);
+      localStorage.removeItem('temporaryToken');
       toast.success('회원가입 완료!', {

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/pages/admin/Signup/components/SignupForm/index.tsx around lines 26-31
(and after line 34), the temporaryToken is read from localStorage but never
removed after a successful signup; update the success path after postSignupForm
completes (where you store accessToken in sessionStorage) to call
localStorage.removeItem('temporaryToken') so the token is cleaned up only on
successful signup, leaving failure/error paths unchanged.


try {
await postSignupForm(signupFormValue);
await postSignupForm(signupFormValue, temporaryToken);
toast.success('회원가입 완료!', {
style: { backgroundColor: theme.colors.primary, color: 'white' },
duration: 1000,
onAutoClose: () => navigate('/'),
});
setTimeout(() => {
navigate(`/login`);
}, 1000);
} catch (e) {
console.error(e);
toast.error('회원가입 실패!', {
duration: 1000,
style: {
backgroundColor: 'white',
color: theme.colors.error,
},
});
} catch (e: unknown) {
if (e instanceof Error) {
toast.error(e.message, {
duration: 1000,
style: {
backgroundColor: 'white',
color: theme.colors.error,
},
});
}
}
};

Expand Down
5 changes: 5 additions & 0 deletions src/pages/admin/Signup/type/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ErrorResponse {
status: number;
message: string;
detail: string;
}
4 changes: 1 addition & 3 deletions src/pages/user/Apply/components/ApplicationForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,8 @@ export const ApplicationForm = ({ questions }: Props) => {
color: 'white',
},
duration: 1000,
onAutoClose: () => navigate(`/clubs/${clubIdNumber}`),
});
setTimeout(() => {
navigate(`/clubs/${clubIdNumber}`);
}, 1000);
})
.catch(() => {
toast.error('제출 실패!', {
Expand Down