마이페이지, 리포트, 메일인증
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user