Skip to content
Merged

Dev #194

Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 28 additions & 6 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import axios from "axios";
// const BASE_URL = "http://localhost:8080/api";
export const BASE_URL = "https://api.serverway.shop/api";

// 메모리에 토큰 저장
/**
* Access Token을 메모리에 저장하기 위한 변수 (새로고침 시 초기화됨)
* HttpOnly 쿠키로 Refresh Token을 관리하는 구조이므로 Access Token만 메모리 관리
*/
let accessTokenInMemory: string | null = null;

/**
Expand Down Expand Up @@ -41,10 +44,13 @@ const client = axios.create({
"Content-Type": "application/json",
Accept: "application/json",
},
withCredentials: true, // HttpOnly 쿠키 전송 (Refresh Token)
withCredentials: true, // Refresh Token(HttpOnly Cookie) 자동 전송
});

// 요청 인터셉터
/**
* 요청 인터셉터
* - In-Memory Access Token이 존재하면 Authorization 헤더 자동 추가
*/
client.interceptors.request.use(
(config) => {
if (accessTokenInMemory) {
Expand All @@ -55,35 +61,51 @@ client.interceptors.request.use(
(error) => Promise.reject(error)
);

// 응답 인터셉터
/**
* 응답 인터셉터
*
* - 401(Unauthorized) 발생 시 Refresh Token을 사용해 토큰 재발급 시도
* - 재발급 성공 시 자동으로 원래 요청 재시도
* - 실패 시 Access Token 삭제 및 메인 페이지로 리다이렉트
*/
client.interceptors.response.use(
(response) => response,

async (error) => {
const originalRequest = error.config;

// 401 + 재시도한 적 없는 요청만 처리
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
// Refresh Token은 HttpOnly 쿠키로 자동 전송됨
// Refresh Token은 HttpOnly 쿠키로 자동 포함됨
const response = await axios.post(
`${BASE_URL}/auth/refresh`,
{},
{ withCredentials: true }
);

const newToken = response.data.accessToken;
setAccessToken(newToken); // 메모리에 저장

// 새 Access Token 메모리에 저장
setAccessToken(newToken);

// 원래 요청에 새로운 토큰 적용
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;

return client(originalRequest);
} catch (error) {
console.error("Token refresh failed:", error);

// 토큰 삭제 및 로그아웃 처리
setAccessToken(null);
window.location.href = "/";

return Promise.reject(error);
}
}

return Promise.reject(error);
}
);
Expand Down
8 changes: 7 additions & 1 deletion src/domains/login/api/loginApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* @author 김대호
* @author 김대호 구희원
* @description 로그인 API - 사용자 인증을 처리하는 API 호출
* 아이디와 비밀번호를 서버로 전송하여 인증 토큰 및 사용자 정보 반환
* 응답은 API 표준 형식(status_code, status_message, result)을 따름
Expand All @@ -8,6 +8,9 @@
import client from "@api/client";
import type { LoginRequest, LoginResponse } from "../types/login";

/**
* API 응답 타입 정의
*/
interface LoginApiResponse {
status_code: number;
status_message: string;
Expand All @@ -22,6 +25,9 @@ interface LoginApiResponse {
*/
export const loginApi = async (data: LoginRequest): Promise<LoginResponse> => {
console.log("Using client:", client.defaults.withCredentials);

const response = await client.post<LoginApiResponse>("/auth/login", data);

// API의 result 필드만 반환
return response.data.result;
};
20 changes: 20 additions & 0 deletions src/domains/login/api/refreshApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
/**
* @author 김대호
* @description Access Token 갱신 API 호출 유틸리티
*
* - 서버에 Refresh Token 기반 요청
* - 새 Access Token을 반환
*/

import client from "@api/client";

/**
* @interface RefreshApiResponse
* @description Refresh API 응답 타입
* @property {number} status_code - 상태 코드
* @property {string} status_message - 상태 메시지
* @property {{ accessToken: string }} result - 새로 발급된 Access Token
*/
interface RefreshApiResponse {
status_code: number;
status_message: string;
Expand All @@ -8,6 +23,11 @@ interface RefreshApiResponse {
};
}

/**
* @function refreshTokenApi
* @description Refresh Token으로 새로운 Access Token 발급
* @returns {Promise<string>} - 새 Access Token
*/
export const refreshTokenApi = async (): Promise<string> => {
const response = await client.post<RefreshApiResponse>("/auth/refresh");
return response.data.result.accessToken;
Expand Down
41 changes: 23 additions & 18 deletions src/domains/login/components/AnimationBackground.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
/**
* @author 구희원
* @description 배경 애니메이션용 Circle 컴포넌트 모음
*
* - 여러 Circle 컴포넌트를 지정된 위치, 크기, 색상으로 배치
* - Tailwind CSS를 활용한 위치 및 z-index 관리
* - 화면 전체를 덮는 고정 배경으로 설정
*/

import Circle from "./Circle";
import type { CircleConfig } from "../types/circle";

/**
* 각 Circle의 설정 정보
*/
const CIRCLE_CONFIGS: CircleConfig[] = [
{ id: "1", size: "small", gradient: "blue", position: "top-1/3 left-1/12" },
{
Expand All @@ -10,27 +22,20 @@ const CIRCLE_CONFIGS: CircleConfig[] = [
position: "bottom-1/4 left-1/12",
},
{ id: "3", size: "medium", gradient: "pink", position: "top-1/4 left-1/3" },
{
id: "4",
size: "medium",
gradient: "orange",
position: "top-1/2 left-1/3",
},
{ id: "4", size: "medium", gradient: "orange", position: "top-1/2 left-1/3" },
{ id: "5", size: "small", gradient: "blue", position: "top-1/2 right-1/4" },
{
id: "6",
size: "small",
gradient: "purple",
position: "top-1/2 right-1/7",
},
{
id: "7",
size: "small",
gradient: "pink",
position: "top-1/6 right-1/12",
},
{ id: "6", size: "small", gradient: "purple", position: "top-1/2 right-1/7" },
{ id: "7", size: "small", gradient: "pink", position: "top-1/6 right-1/12" },
];

/**
* 배경 애니메이션 컴포넌트
*
* @returns {JSX.Element} 배경에 고정된 Circle 애니메이션 요소
*
* @example
* <AnimationBackground />
*/
function AnimationBackground() {
return (
<div className="fixed inset-0 -z-10 overflow-hidden">
Expand Down
20 changes: 20 additions & 0 deletions src/domains/login/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
/**
* @author 구희원
* @description 재사용 가능한 버튼 컴포넌트
*
* - 텍스트와 disabled 상태를 받아 버튼 생성
* - Tailwind CSS를 활용한 스타일링
* - 기본 type="submit"
*/

interface ButtonProps {
/** 버튼에 표시될 텍스트 */
text: string;

/** 버튼 활성화 여부 (기본값: false) */
disabled?: boolean;
}

/**
* 버튼 컴포넌트
*
* @param {ButtonProps} props - 버튼 속성
* @returns {JSX.Element} 스타일이 적용된 버튼 요소
*
* @example
* <Button text="생성" disabled={false} />
*/
function Button({ text, disabled }: ButtonProps) {
return (
<button
Expand Down
25 changes: 25 additions & 0 deletions src/domains/login/components/Circle.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
/**
* @author 구희원
* @description 애니메이션 원(Circle) 컴포넌트
*
* - 크기(size), 그라데이션(gradient), 위치(position)를 받아 원형 요소 생성
* - Tailwind CSS 클래스와 커스텀 CSS 애니메이션(animate-twinkle-slow) 적용
* - 배경 그라데이션은 지정된 컬러 팔레트 중 선택
*/

import type { CircleSize, CircleGradient } from "../types/circle";
import "../css/circle.css";

interface CircleProps {
/** 원의 배경 그라데이션 */
gradient: CircleGradient;

/** 화면 내 위치 (Tailwind CSS 클래스 문자열) */
position: string;

/** 원의 크기 */
size: CircleSize;
}

/** Circle 크기 매핑 */
const sizes: Record<CircleSize, string> = {
small: "15vw",
medium: "20vw",
};

/** Circle 그라데이션 색상 매핑 */
const gradients: Record<CircleGradient, string> = {
blue: "linear-gradient(to right, #06b6d4, #14b8a6)",
purple: "linear-gradient(to right, #4F39F6, #9810FA)",
Expand All @@ -21,6 +37,15 @@ const gradients: Record<CircleGradient, string> = {
green: "linear-gradient(to right, #10b981, #06b6d4)",
};

/**
* Circle 컴포넌트
*
* @param {CircleProps} props - Circle 속성
* @returns {JSX.Element} 애니메이션이 적용된 원형 요소
*
* @example
* <Circle size="small" gradient="blue" position="top-1/3 left-1/12" />
*/
const Circle = ({ size, gradient, position }: CircleProps) => {
return (
<div
Expand Down
43 changes: 43 additions & 0 deletions src/domains/login/components/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,60 @@
/**
* @author 구희원
* @description 재사용 가능한 Input 컴포넌트
*
* - 라벨, 아이콘, 에러 메시지, disabled 상태 등을 지원
* - Tailwind 또는 커스텀 CSS(input.css)로 스타일링
* - 외부에서 상태(value)와 onChange 핸들러를 받아 제어 컴포넌트로 사용
*/

import "../css/input.css";

interface InputProps {
/** 입력 필드 placeholder */
placeholder: string;

/** 입력 타입 (기본값: text) */
type?: string;

/** 입력 값 */
value: string;

/** 입력 필드 라벨 */
label: string;

/** 입력 필드 id */
id: string;

/** 값 변경 시 호출되는 핸들러 */
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;

/** 에러 메시지 */
error?: string;

/** 입력 필드 앞에 표시할 아이콘 */
icon: React.ReactNode;

/** 입력 필드 비활성화 여부 */
disabled?: boolean;
}

/**
* Input 컴포넌트
*
* @param {InputProps} props - Input 속성
* @returns {JSX.Element} 라벨, 아이콘, 에러 메시지가 포함된 입력 필드
*
* @example
* <Input
* id="username"
* label="사용자 이름"
* placeholder="이름을 입력하세요"
* value={username}
* onChange={(e) => setUsername(e.target.value)}
* icon={<UserIcon />}
* error={usernameError}
* />
*/
function Input({
placeholder,
type = "text",
Expand Down
Loading
Loading