diff --git a/app/login/_components/LoginForm.tsx b/app/login/_components/LoginForm.tsx index 842a469..0adcad5 100644 --- a/app/login/_components/LoginForm.tsx +++ b/app/login/_components/LoginForm.tsx @@ -49,7 +49,7 @@ export const LoginForm = () => { /> {!state.success && state.message && ( - * 이메일 혹은 비밀번호가 틀립니다 + * {state.message} )} diff --git a/app/onboarding/_components/StartOnBoarding.tsx b/app/onboarding/_components/StartOnBoarding.tsx index 3fd4de0..79327fe 100644 --- a/app/onboarding/_components/StartOnBoarding.tsx +++ b/app/onboarding/_components/StartOnBoarding.tsx @@ -1,18 +1,23 @@ "use client"; import Button from "@/components/ui/Button"; import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; const StartOnBoarding = () => { + const router = useRouter(); const [showFirst, setShowFirst] = useState(false); const [showSecond, setShowSecond] = useState(false); + const [showThird, setShowThird] = useState(false); const [buttonDisabled, setButtonDisabled] = useState(true); useEffect(() => { const t1 = setTimeout(() => setShowFirst(true), 100); const t2 = setTimeout(() => setShowSecond(true), 2100); + const t3 = setTimeout(() => setShowThird(true), 4100); return () => { clearTimeout(t1); clearTimeout(t2); + clearTimeout(t3); }; }, []); @@ -43,12 +48,24 @@ const StartOnBoarding = () => { opacity: showSecond ? 1 : 0, transition: "opacity 0.7s ease", }} - onTransitionEnd={() => setButtonDisabled(false)} > 원활한 매칭을 위해 여러분의 정보가 필요해요! + setButtonDisabled(false)} + > + 다음 설문을 빠르게 진행해 볼까요? + - diff --git a/app/register/_components/PasswordStep.tsx b/app/register/_components/PasswordStep.tsx index e94545b..2f24549 100644 --- a/app/register/_components/PasswordStep.tsx +++ b/app/register/_components/PasswordStep.tsx @@ -1,4 +1,4 @@ -import Button from "@/components/ui/Button"; +import Button from "@/components/ui/Button"; import FormInput from "@/components/ui/FormInput"; import { validatePasswordLength, @@ -22,6 +22,7 @@ export const PasswordStep = ({ const isLengthValid = validatePasswordLength(password); const isPatternValid = validatePasswordPattern(password); + const isPasswordValid = isLengthValid && isPatternValid; return (
@@ -79,23 +80,41 @@ export const PasswordStep = ({ )} -
+
- - 8~20자 이내 + + 8자 이상 20자 이하 - - 영문 대소문자, 숫자 포함 + + 영문, 숫자, 특수문자(@$!%*#?&) 포함
-
diff --git a/app/register/_components/ScreenRegister.tsx b/app/register/_components/ScreenRegister.tsx index 7acaac2..c7da289 100644 --- a/app/register/_components/ScreenRegister.tsx +++ b/app/register/_components/ScreenRegister.tsx @@ -15,7 +15,7 @@ export const ScreenRegister = () => { const [password, setPassword] = useState(""); const { mutate: sendEmail, isPending: isSendingEmail } = useSendEmail(); - const { mutate: verifyEmail, isPending: isVerifyingEmail } = useVerifyEmail(); + const { verify, isPending: isVerifyingEmail } = useVerifyEmail(); const { mutate: signUp, isPending: isSigningUp } = useSignUp(); const handleEmailSubmit = (e: React.SyntheticEvent) => { @@ -25,17 +25,11 @@ export const ScreenRegister = () => { }); }; - const handleVerify = (code: string, onError: () => void) => { - verifyEmail( + const handleVerify = (code: string, onError: (msg?: string) => void) => { + verify( { email, code }, { - onSuccess: (data) => { - if (data.status === 200) { - setStep(3); - } else { - onError(); - } - }, + onSuccess: () => setStep(3), onError, }, ); diff --git a/app/register/_components/VerificationStep.tsx b/app/register/_components/VerificationStep.tsx index 89cda7a..5790e57 100644 --- a/app/register/_components/VerificationStep.tsx +++ b/app/register/_components/VerificationStep.tsx @@ -6,7 +6,7 @@ type VerificationStepProps = { email: string; verificationCode: string; onVerificationCodeChange: (code: string) => void; - onVerify: (code: string, onError: () => void) => void; + onVerify: (code: string, onError: (msg?: string) => void) => void; onResend: () => void; isVerifying?: boolean; }; @@ -20,7 +20,7 @@ export const VerificationStep = ({ isVerifying = false, }: VerificationStepProps) => { const [timeLeft, setTimeLeft] = useState(300); // 5분 = 300초 - const [isWrong, setIsWrong] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); useEffect(() => { if (timeLeft <= 0) return; @@ -40,19 +40,21 @@ export const VerificationStep = ({ const handleVerificationCodeChange = (value: string) => { onVerificationCodeChange(value); - if (isWrong && value) { - setIsWrong(false); + if (errorMessage && value) { + setErrorMessage(""); } }; const handleSubmit = (e: React.SyntheticEvent) => { e.preventDefault(); - onVerify(verificationCode, () => setIsWrong(true)); + onVerify(verificationCode, (msg?: string) => + setErrorMessage(msg || "인증번호를 다시 확인해 주세요"), + ); }; const handleResend = () => { setTimeLeft(300); // 타이머 리셋 - setIsWrong(false); // 에러 상태 초기화 + setErrorMessage(""); // 에러 상태 초기화 onResend(); }; @@ -96,7 +98,7 @@ export const VerificationStep = ({ required value={verificationCode} onChange={(e) => handleVerificationCodeChange(e.target.value)} - error={isWrong} + error={!!errorMessage} maxLength={6} inputMode="numeric" className="flex-1" @@ -110,9 +112,9 @@ export const VerificationStep = ({ 재전송
- {isWrong && ( + {errorMessage && ( - *인증번호를 다시 확인해 주세요 + *{errorMessage} )} diff --git a/components/ui/FormInput.tsx b/components/ui/FormInput.tsx index 12965b6..62591d0 100644 --- a/components/ui/FormInput.tsx +++ b/components/ui/FormInput.tsx @@ -18,7 +18,7 @@ const INPUT_STYLE = { "linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)", }; const INPUT_CLASSNAME = - "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] typo-16-500 placeholder:text-[#B3B3B3] text-color-gray-900 outline-none"; + "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] typo-16-500 placeholder:text-color-text-caption2 text-color-gray-900 outline-none"; // 안전한 속성 화이트리스트 (XSS 방지) const SAFE_INPUT_ATTRIBUTES = [ diff --git a/components/ui/FormSelect.tsx b/components/ui/FormSelect.tsx new file mode 100644 index 0000000..4755fe5 --- /dev/null +++ b/components/ui/FormSelect.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { cn } from "@/lib/utils"; +import { ChevronDown } from "lucide-react"; + +// 옵션 타입 정의 (일반적인 단일 옵션) +export type SelectOption = { + value: string; + label: string; +}; + +// 옵션 그룹 타입 정의 (대학 - 계열 - 학과 처럼 묶여있는 경우) +export type SelectOptionGroup = { + label: string; + options: SelectOption[]; +}; + +interface FormSelectProps extends Omit< + React.SelectHTMLAttributes, + "id" | "name" +> { + id: string; + name: string; + options: (SelectOption | SelectOptionGroup)[]; + placeholder?: string; + error?: boolean; +} + +const SELECT_CONTAINER_STYLE = { + background: + "linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)", +}; + +const SELECT_CLASSNAME = + "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] pr-8 leading-[19px] typo-16-500 text-color-gray-900 outline-none appearance-none cursor-pointer"; + +// 타입 가드: 옵션 그룹인지 확인 +function isOptionGroup( + option: SelectOption | SelectOptionGroup, +): option is SelectOptionGroup { + return "options" in option; +} + +const FormSelect = ({ + id, + name, + options, + placeholder, + className = "", + style = {}, + error = false, + ...rest +}: FormSelectProps) => { + return ( +
+ + + {/* 오른쪽에 화살표 아이콘 (포인터 이벤트 무시하여 클릭 방해 안 함) */} +
+ +
+
+ ); +}; + +export default FormSelect; diff --git a/hooks/useVerifyEmail.ts b/hooks/useVerifyEmail.ts index e2f3a01..8f5e77d 100644 --- a/hooks/useVerifyEmail.ts +++ b/hooks/useVerifyEmail.ts @@ -1,5 +1,6 @@ import { api } from "@/lib/axios"; import { useMutation } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; type VerifyEmailRequest = { email: string; @@ -23,8 +24,32 @@ const verifyEmail = async ( }; export const useVerifyEmail = () => { - return useMutation({ + const mutation = useMutation({ mutationFn: verifyEmail, retry: 1, }); + + const verify = ( + payload: VerifyEmailRequest, + options: { onSuccess: () => void; onError: (msg?: string) => void }, + ) => { + mutation.mutate(payload, { + onSuccess: (data) => { + if (data.status === 200) { + options.onSuccess(); + } else { + options.onError(data.message); + } + }, + onError: (error) => { + if (isAxiosError(error) && error.response?.data?.message) { + options.onError(error.response.data.message); + } else { + options.onError(); + } + }, + }); + }; + + return { ...mutation, verify }; }; diff --git a/lib/actions/loginAction.ts b/lib/actions/loginAction.ts index 7f21f9b..22004fc 100644 --- a/lib/actions/loginAction.ts +++ b/lib/actions/loginAction.ts @@ -1,5 +1,6 @@ "use server"; +import { redirect } from "next/navigation"; import { serverApi } from "@/lib/server-api"; import { isAxiosError } from "axios"; @@ -21,11 +22,64 @@ export async function loginAction( const email = formData.get("email"); const password = formData.get("password"); + let redirectUrl: string | null = null; + try { - await serverApi.post({ + const { finalUrl, setCookie } = await serverApi.post({ path: "/api/auth/login", body: { email, password }, }); + + // 🍪 백엔드로부터 받은 쿠키가 있다면 브라우저에 배달해줍니다. + if (setCookie) { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + + setCookie.forEach((cookieStr) => { + // Axios가 준 쿠키 문자열을 파싱합니다 (name=value; Path=/ ...) + const [nameValue, ...options] = cookieStr.split(";"); + const [name, ...nameParts] = nameValue.split("="); + const value = nameParts.join("="); + + const cookieOptions: { + path?: string; + httpOnly?: boolean; + secure?: boolean; + maxAge?: number; + expires?: Date; + sameSite?: "strict" | "lax" | "none" | boolean; + } = {}; + + options.forEach((opt) => { + const [key, ...valueParts] = opt.trim().split("="); + const val = valueParts.join("="); + const k = key.toLowerCase(); + if (k === "path") cookieOptions.path = val; + if (k === "httponly") cookieOptions.httpOnly = true; + if (k === "secure") cookieOptions.secure = true; + if (k === "max-age") cookieOptions.maxAge = parseInt(val); + if (k === "expires") cookieOptions.expires = new Date(val); + if (k === "samesite") + cookieOptions.sameSite = val.toLowerCase() as + | "strict" + | "lax" + | "none"; + }); + + cookieStore.set(name, value, cookieOptions); + }); + } + + if (finalUrl) { + // https://comatching.site/onboarding -> /onboarding 추출 + try { + const url = new URL(finalUrl); + redirectUrl = url.pathname + url.search; + } catch { + // 이미 상대 경로인 경우 + redirectUrl = finalUrl; + } + } } catch (error) { if (isAxiosError(error)) { const status = error.response?.status; @@ -45,5 +99,10 @@ export async function loginAction( }; } + // 성공 시 리다이렉트 (Next.js 규칙: try-catch 밖에서 호출 권장) + if (redirectUrl) { + redirect(redirectUrl); + } + return { success: true, message: "로그인 성공" }; } diff --git a/lib/server-api.ts b/lib/server-api.ts index 3d26b4a..db13347 100644 --- a/lib/server-api.ts +++ b/lib/server-api.ts @@ -11,6 +11,8 @@ if (!API_URL) { const serverClient = axios.create({ baseURL: API_URL, timeout: 10000, + maxRedirects: 0, // 🚨 자동으로 리다이렉트를 따라가지 않도록 설정 + validateStatus: (status) => (status >= 200 && status < 300) || status === 302, // 302도 성공으로 간주 headers: { "Content-Type": "application/json", }, @@ -62,7 +64,7 @@ serverClient.interceptors.response.use( // 원래 요청 재시도 return serverClient(originalRequest); - } catch (reissueError) { + } catch { // 재발급 실패 → 로그인 페이지로 redirect("/login"); } @@ -80,23 +82,43 @@ type RequestOptions = { body?: unknown; // POST, PUT 등에서 보낼 데이터 }; +// 요청 결과 타입 정의 +type ApiResponse = { + data: T; + finalUrl?: string; + setCookie?: string[]; // ✅ 백엔드에서 온 쿠키 헤더 +}; + // 3. 통합 요청 함수 async function request( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", { path, params, headers, body }: RequestOptions, -): Promise { +): Promise> { // Axios 설정 객체 const config: AxiosRequestConfig = { url: path, method, headers, - params, // Axios가 객체를 쿼리스트링(?key=value)으로 자동 변환해줍니다. (buildQuery 불필요) + params, data: body, }; try { const response = await serverClient.request(config); - return response.data; + + // 💡 성공 시 로그를 출력합니다. + console.log(`[Server API Success] ${method} ${path}`, { + status: response.status, + location: response.headers["location"], // 리다이렉트 지점 + hasCookie: !!response.headers["set-cookie"], + }); + + return { + data: response.data, + finalUrl: + response.headers["location"] || response.request?.res?.responseUrl, + setCookie: response.headers["set-cookie"] as string[], + }; } catch (error) { if (isAxiosError(error)) { const status = error.response?.status; diff --git a/lib/validators.ts b/lib/validators.ts index 2aeb441..aabcecf 100644 --- a/lib/validators.ts +++ b/lib/validators.ts @@ -3,8 +3,14 @@ export const validateEmail = (email: string): boolean => { return emailRegex.test(email); }; -export const validatePasswordLength = (password: string): boolean => - password.length >= 8 && password.length <= 20; +export const validatePasswordLength = ( + password: string, + minLength = 8, + maxLength = 20, +): boolean => password.length >= minLength && password.length <= maxLength; -export const validatePasswordPattern = (password: string): boolean => - /[a-zA-Z]/.test(password) && /[0-9]/.test(password); +export const validatePasswordPattern = (password: string): boolean => { + const passwordRegex = + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]+$/; + return passwordRegex.test(password); +};