diff --git a/package.json b/package.json index 1fae7706..44498c92 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jest-fixed-jsdom": "^0.0.11", + "js-cookie": "^3.0.5", "motion": "^12.23.24", "next": "16.0.7", "react": "19.2.1", @@ -76,6 +77,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05f5b2a3..197ff077 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: jest-fixed-jsdom: specifier: ^0.0.11 version: 0.0.11(jest-environment-jsdom@30.2.0) + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 motion: specifier: ^12.23.24 version: 12.23.24(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -102,6 +105,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^20 version: 20.19.21 @@ -2562,6 +2568,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -4855,6 +4864,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -10356,6 +10369,8 @@ snapshots: expect: 30.2.0 pretty-format: 30.2.0 + '@types/js-cookie@3.0.6': {} + '@types/jsdom@21.1.7': dependencies: '@types/node': 20.19.21 @@ -13178,6 +13193,8 @@ snapshots: jiti@2.6.1: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: diff --git a/src/api/service/auth-service/index.ts b/src/api/service/auth-service/index.ts index f170f5f2..821d349e 100644 --- a/src/api/service/auth-service/index.ts +++ b/src/api/service/auth-service/index.ts @@ -10,7 +10,7 @@ import { export const authServiceRemote = () => ({ // 로그인 - login: async (payload: LoginRequest): Promise => { + login: async (payload: LoginRequest) => { const data = await api.post('/auth/login', payload); setAccessToken(data.accessToken, data.expiresIn); @@ -18,20 +18,25 @@ export const authServiceRemote = () => ({ }, // 회원가입 - signup: async (payload: SignupRequest): Promise => - api.post(`/auth/signup`, payload), + signup: (payload: SignupRequest) => api.post(`/auth/signup`, payload), // 로그아웃 - logout: async (): Promise => { + logout: async () => { await api.post('/auth/logout'); clearAccessToken(); }, // 액세스 토큰 재발급 - refresh: async (): Promise => { + refresh: async () => { const data = await api.post('/auth/refresh'); setAccessToken(data.accessToken, data.expiresIn); return data; }, + + // 회원 탈퇴 + withdraw: async () => { + await api.delete('/auth/withdraw'); + clearAccessToken(); + }, }); diff --git a/src/app/login/_temp/login-temp-actions.tsx b/src/app/login/_temp/login-temp-actions.tsx new file mode 100644 index 00000000..e210e745 --- /dev/null +++ b/src/app/login/_temp/login-temp-actions.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { MyPageActionButton } from '@/components/pages/user/mypage/mypage-setting-button'; +import { useLogout, useWithdraw } from '@/hooks/use-auth'; + +const LoginTempActions = () => { + const logout = useLogout(); + const withdraw = useWithdraw(); + + return ( +
+ 로그아웃 + 회원탈퇴 +
+ ); +}; + +export default LoginTempActions; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5ebb9f5e..2abab9fe 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,8 +1,20 @@ +import { cookies } from 'next/headers'; + +// import { redirect } from 'next/navigation'; import { Icon } from '@/components/icon'; import { LoginForm } from '@/components/pages/login'; import { AuthSwitch } from '@/components/shared'; -const LoginPage = () => { +import LoginTempActions from './_temp/login-temp-actions'; + +const LoginPage = async () => { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + // if (accessToken) { + // redirect('/'); + // } + return (
@@ -10,6 +22,8 @@ const LoginPage = () => {
+ {/* 📜 임시, 삭제 예정 */} + {accessToken && }
); }; diff --git a/src/components/pages/login/login-form/index.tsx b/src/components/pages/login/login-form/index.tsx index ce14eeec..b2783ff0 100644 --- a/src/components/pages/login/login-form/index.tsx +++ b/src/components/pages/login/login-form/index.tsx @@ -1,14 +1,11 @@ 'use client'; -import { useRouter } from 'next/navigation'; - import { type AnyFieldApi, useForm } from '@tanstack/react-form'; -import { API } from '@/api'; import { FormInput } from '@/components/shared'; import { Button } from '@/components/ui'; +import { useLogin } from '@/hooks/use-auth'; import { loginSchema } from '@/lib/schema/auth'; -import { CommonErrorResponse } from '@/types/service/common'; const getHintMessage = (field: AnyFieldApi) => { const { @@ -23,7 +20,7 @@ const getHintMessage = (field: AnyFieldApi) => { }; export const LoginForm = () => { - const router = useRouter(); + const login = useLogin(); const form = useForm({ defaultValues: { @@ -35,23 +32,12 @@ export const LoginForm = () => { onChange: loginSchema, }, onSubmit: async ({ value, formApi }) => { - try { - const payload = { - email: value.email, - password: value.password, - }; - - const result = await API.authService.login(payload); - console.log('login success:', result); - - formApi.reset(); - router.push('/'); - } catch (error) { - const err = error as CommonErrorResponse; + const payload = { + email: value.email, + password: value.password, + }; - console.error('[LOGIN ERROR]', err.errorCode, err.detail); - alert(err.detail || '로그인에 실패했습니다.'); - } + await login(payload, formApi); }, }); @@ -93,6 +79,7 @@ export const LoginForm = () => { hintMessage={hintMessage} inputProps={{ type: 'password', + autoComplete: 'current-password', placeholder: '비밀번호를 입력해주세요', value: field.state.value, onChange: (e) => field.handleChange(e.target.value), diff --git a/src/components/pages/signup/signup-form/index.tsx b/src/components/pages/signup/signup-form/index.tsx index 32cc7893..82c0a14e 100644 --- a/src/components/pages/signup/signup-form/index.tsx +++ b/src/components/pages/signup/signup-form/index.tsx @@ -1,14 +1,11 @@ 'use client'; -import { useRouter } from 'next/navigation'; - import { type AnyFieldApi, useForm } from '@tanstack/react-form'; -import { API } from '@/api'; import { FormInput } from '@/components/shared'; import { Button } from '@/components/ui'; +import { useSignup } from '@/hooks/use-auth'; import { signupSchema } from '@/lib/schema/auth'; -import { CommonErrorResponse } from '@/types/service/common'; const getHintMessage = (field: AnyFieldApi) => { const { @@ -23,7 +20,7 @@ const getHintMessage = (field: AnyFieldApi) => { }; export const SignupForm = () => { - const router = useRouter(); + const signup = useSignup(); const form = useForm({ defaultValues: { @@ -37,24 +34,13 @@ export const SignupForm = () => { onSubmit: signupSchema, }, onSubmit: async ({ value, formApi }) => { - try { - const payload = { - email: value.email, - password: value.password, - nickName: value.nickname, - }; - - const result = await API.authService.signup(payload); - console.log('signup success:', result); - - formApi.reset(); - router.push('/login'); - } catch (error) { - const err = error as CommonErrorResponse; - - console.error('[SIGNUP ERROR]', err.errorCode, err.detail); - alert(err.detail || '회원가입에 실패했습니다.'); - } + const payload = { + email: value.email, + password: value.password, + nickName: value.nickname, + }; + + await signup(payload, formApi); }, }); @@ -76,6 +62,7 @@ export const SignupForm = () => { hintMessage={hintMessage} inputProps={{ type: 'email', + autoComplete: 'email', placeholder: '이메일을 입력해주세요', value: field.state.value, onChange: (e) => field.handleChange(e.target.value), @@ -94,6 +81,7 @@ export const SignupForm = () => { field.handleChange(e.target.value), @@ -113,6 +101,7 @@ export const SignupForm = () => { hintMessage={hintMessage} inputProps={{ type: 'password', + autoComplete: 'new-password', placeholder: '비밀번호를 입력해주세요', value: field.state.value, onChange: (e) => field.handleChange(e.target.value), @@ -132,6 +121,7 @@ export const SignupForm = () => { hintMessage={hintMessage} inputProps={{ type: 'password', + autoComplete: 'new-password', placeholder: '비밀번호를 한 번 더 입력해주세요', value: field.state.value, onChange: (e) => field.handleChange(e.target.value), diff --git a/src/hooks/use-auth/index.ts b/src/hooks/use-auth/index.ts new file mode 100644 index 00000000..e5bd3fc3 --- /dev/null +++ b/src/hooks/use-auth/index.ts @@ -0,0 +1,4 @@ +export { useLogin } from './use-auth-login'; +export { useLogout } from './use-auth-logout'; +export { useSignup } from './use-auth-signup'; +export { useWithdraw } from './use-auth-withdraw'; diff --git a/src/hooks/use-auth/use-auth-login/index.ts b/src/hooks/use-auth/use-auth-login/index.ts new file mode 100644 index 00000000..bc50a838 --- /dev/null +++ b/src/hooks/use-auth/use-auth-login/index.ts @@ -0,0 +1,45 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { AxiosError } from 'axios'; +import Cookies from 'js-cookie'; + +import { API } from '@/api'; +import { LoginRequest } from '@/types/service/auth'; +import { CommonErrorResponse } from '@/types/service/common'; + +export const useLogin = () => { + const router = useRouter(); + + const handleLogin = async (payload: LoginRequest, formApi: { reset: () => void }) => { + try { + const result = await API.authService.login(payload); + // 📜 추후 삭제 + console.log('login success:', result); + + Cookies.set('userId', String(result.user.userId), { + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + + formApi.reset(); + router.push('/'); + } catch (error) { + const axiosError = error as AxiosError; + const problem = axiosError.response?.data; + + // 📜 에러 UI 결정나면 변경 + if (problem) { + console.error('[LOGIN ERROR]', problem.errorCode, problem.detail); + alert(problem.detail || '로그인에 실패했습니다.'); + } else { + console.error(error); + alert('알 수 없는 오류가 발생했습니다.'); + } + } + }; + + return handleLogin; +}; diff --git a/src/hooks/use-auth/use-auth-logout/index.ts b/src/hooks/use-auth/use-auth-logout/index.ts new file mode 100644 index 00000000..23323005 --- /dev/null +++ b/src/hooks/use-auth/use-auth-logout/index.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useQueryClient } from '@tanstack/react-query'; +import Cookies from 'js-cookie'; + +import { API } from '@/api'; +import { userKeys } from '@/lib/query-key/query-key-user'; + +export const useLogout = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + + const handleLogout = async () => { + try { + await API.authService.logout(); + } catch (error) { + console.error('[LOGOUT ERROR]', error); + } finally { + // 로그인 유저 관련 캐시 정리 + queryClient.removeQueries({ queryKey: userKeys.all }); + Cookies.remove('userId'); + + // 로컬 스토리지/추가 상태도 정리??? + + router.push('/login'); + } + }; + + return handleLogout; +}; diff --git a/src/hooks/use-auth/use-auth-signup/index.ts b/src/hooks/use-auth/use-auth-signup/index.ts new file mode 100644 index 00000000..859f3445 --- /dev/null +++ b/src/hooks/use-auth/use-auth-signup/index.ts @@ -0,0 +1,36 @@ +import { useRouter } from 'next/navigation'; + +import { AxiosError } from 'axios'; + +import { API } from '@/api'; +import { SignupRequest } from '@/types/service/auth'; +import { CommonErrorResponse } from '@/types/service/common'; + +export const useSignup = () => { + const router = useRouter(); + + const handleSignup = async (payload: SignupRequest, formApi: { reset: () => void }) => { + try { + const result = await API.authService.signup(payload); + // 📜 추후 삭제 + console.log('signup success:', result); + + formApi.reset(); + router.push('/login'); + } catch (error) { + const axiosError = error as AxiosError; + const problem = axiosError.response?.data; + + // 📜 에러 UI 결정나면 변경 + if (problem) { + console.error('[SIGNUP ERROR]', problem.errorCode, problem.detail); + alert(problem.detail || '회원가입에 실패했습니다.'); + } else { + console.error(error); + alert('알 수 없는 오류가 발생했습니다.'); + } + } + }; + + return handleSignup; +}; diff --git a/src/hooks/use-auth/use-auth-withdraw/index.ts b/src/hooks/use-auth/use-auth-withdraw/index.ts new file mode 100644 index 00000000..78f3a689 --- /dev/null +++ b/src/hooks/use-auth/use-auth-withdraw/index.ts @@ -0,0 +1,20 @@ +import { API } from '@/api'; + +import { useLogout } from '../use-auth-logout'; + +export const useWithdraw = () => { + const logout = useLogout(); + + const handleWithdraw = async () => { + try { + await API.authService.withdraw(); + await logout(); + } catch (error) { + // 📜 에러 UI 결정나면 변경 + console.error('[WITHDRAW ERROR]', error); + alert('회원탈퇴에 실패했습니다.'); + } + }; + + return handleWithdraw; +}; diff --git a/src/lib/schema/auth.ts b/src/lib/schema/auth.ts index 070057e5..fb639999 100644 --- a/src/lib/schema/auth.ts +++ b/src/lib/schema/auth.ts @@ -1,15 +1,22 @@ import { z } from 'zod'; export const loginSchema = z.object({ - email: z.email('이메일 형식이 올바르지 않습니다.'), - password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다.'), + email: z + .email('올바른 이메일 형식이 아닙니다.') + .max(50, '이메일은 최대 50자까지 입력할 수 있습니다.'), + password: z.string(), }); -export const signupSchema = z - .object({ - email: z.email('이메일 형식이 올바르지 않습니다.'), - nickname: z.string().min(2, '닉네임은 2자 이상이어야 합니다.'), - password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다.'), +export const signupSchema = loginSchema + .extend({ + nickname: z + .string() + .min(2, '닉네임은 2자 이상이어야 합니다.') + .max(14, '닉네임은 14자 이하로 입력해주세요.'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .regex(/[!@#$%^&*]/, '!, @, #, $, %, ^, &, * 중 1개 이상 포함해야 합니다.'), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { diff --git a/src/mock/service/auth/auth-utils.ts b/src/mock/service/auth/auth-utils.ts index 2e8f5188..15b03e2a 100644 --- a/src/mock/service/auth/auth-utils.ts +++ b/src/mock/service/auth/auth-utils.ts @@ -26,7 +26,7 @@ export const createLoginResponse = (email: string, password: string): LoginRespo return { ...tokens, user: { - id: user.id, + userId: user.id, email: user.email, nickName: user.nickName, }, diff --git a/src/types/service/auth.ts b/src/types/service/auth.ts index a8d6fed9..bb803c93 100644 --- a/src/types/service/auth.ts +++ b/src/types/service/auth.ts @@ -21,7 +21,7 @@ export interface LoginResponse { tokenType: 'Bearer'; expiresIn: number; user: { - id: number; + userId: number; email: string; nickName: string; };