feat: 인증 페이지 API 연동 (회원가입/로그인/이메일 인증)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
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:
commit
1e8d33102e
|
|
@ -1,2 +1,2 @@
|
||||||
VITE_API_BASE_URL=http://localhost:8080/api
|
VITE_API_BASE_URL=
|
||||||
VITE_APP_TITLE=SPMS
|
VITE_APP_TITLE=SPMS
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "",
|
||||||
});
|
},
|
||||||
|
d.access_token ?? "",
|
||||||
|
d.refresh_token ?? "",
|
||||||
|
);
|
||||||
setTimeout(() => navigate("/", { replace: true }), 1000);
|
setTimeout(() => navigate("/", { replace: true }), 1000);
|
||||||
} else {
|
break;
|
||||||
/* 이메일 미인증 → 인증 페이지로 바로 이동 (인증 상태 저장 안 함) */
|
|
||||||
setTimeout(() => navigate("/verify-email", { replace: true }), 1000);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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);
|
setIsLoading(false);
|
||||||
setLoginError("이메일 또는 비밀번호가 올바르지 않습니다.");
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
setLoginError(msg ?? "이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||||
triggerShake(["email", "password"]);
|
triggerShake(["email", "password"]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* 유효성 실패 → shake */
|
/* 유효성 실패 → shake */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
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);
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
const res = await resendVerifyEmail({
|
||||||
|
verify_session_id: verifySessionId,
|
||||||
|
});
|
||||||
|
const d = res.data.data;
|
||||||
setResendModal(true);
|
setResendModal(true);
|
||||||
setResendCooldown(60);
|
setResendCooldown(d.cooldown_seconds || 60);
|
||||||
setCode(Array(6).fill(""));
|
setCode(Array(6).fill(""));
|
||||||
setCodeError(null);
|
setCodeError(null);
|
||||||
inputRefs.current[0]?.focus();
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
msg: string | null;
|
||||||
|
data: {
|
||||||
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalPages: 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
Loading…
Reference in New Issue
Block a user