Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
29 changes: 25 additions & 4 deletions src/api/service/auth-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { AxiosError } from 'axios';

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

export const isProblemDetailError = (error: unknown): error is AxiosError<CommonErrorResponse> => {
Expand All @@ -16,18 +23,32 @@ export const isProblemDetailError = (error: unknown): error is AxiosError<Common
export const authServiceRemote = () => ({
// 로그인
login: async (payload: LoginRequest): Promise<LoginResponse> => {
const { data } = await baseAPI.post<LoginResponse>('/api/v1/auth/login', payload);
const { data } = await baseAPI.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);
const { data } = await baseAPI.post<SignupResponse>('/auth/signup', payload);
return data;
},

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

// 액세스 토큰 재발급
refresh: async () => {
const { data } = await baseAPI.post<RefreshResponse>('/auth/refresh');
setAccessToken(data.accessToken, data.expiresIn);

return data;
},
});
5 changes: 5 additions & 0 deletions src/components/pages/login/login-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import { useRouter } from 'next/navigation';

import { type AnyFieldApi, useForm } from '@tanstack/react-form';
import { useQueryClient } from '@tanstack/react-query';

import { API } from '@/api';
import { isProblemDetailError } from '@/api/service';
import { FormInput } from '@/components/shared';
import { Button } from '@/components/ui';
import { userKeys } from '@/lib/query-key/query-key-user';
import { loginSchema } from '@/lib/schema/auth';

const getHintMessage = (field: AnyFieldApi) => {
Expand All @@ -24,6 +26,7 @@ const getHintMessage = (field: AnyFieldApi) => {

export const LoginForm = () => {
const router = useRouter();
const queryClient = useQueryClient();

const form = useForm({
defaultValues: {
Expand All @@ -44,6 +47,8 @@ export const LoginForm = () => {
const result = await API.authService.login(payload);
console.log('login success:', result);

queryClient.invalidateQueries({ queryKey: userKeys.all });
Copy link
Member

Choose a reason for hiding this comment

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

로그인/회원가입은 직접 api 호출을 하고 있는 것 같은데, 맞다면 React Query를 사용하지 않는 거니까 해당 로직은 필요없어보입니다 :)

React Query를 사용하더라도 invalidateQueries의 용도는 useQuery 로 get한 데이터가 stale 됐다고 강제 변경한 뒤 데이터를 다시 refetch 해오는 용도로 쓰기 때문에 login에서는 필요 없을 것 같아요!


formApi.reset();
router.push('/');
} catch (error) {
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=/`;
};
43 changes: 40 additions & 3 deletions src/mock/service/auth/auth-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';

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

import {
Expand Down Expand Up @@ -45,7 +45,13 @@ const loginMock = http.post('*/api/v1/auth/login', async ({ request }) => {

try {
const response = createLoginResponse(body.email, body.password);
return HttpResponse.json<LoginResponse>(response, { status: 200 });
return HttpResponse.json<LoginResponse>(response, {
status: 200,
headers: {
'Set-Cookie':
'refreshToken=mock-refresh-token; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict',
},
});
} catch {
const errorBody: CommonErrorResponse = {
type: 'https://example.com/errors/invalid-credentials',
Expand All @@ -63,4 +69,35 @@ const logoutMock = http.post('*/api/v1/auth/logout', async ({}) => {
return new HttpResponse(null, { status: 204 });
});

export const authHandlers = [signupMock, loginMock, logoutMock];
const refreshMock = http.post<RefreshResponse | CommonErrorResponse>(
'*/api/v1/auth/refresh',
async ({ cookies }) => {
const refreshToken = cookies.refreshToken;

if (!refreshToken || refreshToken !== 'mock-refresh-token') {
const errorBody: CommonErrorResponse = {
type: 'https://example.com/errors/invalid-refresh-token',
title: 'INVALID_REFRESH_TOKEN',
status: 401,
detail: '리프레시 토큰이 유효하지 않습니다.',
instance: '/api/v1/auth/refresh',
errorCode: 'A004',
};
return HttpResponse.json<CommonErrorResponse>(errorBody, { status: 401 });
}

const now = Date.now();
const expiresIn = 3600;

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

return HttpResponse.json<RefreshResponse>(response, { status: 200 });
},
);

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;
}