feat(identity): add gated self-hosted otp login

This commit is contained in:
Jesse_Chen
2026-07-21 17:40:57 +08:00
parent cf42430244
commit dfcc718fa7
4 changed files with 355 additions and 115 deletions
+4 -115
View File
@@ -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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="standalone-page auth-page">
<div className="auth-shell">
<aside className="auth-story" aria-label="Jyotisha 简介">
<div className="auth-story-brand">
<Image src="/jyotish-logo.png" alt="" width={32} height={32} sizes="32px" />
<strong>Jyotisha</strong>
</div>
<div>
<p className="auth-kicker">Vedic astrology · </p>
<h2><br /><span className="phrase-nowrap">线</span></h2>
<p></p>
</div>
<p className="auth-footnote"> · · </p>
</aside>
<section className="auth-panel" aria-labelledby="login-title">
<div className="auth-brand"><span aria-hidden="true" /><strong>Jyotisha</strong></div>
<h1 id="login-title"></h1>
<p className="page-intro"><span className="phrase-nowrap"></span></p>
{!sent ? (
<form key="email" className="stack-form auth-step" onSubmit={sendOtp}>
<label htmlFor="login-email"></label>
<input id="login-email" type="email" autoComplete="email" inputMode="email" required autoFocus value={email} onChange={(event) => { setEmail(event.target.value); setError(""); setNotice(""); }} placeholder="you@example.com" />
<button className="button-primary" type="submit" disabled={!email.trim() || busy}>{busy ? "发送中" : "发送验证码"}</button>
</form>
) : (
<form key="otp" className="stack-form auth-step" onSubmit={verifyOtp}>
<label htmlFor="login-token"></label>
<input id="login-token" className="otp-input" type="text" autoComplete="one-time-code" inputMode="numeric" pattern="[0-9]*" minLength={6} maxLength={6} required autoFocus value={token} onChange={(event) => { setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); setError(""); }} />
<button className="button-primary" type="submit" disabled={!token || busy}>{busy ? "验证中" : "验证并登录"}</button>
<div className="inline-actions">
<button type="button" disabled={busy} onClick={() => void sendOtp()}>{busy ? "发送中" : "重新发送验证码"}</button>
<button type="button" disabled={busy} onClick={changeEmail}></button>
</div>
</form>
)}
{error && <p className="form-error" role="alert">{error}</p>}
{notice && <p className="form-success" role="status">{notice}</p>}
</section>
</div>
</main>
);
const config = readIdentityConfig(process.env);
return <EmailOtpLogin provider={config.provider} />;
}
+209
View File
@@ -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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="standalone-page auth-page">
<div className="auth-shell">
<aside className="auth-story" aria-label="Jyotisha 简介">
<div className="auth-story-brand">
<Image
src="/jyotish-logo.png"
alt=""
width={32}
height={32}
sizes="32px"
/>
<strong>Jyotisha</strong>
</div>
<div>
<p className="auth-kicker">Vedic astrology · </p>
<h2>
<br />
<span className="phrase-nowrap">线</span>
</h2>
<p>
</p>
</div>
<p className="auth-footnote"> · · </p>
</aside>
<section className="auth-panel" aria-labelledby="login-title">
<div className="auth-brand">
<span aria-hidden="true" />
<strong>Jyotisha</strong>
</div>
<h1 id="login-title"></h1>
<p className="page-intro">
<span className="phrase-nowrap"></span>
</p>
{!sent ? (
<form key="email" className="stack-form auth-step" onSubmit={sendOtp}>
<label htmlFor="login-email"></label>
<input
id="login-email"
type="email"
autoComplete="email"
inputMode="email"
required
autoFocus
value={email}
onChange={(event) => {
setEmail(event.target.value);
setError("");
setNotice("");
}}
placeholder="you@example.com"
/>
<button
className="button-primary"
type="submit"
disabled={!email.trim() || busy}
>
{busy ? "发送中" : "发送验证码"}
</button>
</form>
) : (
<form key="otp" className="stack-form auth-step" onSubmit={verifyOtp}>
<label htmlFor="login-token"></label>
<input
id="login-token"
className="otp-input"
type="text"
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
minLength={6}
maxLength={6}
required
autoFocus
value={token}
onChange={(event) => {
setToken(event.target.value.replace(/\D/g, "").slice(0, 6));
setError("");
}}
/>
<button
className="button-primary"
type="submit"
disabled={!token || busy}
>
{busy ? "验证中" : "验证并登录"}
</button>
<div className="inline-actions">
<button type="button" disabled={busy} onClick={() => void sendOtp()}>
{busy ? "发送中" : "重新发送验证码"}
</button>
<button type="button" disabled={busy} onClick={changeEmail}>
</button>
</div>
</form>
)}
{error && (
<p className="form-error" role="alert">
{error}
</p>
)}
{notice && (
<p className="form-success" role="status">
{notice}
</p>
)}
</section>
</div>
</main>
);
}
+58
View File
@@ -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<OtpClientResult>;
};
signIn: {
emailOtp(input: {
email: string;
otp: string;
}): Promise<OtpClientResult>;
};
}
export interface SelfHostedOtpActions {
send(email: string): Promise<void>;
verify(email: string, otp: string): Promise<void>;
}
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,
);
@@ -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<string, string> }> = [];
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("验证码错误或已过期,请重新获取"),
);
});