마이페이지, 리포트, 메일인증
CI / test (push) Failing after 13m23s
CI / e2e (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled

This commit is contained in:
2025-12-15 21:59:43 +09:00
parent a8e1a4a960
commit 87cdc1868b
30 changed files with 5488 additions and 16 deletions
+351
View File
@@ -0,0 +1,351 @@
"use client";
import Link from "next/link";
import { useEffect, useState, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getMyPageMessages } from "@/features/i18n/messages/mypage";
// 마이페이지(/mypage) 화면 컴포넌트
// - 현재 로그인 유저 정보를 /api/auth/me 에서 불러와 표시한다.
// - 비로그인(401) 응답 시 /login 으로 리다이렉트한다.
// - 비밀번호 변경 및 이메일 변경 폼을 통해 각각 /api/auth/change-password, /api/auth/change-email 를 호출한다.
interface AuthUser {
id: string;
email: string;
tokenVersion?: number;
emailVerified?: boolean;
}
export default function MyPage() {
const router = useRouter();
const locale = useAppLocale();
const m = getMyPageMessages(locale);
// 현재 로그인된 유저 정보와 로딩/에러 상태를 관리한다.
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 비밀번호/이메일 변경 폼 상태
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
const [changePasswordMessage, setChangePasswordMessage] = useState<string | null>(null);
const [newEmail, setNewEmail] = useState("");
const [emailChangePassword, setEmailChangePassword] = useState("");
const [changeEmailMessage, setChangeEmailMessage] = useState<string | null>(null);
// 이메일 인증 메일 재발송 상태
const [verificationMessage, setVerificationMessage] = useState<string | null>(null);
const [verificationLoading, setVerificationLoading] = useState(false);
// 마운트 시 /api/auth/me 를 호출해 인증 상태와 유저 정보를 확인한다.
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setLoading(true);
setError(null);
const res = await fetch("/api/auth/me");
if (cancelled) return;
if (res.status === 401) {
// 비로그인 상태이므로 로그인 페이지로 보낸다.
router.push("/login");
return;
}
if (!res.ok) {
setError(m.loadError);
return;
}
const data = (await res.json()) as AuthUser;
setUser(data);
} catch {
if (!cancelled) {
setError(m.loadError);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [router]);
const handleSubmitChangePassword = async (event: FormEvent) => {
event.preventDefault();
setChangePasswordMessage(null);
if (!currentPassword || !newPassword || !newPasswordConfirm) {
setChangePasswordMessage(m.changePasswordErrorRequired);
return;
}
if (newPassword !== newPasswordConfirm) {
setChangePasswordMessage(m.changePasswordErrorMismatch);
return;
}
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json().catch(() => null as any);
if (!res.ok) {
setChangePasswordMessage(data?.message || m.changePasswordErrorNetwork);
return;
}
setChangePasswordMessage(data?.message || m.changePasswordSuccess);
setCurrentPassword("");
setNewPassword("");
setNewPasswordConfirm("");
} catch {
setChangePasswordMessage(m.changePasswordErrorNetwork);
}
};
const handleRequestEmailVerification = async () => {
// 이메일 인증 메일 전송 요청 상태를 초기화한다.
setVerificationMessage(null);
setVerificationLoading(true);
try {
const res = await fetch("/api/auth/request-email-verification", {
method: "POST",
});
const data = await res.json().catch(() => null as any);
if (!res.ok) {
setVerificationMessage(data?.message || m.emailVerificationError);
return;
}
setVerificationMessage(data?.message || m.emailVerificationSuccess);
} catch {
setVerificationMessage(m.emailVerificationError);
} finally {
setVerificationLoading(false);
}
};
const handleSubmitChangeEmail = async (event: FormEvent) => {
event.preventDefault();
setChangeEmailMessage(null);
if (!newEmail || !emailChangePassword) {
setChangeEmailMessage(m.changeEmailErrorRequired);
return;
}
try {
const res = await fetch("/api/auth/change-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newEmail, password: emailChangePassword }),
});
const data = await res.json().catch(() => null as any);
if (!res.ok) {
setChangeEmailMessage(data?.message || m.changeEmailErrorNetwork);
return;
}
setChangeEmailMessage(data?.message || m.changeEmailSuccess);
setNewEmail("");
setEmailChangePassword("");
// 응답에 새로운 이메일이 있으면 프로필에도 반영한다.
if (data?.email && user) {
setUser({ ...user, email: data.email });
}
} catch {
setChangeEmailMessage(m.changeEmailErrorNetwork);
}
};
return (
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.title}</h1>
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400">
{m.description}
</p>
</div>
<div>
<Link
href="/dashboard"
className="inline-flex items-center rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{m.backToDashboardLabel}
</Link>
</div>
</header>
<section className="flex-1 px-6 py-4 space-y-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{error && (
<p className="text-xs text-red-500 dark:text-red-300">{error}</p>
)}
{loading && !user && !error && (
<p className="text-xs text-slate-400">{m.loadingText}</p>
)}
{user && (
<div className="space-y-4 text-xs">
<div className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70">
<h2 className="text-sm font-semibold mb-2">{m.profileSectionTitle}</h2>
<p className="text-slate-600 dark:text-slate-300">
<span className="font-medium">{m.profileEmailLabel}</span> {user.email}
</p>
<p className="text-slate-600 dark:text-slate-300 mt-1">
<span className="font-medium">{m.profileUserIdLabel}</span> {user.id}
</p>
{user.emailVerified && (
<p className="mt-2 text-[11px] font-medium text-emerald-600 dark:text-emerald-300">
{m.emailVerifiedBadge}
</p>
)}
{!user.emailVerified && (
<>
<p className="text-slate-600 dark:text-slate-300 mt-2">
{m.emailVerificationNotice}
</p>
<button
type="button"
className="mt-2 inline-flex items-center justify-center rounded border border-sky-600 bg-sky-50 px-3 py-1 text-[11px] font-medium text-sky-700 hover:bg-sky-100 disabled:opacity-50 disabled:cursor-not-allowed dark:border-sky-500 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900"
onClick={() => {
void handleRequestEmailVerification();
}}
disabled={verificationLoading}
>
{m.emailVerificationButtonLabel}
</button>
{verificationMessage && (
<p className="mt-2 text-[11px] text-slate-600 dark:text-slate-300">
{verificationMessage}
</p>
)}
</>
)}
</div>
<div className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70">
<h2 className="text-sm font-semibold mb-2">{m.changePasswordTitle}</h2>
<form onSubmit={handleSubmitChangePassword} className="space-y-2">
<div className="space-y-1">
<label className="block text-slate-700 dark:text-slate-200">
{m.changePasswordCurrentLabel}
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
/>
</div>
<div className="space-y-1">
<label className="block text-slate-700 dark:text-slate-200">
{m.changePasswordNewLabel}
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
minLength={8}
/>
</div>
<div className="space-y-1">
<label className="block text-slate-700 dark:text-slate-200">
{m.changePasswordConfirmLabel}
</label>
<input
type="password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
minLength={8}
/>
</div>
<button
type="submit"
className="mt-1 inline-flex items-center justify-center rounded bg-sky-600 hover:bg-sky-500 px-3 py-1 text-xs font-medium text-white"
>
{m.changePasswordSubmitLabel}
</button>
{changePasswordMessage && (
<p className="mt-2 text-[11px] text-slate-600 dark:text-slate-300">
{changePasswordMessage}
</p>
)}
</form>
</div>
<div className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70">
<h2 className="text-sm font-semibold mb-2">{m.changeEmailTitle}</h2>
<form onSubmit={handleSubmitChangeEmail} className="space-y-2">
<div className="space-y-1">
<label className="block text-slate-700 dark:text-slate-200">
{m.changeEmailNewLabel}
</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
/>
</div>
<div className="space-y-1">
<label className="block text-slate-700 dark:text-slate-200">
Current password
</label>
<input
type="password"
value={emailChangePassword}
onChange={(e) => setEmailChangePassword(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
minLength={8}
/>
</div>
<button
type="submit"
className="mt-1 inline-flex items-center justify-center rounded bg-sky-600 hover:bg-sky-500 px-3 py-1 text-xs font-medium text-white"
>
{m.changeEmailSubmitLabel}
</button>
{changeEmailMessage && (
<p className="mt-2 text-[11px] text-slate-600 dark:text-slate-300">
{changeEmailMessage}
</p>
)}
</form>
</div>
</div>
)}
</section>
</main>
);
}