Files
rustdesk-server/src/api/admin/pages/users.rs
T
mike 940b407560 fix(admin): drop overflow-hidden from action-dropdown tables
The Users and Devices tables had `overflow-hidden` on the wrapper div for
clean rounded corners. That same clipping was hiding the bottom half of
the per-row action menu (a `<details>`/`<summary>` popover absolutely
positioned inside the last cell). Removing `overflow-hidden` lets the
dropdown extend past the table edge — the popover already has its own
border + shadow, so the loss in corner aesthetics is negligible.

The other read-only tables (audit, recordings, oidc, address_books) keep
`overflow-hidden` since they don't host popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:26:43 +02:00

477 lines
16 KiB
Rust

//! Users page — list / create / set-password / toggle-admin / toggle-status
//! / TOTP enroll-unenroll / delete.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::api::users::hash_password;
use crate::database::{NewUser, UserRow};
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
use totp_rs::Secret;
const PAGE_SIZE: i64 = 50;
// ---------- index page ----------
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_full_page(&state).await?))
}
// ---------- create ----------
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub username: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
pub password: String,
/// Checkbox: "on" if checked, absent otherwise.
#[serde(default)]
pub is_admin: Option<String>,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if form.username.trim().is_empty() {
return notice_then_table(&state, "error", "Username required").await;
}
if form.password.len() < 4 {
return notice_then_table(
&state,
"error",
"Password must be at least 4 characters",
)
.await;
}
let hash = hash_password(form.password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let id = state
.db
.user_insert(NewUser {
username: form.username.trim(),
password_hash: &hash,
display_name: form.display_name.trim(),
is_admin: form.is_admin.as_deref() == Some("on"),
})
.await
.map_err(|e| ApiError::Internal(format!("user_insert: {}", e)))?;
if !form.email.trim().is_empty() {
let _ = set_email_inline(&state, id, form.email.trim()).await;
}
notice_then_table(&state, "ok", &format!("Created user '{}'.", form.username)).await
}
async fn set_email_inline(
state: &Arc<AppState>,
user_id: i64,
email: &str,
) -> Result<(), String> {
state
.db
.raw_update_user_email(user_id, email)
.await
.map_err(|e| e.to_string())
}
// ---------- per-row actions ----------
#[derive(Debug, Deserialize)]
pub struct PasswordResetForm {
pub password: String,
}
pub async fn reset_password(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<PasswordResetForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if form.password.len() < 4 {
return notice_then_table(
&state,
"error",
"Password must be at least 4 characters",
)
.await;
}
let hash = hash_password(form.password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let ok = state
.db
.user_set_password(id, &hash)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
if ok { "ok" } else { "error" },
if ok { "Password updated." } else { "User not found." },
)
.await
}
pub async fn toggle_admin(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if id == admin.user_id {
return notice_then_table(
&state,
"error",
"You can't revoke your own admin flag here. Edit another admin's row instead.",
)
.await;
}
let user = state
.db
.user_find_by_id(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
state
.db
.user_set_admin(id, !user.is_admin)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_table(&state).await?))
}
pub async fn toggle_status(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if id == admin.user_id {
return notice_then_table(
&state,
"error",
"You can't disable your own account from here.",
)
.await;
}
let user = state
.db
.user_find_by_id(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
let new_status: i64 = if user.status == 1 { 0 } else { 1 };
state
.db
.user_set_status(id, new_status)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_table(&state).await?))
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if id == admin.user_id {
return notice_then_table(
&state,
"error",
"You can't delete the account you're signed in with.",
)
.await;
}
let ok = state
.db
.user_delete(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
if ok { "ok" } else { "error" },
if ok { "User deleted." } else { "Already gone." },
)
.await
}
// ---------- TOTP ----------
pub async fn totp_enroll(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let user = state
.db
.user_find_by_id(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
let raw = sodiumoxide::randombytes::randombytes(20);
let secret_b32 = Secret::Raw(raw).to_encoded().to_string();
state
.db
.totp_enroll(id, &secret_b32)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let issuer = "RustDesk";
let otpauth = format!(
"otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
issuer = url_encode(issuer),
account = url_encode(&user.username),
secret = url_encode(&secret_b32),
);
let mut html = format!(
r##"<div class="rounded border border-emerald-700/50 bg-emerald-900/30 p-4 mb-4 text-sm">
<div class="font-semibold text-emerald-300 mb-1">TOTP enrolled for {user}</div>
<div class="space-y-1">
<div><span class="text-slate-400">Secret (base32):</span> <code class="text-emerald-200">{secret}</code></div>
<div><span class="text-slate-400">otpauth URL:</span> <code class="text-emerald-200 break-all">{otpauth}</code></div>
<div class="text-xs text-slate-400 pt-1">
Show this once to the user (or scan the URL as a QR code) — it isn't displayed again.
</div>
</div>
</div>"##,
user = html_escape(&user.username),
secret = html_escape(&secret_b32),
otpauth = html_escape(&otpauth),
);
html.push_str(&render_table(&state).await?);
Ok(Html(html))
}
pub async fn totp_unenroll(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let removed = state
.db
.totp_unenroll(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
if removed { "ok" } else { "error" },
if removed { "TOTP removed." } else { "User had no TOTP." },
)
.await
}
// ---------- rendering helpers ----------
async fn notice_then_table(
state: &Arc<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_table(state).await?);
Ok(Html(html))
}
async fn render_full_page(state: &Arc<AppState>) -> Result<String, ApiError> {
let table = render_table(state).await?;
Ok(format!(
r##"<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Users</h2>
</header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">Create user</h3>
<form
class="grid grid-cols-1 sm:grid-cols-6 gap-3 text-sm"
hx-post="/admin/pages/users/create"
hx-target="#users-region"
hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input name="username" placeholder="username" required class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="display_name" placeholder="display name" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="email" type="email" placeholder="email (optional)" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 col-span-2"/>
<input name="password" type="password" placeholder="password" required class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<label class="flex items-center gap-2 text-slate-400 px-2"><input type="checkbox" name="is_admin"> admin</label>
<button type="submit" class="sm:col-span-6 bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
</form>
</section>
<section id="users-region">
{table}
</section>
</div>"##
))
}
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
let (_total, users) = state
.db
.users_list_all(0, PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// One small query per row for the TOTP-enrolled flag — N is small.
let mut totp = std::collections::HashMap::new();
for u in &users {
if let Ok(b) = state.db.user_has_totp(u.id).await {
totp.insert(u.id, b);
}
}
let mut s = String::new();
// No `overflow-hidden` on the table wrapper: the per-row action menu is
// an absolutely-positioned `<details>` popover inside a <td>, and the
// wrapper's clipping was hiding the bottom half of the menu.
s.push_str(
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr>
<th class="text-left font-medium px-3 py-2">Username</th>
<th class="text-left font-medium px-3 py-2">Display name</th>
<th class="text-left font-medium px-3 py-2">Email</th>
<th class="text-left font-medium px-3 py-2">Status</th>
<th class="text-left font-medium px-3 py-2">Admin</th>
<th class="text-left font-medium px-3 py-2">TOTP</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
);
for u in &users {
render_user_row(&mut s, u, *totp.get(&u.id).unwrap_or(&false));
}
s.push_str(" </tbody>\n</table></div>");
Ok(s)
}
fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
let status_badge = match u.status {
1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-emerald-900/50 border border-emerald-700/50 text-emerald-300 text-xs">active</span>"#,
0 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">disabled</span>"#,
-1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-amber-900/50 border border-amber-700/50 text-amber-300 text-xs">unverified</span>"#,
_ => "",
};
let admin_badge = if u.is_admin {
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-sky-900/50 border border-sky-700/50 text-sky-300 text-xs">admin</span>"#
} else {
""
};
let totp_badge = if has_totp {
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>"#
} else {
""
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-medium text-slate-200">{username}</td>
<td class="px-3 py-2 text-slate-300">{display_name}</td>
<td class="px-3 py-2 text-slate-400">{email}</td>
<td class="px-3 py-2">{status}</td>
<td class="px-3 py-2">{admin}</td>
<td class="px-3 py-2">{totp}</td>
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML">
<input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button>
</form>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/toggle-admin" hx-target="#users-region" hx-swap="innerHTML">
{admin_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/toggle-status" hx-target="#users-region" hx-swap="innerHTML">
{status_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/totp-{totp_action}" hx-target="#users-region" hx-swap="innerHTML">
{totp_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded"
hx-post="/admin/pages/users/{id}/delete"
hx-confirm="Delete user {username}? This cascades into their tokens, group memberships and AB shares."
hx-target="#users-region" hx-swap="innerHTML">
Delete user
</button>
</div>
</details>
</td>
</tr>"##,
id = u.id,
username = html_escape(&u.username),
display_name = html_escape(&u.display_name),
email = html_escape(&u.email),
status = status_badge,
admin = admin_badge,
totp = totp_badge,
admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" },
status_label = if u.status == 1 { "Disable user" } else { "Enable user" },
totp_action = if has_totp { "unenroll" } else { "enroll" },
totp_label = if has_totp { "Disable TOTP" } else { "Enroll TOTP" },
);
}
fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
_ => ("rose-700/50", "rose-900/30", "rose-300"),
};
format!(
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}