feat: 인증 페이지 API 연동 (회원가입/로그인/이메일 인증)
All checks were successful
SPMS_BO/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/8
This commit is contained in:
김선규 2026-02-26 23:01:29 +00:00
commit 1e8d33102e
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

View File

@ -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<ApiResponse<SignupResponse>>("/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<ApiResponse<EmailCheckResponse>>("/v1/in/auth/email/check", data);
}
/** 로그인 */
export function login(data: LoginRequest) {
return apiClient.post<ApiResponse<LoginResponse>>("/auth/login", data);
}
/** 회원가입 */
export function signup(data: SignupRequest) {
return apiClient.post<ApiResponse<{ message: string }>>("/auth/signup", data);
return apiClient.post<ApiResponse<LoginResponse>>("/v1/in/auth/login", data);
}
/** 이메일 인증 */
export function verifyEmail(token: string) {
return apiClient.post<ApiResponse<{ message: string }>>("/auth/verify-email", { token });
export function verifyEmail(data: EmailVerifyRequest) {
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() {
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 인스턴스 */
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",

View File

@ -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<typeof loginSchema>;
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<ApiError>;
const msg = axiosErr.response?.data?.msg;
setLoginError(msg ?? "이메일 또는 비밀번호가 올바르지 않습니다.");
triggerShake(["email", "password"]);
}
};
/* 유효성 실패 → shake */

View File

@ -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<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
setError,
formState: { errors },
} = useForm<SignupForm>({
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<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 */
@ -153,6 +183,19 @@ export default function SignupPage() {
onSubmit={handleSubmit(onSubmit, onError)}
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>
<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">
<button
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>
</div>
</form>

View File

@ -1,10 +1,23 @@
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";
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() {
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 [codeError, setCodeError] = useState<string | null>(null);
@ -13,6 +26,7 @@ export default function VerifyEmailPage() {
const [resendModal, setResendModal] = useState(false);
const [homeOverlay, setHomeOverlay] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@ -84,32 +98,47 @@ export default function VerifyEmailPage() {
}
};
/*
*
* : "123456" ,
*/
const handleVerify = () => {
/* 인증하기 */
const handleVerify = async () => {
const fullCode = code.join("");
if (fullCode.length !== 6) return;
if (fullCode === "123456") {
setIsLoading(true);
try {
await verifyEmail({
code: fullCode,
verify_session_id: verifySessionId,
});
setSuccessModal(true);
} else {
setCodeError("인증 코드가 올바르지 않습니다.");
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
triggerShake();
} finally {
setIsLoading(false);
}
};
/* 재전송 */
const handleResend = () => {
if (resendCooldown > 0) return;
const handleResend = async () => {
if (resendCooldown > 0 || !verifySessionId) return;
// TODO: API 연동
setResendModal(true);
setResendCooldown(60);
setCode(Array(6).fill(""));
setCodeError(null);
inputRefs.current[0]?.focus();
try {
const res = await resendVerifyEmail({
verify_session_id: verifySessionId,
});
const d = res.data.data;
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);
setHomeOverlay(true);
// 인증 완료 → 인증 상태 저장
setUser({
id: 1,
email: "test@spms.com",
name: "테스트 사용자",
role: "ADMIN",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
/* 인증 완료 → 인증 상태 저장 */
if (state.admin) {
setAuth(
{
adminCode: state.admin.admin_code ?? "",
email: state.admin.email ?? "",
name: state.admin.name ?? "",
role: state.admin.role ?? "",
},
state.accessToken ?? "",
state.refreshToken ?? "",
);
}
setTimeout(() => navigate("/", { replace: true }), 1000);
};
@ -217,11 +250,11 @@ export default function VerifyEmailPage() {
<div className="flex justify-end gap-3">
<button
type="button"
disabled={!isFilled}
disabled={!isFilled || isLoading}
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"
>
{isLoading ? "인증 중…" : "인증하기"}
</button>
</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 {
email: string;
password: string;
}
/** 로그인 응답 */
export interface LoginResponse {
accessToken: string;
user: {
id: number;
email: string;
name: string;
role: string;
};
access_token: string | null;
refresh_token: string | null;
expires_in: number;
next_action: "GO_DASHBOARD" | "VERIFY_EMAIL" | "CHANGE_PASSWORD" | null;
email_verified: boolean;
verify_session_id: string | null;
email_sent: boolean | null;
must_change_password: boolean | null;
admin: AdminInfo;
}
/** 회원가입 요청 */
export interface SignupRequest {
email: string;
password: string;
passwordConfirm: string;
name: string;
phone: string;
export interface AdminInfo {
admin_code: string | null;
email: string | null;
name: string | null;
role: string | null;
}
/* ───── 이메일 인증 ───── */
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 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 {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User) => void;
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
user: savedUser,
isAuthenticated: !!(savedUser && savedToken),
setAuth: (user, accessToken, refreshToken) => {
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 응답 공통 래퍼 */
export interface ApiResponse<T> {
success: boolean;
result: boolean;
code: string | null;
msg: string | null;
data: T;
message?: string;
}
/** 페이지네이션 응답 */
export interface PaginatedResponse<T> {
success: boolean;
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
result: boolean;
code: string | null;
msg: string | null;
data: {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
/** API 에러 */
/** API 에러 (axios error.response.data) */
export interface ApiError {
success: false;
message: string;
code?: string;
errors?: Record<string, string[]>;
result: false;
code: string | null;
msg: string | null;
data: null;
}

View File

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

View File

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