2026-04-13 19:21:42 +08:00
|
|
|
"use client";
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
2026-01-18 05:13:53 +08:00
|
|
|
import { useAuthActions } from "@convex-dev/auth/react";
|
|
|
|
|
import { useState, useCallback, useEffect } from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
2026-01-22 18:53:20 +08:00
|
|
|
import { api } from "@/lib/convex/api";
|
2026-01-24 12:32:51 +08:00
|
|
|
import { getUserFacingErrorMessage } from "@/lib/auth/errors";
|
2026-04-13 19:21:42 +08:00
|
|
|
|
|
|
|
|
type AuthStep = "signIn" | "signUp";
|
|
|
|
|
|
|
|
|
|
// 测试账号凭据常量
|
2026-05-06 21:44:20 +08:00
|
|
|
const TEST_CREDENTIALS = {
|
|
|
|
|
email: "mnote.e2e@example.com",
|
|
|
|
|
password: "MnoteE2E123!",
|
|
|
|
|
} as const;
|
2026-04-13 19:21:42 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convex Auth 登录/注册页面
|
|
|
|
|
*
|
|
|
|
|
* 支持功能:
|
|
|
|
|
* - 邮箱密码登录
|
|
|
|
|
* - 邮箱密码注册
|
|
|
|
|
*/
|
2026-01-18 05:13:53 +08:00
|
|
|
export default function AuthPage() {
|
|
|
|
|
const { isLoading, isAuthenticated } = useConvexAuth();
|
|
|
|
|
const { signIn } = useAuthActions();
|
2026-01-22 18:53:20 +08:00
|
|
|
const setMyUsername = useMutation(api.users.setMyUsername);
|
2026-01-18 05:13:53 +08:00
|
|
|
const router = useRouter();
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const navigateToHome = useCallback(() => {
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
window.location.assign("/");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
router.replace("/");
|
|
|
|
|
}, [router]);
|
|
|
|
|
|
2026-01-18 05:13:53 +08:00
|
|
|
useEffect(() => {
|
2026-01-22 18:53:20 +08:00
|
|
|
if (isLoading) return;
|
|
|
|
|
if (!isAuthenticated) return;
|
|
|
|
|
if (currentUser === undefined) return;
|
|
|
|
|
if (currentUser && currentUser.name) {
|
2026-04-17 23:36:24 +08:00
|
|
|
navigateToHome();
|
2026-01-18 05:13:53 +08:00
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
}, [currentUser, isAuthenticated, isLoading, navigateToHome]);
|
2026-04-13 19:21:42 +08:00
|
|
|
|
2026-01-18 05:13:53 +08:00
|
|
|
const [flow, setFlow] = useState<AuthStep>("signIn");
|
|
|
|
|
const [email, setEmail] = useState("");
|
|
|
|
|
const [password, setPassword] = useState("");
|
2026-01-22 18:53:20 +08:00
|
|
|
const [username, setUsername] = useState("");
|
2026-01-24 12:32:51 +08:00
|
|
|
const [pendingUsernameToSave, setPendingUsernameToSave] = useState<string | null>(null);
|
|
|
|
|
const [isSavingUsername, setIsSavingUsername] = useState(false);
|
2026-01-18 05:13:53 +08:00
|
|
|
const [message, setMessage] = useState<{
|
|
|
|
|
type: "success" | "error" | "info";
|
|
|
|
|
text: string;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
// 注册完成后,等待登录态同步完成再写入用户名,避免出现“未登录”的竞态
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!pendingUsernameToSave) return;
|
|
|
|
|
if (!isAuthenticated) return;
|
|
|
|
|
if (currentUser === undefined || currentUser === null) return;
|
|
|
|
|
if (currentUser.name) {
|
|
|
|
|
setPendingUsernameToSave(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setIsSavingUsername(true);
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
await setMyUsername({ username: pendingUsernameToSave });
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setPendingUsernameToSave(null);
|
|
|
|
|
setMessage({ type: "success", text: "注册成功!" });
|
2026-04-17 23:36:24 +08:00
|
|
|
navigateToHome();
|
2026-01-24 12:32:51 +08:00
|
|
|
} catch (err: unknown) {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setPendingUsernameToSave(null);
|
|
|
|
|
setMessage({ type: "error", text: getUserFacingErrorMessage(err, "用户名设置失败,请重试") });
|
|
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) setIsSavingUsername(false);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-04-17 23:36:24 +08:00
|
|
|
}, [currentUser, isAuthenticated, navigateToHome, pendingUsernameToSave, setMyUsername]);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
2026-01-18 05:13:53 +08:00
|
|
|
// 执行登录的核心逻辑
|
|
|
|
|
const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => {
|
|
|
|
|
setMessage(null);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-22 18:53:20 +08:00
|
|
|
if (flow === "signUp" && !username.trim()) {
|
|
|
|
|
setMessage({ type: "error", text: "用户名不能为空" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:13:53 +08:00
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append("email", email);
|
|
|
|
|
formData.append("password", password);
|
|
|
|
|
formData.append("flow", flow);
|
|
|
|
|
|
|
|
|
|
const result = await signIn("password", formData);
|
|
|
|
|
|
|
|
|
|
if (result.signingIn) {
|
2026-01-22 18:53:20 +08:00
|
|
|
if (flow === "signUp") {
|
2026-01-24 12:32:51 +08:00
|
|
|
// 说明:注册已成功,但此刻登录态可能尚未同步到 Convex functions。
|
|
|
|
|
// 这里先进入“待写入用户名”状态,等 currentUser 可用后再写,避免出现“未登录”的竞态。
|
|
|
|
|
setMessage({ type: "info", text: "注册成功,正在保存用户名..." });
|
|
|
|
|
setPendingUsernameToSave(username.trim());
|
|
|
|
|
return;
|
2026-01-22 18:53:20 +08:00
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
// 说明:登录成功后不要立刻强推 `/`。
|
|
|
|
|
// Convex Auth cookie 与 currentUser 查询存在短暂同步窗口,过早跳转会把页面推进到根路由空白态。
|
|
|
|
|
// 这里仅提示“正在同步”,真正跳转交给上面的 useEffect 在登录态稳定后执行。
|
|
|
|
|
setMessage({ type: "info", text: "登录成功,正在同步登录状态..." });
|
2026-01-18 05:13:53 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 说明:部分认证方式会返回 redirect(例如 OAuth);密码模式一般不会走到这里。
|
|
|
|
|
if (result.redirect) {
|
|
|
|
|
setMessage({ type: "info", text: "正在跳转..." });
|
|
|
|
|
router.push(result.redirect.toString());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setMessage({ type: "error", text: "操作失败,请重试" });
|
|
|
|
|
} catch (error: any) {
|
2026-01-24 12:32:51 +08:00
|
|
|
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "操作失败,请重试") });
|
2026-01-18 05:13:53 +08:00
|
|
|
}
|
2026-01-24 12:32:51 +08:00
|
|
|
}, [router, signIn, username]);
|
2026-04-13 19:21:42 +08:00
|
|
|
|
|
|
|
|
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
await performSignIn(email, password, flow);
|
|
|
|
|
}, [email, password, flow, performSignIn]);
|
|
|
|
|
|
|
|
|
|
// 检查是否启用了 Convex
|
|
|
|
|
const isConvex = isConvexEnabled();
|
|
|
|
|
if (!isConvex) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<h1 className="text-2xl font-bold text-red-600 mb-4">认证功能不可用</h1>
|
|
|
|
|
<p className="text-gray-600">请启用 Convex 后端后再试</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
|
|
|
<p className="text-gray-600">加载中...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
if (isAuthenticated && currentUser && currentUser.name) {
|
2026-01-18 05:13:53 +08:00
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
|
|
|
<p className="text-gray-600">正在跳转...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
if (isAuthenticated) {
|
2026-01-24 12:32:51 +08:00
|
|
|
if (currentUser === undefined || currentUser === null) {
|
2026-01-22 18:53:20 +08:00
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
2026-01-24 12:32:51 +08:00
|
|
|
<p className="text-gray-600">正在同步登录状态...</p>
|
2026-01-22 18:53:20 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saveUsername = async () => {
|
|
|
|
|
setMessage(null);
|
|
|
|
|
try {
|
2026-01-24 12:32:51 +08:00
|
|
|
if (isSavingUsername) return;
|
|
|
|
|
setIsSavingUsername(true);
|
|
|
|
|
await setMyUsername({ username: username.trim() });
|
2026-01-22 18:53:20 +08:00
|
|
|
setMessage({ type: "success", text: "用户名已保存!" });
|
2026-04-17 23:36:24 +08:00
|
|
|
navigateToHome();
|
2026-01-22 18:53:20 +08:00
|
|
|
} catch (error: any) {
|
2026-01-24 12:32:51 +08:00
|
|
|
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "保存失败,请重试") });
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSavingUsername(false);
|
2026-01-22 18:53:20 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
|
|
|
<div className="max-w-md w-full space-y-8">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">设置用户名</h2>
|
|
|
|
|
<p className="mt-2 text-center text-sm text-gray-600">用于分享功能的用户查找(全局唯一)</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{message && (
|
|
|
|
|
<div className={`rounded-md p-4 ${
|
|
|
|
|
message.type === "success" ? "bg-green-50 text-green-800" :
|
|
|
|
|
message.type === "error" ? "bg-red-50 text-red-800" :
|
|
|
|
|
"bg-blue-50 text-blue-800"
|
|
|
|
|
}`}>
|
|
|
|
|
<p className="text-sm">{message.text}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label htmlFor="username" className="sr-only">用户名</label>
|
|
|
|
|
<input
|
|
|
|
|
id="username"
|
|
|
|
|
name="username"
|
|
|
|
|
type="text"
|
|
|
|
|
autoComplete="username"
|
|
|
|
|
required
|
|
|
|
|
value={username}
|
|
|
|
|
onChange={(e) => setUsername(e.target.value)}
|
2026-01-24 12:32:51 +08:00
|
|
|
disabled={isSavingUsername}
|
2026-01-22 18:53:20 +08:00
|
|
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
|
|
|
|
placeholder="用户名(2-32 位,不含空格)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void saveUsername()}
|
2026-01-24 12:32:51 +08:00
|
|
|
disabled={isSavingUsername}
|
2026-01-22 18:53:20 +08:00
|
|
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
|
|
|
>
|
2026-01-24 12:32:51 +08:00
|
|
|
{isSavingUsername ? "保存中..." : "保存并继续"}
|
2026-01-22 18:53:20 +08:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:13:53 +08:00
|
|
|
return (
|
2026-04-13 19:21:42 +08:00
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
|
|
|
<div className="max-w-md w-full space-y-8">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
|
|
|
|
{flow === "signIn" ? "登录账户" : "创建账户"}
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="mt-2 text-center text-sm text-gray-600">
|
|
|
|
|
使用 Convex Auth
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{message && (
|
|
|
|
|
<div className={`rounded-md p-4 ${
|
|
|
|
|
message.type === "success" ? "bg-green-50 text-green-800" :
|
|
|
|
|
message.type === "error" ? "bg-red-50 text-red-800" :
|
|
|
|
|
"bg-blue-50 text-blue-800"
|
|
|
|
|
}`}>
|
|
|
|
|
<p className="text-sm">{message.text}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
|
|
|
<div className="rounded-md shadow-sm -space-y-px">
|
2026-01-18 05:13:53 +08:00
|
|
|
<div>
|
|
|
|
|
<label htmlFor="email" className="sr-only">邮箱地址</label>
|
|
|
|
|
<input
|
|
|
|
|
id="email"
|
|
|
|
|
name="email"
|
|
|
|
|
type="email"
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
required
|
|
|
|
|
value={email}
|
|
|
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
|
|
|
|
placeholder="邮箱地址"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{flow === "signUp" && (
|
|
|
|
|
<div>
|
2026-01-22 18:53:20 +08:00
|
|
|
<label htmlFor="username" className="sr-only">用户名</label>
|
2026-01-18 05:13:53 +08:00
|
|
|
<input
|
2026-01-22 18:53:20 +08:00
|
|
|
id="username"
|
|
|
|
|
name="username"
|
2026-01-18 05:13:53 +08:00
|
|
|
type="text"
|
2026-01-22 18:53:20 +08:00
|
|
|
autoComplete="username"
|
|
|
|
|
required
|
|
|
|
|
value={username}
|
|
|
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
|
|
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
|
|
|
|
placeholder="用户名(2-32 位,不含空格)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-18 05:13:53 +08:00
|
|
|
)}
|
|
|
|
|
<div>
|
2026-04-13 19:21:42 +08:00
|
|
|
<label htmlFor="password" className="sr-only">密码</label>
|
|
|
|
|
<input
|
|
|
|
|
id="password"
|
|
|
|
|
name="password"
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
|
|
|
|
|
required
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
|
|
|
|
placeholder="密码(至少 8 位)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
|
|
|
>
|
|
|
|
|
{flow === "signIn" ? "登录" : "注册"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{flow === "signIn" && (
|
|
|
|
|
<div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEmail(TEST_CREDENTIALS.email);
|
|
|
|
|
setPassword(TEST_CREDENTIALS.password);
|
|
|
|
|
// 直接调用登录逻辑
|
|
|
|
|
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
|
|
|
|
|
}}
|
|
|
|
|
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
|
|
|
|
>
|
|
|
|
|
测试账号快速登录
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setFlow(flow === "signIn" ? "signUp" : "signIn");
|
|
|
|
|
setMessage(null);
|
|
|
|
|
}}
|
|
|
|
|
className="text-blue-600 hover:text-blue-500 text-sm"
|
|
|
|
|
>
|
|
|
|
|
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|