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:
2026-05-01 20:13:35 +02:00
parent 8ecf05b106
commit 5b288d671c
21 changed files with 2633 additions and 9 deletions
+172
View File
@@ -0,0 +1,172 @@
//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login
//! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted
//! Bearer token; the browser carries it on every subsequent request to
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
//! accepts both `Authorization: Bearer …` and the cookie.
use crate::api::auth::mint_token;
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
use crate::api::state::AppState;
use crate::api::users::verify_password;
use axum::extract::{Extension, Form};
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
/// 6-digit TOTP code, present on the second leg when the first leg
/// returned `tfa_check`.
#[serde(default)]
pub tfa_code: String,
/// Echo of the TOTP nonce the first-leg response set on the form.
#[serde(default)]
pub secret: String,
}
pub async fn login(
Extension(state): Extension<Arc<AppState>>,
Form(form): Form<LoginForm>,
) -> Response {
// First leg: password verify. Same DB call paths as `/api/login` —
// we re-use the existing helpers so the dashboard can't accidentally
// diverge from the API's auth contract.
let user = match state.db.user_find_by_username(&form.username).await {
Ok(Some(u)) => u,
Ok(None) => return error_fragment("Bad credentials"),
Err(e) => return error_fragment(&format!("internal: {}", e)),
};
let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await {
Ok(b) => b,
Err(e) => return error_fragment(&format!("internal: {}", e)),
};
if !pw_ok {
return error_fragment("Bad credentials");
}
if user.status == 0 {
return error_fragment("Account disabled");
}
if !user.is_admin {
// Only admins can use the dashboard. Non-admin users still get
// tokens via `/api/login` for the desktop client; they just don't
// see the management surface.
return error_fragment("Admin access required");
}
// Optional second leg: TOTP. If the user has a secret enrolled and the
// form didn't carry a code, return a fragment that asks for one.
let totp_secret = state
.db
.totp_get_secret(user.id)
.await
.ok()
.flatten();
if let Some(secret_b32) = totp_secret {
if form.tfa_code.is_empty() {
// Shape used by the JS in login.html to switch to the second
// leg: it watches for the special marker via HX-Trigger and
// reveals the #tfa-section.
let frag = format!(
r#"<span data-tfa-required="1" class="text-amber-300">Enter your 6-digit authenticator code.</span>
<script>
document.getElementById('tfa-section').classList.remove('hidden');
document.getElementById('tfaCode').focus();
</script>"#
);
// We don't need a session yet — caller will resubmit with the
// same username/password plus the code. (No nonce involved on
// the dashboard path: the password is already in scope, so
// tfa_check / tfa_code are folded into one form.)
let _ = secret_b32;
return Html(frag).into_response();
}
// Verify the supplied code.
let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) {
Ok(b) => b,
Err(_) => return error_fragment("Internal TOTP error"),
};
if !ok {
return error_fragment("Bad TOTP code");
}
}
// Mint + persist a token, set the cookie.
let token = mint_token();
let sha = sha256_token(&token);
if let Err(e) = state
.db
.token_insert(
user.id,
&sha,
"",
"",
r#"{"source":"admin-ui"}"#,
state.cfg.session_ttl_secs,
)
.await
{
return error_fragment(&format!("internal: {}", e));
}
let cookie = format!(
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
name = SESSION_COOKIE,
token = token,
ttl = state.cfg.session_ttl_secs,
);
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(&cookie) {
headers.insert(SET_COOKIE, v);
}
// 200 with empty body; the form's hx-on::after-request redirects on
// success.
(StatusCode::OK, headers, "").into_response()
}
pub async fn logout(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
// Best-effort: pull the token out of the cookie, drop the row.
if let Some(tok) = cookie_token(&headers) {
let sha = sha256_token(&tok);
let _ = state.db.token_delete(&sha).await;
}
let mut out = HeaderMap::new();
let clear = format!(
"{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0",
name = SESSION_COOKIE
);
if let Ok(v) = HeaderValue::from_str(&clear) {
out.insert(SET_COOKIE, v);
}
(StatusCode::OK, out, "").into_response()
}
fn cookie_token(headers: &HeaderMap) -> Option<String> {
let s = headers.get(COOKIE)?.to_str().ok()?;
for pair in s.split(';') {
if let Some((name, value)) = pair.trim().split_once('=') {
if name.trim() == SESSION_COOKIE {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
}
None
}
fn error_fragment(msg: &str) -> Response {
let html = format!("<span>{}</span>", html_escape(msg));
(StatusCode::UNAUTHORIZED, Html(html)).into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+21
View File
@@ -0,0 +1,21 @@
//! `/admin/me` — small HTMX fragment used by the sidebar to show "signed in
//! as <name>". Doubles as a cheap auth-check for the dashboard shell: if
//! the cookie isn't valid, the AuthedUser extractor 401s and the page-level
//! HTMX response handler bounces back to the login form.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::response::Html;
pub async fn me(user: AuthedUser) -> Result<Html<String>, ApiError> {
Ok(Html(format!(
"Signed in as <span class=\"text-slate-300\">{}</span>",
html_escape(&user.name)
)))
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+139
View File
@@ -0,0 +1,139 @@
//! Admin dashboard router. Mounted at `/admin/*` by `api::router` when
//! the operator hasn't disabled it via `--admin-ui-dir=` (empty).
//!
//! Static HTML/CSS lives in `admin_ui/` next to the source tree and is
//! embedded into the binary at build time via `include_str!` — no separate
//! deploy artifact, no ServeDir wildcard route conflicting with the
//! literal /admin/login etc. The ASSETS table at the bottom is the
//! authoritative list of files we ship.
//!
//! Layout served at runtime:
//! /admin/ ← index.html (the SPA shell)
//! /admin/login.html ← login form
//! /admin/login POST handler (form-encoded, sets session cookie)
//! /admin/logout POST handler (clears session cookie)
//! /admin/me GET fragment (current user, sidebar widget)
//! /admin/pages/* GET fragments (one per page)
pub mod auth;
pub mod me;
pub mod pages;
use axum::http::header;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;
/// Files embedded into the binary. Paths are relative to this source file
/// per `include_str!`. Adding a new HTML asset = one new entry here.
const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html");
const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html");
pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
if state.cfg.admin_ui_dir.is_empty() {
// Operator opted out by setting the flag to empty.
return None;
}
let r = Router::new()
// Static HTML pages — explicit routes per file, no wildcard.
.route("/admin", get(serve_index))
.route("/admin/", get(serve_index))
.route("/admin/index.html", get(serve_index))
.route("/admin/login.html", get(serve_login))
// Dynamic dashboard endpoints.
.route("/admin/login", post(auth::login))
.route("/admin/logout", post(auth::logout))
.route("/admin/me", get(me::me))
// Page fragments — one per sidebar entry.
.route("/admin/pages/users", get(pages::users::index))
.route("/admin/pages/users/create", post(pages::users::create))
.route(
"/admin/pages/users/:id/password-reset",
post(pages::users::reset_password),
)
.route(
"/admin/pages/users/:id/toggle-admin",
post(pages::users::toggle_admin),
)
.route(
"/admin/pages/users/:id/toggle-status",
post(pages::users::toggle_status),
)
.route(
"/admin/pages/users/:id/totp-enroll",
post(pages::users::totp_enroll),
)
.route(
"/admin/pages/users/:id/totp-unenroll",
post(pages::users::totp_unenroll),
)
.route("/admin/pages/users/:id/delete", post(pages::users::delete))
// Devices
.route(
"/admin/pages/devices/:peer_id/disconnect",
post(pages::devices::force_disconnect),
)
.route(
"/admin/pages/devices/:peer_id/sysinfo-refresh",
post(pages::devices::force_sysinfo),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
.route(
"/admin/pages/groups/:id/members/add",
post(pages::groups::add_member),
)
.route(
"/admin/pages/groups/:id/members/:user_id/remove",
post(pages::groups::remove_member),
)
// Strategies
.route(
"/admin/pages/strategies/create",
post(pages::strategies::create),
)
.route(
"/admin/pages/strategies/:id/update",
post(pages::strategies::update),
)
.route(
"/admin/pages/strategies/:id/delete",
post(pages::strategies::delete),
)
.route("/admin/pages/devices", get(pages::devices::index))
.route("/admin/pages/groups", get(pages::groups::index))
.route("/admin/pages/strategies", get(pages::strategies::index))
.route(
"/admin/pages/address-books",
get(pages::address_books::index),
)
.route("/admin/pages/oidc", get(pages::oidc::index))
.route("/admin/pages/audit", get(pages::audit::index))
.route("/admin/pages/recordings", get(pages::recordings::index));
hbb_common::log::info!(
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
);
Some(r)
}
async fn serve_index() -> Response {
html_response(INDEX_HTML)
}
async fn serve_login() -> Response {
html_response(LOGIN_HTML)
}
fn html_response(body: &'static str) -> Response {
// We hand back `Html<&'static str>` so axum sets `text/html` for us.
// Cache-Control: no-cache so the operator sees fresh HTML after a
// server upgrade without having to bump asset URLs.
let mut resp = Html(body).into_response();
resp.headers_mut().insert(
header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
);
resp
}
+75
View File
@@ -0,0 +1,75 @@
//! Address books — read-only overview. Showing every AB on the server with
//! its owner, kind (personal/shared), and peer count. Mutations live in the
//! desktop client; admins use this page to confirm what's in place.
use super::shared::{fmt_unix, html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let books = state
.db
.ab_list_all_with_owner()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">Address books</h2>
<p class="text-xs text-slate-500 mt-1">Read-only. Address-book contents are mutated from the desktop client; this page surfaces who owns what and how big each book is.</p>
</header>"##,
);
if books.is_empty() {
s.push_str(r##"<p class="text-slate-500 text-sm">No address books exist yet.</p></div>"##);
return Ok(Html(s));
}
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">Owner</th>
<th class="text-left font-medium px-3 py-2">Kind</th>
<th class="text-left font-medium px-3 py-2">Name</th>
<th class="text-left font-medium px-3 py-2">Peers</th>
<th class="text-left font-medium px-3 py-2">GUID</th>
<th class="text-left font-medium px-3 py-2">Created</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for b in &books {
let kind = match b.kind {
0 => r#"<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300">personal</span>"#,
1 => r#"<span class="text-xs px-1.5 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300">shared</span>"#,
_ => "",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-200">{owner}</td>
<td class="px-3 py-2">{kind}</td>
<td class="px-3 py-2 text-slate-300">{name}</td>
<td class="px-3 py-2 text-slate-400">{count}</td>
<td class="px-3 py-2 font-mono text-xs text-slate-500">{guid}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{created}</td>
</tr>"##,
owner = html_escape(&b.owner_username),
kind = kind,
name = html_escape(&b.name),
count = b.peer_count,
guid = html_escape(&b.guid),
created = html_escape(&fmt_unix(b.created_at)),
);
}
s.push_str("</tbody></table></div></div>");
Ok(Html(s))
}
+213
View File
@@ -0,0 +1,213 @@
//! Audit log browser — three tabs (conn / file / alarm), each capped at the
//! latest 200 rows. M5c MVP. Pagination/filtering by date range can come in
//! a follow-up if the operator outgrows this view.
use super::shared::{fmt_unix, html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 200;
#[derive(Debug, Deserialize)]
pub struct TabQuery {
#[serde(default)]
pub tab: Option<String>,
}
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Query(q): Query<TabQuery>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let tab = q.tab.as_deref().unwrap_or("conn");
let body = match tab {
"file" => render_file(&state).await?,
"alarm" => render_alarm(&state).await?,
_ => render_conn(&state).await?,
};
let pill = |id: &str, label: &str| {
let active = id == tab;
let cls = if active {
"bg-slate-800 text-sky-300 border-sky-800"
} else {
"bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200"
};
format!(
r##"<a href="#audit" hx-get="/admin/pages/audit?tab={id}" hx-target="#main" class="px-3 py-1 rounded border {cls}">{label}</a>"##,
id = id,
cls = cls,
label = label,
)
};
Ok(Html(format!(
r##"<div class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Audit log</h2>
<p class="text-xs text-slate-500">Latest {n} rows.</p>
</header>
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
{body}
</div>"##,
n = PAGE_SIZE,
pill_conn = pill("conn", "Connections"),
pill_file = pill("file", "File transfers"),
pill_alarm = pill("alarm", "Alarms"),
body = body,
)))
}
async fn render_conn(state: &Arc<AppState>) -> Result<String, ApiError> {
let rows = state
.db
.audit_conn_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table("No connection audit rows yet."));
}
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">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Conn / Session</th>
<th class="text-left font-medium px-3 py-2">IP</th>
<th class="text-left font-medium px-3 py-2">Action</th>
<th class="text-left font-medium px-3 py-2">Note</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{conn} / {sess}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{ip}</td>
<td class="px-3 py-2 text-slate-300">{action}</td>
<td class="px-3 py-2 text-slate-400">{note}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.started_at)),
peer = html_escape(&r.peer_id),
conn = r.conn_id,
sess = r.session_id,
ip = html_escape(&r.ip),
action = html_escape(&r.action),
note = html_escape(&r.note)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
let rows = state
.db
.audit_file_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table("No file-transfer audit rows yet."));
}
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">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Direction</th>
<th class="text-left font-medium px-3 py-2">Path</th>
<th class="text-left font-medium px-3 py-2">Remote</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let dir = match r.direction {
0 => "→ remote",
1 => "← remote",
_ => "?",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{dir}</td>
<td class="px-3 py-2 text-slate-300 font-mono text-xs">{path}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{remote}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
dir = dir,
path = html_escape(&r.path),
remote = html_escape(&r.remote_peer)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
async fn render_alarm(state: &Arc<AppState>) -> Result<String, ApiError> {
let rows = state
.db
.audit_alarm_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table("No alarm audit rows yet."));
}
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">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Type</th>
<th class="text-left font-medium px-3 py-2">Info</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let typ = match r.typ {
0 => "IpWhitelist",
1 => "ExceedThirtyAttempts",
2 => "SixAttemptsWithinOneMinute",
6 => "ExceedIPv6PrefixAttempts",
n => return Ok(format!("(unknown alarm type {})", n)),
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-amber-300">{typ}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs break-all">{info}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
typ = typ,
info = html_escape(&r.info_json)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
fn empty_table(msg: &str) -> String {
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-6 text-center text-sm text-slate-500">{}</div>"##,
html_escape(msg)
)
}
+211
View File
@@ -0,0 +1,211 @@
//! Devices page — list devices currently / recently registered, with
//! force-disconnect (queues a `heartbeat_commands` row consumed on the
//! peer's next /api/heartbeat tick) and force-sysinfo refresh.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::DashboardDeviceRow;
use axum::extract::{Extension, Path};
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 100;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let table = render_table(&state).await?;
Ok(Html(format!(
r##"<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Devices</h2>
<p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p>
</header>
<section id="devices-region">
{table}
</section>
</div>"##
)))
}
pub async fn force_disconnect(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let conns = state
.db
.device_sysinfo_get_conns(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.heartbeat_command_queue(&peer_id, "disconnect", Some(&conns))
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
"ok",
&format!("Queued disconnect for {} (conns={})", peer_id, conns),
)
.await
}
pub async fn force_sysinfo(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.heartbeat_command_queue(&peer_id, "sysinfo", None)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
"ok",
&format!("Queued sysinfo refresh for {}", peer_id),
)
.await
}
// ---------- helpers ----------
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
let (total, devices) = state
.db
.devices_list_all(0, PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
let _ = write!(
s,
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">Peer ID</th>
<th class="text-left font-medium px-3 py-2">Owner</th>
<th class="text-left font-medium px-3 py-2">Hostname</th>
<th class="text-left font-medium px-3 py-2">OS</th>
<th class="text-left font-medium px-3 py-2">Last heartbeat</th>
<th class="text-left font-medium px-3 py-2">Conns</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##
);
if devices.is_empty() {
s.push_str(
r##"<tr><td colspan="7" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
);
}
for d in &devices {
render_device_row(&mut s, d);
}
let _ = write!(
s,
r##"</tbody>
</table>
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div>
</div>"##
);
Ok(s)
}
fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
let pick = |k: &str| -> String {
parsed
.get(k)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
let hostname = pick("hostname");
let os = pick("os");
let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json)
.map(|v| v.len())
.unwrap_or(0);
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-mono text-slate-200">{id}</td>
<td class="px-3 py-2 text-slate-300">{owner}</td>
<td class="px-3 py-2 text-slate-400">{host}</td>
<td class="px-3 py-2 text-slate-400">{os}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
<td class="px-3 py-2 text-slate-400">{n}</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">
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="Disconnect all active sessions on {id}?">
Force disconnect
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/sysinfo-refresh"
hx-target="#devices-region" hx-swap="innerHTML">
Force sysinfo refresh
</button>
</div>
</details>
</td>
</tr>"##,
id = html_escape(&d.id),
owner = html_escape(&d.owner_username),
host = html_escape(&hostname),
os = html_escape(&os),
last = html_escape(&d.last_heartbeat_at),
n = conn_count
);
}
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))
}
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 require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Device-groups page — list, create, delete, add/remove member.
//! Strategies and AB shares hang off device-group membership, so this is
//! the canonical place to manage who can see whose devices.
use super::shared::{html_escape, notice_html, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_full(&state).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: 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.name.trim().is_empty() {
return notice_then(&state, "error", "Name required").await;
}
state
.db
.device_group_create(form.name.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, "ok", &format!("Group '{}' created.", form.name)).await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.device_group_delete(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
if ok { "ok" } else { "error" },
if ok { "Group deleted." } else { "Already gone." },
)
.await
}
#[derive(Debug, Deserialize)]
pub struct MemberForm {
pub user_id: i64,
}
pub async fn add_member(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<MemberForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.device_group_add_member(id, form.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state).await?))
}
pub async fn remove_member(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path((id, user_id)): Path<(i64, i64)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.device_group_remove_member(id, user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state).await?))
}
// ---------- rendering ----------
async fn notice_then(
state: &Arc<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
let groups = state
.db
.device_groups_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_, all_users) = state
.db
.users_list_all(0, 1000)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div id="groups-region" class="space-y-6">
<header><h2 class="text-lg font-semibold">Device groups</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 group</h3>
<form class="flex gap-2 text-sm" hx-post="/admin/pages/groups/create" hx-target="#groups-region" hx-swap="outerHTML">
<input name="name" placeholder="group name" required class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
</form>
</section>
"##,
);
if groups.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No device groups yet.</p>"##,
);
}
for g in &groups {
let members = state
.db
.device_group_members(g.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<header class="flex items-center justify-between">
<h3 class="font-semibold">{name}</h3>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{id}/delete"
hx-confirm="Delete group {name}? Members aren't deleted; just unassigned."
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button>
</header>
<ul class="text-sm divide-y divide-slate-800">"##,
id = g.id,
name = html_escape(&g.name)
);
if members.is_empty() {
s.push_str(
r##"<li class="py-2 text-slate-500 text-xs">No members yet.</li>"##,
);
}
for u in &members {
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="text-slate-200">{username}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/members/{uid}/remove"
hx-target="#groups-region" hx-swap="outerHTML">Remove</button>
</li>"##,
username = html_escape(&u.username),
gid = g.id,
uid = u.id
);
}
s.push_str("</ul>");
// Add-member form: dropdown of users not currently in the group.
let in_group: std::collections::HashSet<i64> =
members.iter().map(|u| u.id).collect();
let candidates: Vec<_> =
all_users.iter().filter(|u| !in_group.contains(&u.id)).collect();
if !candidates.is_empty() {
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/members/add"
hx-target="#groups-region" hx-swap="outerHTML">
<select name="user_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
"##,
id = g.id
);
for u in &candidates {
let _ = write!(
s,
r##"<option value="{uid}">{username}</option>"##,
uid = u.id,
username = html_escape(&u.username)
);
}
s.push_str(
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add member</button>
</form>"##,
);
}
s.push_str("</section>");
}
s.push_str("</div>");
Ok(s)
}
+24
View File
@@ -0,0 +1,24 @@
//! Per-page HTMX fragment handlers. Each page returns a chunk of HTML that
//! the dashboard shell drops into `#main`. Filled in across M5b/M5c.
pub mod address_books;
pub mod audit;
pub mod devices;
pub mod groups;
pub mod oidc;
pub mod recordings;
pub mod shared;
pub mod strategies;
pub mod users;
use axum::response::Html;
/// Tiny placeholder fragment — replaced by the real page handlers in M5b.
pub fn placeholder(title: &str) -> Html<String> {
Html(format!(
r##"<div class="space-y-2">
<h2 class="text-lg font-semibold">{title}</h2>
<p class="text-slate-400 text-sm">This page is part of M5b — the dashboard shell, login, and per-page navigation are wired in M5a; the actual table + form for <strong>{title}</strong> lands in the next slice.</p>
</div>"##
))
}
+73
View File
@@ -0,0 +1,73 @@
//! OIDC providers — read-only listing of what's currently in
//! `oidc_providers`. Editing providers is operator-side via the
//! `--oidc-config` TOML or hand-inserted SQL; the dashboard surfaces them
//! so admins can confirm what's wired up without leaving the UI.
use super::shared::{html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let providers = state
.db
.oidc_provider_list_enabled()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">OIDC providers</h2>
<p class="text-xs text-slate-500 mt-1">Read-only. Add/edit via <code>--oidc-config</code> TOML at startup, or by inserting into the <code>oidc_providers</code> table.</p>
</header>"##,
);
if providers.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No OIDC providers configured.</p></div>"##,
);
return Ok(Html(s));
}
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">Name</th>
<th class="text-left font-medium px-3 py-2">Display name</th>
<th class="text-left font-medium px-3 py-2">Issuer</th>
<th class="text-left font-medium px-3 py-2">Client ID</th>
<th class="text-left font-medium px-3 py-2">Scopes</th>
<th class="text-left font-medium px-3 py-2">Redirect</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for p in &providers {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 font-medium text-slate-200">{name}</td>
<td class="px-3 py-2 text-slate-300">{display}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{issuer}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{client_id}</td>
<td class="px-3 py-2 text-slate-400 text-xs">{scopes}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{redirect}</td>
</tr>"##,
name = html_escape(&p.name),
display = html_escape(p.display_name.as_deref().unwrap_or("")),
issuer = html_escape(&p.issuer_url),
client_id = html_escape(&p.client_id),
scopes = html_escape(&p.scopes),
redirect = html_escape(&p.redirect_url),
);
}
s.push_str("</tbody></table></div></div>");
Ok(Html(s))
}
+87
View File
@@ -0,0 +1,87 @@
//! Recordings — list-only. Adding a streaming download handler is a
//! follow-up; for now the operator looks at the filenames + sizes and
//! pulls files from `--recording-dir` directly.
use super::shared::{fmt_unix, html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 200;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let rows = state
.db
.recordings_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">Recordings</h2>
<p class="text-xs text-slate-500 mt-1">Files live under <code>--recording-dir</code>. Pull them with <code>scp</code> / <code>rsync</code> for now; an in-browser download is coming.</p>
</header>"##,
);
if rows.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No session recordings yet.</p></div>"##,
);
return Ok(Html(s));
}
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">Filename</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Size</th>
<th class="text-left font-medium px-3 py-2">State</th>
<th class="text-left font-medium px-3 py-2">Started</th>
<th class="text-left font-medium px-3 py-2">Finished</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 font-mono text-slate-200 text-xs">{file}</td>
<td class="px-3 py-2 font-mono text-slate-300">{peer}</td>
<td class="px-3 py-2 text-slate-400">{size}</td>
<td class="px-3 py-2 text-slate-400">{state}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{started}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{finished}</td>
</tr>"##,
file = html_escape(&r.filename),
peer = html_escape(&r.peer_id),
size = human_size(r.size),
state = html_escape(&r.state),
started = html_escape(&fmt_unix(r.started_at)),
finished = html_escape(&r.finished_at.map(fmt_unix).unwrap_or_else(|| "".into()))
);
}
s.push_str("</tbody></table></div></div>");
Ok(Html(s))
}
fn human_size(bytes: i64) -> String {
let b = bytes as f64;
if bytes < 1024 {
format!("{} B", bytes)
} else if b < 1024.0 * 1024.0 {
format!("{:.1} KiB", b / 1024.0)
} else if b < 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} MiB", b / (1024.0 * 1024.0))
} else {
format!("{:.2} GiB", b / (1024.0 * 1024.0 * 1024.0))
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Tiny rendering helpers shared by every admin page. Splitting these out
//! keeps each page module under ~200 LOC.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
pub fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}
pub fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub 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),
)
}
/// Format a unix timestamp as a short ISO-ish string for table cells.
pub fn fmt_unix(ts: i64) -> String {
if ts <= 0 {
return "".into();
}
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(ts, 0)
.single()
.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
.unwrap_or_else(|| ts.to_string())
}
+180
View File
@@ -0,0 +1,180 @@
//! Strategies page — list / create / edit-config / delete. Assignment to
//! peers/groups/users is intentionally still SQL-driven for v1; building a
//! full assignment matrix UI is a follow-up.
use super::shared::{html_escape, notice_html, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_full(&state).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: String,
#[serde(default)]
pub config_options_json: 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.name.trim().is_empty() {
return notice_then(&state, "error", "Name required").await;
}
let cfg = if form.config_options_json.trim().is_empty() {
"{}".to_string()
} else {
// Validate it's a JSON object — empty object is fine, anything else
// gets rejected with a friendly message.
match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(),
Ok(_) => {
return notice_then(&state, "error", "config_options must be a JSON object").await
}
Err(e) => return notice_then(&state, "error", &format!("invalid JSON: {}", e)).await,
}
};
state
.db
.strategy_create(form.name.trim(), &cfg, "{}")
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
"ok",
&format!("Strategy '{}' created.", form.name),
)
.await
}
#[derive(Debug, Deserialize)]
pub struct UpdateForm {
pub config_options_json: String,
}
pub async fn update(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<UpdateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cfg = match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(),
_ => {
return notice_then(&state, "error", "config_options must be a JSON object").await
}
};
state
.db
.strategy_update_config(id, &cfg)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, "ok", "Strategy updated.").await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.strategy_delete(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
if ok { "ok" } else { "error" },
if ok { "Strategy deleted." } else { "Already gone." },
)
.await
}
// ---------- rendering ----------
async fn notice_then(
state: &Arc<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
let strategies = state
.db
.strategies_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div id="strategies-region" class="space-y-6">
<header>
<h2 class="text-lg font-semibold">Strategies</h2>
<p class="text-xs text-slate-500 mt-1">Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).</p>
</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 strategy</h3>
<form class="space-y-2 text-sm" hx-post="/admin/pages/strategies/create" hx-target="#strategies-region" hx-swap="outerHTML">
<input name="name" placeholder="name (unique)" required class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<textarea name="config_options_json" rows="3" placeholder='{"enable-udp": "N", "whitelist": ""}'
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs"></textarea>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
</form>
</section>
"##,
);
if strategies.is_empty() {
s.push_str(r##"<p class="text-slate-500 text-sm">No strategies yet.</p>"##);
}
for str_ in &strategies {
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<header class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{name}</h3>
<p class="text-xs text-slate-500">id={id}, modified_at={mod_at}</p>
</div>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{id}/delete"
hx-confirm="Delete strategy {name}? Assignments will be cleaned up too."
hx-target="#strategies-region" hx-swap="outerHTML">Delete</button>
</header>
<form class="space-y-2 text-sm"
hx-post="/admin/pages/strategies/{id}/update"
hx-target="#strategies-region" hx-swap="outerHTML">
<label class="block text-xs text-slate-400">config_options (JSON object)</label>
<textarea name="config_options_json" rows="4"
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Save</button>
</form>
</section>"##,
id = str_.id,
name = html_escape(&str_.name),
mod_at = str_.modified_at,
cfg = html_escape(&str_.config_options_json),
);
}
s.push_str("</div>");
Ok(s)
}
+473
View File
@@ -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('&', "&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()))
}
}