feat(auth): use password-only admin login

This commit is contained in:
Jesse_Chen
2026-07-27 20:40:55 +08:00
parent 6b13f96f9a
commit ee166ab5d3
7 changed files with 58 additions and 45 deletions
+8 -2
View File
@@ -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 (
<EmailOtpLogin provider={provider} passwordEnabled={passwordEnabled} />
<EmailOtpLogin
provider={provider}
passwordEnabled={passwordEnabled}
passwordOnly={passwordOnly}
/>
);
}
+5 -3
View File
@@ -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<AuthMode>("otp");
const [mode, setMode] = useState<AuthMode>(passwordOnly ? "password" : "otp");
const [step, setStep] = useState<AuthStep>("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);
+10 -12
View File
@@ -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"],
+8 -9
View File
@@ -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", () => {
@@ -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);
@@ -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<AuthMode>\(passwordOnly \? "password" : "otp"\)/);
assert.match(component, /\{showLoginNavigation && \(/);
assert.match(component, />\s*返回登录\s*</);
for (const autocomplete of [
+1
View File
@@ -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 全绿。