From dfcc718fa7fa95545a2674398220be02e855d367 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 17:40:57 +0800 Subject: [PATCH] feat(identity): add gated self-hosted otp login --- frontend/src/app/login/page.tsx | 119 +--------- frontend/src/components/email-otp-login.tsx | 209 ++++++++++++++++++ frontend/src/modules/identity/client.ts | 58 +++++ .../tests/identity-login-provider.test.ts | 84 +++++++ 4 files changed, 355 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/email-otp-login.tsx create mode 100644 frontend/src/modules/identity/client.ts create mode 100644 frontend/tests/identity-login-provider.test.ts diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 0d092d76..9c21e7cf 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,118 +1,7 @@ -"use client"; - -import Image from "next/image"; -import { FormEvent, useState } from "react"; -import { createBrowserSupabaseClient } from "@/lib/supabase/client"; - -function authMessage(caught: unknown) { - const message = caught instanceof Error ? caught.message : "暂时无法登录"; - const lower = message.toLowerCase(); - if (message.includes("Supabase") || message.includes("environment") || message.includes("URL")) return "Supabase 尚未配置"; - if (lower.includes("expired") || lower.includes("invalid")) return "验证码错误或已过期,请重新获取"; - if (lower.includes("rate limit")) return "发送过于频繁,请稍后再试"; - return message; -} +import { EmailOtpLogin } from "@/components/email-otp-login"; +import { readIdentityConfig } from "@/modules/identity/config"; export default function LoginPage() { - const [email, setEmail] = useState(""); - const [token, setToken] = useState(""); - const [sent, setSent] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [notice, setNotice] = useState(""); - - async function sendOtp(event?: FormEvent) { - event?.preventDefault(); - const normalizedEmail = email.trim(); - if (!normalizedEmail || busy) return; - setBusy(true); - setError(""); - setNotice(""); - try { - const { error: otpError } = await createBrowserSupabaseClient().auth.signInWithOtp({ - email: normalizedEmail, - options: { shouldCreateUser: true }, - }); - if (otpError) throw otpError; - setSent(true); - setNotice(`验证码已发送至 ${normalizedEmail}`); - } catch (caught) { - if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); - } finally { - setBusy(false); - } - } - - async function verifyOtp(event: FormEvent) { - event.preventDefault(); - if (!token || busy) return; - setBusy(true); - setError(""); - try { - const { error: otpError } = await createBrowserSupabaseClient().auth.verifyOtp({ - email: email.trim(), - token, - type: "email", - }); - if (otpError) throw otpError; - window.location.assign("/"); - } catch (caught) { - if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); - setBusy(false); - } - } - - function changeEmail() { - setSent(false); - setToken(""); - setError(""); - setNotice(""); - } - - return ( -
-
- - -
-
-

欢迎回来

-

邮箱验证码登录,新邮箱将自动创建账户。

- - {!sent ? ( -
- - { setEmail(event.target.value); setError(""); setNotice(""); }} placeholder="you@example.com" /> - -
- ) : ( -
- - { setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); setError(""); }} /> - -
- - -
-
- )} - {error &&

{error}

} - {notice &&

{notice}

} -
-
-
- ); + const config = readIdentityConfig(process.env); + return ; } diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx new file mode 100644 index 00000000..a2688746 --- /dev/null +++ b/frontend/src/components/email-otp-login.tsx @@ -0,0 +1,209 @@ +"use client"; + +import Image from "next/image"; +import { FormEvent, useState } from "react"; + +import { createBrowserSupabaseClient } from "@/lib/supabase/client"; +import { selfHostedOtpActions } from "@/modules/identity/client"; + +type AuthProvider = "supabase" | "self-hosted"; + +function authMessage(caught: unknown) { + const message = caught instanceof Error ? caught.message : "暂时无法登录"; + const lower = message.toLowerCase(); + if ( + message.includes("Supabase") || + message.includes("environment") || + message.includes("URL") + ) + return "Supabase 尚未配置"; + if (lower.includes("expired") || lower.includes("invalid")) + return "验证码错误或已过期,请重新获取"; + if (lower.includes("rate limit")) return "发送过于频繁,请稍后再试"; + return message; +} + +export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { + const [email, setEmail] = useState(""); + const [token, setToken] = useState(""); + const [sent, setSent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + async function sendOtp(event?: FormEvent) { + event?.preventDefault(); + const normalizedEmail = email.trim(); + if (!normalizedEmail || busy) return; + setBusy(true); + setError(""); + setNotice(""); + try { + if (provider === "self-hosted") { + await selfHostedOtpActions.send(normalizedEmail); + } else { + const { error: otpError } = + await createBrowserSupabaseClient().auth.signInWithOtp({ + email: normalizedEmail, + options: { shouldCreateUser: true }, + }); + if (otpError) throw otpError; + } + setSent(true); + setNotice(`验证码已发送至 ${normalizedEmail}`); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + } finally { + setBusy(false); + } + } + + async function verifyOtp(event: FormEvent) { + event.preventDefault(); + if (!token || busy) return; + setBusy(true); + setError(""); + try { + if (provider === "self-hosted") { + await selfHostedOtpActions.verify(email, token); + } else { + const { error: otpError } = + await createBrowserSupabaseClient().auth.verifyOtp({ + email: email.trim(), + token, + type: "email", + }); + if (otpError) throw otpError; + } + window.location.assign("/"); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + setBusy(false); + } + } + + function changeEmail() { + setSent(false); + setToken(""); + setError(""); + setNotice(""); + } + + return ( +
+
+ + +
+
+
+

欢迎回来

+

+ 邮箱验证码登录, + 新邮箱将自动创建账户。 +

+ + {!sent ? ( +
+ + { + setEmail(event.target.value); + setError(""); + setNotice(""); + }} + placeholder="you@example.com" + /> + +
+ ) : ( +
+ + { + setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); + setError(""); + }} + /> + +
+ + +
+
+ )} + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts new file mode 100644 index 00000000..a58d7d5e --- /dev/null +++ b/frontend/src/modules/identity/client.ts @@ -0,0 +1,58 @@ +import { createAuthClient } from "better-auth/react"; +import { emailOTPClient } from "better-auth/client/plugins"; + +interface OtpClientResult { + data: unknown; + error: unknown; +} + +export interface SelfHostedOtpClient { + emailOtp: { + sendVerificationOtp(input: { + email: string; + type: "sign-in"; + }): Promise; + }; + signIn: { + emailOtp(input: { + email: string; + otp: string; + }): Promise; + }; +} + +export interface SelfHostedOtpActions { + send(email: string): Promise; + verify(email: string, otp: string): Promise; +} + +export function createSelfHostedOtpActions( + client: SelfHostedOtpClient, +): SelfHostedOtpActions { + return { + async send(email) { + const result = await client.emailOtp.sendVerificationOtp({ + email: email.trim().toLowerCase(), + type: "sign-in", + }); + if (result.error) { + throw new Error("暂时无法发送验证码,请稍后再试"); + } + }, + async verify(email, otp) { + const result = await client.signIn.emailOtp({ + email: email.trim().toLowerCase(), + otp, + }); + if (result.error) { + throw new Error("验证码错误或已过期,请重新获取"); + } + }, + }; +} + +const authClient = createAuthClient({ plugins: [emailOTPClient()] }); + +export const selfHostedOtpActions = createSelfHostedOtpActions( + authClient as SelfHostedOtpClient, +); diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts new file mode 100644 index 00000000..0d69aeb1 --- /dev/null +++ b/frontend/tests/identity-login-provider.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { createSelfHostedOtpActions } from "../src/modules/identity/client.ts"; + +test("login page selects the auth provider from server-only validated config", () => { + const page = readFileSync( + new URL("../src/app/login/page.tsx", import.meta.url), + "utf8", + ); + + assert.doesNotMatch(page, /["']use client["']/); + assert.match(page, /readIdentityConfig\(process\.env\)/); + assert.match(page, /provider=\{config\.provider\}/); + assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/); +}); + +test("self-hosted OTP actions call Better Auth without browser token storage", async () => { + const calls: Array<{ operation: string; input: Record }> = []; + const actions = createSelfHostedOtpActions({ + emailOtp: { + async sendVerificationOtp(input) { + calls.push({ operation: "send", input }); + return { data: { success: true }, error: null }; + }, + }, + signIn: { + async emailOtp(input) { + calls.push({ operation: "verify", input }); + return { data: { user: { id: "user-id" } }, error: null }; + }, + }, + }); + + await actions.send(" Person@Example.com "); + await actions.verify(" Person@Example.com ", "123456"); + + assert.deepEqual(calls, [ + { + operation: "send", + input: { email: "person@example.com", type: "sign-in" }, + }, + { + operation: "verify", + input: { email: "person@example.com", otp: "123456" }, + }, + ]); + const clientSource = readFileSync( + new URL("../src/modules/identity/client.ts", import.meta.url), + "utf8", + ); + assert.doesNotMatch(clientSource, /localStorage|sessionStorage/); +}); + +test("self-hosted OTP actions expose generic enumeration-safe errors", async () => { + const actions = createSelfHostedOtpActions({ + emailOtp: { + async sendVerificationOtp() { + return { + data: null, + error: { message: "database says account does not exist" }, + }; + }, + }, + signIn: { + async emailOtp() { + return { + data: null, + error: { message: "internal OTP hash 123456 mismatch" }, + }; + }, + }, + }); + + await assert.rejects( + actions.send("missing@example.com"), + new Error("暂时无法发送验证码,请稍后再试"), + ); + await assert.rejects( + actions.verify("missing@example.com", "123456"), + new Error("验证码错误或已过期,请重新获取"), + ); +});