Refactor: 인증 흐름 개선 및 에러 메시지 통합

.vscode/settings.json
- chatgpt 확장 자동 실행 비활성화 설정 추가

app/auth/callback/route.ts
- OAuth 콜백 처리 개선: 에러 메시지 매핑 함수 사용 및 리다이렉트 로직 정리

app/auth/confirm/route.ts
- 이메일 인증(토큰 검증) 라우트 신규 구현: recovery 쿠키 설정 및 안전한 리다이렉트 처리

app/forgot-password/page.tsx
- UI 텍스트/플레이스홀더 정리, 메시지 렌더링 조건부 처리

app/reset-password/page.tsx
- 리셋 페이지 접근/세션 검증 로직 정리 및 UI 문구/아이콘 변경

features/auth/actions.ts
- 에러 처리 통합(getAuthErrorMessage 사용), 서버 액션 주석 정리
- 비밀번호 재설정 플로우 반환값을 객체로 변경하고 recovery 쿠키 삭제/로그아웃 처리

features/auth/components/reset-password-form.tsx
- 클라이언트 폼: updatePassword 결과 처리 개선, 라우터 리다이렉션 및 에러 메시지 표시 개선

features/auth/constants.ts
- 인증 관련 상수(에러 메시지, 코드/상태 매핑, 라우트, 검증 규칙, 쿠키 이름) 신규 추가

features/auth/errors.ts
- Supabase Auth 에러를 한글 메시지로 변환하는 유틸 추가

features/auth/schemas/auth-schema.ts
- zod 스키마 메시지 문구 정리 및 포맷 통일

utils/supabase/middleware.ts
- 세션/쿠키 갱신 및 라우트 보호 로직 개선, recovery 쿠키 기반 리다이렉트 처리 추가

utils/supabase/server.ts
- 서버용 Supabase 클라이언트 초기화 함수 추가 (쿠키 읽기/쓰기 처리)
This commit is contained in:
2026-02-05 09:38:42 +09:00
parent edcfa2a837
commit 22ced3a6ae
12 changed files with 342 additions and 300 deletions

View File

@@ -1,13 +1,16 @@
"use server";
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import {
AUTH_ERROR_MESSAGES,
type AuthFormData,
type AuthError,
RECOVERY_COOKIE_NAME,
} from "./constants";
import { getAuthErrorMessage } from "./errors";
// ========================================
// 헬퍼 함수
@@ -139,31 +142,18 @@ function validateAuthInput(email: string, password: string): AuthError | null {
* @param error - Supabase에서 받은 에러 메시지
* @returns string - 한글로 번역된 에러 메시지
*/
function getErrorMessage(error: string): string {
// Supabase 에러 메시지 패턴 매칭
if (error.includes("Invalid login credentials")) {
return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS;
}
if (error.includes("User already registered")) {
return AUTH_ERROR_MESSAGES.USER_EXISTS;
}
if (error.includes("Password should be at least")) {
return AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT;
}
if (error.includes("Email not confirmed")) {
return AUTH_ERROR_MESSAGES.EMAIL_NOT_CONFIRMED;
}
if (error.toLowerCase().includes("email rate limit exceeded")) {
return AUTH_ERROR_MESSAGES.EMAIL_RATE_LIMIT_DETAILED;
}
// 알 수 없는 에러는 기본 메시지 반환
return AUTH_ERROR_MESSAGES.DEFAULT;
}
// 에러 메시지는 getAuthErrorMessage로 통일합니다.
// ========================================
// Server Actions (서버 액션)
// ========================================
// 흐름 요약 (어디서 호출되는지)
// - /login: features/auth/components/login-form.tsx -> login, signInWithGoogle, signInWithKakao
// - /signup: features/auth/components/signup-form.tsx -> signup
// - /forgot-password: app/forgot-password/page.tsx -> requestPasswordReset
// - /reset-password: features/auth/components/reset-password-form.tsx -> updatePassword
// - 메인(/): app/page.tsx -> signout
// - OAuth: signInWithGoogle/Kakao -> Supabase -> /auth/callback 라우트
/**
* [로그인 액션]
@@ -180,6 +170,7 @@ function getErrorMessage(error: string): string {
* @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함)
*/
export async function login(formData: FormData) {
// 호출: features/auth/components/login-form.tsx (로그인 폼 action)
// 1. FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
@@ -200,7 +191,7 @@ export async function login(formData: FormData) {
// 4. 로그인 실패 시 에러 처리
if (error) {
const message = getErrorMessage(error.message);
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
@@ -226,6 +217,7 @@ export async function login(formData: FormData) {
* @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함)
*/
export async function signup(formData: FormData) {
// 호출: features/auth/components/signup-form.tsx (회원가입 폼 action)
// 1. FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
@@ -252,7 +244,7 @@ export async function signup(formData: FormData) {
// 4. 회원가입 실패 시 에러 처리
if (error) {
const message = getErrorMessage(error.message);
const message = getAuthErrorMessage(error);
return redirect(`/signup?message=${encodeURIComponent(message)}`);
}
@@ -283,6 +275,7 @@ export async function signup(formData: FormData) {
* 3. 로그인 페이지로 리다이렉트
*/
export async function signout() {
// 호출: app/page.tsx (로그아웃 버튼의 formAction)
const supabase = await createClient();
// 1. Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
@@ -313,6 +306,7 @@ export async function signout() {
* @param formData - 이메일이 포함된 FormData
*/
export async function requestPasswordReset(formData: FormData) {
// 호출: app/forgot-password/page.tsx (비밀번호 재설정 요청 폼)
// 1. FormData에서 이메일 추출
const email = (formData.get("email") as string)?.trim() || "";
@@ -338,18 +332,8 @@ export async function requestPasswordReset(formData: FormData) {
// 4. 에러 처리
if (error) {
console.error("Password reset error:", error.message);
// Rate limit 오류는 사용자에게 알려줌 (보안과 무관)
if (error.message.toLowerCase().includes("rate limit")) {
return redirect(
`/forgot-password?message=${encodeURIComponent(
"이메일 발송 제한을 초과했습니다. Supabase 무료 플랜은 시간당 이메일 발송 횟수가 제한됩니다. 약 1시간 후에 다시 시도해 주세요.",
)}`,
);
}
// 그 외 에러는 보안상 동일한 메시지 표시
// (이메일 존재 여부를 외부에 노출하지 않음)
const message = getAuthErrorMessage(error);
return redirect(`/forgot-password?message=${encodeURIComponent(message)}`);
}
// 5. 성공 메시지 표시
@@ -372,34 +356,33 @@ export async function requestPasswordReset(formData: FormData) {
* @param formData - 새 비밀번호가 포함된 FormData
*/
export async function updatePassword(formData: FormData) {
// 1. FormData에서 새 비밀번호 추출
// 호출: features/auth/components/reset-password-form.tsx (재설정 폼)
const password = (formData.get("password") as string) || "";
// 2. 비밀번호 강도 검증
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return redirect(
`/reset-password?message=${encodeURIComponent(passwordValidation.message)}`,
);
return { ok: false, message: passwordValidation.message };
}
// 3. Supabase를 통한 비밀번호 업데이트
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({
password: password,
});
// 4. 에러 처리
if (error) {
const message = getErrorMessage(error.message);
return redirect(`/reset-password?message=${encodeURIComponent(message)}`);
const message = getAuthErrorMessage(error);
return { ok: false, message };
}
// 5. 성공 - 캐시 무효화 및 로그인 페이지로 리다이렉트
const cookieStore = await cookies();
cookieStore.delete(RECOVERY_COOKIE_NAME);
await supabase.auth.signOut();
revalidatePath("/", "layout");
redirect(
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS)}`,
);
return {
ok: true,
message: AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS,
};
}
// ========================================
@@ -427,6 +410,7 @@ export async function updatePassword(formData: FormData) {
* @param provider - OAuth 제공자 ('google' | 'kakao')
*/
async function signInWithProvider(provider: "google" | "kakao") {
// 호출: 아래 signInWithGoogle / signInWithKakao에서 공통 사용
const supabase = await createClient();
// 1. OAuth 인증 시작
@@ -442,9 +426,8 @@ async function signInWithProvider(provider: "google" | "kakao") {
// 2. 에러 처리
if (error) {
console.error(`[${provider} OAuth] 로그인 실패:`, error.message);
return redirect(
`/login?message=${encodeURIComponent(`${provider} 로그인에 실패했습니다. 다시 시도해 주세요.`)}`,
);
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// 3. OAuth 제공자 로그인 페이지로 리다이렉트
@@ -473,6 +456,7 @@ async function signInWithProvider(provider: "google" | "kakao") {
* 5. 메인 페이지로 이동
*/
export async function signInWithGoogle() {
// 호출: features/auth/components/login-form.tsx (Google 로그인 버튼)
return signInWithProvider("google");
}
@@ -490,5 +474,6 @@ export async function signInWithGoogle() {
* 5. 메인 페이지로 이동
*/
export async function signInWithKakao() {
// 호출: features/auth/components/login-form.tsx (Kakao 로그인 버튼)
return signInWithProvider("kakao");
}

View File

@@ -1,7 +1,8 @@
"use client";
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { updatePassword } from "@/features/auth/actions";
import {
resetPasswordSchema,
@@ -13,22 +14,14 @@ import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { useState } from "react";
/**
* [비밀번호 재설정 폼 클라이언트 컴포넌트 - React Hook Form 버전]
*
* React Hook Form과 Zod를 사용한 비밀번호 재설정 폼입니다.
* - 타입 안전한 폼 검증
* - 비밀번호/비밀번호 확인 일치 검증
* - 로딩 상태 표시
*
* @see app/reset-password/page.tsx - 이 컴포넌트를 사용하는 페이지
*/
const DEFAULT_ERROR_MESSAGE =
"알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
export default function ResetPasswordForm() {
// ========== 로딩 상태 ==========
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState("");
// ========== React Hook Form 설정 ==========
const {
register,
handleSubmit,
@@ -39,11 +32,9 @@ export default function ResetPasswordForm() {
mode: "onBlur",
});
// 비밀번호 실시간 감시
const password = watch("password");
const confirmPassword = watch("confirmPassword");
// ========== 폼 제출 핸들러 ==========
const onSubmit = async (data: ResetPasswordFormData) => {
setServerError("");
setIsLoading(true);
@@ -52,10 +43,18 @@ export default function ResetPasswordForm() {
const formData = new FormData();
formData.append("password", data.password);
await updatePassword(formData);
const result = await updatePassword(formData);
if (result?.ok) {
const message = encodeURIComponent(result.message);
router.replace(`/login?message=${message}`);
return;
}
setServerError(result?.message || DEFAULT_ERROR_MESSAGE);
} catch (error) {
console.error("Password reset error:", error);
setServerError("비밀번호 변경 중 오류가 발생했습니다.");
setServerError(DEFAULT_ERROR_MESSAGE);
} finally {
setIsLoading(false);
}
@@ -63,14 +62,12 @@ export default function ResetPasswordForm() {
return (
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{/* ========== 서버 에러 메시지 표시 ========== */}
{serverError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
{serverError}
</div>
)}
{/* ========== 새 비밀번호 입력 ========== */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
@@ -78,14 +75,14 @@ export default function ResetPasswordForm() {
<Input
id="password"
type="password"
placeholder="••••••••"
placeholder="새 비밀번호를 입력해주세요"
autoComplete="new-password"
disabled={isLoading}
{...register("password")}
className="h-11 transition-all duration-200"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
8 , , , ,
8 , /// 1 .
</p>
{errors.password && (
<p className="text-xs text-red-600 dark:text-red-400">
@@ -94,37 +91,33 @@ export default function ResetPasswordForm() {
)}
</div>
{/* ========== 비밀번호 확인 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium">
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
placeholder="새 비밀번호를 다시 입력해주세요"
autoComplete="new-password"
disabled={isLoading}
{...register("confirmPassword")}
className="h-11 transition-all duration-200"
/>
{/* 비밀번호 불일치 시 실시간 피드백 */}
{confirmPassword &&
password !== confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
.
</p>
)}
{/* 비밀번호 일치 시 확인 메시지 */}
{confirmPassword &&
password === confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-green-600 dark:text-green-400">
.
</p>
)}
{/* Zod 검증 에러 */}
{errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.confirmPassword.message}
@@ -132,7 +125,6 @@ export default function ResetPasswordForm() {
)}
</div>
{/* ========== 비밀번호 변경 버튼 ========== */}
<Button
type="submit"
disabled={isLoading}

View File

@@ -1,4 +1,4 @@
/**
/**
* [인증 관련 상수 정의]
*
* 인증 모듈 전체에서 공통으로 사용하는 상수들을 정의합니다.
@@ -61,6 +61,107 @@ export const AUTH_ERROR_MESSAGES = {
DEFAULT: "요청을 처리하는 중 오류가 발생했습니다.",
} as const;
// ========================================
// Supabase Auth 에러 코드 매핑
// ========================================
export const AUTH_ERROR_CODE_MESSAGES = {
anonymous_provider_disabled: "익명 로그인은 비활성화되어 있습니다.",
bad_code_verifier: "PKCE code verifier가 일치하지 않습니다.",
bad_json: "요청 본문이 올바른 JSON이 아닙니다.",
bad_jwt: "Authorization 헤더의 JWT가 유효하지 않습니다.",
bad_oauth_callback: "OAuth 콜백에 필요한 값(state)이 없습니다.",
bad_oauth_state: "OAuth state 형식이 올바르지 않습니다.",
captcha_failed: "CAPTCHA 검증에 실패했습니다.",
conflict: "요청 충돌이 발생했습니다. 잠시 후 다시 시도해주세요.",
email_address_invalid: "예시/테스트 도메인은 사용할 수 없습니다.",
email_address_not_authorized:
"기본 SMTP 사용 시 허용되지 않은 이메일 주소입니다.",
email_conflict_identity_not_deletable:
"이메일 충돌로 이 아이덴티티를 삭제할 수 없습니다.",
email_exists: "이미 가입된 이메일 주소입니다.",
email_not_confirmed: "이메일 인증이 완료되지 않았습니다.",
email_provider_disabled: "이메일/비밀번호 가입이 비활성화되어 있습니다.",
flow_state_expired: "로그인 흐름이 만료되었습니다. 다시 시도해주세요.",
flow_state_not_found: "로그인 흐름을 찾을 수 없습니다. 다시 시도해주세요.",
hook_payload_invalid_content_type:
"훅 페이로드의 Content-Type이 올바르지 않습니다.",
hook_payload_over_size_limit: "훅 페이로드가 최대 크기를 초과했습니다.",
hook_timeout: "훅 요청 시간이 초과되었습니다.",
hook_timeout_after_retry: "훅 요청 재시도 후에도 시간이 초과되었습니다.",
identity_already_exists: "이미 연결된 아이덴티티입니다.",
identity_not_found: "아이덴티티를 찾을 수 없습니다.",
insufficient_aal: "추가 인증이 필요합니다.",
invalid_credentials: "이메일 또는 비밀번호가 일치하지 않습니다.",
invite_not_found: "초대 링크가 만료되었거나 이미 사용되었습니다.",
manual_linking_disabled: "계정 연결 기능이 비활성화되어 있습니다.",
mfa_challenge_expired: "MFA 인증 시간이 초과되었습니다.",
mfa_factor_name_conflict: "MFA 요인 이름이 중복되었습니다.",
mfa_factor_not_found: "MFA 요인을 찾을 수 없습니다.",
mfa_ip_address_mismatch: "MFA 등록 시작/종료 IP가 일치하지 않습니다.",
mfa_phone_enroll_not_enabled: "전화 MFA 등록이 비활성화되어 있습니다.",
mfa_phone_verify_not_enabled: "전화 MFA 검증이 비활성화되어 있습니다.",
mfa_totp_enroll_not_enabled: "TOTP MFA 등록이 비활성화되어 있습니다.",
mfa_totp_verify_not_enabled: "TOTP MFA 검증이 비활성화되어 있습니다.",
mfa_verification_failed: "MFA 검증에 실패했습니다.",
mfa_verification_rejected: "MFA 검증이 거부되었습니다.",
mfa_verified_factor_exists: "이미 검증된 전화 MFA가 존재합니다.",
mfa_web_authn_enroll_not_enabled: "WebAuthn MFA 등록이 비활성화되어 있습니다.",
mfa_web_authn_verify_not_enabled: "WebAuthn MFA 검증이 비활성화되어 있습니다.",
no_authorization: "Authorization 헤더가 필요합니다.",
not_admin: "관리자 권한이 없습니다.",
oauth_provider_not_supported: "OAuth 제공자가 비활성화되어 있습니다.",
otp_disabled: "OTP 로그인이 비활성화되어 있습니다.",
otp_expired: "OTP가 만료되었습니다.",
over_email_send_rate_limit: "이메일 발송 제한을 초과했습니다.",
over_request_rate_limit: "요청 제한을 초과했습니다.",
over_sms_send_rate_limit: "SMS 발송 제한을 초과했습니다.",
phone_exists: "이미 가입된 전화번호입니다.",
phone_not_confirmed: "전화번호 인증이 완료되지 않았습니다.",
phone_provider_disabled: "전화번호 가입이 비활성화되어 있습니다.",
provider_disabled: "OAuth 제공자가 비활성화되어 있습니다.",
provider_email_needs_verification: "OAuth 이메일 인증이 필요합니다.",
reauthentication_needed: "비밀번호 변경을 위해 재인증이 필요합니다.",
reauthentication_not_valid: "재인증 코드가 유효하지 않습니다.",
refresh_token_already_used: "세션이 만료되었습니다. 다시 로그인해주세요.",
refresh_token_not_found: "세션을 찾을 수 없습니다.",
request_timeout: "요청 시간이 초과되었습니다.",
same_password: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
saml_assertion_no_email: "SAML 응답에 이메일이 없습니다.",
saml_assertion_no_user_id: "SAML 응답에 사용자 ID가 없습니다.",
saml_entity_id_mismatch: "SAML 엔티티 ID가 일치하지 않습니다.",
saml_idp_already_exists: "SAML IdP가 이미 등록되어 있습니다.",
saml_idp_not_found: "SAML IdP를 찾을 수 없습니다.",
saml_metadata_fetch_failed: "SAML 메타데이터를 불러오지 못했습니다.",
saml_provider_disabled: "SAML SSO가 비활성화되어 있습니다.",
saml_relay_state_expired: "SAML relay state가 만료되었습니다.",
saml_relay_state_not_found: "SAML relay state를 찾을 수 없습니다.",
session_expired: "세션이 만료되었습니다.",
session_not_found: "세션을 찾을 수 없습니다.",
signup_disabled: "회원가입이 비활성화되어 있습니다.",
single_identity_not_deletable: "유일한 아이덴티티는 삭제할 수 없습니다.",
sms_send_failed: "SMS 발송에 실패했습니다.",
sso_domain_already_exists: "SSO 도메인이 이미 등록되어 있습니다.",
sso_provider_not_found: "SSO 제공자를 찾을 수 없습니다.",
too_many_enrolled_mfa_factors: "등록 가능한 MFA 요인 수를 초과했습니다.",
unexpected_audience: "토큰 audience가 일치하지 않습니다.",
unexpected_failure: "인증 서버 오류가 발생했습니다.",
user_already_exists: "이미 존재하는 사용자입니다.",
user_banned: "계정이 일시적으로 차단되었습니다.",
user_not_found: "사용자를 찾을 수 없습니다.",
user_sso_managed: "SSO 사용자 정보는 수정할 수 없습니다.",
validation_failed: "요청 값 형식이 올바르지 않습니다.",
weak_password: "비밀번호가 정책을 만족하지 않습니다.",
} as const;
export const AUTH_ERROR_STATUS_MESSAGES = {
403: "요청한 기능을 사용할 수 없습니다.",
422: "요청을 처리할 수 없는 상태입니다.",
429: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
500: "인증 서버 오류가 발생했습니다.",
501: "요청한 기능이 활성화되어 있지 않습니다.",
} as const;
// ========================================
// 라우트 경로 상수
// ========================================
@@ -91,6 +192,10 @@ export const PUBLIC_AUTH_PAGES = [
AUTH_ROUTES.AUTH_CALLBACK,
] as const;
// 복구 플로우 전용 쿠키 (비밀번호 재설정 화면 외 접근 차단에 사용)
export const RECOVERY_COOKIE_NAME = "sb-recovery";
export const RECOVERY_COOKIE_MAX_AGE_SECONDS = 10 * 60;
// ========================================
// 검증 규칙 상수
// ========================================

30
features/auth/errors.ts Normal file
View File

@@ -0,0 +1,30 @@
import {
AUTH_ERROR_CODE_MESSAGES,
AUTH_ERROR_MESSAGES,
AUTH_ERROR_STATUS_MESSAGES,
} from "./constants";
export type AuthApiErrorLike = {
message?: string | null;
code?: string | null;
status?: number | null;
};
/**
* Supabase Auth 에러 데이터를 인간이 읽을 수 있는 한글 메시지로 변환합니다.
*/
export function getAuthErrorMessage(error: AuthApiErrorLike): string {
if (error.code && error.code in AUTH_ERROR_CODE_MESSAGES) {
return AUTH_ERROR_CODE_MESSAGES[
error.code as keyof typeof AUTH_ERROR_CODE_MESSAGES
];
}
if (error.status && error.status in AUTH_ERROR_STATUS_MESSAGES) {
return AUTH_ERROR_STATUS_MESSAGES[
error.status as keyof typeof AUTH_ERROR_STATUS_MESSAGES
];
}
return AUTH_ERROR_MESSAGES.DEFAULT;
}

View File

@@ -1,93 +1,68 @@
import { z } from "zod";
import { z } from "zod";
import { PASSWORD_RULES } from "@/features/auth/constants";
/**
* [비밀번호 검증 스키마]
*
* 비밀번호 강도 요구사항:
* - 최소 8자 이상
* - 대문자 1개 이상
* - 소문자 1개 이상
* - 숫자 1개 이상
* - 특수문자 1개 이상
*/
const passwordSchema = z
.string()
.min(PASSWORD_RULES.MIN_LENGTH, {
message: `비밀번호는 최소 ${PASSWORD_RULES.MIN_LENGTH}자 이상이어야 합니다.`,
})
.regex(/[A-Z]/, {
message: "비밀번호에 대문자 최소 1개 이상 포함되어야 합니다.",
message: "대문자 최소 1개 이상 포함야 합니다.",
})
.regex(/[a-z]/, {
message: "비밀번호에 소문자 최소 1개 이상 포함되어야 합니다.",
message: "소문자 최소 1개 이상 포함야 합니다.",
})
.regex(/[0-9]/, {
message: "비밀번호에 숫자 최소 1개 이상 포함되어야 합니다.",
message: "숫자 최소 1개 이상 포함야 합니다.",
})
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
message: "비밀번호에 특수문자 최소 1개 이상 포함되어야 합니다.",
message: "특수문자 최소 1개 이상 포함야 합니다.",
});
/**
* [회원가입 폼 스키마]
*
* 회원가입 시 필요한 필드 검증
*/
export const signupSchema = z
.object({
email: z
.string()
.min(1, { message: "이메일을 입력해주세요." })
.email({ message: "올바른 이메일 형식이 아닙니다." }),
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
password: passwordSchema,
confirmPassword: z
.string()
.min(1, { message: "비밀번호 확인을 입력해주세요." }),
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
/**
* [비밀번호 재설정 폼 스키마]
*/
export const resetPasswordSchema = z
.object({
password: passwordSchema,
confirmPassword: z
.string()
.min(1, { message: "비밀번호 확인을 입력해주세요." }),
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
/**
* [로그인 폼 스키마]
*/
export const loginSchema = z.object({
email: z
.string()
.min(1, { message: "이메일을 입력해주세요." })
.email({ message: "올바른 이메일 형식이 아닙니다." }),
password: z.string().min(1, { message: "비밀번호를 입력해주세요." }),
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
password: z.string().min(1, { message: "비밀번호를 입력해 주세요." }),
rememberMe: z.boolean().optional(),
});
/**
* [비밀번호 찾기 폼 스키마]
*/
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, { message: "이메일을 입력해주세요." })
.email({ message: "올바른 이메일 형식이 아닙니다." }),
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
});
// TypeScript 타입 추론
export type SignupFormData = z.infer<typeof signupSchema>;
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
export type LoginFormData = z.infer<typeof loginSchema>;