@@ -123,13 +254,82 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) {
Jyotisha
- 欢迎回来
-
- 邮箱验证码登录,
- 新邮箱将自动创建账户。
-
+ {title}
+ {intro}
- {!sent ? (
+ {canUsePassword && (
+
+ )}
+
+ {mode === "password" && step === "email" ? (
+
+ ) : step === "email" ? (
- ) : (
+ ) : step === "otp" && mode === "forgot" ? (
+
+ ) : step === "otp" ? (
+ ) : step === "set-password" ? (
+
+ ) : (
+
+
+
)}
+
{error && (
{error}
diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts
index dbd9f8ec..63dac181 100644
--- a/frontend/src/modules/identity/auth-factory.ts
+++ b/frontend/src/modules/identity/auth-factory.ts
@@ -100,6 +100,17 @@ export function buildAuthOptions({
path: "/",
},
},
+ ...(surface === "user"
+ ? {
+ emailAndPassword: {
+ enabled: true,
+ disableSignUp: true,
+ minPasswordLength: 8,
+ maxPasswordLength: 128,
+ revokeSessionsOnPasswordReset: true,
+ },
+ }
+ : {}),
plugins: [
emailOTP(createEmailOtpOptions(emailSender, secret, surface === "admin")),
admin({
diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts
index 15ec58b1..75bd492a 100644
--- a/frontend/src/modules/identity/client.ts
+++ b/frontend/src/modules/identity/client.ts
@@ -1,40 +1,62 @@
import { createAuthClient } from "better-auth/react";
import { emailOTPClient } from "better-auth/client/plugins";
-interface OtpClientResult {
+interface AuthClientResult {
data: unknown;
error: unknown;
}
-export interface SelfHostedOtpClient {
+export interface SelfHostedAuthClient {
emailOtp: {
sendVerificationOtp(input: {
email: string;
type: "sign-in";
- }): Promise;
+ }): Promise;
+ requestPasswordReset?(input: { email: string }): Promise;
+ resetPassword?(input: {
+ email: string;
+ otp: string;
+ password: string;
+ }): Promise;
};
signIn: {
emailOtp(input: {
email: string;
otp: string;
- }): Promise;
+ }): Promise;
+ email?(input: {
+ email: string;
+ password: string;
+ }): Promise;
};
- signOut?(): Promise;
+ signOut?(): Promise;
}
-export interface SelfHostedOtpActions {
+export interface SelfHostedAuthActions {
send(email: string): Promise;
verify(email: string, otp: string): Promise;
+ signInWithPassword(email: string, password: string): Promise;
+ requestPasswordReset(email: string): Promise;
+ resetPassword(email: string, otp: string, password: string): Promise;
+ hasPassword(): Promise;
+ setPassword(password: string): Promise;
signOut(): Promise;
}
-export function createSelfHostedOtpActions(
- client: SelfHostedOtpClient,
-): SelfHostedOtpActions {
+type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise;
+
+function normalizeEmail(email: string): string {
+ return email.trim().toLowerCase();
+}
+
+export function createSelfHostedAuthActions(
+ client: SelfHostedAuthClient,
+ fetcher: Fetcher = fetch,
+): SelfHostedAuthActions {
return {
async send(email) {
const result = await client.emailOtp.sendVerificationOtp({
- email: email.trim().toLowerCase(),
+ email: normalizeEmail(email),
type: "sign-in",
});
if (result.error) {
@@ -43,13 +65,68 @@ export function createSelfHostedOtpActions(
},
async verify(email, otp) {
const result = await client.signIn.emailOtp({
- email: email.trim().toLowerCase(),
+ email: normalizeEmail(email),
otp,
});
if (result.error) {
throw new Error("验证码错误或已过期,请重新获取");
}
},
+ async signInWithPassword(email, password) {
+ if (!client.signIn.email) throw new Error("邮箱或密码错误");
+ const result = await client.signIn.email({
+ email: normalizeEmail(email),
+ password,
+ });
+ if (result.error) throw new Error("邮箱或密码错误");
+ },
+ async requestPasswordReset(email) {
+ if (!client.emailOtp.requestPasswordReset) {
+ throw new Error("暂时无法发送验证码,请稍后再试");
+ }
+ const result = await client.emailOtp.requestPasswordReset({
+ email: normalizeEmail(email),
+ });
+ if (result.error) {
+ throw new Error("暂时无法发送验证码,请稍后再试");
+ }
+ },
+ async resetPassword(email, otp, password) {
+ if (!client.emailOtp.resetPassword) {
+ throw new Error("验证码错误或已过期,请重新获取");
+ }
+ const result = await client.emailOtp.resetPassword({
+ email: normalizeEmail(email),
+ otp,
+ password,
+ });
+ if (result.error) {
+ throw new Error("验证码错误或已过期,请重新获取");
+ }
+ },
+ async hasPassword() {
+ const response = await fetcher("/api/account/password", {
+ credentials: "same-origin",
+ });
+ if (!response.ok) throw new Error("暂时无法确认密码状态,请稍后再试");
+ const body = (await response.json()) as { hasPassword?: unknown };
+ if (typeof body.hasPassword !== "boolean") {
+ throw new Error("暂时无法确认密码状态,请稍后再试");
+ }
+ return body.hasPassword;
+ },
+ async setPassword(password) {
+ const response = await fetcher("/api/account/password", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ newPassword: password }),
+ });
+ if (response.status === 409) {
+ throw new Error("此账户已设置密码,原密码未被更改");
+ }
+ if (!response.ok) throw new Error("暂时无法设置密码,请稍后再试");
+ },
async signOut() {
if (!client.signOut) throw new Error("退出失败,请稍后再试");
const result = await client.signOut();
@@ -58,8 +135,14 @@ export function createSelfHostedOtpActions(
};
}
+export type SelfHostedOtpClient = SelfHostedAuthClient;
+export type SelfHostedOtpActions = SelfHostedAuthActions;
+export const createSelfHostedOtpActions = createSelfHostedAuthActions;
+
const authClient = createAuthClient({ plugins: [emailOTPClient()] });
-export const selfHostedOtpActions = createSelfHostedOtpActions(
- authClient as SelfHostedOtpClient,
+export const selfHostedAuthActions = createSelfHostedAuthActions(
+ authClient as unknown as SelfHostedAuthClient,
);
+
+export const selfHostedOtpActions = selfHostedAuthActions;
diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts
index 5e4bf342..5012c4b9 100644
--- a/frontend/tests/identity-auth-factory.test.ts
+++ b/frontend/tests/identity-auth-factory.test.ts
@@ -107,6 +107,14 @@ test("user and admin auth surfaces have host-only isolated cookies", () => {
assert.equal(adminOptions.secret, config.adminSecret);
assert.equal(userOptions.advanced?.cookiePrefix, "jyotisha-user");
assert.equal(adminOptions.advanced?.cookiePrefix, "jyotisha-admin");
+ assert.deepEqual(userOptions.emailAndPassword, {
+ enabled: true,
+ disableSignUp: true,
+ minPasswordLength: 8,
+ maxPasswordLength: 128,
+ revokeSessionsOnPasswordReset: true,
+ });
+ assert.equal(adminOptions.emailAndPassword, undefined);
for (const options of [userOptions, adminOptions]) {
const attributes = options.advanced?.defaultCookieAttributes;
assert.equal(attributes?.secure, true);
diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts
index 1021f569..f0bd4380 100644
--- a/frontend/tests/identity-auth-integration.test.ts
+++ b/frontend/tests/identity-auth-integration.test.ts
@@ -11,6 +11,10 @@ import {
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
import { createHostIsolatedAuthHandlers } from "../src/modules/identity/host.ts";
+import {
+ GET as getPasswordStatus,
+ POST as setAccountPassword,
+} from "../src/app/api/account/password/route.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
@@ -19,24 +23,45 @@ const runnerPath = fileURLToPath(
const migrationsDirectory = fileURLToPath(
new URL("../db/migrations", import.meta.url),
);
+const userHost = "staging.jyotisha.chat";
+const adminHost = "admin.staging.jyotisha.chat";
function request(
host: string,
path: string,
- body: Record,
+ body?: Record,
+ cookie?: string,
): Request {
+ const headers: Record = {
+ host,
+ origin: `https://${host}`,
+ };
+ if (body) headers["content-type"] = "application/json";
+ if (cookie) headers.cookie = cookie;
return new Request(`https://${host}${path}`, {
- method: "POST",
- headers: {
- "content-type": "application/json",
- host,
- origin: `https://${host}`,
- },
- body: JSON.stringify(body),
+ method: body ? "POST" : "GET",
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
});
}
-test("Better Auth completes OTP sign-in against the migrated identity schema with isolated cookies", async () => {
+function sessionCookie(response: Response): string {
+ return (response.headers.get("set-cookie") ?? "").split(";", 1)[0];
+}
+
+const envKeys = [
+ "AUTH_PROVIDER",
+ "SELF_HOSTED_IDENTITY_ENABLED",
+ "IDENTITY_DATABASE_URL",
+ "AUTH_USER_ORIGIN",
+ "AUTH_ADMIN_ORIGIN",
+ "BETTER_AUTH_USER_SECRET",
+ "BETTER_AUTH_ADMIN_SECRET",
+ "RESEND_API_KEY",
+ "RESEND_FROM_EMAIL",
+] as const;
+
+test("Better Auth supports OTP registration/login, first password, password login, and OTP reset while admin stays OTP-only", async () => {
const fixture = startPostgresFixture();
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
@@ -57,13 +82,33 @@ test("Better Auth completes OTP sign-in against the migrated identity schema wit
"identity_runtime",
"identity-runtime-test-password",
),
- userOrigin: "https://staging.jyotisha.chat",
- adminOrigin: "https://admin.staging.jyotisha.chat",
+ userOrigin: `https://${userHost}`,
+ adminOrigin: `https://${adminHost}`,
userSecret: "user-secret-that-is-at-least-32-bytes-long",
adminSecret: "admin-secret-that-is-at-least-32-bytes-long",
resendApiKey: "re_test",
resendFrom: "Jyotisha ",
};
+ const previousEnv = new Map(
+ envKeys.map((key) => [key, process.env[key]] as const),
+ );
+ Object.assign(process.env, {
+ AUTH_PROVIDER: "self-hosted",
+ SELF_HOSTED_IDENTITY_ENABLED: "true",
+ IDENTITY_DATABASE_URL: config.databaseUrl,
+ AUTH_USER_ORIGIN: config.userOrigin,
+ AUTH_ADMIN_ORIGIN: config.adminOrigin,
+ BETTER_AUTH_USER_SECRET: config.userSecret,
+ BETTER_AUTH_ADMIN_SECRET: config.adminSecret,
+ RESEND_API_KEY: config.resendApiKey,
+ RESEND_FROM_EMAIL: config.resendFrom,
+ });
+
+ const identityGlobal = globalThis as typeof globalThis & {
+ jyotishaIdentityAuth?: ReturnType;
+ };
+ delete identityGlobal.jyotishaIdentityAuth;
+
const sender = new FakeEmailOtpSender();
const pool = createIdentityPool(config.databaseUrl);
const services = createIdentityAuthServices(config, {
@@ -75,81 +120,239 @@ test("Better Auth completes OTP sign-in against the migrated identity schema wit
admin: toNextJsHandler(services.admin),
});
- try {
- const userSend = await handlers.POST(
- request(
- "staging.jyotisha.chat",
- "/api/auth/email-otp/send-verification-otp",
- { email: "person@example.com", type: "sign-in" },
- ),
- );
- assert.equal(userSend.status, 200, await userSend.text());
- assert.equal(sender.messages.length, 1);
-
- const userSignIn = await handlers.POST(
- request("staging.jyotisha.chat", "/api/auth/sign-in/email-otp", {
- email: "person@example.com",
- otp: sender.messages[0].otp,
+ async function otpSignIn(email: string): Promise {
+ const send = await handlers.POST(
+ request(userHost, "/api/auth/email-otp/send-verification-otp", {
+ email,
+ type: "sign-in",
}),
);
- const userCookie = userSignIn.headers.get("set-cookie") ?? "";
- assert.equal(userSignIn.status, 200, await userSignIn.text());
- assert.match(userCookie, /jyotisha-user\.session_token=/);
- assert.doesNotMatch(userCookie, /jyotisha-admin/);
- assert.match(userCookie, /HttpOnly/i);
- assert.match(userCookie, /Secure/i);
- assert.match(userCookie, /SameSite=Lax/i);
- assert.doesNotMatch(userCookie, /Domain=/i);
- assert.equal(fixture.psql("select count(*) from identity.users"), "1");
- assert.equal(fixture.psql("select count(*) from identity.sessions"), "1");
+ assert.equal(send.status, 200);
+ const message = sender.messages.at(-1);
+ assert.equal(message?.email, email);
+ assert.equal(message?.type, "sign-in");
- const adminSend = await handlers.POST(
+ const signIn = await handlers.POST(
+ request(userHost, "/api/auth/sign-in/email-otp", {
+ email,
+ otp: message?.otp,
+ }),
+ );
+ assert.equal(signIn.status, 200);
+ const cookie = sessionCookie(signIn);
+ assert.match(cookie, /^(?:__Secure-)?jyotisha-user\.session_token=/);
+ return cookie;
+ }
+
+ async function passwordSignIn(
+ email: string,
+ password: string,
+ ): Promise {
+ return handlers.POST(
+ request(userHost, "/api/auth/sign-in/email", { email, password }),
+ );
+ }
+
+ try {
+ const unauthenticatedSet = await setAccountPassword(
+ request(userHost, "/api/account/password", {
+ newPassword: "not-authorized",
+ }),
+ );
+ assert.equal(unauthenticatedSet.status, 401);
+
+ const newEmail = "new-user@example.com";
+ const firstPassword = "first-password";
+ const resetPassword = "reset-password";
+ const newUserOtpCookie = await otpSignIn(newEmail);
+
+ const initialStatus = await getPasswordStatus(
+ request(userHost, "/api/account/password", undefined, newUserOtpCookie),
+ );
+ assert.equal(initialStatus.status, 200);
+ assert.deepEqual(await initialStatus.json(), { hasPassword: false });
+
+ const firstSet = await setAccountPassword(
request(
- "admin.staging.jyotisha.chat",
- "/api/auth/email-otp/send-verification-otp",
- { email: "person@example.com", type: "sign-in" },
+ userHost,
+ "/api/account/password",
+ { newPassword: firstPassword },
+ newUserOtpCookie,
),
);
- assert.equal(adminSend.status, 200, await adminSend.text());
- const deniedAdminSignIn = await handlers.POST(
+ assert.equal(firstSet.status, 200);
+
+ const secondSet = await setAccountPassword(
request(
- "admin.staging.jyotisha.chat",
- "/api/auth/sign-in/email-otp",
- { email: "person@example.com", otp: sender.messages[1].otp },
+ userHost,
+ "/api/account/password",
+ { newPassword: "must-not-overwrite" },
+ newUserOtpCookie,
),
);
- assert.equal(deniedAdminSignIn.status, 403);
- assert.equal(deniedAdminSignIn.headers.has("set-cookie"), false);
- assert.equal(fixture.psql("select count(*) from identity.sessions"), "1");
+ assert.equal(secondSet.status, 409);
+
+ const storedHash = fixture.psql(
+ "select password from identity.accounts where provider_id = 'credential' and user_id = (select id from identity.users where email = 'new-user@example.com')",
+ );
+ assert.notEqual(storedHash, firstPassword);
+ assert.match(storedHash, /^[0-9a-f]{32}:[0-9a-f]{128}$/);
+
+ const passwordLogin = await passwordSignIn(newEmail, firstPassword);
+ assert.equal(passwordLogin.status, 200);
+ const passwordCookie = sessionCookie(passwordLogin);
+ assert.match(passwordCookie, /^(?:__Secure-)?jyotisha-user\.session_token=/);
+
+ const wrongPassword = await passwordSignIn(newEmail, "wrong-password");
+ assert.notEqual(wrongPassword.status, 200);
+ assert.equal(wrongPassword.headers.has("set-cookie"), false);
+
+ const otpLoginCookie = await otpSignIn(newEmail);
+ assert.match(otpLoginCookie, /^(?:__Secure-)?jyotisha-user\.session_token=/);
+
+ const oldOtpEmail = "otp-only@example.com";
+ const firstOldOtpCookie = await otpSignIn(oldOtpEmail);
+ const signOut = await handlers.POST(
+ request(
+ userHost,
+ "/api/auth/sign-out",
+ {},
+ firstOldOtpCookie,
+ ),
+ );
+ assert.equal(signOut.status, 200);
+ const returningOldOtpCookie = await otpSignIn(oldOtpEmail);
+ const oldOtpStatus = await getPasswordStatus(
+ request(
+ userHost,
+ "/api/account/password",
+ undefined,
+ returningOldOtpCookie,
+ ),
+ );
+ assert.deepEqual(await oldOtpStatus.json(), { hasPassword: false });
+ const oldOtpSet = await setAccountPassword(
+ request(
+ userHost,
+ "/api/account/password",
+ { newPassword: "old-user-password" },
+ returningOldOtpCookie,
+ ),
+ );
+ assert.equal(oldOtpSet.status, 200);
+ assert.equal(
+ (await passwordSignIn(oldOtpEmail, "old-user-password")).status,
+ 200,
+ );
+
+ const unknownResetMessageCount = sender.messages.length;
+ const unknownReset = await handlers.POST(
+ request(userHost, "/api/auth/email-otp/request-password-reset", {
+ email: "missing@example.com",
+ }),
+ );
+ assert.equal(unknownReset.status, 200);
+ assert.equal(sender.messages.length, unknownResetMessageCount);
+
+ const resetRequest = await handlers.POST(
+ request(userHost, "/api/auth/email-otp/request-password-reset", {
+ email: newEmail,
+ }),
+ );
+ assert.equal(resetRequest.status, 200);
+ const resetMessage = sender.messages.at(-1);
+ assert.equal(resetMessage?.type, "forget-password");
+
+ const reset = await handlers.POST(
+ request(userHost, "/api/auth/email-otp/reset-password", {
+ email: newEmail,
+ otp: resetMessage?.otp,
+ password: resetPassword,
+ }),
+ );
+ assert.equal(reset.status, 200);
+
+ const newUserId = fixture.psql(
+ "select id from identity.users where email = 'new-user@example.com'",
+ );
+ assert.equal(
+ fixture.psql(
+ `select count(*) from identity.sessions where user_id = '${newUserId}'`,
+ ),
+ "0",
+ );
+ for (const cookie of [newUserOtpCookie, passwordCookie, otpLoginCookie]) {
+ assert.equal(
+ await services.user.api.getSession({
+ headers: new Headers({ cookie }),
+ }),
+ null,
+ );
+ }
+
+ const oldPasswordAfterReset = await passwordSignIn(newEmail, firstPassword);
+ assert.notEqual(oldPasswordAfterReset.status, 200);
+ assert.equal(oldPasswordAfterReset.headers.has("set-cookie"), false);
+ const newPasswordAfterReset = await passwordSignIn(newEmail, resetPassword);
+ assert.equal(newPasswordAfterReset.status, 200);
+ assert.match(
+ sessionCookie(newPasswordAfterReset),
+ /^(?:__Secure-)?jyotisha-user\.session_token=/,
+ );
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
- "update identity.users set role = 'user,admin' where email = 'person@example.com'",
+ "update identity.users set role = 'user,admin' where email = 'new-user@example.com'",
);
- const promotedSend = await handlers.POST(
- request(
- "admin.staging.jyotisha.chat",
- "/api/auth/email-otp/send-verification-otp",
- { email: "person@example.com", type: "sign-in" },
- ),
+ const adminPasswordLogin = await handlers.POST(
+ request(adminHost, "/api/auth/sign-in/email", {
+ email: newEmail,
+ password: resetPassword,
+ }),
);
- assert.equal(promotedSend.status, 200, await promotedSend.text());
+ assert.notEqual(adminPasswordLogin.status, 200);
+ assert.equal(adminPasswordLogin.headers.has("set-cookie"), false);
+
+ const adminSend = await handlers.POST(
+ request(adminHost, "/api/auth/email-otp/send-verification-otp", {
+ email: newEmail,
+ type: "sign-in",
+ }),
+ );
+ assert.equal(adminSend.status, 200);
+ const adminMessage = sender.messages.at(-1);
+ assert.equal(adminMessage?.type, "sign-in");
const adminSignIn = await handlers.POST(
+ request(adminHost, "/api/auth/sign-in/email-otp", {
+ email: newEmail,
+ otp: adminMessage?.otp,
+ }),
+ );
+ assert.equal(adminSignIn.status, 200);
+ assert.match(sessionCookie(adminSignIn), /^(?:__Secure-)?jyotisha-admin\.session_token=/);
+
+ const adminPasswordRoute = await setAccountPassword(
request(
- "admin.staging.jyotisha.chat",
- "/api/auth/sign-in/email-otp",
- { email: "person@example.com", otp: sender.messages[2].otp },
+ adminHost,
+ "/api/account/password",
+ { newPassword: "admin-must-not-set-password" },
+ sessionCookie(adminSignIn),
),
);
- const adminCookie = adminSignIn.headers.get("set-cookie") ?? "";
- assert.equal(adminSignIn.status, 200, await adminSignIn.text());
- assert.match(adminCookie, /jyotisha-admin\.session_token=/);
- assert.doesNotMatch(adminCookie, /jyotisha-user/);
- assert.doesNotMatch(adminCookie, /Domain=/i);
- assert.equal(fixture.psql("select count(*) from identity.sessions"), "2");
+ assert.equal(adminPasswordRoute.status, 401);
} finally {
+ const globalServices = identityGlobal.jyotishaIdentityAuth;
+ if (globalServices) {
+ await globalServices.pool.end();
+ delete identityGlobal.jyotishaIdentityAuth;
+ }
await pool.end();
fixture.stop();
+ for (const key of envKeys) {
+ const value = previousEnv.get(key);
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
}
});
diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts
index b1236641..7c2a89a0 100644
--- a/frontend/tests/identity-login-provider.test.ts
+++ b/frontend/tests/identity-login-provider.test.ts
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
-import { createSelfHostedOtpActions } from "../src/modules/identity/client.ts";
+import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts";
-test("login page selects the auth provider from server-only validated config", () => {
+test("login page selects the auth provider and limits passwords to the user surface", () => {
const page = readFileSync(
new URL("../src/app/login/page.tsx", import.meta.url),
"utf8",
@@ -16,29 +16,67 @@ test("login page selects the auth provider from server-only validated config", (
assert.match(page, /isSelfHostedIdentityEnabled\(process\.env\)/);
assert.match(page, /resolveIdentitySurface/);
assert.match(page, /surface === "admin"/);
- assert.match(page, /provider=\{provider\}/);
+ assert.match(
+ page,
+ /passwordEnabled = provider === "self-hosted" && surface === "user"/,
+ );
+ assert.match(page, /passwordEnabled=\{passwordEnabled\}/);
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 };
+test("self-hosted auth actions call Better Auth without browser token storage", async () => {
+ const calls: Array<{ operation: string; input?: Record }> = [];
+ const fetchCalls: Array<{ path: string; method: string; body: string }> = [];
+ const actions = createSelfHostedAuthActions(
+ {
+ emailOtp: {
+ async sendVerificationOtp(input) {
+ calls.push({ operation: "send", input });
+ return { data: { success: true }, error: null };
+ },
+ async requestPasswordReset(input) {
+ calls.push({ operation: "request-reset", input });
+ return { data: { success: true }, error: null };
+ },
+ async resetPassword(input) {
+ calls.push({ operation: "reset", input });
+ return { data: { success: true }, error: null };
+ },
+ },
+ signIn: {
+ async emailOtp(input) {
+ calls.push({ operation: "verify", input });
+ return { data: { user: { id: "user-id" } }, error: null };
+ },
+ async email(input) {
+ calls.push({ operation: "password", input });
+ return { data: { user: { id: "user-id" } }, error: null };
+ },
},
},
- signIn: {
- async emailOtp(input) {
- calls.push({ operation: "verify", input });
- return { data: { user: { id: "user-id" } }, error: null };
- },
+ async (input, init) => {
+ fetchCalls.push({
+ path: String(input),
+ method: init?.method ?? "GET",
+ body: typeof init?.body === "string" ? init.body : "",
+ });
+ return Response.json(
+ init?.method === "POST" ? { ok: true } : { hasPassword: false },
+ );
},
- });
+ );
await actions.send(" Person@Example.com ");
await actions.verify(" Person@Example.com ", "123456");
+ await actions.signInWithPassword(" Person@Example.com ", "password-1");
+ await actions.requestPasswordReset(" Person@Example.com ");
+ await actions.resetPassword(
+ " Person@Example.com ",
+ "654321",
+ "password-2",
+ );
+ assert.equal(await actions.hasPassword(), false);
+ await actions.setPassword("password-3");
assert.deepEqual(calls, [
{
@@ -49,7 +87,32 @@ test("self-hosted OTP actions call Better Auth without browser token storage", a
operation: "verify",
input: { email: "person@example.com", otp: "123456" },
},
+ {
+ operation: "password",
+ input: { email: "person@example.com", password: "password-1" },
+ },
+ {
+ operation: "request-reset",
+ input: { email: "person@example.com" },
+ },
+ {
+ operation: "reset",
+ input: {
+ email: "person@example.com",
+ otp: "654321",
+ password: "password-2",
+ },
+ },
]);
+ assert.deepEqual(fetchCalls, [
+ { path: "/api/account/password", method: "GET", body: "" },
+ {
+ path: "/api/account/password",
+ method: "POST",
+ body: JSON.stringify({ newPassword: "password-3" }),
+ },
+ ]);
+
const clientSource = readFileSync(
new URL("../src/modules/identity/client.ts", import.meta.url),
"utf8",
@@ -57,22 +120,26 @@ test("self-hosted OTP actions call Better Auth without browser token storage", a
assert.doesNotMatch(clientSource, /localStorage|sessionStorage/);
});
-test("self-hosted OTP actions expose generic enumeration-safe errors", async () => {
- const actions = createSelfHostedOtpActions({
+test("self-hosted auth actions expose generic enumeration-safe errors", async () => {
+ const failed = { data: null, error: { message: "internal account detail" } };
+ const actions = createSelfHostedAuthActions({
emailOtp: {
async sendVerificationOtp() {
- return {
- data: null,
- error: { message: "database says account does not exist" },
- };
+ return failed;
+ },
+ async requestPasswordReset() {
+ return failed;
+ },
+ async resetPassword() {
+ return failed;
},
},
signIn: {
async emailOtp() {
- return {
- data: null,
- error: { message: "internal OTP hash 123456 mismatch" },
- };
+ return failed;
+ },
+ async email() {
+ return failed;
},
},
});
@@ -85,4 +152,45 @@ test("self-hosted OTP actions expose generic enumeration-safe errors", async ()
actions.verify("missing@example.com", "123456"),
new Error("验证码错误或已过期,请重新获取"),
);
+ await assert.rejects(
+ actions.signInWithPassword("missing@example.com", "password"),
+ new Error("邮箱或密码错误"),
+ );
+ await assert.rejects(
+ actions.requestPasswordReset("missing@example.com"),
+ new Error("暂时无法发送验证码,请稍后再试"),
+ );
+ await assert.rejects(
+ actions.resetPassword("missing@example.com", "123456", "password"),
+ new Error("验证码错误或已过期,请重新获取"),
+ );
+});
+
+test("login UI preserves accessible OTP, password, registration, and reset inputs", () => {
+ const component = readFileSync(
+ new URL("../src/components/email-otp-login.tsx", import.meta.url),
+ "utf8",
+ );
+ for (const label of ["验证码登录", "密码登录", "注册账号", "忘记密码"]) {
+ assert.match(component, new RegExp(label));
+ }
+ for (const autocomplete of [
+ "email",
+ "current-password",
+ "new-password",
+ "one-time-code",
+ ]) {
+ assert.match(component, new RegExp(`autoComplete="${autocomplete}"`));
+ }
+ assert.match(component, /role="alert"/);
+ assert.match(component, /role="status"/);
+
+ const route = readFileSync(
+ new URL("../src/app/api/account/password/route.ts", import.meta.url),
+ "utf8",
+ );
+ assert.match(route, /services\.user\.api\.getSession/);
+ assert.match(route, /services\.user\.api\.setPassword/);
+ assert.match(route, /provider_id = 'credential'/);
+ assert.doesNotMatch(route, /update\s+identity\.accounts/i);
});
diff --git a/progress.md b/progress.md
index 0cfe0cfb..4f178a99 100644
--- a/progress.md
+++ b/progress.md
@@ -972,3 +972,18 @@
- 2026-07-11:补跑年度尺度控制日期;主链 timing gate 降级为 `unvalidated_broad_window`,并按领域标记 career blocked、marriage partial candidate。
- 2026-07-16:Prashna guarded evidence、Rangacharya knowledge-only/source gates 与 clean-checkout governance 已提交;主域 score/verdict 不受 Prashna context 影响。
- 2026-07-16:VedAstro preview/metadata 已移除认证 header,实际 HTTP 发送前才注入 API key;此前暴露的 key 必须轮换。
+
+## 2026-07-27 - staging 普通用户邮箱密码认证
+
+- 目标:仅 staging 普通用户启用 Better Auth 邮箱注册、OTP/密码登录、首次设密与 OTP 重置密码;不改 admin、production Supabase、数据库结构或依赖。
+- 顺序:基线验证 → 后端原生密码能力 → 单页登录/注册/重置 UI → 测试与反向验证 → 精确提交 → main/staging 同 SHA 发布验收。
+- 基线:fetch 后 origin/main=origin/staging=f402e6f79c7c8c65de9137770b3041c6eb55da42,双向祖先检查通过;独立 worktree/分支创建完成。
+- 基线验证:npm ci 成功;identity 单元 35/35、PostgreSQL OTP 集成 1/1、npm run build 全绿,skipped=todo=0。
+- 最大风险:Better Auth 1.6.23 的 OTP 新用户/设密/重置会话语义、真实 staging 收信条件、main 分支保护与同 SHA 发布控制器。
+- 实现:仅 user surface 启用原生 emailAndPassword;新增登录/注册/首次设密/OTP 重置单页流程与受 session 保护的设密接口,admin/Supabase 保持 OTP-only。
+- 新集成测试:1/1 通过,覆盖新旧 OTP 用户设密、密码/OTP 登录、重置撤销旧会话、哈希、错误密码无 Cookie、admin 无密码登录;skipped=todo=0。
+- 第 1 轮完整验证:identity 36/36、PostgreSQL 集成 1/1、staging 契约 20/20、ESLint 0 warning/error、Next build 成功、git diff --check 通过,全部 skipped=todo=0。
+- 反向验证:临时将 user emailAndPassword.enabled=false 后新增集成测试按预期失败(400 != 200);trap 恢复文件后同测试 1/1 全绿,未保留临时破坏。
+- 第 2 轮最终验证:兼容保留既有 selfHostedOtpActions/createSelfHostedOtpActions 导出后,identity 36/36、集成 1/1、staging 契约 20/20、ESLint、build、diff check 再次全绿。
+- 发布前:再次 fetch 后 main/staging 仍同为 f402e6f79c7c8c65de9137770b3041c6eb55da42,main 未受保护;允许范围审计通过,依赖/lockfile/migration/deploy/workflow diff=0。
+- 阻塞:执行环境无受控 staging 测试邮箱/收件箱,真实收信验收写入 BLOCKED.md;不使用他人邮箱,代码与部署继续。
From 88060f20d797847e8fcd1c815b88c82ec2067bf5 Mon Sep 17 00:00:00 2001
From: Jesse_Chen
Date: Mon, 27 Jul 2026 17:41:03 +0800
Subject: [PATCH 09/26] fix(auth): simplify registration navigation
---
frontend/src/components/email-otp-login.tsx | 14 +++++++++++++-
frontend/tests/identity-login-provider.test.ts | 10 ++++++++++
progress.md | 1 +
3 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx
index 72e1a45f..075d2fb3 100644
--- a/frontend/src/components/email-otp-login.tsx
+++ b/frontend/src/components/email-otp-login.tsx
@@ -51,6 +51,10 @@ export function EmailOtpLogin({
const [notice, setNotice] = useState("");
const canUsePassword = provider === "self-hosted" && passwordEnabled;
+ const showLoginNavigation =
+ canUsePassword && (mode === "otp" || mode === "password");
+ const showBackToLogin =
+ canUsePassword && (mode === "register" || mode === "forgot");
function chooseMode(nextMode: AuthMode, nextNotice = "") {
setMode(nextMode);
@@ -257,7 +261,7 @@ export function EmailOtpLogin({
{title}
{intro}
- {canUsePassword && (
+ {showLoginNavigation && (