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*);
for (const autocomplete of [
diff --git a/progress.md b/progress.md
index d3fe63d1..892d751c 100644
--- a/progress.md
+++ b/progress.md
@@ -989,3 +989,4 @@
- 阻塞:执行环境无受控 staging 测试邮箱/收件箱,真实收信验收写入 BLOCKED.md;不使用他人邮箱,代码与部署继续。
- 2026-07-27 UI 跟进:注册与忘记密码子流程不再显示“验证码登录 / 密码登录 / 注册账号 / 忘记密码”顶层导航,仅保留“返回登录”;聚焦测试 4/4、ESLint、diff check 通过。
- 2026-07-27 staging 测试账户:确认 luna@copse.life 原为 0 用户/0 credential/0 会话;仅在 staging 通过 Better Auth createUser 创建,复核 user=1、credential=1、password_hashed=true,密码登录 HTTP 200 且 Set-Cookie=true;未直接写密码 SQL、未触发 production、临时文件已清理。
+- 2026-07-27 admin 跟进:按最新要求将 self-hosted admin 改为仅邮箱密码登录,OTP 插件不挂载 admin;普通账号仍由服务端持久化 admin 角色门禁拒绝。聚焦单测 11/11、identity 36/36、集成 1/1、staging 契约 20/20、ESLint、build、diff check 全绿,skipped/todo=0;本地 admin Host `/login`=200、密码表单存在、OTP/注册/忘记密码入口不存在。反向关闭 admin password 后集成测试按预期在 admin 登录 400!=200 变红,恢复后 1/1 全绿。