From 37ac854bc8ae14e1cf9da74b01d87b5be42629df Mon Sep 17 00:00:00 2001 From: SEAN Date: Fri, 27 Feb 2026 08:00:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20API=20=EC=97=B0=EB=8F=99=20(=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85/=EB=A1=9C=EA=B7=B8=EC=9D=B8/=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D)=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 API 연동 (POST /v1/in/auth/signup) - 로그인 API 연동 (POST /v1/in/auth/login, next_action 분기) - 이메일 인증 API 연동 (POST /v1/in/auth/email/verify, /resend) - API 타입 swagger 스펙에 맞게 수정 (ApiResponse, auth types) - User 타입 백엔드 AdminInfo 기반으로 변경 - authStore localStorage 영속화 (새로고침 시 인증 유지) - Vite dev 프록시 설정 (/v1 → devspms) Closes #7 Co-Authored-By: Claude Opus 4.6 --- react/.env.development | 2 +- react/src/api/auth.api.ts | 63 ++++++---- react/src/api/client.ts | 2 +- react/src/features/auth/pages/LoginPage.tsx | 99 ++++++++++------ react/src/features/auth/pages/SignupPage.tsx | 65 ++++++++++- .../features/auth/pages/VerifyEmailPage.tsx | 91 ++++++++++----- react/src/features/auth/types.ts | 109 +++++++++++++++--- react/src/stores/authStore.ts | 33 +++++- react/src/types/api.ts | 31 ++--- react/src/types/user.ts | 18 +-- react/vite.config.ts | 9 ++ 11 files changed, 380 insertions(+), 142 deletions(-) diff --git a/react/.env.development b/react/.env.development index ddb9b21..7a93143 100644 --- a/react/.env.development +++ b/react/.env.development @@ -1,2 +1,2 @@ -VITE_API_BASE_URL=http://localhost:8080/api +VITE_API_BASE_URL= VITE_APP_TITLE=SPMS diff --git a/react/src/api/auth.api.ts b/react/src/api/auth.api.ts index b62e712..df3bc28 100644 --- a/react/src/api/auth.api.ts +++ b/react/src/api/auth.api.ts @@ -1,39 +1,58 @@ import { apiClient } from "./client"; import type { ApiResponse } from "@/types/api"; -import type { User } from "@/types/user"; +import type { + SignupRequest, + SignupResponse, + EmailCheckRequest, + EmailCheckResponse, + LoginRequest, + LoginResponse, + EmailVerifyRequest, + EmailVerifyResponse, + EmailResendRequest, + EmailResendResponse, + TokenRefreshRequest, + TokenRefreshResponse, + LogoutResponse, + TempPasswordRequest, +} from "@/features/auth/types"; -interface LoginRequest { - email: string; - password: string; +/** 회원가입 */ +export function signup(data: SignupRequest) { + return apiClient.post>("/v1/in/auth/signup", data); } -interface LoginResponse { - accessToken: string; - user: User; -} - -interface SignupRequest { - email: string; - password: string; - name: string; +/** 이메일 중복 체크 */ +export function checkEmail(data: EmailCheckRequest) { + return apiClient.post>("/v1/in/auth/email/check", data); } /** 로그인 */ export function login(data: LoginRequest) { - return apiClient.post>("/auth/login", data); -} - -/** 회원가입 */ -export function signup(data: SignupRequest) { - return apiClient.post>("/auth/signup", data); + return apiClient.post>("/v1/in/auth/login", data); } /** 이메일 인증 */ -export function verifyEmail(token: string) { - return apiClient.post>("/auth/verify-email", { token }); +export function verifyEmail(data: EmailVerifyRequest) { + return apiClient.post>("/v1/in/auth/email/verify", data); +} + +/** 인증코드 재전송 */ +export function resendVerifyEmail(data: EmailResendRequest) { + return apiClient.post>("/v1/in/auth/email/verify/resend", data); +} + +/** 토큰 갱신 */ +export function refreshToken(data: TokenRefreshRequest) { + return apiClient.post>("/v1/in/auth/token/refresh", data); } /** 로그아웃 */ export function logout() { - return apiClient.post>("/auth/logout"); + return apiClient.post>("/v1/in/auth/logout"); +} + +/** 임시 비밀번호 발급 */ +export function requestTempPassword(data: TempPasswordRequest) { + return apiClient.post>("/v1/in/account/password/temp", data); } diff --git a/react/src/api/client.ts b/react/src/api/client.ts index a9e7cd8..e3cfde0 100644 --- a/react/src/api/client.ts +++ b/react/src/api/client.ts @@ -2,7 +2,7 @@ import axios from "axios"; /** Axios 인스턴스 */ export const apiClient = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api", + baseURL: import.meta.env.VITE_API_BASE_URL ?? "", timeout: 15000, headers: { "Content-Type": "application/json", diff --git a/react/src/features/auth/pages/LoginPage.tsx b/react/src/features/auth/pages/LoginPage.tsx index 4c7271c..50dd46a 100644 --- a/react/src/features/auth/pages/LoginPage.tsx +++ b/react/src/features/auth/pages/LoginPage.tsx @@ -3,6 +3,9 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Link, useNavigate } from "react-router-dom"; +import { AxiosError } from "axios"; +import { login } from "@/api/auth.api"; +import type { ApiError } from "@/types/api"; import { useAuthStore } from "@/stores/authStore"; import ResetPasswordModal from "../components/ResetPasswordModal"; @@ -19,7 +22,7 @@ type LoginForm = z.infer; export default function LoginPage() { const navigate = useNavigate(); - const setUser = useAuthStore((s) => s.setUser); + const setAuth = useAuthStore((s) => s.setAuth); const [showPassword, setShowPassword] = useState(false); const [resetOpen, setResetOpen] = useState(false); @@ -47,44 +50,72 @@ export default function LoginPage() { setLoginError(null); setIsLoading(true); - // TODO: 실제 API 연동 시 교체 - await new Promise((r) => setTimeout(r, 800)); - - /* - * 테스트 계정 - * - admin@spms.com / 1234 → 이메일 인증 완료 → 홈 이동 - * - test@spms.com / 1234 → 이메일 미인증 → 인증 페이지 이동 - */ - if ( - (data.email === "admin@spms.com" || data.email === "test@spms.com") && - data.password === "1234" - ) { - const emailVerified = data.email === "admin@spms.com"; + try { + const res = await login({ email: data.email, password: data.password }); + const d = res.data.data; setIsLoading(false); setLoginSuccess(true); - if (emailVerified) { - /* 이메일 인증 완료 → 인증 상태 저장 후 홈 이동 */ - setUser({ - id: 1, - email: data.email, - name: "관리자", - role: "ADMIN", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - setTimeout(() => navigate("/", { replace: true }), 1000); - } else { - /* 이메일 미인증 → 인증 페이지로 바로 이동 (인증 상태 저장 안 함) */ - setTimeout(() => navigate("/verify-email", { replace: true }), 1000); - } - return; - } + switch (d.next_action) { + case "GO_DASHBOARD": + /* 토큰 & 유저 정보 저장 → 홈 이동 */ + setAuth( + { + adminCode: d.admin.admin_code ?? "", + email: d.admin.email ?? "", + name: d.admin.name ?? "", + role: d.admin.role ?? "", + }, + d.access_token ?? "", + d.refresh_token ?? "", + ); + setTimeout(() => navigate("/", { replace: true }), 1000); + break; - setIsLoading(false); - setLoginError("이메일 또는 비밀번호가 올바르지 않습니다."); - triggerShake(["email", "password"]); + case "VERIFY_EMAIL": + /* 이메일 미인증 → 인증 페이지 이동 */ + setTimeout( + () => + navigate("/verify-email", { + replace: true, + state: { + verifySessionId: d.verify_session_id, + accessToken: d.access_token, + refreshToken: d.refresh_token, + admin: d.admin, + }, + }), + 1000, + ); + break; + + case "CHANGE_PASSWORD": + /* 비밀번호 변경 필요 → 토큰 저장 후 비밀번호 변경으로 이동 */ + setAuth( + { + adminCode: d.admin.admin_code ?? "", + email: d.admin.email ?? "", + name: d.admin.name ?? "", + role: d.admin.role ?? "", + }, + d.access_token ?? "", + d.refresh_token ?? "", + ); + setTimeout(() => navigate("/profile/edit", { replace: true }), 1000); + break; + + default: + setTimeout(() => navigate("/", { replace: true }), 1000); + break; + } + } catch (err) { + setIsLoading(false); + const axiosErr = err as AxiosError; + const msg = axiosErr.response?.data?.msg; + setLoginError(msg ?? "이메일 또는 비밀번호가 올바르지 않습니다."); + triggerShake(["email", "password"]); + } }; /* 유효성 실패 → shake */ diff --git a/react/src/features/auth/pages/SignupPage.tsx b/react/src/features/auth/pages/SignupPage.tsx index 95bfa6d..50ba591 100644 --- a/react/src/features/auth/pages/SignupPage.tsx +++ b/react/src/features/auth/pages/SignupPage.tsx @@ -3,6 +3,9 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Link, useNavigate } from "react-router-dom"; +import { AxiosError } from "axios"; +import { signup } from "@/api/auth.api"; +import type { ApiError } from "@/types/api"; /* ───── 정규식 ───── */ const pwRegex = @@ -45,12 +48,15 @@ export default function SignupPage() { const [emailSentModal, setEmailSentModal] = useState(false); const [termsModal, setTermsModal] = useState(false); const [privacyModal, setPrivacyModal] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [serverError, setServerError] = useState(null); const { register, handleSubmit, watch, setValue, + setError, formState: { errors }, } = useForm({ resolver: zodResolver(signupSchema), @@ -86,10 +92,34 @@ export default function SignupPage() { setValue("phone", v, { shouldValidate: true }); }; - /* 가입 성공 */ - const onSubmit = (_data: SignupForm) => { - // TODO: API 연동 - setEmailSentModal(true); + /* 가입 요청 */ + const onSubmit = async (data: SignupForm) => { + setIsLoading(true); + setServerError(null); + try { + await signup({ + email: data.email, + password: data.password, + name: data.name, + phone: data.phone, + agreeTerms: data.agree, + agreePrivacy: data.agree, + }); + setEmailSentModal(true); + } catch (err) { + const axiosErr = err as AxiosError; + const code = axiosErr.response?.data?.code; + const msg = axiosErr.response?.data?.msg; + + if (axiosErr.response?.status === 409 || code === "EMAIL_DUPLICATE") { + setError("email", { message: msg ?? "이미 사용 중인 이메일입니다." }); + triggerShake(["email"]); + } else { + setServerError(msg ?? "회원가입에 실패했습니다. 다시 시도해주세요."); + } + } finally { + setIsLoading(false); + } }; /* 유효성 실패 → shake */ @@ -153,6 +183,19 @@ export default function SignupPage() { onSubmit={handleSubmit(onSubmit, onError)} noValidate > + {/* 서버 에러 */} + {serverError && ( +
+ + error + + {serverError} +
+ )} + {/* 이메일 */}