diff --git a/app/[locale]/login/verify/VerifyEmailForm.tsx b/app/[locale]/login/verify/VerifyEmailForm.tsx new file mode 100644 index 00000000..3e3db419 --- /dev/null +++ b/app/[locale]/login/verify/VerifyEmailForm.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { type FormEvent, useEffect, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import Link from "next/link"; +import { Button } from "@/app/components/ui/button"; +import { labelOf } from "@/app/[locale]/settings/LinkedAccounts"; +import { armLoginNonce } from "@/lib/use-auth"; + +type Step = "loading" | "email" | "code" | "expired"; + +// /auth/register/* 用状态码区分失败原因,这里映射成 verifyEmail 下的 i18n key +export function resolveVerifyError( + status: number, + step: "send" | "verify", +): string { + switch (status) { + case 400: + return step === "send" ? "errorInvalidEmail" : "errorInvalidCode"; + case 409: + return "errorEmailTaken"; + case 410: + return "errorExpired"; + case 429: + return "errorRateLimited"; + default: + return step === "send" ? "errorSendFailed" : "errorGeneric"; + } +} + +const ALERT_CLASS = + "w-full rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"; +const INPUT_CLASS = + "mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"; + +export function VerifyEmailForm() { + const t = useTranslations("verifyEmail"); + const locale = useLocale(); + const [step, setStep] = useState("loading"); + const [provider, setProvider] = useState(""); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const vars = { provider: labelOf(provider), email }; + + // 会话凭据是 httpOnly 的 ih_reg_pending cookie,同源 fetch 自动带上 + useEffect(() => { + fetch("/auth/register/pending") + .then((res) => (res.ok ? res.json() : Promise.reject())) + .then(({ data }) => { + setProvider(data.provider); + setEmail(data.providerEmail ?? ""); + setStep("email"); + }) + .catch(() => setStep("expired")); + }, []); + + async function post(kind: "send" | "verify", body: object) { + setBusy(true); + setError(null); + const res = await fetch( + kind === "send" ? "/auth/register/send-otp" : "/auth/register/verify", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ).catch(() => null); + if (res?.ok) return res.json(); + setBusy(false); + // 409 时后端已消费掉注册会话,和 410 一样是终态 + if (res?.status === 410 || res?.status === 409) setStep("expired"); + setError(res ? resolveVerifyError(res.status, kind) : "errorGeneric"); + return null; + } + + async function sendCode(e?: FormEvent) { + e?.preventDefault(); + if (!(await post("send", { email }))) return; + setBusy(false); + setCode(""); + setStep("code"); + } + + // 登录交给 use-auth 现成的 #token 流程(含 SSO 续接),所以同样要先 arm nonce + async function verify(e: FormEvent) { + e.preventDefault(); + const body = await post("verify", { email, code }); + if (!body) return; + const n = armLoginNonce(sessionStorage); + window.location.replace( + `/${locale}#token=${encodeURIComponent(body.data.tokenValue)}&n=${n}`, + ); + } + + if (step === "loading") { + return ( +
+ ); + } + + if (step === "expired") { + return ( +
+
+ {t(error ?? "errorExpired", vars)} +
+ + {t("backToLogin")} + +
+ ); + } + + return ( +
+

{t("intro", vars)}

+ {error && ( +
+ {t(error, vars)} +
+ )} + {step === "email" ? ( +
+ + +
+ ) : ( +
+

{t("codeSent", vars)}

+ + +
+ + +
+
+ )} +

+ {t("haveAccount", vars)}{" "} + + {t("backToLogin")} + +

+
+ ); +} diff --git a/app/[locale]/login/verify/page.tsx b/app/[locale]/login/verify/page.tsx new file mode 100644 index 00000000..03a4b960 --- /dev/null +++ b/app/[locale]/login/verify/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { setRequestLocale, getTranslations } from "next-intl/server"; +import { hasLocale } from "next-intl"; +import { notFound } from "next/navigation"; +import { routing } from "@/i18n/routing"; +import { VerifyEmailForm } from "./VerifyEmailForm"; + +export const metadata: Metadata = { + title: "Verify Email", + robots: { index: false, follow: false }, +}; + +interface Props { + params: Promise<{ locale: string }>; +} + +// 注册会话只在 httpOnly cookie 里,内容全靠客户端拉取;页面壳照常 SSG +export default async function VerifyEmailPage({ params }: Props) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + + const t = await getTranslations("verifyEmail"); + return ( +
+
+

{t("title")}

+ +
+
+ ); +} + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx index 1558bc60..393dfb3d 100644 --- a/app/[locale]/settings/LinkedAccounts.tsx +++ b/app/[locale]/settings/LinkedAccounts.tsx @@ -1,7 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; -import { useTranslations } from "next-intl"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; type LinkedIdentity = { provider: string; @@ -15,41 +17,106 @@ function getToken(): string | null { return localStorage.getItem("satoken"); } +// 只是展示名。后端 /identities/providers 才是"有哪些登录方式"的真相源, +// 这里查不到就回退显示原始 key,保证新接入的 provider 不会静默消失。 const PROVIDER_LABEL: Record = { github: "GitHub", discord: "Discord", }; +export function labelOf(provider: string): string { + return Object.hasOwn(PROVIDER_LABEL, provider) + ? PROVIDER_LABEL[provider]! + : provider; +} + +// 后端绑定回调会跳回 /settings?bind=ok 或 ?bind_error= +const BIND_ERROR_KEYS: Record = { + bind_taken: "bindTaken", + bind_duplicate: "bindDuplicate", + bind_already_yours: "bindAlreadyYours", + bind_session: "bindSession", + oauth_state: "bindFailed", + oauth_provider: "bindFailed", + oauth_failed: "bindFailed", +}; + /** - * 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程, - * 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。 + * 已绑定登录方式的查看 / 绑定(M2b) / 解绑(M2a)。 + * + * 绑定必须走 /oauth/bind/{provider} 而不是普通登录入口:后者会把用户登成新账号 + * (分叉),而分叉之后那个第三方身份就被新账号占住,本尊再也补绑不回来。 */ export function LinkedAccounts() { const t = useTranslations("settings.linked"); + const locale = useLocale(); const [items, setItems] = useState(null); - const [error, setError] = useState(null); + const [supported, setSupported] = useState([]); + const [error, setError] = useState(null); const [busy, setBusy] = useState(null); + const params = useSearchParams(); - async function load() { + // 回调结果直接从 URL 派生,不能放进 error state:load() 成功时会 setError(null) 把它冲掉 + const bindOk = params.get("bind") === "ok"; + const bindCode = params.get("bind_error"); + const bindError = bindCode + ? t( + Object.hasOwn(BIND_ERROR_KEYS, bindCode) + ? BIND_ERROR_KEYS[bindCode]! + : "bindFailed", + ) + : null; + + const load = useCallback(async () => { const token = getToken(); if (!token) return; try { - const res = await fetch("/api/user-center/identities", { - headers: { satoken: token }, - }); - if (!res.ok) throw new Error(); - const body = await res.json(); - setItems(body.data ?? []); + const [identities, providers] = await Promise.all([ + fetch("/api/user-center/identities", { headers: { satoken: token } }), + fetch("/api/user-center/identities/providers", { + headers: { satoken: token }, + }), + ]); + if (!identities.ok) throw new Error(); + setItems((await identities.json()).data ?? []); + if (providers.ok) setSupported((await providers.json()).data ?? []); setError(null); } catch { setError(t("loadFail")); } - } + }, [t]); useEffect(() => { load(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [load]); + + // 必须 POST + satoken header 拿授权地址,不能是 GET:GET 可被别的站点直接触发 + async function connect(provider: string) { + const token = getToken(); + if (!token) return; + setBusy(provider); + const res = await fetch(`/oauth/bind/${provider}`, { + method: "POST", + headers: { satoken: token }, + }).catch(() => null); + const body = await res?.json().catch(() => null); + if (res?.ok && typeof body?.data?.url === "string") { + window.location.assign(body.data.url); // 不清 busy,跳走前不让再点 + return; + } + setBusy(null); + setError( + res?.status === 401 + ? t.rich("bindNeedLogin", { + login: (chunks) => ( + + {chunks} + + ), + }) + : (body?.message ?? t("bindFailed")), + ); + } async function unbind(provider: string) { const token = getToken(); @@ -77,36 +144,63 @@ export function LinkedAccounts() { if (items === null && !error) return null; // 未登录或加载中,保持安静 + const bound = items ?? []; + const boundKeys = new Set(bound.map((i) => i.provider)); + const bindable = supported.filter((p) => !boundKeys.has(p)); + return (
+ {bindOk && ( +

+ {t("bindOk")} +

+ )} + {bindError && ( +

{bindError}

+ )} {error && (

{error}

)}
    - {(items ?? []).map((it) => ( + {bound.map((it) => (
  • - {PROVIDER_LABEL[it.provider] ?? it.provider} + {labelOf(it.provider)} {it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
  • ))} - {(items ?? []).length === 0 && ( + {bindable.map((p) => ( +
  • + + {labelOf(p)} + + +
  • + ))} + {bound.length === 0 && bindable.length === 0 && (
  • {t("empty")}
  • diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 7294d24a..5b7965a4 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -2,6 +2,7 @@ // 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取, // 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。 import type { Metadata } from "next"; +import { Suspense } from "react"; import { Header } from "@/app/components/Header"; import { Footer } from "@/app/components/Footer"; import { SettingsForm } from "./SettingsForm"; @@ -30,7 +31,11 @@ export default function SettingsPage() {

- + {/* LinkedAccounts 用 useSearchParams 读绑定回调结果(?bind=ok / ?bind_error=), + 必须有 Suspense 边界,否则整页会被拖成动态渲染 */} + + +