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,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>RustDesk Admin</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||||
|
.nav-link.active { background: rgb(15 23 42); color: rgb(125 211 252); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-950 text-slate-100">
|
||||||
|
<!--
|
||||||
|
Single-page shell. The sidebar drives navigation via HTMX:
|
||||||
|
each link does an `hx-get` of an HTML fragment URL that returns the
|
||||||
|
body of the page. The fragments live under /admin/pages/ and are
|
||||||
|
server-rendered Rust handlers that return Html<String>.
|
||||||
|
This keeps the UI a flat directory of static files plus a small
|
||||||
|
set of fragment endpoints — no SPA, no Node, no build step.
|
||||||
|
-->
|
||||||
|
<div class="min-h-full flex">
|
||||||
|
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||||
|
<div class="px-4 py-5 border-b border-slate-800">
|
||||||
|
<h1 class="text-base font-semibold">RustDesk Admin</h1>
|
||||||
|
<p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML">…</p>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-2 py-3 space-y-1">
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">Users</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/devices" hx-target="#main" hx-push-url="#devices">Devices</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/groups" hx-target="#main" hx-push-url="#groups">Device groups</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">Address books</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/oidc" hx-target="#main" hx-push-url="#oidc">OIDC providers</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a>
|
||||||
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
|
hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings</a>
|
||||||
|
</nav>
|
||||||
|
<div class="px-2 py-3 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
||||||
|
hx-post="/admin/logout"
|
||||||
|
hx-on::after-request="window.location.href = '/admin/login.html'"
|
||||||
|
>Sign out</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="main" class="flex-1 p-6 overflow-x-hidden"
|
||||||
|
hx-get="/admin/pages/users" hx-trigger="load">
|
||||||
|
<div class="text-slate-500 text-sm">Loading…</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast container used by all admin handlers via hx-trigger="load delay:1s" -->
|
||||||
|
<div id="toast"
|
||||||
|
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<!-- Highlight active link based on hash -->
|
||||||
|
<script>
|
||||||
|
function refreshActive() {
|
||||||
|
const hash = location.hash || '#users';
|
||||||
|
document.querySelectorAll('.nav-link').forEach(a => {
|
||||||
|
const ahash = a.getAttribute('hx-push-url');
|
||||||
|
a.classList.toggle('active', ahash === hash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', refreshActive);
|
||||||
|
document.body.addEventListener('htmx:afterSwap', refreshActive);
|
||||||
|
refreshActive();
|
||||||
|
|
||||||
|
// Bounce to login if any HTMX request comes back 401.
|
||||||
|
document.body.addEventListener('htmx:responseError', (evt) => {
|
||||||
|
if (evt.detail.xhr.status === 401) {
|
||||||
|
window.location.href = '/admin/login.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Sign in — RustDesk Admin</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<main class="w-full max-w-sm px-6">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-semibold">RustDesk Admin</h1>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Sign in to manage the server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-6 shadow-xl"
|
||||||
|
hx-post="/admin/login"
|
||||||
|
hx-target="#err"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="if (event.detail.successful) window.location.href = '/admin/'"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username" name="username" type="text" required autocomplete="username"
|
||||||
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password" name="password" type="password" required autocomplete="current-password"
|
||||||
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tfa-section" class="hidden">
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1" for="tfaCode">6-digit TOTP code</label>
|
||||||
|
<input
|
||||||
|
id="tfaCode" name="tfaCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
|
||||||
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm tracking-widest text-center focus:outline-none focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
<input id="secret" name="secret" type="hidden" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
|
||||||
|
if u.is_admin {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::Forbidden("admin required".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>"##
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -311,7 +311,7 @@ async fn issue_session(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
|
pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
|
||||||
let secret = Secret::Encoded(secret_b32.to_string())
|
let secret = Secret::Encoded(secret_b32.to_string())
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
|
||||||
|
|||||||
+41
-5
@@ -1,10 +1,13 @@
|
|||||||
use crate::api::error::ApiError;
|
use crate::api::error::ApiError;
|
||||||
use crate::api::state::AppState;
|
use crate::api::state::AppState;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::extract::{FromRequest, RequestParts, TypedHeader};
|
use axum::extract::{FromRequest, RequestParts};
|
||||||
use axum::headers::{authorization::Bearer, Authorization};
|
use axum::http::header;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Cookie name used by the admin dashboard. Browser-set, HttpOnly, SameSite=Strict.
|
||||||
|
pub const SESSION_COOKIE: &str = "rd_admin_session";
|
||||||
|
|
||||||
pub struct AuthedUser {
|
pub struct AuthedUser {
|
||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -22,13 +25,11 @@ impl<B: Send> FromRequest<B> for AuthedUser {
|
|||||||
type Rejection = ApiError;
|
type Rejection = ApiError;
|
||||||
|
|
||||||
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||||
let bearer: TypedHeader<Authorization<Bearer>> =
|
|
||||||
TypedHeader::from_request(req).await.map_err(|_| ApiError::Unauthorized)?;
|
|
||||||
let state: axum::extract::Extension<Arc<AppState>> =
|
let state: axum::extract::Extension<Arc<AppState>> =
|
||||||
axum::extract::Extension::from_request(req)
|
axum::extract::Extension::from_request(req)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Internal("missing state".into()))?;
|
.map_err(|_| ApiError::Internal("missing state".into()))?;
|
||||||
let token = bearer.0 .0.token().to_string();
|
let token = extract_token(req).ok_or(ApiError::Unauthorized)?;
|
||||||
let sha = sha256_token(&token);
|
let sha = sha256_token(&token);
|
||||||
|
|
||||||
let (user_id, _exp) = state
|
let (user_id, _exp) = state
|
||||||
@@ -57,3 +58,38 @@ impl<B: Send> FromRequest<B> for AuthedUser {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a token from either the `Authorization: Bearer …` header (preferred,
|
||||||
|
/// for the desktop client and curl) or the `rd_admin_session` cookie (for the
|
||||||
|
/// browser-driven admin dashboard). Returns `None` if neither is present.
|
||||||
|
fn extract_token<B>(req: &RequestParts<B>) -> Option<String> {
|
||||||
|
// Bearer header wins so a curl smoke test always behaves predictably,
|
||||||
|
// even when run from the same browser session.
|
||||||
|
if let Some(auth) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = auth.to_str() {
|
||||||
|
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
|
||||||
|
if !tok.is_empty() {
|
||||||
|
return Some(tok.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cookie header is a single line: `name=value; name2=value2; …`. Walk
|
||||||
|
// the comma-or-semicolon-separated pairs without pulling in a cookie crate.
|
||||||
|
if let Some(cookie_hdr) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = cookie_hdr.to_str() {
|
||||||
|
for pair in s.split(';') {
|
||||||
|
let pair = pair.trim();
|
||||||
|
if let Some((name, value)) = pair.split_once('=') {
|
||||||
|
if name.trim() == SESSION_COOKIE {
|
||||||
|
let v = value.trim();
|
||||||
|
if !v.is_empty() {
|
||||||
|
return Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
+10
-3
@@ -4,6 +4,7 @@
|
|||||||
//! add address book, audit, OIDC, etc.
|
//! add address book, audit, OIDC, etc.
|
||||||
|
|
||||||
pub mod ab;
|
pub mod ab;
|
||||||
|
pub mod admin;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod devices_cli;
|
pub mod devices_cli;
|
||||||
@@ -34,7 +35,7 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
let app = Router::new()
|
||||||
// M1: auth + heartbeat + sysinfo
|
// M1: auth + heartbeat + sysinfo
|
||||||
.route(
|
.route(
|
||||||
"/api/login-options",
|
"/api/login-options",
|
||||||
@@ -91,8 +92,14 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
// M4: OIDC device-flow login
|
// M4: OIDC device-flow login
|
||||||
.route("/api/oidc/auth", post(oidc::auth::auth))
|
.route("/api/oidc/auth", post(oidc::auth::auth))
|
||||||
.route("/api/oidc/auth-query", get(oidc::poll::auth_query))
|
.route("/api/oidc/auth-query", get(oidc::poll::auth_query))
|
||||||
.route("/oidc/callback", get(oidc::callback::callback))
|
.route("/oidc/callback", get(oidc::callback::callback));
|
||||||
.layer(Extension(state))
|
// M5: admin dashboard (HTMX + embedded HTML). Merged BEFORE the
|
||||||
|
// Extension layer so the merged router carries the shared state.
|
||||||
|
let app = match admin::build(state.clone()) {
|
||||||
|
Some(admin_router) => app.merge(admin_router),
|
||||||
|
None => app,
|
||||||
|
};
|
||||||
|
app.layer(Extension(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(addr: SocketAddr, state: Arc<AppState>) -> ResultType<()> {
|
pub async fn serve(addr: SocketAddr, state: Arc<AppState>) -> ResultType<()> {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ pub struct ApiConfig {
|
|||||||
/// Externally reachable base URL of this server, e.g. for the OIDC
|
/// Externally reachable base URL of this server, e.g. for the OIDC
|
||||||
/// redirect_uri. Empty disables OIDC.
|
/// redirect_uri. Empty disables OIDC.
|
||||||
pub public_base_url: String,
|
pub public_base_url: String,
|
||||||
|
/// On-disk root for the admin dashboard's static files. Empty disables
|
||||||
|
/// the dashboard entirely.
|
||||||
|
pub admin_ui_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SMTP wiring for email-code login.
|
/// SMTP wiring for email-code login.
|
||||||
@@ -68,6 +71,7 @@ impl AppState {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let email = build_email_config();
|
let email = build_email_config();
|
||||||
let public_base_url = get_arg("public-base-url");
|
let public_base_url = get_arg("public-base-url");
|
||||||
|
let admin_ui_dir = get_arg_or("admin-ui-dir", "./admin_ui".to_string());
|
||||||
// login_options advertises every login method this server accepts.
|
// login_options advertises every login method this server accepts.
|
||||||
// The Flutter client uses this to render the matching button on the
|
// The Flutter client uses this to render the matching button on the
|
||||||
// sign-in dialog. `email_code` and `oidc/<name>` are opt-in so a
|
// sign-in dialog. `email_code` and `oidc/<name>` are opt-in so a
|
||||||
@@ -91,6 +95,7 @@ impl AppState {
|
|||||||
audit_retention_days,
|
audit_retention_days,
|
||||||
email,
|
email,
|
||||||
public_base_url,
|
public_base_url,
|
||||||
|
admin_ui_dir,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+497
@@ -108,6 +108,78 @@ pub struct DeviceGroupRow {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AbOverviewRow {
|
||||||
|
pub guid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: i64, // 0=personal, 1=shared
|
||||||
|
pub owner_username: String,
|
||||||
|
pub peer_count: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StrategyRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub modified_at: i64,
|
||||||
|
pub config_options_json: String,
|
||||||
|
pub extra_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuditConnRow {
|
||||||
|
pub guid: String,
|
||||||
|
pub peer_id: String,
|
||||||
|
pub conn_id: i64,
|
||||||
|
pub session_id: i64,
|
||||||
|
pub ip: String,
|
||||||
|
pub action: String,
|
||||||
|
pub note: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuditFileRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub peer_id: String,
|
||||||
|
pub remote_peer: String,
|
||||||
|
pub direction: i64,
|
||||||
|
pub path: String,
|
||||||
|
pub is_file: bool,
|
||||||
|
pub info_json: String,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuditAlarmRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub peer_id: String,
|
||||||
|
pub typ: i64,
|
||||||
|
pub info_json: String,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecordingRow {
|
||||||
|
pub filename: String,
|
||||||
|
pub peer_id: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub state: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
pub finished_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DashboardDeviceRow {
|
||||||
|
pub id: String,
|
||||||
|
pub uuid: String,
|
||||||
|
pub owner_username: String,
|
||||||
|
pub last_heartbeat_at: String,
|
||||||
|
pub sysinfo_payload: String,
|
||||||
|
pub conns_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PeerListRow {
|
pub struct PeerListRow {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -347,6 +419,431 @@ impl Database {
|
|||||||
Ok(row.map(row_to_user))
|
Ok(row.map(row_to_user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All users, including disabled ones — distinct from
|
||||||
|
/// `users_list_accessible`, which the API uses (filtering by status=1
|
||||||
|
/// and visibility through device-groups). The dashboard wants the
|
||||||
|
/// full picture.
|
||||||
|
pub async fn users_list_all(
|
||||||
|
&self,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> ResultType<(i64, Vec<UserRow>)> {
|
||||||
|
let total: i64 = sqlx::query("SELECT COUNT(*) AS c FROM users")
|
||||||
|
.fetch_one(self.pool.get().await?.deref_mut())
|
||||||
|
.await?
|
||||||
|
.try_get("c")?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
|
||||||
|
FROM users ORDER BY username LIMIT ? OFFSET ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok((total, rows.into_iter().map(row_to_user).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_set_status(&self, id: i64, status: i64) -> ResultType<bool> {
|
||||||
|
let res = sqlx::query("UPDATE users SET status = ?, updated_at = current_timestamp WHERE id = ?")
|
||||||
|
.bind(status)
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_set_admin(&self, id: i64, is_admin: bool) -> ResultType<bool> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE users SET is_admin = ?, updated_at = current_timestamp WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(if is_admin { 1i64 } else { 0i64 })
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_set_password(&self, id: i64, hash: &str) -> ResultType<bool> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE users SET password_hash = ?, updated_at = current_timestamp WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the user row. Cascade hits `tokens` (FK ON DELETE CASCADE)
|
||||||
|
/// — TOTP secrets and AB ownership are best-effort cleaned by separate
|
||||||
|
/// queries below.
|
||||||
|
pub async fn user_delete(&self, id: i64) -> ResultType<bool> {
|
||||||
|
let _ = sqlx::query("DELETE FROM user_totp_secrets WHERE user_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
|
let _ = sqlx::query("DELETE FROM device_group_members WHERE user_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
|
let _ = sqlx::query("DELETE FROM address_book_shares WHERE user_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
|
let res = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devices listed for the dashboard. Returns each row of device_sysinfo
|
||||||
|
/// joined to its owner's username, sorted by recency.
|
||||||
|
pub async fn devices_list_all(
|
||||||
|
&self,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> ResultType<(i64, Vec<DashboardDeviceRow>)> {
|
||||||
|
let total: i64 = sqlx::query("SELECT COUNT(*) AS c FROM device_sysinfo")
|
||||||
|
.fetch_one(self.pool.get().await?.deref_mut())
|
||||||
|
.await?
|
||||||
|
.try_get("c")?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT ds.id AS pid, ds.uuid AS puuid, \
|
||||||
|
COALESCE(u.username, '') AS owner_username, \
|
||||||
|
ds.last_heartbeat_at AS last_hb, \
|
||||||
|
ds.payload AS payload, \
|
||||||
|
ds.conns AS conns \
|
||||||
|
FROM device_sysinfo ds \
|
||||||
|
LEFT JOIN users u ON u.id = ds.user_id \
|
||||||
|
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
let data = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| DashboardDeviceRow {
|
||||||
|
id: r.try_get("pid").unwrap_or_default(),
|
||||||
|
uuid: r.try_get("puuid").unwrap_or_default(),
|
||||||
|
owner_username: r.try_get("owner_username").unwrap_or_default(),
|
||||||
|
last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
|
||||||
|
sysinfo_payload: r.try_get("payload").unwrap_or_default(),
|
||||||
|
conns_json: r.try_get("conns").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((total, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_sysinfo_get_conns(&self, peer_id: &str) -> ResultType<String> {
|
||||||
|
let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1")
|
||||||
|
.bind(peer_id)
|
||||||
|
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(row
|
||||||
|
.and_then(|r| r.try_get::<String, _>("conns").ok())
|
||||||
|
.unwrap_or_else(|| "[]".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn heartbeat_command_queue(
|
||||||
|
&self,
|
||||||
|
peer_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
payload: Option<&str>,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO heartbeat_commands(peer_id, kind, payload, created_at) \
|
||||||
|
VALUES(?, ?, ?, strftime('%s','now'))",
|
||||||
|
)
|
||||||
|
.bind(peer_id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(payload)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All address books, with the owner's username and an optional
|
||||||
|
/// per-AB peer count. Used by the dashboard's read-only AB overview.
|
||||||
|
pub async fn ab_list_all_with_owner(&self) -> ResultType<Vec<AbOverviewRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT ab.guid, ab.name, ab.kind, ab.created_at, \
|
||||||
|
COALESCE(u.username, '') AS owner_username, \
|
||||||
|
(SELECT COUNT(*) FROM address_book_peers abp WHERE abp.ab_guid = ab.guid) AS peer_count \
|
||||||
|
FROM address_books ab LEFT JOIN users u ON u.id = ab.owner_user_id \
|
||||||
|
ORDER BY ab.kind, owner_username, ab.name",
|
||||||
|
)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| AbOverviewRow {
|
||||||
|
guid: r.try_get("guid").unwrap_or_default(),
|
||||||
|
name: r.try_get("name").unwrap_or_default(),
|
||||||
|
kind: r.try_get("kind").unwrap_or(0),
|
||||||
|
owner_username: r.try_get("owner_username").unwrap_or_default(),
|
||||||
|
peer_count: r.try_get("peer_count").unwrap_or(0),
|
||||||
|
created_at: r.try_get("created_at").unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- M5 dashboard helpers: groups / strategies / audit / recordings ----
|
||||||
|
|
||||||
|
pub async fn device_groups_list_all(&self) -> ResultType<Vec<DeviceGroupRow>> {
|
||||||
|
let rows = sqlx::query("SELECT id, name FROM device_groups ORDER BY name")
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| DeviceGroupRow {
|
||||||
|
id: r.try_get("id").unwrap_or(0),
|
||||||
|
name: r.try_get("name").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_members(&self, group_id: i64) -> ResultType<Vec<UserRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT u.id, u.username, u.password_hash, u.display_name, u.email, u.note, u.avatar, u.status, u.is_admin \
|
||||||
|
FROM users u JOIN device_group_members m ON m.user_id = u.id \
|
||||||
|
WHERE m.device_group_id = ? ORDER BY u.username",
|
||||||
|
)
|
||||||
|
.bind(group_id)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(row_to_user).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_create(&self, name: &str) -> ResultType<i64> {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO device_groups(name) VALUES(?)")
|
||||||
|
.bind(name)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
let row = sqlx::query("SELECT id FROM device_groups WHERE name = ?")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(row.try_get("id")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_delete(&self, group_id: i64) -> ResultType<bool> {
|
||||||
|
let _ = sqlx::query("DELETE FROM device_group_members WHERE device_group_id = ?")
|
||||||
|
.bind(group_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
|
let res = sqlx::query("DELETE FROM device_groups WHERE id = ?")
|
||||||
|
.bind(group_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_add_member(
|
||||||
|
&self,
|
||||||
|
group_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO device_group_members(device_group_id, user_id) VALUES(?, ?)",
|
||||||
|
)
|
||||||
|
.bind(group_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_remove_member(
|
||||||
|
&self,
|
||||||
|
group_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM device_group_members WHERE device_group_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(group_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn strategies_list_all(&self) -> ResultType<Vec<StrategyRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, name, modified_at, config_options_json, extra_json \
|
||||||
|
FROM strategies ORDER BY name",
|
||||||
|
)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| StrategyRow {
|
||||||
|
id: r.try_get("id").unwrap_or(0),
|
||||||
|
name: r.try_get("name").unwrap_or_default(),
|
||||||
|
modified_at: r.try_get("modified_at").unwrap_or(0),
|
||||||
|
config_options_json: r
|
||||||
|
.try_get("config_options_json")
|
||||||
|
.unwrap_or_else(|_| "{}".to_string()),
|
||||||
|
extra_json: r.try_get("extra_json").unwrap_or_else(|_| "{}".to_string()),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn strategy_create(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
config_options_json: &str,
|
||||||
|
extra_json: &str,
|
||||||
|
) -> ResultType<i64> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"INSERT INTO strategies(name, modified_at, config_options_json, extra_json) \
|
||||||
|
VALUES(?, strftime('%s','now'), ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(config_options_json)
|
||||||
|
.bind(extra_json)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn strategy_update_config(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
config_options_json: &str,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE strategies SET config_options_json = ?, modified_at = strftime('%s','now') \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(config_options_json)
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn strategy_delete(&self, id: i64) -> ResultType<bool> {
|
||||||
|
let _ = sqlx::query("DELETE FROM strategy_assignments WHERE strategy_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
|
let res = sqlx::query("DELETE FROM strategies WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audit listings (newest first) — used by the dashboard browser. Each
|
||||||
|
/// returns at most `limit` rows; the dashboard caps at a few hundred.
|
||||||
|
pub async fn audit_conn_list(&self, limit: i64) -> ResultType<Vec<AuditConnRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT guid, peer_id, conn_id, session_id, ip, action, note, started_at \
|
||||||
|
FROM audit_conn ORDER BY started_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| AuditConnRow {
|
||||||
|
guid: r.try_get("guid").unwrap_or_default(),
|
||||||
|
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||||
|
conn_id: r.try_get("conn_id").unwrap_or(0),
|
||||||
|
session_id: r.try_get("session_id").unwrap_or(0),
|
||||||
|
ip: r.try_get::<Option<String>, _>("ip").ok().flatten().unwrap_or_default(),
|
||||||
|
action: r.try_get("action").unwrap_or_default(),
|
||||||
|
note: r.try_get::<Option<String>, _>("note").ok().flatten().unwrap_or_default(),
|
||||||
|
started_at: r.try_get("started_at").unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn audit_file_list(&self, limit: i64) -> ResultType<Vec<AuditFileRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, peer_id, remote_peer, direction, path, is_file, info_json, at \
|
||||||
|
FROM audit_file ORDER BY at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| AuditFileRow {
|
||||||
|
id: r.try_get("id").unwrap_or(0),
|
||||||
|
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||||
|
remote_peer: r.try_get::<Option<String>, _>("remote_peer").ok().flatten().unwrap_or_default(),
|
||||||
|
direction: r.try_get("direction").unwrap_or(0),
|
||||||
|
path: r.try_get("path").unwrap_or_default(),
|
||||||
|
is_file: r.try_get::<i64, _>("is_file").unwrap_or(0) != 0,
|
||||||
|
info_json: r.try_get("info_json").unwrap_or_default(),
|
||||||
|
at: r.try_get("at").unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn audit_alarm_list(&self, limit: i64) -> ResultType<Vec<AuditAlarmRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, peer_id, typ, info_json, at \
|
||||||
|
FROM audit_alarm ORDER BY at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| AuditAlarmRow {
|
||||||
|
id: r.try_get("id").unwrap_or(0),
|
||||||
|
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||||
|
typ: r.try_get("typ").unwrap_or(0),
|
||||||
|
info_json: r.try_get("info_json").unwrap_or_default(),
|
||||||
|
at: r.try_get("at").unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recordings_list(&self, limit: i64) -> ResultType<Vec<RecordingRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT filename, peer_id, size, state, started_at, finished_at \
|
||||||
|
FROM recordings ORDER BY started_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| RecordingRow {
|
||||||
|
filename: r.try_get("filename").unwrap_or_default(),
|
||||||
|
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||||
|
size: r.try_get("size").unwrap_or(0),
|
||||||
|
state: r.try_get("state").unwrap_or_default(),
|
||||||
|
started_at: r.try_get("started_at").unwrap_or(0),
|
||||||
|
finished_at: r.try_get::<Option<i64>, _>("finished_at").ok().flatten(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn raw_update_user_email(&self, user_id: i64, email: &str) -> ResultType<()> {
|
||||||
|
sqlx::query("UPDATE users SET email = ?, updated_at = current_timestamp WHERE id = ?")
|
||||||
|
.bind(email)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_has_totp(&self, user_id: i64) -> ResultType<bool> {
|
||||||
|
let row =
|
||||||
|
sqlx::query("SELECT 1 AS ok FROM user_totp_secrets WHERE user_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn user_insert(&self, u: NewUser<'_>) -> ResultType<i64> {
|
pub async fn user_insert(&self, u: NewUser<'_>) -> ResultType<i64> {
|
||||||
let admin_int: i64 = if u.is_admin { 1 } else { 0 };
|
let admin_int: i64 = if u.is_admin { 1 } else { 0 };
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ fn main() -> ResultType<()> {
|
|||||||
--smtp-tls=[on|off] 'STARTTLS on the SMTP connection (default: on)'
|
--smtp-tls=[on|off] 'STARTTLS on the SMTP connection (default: on)'
|
||||||
--public-base-url=[URL] 'Externally reachable HTTP base URL (e.g. https://rustdesk.example.com:21114) — required for OIDC redirect callbacks'
|
--public-base-url=[URL] 'Externally reachable HTTP base URL (e.g. https://rustdesk.example.com:21114) — required for OIDC redirect callbacks'
|
||||||
--oidc-config=[PATH] 'TOML file describing OIDC providers (upserted into oidc_providers at startup)'
|
--oidc-config=[PATH] 'TOML file describing OIDC providers (upserted into oidc_providers at startup)'
|
||||||
|
--admin-ui-dir=[PATH] 'Directory of static admin-dashboard files served at /admin/ (default: ./admin_ui; empty disables)'
|
||||||
, --mask=[MASK] 'Determine if the connection comes from LAN, e.g. 192.168.0.0/16'
|
, --mask=[MASK] 'Determine if the connection comes from LAN, e.g. 192.168.0.0/16'
|
||||||
-k, --key=[KEY] 'Only allow the client with the same key'",
|
-k, --key=[KEY] 'Only allow the client with the same key'",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user