diff --git a/.vscode/settings.json b/.vscode/settings.json
index e69de29..c269e49 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "chatgpt.openOnStartup": false
+}
\ No newline at end of file
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
index 40e207f..604d5f9 100644
--- a/app/auth/callback/route.ts
+++ b/app/auth/callback/route.ts
@@ -1,24 +1,20 @@
-import { createClient } from "@/utils/supabase/server";
+import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";
import { AUTH_ERROR_MESSAGES, AUTH_ROUTES } from "@/features/auth/constants";
+import { getAuthErrorMessage } from "@/features/auth/errors";
/**
- * [인증 콜백 라우트 핸들러]
- *
- * Supabase 인증 이메일(회원가입 확인, 비밀번호 재설정 등) 및 OAuth(소셜 로그인)
- * 리다이렉트될 때 호출되는 API 라우트입니다.
+ * OAuth/??? ?? ?? ??
*/
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
- // 1. URL에서 주요 직접 파라미터 및 에러 추출
const code = searchParams.get("code");
const next = searchParams.get("next") ?? AUTH_ROUTES.HOME;
const error = searchParams.get("error");
const error_code = searchParams.get("error_code");
const error_description = searchParams.get("error_description");
- // 2. 인증 오류가 있는 경우 (예: 구글 로그인 취소 등)
if (error) {
console.error("Auth callback error parameter:", {
error,
@@ -28,7 +24,6 @@ export async function GET(request: Request) {
let message: string = AUTH_ERROR_MESSAGES.DEFAULT;
- // 에러 종류에 따른 메시지 분기
if (error === "access_denied") {
message = AUTH_ERROR_MESSAGES.OAUTH_ACCESS_DENIED;
} else if (error === "server_error") {
@@ -40,31 +35,33 @@ export async function GET(request: Request) {
);
}
- // 3. code가 있으면 세션으로 교환 (정상 플로우)
if (code) {
const supabase = await createClient();
const { error: exchangeError } =
await supabase.auth.exchangeCodeForSession(code);
if (!exchangeError) {
- // 세션 교환 성공 - 원래 목적지로 리다이렉트
const forwardedHost = request.headers.get("x-forwarded-host");
const isLocalEnv = process.env.NODE_ENV === "development";
if (isLocalEnv) {
return NextResponse.redirect(`${origin}${next}`);
- } else if (forwardedHost) {
- return NextResponse.redirect(`https://${forwardedHost}${next}`);
- } else {
- return NextResponse.redirect(`${origin}${next}`);
}
+
+ if (forwardedHost) {
+ return NextResponse.redirect(`https://${forwardedHost}${next}`);
+ }
+
+ return NextResponse.redirect(`${origin}${next}`);
}
- // 세션 교환 실패 시 로그 및 에러 메시지 설정
console.error("Auth exchange error:", exchangeError.message);
+ const message = getAuthErrorMessage(exchangeError);
+ return NextResponse.redirect(
+ `${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
+ );
}
- // 4. code가 없거나 교환 실패 시 기본 에러 페이지로 리다이렉트
const errorMessage = encodeURIComponent(
AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK,
);
diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts
index 9865a7b..7915598 100644
--- a/app/auth/confirm/route.ts
+++ b/app/auth/confirm/route.ts
@@ -1,76 +1,84 @@
-import { createClient } from "@/utils/supabase/server";
-import { AUTH_ERROR_MESSAGES } from "@/features/auth/constants";
+import { createClient } from "@/utils/supabase/server";
+import {
+ AUTH_ERROR_MESSAGES,
+ AUTH_ROUTES,
+ RECOVERY_COOKIE_MAX_AGE_SECONDS,
+ RECOVERY_COOKIE_NAME,
+} from "@/features/auth/constants";
+import { getAuthErrorMessage } from "@/features/auth/errors";
import { type EmailOtpType } from "@supabase/supabase-js";
-import { redirect } from "next/navigation";
-import { type NextRequest } from "next/server";
+import { NextResponse, type NextRequest } from "next/server";
-// ========================================
-// 상수 정의
-// ========================================
-
-/** 비밀번호 재설정 후 이동할 경로 */
-const RESET_PASSWORD_PATH = "/reset-password";
-
-/** 인증 실패 시 리다이렉트할 경로 */
-const LOGIN_PATH = "/login";
-
-// ========================================
-// 라우트 핸들러
-// ========================================
+const RESET_PASSWORD_PATH = AUTH_ROUTES.RESET_PASSWORD;
+const LOGIN_PATH = AUTH_ROUTES.LOGIN;
/**
- * [이메일 인증 확인 라우트]
- *
- * Supabase 이메일 템플릿의 인증 링크를 처리합니다.
- * - 회원가입 이메일 확인
- * - 비밀번호 재설정
- *
- * @example Supabase 이메일 템플릿 형식
- * {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery
+ * 이메일 인증(/auth/confirm) 처리
+ * - token_hash + type 검증
+ * - recovery 타입일 경우 세션 쿠키 설정 후 비밀번호 재설정 페이지로 리다이렉트
*/
export async function GET(request: NextRequest) {
+ // 1) 이메일 링크에 들어있는 값 읽기
const { searchParams } = new URL(request.url);
- // ========== 파라미터 추출 ==========
+ // token_hash: 인증에 필요한 값
+ // type: 어떤 인증인지 구분 (예: 가입, 비밀번호 재설정)
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
- const rawNext = searchParams.get("next");
- // 보안: 외부 URL 리다이렉트 방지 (상대 경로만 허용)
- const nextPath = rawNext?.startsWith("/") ? rawNext : "/";
+ // redirect_to/next: 인증 후에 이동할 주소
+ const rawRedirect =
+ searchParams.get("redirect_to") ?? searchParams.get("next");
- // ========== 토큰 검증 ==========
+ // 보안상 우리 사이트 안 경로(`/...`)만 허용
+ const safeRedirect =
+ rawRedirect && rawRedirect.startsWith("/") ? rawRedirect : null;
+
+ // 일반 인증이 끝난 뒤 이동할 경로
+ const nextPath = safeRedirect ?? AUTH_ROUTES.HOME;
+ // 비밀번호 재설정일 때 이동할 경로
+ const recoveryPath = safeRedirect ?? RESET_PASSWORD_PATH;
+
+ // 필수 값이 없으면 로그인으로 보내고 에러를 보여줌
if (!tokenHash || !type) {
- return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
+ return redirectWithError(request, AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
}
+ // 2) Supabase에게 "이 링크가 맞는지" 확인 요청
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash: tokenHash,
});
+ // 확인 실패 시 이유를 알기 쉬운 메시지로 보여줌
if (error) {
- console.error("[Auth Confirm] verifyOtp 실패:", error.message);
- return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
+ console.error("[Auth Confirm] verifyOtp error:", error.message);
+ const message = getAuthErrorMessage(error);
+ return redirectWithError(request, message);
}
- // ========== 검증 성공 - 적절한 페이지로 리다이렉트 ==========
+ // 3) 비밀번호 재설정이면 재설정 페이지로 보내고 쿠키를 저장
if (type === "recovery") {
- redirect(RESET_PASSWORD_PATH);
+ const response = NextResponse.redirect(new URL(recoveryPath, request.url));
+ response.cookies.set(RECOVERY_COOKIE_NAME, "1", {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
+ path: "/",
+ });
+ return response;
}
- redirect(nextPath);
+ // 4) 그 외 인증은 기본 경로로 이동
+ return NextResponse.redirect(new URL(nextPath, request.url));
}
-// ========================================
-// 헬퍼 함수
-// ========================================
-
-/**
- * 에러 메시지와 함께 로그인 페이지로 리다이렉트
- */
-function redirectWithError(message: string): never {
+// 로그인 페이지로 보내면서 에러 메시지를 함께 전달
+function redirectWithError(request: NextRequest, message: string) {
const encodedMessage = encodeURIComponent(message);
- redirect(`${LOGIN_PATH}?message=${encodedMessage}`);
+ return NextResponse.redirect(
+ new URL(`${LOGIN_PATH}?message=${encodedMessage}`, request.url),
+ );
}
diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx
index e111adc..e439111 100644
--- a/app/forgot-password/page.tsx
+++ b/app/forgot-password/page.tsx
@@ -1,4 +1,4 @@
-import FormMessage from "@/components/form-message";
+import FormMessage from "@/components/form-message";
import { requestPasswordReset } from "@/features/auth/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -15,58 +15,46 @@ import Link from "next/link";
/**
* [비밀번호 찾기 페이지]
*
- * 사용자가 이메일을 입력하면 비밀번호 재설정 링크를 이메일로 발송합니다.
- * 로그인/회원가입 페이지와 동일한 디자인 시스템 사용
- *
- * @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
+ * 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
+ * - 이메일 입력 폼 제공
+ * - 서버 액션(requestPasswordReset)과 연동
*/
export default async function ForgotPasswordPage({
searchParams,
}: {
- searchParams: Promise<{ message: string }>;
+ searchParams: Promise<{ message?: string }>;
}) {
- // URL에서 메시지 파라미터 추출
const { message } = await searchParams;
return (
- {/* ========== 배경 그라디언트 ========== */}
- {/* ========== 애니메이션 블러 효과 ========== */}
- {/* ========== 메인 콘텐츠 ========== */}
- {/* 에러/성공 메시지 */}
-
+ {message &&
}
- {/* ========== 비밀번호 찾기 카드 ========== */}
- {/* 아이콘 */}
- 🔑
+ MAIL
- {/* 페이지 제목 */}
- 비밀번호 찾기
+ 비밀번호 재설정
- {/* 페이지 설명 */}
- 가입하신 이메일 주소를 입력하시면
+ 가입한 이메일 주소를 입력하시면 비밀번호 재설정 링크를
+ 보내드립니다.
- 비밀번호 재설정 링크를 보내드립니다.
+ 메일을 받지 못하셨다면 스팸함을 확인해 주세요.
- {/* ========== 폼 영역 ========== */}
- {/* 비밀번호 재설정 요청 폼 */}
- {/* ========== 로그인 페이지로 돌아가기 ========== */}
- ← 로그인 페이지로 돌아가기
+ 로그인 페이지로 돌아가기
diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx
index b768761..6f0ef00 100644
--- a/app/reset-password/page.tsx
+++ b/app/reset-password/page.tsx
@@ -1,4 +1,4 @@
-import FormMessage from "@/components/form-message";
+import FormMessage from "@/components/form-message";
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
import {
Card,
@@ -13,94 +13,55 @@ import { redirect } from "next/navigation";
/**
* [비밀번호 재설정 페이지]
*
- * 이메일 링크를 통해 접근한 사용자만 새 비밀번호를 설정할 수 있습니다.
- * Supabase recovery 세션 검증으로 직접 URL 접근 차단
- *
- * PKCE 플로우:
- * 1. 사용자가 비밀번호 재설정 이메일의 링크 클릭
- * 2. Supabase가 토큰 검증 후 이 페이지로 ?code=xxx 파라미터와 함께 리다이렉트
- * 3. 이 페이지에서 code를 세션으로 교환
- * 4. 유효한 세션이 있으면 비밀번호 재설정 폼 표시
- *
- * @param searchParams - URL 쿼리 파라미터 (code: PKCE 코드, message: 에러/성공 메시지)
+ * 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
+ * - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
+ * - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
*/
export default async function ResetPasswordPage({
searchParams,
}: {
- searchParams: Promise<{ message?: string; code?: string }>;
+ searchParams: Promise<{ message?: string }>;
}) {
const params = await searchParams;
const supabase = await createClient();
- // 1. 이메일 링크에서 code 파라미터 확인
- // Supabase는 이메일 링크를 통해 ?code=xxx 형태로 PKCE code를 전달합니다
- if (params.code) {
- // code를 세션으로 교환
- const { error } = await supabase.auth.exchangeCodeForSession(params.code);
-
- if (error) {
- // code 교환 실패 (만료되었거나 유효하지 않음)
- console.error("Password reset code exchange error:", error.message);
- const message = encodeURIComponent(
- "비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
- );
- redirect(`/login?message=${message}`);
- }
-
- // code 교환 성공 - code 없이 같은 페이지로 리다이렉트
- // (URL을 깨끗하게 유지하고 세션 쿠키가 설정된 상태로 리로드)
- redirect("/reset-password");
- }
-
- // 2. 세션 확인
const {
data: { user },
} = await supabase.auth.getUser();
- // 3. 유효한 세션이 없으면 로그인 페이지로 리다이렉트
if (!user) {
const message = encodeURIComponent(
- "비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
+ "유효하지 않은 재설정 링크이거나 만료되었습니다. 다시 시도해 주세요.",
);
redirect(`/login?message=${message}`);
}
- // URL에서 메시지 파라미터 추출
const { message } = params;
return (
- {/* ========== 배경 그라디언트 ========== */}
- {/* ========== 애니메이션 블러 효과 ========== */}
- {/* ========== 메인 콘텐츠 ========== */}
- {/* 에러/성공 메시지 */}
{message &&
}
- {/* ========== 비밀번호 재설정 카드 ========== */}
- {/* 아이콘 */}
- 🔐
+ PW
- {/* 페이지 제목 */}
비밀번호 재설정
- {/* 페이지 설명 */}
- 새로운 비밀번호를 입력해 주세요.
+ 새 비밀번호를 입력해 주세요.
- {/* ========== 폼 영역 ========== */}
diff --git a/features/auth/actions.ts b/features/auth/actions.ts
index 7383dc8..a3947c3 100644
--- a/features/auth/actions.ts
+++ b/features/auth/actions.ts
@@ -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");
}
diff --git a/features/auth/components/reset-password-form.tsx b/features/auth/components/reset-password-form.tsx
index d0eceab..49783c7 100644
--- a/features/auth/components/reset-password-form.tsx
+++ b/features/auth/components/reset-password-form.tsx
@@ -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 (