From ee166ab5d3887c3db36712d3b40d864d689357cb Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 27 Jul 2026 20:40:55 +0800 Subject: [PATCH] feat(auth): use password-only admin login --- frontend/src/app/login/page.tsx | 10 ++++-- frontend/src/components/email-otp-login.tsx | 8 +++-- frontend/src/modules/identity/auth-factory.ts | 22 ++++++------ frontend/tests/identity-auth-factory.test.ts | 17 +++++----- .../tests/identity-auth-integration.test.ts | 34 +++++++++++-------- .../tests/identity-login-provider.test.ts | 11 +++--- progress.md | 1 + 7 files changed, 58 insertions(+), 45 deletions(-) diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 1566eb00..38692169 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -14,6 +14,7 @@ export default async function LoginPage() { const config = readIdentityConfig(process.env); let provider = config.provider; let passwordEnabled = false; + let passwordOnly = false; if (isSelfHostedIdentityEnabled(process.env)) { const selfHosted = readSelfHostedIdentityConfig(process.env); const surface = resolveIdentitySurface( @@ -21,9 +22,14 @@ export default async function LoginPage() { selfHosted, ); if (surface === "admin") provider = "self-hosted"; - passwordEnabled = provider === "self-hosted" && surface === "user"; + passwordEnabled = provider === "self-hosted"; + passwordOnly = surface === "admin"; } return ( - + ); } diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index 075d2fb3..f8fcfaa6 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -36,11 +36,13 @@ function passwordError(password: string, confirmation: string): string { export function EmailOtpLogin({ provider, passwordEnabled = false, + passwordOnly = false, }: { provider: AuthProvider; passwordEnabled?: boolean; + passwordOnly?: boolean; }) { - const [mode, setMode] = useState("otp"); + const [mode, setMode] = useState(passwordOnly ? "password" : "otp"); const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); const [token, setToken] = useState(""); @@ -52,9 +54,9 @@ export function EmailOtpLogin({ const canUsePassword = provider === "self-hosted" && passwordEnabled; const showLoginNavigation = - canUsePassword && (mode === "otp" || mode === "password"); + canUsePassword && !passwordOnly && (mode === "otp" || mode === "password"); const showBackToLogin = - canUsePassword && (mode === "register" || mode === "forgot"); + canUsePassword && !passwordOnly && (mode === "register" || mode === "forgot"); function chooseMode(nextMode: AuthMode, nextNotice = "") { setMode(nextMode); diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts index 63dac181..4367c23f 100644 --- a/frontend/src/modules/identity/auth-factory.ts +++ b/frontend/src/modules/identity/auth-factory.ts @@ -100,19 +100,17 @@ export function buildAuthOptions({ path: "/", }, }, - ...(surface === "user" - ? { - emailAndPassword: { - enabled: true, - disableSignUp: true, - minPasswordLength: 8, - maxPasswordLength: 128, - revokeSessionsOnPasswordReset: true, - }, - } - : {}), + emailAndPassword: { + enabled: true, + disableSignUp: true, + minPasswordLength: 8, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + }, plugins: [ - emailOTP(createEmailOtpOptions(emailSender, secret, surface === "admin")), + ...(surface === "user" + ? [emailOTP(createEmailOtpOptions(emailSender, secret, false))] + : []), admin({ defaultRole: "user", adminRoles: ["admin"], diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts index 5012c4b9..f0f321de 100644 --- a/frontend/tests/identity-auth-factory.test.ts +++ b/frontend/tests/identity-auth-factory.test.ts @@ -114,7 +114,10 @@ test("user and admin auth surfaces have host-only isolated cookies", () => { maxPasswordLength: 128, revokeSessionsOnPasswordReset: true, }); - assert.equal(adminOptions.emailAndPassword, undefined); + assert.deepEqual( + adminOptions.emailAndPassword, + userOptions.emailAndPassword, + ); for (const options of [userOptions, adminOptions]) { const attributes = options.advanced?.defaultCookieAttributes; assert.equal(attributes?.secure, true); @@ -126,7 +129,7 @@ test("user and admin auth surfaces have host-only isolated cookies", () => { } }); -test("admin surface disables sign-up and rejects non-admin session creation", async () => { +test("admin surface is password-only and rejects non-admin session creation", async () => { const checkedUserIds: string[] = []; const options = buildAuthOptions({ surface: "admin", @@ -141,7 +144,9 @@ test("admin surface disables sign-up and rejects non-admin session creation", as const emailPlugin = options.plugins?.find( (plugin) => plugin.id === "email-otp", ); - assert.ok(emailPlugin); + assert.equal(emailPlugin, undefined); + assert.equal(options.emailAndPassword?.enabled, true); + assert.equal(options.emailAndPassword?.disableSignUp, true); const before = options.databaseHooks?.session?.create?.before; assert.ok(before); @@ -163,12 +168,6 @@ test("admin surface disables sign-up and rejects non-admin session creation", as ); assert.deepEqual(checkedUserIds, ["ordinary-user-id", "admin-user-id"]); - const otpOptions = createEmailOtpOptions( - new FakeEmailOtpSender(), - config.adminSecret, - true, - ); - assert.equal(otpOptions.disableSignUp, true); }); test("admin surface requires a server-side persisted-role authorizer", () => { diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts index f0bd4380..50af8fbd 100644 --- a/frontend/tests/identity-auth-integration.test.ts +++ b/frontend/tests/identity-auth-integration.test.ts @@ -61,7 +61,7 @@ const envKeys = [ "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 () => { +test("Better Auth supports user OTP/password flows and password-only admin login", async () => { const fixture = startPostgresFixture(); const migration = spawnSync(process.execPath, [runnerPath], { encoding: "utf8", @@ -300,6 +300,15 @@ test("Better Auth supports OTP registration/login, first password, password logi /^(?:__Secure-)?jyotisha-user\.session_token=/, ); + const nonAdminPasswordLogin = await handlers.POST( + request(adminHost, "/api/auth/sign-in/email", { + email: newEmail, + password: resetPassword, + }), + ); + assert.notEqual(nonAdminPasswordLogin.status, 200); + assert.equal(nonAdminPasswordLogin.headers.has("set-cookie"), false); + fixture.psqlAs( "identity_runtime", "identity-runtime-test-password", @@ -311,33 +320,28 @@ test("Better Auth supports OTP registration/login, first password, password logi password: resetPassword, }), ); - assert.notEqual(adminPasswordLogin.status, 200); - assert.equal(adminPasswordLogin.headers.has("set-cookie"), false); + assert.equal(adminPasswordLogin.status, 200); + assert.match( + sessionCookie(adminPasswordLogin), + /^(?:__Secure-)?jyotisha-admin\.session_token=/, + ); + const sentMessageCount = sender.messages.length; 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=/); + assert.notEqual(adminSend.status, 200); + assert.equal(sender.messages.length, sentMessageCount); const adminPasswordRoute = await setAccountPassword( request( adminHost, "/api/account/password", { newPassword: "admin-must-not-set-password" }, - sessionCookie(adminSignIn), + sessionCookie(adminPasswordLogin), ), ); assert.equal(adminPasswordRoute.status, 401); diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts index f9b760e8..93ba111d 100644 --- a/frontend/tests/identity-login-provider.test.ts +++ b/frontend/tests/identity-login-provider.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts"; -test("login page selects the auth provider and limits passwords to the user surface", () => { +test("login page uses password-only mode on the self-hosted admin surface", () => { const page = readFileSync( new URL("../src/app/login/page.tsx", import.meta.url), "utf8", @@ -18,9 +18,11 @@ test("login page selects the auth provider and limits passwords to the user surf assert.match(page, /surface === "admin"/); assert.match( page, - /passwordEnabled = provider === "self-hosted" && surface === "user"/, + /passwordEnabled = provider === "self-hosted"/, ); + assert.match(page, /passwordOnly = surface === "admin"/); assert.match(page, /passwordEnabled=\{passwordEnabled\}/); + assert.match(page, /passwordOnly=\{passwordOnly\}/); assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/); }); @@ -176,12 +178,13 @@ test("login UI preserves accessible OTP, password, registration, and reset input } assert.match( component, - /canUsePassword && \(mode === "otp" \|\| mode === "password"\)/, + /canUsePassword && !passwordOnly && \(mode === "otp" \|\| mode === "password"\)/, ); assert.match( component, - /canUsePassword && \(mode === "register" \|\| mode === "forgot"\)/, + /canUsePassword && !passwordOnly && \(mode === "register" \|\| mode === "forgot"\)/, ); + assert.match(component, /useState\(passwordOnly \? "password" : "otp"\)/); assert.match(component, /\{showLoginNavigation && \(/); assert.match(component, />\s*返回登录\s*