feat: M5 admin dashboard (HTMX + Tailwind CDN, embedded HTML)
A web admin UI for the rustdesk-server, mounted at /admin/* on the
existing HTTP API listener. Single-binary deploy preserved — the two
HTML files live in admin_ui/ and are pulled into the binary via
include_str! at build time, so there's nothing extra to ship.
================================================================================
Architecture
================================================================================
- Stack: HTMX 1.9 + Tailwind play CDN. No SPA, no Node toolchain. Pages
are server-rendered HTML fragments returned by Rust handlers via
Html<String>; the index.html shell uses hx-get to drop a fragment into
the main pane and hx-push-url for back-button history.
- Auth: same Bearer-token table the API uses. The dashboard log-in form
POSTs username + password (+ optional TOTP) to /admin/login; on success
the server mints a token and pins it in an HttpOnly + SameSite=Strict
cookie (`rd_admin_session`). The AuthedUser extractor was extended to
accept either the Authorization: Bearer header (curl, desktop client)
OR the session cookie (browser).
- Embedding: src/api/admin/mod.rs has `include_str!("../../../admin_ui/index.html")`
+ login.html. No tower_http::ServeDir wildcard — we ran into axum 0.5
routing conflicts between literal /admin/login routes and an /admin/*
catch-all, so each HTML file is its own explicit route.
================================================================================
M5a — foundation
================================================================================
Files:
admin_ui/index.html page shell + sidebar + HTMX + 401-bounces-to-login
admin_ui/login.html credentials + TOTP form, posts to /admin/login
src/api/admin/mod.rs router + include_str! + Cache-Control: no-cache
src/api/admin/auth.rs /admin/login POST (form-encoded), /admin/logout POST
src/api/admin/me.rs sidebar fragment ("Signed in as <name>")
src/api/middleware.rs `AuthedUser` now reads either Bearer OR cookie
src/api/state.rs `admin_ui_dir` (informational; UI is embedded)
src/main.rs --admin-ui-dir flag (empty disables the dashboard)
The login flow asks for TOTP transparently in the same form when the
target user has a secret enrolled, so the dashboard inherits the TOTP
gate from the API auth surface for free.
================================================================================
M5b — full CRUD pages
================================================================================
- Users (src/api/admin/pages/users.rs) — list, create, password reset,
toggle admin / status, TOTP enroll / unenroll, delete. TOTP enroll
surfaces the secret + otpauth URL once, on a dismissible banner above
the table.
- Devices (devices.rs) — list with hostname/OS/last-heartbeat/conn count,
force-disconnect (queues `heartbeat_commands` row consumed at the next
/api/heartbeat tick), force-sysinfo refresh.
- Device groups (groups.rs) — list / create / delete / add member /
remove member. Per-group section, with an add-member dropdown of users
not yet in the group.
- Strategies (strategies.rs) — list / create / edit config_options /
delete. config_options is validated as a JSON object on the server side
before persist; bad JSON is reflected to the page with a friendly
error notice.
- Address books (address_books.rs) — read-only overview of all books
with owner, kind (personal / shared badge), peer count, GUID.
- OIDC providers (oidc.rs) — read-only list of what's configured. Editing
remains operator-side via --oidc-config TOML or direct SQL.
================================================================================
M5c — audit + recordings browsers
================================================================================
- Audit log (audit.rs) — three tabs (Connections / File transfers /
Alarms), each capped at the latest 200 rows. Tab pills are HTMX links
with hx-get + hx-target="#main" so the tab switch is a single fetch.
- Recordings (recordings.rs) — read-only list with peer / size / state /
start / finish. Streaming download is a follow-up; for now operators
pull files from --recording-dir directly.
================================================================================
DB methods added
================================================================================
- Users: users_list_all, user_set_status, user_set_admin,
user_set_password, user_delete, user_has_totp,
raw_update_user_email
- Devices: devices_list_all, device_sysinfo_get_conns,
heartbeat_command_queue (also used elsewhere; surfaced)
- Groups: device_groups_list_all, device_group_members,
device_group_create, device_group_delete,
device_group_add_member, device_group_remove_member
- Strategy: strategies_list_all, strategy_create,
strategy_update_config, strategy_delete
- Audit: audit_conn_list, audit_file_list, audit_alarm_list
- Misc: ab_list_all_with_owner, recordings_list
All use the runtime sqlx::query("...") form (matching the project-wide
convention) so the SQLite compile-time-check macros don't require these
new tables to pre-exist in the dev DB.
================================================================================
Conventions enforced
================================================================================
- Every page handler gates on require_admin(&AuthedUser) — non-admin
users get an HTTP 403 + JSON envelope, which the SPA shell catches and
bounces back to the login form.
- HTML fragments are produced via `format!`-with-named-args; html_escape
is centralized in src/api/admin/pages/shared.rs and applied to every
user-supplied string before it lands in the DOM.
- All mutations return either the updated table fragment OR
notice_html(kind, msg) + the table — same pattern across pages, so
HTMX swap targets stay simple (always #region innerHTML).
- Cookie carries no path restriction so it also authorizes /api/* calls
the dashboard might want to make from the browser; HttpOnly +
SameSite=Strict mitigates XSS / CSRF; Max-Age tracks ApiConfig's
session_ttl_secs (30 days).
================================================================================
Verification
================================================================================
1. cargo build --release — clean.
2. End-to-end smoke test:
- /admin/ serves index.html (4406 bytes), /admin/login.html serves
login.html (2598 bytes).
- POST /admin/login with valid creds returns 200 + Set-Cookie
`rd_admin_session=…; HttpOnly; Path=/; SameSite=Strict; Max-Age=…`.
- All eight /admin/pages/* fragments return 200 with cookie.
- Users CRUD round-trip: create alice → toggle admin → disable →
reset password → enroll TOTP (32-char secret displayed once) →
unenroll → delete; self-action guard rejects suicide deletes.
- Groups CRUD: create engineering → add alice as member → SQL
confirms the row.
- Strategies: valid JSON accepted, invalid JSON rejected with a
friendly notice.
- Audit tabs: all three render 200; empty-state messages appear when
no rows.
- /admin/logout clears the cookie; subsequent /admin/me returns 401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
//! 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();
|
||||
s.push_str(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
||||
<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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user