feat: 인증 페이지 API 연동 (회원가입/로그인/이메일 인증) #8

Merged
seonkyu.kim merged 1 commits from feature/7-auth-api-integration into develop 2026-02-26 23:01:29 +00:00
11 changed files with 380 additions and 142 deletions

View File

@ -1,2 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080/api VITE_API_BASE_URL=
VITE_APP_TITLE=SPMS VITE_APP_TITLE=SPMS

View File

@ -1,39 +1,58 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api"; 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; export function signup(data: SignupRequest) {
password: string; return apiClient.post<ApiResponse<SignupResponse>>("/v1/in/auth/signup", data);
} }
interface LoginResponse { /** 이메일 중복 체크 */
accessToken: string; export function checkEmail(data: EmailCheckRequest) {
user: User; return apiClient.post<ApiResponse<EmailCheckResponse>>("/v1/in/auth/email/check", data);
}
interface SignupRequest {
email: string;
password: string;
name: string;
} }
/** 로그인 */ /** 로그인 */
export function login(data: LoginRequest) { export function login(data: LoginRequest) {
return apiClient.post<ApiResponse<LoginResponse>>("/auth/login", data); return apiClient.post<ApiResponse<LoginResponse>>("/v1/in/auth/login", data);
}
/** 회원가입 */
export function signup(data: SignupRequest) {
return apiClient.post<ApiResponse<{ message: string }>>("/auth/signup", data);
} }
/** 이메일 인증 */ /** 이메일 인증 */
export function verifyEmail(token: string) { export function verifyEmail(data: EmailVerifyRequest) {
return apiClient.post<ApiResponse<{ message: string }>>("/auth/verify-email", { token }); return apiClient.post<ApiResponse<EmailVerifyResponse>>("/v1/in/auth/email/verify", data);
}
/** 인증코드 재전송 */
export function resendVerifyEmail(data: EmailResendRequest) {
return apiClient.post<ApiResponse<EmailResendResponse>>("/v1/in/auth/email/verify/resend", data);
}
/** 토큰 갱신 */
export function refreshToken(data: TokenRefreshRequest) {
return apiClient.post<ApiResponse<TokenRefreshResponse>>("/v1/in/auth/token/refresh", data);
} }
/** 로그아웃 */ /** 로그아웃 */
export function logout() { export function logout() {
return apiClient.post<ApiResponse<null>>("/auth/logout"); return apiClient.post<ApiResponse<LogoutResponse>>("/v1/in/auth/logout");
}
/** 임시 비밀번호 발급 */
export function requestTempPassword(data: TempPasswordRequest) {
return apiClient.post<ApiResponse<null>>("/v1/in/account/password/temp", data);
} }

View File

@ -2,7 +2,7 @@ import axios from "axios";
/** Axios 인스턴스 */ /** Axios 인스턴스 */
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api", baseURL: import.meta.env.VITE_API_BASE_URL ?? "",
timeout: 15000, timeout: 15000,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -3,6 +3,9 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Link, useNavigate } from "react-router-dom"; 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 { useAuthStore } from "@/stores/authStore";
import ResetPasswordModal from "../components/ResetPasswordModal"; import ResetPasswordModal from "../components/ResetPasswordModal";
@ -19,7 +22,7 @@ type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const setUser = useAuthStore((s) => s.setUser); const setAuth = useAuthStore((s) => s.setAuth);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [resetOpen, setResetOpen] = useState(false); const [resetOpen, setResetOpen] = useState(false);
@ -47,44 +50,72 @@ export default function LoginPage() {
setLoginError(null); setLoginError(null);
setIsLoading(true); setIsLoading(true);
// TODO: 실제 API 연동 시 교체 try {
await new Promise((r) => setTimeout(r, 800)); const res = await login({ email: data.email, password: data.password });
const d = res.data.data;
/*
*
* - 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";
setIsLoading(false); setIsLoading(false);
setLoginSuccess(true); setLoginSuccess(true);
if (emailVerified) { switch (d.next_action) {
/* 이메일 인증 완료 → 인증 상태 저장 후 홈 이동 */ case "GO_DASHBOARD":
setUser({ /* 토큰 & 유저 정보 저장 → 홈 이동 */
id: 1, setAuth(
email: data.email, {
name: "관리자", adminCode: d.admin.admin_code ?? "",
role: "ADMIN", email: d.admin.email ?? "",
createdAt: new Date().toISOString(), name: d.admin.name ?? "",
updatedAt: new Date().toISOString(), role: d.admin.role ?? "",
}); },
setTimeout(() => navigate("/", { replace: true }), 1000); d.access_token ?? "",
} else { d.refresh_token ?? "",
/* 이메일 미인증 → 인증 페이지로 바로 이동 (인증 상태 저장 안 함) */ );
setTimeout(() => navigate("/verify-email", { replace: true }), 1000); setTimeout(() => navigate("/", { replace: true }), 1000);
} break;
return;
}
setIsLoading(false); case "VERIFY_EMAIL":
setLoginError("이메일 또는 비밀번호가 올바르지 않습니다."); /* 이메일 미인증 → 인증 페이지 이동 */
triggerShake(["email", "password"]); 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<ApiError>;
const msg = axiosErr.response?.data?.msg;
setLoginError(msg ?? "이메일 또는 비밀번호가 올바르지 않습니다.");
triggerShake(["email", "password"]);
}
}; };
/* 유효성 실패 → shake */ /* 유효성 실패 → shake */

View File

@ -3,6 +3,9 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Link, useNavigate } from "react-router-dom"; 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 = const pwRegex =
@ -45,12 +48,15 @@ export default function SignupPage() {
const [emailSentModal, setEmailSentModal] = useState(false); const [emailSentModal, setEmailSentModal] = useState(false);
const [termsModal, setTermsModal] = useState(false); const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false); const [privacyModal, setPrivacyModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const { const {
register, register,
handleSubmit, handleSubmit,
watch, watch,
setValue, setValue,
setError,
formState: { errors }, formState: { errors },
} = useForm<SignupForm>({ } = useForm<SignupForm>({
resolver: zodResolver(signupSchema), resolver: zodResolver(signupSchema),
@ -86,10 +92,34 @@ export default function SignupPage() {
setValue("phone", v, { shouldValidate: true }); setValue("phone", v, { shouldValidate: true });
}; };
/* 가입 성공 */ /* 가입 요청 */
const onSubmit = (_data: SignupForm) => { const onSubmit = async (data: SignupForm) => {
// TODO: API 연동 setIsLoading(true);
setEmailSentModal(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<ApiError>;
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 */ /* 유효성 실패 → shake */
@ -153,6 +183,19 @@ export default function SignupPage() {
onSubmit={handleSubmit(onSubmit, onError)} onSubmit={handleSubmit(onSubmit, onError)}
noValidate noValidate
> >
{/* 서버 에러 */}
{serverError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{serverError}</span>
</div>
)}
{/* 이메일 */} {/* 이메일 */}
<div> <div>
<label className="mb-1.5 block text-sm font-bold text-gray-700"> <label className="mb-1.5 block text-sm font-bold text-gray-700">
@ -335,9 +378,19 @@ export default function SignupPage() {
<div className="pt-4"> <div className="pt-4">
<button <button
type="submit" type="submit"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98]" disabled={isLoading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
> >
{isLoading ? (
<>
<span className="material-symbols-outlined animate-spin text-lg">
progress_activity
</span>
</>
) : (
"가입하기"
)}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,10 +1,23 @@
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { AxiosError } from "axios";
import { verifyEmail, resendVerifyEmail } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
interface LocationState {
verifySessionId?: string;
accessToken?: string;
refreshToken?: string;
admin?: { admin_code: string | null; email: string | null; name: string | null; role: string | null };
}
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const setUser = useAuthStore((s) => s.setUser); const location = useLocation();
const setAuth = useAuthStore((s) => s.setAuth);
const state = (location.state as LocationState) ?? {};
const verifySessionId = state.verifySessionId ?? null;
const [code, setCode] = useState<string[]>(Array(6).fill("")); const [code, setCode] = useState<string[]>(Array(6).fill(""));
const [codeError, setCodeError] = useState<string | null>(null); const [codeError, setCodeError] = useState<string | null>(null);
@ -13,6 +26,7 @@ export default function VerifyEmailPage() {
const [resendModal, setResendModal] = useState(false); const [resendModal, setResendModal] = useState(false);
const [homeOverlay, setHomeOverlay] = useState(false); const [homeOverlay, setHomeOverlay] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0); const [resendCooldown, setResendCooldown] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@ -84,32 +98,47 @@ export default function VerifyEmailPage() {
} }
}; };
/* /* 인증하기 */
* const handleVerify = async () => {
* : "123456" ,
*/
const handleVerify = () => {
const fullCode = code.join(""); const fullCode = code.join("");
if (fullCode.length !== 6) return; if (fullCode.length !== 6) return;
if (fullCode === "123456") { setIsLoading(true);
try {
await verifyEmail({
code: fullCode,
verify_session_id: verifySessionId,
});
setSuccessModal(true); setSuccessModal(true);
} else { } catch (err) {
setCodeError("인증 코드가 올바르지 않습니다."); const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
triggerShake(); triggerShake();
} finally {
setIsLoading(false);
} }
}; };
/* 재전송 */ /* 재전송 */
const handleResend = () => { const handleResend = async () => {
if (resendCooldown > 0) return; if (resendCooldown > 0 || !verifySessionId) return;
// TODO: API 연동 try {
setResendModal(true); const res = await resendVerifyEmail({
setResendCooldown(60); verify_session_id: verifySessionId,
setCode(Array(6).fill("")); });
setCodeError(null); const d = res.data.data;
inputRefs.current[0]?.focus(); setResendModal(true);
setResendCooldown(d.cooldown_seconds || 60);
setCode(Array(6).fill(""));
setCodeError(null);
inputRefs.current[0]?.focus();
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setCodeError(msg ?? "재전송에 실패했습니다. 잠시 후 다시 시도해주세요.");
}
}; };
/* 재전송 모달 닫기 */ /* 재전송 모달 닫기 */
@ -123,15 +152,19 @@ export default function VerifyEmailPage() {
setSuccessModal(false); setSuccessModal(false);
setHomeOverlay(true); setHomeOverlay(true);
// 인증 완료 → 인증 상태 저장 /* 인증 완료 → 인증 상태 저장 */
setUser({ if (state.admin) {
id: 1, setAuth(
email: "test@spms.com", {
name: "테스트 사용자", adminCode: state.admin.admin_code ?? "",
role: "ADMIN", email: state.admin.email ?? "",
createdAt: new Date().toISOString(), name: state.admin.name ?? "",
updatedAt: new Date().toISOString(), role: state.admin.role ?? "",
}); },
state.accessToken ?? "",
state.refreshToken ?? "",
);
}
setTimeout(() => navigate("/", { replace: true }), 1000); setTimeout(() => navigate("/", { replace: true }), 1000);
}; };
@ -217,11 +250,11 @@ export default function VerifyEmailPage() {
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
disabled={!isFilled} disabled={!isFilled || isLoading}
onClick={handleVerify} onClick={handleVerify}
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-50" className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-50"
> >
{isLoading ? "인증 중…" : "인증하기"}
</button> </button>
</div> </div>

View File

@ -1,25 +1,102 @@
/** 로그인 요청 */ /* ───── 회원가입 ───── */
export interface SignupRequest {
email: string;
password: string;
name: string;
phone: string;
agreeTerms: boolean;
agreePrivacy: boolean;
}
export interface SignupResponse {
admin_code: string | null;
email: string | null;
verify_session_id: string | null;
email_sent: boolean;
}
/* ───── 이메일 중복 체크 ───── */
export interface EmailCheckRequest {
email: string;
}
export interface EmailCheckResponse {
email: string | null;
is_available: boolean;
}
/* ───── 로그인 ───── */
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
password: string; password: string;
} }
/** 로그인 응답 */
export interface LoginResponse { export interface LoginResponse {
accessToken: string; access_token: string | null;
user: { refresh_token: string | null;
id: number; expires_in: number;
email: string; next_action: "GO_DASHBOARD" | "VERIFY_EMAIL" | "CHANGE_PASSWORD" | null;
name: string; email_verified: boolean;
role: string; verify_session_id: string | null;
}; email_sent: boolean | null;
must_change_password: boolean | null;
admin: AdminInfo;
} }
/** 회원가입 요청 */ export interface AdminInfo {
export interface SignupRequest { admin_code: string | null;
email: string; email: string | null;
password: string; name: string | null;
passwordConfirm: string; role: string | null;
name: string; }
phone: string;
/* ───── 이메일 인증 ───── */
export interface EmailVerifyRequest {
code: string;
verify_session_id?: string | null;
email?: string | null;
}
export interface EmailVerifyResponse {
verified: boolean;
next_action: string | null;
}
export interface EmailResendRequest {
verify_session_id: string;
}
export interface EmailResendResponse {
resent: boolean;
cooldown_seconds: number;
expires_in_seconds: number;
}
/* ───── 토큰 ───── */
export interface TokenRefreshRequest {
refresh_token: string;
}
export interface TokenRefreshResponse {
access_token: string | null;
refresh_token: string | null;
expires_in: number;
}
/* ───── 로그아웃 ───── */
export interface LogoutResponse {
logged_out: boolean;
redirect_to: string | null;
}
/* ───── 비밀번호 ───── */
export interface TempPasswordRequest {
email: string;
} }

View File

@ -1,16 +1,39 @@
import { create } from "zustand"; import { create } from "zustand";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
/* localStorage에서 유저 정보 복원 */
function loadUser(): User | null {
try {
const raw = localStorage.getItem("user");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
const savedUser = loadUser();
const savedToken = localStorage.getItem("accessToken");
interface AuthState { interface AuthState {
user: User | null; user: User | null;
isAuthenticated: boolean; isAuthenticated: boolean;
setUser: (user: User) => void; setAuth: (user: User, accessToken: string, refreshToken: string) => void;
logout: () => void; logout: () => void;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
user: null, user: savedUser,
isAuthenticated: false, isAuthenticated: !!(savedUser && savedToken),
setUser: (user) => set({ user, isAuthenticated: true }), setAuth: (user, accessToken, refreshToken) => {
logout: () => set({ user: null, isAuthenticated: false }), localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
localStorage.setItem("user", JSON.stringify(user));
set({ user, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
localStorage.removeItem("user");
set({ user: null, isAuthenticated: false });
},
})); }));

View File

@ -1,24 +1,29 @@
/** API 응답 공통 래퍼 */ /** API 응답 공통 래퍼 */
export interface ApiResponse<T> { export interface ApiResponse<T> {
success: boolean; result: boolean;
code: string | null;
msg: string | null;
data: T; data: T;
message?: string;
} }
/** 페이지네이션 응답 */ /** 페이지네이션 응답 */
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
success: boolean; result: boolean;
data: T[]; code: string | null;
total: number; msg: string | null;
page: number; data: {
pageSize: number; items: T[];
totalPages: number; total: number;
page: number;
pageSize: number;
totalPages: number;
};
} }
/** API 에러 */ /** API 에러 (axios error.response.data) */
export interface ApiError { export interface ApiError {
success: false; result: false;
message: string; code: string | null;
code?: string; msg: string | null;
errors?: Record<string, string[]>; data: null;
} }

View File

@ -1,19 +1,7 @@
/** 사용자 역할 */ /** 사용자 (백엔드 AdminInfo 기반) */
export const UserRole = {
ADMIN: "ADMIN",
MANAGER: "MANAGER",
USER: "USER",
} as const;
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
/** 사용자 */
export interface User { export interface User {
id: number; adminCode: string;
email: string; email: string;
name: string; name: string;
role: UserRole; role: string;
profileImage?: string;
createdAt: string;
updatedAt: string;
} }

View File

@ -10,4 +10,13 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: {
proxy: {
'/v1': {
target: 'https://devspms.ipstein.myds.me',
changeOrigin: true,
secure: true,
},
},
},
}) })