Skip to content
Merged
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
44 changes: 24 additions & 20 deletions src/api/service/auth-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
import type { AxiosError } from 'axios';

import { baseAPI } from '@/api/core';
import { LoginRequest, LoginResponse, SignupRequest, SignupResponse } from '@/types/service/auth';
import { CommonErrorResponse } from '@/types/service/common';

export const isProblemDetailError = (error: unknown): error is AxiosError<CommonErrorResponse> => {
return (
typeof error === 'object' &&
error !== null &&
'isAxiosError' in error &&
(error as AxiosError).isAxiosError === true
);
};
import { api } from '@/api/core';
import { clearAccessToken, setAccessToken } from '@/lib/auth/token';
import {
LoginRequest,
LoginResponse,
RefreshResponse,
SignupRequest,
SignupResponse,
} from '@/types/service/auth';

export const authServiceRemote = () => ({
// 로그인
login: async (payload: LoginRequest): Promise<LoginResponse> => {
const { data } = await baseAPI.post<LoginResponse>('/api/v1/auth/login', payload);
const data = await api.post<LoginResponse>('/auth/login', payload);

setAccessToken(data.accessToken, data.expiresIn);
return data;
},

// 회원가입
signup: async (payload: SignupRequest): Promise<SignupResponse> => {
const { data } = await baseAPI.post<SignupResponse>('/api/v1/auth/signup', payload);
return data;
},
signup: async (payload: SignupRequest): Promise<SignupResponse> =>
api.post<SignupResponse>(`/auth/signup`, payload),

// 로그아웃
logout: async (): Promise<void> => {
await baseAPI.post('/api/v1/auth/logout');
await api.post<void>('/auth/logout');
clearAccessToken();
},

// 액세스 토큰 재발급
refresh: async (): Promise<RefreshResponse> => {
const data = await api.post<RefreshResponse>('/auth/refresh');

setAccessToken(data.accessToken, data.expiresIn);
return data;
},
});
15 changes: 5 additions & 10 deletions src/components/pages/login/login-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
import { type AnyFieldApi, useForm } from '@tanstack/react-form';

import { API } from '@/api';
import { isProblemDetailError } from '@/api/service';
import { FormInput } from '@/components/shared';
import { Button } from '@/components/ui';
import { loginSchema } from '@/lib/schema/auth';
import { CommonErrorResponse } from '@/types/service/common';

const getHintMessage = (field: AnyFieldApi) => {
const {
Expand Down Expand Up @@ -47,15 +47,10 @@ export const LoginForm = () => {
formApi.reset();
router.push('/');
} catch (error) {
if (isProblemDetailError(error) && error.response?.data) {
const problem = error.response.data;

console.error('[LOGIN ERROR]', problem.errorCode, problem.detail);
alert(problem.detail || '로그인에 실패했습니다.');
} else {
console.error(error);
alert('알 수 없는 오류가 발생했습니다.');
}
const err = error as CommonErrorResponse;

console.error('[LOGIN ERROR]', err.errorCode, err.detail);
alert(err.detail || '로그인에 실패했습니다.');
}
},
});
Expand Down
15 changes: 5 additions & 10 deletions src/components/pages/signup/signup-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
import { type AnyFieldApi, useForm } from '@tanstack/react-form';

import { API } from '@/api';
import { isProblemDetailError } from '@/api/service';
import { FormInput } from '@/components/shared';
import { Button } from '@/components/ui';
import { signupSchema } from '@/lib/schema/auth';
import { CommonErrorResponse } from '@/types/service/common';

const getHintMessage = (field: AnyFieldApi) => {
const {
Expand Down Expand Up @@ -50,15 +50,10 @@ export const SignupForm = () => {
formApi.reset();
router.push('/login');
} catch (error) {
if (isProblemDetailError(error) && error.response?.data) {
const problem = error.response.data;

console.error('[SIGNUP ERROR]', problem.errorCode, problem.detail);
alert(problem.detail || '회원가입에 실패했습니다.');
} else {
console.error(error);
alert('알 수 없는 오류가 발생했습니다.');
}
const err = error as CommonErrorResponse;

console.error('[SIGNUP ERROR]', err.errorCode, err.detail);
alert(err.detail || '회원가입에 실패했습니다.');
}
},
});
Expand Down
19 changes: 19 additions & 0 deletions src/lib/auth/token.ts
Copy link
Member

Choose a reason for hiding this comment

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

getAccessToken과 clearAccessToken은 클라이언트에서만 사용되어야 하니까 서버 환경일 때는 error를 던져주면 디버깅에 용이할 것 같네요!

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const ACCESS_TOKEN_KEY = 'accessToken';

export const setAccessToken = (token: string, maxAgeSeconds?: number) => {
if (typeof document === 'undefined') return;

const parts = [`${ACCESS_TOKEN_KEY}=${encodeURIComponent(token)}`, 'path=/'];

if (typeof maxAgeSeconds === 'number' && maxAgeSeconds > 0) {
parts.push(`Max-Age=${maxAgeSeconds}`);
}

document.cookie = parts.join('; ');
};

export const clearAccessToken = () => {
if (typeof document === 'undefined') return;

document.cookie = `${ACCESS_TOKEN_KEY}=; Max-Age=0; path=/`;
};
106 changes: 72 additions & 34 deletions src/mock/service/auth/auth-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,104 @@
import { http, HttpResponse } from 'msw';

import { LoginRequest, LoginResponse, SignupRequest } from '@/types/service/auth';
import { CommonErrorResponse } from '@/types/service/common';
import {
LoginRequest,
LoginResponse,
RefreshResponse,
SignupRequest,
SignupResponse,
} from '@/types/service/auth';

import { createMockErrorResponse, createMockSuccessResponse } from '../common/common-mock';
import {
createLoginResponse,
createSignupResponse,
isEmailTaken,
isNicknameTaken,
} from './auth-utils';

// 회원가입
const signupMock = http.post('*/api/v1/auth/signup', async ({ request }) => {
const body = (await request.json()) as SignupRequest;

if (isEmailTaken(body.email)) {
const errorBody: CommonErrorResponse = {
type: 'https://example.com/errors/email-duplicate',
title: 'EMAIL_DUPLICATE',
status: 400,
detail: '이미 존재하는 이메일입니다.',
instance: '/api/v1/auth/signup',
errorCode: 'A002',
};
return HttpResponse.json<CommonErrorResponse>(errorBody, { status: 400 });
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '이미 존재하는 이메일입니다.',
errorCode: 'A002',
}),
{ status: 400 },
);
}

if (isNicknameTaken(body.nickName)) {
const errorBody: CommonErrorResponse = {
type: 'https://example.com/errors/nickname-duplicate',
title: 'NICKNAME_DUPLICATE',
status: 400,
detail: '이미 존재하는 닉네임입니다.',
instance: '/api/v1/auth/signup',
errorCode: 'A003',
};
return HttpResponse.json<CommonErrorResponse>(errorBody, { status: 400 });
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '이미 존재하는 닉네임입니다.',
errorCode: 'A003',
}),
{ status: 400 },
);
}

const response = createSignupResponse(body.email, body.nickName, body.password);
return HttpResponse.json(response, { status: 201 });

return HttpResponse.json(createMockSuccessResponse<SignupResponse>(response));
});

// 로그인
const loginMock = http.post('*/api/v1/auth/login', async ({ request }) => {
const body = (await request.json()) as LoginRequest;

try {
const response = createLoginResponse(body.email, body.password);
return HttpResponse.json<LoginResponse>(response, { status: 200 });

return HttpResponse.json(createMockSuccessResponse<LoginResponse>(response), {
headers: {
'Set-Cookie': 'refreshToken=mock-refresh-token; Path=/; HttpOnly; SameSite=Strict; Secure',
},
});
} catch {
const errorBody: CommonErrorResponse = {
type: 'https://example.com/errors/invalid-credentials',
title: 'INVALID_CREDENTIALS',
status: 400,
detail: '이메일 또는 비밀번호가 올바르지 않습니다.',
instance: '/api/v1/auth/login',
errorCode: 'A001',
};
return HttpResponse.json(errorBody, { status: 400 });
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '이메일 또는 비밀번호가 올바르지 않습니다.',
errorCode: 'A001',
}),
{ status: 400 },
);
}
});

const logoutMock = http.post('*/api/v1/auth/logout', async ({}) => {
return new HttpResponse(null, { status: 204 });
// 로그아웃
const logoutMock = http.post('*/api/v1/auth/logout', async () => {
return HttpResponse.json(createMockSuccessResponse<void>(undefined));
});

// 액세스 토큰 재발급
const refreshMock = http.post('*/api/v1/auth/refresh', async ({ cookies }) => {
const refreshToken = cookies['refreshToken'];

if (!refreshToken) {
return HttpResponse.json(
createMockErrorResponse({
status: 401,
detail: '리프레시 토큰이 없습니다.',
errorCode: 'A004',
}),
{ status: 401 },
);
}

const response: RefreshResponse = {
accessToken: 'refreshed-mock-access-token',
tokenType: 'Bearer',
expiresIn: 3600,
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
};

return HttpResponse.json(createMockSuccessResponse<RefreshResponse>(response));
});

export const authHandlers = [signupMock, loginMock, logoutMock];
export const authHandlers = [signupMock, loginMock, logoutMock, refreshMock];
1 change: 0 additions & 1 deletion src/mock/service/auth/auth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const findUserByCredentials = (email: string, password: string) =>

const createMockTokens = () => ({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
tokenType: 'Bearer' as const,
expiresIn: 3600,
});
Expand Down
8 changes: 7 additions & 1 deletion src/types/service/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export interface LoginRequest {

export interface LoginResponse {
accessToken: string;
refreshToken: string;
tokenType: 'Bearer';
expiresIn: number;
user: {
Expand All @@ -27,3 +26,10 @@ export interface LoginResponse {
nickName: string;
};
}

export interface RefreshResponse {
accessToken: string;
tokenType: 'Bearer';
expiresIn: number;
expiresAt: string;
}