mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
切到账号密码登录时,已经有能用密码登录的管理员就不再要求建第一个管理员:以前设置页只要从「未开启」切到账号密码登录就一律显示「第一个管理员」表单、一律提交 bootstrap(前端 needsBoot 只看切换方向),库里已经有设了密码的管理员也得再建一个。/api/auth/settings 现在带 local_admins(没停用、能用密码登录的管理员)和 passwordless_admins(没停用、还没设密码的);设置页按它判断:有能登录的就提示「切换后用其中一个账号登录,保存后会回到登录页」、不提交 bootstrap;只有没设密码的管理员时照旧要建,并说清是谁没设密码、可以在用户列表里给他重设密码。从单点登录切到账号密码登录同样按这条判(以前不显示表单、后端却要求)。用户列表里新建用户、重设密码后,登录方式那一块重新判断。判据和后端 _local_admin_exists 同一个
This commit is contained in:
+22
-5
@@ -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()))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -5706,6 +5706,11 @@ export const IDENTITY_TEXT = {
|
||||
envLocked: '登录方式由服务端环境变量指定,这里不能修改',
|
||||
bootstrap: '第一个管理员',
|
||||
bootstrapHint: '切换到本地账号时必须同时建出第一个管理员,否则切换后没有人能登录',
|
||||
/** 已经有能用密码登录的管理员:切过去不用再建,保存后回登录页用他登录 */
|
||||
existingAdmins: (names: string) => `已有能用密码登录的管理员:${names}。切换后用其中一个账号登录,保存后会回到登录页`,
|
||||
/** 有管理员、但都没设密码(关登录时新建用户可以不填密码) */
|
||||
passwordlessAdmins: (names: string) =>
|
||||
`已有的管理员 ${names} 还没设密码,切换后不能用来登录:可以在下面的用户列表里给其中一位重设密码,或者在这里新建一个管理员`,
|
||||
oidcTitle: '单点登录设置',
|
||||
oidcIssuer: '签发方地址',
|
||||
oidcClientId: '客户端标识',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user