260 lines
9.3 KiB
TypeScript
260 lines
9.3 KiB
TypeScript
/**
|
||
* 登录、注册及认证回调共用的客户端页面。左侧品牌说明为展示内容,右侧管理表单/OAuth/验证状态。
|
||
* 成功会话由同源代理写HttpOnly Cookie,组件不持有access_token;busy仅控制当前表单交互和加载提示。
|
||
*/
|
||
"use client";
|
||
|
||
import { useEffect, useState, type FormEvent } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
ArrowRight,
|
||
Eye,
|
||
EyeOff,
|
||
ArrowUpRight,
|
||
LoaderCircle,
|
||
} from "lucide-react";
|
||
import { Brand } from "@/components/brand";
|
||
import { Button } from "@/components/ui/button";
|
||
import { api, errorText, startOAuth } from "@/lib/api";
|
||
/** 状态分别表示登录/注册模式、邮箱密码、密码可见性、请求忙碌和成功/错误文案,均不存入业务文档。 */
|
||
export default function Login() {
|
||
const router = useRouter();
|
||
const [register, setRegister] = useState(false),
|
||
[email, setEmail] = useState(""),
|
||
[password, setPassword] = useState(""),
|
||
[show, setShow] = useState(false),
|
||
[busy, setBusy] = useState(false),
|
||
[message, setMessage] = useState(""),
|
||
[error, setError] = useState("");
|
||
/** 页面挂载读取回调片段,先清掉URL查询/片段再发验证或交换请求;这样重跑不会再次消费同一片段。
|
||
* 此effect没有单独AbortController,不具备画布loader的导航代次隔离;结果由此页面的router/state回调处理。 */
|
||
useEffect(() => {
|
||
const hash = new URLSearchParams(location.hash.slice(1));
|
||
const code = hash.get("code"),
|
||
verify = hash.get("verify_email"),
|
||
failure = hash.get("error");
|
||
if (!code && !verify && !failure) return;
|
||
history.replaceState(null, "", location.pathname);
|
||
if (failure) {
|
||
setError("第三方登录未完成,请重新尝试。");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
// verifier从本标签页sessionStorage取出即删除,避免后续登录误复用;verify_email分支优先于code交换。
|
||
const verifier = sessionStorage.getItem("foundry_verifier");
|
||
sessionStorage.removeItem("foundry_verifier");
|
||
// 邮箱验证提交token;OAuth交换只用一次性code+verifier,请求代理负责把返回令牌改写成Cookie。
|
||
(verify
|
||
? api("auth/verify-email", {
|
||
method: "POST",
|
||
body: JSON.stringify({ token: verify }),
|
||
})
|
||
: verifier
|
||
? api("auth/exchange", {
|
||
method: "POST",
|
||
headers: {
|
||
"X-Foundry-Code": code!,
|
||
"X-Foundry-Verifier": verifier,
|
||
},
|
||
})
|
||
: Promise.reject(new Error("invalid_session"))
|
||
)
|
||
.then(() => router.replace("/dashboard"))
|
||
.catch((e) => setError(errorText(e)))
|
||
.finally(() => setBusy(false));
|
||
}, [router]);
|
||
/** 阻止原生表单跳转,提交当前模式对应端点;注册后提示验证邮件,登录成功才push工作台。
|
||
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
|
||
async function submit(e: FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
setError("");
|
||
setMessage("");
|
||
try {
|
||
await api("auth/" + (register ? "register" : "login"), {
|
||
method: "POST",
|
||
body: JSON.stringify({ email, password }),
|
||
});
|
||
if (register)
|
||
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
||
else router.push("/dashboard");
|
||
} catch (e) {
|
||
setError(errorText(e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
/** 清旧提示,创建PKCE后整页跳转;发起失败恢复表单状态,成功保持busy等待浏览器导航。 */
|
||
async function oauth(p: "google" | "slack") {
|
||
setError("");
|
||
setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…");
|
||
setBusy(true);
|
||
try {
|
||
await startOAuth(p);
|
||
} catch {
|
||
setBusy(false);
|
||
setMessage("");
|
||
setError("无法启动登录,请重新尝试。");
|
||
}
|
||
}
|
||
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
|
||
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
|
||
return (
|
||
<div className="auth-page">
|
||
<aside className="auth-story">
|
||
<Brand light />
|
||
<div className="auth-story-copy">
|
||
<div className="eyebrow">A SPACE FOR YOUR NEXT IDEA</div>
|
||
<h1>
|
||
给想法留白,
|
||
<br />
|
||
让灵感有形。
|
||
</h1>
|
||
<p>
|
||
收集一闪而过的灵感,把有用的知识连接起来。
|
||
<br />
|
||
下一件作品,从这里开始。
|
||
</p>
|
||
</div>
|
||
<div className="auth-collage">
|
||
<div className="auth-orbit" />
|
||
<article className="auth-note">
|
||
<small>01 / THE IDEA</small>
|
||
<h2>
|
||
留住清晨
|
||
<br />
|
||
的一束光。
|
||
</h2>
|
||
<p>灵感片段 · 随手记下</p>
|
||
</article>
|
||
<article className="auth-art">
|
||
<small>02 / IN THE MAKING</small>
|
||
<div className="light-shape" />
|
||
<h3>光的形状</h3>
|
||
<p>让一个念头,慢慢成形。</p>
|
||
</article>
|
||
</div>
|
||
<span className="auth-caption">COLLECT. CONNECT. CREATE.</span>
|
||
<footer>啡跃 Endoleap 为下一次创作,留一个起点。</footer>
|
||
</aside>
|
||
<main className="auth-main">
|
||
<Link href="/" className="auth-back">
|
||
返回首页 <ArrowUpRight size={14} />
|
||
</Link>
|
||
<div className="auth-form-wrap">
|
||
<div className="eyebrow">WELCOME TO YOUR CREATIVE SPACE</div>
|
||
<h1>{register ? "创建你的账户" : "欢迎回来"}</h1>
|
||
<p>
|
||
{register ? "为下一次创作,留一个起点。" : "登录,继续你的创作。"}
|
||
</p>
|
||
<div className="auth-tabs" role="tablist" aria-label="登录方式">
|
||
<button
|
||
role="tab"
|
||
aria-selected={!register}
|
||
onClick={() => {
|
||
setRegister(false);
|
||
setError("");
|
||
setMessage("");
|
||
}}
|
||
>
|
||
登录
|
||
</button>
|
||
<button
|
||
role="tab"
|
||
aria-selected={register}
|
||
onClick={() => {
|
||
setRegister(true);
|
||
setError("");
|
||
setMessage("");
|
||
}}
|
||
>
|
||
创建账户
|
||
</button>
|
||
</div>
|
||
<form onSubmit={submit}>
|
||
<label htmlFor="email">邮箱地址</label>
|
||
<input
|
||
id="email"
|
||
type="email"
|
||
autoComplete="email"
|
||
placeholder="you@example.com"
|
||
required
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
/>
|
||
<label htmlFor="password">密码</label>
|
||
<div className="password-field">
|
||
<input
|
||
id="password"
|
||
type={show ? "text" : "password"}
|
||
autoComplete={register ? "new-password" : "current-password"}
|
||
placeholder={register ? "至少 12 个字符" : "输入你的密码"}
|
||
required
|
||
minLength={register ? 12 : 1}
|
||
maxLength={72}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={show ? "隐藏密码" : "显示密码"}
|
||
onClick={() => setShow(!show)}
|
||
>
|
||
{show ? <EyeOff size={18} /> : <Eye size={18} />}
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p role="alert" className="form-error">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{message && (
|
||
<p role="status" className="form-success">
|
||
{message}
|
||
</p>
|
||
)}
|
||
<Button className="auth-submit" disabled={busy} type="submit">
|
||
{busy ? (
|
||
<LoaderCircle className="animate-spin" size={18} />
|
||
) : (
|
||
<>
|
||
{register ? "创建账户" : "登录"}
|
||
<ArrowRight size={17} />
|
||
</>
|
||
)}
|
||
</Button>
|
||
</form>
|
||
<div className="auth-divider">
|
||
<span>或使用以下方式继续</span>
|
||
</div>
|
||
<div className="social-buttons">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => oauth("google")}
|
||
disabled={busy}
|
||
>
|
||
<strong className="google-mark">G</strong> Google
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => oauth("slack")}
|
||
disabled={busy}
|
||
>
|
||
<strong className="slack-mark">#</strong> Slack
|
||
</Button>
|
||
</div>
|
||
<p className="auth-notice">
|
||
首次注册需要验证邮箱。
|
||
<br />
|
||
验证成功后,即可进入你的创作空间。
|
||
</p>
|
||
<Link href="/dashboard?demo=1" className="demo-link">
|
||
先看看工作台 <ArrowUpRight size={13} />
|
||
</Link>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|