切到账号密码登录时,已经有能用密码登录的管理员就不再要求建第一个管理员:以前设置页只要从「未开启」切到账号密码登录就一律显示「第一个管理员」表单、一律提交 bootstrap(前端 needsBoot 只看切换方向),库里已经有设了密码的管理员也得再建一个。/api/auth/settings 现在带 local_admins(没停用、能用密码登录的管理员)和 passwordless_admins(没停用、还没设密码的);设置页按它判断:有能登录的就提示「切换后用其中一个账号登录,保存后会回到登录页」、不提交 bootstrap;只有没设密码的管理员时照旧要建,并说清是谁没设密码、可以在用户列表里给他重设密码。从单点登录切到账号密码登录同样按这条判(以前不显示表单、后端却要求)。用户列表里新建用户、重设密码后,登录方式那一块重新判断。判据和后端 _local_admin_exists 同一个

This commit is contained in:
ThearchyHelios
2026-10-19 18:48:22 +08:00
parent 5d5a29cfa6
commit fa951a2756
5 changed files with 81 additions and 12 deletions
+22 -5
View File
@@ -196,12 +196,29 @@ async def jwks() -> dict[str, Any]:
# ---------------------------------------------------------------- 设置与引导
def _settings_out(value: dict[str, Any]) -> dict[str, Any]:
#: 设置页列出的管理员最多几个(只拿来说「切过去用谁登录」「谁还没设密码」,不是用户列表)
ADMINS_SHOWN = 10
async def _admin_accounts(session: AsyncSession) -> dict[str, Any]:
"""没停用的管理员里,哪些能用口令登录、哪些还没设口令。
设置页据此决定切到账号密码登录时要不要建第一个管理员:已经有能登录的就不用建(切过去用它登录),只有没设口令的
要说清是谁——关登录时新建用户可以不填密码,这样的管理员切过去登录不了。判据和 _local_admin_exists 同一个。
"""
rows = (await session.execute(select(User.username, User.display_name, User.password_hash).where(
User.role == "admin", User.disabled.is_(False)).order_by(User.username, User.id))).all()
login = [{"username": r.username, "display_name": r.display_name or r.username} for r in rows if r.password_hash]
passwordless = [r.username for r in rows if not r.password_hash]
return {"local_admins": login[:ADMINS_SHOWN], "passwordless_admins": passwordless[:ADMINS_SHOWN]}
async def _settings_out(session: AsyncSession, value: dict[str, Any]) -> dict[str, Any]:
config = {k: v for k, v in (value.get("oidc") or {}).items() if k in _OIDC_FIELDS}
if config.get("client_secret"):
config["client_secret"] = crypto.mask(config["client_secret"])
return {"mode": current_mode(), "env_locked": env_mode() is not None, "oidc": config,
"user_can_chat": bool(value.get("user_can_chat"))}
"user_can_chat": bool(value.get("user_can_chat")), **await _admin_accounts(session)}
def _require_admin() -> None:
@@ -211,9 +228,9 @@ def _require_admin() -> None:
@router.get("/settings")
async def get_settings() -> dict[str, Any]:
async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
_require_admin()
return _settings_out(await load_settings())
return await _settings_out(session, await load_settings())
class BootstrapIn(BaseModel):
@@ -321,7 +338,7 @@ async def put_settings(payload: SettingsIn, request: Request, response: Response
from app.cluster import caches
await caches.broadcast("auth_settings")
out = _settings_out(value)
out = await _settings_out(session, value)
if token is not None and created is not None:
_login_response(request, response, token)
out["principal"] = await principal_full(session, await sessions.principal_of(session, created, current_mode()))
+28
View File
@@ -84,6 +84,34 @@ async def test_switching_to_local_needs_the_first_admin_in_the_same_request():
"库里只存 token 的 sha256"
async def test_switching_to_local_with_an_admin_who_has_a_password_does_not_ask_for_another():
"""关登录时已经建了设过密码的管理员:设置页不该再要求建第一个管理员,切过去用他登录。"""
await auth_env.make_user("ada", "admin")
await auth_env.make_user("bo", "builder")
c = auth_env.client()
got = (await c.get("/api/auth/settings")).json()
assert got["local_admins"] == [{"username": "ada", "display_name": "ada"}], got
assert got["passwordless_admins"] == []
r = await c.put("/api/auth/settings", json={"mode": "local"})
assert r.status_code == 200, r.text
assert "principal" not in r.json(), "没新建管理员就不替谁登录:切过去回登录页"
assert (await c.get("/api/auth/me")).status_code == 401
assert (await (await auth_env.login("ada")).get("/api/auth/me")).json()["role"] == "admin"
async def test_an_admin_without_a_password_is_named_and_still_needs_the_first_admin():
"""关登录时新建用户可以不填密码:这样的管理员切过去登录不了,要建一个能登录的,并说清是谁没设密码。"""
async with SessionLocal() as s:
s.add(User(username="nopw", display_name="没设密码的", role="admin", password_hash=None))
s.add(User(username="gone", display_name="停用的", role="admin", password_hash=None, disabled=True))
await s.commit()
c = auth_env.client()
got = (await c.get("/api/auth/settings")).json()
assert got["local_admins"] == [] and got["passwordless_admins"] == ["nopw"], got
r = await c.put("/api/auth/settings", json={"mode": "local"})
assert r.status_code == 422 and "第一个管理员" in r.json()["detail"]
async def test_roles_401_403_and_forged_actor():
admin = await auth_env.turn_on_local()
await auth_env.make_user("bob", "builder")
+4 -2
View File
@@ -14,8 +14,10 @@ export const auth = {
logout: () => post<void>('/auth/logout'),
/** OIDC:浏览器整页跳转到这里 */
oidcStartUrl: (next?: string) => `/api/auth/oidc/start${qs({ next })}`,
settings: () => get<{ mode: AuthMode; env_locked: boolean; oidc: Record<string, any>; user_can_chat: boolean }>(
'/auth/settings'),
/** local_admins:没停用、能用密码登录的管理员;passwordless_admins:没停用、还没设密码的管理员(切到账号密码登录时据此
* 决定要不要同时建第一个管理员) */
settings: () => get<{ mode: AuthMode; env_locked: boolean; oidc: Record<string, any>; user_can_chat: boolean;
local_admins?: { username: string; display_name: string }[]; passwordless_admins?: string[] }>('/auth/settings'),
saveSettings: (body: { mode?: AuthMode; oidc?: Record<string, any>; user_can_chat?: boolean;
bootstrap?: { username: string; password: string; display_name?: string } }) => put<any>('/auth/settings', body),
}
+5
View File
@@ -5706,6 +5706,11 @@ export const IDENTITY_TEXT = {
envLocked: '登录方式由服务端环境变量指定,这里不能修改',
bootstrap: '第一个管理员',
bootstrapHint: '切换到本地账号时必须同时建出第一个管理员,否则切换后没有人能登录',
/** 已经有能用密码登录的管理员:切过去不用再建,保存后回登录页用他登录 */
existingAdmins: (names: string) => `已有能用密码登录的管理员:${names}。切换后用其中一个账号登录,保存后会回到登录页`,
/** 有管理员、但都没设密码(关登录时新建用户可以不填密码) */
passwordlessAdmins: (names: string) =>
`已有的管理员 ${names} 还没设密码,切换后不能用来登录:可以在下面的用户列表里给其中一位重设密码,或者在这里新建一个管理员`,
oidcTitle: '单点登录设置',
oidcIssuer: '签发方地址',
oidcClientId: '客户端标识',
+22 -5
View File
@@ -13,7 +13,8 @@ import type { AuthMode, GroupRow, Role, UserRow } from '../../typesApp'
/*
* 设置 → 身份(A7、§3.12):登录方式、单点登录、使用者能否问数据、用户、组。只给管理员;关登录时所有人等同管理员。
*
* 从关闭登录切到本地账号时,必须在同一个请求里建出第一个管理员(否则切过去没有人能登录);切到单点登录时至少
* 从关闭登录(或单点登录)切到本地账号时,库里还没有能用密码登录的管理员,就必须在同一个请求里建出第一个管理员(否则切过去
* 没有人能登录);已经有了就不用建,保存后回登录页用他登录。有管理员但都没设密码(关登录时新建用户可以不填)的,照实说是谁。切到单点登录时至少
* 配一个映射为管理员的用户或组(admin_users、admin_groups 至少一项:小团队的单点登录服务常常没有组声明)。环境变量指定了登录方式时这里只读。人员变动只改组成员,不发应用的新版本(写审计)
*/
@@ -37,7 +38,8 @@ export function IdentityTab() {
return (
<div className="space-y-8" data-identity="">
<ModeSection settings={settings} onSaved={load} />
<UsersSection mode={settings.mode} />
{/* 用户列表里新建了管理员、重设了密码:登录方式那一块要重新判断还要不要建第一个管理员 */}
<UsersSection mode={settings.mode} onChanged={load} />
<GroupsSection mode={settings.mode} />
</div>
)
@@ -51,7 +53,11 @@ function ModeSection({ settings, onSaved }: { settings: Settings; onSaved: () =>
const [boot, setBoot] = useState({ username: '', password: '', display_name: '' })
const [busy, setBusy] = useState(false)
const radio = useRadioGroup(MODES, mode, setMode)
const needsBoot = settings.mode === 'off' && mode === 'local'
const loginAdmins = settings.local_admins ?? []
const passwordless = settings.passwordless_admins ?? []
const toLocal = settings.mode !== 'local' && mode === 'local'
// 已经有能用密码登录的管理员就不用再建:后端也是这条判据(_local_admin_exists)
const needsBoot = toLocal && loginAdmins.length === 0
const oidcList = (key: string): string =>
Array.isArray(oidc[key]) ? oidc[key].join('\n') : typeof oidc[key] === 'string' ? oidc[key] : ''
const setList = (key: string, text: string) =>
@@ -91,10 +97,20 @@ function ModeSection({ settings, onSaved }: { settings: Settings; onSaved: () =>
</button>
))}
</div>
{toLocal && !needsBoot && (
<p className="mt-3 rounded-lg border px-3 py-2 text-2xs text-dim" data-identity-existing-admins="">
{T.existingAdmins(loginAdmins.map((a) => a.username).join('、'))}
</p>
)}
{needsBoot && (
<fieldset className="mt-3 space-y-2 rounded-lg border px-3 py-2" data-identity-bootstrap="">
<legend className="px-1 text-xs font-medium">{T.bootstrap}</legend>
<p className="text-2xs text-faint">{T.bootstrapHint}</p>
{passwordless.length > 0 && (
<p className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-identity-passwordless-admins="">
{T.passwordlessAdmins(passwordless.join('、'))}
</p>
)}
<div className="grid grid-cols-3 gap-2">
<Field label={T.username} required>
{(p) => <input {...p} className="field" value={boot.username} autoComplete="off"
@@ -154,7 +170,7 @@ function ModeSection({ settings, onSaved }: { settings: Settings; onSaved: () =>
)
}
function UsersSection({ mode }: { mode: AuthMode }) {
function UsersSection({ mode, onChanged }: { mode: AuthMode; onChanged?: () => void }) {
const [rows, setRows] = useState<UserRow[] | null>(null)
const [error, setError] = useState<unknown>(null)
const [creating, setCreating] = useState(false)
@@ -167,6 +183,7 @@ function UsersSection({ mode }: { mode: AuthMode }) {
try {
await api.identity.updateUser(u.id, body)
load()
onChanged?.()
} catch (e) {
toast.error(e)
}
@@ -219,7 +236,7 @@ function UsersSection({ mode }: { mode: AuthMode }) {
))}
</div>
)}
{creating && <UserCreator onClose={() => setCreating(false)} onSaved={() => { setCreating(false); load() }} />}
{creating && <UserCreator onClose={() => setCreating(false)} onSaved={() => { setCreating(false); load(); onChanged?.() }} />}
</section>
)
}