diff --git a/ENV.md b/ENV.md
index 4859f906..35595fd9 100644
--- a/ENV.md
+++ b/ENV.md
@@ -50,7 +50,7 @@ These variables are for local repo tooling and development workflows. They are n
| Environment Variable | TOML Key | Type | Default | Description |
|---------------------|----------|------|---------|-------------|
| `SERVERBEE_OAUTH__BASE_URL` | `oauth.base_url` | string | `""` | Public base URL for constructing OAuth callback URLs (e.g. `https://monitor.example.com`) |
-| `SERVERBEE_OAUTH__ALLOW_REGISTRATION` | `oauth.allow_registration` | bool | `false` | Auto-create user accounts on first OAuth login |
+| `SERVERBEE_OAUTH__ALLOW_REGISTRATION` | `oauth.allow_registration` | bool | `false` | Auto-create member accounts on first OAuth login. ⚠️ Enable only when the OAuth provider itself is access-controlled (self-hosted OIDC / org-internal IdP): members can read **all** monitoring data (security events, public IPs), so enabling this with a public provider such as GitHub grants that access to every user of the provider |
| `SERVERBEE_OAUTH__GITHUB__CLIENT_ID` | `oauth.github.client_id` | string | - | GitHub OAuth App client ID |
| `SERVERBEE_OAUTH__GITHUB__CLIENT_SECRET` | `oauth.github.client_secret` | string | - | GitHub OAuth App client secret |
| `SERVERBEE_OAUTH__GOOGLE__CLIENT_ID` | `oauth.google.client_id` | string | - | Google OAuth client ID |
diff --git a/apps/docs/content/docs/en/admin.mdx b/apps/docs/content/docs/en/admin.mdx
index 4449678a..4ee00c56 100644
--- a/apps/docs/content/docs/en/admin.mdx
+++ b/apps/docs/content/docs/en/admin.mdx
@@ -13,7 +13,11 @@ ServerBee supports multiple users across two roles:
| Role | Permissions |
|------|-------------|
| **Admin** | Full management: users, server config, alert rules, notifications, audit logs, etc. |
-| **Member** | Read-only: view Dashboard, server details, Ping results |
+| **Member** | Read-only access to **all** servers' monitoring data — dashboards, server details, Ping results, security events, public IPs — plus self-service settings (own password, 2FA, API key list, mobile devices) |
+
+
+Member visibility is fleet-wide; there is no per-server scoping. A member account is for trusted collaborators only. To show selected servers to outsiders (e.g., customers), use a [status page](/en/docs/status-page) instead.
+
### Managing Users
@@ -23,6 +27,16 @@ Go to Settings → Users:
- **Edit role**: Change a user's role (Admin/Member)
- **Delete user**: Remove a user account (cannot delete the last Admin)
+### Recovering a Lost Admin Account
+
+There is no password-recovery flow (self-hosted deployments have no mail channel), and passwords are argon2 hashes that cannot be hand-edited into the database. If the only admin credential (password or 2FA device) is lost:
+
+1. Stop the server.
+2. Run `sqlite3 /path/to/serverbee.db "DELETE FROM users;"`.
+3. Start the server — with an empty users table it re-creates the `admin` user and prints a fresh one-time password banner in the startup log.
+
+This removes **all** user accounts, and their API keys stop working. Monitoring data, servers, and configuration are untouched.
+
### API Endpoints
| Endpoint | Method | Description |
diff --git a/apps/docs/content/docs/en/security.mdx b/apps/docs/content/docs/en/security.mdx
index 3df1ca90..d83c0996 100644
--- a/apps/docs/content/docs/en/security.mdx
+++ b/apps/docs/content/docs/en/security.mdx
@@ -68,6 +68,10 @@ client_secret = "your-github-client-secret"
- Click **Unlink** to disconnect an OAuth account
- If `allow_registration = false` (default), first-time OAuth logins do not create new users — an admin must create the user first
+
+Enable `allow_registration = true` only when the OAuth provider itself is access-controlled (self-hosted OIDC or an org-internal IdP). Auto-created accounts get the Member role, which can read **all** monitoring data — including security events and public IPs. With a public provider such as GitHub, enabling it grants that access to every user of the provider.
+
+
### Login Flow
1. Click an OAuth provider button on the login page (e.g., "Login with GitHub")
diff --git a/apps/docs/content/docs/zh/admin.mdx b/apps/docs/content/docs/zh/admin.mdx
index d7952528..f0b162e1 100644
--- a/apps/docs/content/docs/zh/admin.mdx
+++ b/apps/docs/content/docs/zh/admin.mdx
@@ -13,7 +13,11 @@ ServerBee 支持多用户,分为两种角色:
| 角色 | 权限 |
|------|------|
| **Admin** | 完全管理权限:用户管理、服务器配置、告警规则、通知渠道、审计日志等 |
-| **Member** | 只读权限:查看 Dashboard、服务器详情、Ping 结果 |
+| **Member** | 对**所有**服务器监控数据的只读权限——Dashboard、服务器详情、Ping 结果、安全事件、公网 IP,以及自助设置(自己的密码、2FA、API Key 列表、移动设备) |
+
+
+Member 的可见范围是全部服务器,不支持按服务器隔离。Member 账号仅适合完全信任的协作者。如需向外部人员(如客户)展示部分服务器,请改用[状态页](/zh/docs/status-page)。
+
### 管理用户
@@ -23,6 +27,16 @@ ServerBee 支持多用户,分为两种角色:
- **编辑角色**:修改用户的角色(Admin/Member)
- **删除用户**:删除用户账号(禁止删除最后一个 Admin)
+### 找回丢失的管理员账号
+
+系统没有"忘记密码"流程(自托管部署没有邮件通道),密码以 argon2 哈希存储,无法手工改库重置。如果唯一的管理员凭证(密码或 2FA 设备)丢失:
+
+1. 停止服务端。
+2. 执行 `sqlite3 /path/to/serverbee.db "DELETE FROM users;"`。
+3. 重新启动服务端——users 表为空时会重新创建 `admin` 用户,并在启动日志中打印一次性的新随机密码横幅。
+
+此操作会删除**所有**用户账号,其 API Key 随之失效;监控数据、服务器与配置不受影响。
+
### API 端点
| 端点 | 方法 | 说明 |
diff --git a/apps/docs/content/docs/zh/security.mdx b/apps/docs/content/docs/zh/security.mdx
index 54ae1045..a745977b 100644
--- a/apps/docs/content/docs/zh/security.mdx
+++ b/apps/docs/content/docs/zh/security.mdx
@@ -68,6 +68,10 @@ client_secret = "your-github-client-secret"
- 点击 **Unlink** 可以解除 OAuth 账号关联
- 如果 `allow_registration = false`(默认),OAuth 首次登录不会自动创建新用户,需要管理员先创建用户再关联
+
+仅当 OAuth 提供商本身受控(自建 OIDC 或组织内部 IdP)时才应开启 `allow_registration = true`。自动创建的账号为 Member 角色,可读取**全部**监控数据(含安全事件与公网 IP)。若配合 GitHub 等公共提供商开启,等于向该提供商的所有用户开放这些数据。
+
+
### 登录流程
1. 在登录页面点击 OAuth 提供商按钮(如 "Login with GitHub")
diff --git a/apps/ios/ServerBee/Localizable.xcstrings b/apps/ios/ServerBee/Localizable.xcstrings
index 7248bac5..45b3e45a 100644
--- a/apps/ios/ServerBee/Localizable.xcstrings
+++ b/apps/ios/ServerBee/Localizable.xcstrings
@@ -943,6 +943,23 @@
}
}
},
+ "Admins have full control over the entire system, including terminals, file access, and user management.": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Admins have full control over the entire system, including terminals, file access, and user management."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "管理员拥有整个系统的完全控制权,包括终端、文件访问和用户管理。"
+ }
+ }
+ }
+ },
"Advanced": {
"extractionState": "manual",
"localizations": {
@@ -6993,6 +7010,23 @@
}
}
},
+ "Members can view all servers' monitoring data — including security events and public IPs — with no write access.": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Members can view all servers' monitoring data — including security events and public IPs — with no write access."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "成员可查看所有服务器的全部监控数据(含安全事件与公网 IP),但没有任何写入或操作权限。"
+ }
+ }
+ }
+ },
"Memory": {
"extractionState": "manual",
"localizations": {
@@ -12450,6 +12484,23 @@
}
}
},
+ "To show only selected servers to outsiders, use a status page instead.": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "To show only selected servers to outsiders, use a status page instead."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "如需仅向外部人员展示部分服务器,请使用状态页。"
+ }
+ }
+ }
+ },
"Too many attempts. Please try again later.": {
"extractionState": "manual",
"localizations": {
diff --git a/apps/ios/ServerBee/Views/Admin/UsersView.swift b/apps/ios/ServerBee/Views/Admin/UsersView.swift
index ef259974..bf5a0e21 100644
--- a/apps/ios/ServerBee/Views/Admin/UsersView.swift
+++ b/apps/ios/ServerBee/Views/Admin/UsersView.swift
@@ -32,7 +32,12 @@ struct UsersView: View {
Button { showCreate = true } label: { Image(systemName: "person.badge.plus") }
}
}
- .task { await viewModel.load(apiClient: apiClient) }
+ .task {
+ #if DEBUG
+ if UITestSupport.autoPresent == "users-create" { showCreate = true }
+ #endif
+ await viewModel.load(apiClient: apiClient)
+ }
.refreshable { await viewModel.load(apiClient: apiClient) }
.sheet(isPresented: $showCreate) { CreateUserSheet(viewModel: viewModel) }
.sheet(item: $editing) { user in
@@ -76,6 +81,15 @@ private struct CreateUserSheet: View {
!username.trimmingCharacters(in: .whitespaces).isEmpty && password.count >= 8 && !working
}
+ private var roleHint: String {
+ if role == "admin" {
+ return String(localized: "Admins have full control over the entire system, including terminals, file access, and user management.")
+ }
+ let visibility = String(localized: "Members can view all servers' monitoring data — including security events and public IPs — with no write access.")
+ let pointer = String(localized: "To show only selected servers to outsiders, use a status page instead.")
+ return "\(visibility) \(pointer)"
+ }
+
var body: some View {
NavigationStack {
Form {
@@ -85,12 +99,16 @@ private struct CreateUserSheet: View {
.autocorrectionDisabled()
SecureField(String(localized: "Password (min 8)"), text: $password)
}
- Section(String(localized: "Role")) {
+ Section {
Picker(String(localized: "Role"), selection: $role) {
Text(String(localized: "Member")).tag("member")
Text(String(localized: "Admin")).tag("admin")
}
.pickerStyle(.segmented)
+ } header: {
+ Text("Role")
+ } footer: {
+ Text(roleHint)
}
if let error {
Section { Label(error, systemImage: "exclamationmark.triangle.fill").foregroundStyle(Color.serverOffline) }
diff --git a/apps/ios/ServerBee/Views/Settings/SettingsView.swift b/apps/ios/ServerBee/Views/Settings/SettingsView.swift
index 870855a4..e93f43f6 100644
--- a/apps/ios/ServerBee/Views/Settings/SettingsView.swift
+++ b/apps/ios/ServerBee/Views/Settings/SettingsView.swift
@@ -17,7 +17,7 @@ struct SettingsView: View {
/// DEBUG-only value-routed admin destinations, used by the launch hook to
/// push a sub-screen without the cliclick harness scrolling the list.
- enum AdminRoute: Hashable { case networkProbes, ipQuality, statusPage }
+ enum AdminRoute: Hashable { case networkProbes, ipQuality, statusPage, users }
#endif
var body: some View {
@@ -40,6 +40,7 @@ struct SettingsView: View {
case .networkProbes: NetworkProbeConfigView(isAdmin: isAdmin)
case .ipQuality: IpQualityConfigView(isAdmin: isAdmin)
case .statusPage: StatusPageConfigView(isAdmin: isAdmin)
+ case .users: UsersView()
}
}
.task {
@@ -47,6 +48,7 @@ struct SettingsView: View {
case "network-probes": debugPath.append(AdminRoute.networkProbes)
case "ip-quality": debugPath.append(AdminRoute.ipQuality)
case "status-page": debugPath.append(AdminRoute.statusPage)
+ case "users": debugPath.append(AdminRoute.users)
default: break
}
}
diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json
index 7bdbdb44..f7997fc8 100644
--- a/apps/web/src/locales/en/settings.json
+++ b/apps/web/src/locales/en/settings.json
@@ -59,6 +59,8 @@
"users.password_hint": "Password (min 6 chars)",
"users.role_member": "Member",
"users.role_admin": "Admin",
+ "users.role_hint_member": "Members can view monitoring data for all servers — including security events and public IPs — but have no write or control access. To show only selected servers to outsiders, use a status page instead.",
+ "users.role_hint_admin": "Admins have full control over the entire system, including terminals, file access, and user management.",
"users.no_users": "No users found",
"users.two_factor": "2FA",
"users.role_label": "Role:",
diff --git a/apps/web/src/locales/zh/settings.json b/apps/web/src/locales/zh/settings.json
index 23bff1da..f4847889 100644
--- a/apps/web/src/locales/zh/settings.json
+++ b/apps/web/src/locales/zh/settings.json
@@ -59,6 +59,8 @@
"users.password_hint": "密码(最少 6 位)",
"users.role_member": "普通成员",
"users.role_admin": "管理员",
+ "users.role_hint_member": "普通成员可查看所有服务器的全部监控数据(含安全事件与公网 IP),但没有任何写入或操作权限。如需仅向外部人员展示部分服务器,请使用状态页。",
+ "users.role_hint_admin": "管理员拥有整个系统的完全控制权,包括终端、文件访问和用户管理。",
"users.no_users": "暂无用户",
"users.two_factor": "2FA",
"users.role_label": "角色:",
diff --git a/apps/web/src/routes/_authed/settings/users.tsx b/apps/web/src/routes/_authed/settings/users.tsx
index 4de10f42..cc2a5a68 100644
--- a/apps/web/src/routes/_authed/settings/users.tsx
+++ b/apps/web/src/routes/_authed/settings/users.tsx
@@ -202,6 +202,9 @@ function UsersPage() {
{t('users.role_admin')}
+
+ {state.newRole === 'admin' ? t('users.role_hint_admin') : t('users.role_hint_member')}
+
{createMutation.error && {createMutation.error.message}
}
diff --git a/bun.lock b/bun.lock
index 3ad36ea3..0b10f881 100644
--- a/bun.lock
+++ b/bun.lock
@@ -48,7 +48,7 @@
},
"apps/web": {
"name": "@serverbee/web",
- "version": "1.0.0-alpha.10",
+ "version": "1.0.0-alpha.11",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@fontsource-variable/inter": "^5.2.8",
diff --git a/crates/server/src/service/user.rs b/crates/server/src/service/user.rs
index 5bd62ace..16e85e2d 100644
--- a/crates/server/src/service/user.rs
+++ b/crates/server/src/service/user.rs
@@ -92,13 +92,21 @@ impl UserService {
}
/// Update a user's role and optionally reset their password.
+ ///
+ /// Runs entirely in one transaction so the last-admin guard's count and
+ /// the mutation cannot interleave with a concurrent demotion/deletion —
+ /// two racing requests could otherwise both pass the count check and
+ /// leave the system with zero admins, a state `init_admin` cannot recover
+ /// (it only re-bootstraps an empty users table).
pub async fn update_user(
db: &DatabaseConnection,
id: &str,
input: UpdateUserInput,
) -> Result {
+ let txn = db.begin().await?;
+
let user = user::Entity::find_by_id(id)
- .one(db)
+ .one(&txn)
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
@@ -110,7 +118,7 @@ impl UserService {
if user.role == "admin" && role != "admin" {
let admin_count = user::Entity::find()
.filter(user::Column::Role.eq("admin"))
- .count(db)
+ .count(&txn)
.await?;
if admin_count <= 1 {
return Err(AppError::BadRequest(
@@ -133,35 +141,39 @@ impl UserService {
}
active.updated_at = Set(now);
+ let updated = active.update(&txn).await?;
+
// If an admin reset this user's password, revoke all their existing
// sessions so a previously issued (possibly stolen) session cannot
// outlive the reset. This includes the mobile auth path, whose refresh
// secret lives in `mobile_session` (a separate table); an admin reset
- // unconditionally drops all of the target user's mobile sessions. The
- // update + revocation run in one transaction so the reset can't commit
+ // unconditionally drops all of the target user's mobile sessions.
+ // Sharing the surrounding transaction means the reset can't commit
// while sessions stay live.
- let updated = if password_reset {
- let txn = db.begin().await?;
- let updated = active.update(&txn).await?;
+ if password_reset {
session::Entity::delete_many()
.filter(session::Column::UserId.eq(id))
.exec(&txn)
.await?;
AuthService::revoke_user_mobile_sessions(&txn, id, None).await?;
- txn.commit().await?;
- updated
- } else {
- active.update(db).await?
- };
+ }
+
+ txn.commit().await?;
Ok(updated)
}
/// Delete a user along with their sessions and API keys.
/// Refuses to delete the last admin.
+ ///
+ /// Runs entirely in one transaction: the last-admin guard's count cannot
+ /// interleave with a concurrent demotion/deletion (see `update_user`),
+ /// and the multi-table cleanup is atomic.
pub async fn delete_user(db: &DatabaseConnection, id: &str) -> Result<(), AppError> {
+ let txn = db.begin().await?;
+
let user = user::Entity::find_by_id(id)
- .one(db)
+ .one(&txn)
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
@@ -169,7 +181,7 @@ impl UserService {
if user.role == "admin" {
let admin_count = user::Entity::find()
.filter(user::Column::Role.eq("admin"))
- .count(db)
+ .count(&txn)
.await?;
if admin_count <= 1 {
return Err(AppError::BadRequest(
@@ -181,23 +193,25 @@ impl UserService {
// Clean up sessions
session::Entity::delete_many()
.filter(session::Column::UserId.eq(id))
- .exec(db)
+ .exec(&txn)
.await?;
// Clean up API keys
api_key::Entity::delete_many()
.filter(api_key::Column::UserId.eq(id))
- .exec(db)
+ .exec(&txn)
.await?;
// Clean up OAuth accounts
oauth_account::Entity::delete_many()
.filter(oauth_account::Column::UserId.eq(id))
- .exec(db)
+ .exec(&txn)
.await?;
// Delete the user
- user::Entity::delete_by_id(id).exec(db).await?;
+ user::Entity::delete_by_id(id).exec(&txn).await?;
+
+ txn.commit().await?;
Ok(())
}
diff --git a/docs/superpowers/specs/2026-07-07-user-system-design-review.md b/docs/superpowers/specs/2026-07-07-user-system-design-review.md
new file mode 100644
index 00000000..bf04e293
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-07-user-system-design-review.md
@@ -0,0 +1,105 @@
+# User System Design Review
+
+**Date:** 2026-07-07
+**Status:** Concluded — 6 decisions settled; follow-ups implemented alongside this document
+**Scope:** The two-tier RBAC user system: `users` table, `admin`/`member` roles, `/settings/users` management UI, `require_admin` route gating, API keys, sessions.
+
+## Context
+
+ServerBee ships a deliberately simple user model:
+
+- `users.role` is a plain string, validated to `"admin" | "member"` (`crates/server/src/service/user.rs`).
+- A default `admin` user is bootstrapped only when the users table is empty, with a random password and `must_change_password = true` (`AuthService::init_admin`).
+- Authorization is enforced at the router layer: public routes → authenticated read routers → a write-router block wrapped in `require_admin` (`crates/server/src/router/api/mod.rs`). Terminal and Docker-log WebSockets check `role == "admin"` at upgrade time.
+- The web UI fail-closes: every `/settings` route is admin-only except an explicit member allowlist (`MEMBER_SETTINGS_ROUTES` in `apps/web/src/routes/_authed.tsx`).
+
+This document records the design decisions confirmed during the review, one per section.
+
+## Decision 1 — Member is a trusted, fleet-wide, read-only observer
+
+**Question:** Should `member` get per-server / per-group visibility scoping, or stay global read-only?
+
+**Decision:** Keep the global two-tier model. `member` is positioned as a **trusted collaborator with full fleet visibility and zero control**. No per-server ACL will be built.
+
+**Rationale:**
+
+- ServerBee already has a three-tier disclosure spectrum; each audience maps to an existing tool:
+
+ | Audience | Tool | Visibility |
+ |---|---|---|
+ | Strangers / customers | Status pages | Hand-picked servers, masked/aggregated status |
+ | Trusted collaborators | `member` account | All servers, all monitoring detail, no control |
+ | Operators | `admin` account | Everything |
+
+- "Show a customer their one server" is served by creating a status page for that server (already supports per-server selection and IP masking) — not by scoping member accounts.
+- Per-server ACL would require filtering 30+ read endpoints, the browser WebSocket FullSync/Update fan-out, and every cross-server aggregate (insights, alert event streams). Any missed endpoint is a privilege leak. That complexity tax is not worth paying in a single-tenant, self-hosted product; true multi-tenant isolation should be solved by deploying separate instances.
+
+**Follow-ups (cheap, disclosure-only):**
+
+- [x] Create-user dialog: the `member` role option must state that members can see **all** servers' monitoring data (including security events and public IPs) with no write access.
+- [x] User-management docs page: state the same boundary and point to status pages for external/partial exposure.
+
+## Decision 2 — No forced password change for admin-created users
+
+**Question:** `AuthService::create_user` hardcodes `must_change_password = false`, and the admin password-reset path (`PUT /api/users/{id}` with `password`) does not set it either — so the admin permanently knows the initial/reset password. Should both paths set `must_change_password = true` (reusing the existing onboarding machinery built for the bootstrap admin)?
+
+**Decision:** Keep as-is. No forced first-login password change for admin-created or admin-reset accounts.
+
+**Rationale (owner's call):** The admin is fully trusted and already holds maximum privilege over the whole system; knowing a member's password grants nothing the admin cannot already do. Forcing a change would add onboarding friction without a meaningful security gain in this trust model. The bootstrap admin keeps its forced change because its random password is printed to the startup log (a broader exposure channel).
+
+**Clarification recorded during this decision:** A bootstrap-created admin and a UI-created admin are identical after creation. There is no "super admin" concept — `role` is a plain string and every check is `role == "admin"`. Differences exist only at birth: the bootstrap admin has a fixed `admin` username, a random password shown once in a startup banner, `must_change_password = true`, and **no audit entry** (UI creation writes `user.create` to the audit log). The bootstrap admin can be deleted or demoted like any other admin as long as the count-based last-admin guard is satisfied. There is no config/env path that seeds the bootstrap admin password in production; `admin/admin123` exists only in the dev demo seed.
+
+## Decision 3 — Live WebSocket channels are not re-validated after role changes (known boundary)
+
+**Question:** Terminal (`ws/terminal.rs`) and Docker-log (`ws/docker_logs.rs`) WebSockets check `role == "admin"` only at upgrade time. A demoted or deleted admin's already-open PTY/log stream stays alive until the client disconnects, even though REST access is revoked immediately (role is re-read from the DB on every request, and deletion drops sessions). Should the WS loops periodically re-validate the session and role (e.g., every 60s) and close on failure?
+
+**Decision:** No — rejected as overdesign for this product's trust model. Recorded as a known boundary instead.
+
+**Rationale (owner's call):** Admin demotion/removal is a rare, deliberate act performed by another trusted admin in a single-operator/small-team deployment. The residual window (an already-open terminal surviving until disconnect) does not justify adding re-validation machinery to the WS loops. Operators who need immediate revocation can restart the server process, which drops all WS connections.
+
+## Decision 4 — OAuth auto-registration risk is handled by documentation, not an allowlist
+
+**Question:** With `SERVERBEE_OAUTH__ALLOW_REGISTRATION=true` (default `false`), any identity that can authenticate against the configured OAuth provider is auto-provisioned as a `member` on first login (`OAuthService::find_or_create_user`), with no domain/org/username allowlist and no approval step. Combined with Decision 1 (member = fleet-wide read visibility), enabling this against a public provider such as GitHub effectively publishes all read-only monitoring data — public IPs, process lists, security events — to every user of that provider. Should an allowlist be added?
+
+**Decision:** No allowlist. Documentation-only mitigation.
+
+**Rationale:** The flag is fail-closed by default, and the legitimate use case (a private/self-hosted IdP where the provider itself is the allowlist) doesn't need extra config. The danger is purely one of operator expectation, so it is fixed at the disclosure layer.
+
+**Follow-ups:**
+
+- [x] ENV.md `SERVERBEE_OAUTH__ALLOW_REGISTRATION` row and the OAuth docs page must warn: enable only when the OAuth provider itself is access-controlled (self-hosted OIDC / org-internal IdP). Enabling it with a public provider (e.g., GitHub) grants every user of that provider full read access to all monitoring data, including security events and public IPs.
+
+## Verified clean — no credential leakage to members
+
+Checked during the review, no action needed: `ServerResponse` deliberately excludes `token_hash`/`token_prefix` (exposes only a `has_token` bool), and `agent::read_router` exposes only `/agent/latest-version`. Members have no read path to agent tokens or enrollment secrets, so a member account cannot escalate to agent impersonation.
+
+## Decision 5 — Fix the last-admin guard race (wrap check + mutation in a transaction)
+
+**Question:** The last-admin guard in `UserService::update_user` (demote path) and `UserService::delete_user` is check-then-act: the `count(role = 'admin')` query and the subsequent write run outside a shared transaction. Two concurrent requests (e.g., two admins demoting each other) can both observe `admin_count = 2`, both pass, and leave the system with **zero admins**. Because `init_admin` re-bootstraps only when the users table is empty, an all-admins-demoted state is unrecoverable in-product — every `require_admin` route (including user management itself) returns 403 forever; the only way out is manual SQLite surgery.
+
+**Decision:** Fix. Wrap the guard count and the mutation in a single transaction in both paths (`update_user` role-demotion branch, `delete_user`). SQLite serializes write transactions, so the second concurrent request re-reads `admin_count = 1` inside its transaction and is correctly rejected. No new concepts or config; `delete_user`'s multi-table cleanup should have been transactional anyway.
+
+**Status:** Implemented — `update_user` and `delete_user` each run guard + mutation in one transaction.
+
+## Decision 6 — Admin lockout recovery is documented, not built
+
+**Question:** There is no forgot-password flow (reasonable — self-hosted, no mail channel), and passwords are argon2 hashes that cannot be hand-crafted via the sqlite CLI. If the only admin loses their password or 2FA device, the only real recovery is: stop the server → `sqlite3 serverbee.db "DELETE FROM users;"` → restart, which triggers `init_admin` re-bootstrap (new random password in the startup banner). This works but is written down nowhere. Should ServerBee ship a `reset-admin-password` CLI subcommand instead?
+
+**Decision:** No CLI. Document the wipe-and-rebootstrap recovery procedure in the docs site.
+
+**Rationale:** Anyone who can reach the DB file already has host-level access, so a CLI adds no new capability — only a new maintained entry point. The recovery recipe (with its side effects: all user accounts and API-key ownership are reset; monitoring data untouched) belongs in the troubleshooting docs.
+
+**Follow-ups:**
+
+- [x] Add a "Lost admin access" recovery section to the docs (Admin Guide, CN+EN).
+
+## Summary of outcomes
+
+| # | Topic | Outcome |
+|---|---|---|
+| 1 | Member scoping | Keep global read-only member; external exposure → status pages; add UI/docs disclosure |
+| 2 | Forced password change for created users | Keep as-is (admin is fully trusted) |
+| 3 | Live WS revalidation after role change | Known boundary, no code change |
+| 4 | OAuth auto-registration blast radius | Docs warning only, no allowlist |
+| 5 | Last-admin guard race | **Fix**: wrap count+mutation in one transaction (update_user demote path, delete_user) |
+| 6 | Admin lockout recovery | Document wipe-and-rebootstrap procedure, no CLI |