Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ENV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 15 additions & 1 deletion apps/docs/content/docs/en/admin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

<Callout type="warn">
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.
</Callout>

### Managing Users

Expand All @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/docs/en/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Callout type="warn">
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.
</Callout>

### Login Flow

1. Click an OAuth provider button on the login page (e.g., "Login with GitHub")
Expand Down
16 changes: 15 additions & 1 deletion apps/docs/content/docs/zh/admin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ ServerBee 支持多用户,分为两种角色:
| 角色 | 权限 |
|------|------|
| **Admin** | 完全管理权限:用户管理、服务器配置、告警规则、通知渠道、审计日志等 |
| **Member** | 只读权限:查看 Dashboard、服务器详情、Ping 结果 |
| **Member** | 对**所有**服务器监控数据的只读权限——Dashboard、服务器详情、Ping 结果、安全事件、公网 IP,以及自助设置(自己的密码、2FA、API Key 列表、移动设备) |

<Callout type="warn">
Member 的可见范围是全部服务器,不支持按服务器隔离。Member 账号仅适合完全信任的协作者。如需向外部人员(如客户)展示部分服务器,请改用[状态页](/zh/docs/status-page)。
</Callout>

### 管理用户

Expand All @@ -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 端点

| 端点 | 方法 | 说明 |
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/docs/zh/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ client_secret = "your-github-client-secret"
- 点击 **Unlink** 可以解除 OAuth 账号关联
- 如果 `allow_registration = false`(默认),OAuth 首次登录不会自动创建新用户,需要管理员先创建用户再关联

<Callout type="warn">
仅当 OAuth 提供商本身受控(自建 OIDC 或组织内部 IdP)时才应开启 `allow_registration = true`。自动创建的账号为 Member 角色,可读取**全部**监控数据(含安全事件与公网 IP)。若配合 GitHub 等公共提供商开启,等于向该提供商的所有用户开放这些数据。
</Callout>

### 登录流程

1. 在登录页面点击 OAuth 提供商按钮(如 "Login with GitHub")
Expand Down
51 changes: 51 additions & 0 deletions apps/ios/ServerBee/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
22 changes: 20 additions & 2 deletions apps/ios/ServerBee/Views/Admin/UsersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) }
Expand Down
4 changes: 3 additions & 1 deletion apps/ios/ServerBee/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,13 +40,15 @@ struct SettingsView: View {
case .networkProbes: NetworkProbeConfigView(isAdmin: isAdmin)
case .ipQuality: IpQualityConfigView(isAdmin: isAdmin)
case .statusPage: StatusPageConfigView(isAdmin: isAdmin)
case .users: UsersView()
}
}
.task {
switch UITestSupport.adminRoute {
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
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "角色:",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/routes/_authed/settings/users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ function UsersPage() {
<SelectItem value="admin">{t('users.role_admin')}</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{state.newRole === 'admin' ? t('users.role_hint_admin') : t('users.role_hint_member')}
</p>
{createMutation.error && <p className="text-destructive text-sm">{createMutation.error.message}</p>}
</form>
<DialogFooter>
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 32 additions & 18 deletions crates/server/src/service/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<user::Model, 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()))?;

Expand All @@ -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(
Expand All @@ -133,43 +141,47 @@ 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()))?;

// Guard: do not allow deleting the last admin
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(
Expand All @@ -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(())
}
Expand Down
Loading
Loading