Files
Endoleap/src/app/login/page.tsx
T

260 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 登录、注册及认证回调共用的客户端页面。左侧品牌说明为展示内容,右侧管理表单/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>
);
}