This commit is contained in:
+26
-13
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<html lang="{{LANG_CODE}}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>RustDesk Admin</title>
|
||||
<title>{{T_APP_TITLE}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="/admin/assets/tailwindcss.js"></script>
|
||||
<script src="/admin/assets/htmx.min.js"></script>
|
||||
@@ -23,38 +23,51 @@
|
||||
<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>
|
||||
<h1 class="text-base font-semibold">{{T_APP_TITLE}}</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>
|
||||
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">{{T_NAV_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>
|
||||
hx-get="/admin/pages/devices" hx-target="#main" hx-push-url="#devices">{{T_NAV_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>
|
||||
hx-get="/admin/pages/groups" hx-target="#main" hx-push-url="#groups">{{T_NAV_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>
|
||||
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">{{T_NAV_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>
|
||||
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">{{T_NAV_AB}}</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>
|
||||
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">{{T_NAV_AUDIT}}</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/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
|
||||
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">{{T_NAV_DEPLOY}}</a>
|
||||
</nav>
|
||||
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">My profile</a>
|
||||
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">{{T_NAV_PROFILE}}</a>
|
||||
<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>
|
||||
>{{T_NAV_SIGNOUT}}</button>
|
||||
<div class="pt-2">
|
||||
<label class="block text-[10px] uppercase tracking-wide text-slate-600 px-3 mb-1">{{T_LANGUAGE}}</label>
|
||||
<select
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
||||
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
|
||||
>
|
||||
<option value="en"{{LANG_SEL_EN}}>English</option>
|
||||
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
|
||||
<option value="es"{{LANG_SEL_ES}}>Español</option>
|
||||
<option value="fr"{{LANG_SEL_FR}}>Français</option>
|
||||
<option value="ro"{{LANG_SEL_RO}}>Română</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="main" class="flex-1 p-6 overflow-x-hidden">
|
||||
<div class="text-slate-500 text-sm">Loading…</div>
|
||||
<div class="text-slate-500 text-sm">{{T_LOADING}}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+25
-10
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<html lang="{{LANG_CODE}}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Sign in — RustDesk Admin</title>
|
||||
<title>{{T_SIGNIN}} — {{T_TITLE}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="/admin/assets/tailwindcss.js"></script>
|
||||
<script src="/admin/assets/htmx.min.js"></script>
|
||||
@@ -13,8 +13,8 @@
|
||||
<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>
|
||||
<h1 class="text-2xl font-semibold">{{T_TITLE}}</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{T_SUBTITLE}}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -42,7 +42,7 @@
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">Username</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">{{T_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"
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">Password</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">{{T_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"
|
||||
@@ -58,7 +58,7 @@
|
||||
</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>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="tfaCode">{{T_TOTP_LABEL}}</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"
|
||||
@@ -70,7 +70,7 @@
|
||||
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
|
||||
{{T_SIGNIN}}
|
||||
</button>
|
||||
|
||||
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
|
||||
@@ -80,11 +80,25 @@
|
||||
<div id="oidc-block" class="mt-6 hidden">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="flex-1 h-px bg-slate-800"></div>
|
||||
<span class="text-xs text-slate-500">or</span>
|
||||
<span class="text-xs text-slate-500">{{T_OR}}</span>
|
||||
<div class="flex-1 h-px bg-slate-800"></div>
|
||||
</div>
|
||||
<div id="oidc-buttons" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<label class="text-[10px] uppercase tracking-wide text-slate-600 mr-2">{{T_LANGUAGE}}</label>
|
||||
<select
|
||||
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
||||
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
|
||||
>
|
||||
<option value="en"{{LANG_SEL_EN}}>English</option>
|
||||
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
|
||||
<option value="es"{{LANG_SEL_ES}}>Español</option>
|
||||
<option value="fr"{{LANG_SEL_FR}}>Français</option>
|
||||
<option value="ro"{{LANG_SEL_RO}}>Română</option>
|
||||
</select>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -92,6 +106,7 @@
|
||||
// navigates to /admin/login/oidc/<name>, which 302s the browser to the
|
||||
// IdP. After the IdP redirects to /oidc/callback, the server sets our
|
||||
// session cookie and redirects to /admin/.
|
||||
var SIGNIN_WITH = {{T_SIGNIN_WITH_JSON}};
|
||||
fetch('/admin/oidc/providers').then(r => r.json()).then(list => {
|
||||
if (!Array.isArray(list) || list.length === 0) return;
|
||||
const block = document.getElementById('oidc-block');
|
||||
@@ -100,7 +115,7 @@
|
||||
const a = document.createElement('a');
|
||||
a.href = '/admin/login/oidc/' + encodeURIComponent(p.name);
|
||||
a.className = 'block w-full text-center bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm rounded px-4 py-2 transition';
|
||||
a.textContent = 'Sign in with ' + (p.display_name || p.name);
|
||||
a.textContent = SIGNIN_WITH + ' ' + (p.display_name || p.name);
|
||||
root.appendChild(a);
|
||||
});
|
||||
block.classList.remove('hidden');
|
||||
|
||||
+11
-8
@@ -4,6 +4,7 @@
|
||||
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
|
||||
//! accepts both `Authorization: Bearer …` and the cookie.
|
||||
|
||||
use crate::api::admin::i18n::{t, Lang};
|
||||
use crate::api::auth::mint_token;
|
||||
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
|
||||
use crate::api::state::AppState;
|
||||
@@ -32,6 +33,7 @@ pub struct LoginForm {
|
||||
|
||||
pub async fn login(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
lang: Lang,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
// First leg: password verify. Same DB call paths as `/api/login` —
|
||||
@@ -39,7 +41,7 @@ pub async fn login(
|
||||
// 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"),
|
||||
Ok(None) => return error_fragment(t(lang, "login.bad_credentials")),
|
||||
Err(e) => return error_fragment(&format!("internal: {}", e)),
|
||||
};
|
||||
let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await {
|
||||
@@ -47,16 +49,16 @@ pub async fn login(
|
||||
Err(e) => return error_fragment(&format!("internal: {}", e)),
|
||||
};
|
||||
if !pw_ok {
|
||||
return error_fragment("Bad credentials");
|
||||
return error_fragment(t(lang, "login.bad_credentials"));
|
||||
}
|
||||
if user.status == 0 {
|
||||
return error_fragment("Account disabled");
|
||||
return error_fragment(t(lang, "login.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");
|
||||
return error_fragment(t(lang, "login.admin_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.
|
||||
@@ -72,11 +74,12 @@ pub async fn login(
|
||||
// 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>
|
||||
r#"<span data-tfa-required="1" class="text-amber-300">{msg}</span>
|
||||
<script>
|
||||
document.getElementById('tfa-section').classList.remove('hidden');
|
||||
document.getElementById('tfaCode').focus();
|
||||
</script>"#
|
||||
</script>"#,
|
||||
msg = t(lang, "login.totp_required"),
|
||||
);
|
||||
// We don't need a session yet — caller will resubmit with the
|
||||
// same username/password plus the code. (No nonce involved on
|
||||
@@ -88,10 +91,10 @@ pub async fn login(
|
||||
// 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"),
|
||||
Err(_) => return error_fragment(t(lang, "login.internal_totp")),
|
||||
};
|
||||
if !ok {
|
||||
return error_fragment("Bad TOTP code");
|
||||
return error_fragment(t(lang, "login.bad_totp"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -3,14 +3,16 @@
|
||||
//! 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::admin::i18n::{t, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use axum::response::Html;
|
||||
|
||||
pub async fn me(user: AuthedUser) -> Result<Html<String>, ApiError> {
|
||||
pub async fn me(user: AuthedUser, lang: Lang) -> Result<Html<String>, ApiError> {
|
||||
Ok(Html(format!(
|
||||
"Signed in as <span class=\"text-slate-300\">{}</span>",
|
||||
html_escape(&user.name)
|
||||
"{label} <span class=\"text-slate-300\">{name}</span>",
|
||||
label = t(lang, "nav.signed_in_as"),
|
||||
name = html_escape(&user.name),
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
+99
-7
@@ -16,16 +16,19 @@
|
||||
//! /admin/pages/* GET fragments (one per page)
|
||||
|
||||
pub mod auth;
|
||||
pub mod i18n;
|
||||
pub mod me;
|
||||
pub mod oidc_login;
|
||||
pub mod pages;
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::admin::i18n::{lang_from_headers, t, Lang};
|
||||
|
||||
/// 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");
|
||||
@@ -218,16 +221,105 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
async fn serve_index() -> Response {
|
||||
html_response(INDEX_HTML)
|
||||
async fn serve_index(headers: HeaderMap) -> Response {
|
||||
let lang = lang_from_headers(&headers);
|
||||
html_response_owned(render_index(lang))
|
||||
}
|
||||
|
||||
async fn serve_login() -> Response {
|
||||
html_response(LOGIN_HTML)
|
||||
async fn serve_login(headers: HeaderMap) -> Response {
|
||||
let lang = lang_from_headers(&headers);
|
||||
html_response_owned(render_login(lang))
|
||||
}
|
||||
|
||||
fn html_response(body: &'static str) -> Response {
|
||||
// We hand back `Html<&'static str>` so axum sets `text/html` for us.
|
||||
/// Apply i18n placeholders to the embedded `index.html` template.
|
||||
fn render_index(lang: Lang) -> String {
|
||||
let body = INDEX_HTML
|
||||
.replace("{{LANG_CODE}}", lang.code())
|
||||
.replace("{{T_APP_TITLE}}", t(lang, "shell.app_title"))
|
||||
.replace("{{T_NAV_USERS}}", t(lang, "nav.users"))
|
||||
.replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices"))
|
||||
.replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups"))
|
||||
.replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies"))
|
||||
.replace("{{T_NAV_AB}}", t(lang, "nav.address_books"))
|
||||
.replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit"))
|
||||
.replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy"))
|
||||
.replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile"))
|
||||
.replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout"))
|
||||
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
|
||||
.replace("{{T_LOADING}}", t(lang, "common.loading"));
|
||||
apply_lang_selected(body, lang)
|
||||
}
|
||||
|
||||
/// Apply i18n placeholders to the embedded `login.html` template.
|
||||
fn render_login(lang: Lang) -> String {
|
||||
let body = LOGIN_HTML
|
||||
.replace("{{LANG_CODE}}", lang.code())
|
||||
.replace("{{T_TITLE}}", t(lang, "login.title"))
|
||||
.replace("{{T_SUBTITLE}}", t(lang, "login.subtitle"))
|
||||
.replace("{{T_USERNAME}}", t(lang, "login.username"))
|
||||
.replace("{{T_PASSWORD}}", t(lang, "login.password"))
|
||||
.replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label"))
|
||||
.replace("{{T_SIGNIN}}", t(lang, "login.signin"))
|
||||
.replace("{{T_OR}}", t(lang, "login.or"))
|
||||
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
|
||||
.replace(
|
||||
"{{T_SIGNIN_WITH_JSON}}",
|
||||
&json_string(t(lang, "login.signin_with")),
|
||||
);
|
||||
apply_lang_selected(body, lang)
|
||||
}
|
||||
|
||||
/// Inject `selected` into the matching `<option>` for the active language and
|
||||
/// blank out the others. Both templates use the same `{{LANG_SEL_XX}}` markers.
|
||||
fn apply_lang_selected(body: String, lang: Lang) -> String {
|
||||
let mut sel_en = "";
|
||||
let mut sel_de = "";
|
||||
let mut sel_fr = "";
|
||||
let mut sel_ro = "";
|
||||
let mut sel_es = "";
|
||||
match lang {
|
||||
Lang::En => sel_en = " selected",
|
||||
Lang::De => sel_de = " selected",
|
||||
Lang::Fr => sel_fr = " selected",
|
||||
Lang::Ro => sel_ro = " selected",
|
||||
Lang::Es => sel_es = " selected",
|
||||
}
|
||||
body.replace("{{LANG_SEL_EN}}", sel_en)
|
||||
.replace("{{LANG_SEL_DE}}", sel_de)
|
||||
.replace("{{LANG_SEL_FR}}", sel_fr)
|
||||
.replace("{{LANG_SEL_RO}}", sel_ro)
|
||||
.replace("{{LANG_SEL_ES}}", sel_es)
|
||||
}
|
||||
|
||||
/// JSON-encode a string so it can be embedded inside a `<script>` block as a
|
||||
/// JS string literal. We only need to escape `"`, `\`, and the control chars
|
||||
/// that show up in our translations — none of them realistically contain
|
||||
/// newlines or `</script>`, but escape them defensively anyway.
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'<' => out.push_str("\\u003c"),
|
||||
'>' => out.push_str("\\u003e"),
|
||||
'&' => out.push_str("\\u0026"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn html_response_owned(body: String) -> Response {
|
||||
// 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();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s).
|
||||
|
||||
use super::shared::{fmt_unix, html_escape, notice_html, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -18,9 +19,10 @@ use std::sync::Arc;
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_index(&state, None).await?))
|
||||
Ok(Html(render_index(&state, lang, None).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -31,35 +33,39 @@ pub struct CreateForm {
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(f): Form<CreateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let name = f.name.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(Html(render_index(&state, Some(("error", "Name is required."))).await?));
|
||||
return Ok(Html(
|
||||
render_index(&state, lang, Some(("error", t(lang, "ab.name_required")))).await?,
|
||||
));
|
||||
}
|
||||
let res = state.db.ab_create_shared(admin.user_id, name).await;
|
||||
let notice = match res {
|
||||
Ok(_) => Some(("ok", format!("Shared address book '{}' created.", name))),
|
||||
Ok(_) => Some(("ok", tf1(lang, "ab.created", name))),
|
||||
Err(e) => {
|
||||
// The unique index trips when the same admin creates two books
|
||||
// with the same name. Surface that cleanly instead of leaking
|
||||
// the raw SQL error.
|
||||
let msg = if e.to_string().to_lowercase().contains("unique") {
|
||||
"An address book with that name already exists.".to_string()
|
||||
t(lang, "ab.exists").to_string()
|
||||
} else {
|
||||
format!("Create failed: {}", e)
|
||||
tf1(lang, "ab.create_failed", &e.to_string())
|
||||
};
|
||||
Some(("error", msg))
|
||||
}
|
||||
};
|
||||
let n = notice.as_ref().map(|(k, m)| (*k, m.as_str()));
|
||||
Ok(Html(render_index(&state, n).await?))
|
||||
Ok(Html(render_index(&state, lang, n).await?))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -69,20 +75,21 @@ pub async fn delete(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let notice = if ok {
|
||||
("ok", "Deleted.")
|
||||
("ok", t(lang, "ab.deleted"))
|
||||
} else {
|
||||
("error", "Address book not found.")
|
||||
("error", t(lang, "ab.not_found"))
|
||||
};
|
||||
Ok(Html(render_index(&state, Some(notice)).await?))
|
||||
Ok(Html(render_index(&state, lang, Some(notice)).await?))
|
||||
}
|
||||
|
||||
pub async fn manage(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_manage(&state, &guid, None).await?))
|
||||
Ok(Html(render_manage(&state, lang, &guid, None).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -94,24 +101,30 @@ pub struct ShareForm {
|
||||
pub async fn share_add(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(guid): Path<String>,
|
||||
Form(f): Form<ShareForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if !(1..=3).contains(&f.rule) {
|
||||
return Ok(Html(render_manage(&state, &guid, Some(("error", "Invalid rule."))).await?));
|
||||
return Ok(Html(
|
||||
render_manage(&state, lang, &guid, Some(("error", t(lang, "ab.invalid_rule")))).await?,
|
||||
));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_share_set(&guid, f.user_id, f.rule)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_manage(&state, &guid, Some(("ok", "Share saved."))).await?))
|
||||
Ok(Html(
|
||||
render_manage(&state, lang, &guid, Some(("ok", t(lang, "ab.share_saved")))).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn share_remove(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((guid, user_id)): Path<(String, i64)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -120,13 +133,16 @@ pub async fn share_remove(
|
||||
.ab_share_remove(&guid, user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_manage(&state, &guid, Some(("ok", "Share removed."))).await?))
|
||||
Ok(Html(
|
||||
render_manage(&state, lang, &guid, Some(("ok", t(lang, "ab.share_removed")))).await?,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
async fn render_index(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let books = state
|
||||
@@ -140,8 +156,8 @@ async fn render_index(
|
||||
s,
|
||||
r##"<div id="ab-region" class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">Address books</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Personal books are owned by users and managed from their desktop client. Shared books are server-side: create one here, share it with users / rules, and the client picks it up on its next AB sync.</p>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
@@ -152,41 +168,63 @@ async fn render_index(
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="ab-name">New shared book</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="ab-name">{new_shared}</label>
|
||||
<input id="ab-name" name="name" type="text" required
|
||||
placeholder="Engineering laptops"
|
||||
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>
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
Create
|
||||
{create}
|
||||
</button>
|
||||
</form>
|
||||
"##
|
||||
"##,
|
||||
heading = t(lang, "ab.heading"),
|
||||
tagline = t(lang, "ab.tagline"),
|
||||
new_shared = t(lang, "ab.new_shared"),
|
||||
create = t(lang, "common.create"),
|
||||
);
|
||||
if books.is_empty() {
|
||||
s.push_str(r##"<p class="text-slate-500 text-sm">No address books exist yet.</p></div>"##);
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p></div>"##,
|
||||
t(lang, "ab.no_books"),
|
||||
);
|
||||
return Ok(s);
|
||||
}
|
||||
s.push_str(
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">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>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_owner}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_kind}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_name}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peers}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_guid}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_created}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_owner = t(lang, "ab.col_owner"),
|
||||
c_kind = t(lang, "ab.col_kind"),
|
||||
c_name = t(lang, "ab.col_name"),
|
||||
c_peers = t(lang, "ab.col_peers"),
|
||||
c_guid = t(lang, "ab.col_guid"),
|
||||
c_created = t(lang, "ab.col_created"),
|
||||
c_actions = t(lang, "common.actions"),
|
||||
);
|
||||
for b in &books {
|
||||
let kind_pill = 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>"#,
|
||||
_ => "",
|
||||
0 => format!(
|
||||
r#"<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300">{}</span>"#,
|
||||
t(lang, "ab.kind_personal"),
|
||||
),
|
||||
1 => format!(
|
||||
r#"<span class="text-xs px-1.5 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300">{}</span>"#,
|
||||
t(lang, "ab.kind_shared"),
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
// Both kinds get a delete action. Shared books additionally get
|
||||
// "Manage shares". Personal books carry an extra warning in the
|
||||
@@ -201,19 +239,21 @@ async fn render_index(
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-get="/admin/pages/address-books/{guid}/manage"
|
||||
hx-target="#ab-region" hx-swap="outerHTML">
|
||||
Manage shares
|
||||
{manage}
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/address-books/{guid}/delete"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="Delete shared book '{name}'? Peers, tags, and shares will be removed.">
|
||||
Delete book
|
||||
hx-confirm="{confirm}">
|
||||
{delete}
|
||||
</button>
|
||||
</div>
|
||||
</details>"##,
|
||||
guid = html_escape(&b.guid),
|
||||
name = html_escape(&b.name)
|
||||
manage = t(lang, "ab.manage_shares"),
|
||||
confirm = html_escape(&tf1(lang, "ab.confirm_delete_shared", &b.name)),
|
||||
delete = t(lang, "ab.delete_book"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
@@ -223,13 +263,14 @@ async fn render_index(
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/address-books/{guid}/delete"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="Delete {owner}'s personal book? This wipes all peers and tags on the server. If {owner}'s desktop client is still signed in, it will recreate an empty personal book on its next sync (~30 s).">
|
||||
Delete book
|
||||
hx-confirm="{confirm}">
|
||||
{delete}
|
||||
</button>
|
||||
</div>
|
||||
</details>"##,
|
||||
guid = html_escape(&b.guid),
|
||||
owner = html_escape(&b.owner_username)
|
||||
confirm = html_escape(&tf1(lang, "ab.confirm_delete_personal", &b.owner_username)),
|
||||
delete = t(lang, "ab.delete_book"),
|
||||
)
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -258,6 +299,7 @@ async fn render_index(
|
||||
|
||||
async fn render_manage(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
guid: &str,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
@@ -269,13 +311,13 @@ async fn render_manage(
|
||||
let Some((_owner_id, kind)) = owner_kind else {
|
||||
return Ok(format!(
|
||||
r##"<div id="ab-region">{notice}</div>"##,
|
||||
notice = notice_html("error", "Address book not found."),
|
||||
notice = notice_html("error", t(lang, "ab.not_found")),
|
||||
));
|
||||
};
|
||||
if kind != 1 {
|
||||
return Ok(format!(
|
||||
r##"<div id="ab-region">{notice}</div>"##,
|
||||
notice = notice_html("error", "Personal address books are managed from the desktop client."),
|
||||
notice = notice_html("error", t(lang, "ab.personal_managed_at_client")),
|
||||
));
|
||||
}
|
||||
let shares = state
|
||||
@@ -298,38 +340,47 @@ async fn render_manage(
|
||||
r##"<div id="ab-region" class="space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Manage shares</h2>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1 font-mono">{guid}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-slate-300 hover:text-slate-100 px-2 py-1 rounded border border-slate-700 hover:border-slate-500"
|
||||
hx-get="/admin/pages/address-books"
|
||||
hx-target="#ab-region" hx-swap="outerHTML">
|
||||
← Back
|
||||
{back}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-slate-200">Add or update a share</h3>
|
||||
<h3 class="text-sm font-semibold text-slate-200">{add_or_update}</h3>
|
||||
<form
|
||||
class="flex flex-wrap items-end gap-2"
|
||||
hx-post="/admin/pages/address-books/{guid}/shares/add"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-user">User</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-user">{user_label}</label>
|
||||
<select id="share-user" name="user_id" required
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">"##,
|
||||
heading = t(lang, "ab.manage_heading"),
|
||||
back = t(lang, "ab.back"),
|
||||
add_or_update = t(lang, "ab.add_or_update"),
|
||||
user_label = t(lang, "ab.user_label"),
|
||||
guid = html_escape(guid),
|
||||
notice_html = notice_html,
|
||||
);
|
||||
if users.is_empty() {
|
||||
s.push_str(r#"<option disabled>No users defined</option>"#);
|
||||
let _ = write!(
|
||||
s,
|
||||
r#"<option disabled>{}</option>"#,
|
||||
t(lang, "ab.no_users"),
|
||||
);
|
||||
}
|
||||
let already_text = t(lang, "ab.existing_will_update");
|
||||
for u in &users {
|
||||
let already = if already_shared.contains(&u.id) { " (existing — will update rule)" } else { "" };
|
||||
let already = if already_shared.contains(&u.id) { already_text } else { "" };
|
||||
let _ = write!(
|
||||
s,
|
||||
r#"<option value="{id}">{name}{already}</option>"#,
|
||||
@@ -343,46 +394,58 @@ async fn render_manage(
|
||||
r##"</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-rule">Rule</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-rule">{rule}</label>
|
||||
<select id="share-rule" name="rule"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">
|
||||
<option value="1">Read</option>
|
||||
<option value="2" selected>Read + write</option>
|
||||
<option value="3">Full</option>
|
||||
<option value="1">{r_read}</option>
|
||||
<option value="2" selected>{r_rw}</option>
|
||||
<option value="3">{r_full}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
Save
|
||||
{save}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<header class="px-4 py-2 text-xs uppercase text-slate-500 border-b border-slate-800">
|
||||
Current shares ({n})
|
||||
{current_shares}
|
||||
</header>
|
||||
"##,
|
||||
n = shares.len()
|
||||
rule = t(lang, "ab.rule"),
|
||||
r_read = t(lang, "ab.rule_read"),
|
||||
r_rw = t(lang, "ab.rule_read_write"),
|
||||
r_full = t(lang, "ab.rule_full"),
|
||||
save = t(lang, "common.save"),
|
||||
current_shares = tf1(lang, "ab.current_shares", &shares.len().to_string()),
|
||||
);
|
||||
if shares.is_empty() {
|
||||
s.push_str(r##"<p class="px-4 py-3 text-slate-500 text-sm">No shares yet. The book is invisible to non-owners until you add at least one user share.</p></section></div>"##);
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="px-4 py-3 text-slate-500 text-sm">{}</p></section></div>"##,
|
||||
t(lang, "ab.no_shares"),
|
||||
);
|
||||
return Ok(s);
|
||||
}
|
||||
s.push_str(
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<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">User</th>
|
||||
<th class="text-left font-medium px-3 py-2">Rule</th>
|
||||
<th class="text-left font-medium px-3 py-2">{user_l}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{rule_l}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1"></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
user_l = t(lang, "ab.user_label"),
|
||||
rule_l = t(lang, "ab.rule"),
|
||||
);
|
||||
for sh in &shares {
|
||||
let rule = match sh.rule {
|
||||
1 => "Read",
|
||||
2 => "Read + write",
|
||||
3 => "Full",
|
||||
1 => t(lang, "ab.rule_read"),
|
||||
2 => t(lang, "ab.rule_read_write"),
|
||||
3 => t(lang, "ab.rule_full"),
|
||||
_ => "?",
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -394,8 +457,8 @@ async fn render_manage(
|
||||
<button class="text-xs text-rose-300 hover:text-rose-200 px-2 py-1 rounded hover:bg-rose-900/40"
|
||||
hx-post="/admin/pages/address-books/{guid}/shares/{uid}/remove"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="Remove {user}'s access?">
|
||||
Remove
|
||||
hx-confirm="{confirm}">
|
||||
{remove}
|
||||
</button>
|
||||
</td>
|
||||
</tr>"##,
|
||||
@@ -403,6 +466,8 @@ async fn render_manage(
|
||||
rule = rule,
|
||||
guid = html_escape(guid),
|
||||
uid = sh.user_id,
|
||||
confirm = html_escape(&tf1(lang, "ab.confirm_remove", &sh.username)),
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></section></div>");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! a follow-up if the operator outgrows this view.
|
||||
|
||||
use super::shared::{fmt_unix, html_escape, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -23,14 +24,15 @@ pub struct TabQuery {
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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?,
|
||||
"file" => render_file(&state, lang).await?,
|
||||
"alarm" => render_alarm(&state, lang).await?,
|
||||
_ => render_conn(&state, lang).await?,
|
||||
};
|
||||
let pill = |id: &str, label: &str| {
|
||||
let active = id == tab;
|
||||
@@ -49,42 +51,50 @@ pub async fn index(
|
||||
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>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500">{latest}</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"),
|
||||
heading = t(lang, "audit.heading"),
|
||||
latest = tf1(lang, "audit.latest", &PAGE_SIZE.to_string()),
|
||||
pill_conn = pill("conn", t(lang, "audit.tab_conn")),
|
||||
pill_file = pill("file", t(lang, "audit.tab_file")),
|
||||
pill_alarm = pill("alarm", t(lang, "audit.tab_alarm")),
|
||||
body = body,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn render_conn(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_conn(state: &Arc<AppState>, lang: Lang) -> 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."));
|
||||
return Ok(empty_table(t(lang, "audit.no_conn")));
|
||||
}
|
||||
let mut s = String::new();
|
||||
s.push_str(
|
||||
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">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>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_conn}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_ip}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_action}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_note}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_when = t(lang, "audit.col_when"),
|
||||
c_peer = t(lang, "audit.col_peer"),
|
||||
c_conn = t(lang, "audit.col_conn_session"),
|
||||
c_ip = t(lang, "audit.col_ip"),
|
||||
c_action = t(lang, "audit.col_action"),
|
||||
c_note = t(lang, "audit.col_note"),
|
||||
);
|
||||
for r in &rows {
|
||||
let _ = write!(
|
||||
@@ -110,32 +120,38 @@ async fn render_conn(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_file(state: &Arc<AppState>, lang: Lang) -> 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."));
|
||||
return Ok(empty_table(t(lang, "audit.no_file")));
|
||||
}
|
||||
let mut s = String::new();
|
||||
s.push_str(
|
||||
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">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>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_dir}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_path}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_remote}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_when = t(lang, "audit.col_when"),
|
||||
c_peer = t(lang, "audit.col_peer"),
|
||||
c_dir = t(lang, "audit.col_direction"),
|
||||
c_path = t(lang, "audit.col_path"),
|
||||
c_remote = t(lang, "audit.col_remote"),
|
||||
);
|
||||
for r in &rows {
|
||||
let dir = match r.direction {
|
||||
0 => "→ remote",
|
||||
1 => "← remote",
|
||||
0 => t(lang, "audit.dir_to_remote"),
|
||||
1 => t(lang, "audit.dir_from_remote"),
|
||||
_ => "?",
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -158,26 +174,31 @@ async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
async fn render_alarm(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_alarm(state: &Arc<AppState>, lang: Lang) -> 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."));
|
||||
return Ok(empty_table(t(lang, "audit.no_alarm")));
|
||||
}
|
||||
let mut s = String::new();
|
||||
s.push_str(
|
||||
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">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>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_type}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_info}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_when = t(lang, "audit.col_when"),
|
||||
c_peer = t(lang, "audit.col_peer"),
|
||||
c_type = t(lang, "audit.col_type"),
|
||||
c_info = t(lang, "audit.col_info"),
|
||||
);
|
||||
for r in &rows {
|
||||
let typ = match r.typ {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! signature verification, so we don't need the Pro private key.
|
||||
|
||||
use super::shared::{html_escape, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use axum::extract::Form;
|
||||
@@ -14,7 +15,11 @@ use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn index(admin: AuthedUser, headers: HeaderMap) -> Result<Html<String>, ApiError> {
|
||||
pub async fn index(
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let pubkey = read_pubkey();
|
||||
// Best-effort prefill: the Host the admin's browser is currently
|
||||
@@ -35,6 +40,7 @@ pub async fn index(admin: AuthedUser, headers: HeaderMap) -> Result<Html<String>
|
||||
(format!("https://{}", host_default), host_default.clone())
|
||||
};
|
||||
Ok(Html(render_form(
|
||||
lang,
|
||||
&pubkey,
|
||||
&host_default,
|
||||
&api_default,
|
||||
@@ -71,22 +77,25 @@ pub struct DeployForm {
|
||||
|
||||
pub async fn generate(
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(f): Form<DeployForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if f.host.trim().is_empty() {
|
||||
return Ok(Html(render_form(
|
||||
lang,
|
||||
&f.key,
|
||||
&f.host,
|
||||
&f.api,
|
||||
&f.relay,
|
||||
"",
|
||||
Some(("error", "Host is required.")),
|
||||
Some(("error", t(lang, "deploy.host_required"))),
|
||||
)));
|
||||
}
|
||||
let blob = encode_blob(&f.host, &f.key, &f.api, &f.relay);
|
||||
let result = render_result(&f.host, &f.key, &f.api, &f.relay, &blob);
|
||||
let result = render_result(lang, &f.host, &f.key, &f.api, &f.relay, &blob);
|
||||
Ok(Html(render_form(
|
||||
lang,
|
||||
&f.key,
|
||||
&f.host,
|
||||
&f.api,
|
||||
@@ -125,6 +134,7 @@ fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String {
|
||||
}
|
||||
|
||||
fn render_form(
|
||||
lang: Lang,
|
||||
key: &str,
|
||||
host: &str,
|
||||
api: &str,
|
||||
@@ -139,8 +149,8 @@ fn render_form(
|
||||
format!(
|
||||
r##"<div class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">Deploy</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Generate a config blob the stock client accepts via <code>rustdesk --config <blob></code>, or the equivalent renamed-installer filename. The public key below is read from <code>id_ed25519.pub</code> on the server; override if you bootstrapped a custom keypair.</p>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{intro}</p>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
@@ -152,7 +162,7 @@ fn render_form(
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="host">Rendezvous host (required)</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="host">{host_label}</label>
|
||||
<input id="host" name="host" type="text" required value="{host}"
|
||||
placeholder="rustdesk.example.com or 203.0.113.10"
|
||||
oninput="
|
||||
@@ -164,42 +174,53 @@ fn render_form(
|
||||
api.placeholder = h ? 'https://' + h : 'https://rustdesk.example.com';
|
||||
"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">The hostname or IP clients reach hbbs at (TCP/UDP 21116).</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{host_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="api">API URL (optional)</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="api">{api_label}</label>
|
||||
<input id="api" name="api" type="text" value="{api}"
|
||||
placeholder="https://rustdesk.example.com"
|
||||
oninput="this.dataset.derived = '0';"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">Full URL of this admin/login API. Defaults to <code>https://<host></code>; edit if your API runs on a different scheme/port. Leave blank to disable login on the client.</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{api_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="relay">Relay host (optional)</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="relay">{relay_label}</label>
|
||||
<input id="relay" name="relay" type="text" value="{relay}"
|
||||
placeholder="rustdesk.example.com"
|
||||
oninput="this.dataset.derived = '0';"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">Only set if hbbr runs on a separate host; otherwise leave blank.</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{relay_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="key">Public key</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="key">{key_label}</label>
|
||||
<textarea id="key" name="key" rows="2"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{key}</textarea>
|
||||
<p class="text-xs text-slate-500 mt-1">Base64 contents of <code>id_ed25519.pub</code>.</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{key_hint}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
Generate
|
||||
{generate}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result_html}
|
||||
</div>"##,
|
||||
heading = t(lang, "deploy.heading"),
|
||||
intro = t(lang, "deploy.intro"),
|
||||
host_label = t(lang, "deploy.host_label"),
|
||||
host_hint = t(lang, "deploy.host_hint"),
|
||||
api_label = t(lang, "deploy.api_label"),
|
||||
api_hint = t(lang, "deploy.api_hint"),
|
||||
relay_label = t(lang, "deploy.relay_label"),
|
||||
relay_hint = t(lang, "deploy.relay_hint"),
|
||||
key_label = t(lang, "deploy.key_label"),
|
||||
key_hint = t(lang, "deploy.key_hint"),
|
||||
generate = t(lang, "deploy.generate"),
|
||||
host = html_escape(host),
|
||||
api = html_escape(api),
|
||||
relay = html_escape(relay),
|
||||
@@ -209,7 +230,7 @@ fn render_form(
|
||||
)
|
||||
}
|
||||
|
||||
fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String {
|
||||
fn render_result(lang: Lang, host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String {
|
||||
// Build the rename-the-installer alternative. Windows filenames disallow
|
||||
// `:` and `/`, which the API URL is full of (`http://host:21114`). The
|
||||
// client falls back to `http://<host>:21114` when `api` is empty
|
||||
@@ -258,36 +279,44 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
|
||||
format!(
|
||||
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||
<header>
|
||||
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. All three produce the same client config.</p>
|
||||
<h3 class="text-sm font-semibold text-slate-200">{artifact_heading}</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">{artifact_intro}</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">A. Post-install command (Windows, Administrator)</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">{a_label}</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_win}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">Requires the client to be installed and running as admin. Equivalent on macOS/Linux: <code class="text-slate-300">{cmd_unix}</code>.</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{a_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">B. Renamed installer (drop-in)</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">{b_label}</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{renamed}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">Rename the official RustDesk installer to this exact name and run it; the client reads its own filename on first launch and writes the config into the registry.</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{b_hint}</p>
|
||||
{renamed_note}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">C. HelloAgent (Windows, MDM one-liner)</label>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">{c_label}</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_hello}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">Headless agent — registers the Windows service and imports this config in a single command. Run elevated.</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{c_hint}</p>
|
||||
</div>
|
||||
|
||||
<details class="text-xs text-slate-400">
|
||||
<summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary>
|
||||
<summary class="cursor-pointer text-slate-300 select-none">{raw_blob}</summary>
|
||||
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
|
||||
</details>
|
||||
</section>"##,
|
||||
artifact_heading = t(lang, "deploy.artifact_heading"),
|
||||
artifact_intro = t(lang, "deploy.artifact_intro"),
|
||||
a_label = t(lang, "deploy.cmd_a_label"),
|
||||
a_hint = tf1(lang, "deploy.cmd_a_hint", &html_escape(&cmd_unix)),
|
||||
b_label = t(lang, "deploy.cmd_b_label"),
|
||||
b_hint = t(lang, "deploy.cmd_b_hint"),
|
||||
c_label = t(lang, "deploy.cmd_c_label"),
|
||||
c_hint = t(lang, "deploy.cmd_c_hint"),
|
||||
raw_blob = t(lang, "deploy.raw_blob"),
|
||||
cmd_win = html_escape(&cmd_win),
|
||||
cmd_unix = html_escape(&cmd_unix),
|
||||
cmd_hello = html_escape(&cmd_hello),
|
||||
renamed = html_escape(&renamed),
|
||||
renamed_note = renamed_note,
|
||||
|
||||
+187
-103
@@ -2,6 +2,7 @@
|
||||
//! force-disconnect (queues a `heartbeat_commands` row consumed on the
|
||||
//! peer's next /api/heartbeat tick) and force-sysinfo refresh.
|
||||
|
||||
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -21,25 +22,30 @@ const ONLINE_THRESHOLD_SECS: i64 = 45;
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let table = render_table(&state).await?;
|
||||
let table = render_table(&state, lang).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>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500">{tagline}</p>
|
||||
</header>
|
||||
<section id="devices-region">
|
||||
{table}
|
||||
</section>
|
||||
</div>"##
|
||||
</div>"##,
|
||||
heading = t(lang, "devices.heading"),
|
||||
tagline = t(lang, "devices.tagline"),
|
||||
table = table,
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn force_disconnect(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -55,8 +61,9 @@ pub async fn force_disconnect(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&format!("Queued disconnect for {} (conns={})", peer_id, conns),
|
||||
&tf2(lang, "devices.queued_disconnect", &peer_id, &conns),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -64,6 +71,7 @@ pub async fn force_disconnect(
|
||||
pub async fn force_sysinfo(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -74,8 +82,9 @@ pub async fn force_sysinfo(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&format!("Queued sysinfo refresh for {}", peer_id),
|
||||
&tf1(lang, "devices.queued_sysinfo", &peer_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -83,6 +92,7 @@ pub async fn force_sysinfo(
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -92,11 +102,11 @@ pub async fn delete(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let msg = if ok {
|
||||
format!("Deleted device {}.", peer_id)
|
||||
tf1(lang, "devices.deleted", &peer_id)
|
||||
} else {
|
||||
"Device already gone.".to_string()
|
||||
t(lang, "devices.already_gone").to_string()
|
||||
};
|
||||
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
|
||||
notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await
|
||||
}
|
||||
|
||||
/// Per-device detail page: hardware / OS inventory reported by hello-agent
|
||||
@@ -107,6 +117,7 @@ pub async fn delete(
|
||||
pub async fn detail(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -116,15 +127,17 @@ pub async fn detail(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let html = match row {
|
||||
Some(d) => render_detail(&d),
|
||||
Some(d) => render_detail(lang, &d),
|
||||
None => format!(
|
||||
r##"<div class="space-y-4">
|
||||
{back}
|
||||
<div class="rounded border border-rose-700/50 bg-rose-900/30 p-3 text-sm text-rose-300">
|
||||
No device with peer ID <code class="font-mono">{id}</code> in the dashboard.
|
||||
{no_device} <code class="font-mono">{id}</code> {in_dashboard}
|
||||
</div>
|
||||
</div>"##,
|
||||
back = back_button(),
|
||||
back = back_button(lang),
|
||||
no_device = t(lang, "devices.no_device_with_id"),
|
||||
in_dashboard = t(lang, "devices.in_dashboard"),
|
||||
id = html_escape(&peer_id),
|
||||
),
|
||||
};
|
||||
@@ -150,7 +163,7 @@ fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) ->
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let (total, devices) = state
|
||||
.db
|
||||
.devices_list_all(0, PAGE_SIZE)
|
||||
@@ -167,39 +180,57 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<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">User</th>
|
||||
<th class="text-left font-medium px-3 py-2">Unattended pwd</th>
|
||||
<th class="text-left font-medium px-3 py-2">OS</th>
|
||||
<th class="text-left font-medium px-3 py-2">Version</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>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_owner}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_host}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_user}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_pwd}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_os}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_ver}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_last}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_conns}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">"##
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_peer = t(lang, "devices.col_peer_id"),
|
||||
c_owner = t(lang, "devices.col_owner"),
|
||||
c_host = t(lang, "devices.col_hostname"),
|
||||
c_user = t(lang, "devices.col_user"),
|
||||
c_pwd = t(lang, "devices.col_unattended_pwd"),
|
||||
c_os = t(lang, "devices.col_os"),
|
||||
c_ver = t(lang, "devices.col_version"),
|
||||
c_last = t(lang, "devices.col_last_heartbeat"),
|
||||
c_conns = t(lang, "devices.col_conns"),
|
||||
c_actions = t(lang, "common.actions"),
|
||||
);
|
||||
if devices.is_empty() {
|
||||
s.push_str(
|
||||
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
|
||||
t(lang, "devices.no_devices"),
|
||||
);
|
||||
}
|
||||
for d in &devices {
|
||||
render_device_row(&mut s, d, now);
|
||||
render_device_row(&mut s, lang, d, now);
|
||||
}
|
||||
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>"##
|
||||
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{count}</div>
|
||||
</div>"##,
|
||||
count = tf1(lang, "devices.devices_count", &total.to_string()),
|
||||
);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTime<chrono::Utc>) {
|
||||
fn render_device_row(
|
||||
s: &mut String,
|
||||
lang: Lang,
|
||||
d: &DashboardDeviceRow,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
|
||||
let pick = |k: &str| -> String {
|
||||
@@ -246,14 +277,14 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
let (dot_class, tooltip) = if is_online {
|
||||
(
|
||||
"bg-emerald-400",
|
||||
format!("Online — last heartbeat {}s ago", age_secs),
|
||||
tf1(lang, "devices.online", &age_secs.to_string()),
|
||||
)
|
||||
} else if age_secs == i64::MAX {
|
||||
("bg-slate-500", "No heartbeat recorded".to_string())
|
||||
("bg-slate-500", t(lang, "devices.no_heartbeat").to_string())
|
||||
} else {
|
||||
(
|
||||
"bg-rose-500",
|
||||
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
|
||||
tf1(lang, "devices.offline", &fmt_age(age_secs)),
|
||||
)
|
||||
};
|
||||
// Per-boot unattended-access password reported by hello-agent. Visible
|
||||
@@ -304,31 +335,31 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
<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">
|
||||
<a class="block w-full text-left px-2 py-1 text-xs text-sky-300 hover:bg-sky-900/40 rounded"
|
||||
href="/admin/connect/{id}" target="_blank" rel="noopener">
|
||||
Connect (web client)
|
||||
{connect_web}
|
||||
</a>
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-get="/admin/pages/devices/{id}/detail"
|
||||
hx-target="#devices-region" hx-swap="innerHTML">
|
||||
Details & inventory
|
||||
{details}
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<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
|
||||
hx-confirm="{confirm_disc}">
|
||||
{force_disc}
|
||||
</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
|
||||
{force_sysinfo}
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/devices/{id}/delete"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
hx-confirm="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
|
||||
Delete device
|
||||
hx-confirm="{confirm_delete}">
|
||||
{delete_device}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
@@ -347,7 +378,14 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
os = html_escape(&os),
|
||||
ver = html_escape(&version_label),
|
||||
last = html_escape(&d.last_heartbeat_at),
|
||||
n = conn_count
|
||||
n = conn_count,
|
||||
connect_web = t(lang, "devices.connect_web"),
|
||||
details = t(lang, "devices.details"),
|
||||
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
|
||||
force_disc = t(lang, "devices.force_disconnect"),
|
||||
force_sysinfo = t(lang, "devices.force_sysinfo"),
|
||||
confirm_delete = html_escape(&tf1(lang, "devices.confirm_delete", &d.id)),
|
||||
delete_device = t(lang, "devices.delete_device"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -361,19 +399,22 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
pub async fn list_fragment(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_table(&state).await?))
|
||||
Ok(Html(render_table(&state, lang).await?))
|
||||
}
|
||||
|
||||
/// "Back to devices" — refetches the devices table fragment via HTMX
|
||||
/// and swaps it back into `#devices-region`. Used by the detail page.
|
||||
fn back_button() -> String {
|
||||
r##"<button class="text-xs text-sky-300 hover:text-sky-200"
|
||||
fn back_button(lang: Lang) -> String {
|
||||
format!(
|
||||
r##"<button class="text-xs text-sky-300 hover:text-sky-200"
|
||||
hx-get="/admin/pages/devices/list-fragment"
|
||||
hx-target="#devices-region"
|
||||
hx-swap="innerHTML">← Back to devices</button>"##
|
||||
.to_string()
|
||||
hx-swap="innerHTML">{label}</button>"##,
|
||||
label = t(lang, "devices.back"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Pretty-print a JSON value for the inventory table cells. Strings are
|
||||
@@ -391,7 +432,7 @@ fn fmt_inv_value(v: Option<&serde_json::Value>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
|
||||
let pick = |k: &str| -> String {
|
||||
@@ -426,20 +467,29 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
let header = format!(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold">Device <code class="font-mono text-sky-300">{id}</code></h2>
|
||||
<h2 class="text-lg font-semibold">{device_label} <code class="font-mono text-sky-300">{id}</code></h2>
|
||||
<span class="text-xs text-slate-500">UUID <code class="font-mono">{uuid}</code></span>
|
||||
</div>
|
||||
<dl class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-sm md:grid-cols-3">
|
||||
<div><dt class="text-xs text-slate-500">Hostname</dt><dd class="text-slate-200">{host}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Owner</dt><dd class="text-slate-200">{owner}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Active user</dt><dd class="text-slate-200">{user}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Agent</dt><dd class="text-slate-200">{ident}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">OS (runtime)</dt><dd class="text-slate-200">{os_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Last heartbeat</dt><dd class="text-slate-200">{last}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">CPU (runtime)</dt><dd class="text-slate-200">{cpu_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Memory (runtime)</dt><dd class="text-slate-200">{mem_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_host}</dt><dd class="text-slate-200">{host}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_owner}</dt><dd class="text-slate-200">{owner}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_user}</dt><dd class="text-slate-200">{user}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_agent}</dt><dd class="text-slate-200">{ident}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_os_rt}</dt><dd class="text-slate-200">{os_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_last}</dt><dd class="text-slate-200">{last}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_cpu_rt}</dt><dd class="text-slate-200">{cpu_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">{l_mem_rt}</dt><dd class="text-slate-200">{mem_rt}</dd></div>
|
||||
</dl>
|
||||
</div>"##,
|
||||
device_label = t(lang, "devices.device_label"),
|
||||
l_host = t(lang, "devices.col_hostname"),
|
||||
l_owner = t(lang, "devices.col_owner"),
|
||||
l_user = t(lang, "devices.detail_active_user"),
|
||||
l_agent = t(lang, "devices.detail_agent"),
|
||||
l_os_rt = t(lang, "devices.detail_os_runtime"),
|
||||
l_last = t(lang, "devices.col_last_heartbeat"),
|
||||
l_cpu_rt = t(lang, "devices.detail_cpu_runtime"),
|
||||
l_mem_rt = t(lang, "devices.detail_memory_runtime"),
|
||||
id = html_escape(&d.id),
|
||||
uuid = html_escape(&d.uuid),
|
||||
host = html_escape(if hostname.is_empty() { "—" } else { &hostname }),
|
||||
@@ -463,22 +513,24 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
// hello-agent's main.rs; absence means vanilla rustdesk.
|
||||
let inventory_section = if agent_name == "HelloAgent" {
|
||||
match parsed.get("inventory") {
|
||||
Some(inv) if inv.is_object() => render_inventory_table(inv),
|
||||
Some(inv) if inv.is_object() => render_inventory_table(lang, inv),
|
||||
_ => format!(
|
||||
r##"<div class="rounded-md border border-amber-700/50 bg-amber-900/20 p-3 text-sm text-amber-300">
|
||||
Inventory data not yet reported. The agent collects it on startup and
|
||||
uploads on the next sysinfo cycle (≤120 s).
|
||||
</div>"##
|
||||
{msg}
|
||||
</div>"##,
|
||||
msg = t(lang, "devices.inventory_pending"),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
|
||||
Inventory data is only reported by HelloAgent. This device is running
|
||||
<span class="text-slate-200">{ident}</span>; the standard RustDesk client
|
||||
does not collect hardware or BitLocker inventory.
|
||||
{msg}
|
||||
</div>"##,
|
||||
ident = html_escape(&identity_label),
|
||||
msg = tf1(
|
||||
lang,
|
||||
"devices.inventory_unsupported",
|
||||
&format!("<span class=\"text-slate-200\">{}</span>", html_escape(&identity_label))
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -486,19 +538,21 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
r##"<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
{back}
|
||||
<div class="text-xs text-slate-500">Detail view</div>
|
||||
<div class="text-xs text-slate-500">{detail_view}</div>
|
||||
</div>
|
||||
{header}
|
||||
<h3 class="text-sm font-semibold text-slate-300 mt-4">Inventory</h3>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
|
||||
{inv}
|
||||
</div>"##,
|
||||
back = back_button(),
|
||||
back = back_button(lang),
|
||||
detail_view = t(lang, "devices.detail_view"),
|
||||
inventory = t(lang, "devices.inventory"),
|
||||
header = header,
|
||||
inv = inventory_section,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
|
||||
let row = |label: &str, key: &str| {
|
||||
format!(
|
||||
r##"<tr class="border-b border-slate-800">
|
||||
@@ -513,8 +567,12 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
// Disks need their own renderer — they're an array of objects.
|
||||
let disks_html = match inv.get("disks") {
|
||||
Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
|
||||
let mut s = String::from(
|
||||
r##"<table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">Name</th><th class="text-left font-medium px-2 py-1">Model</th><th class="text-right font-medium px-2 py-1">Size (GB)</th><th class="text-left font-medium px-2 py-1">Media</th></tr></thead><tbody>"##,
|
||||
let mut s = format!(
|
||||
r##"<table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_model}</th><th class="text-right font-medium px-2 py-1">{c_size}</th><th class="text-left font-medium px-2 py-1">{c_media}</th></tr></thead><tbody>"##,
|
||||
c_name = t(lang, "devices.disk_name"),
|
||||
c_model = t(lang, "devices.disk_model"),
|
||||
c_size = t(lang, "devices.disk_size"),
|
||||
c_media = t(lang, "devices.disk_media"),
|
||||
);
|
||||
for disk in arr {
|
||||
let name = fmt_inv_value(disk.get("name"));
|
||||
@@ -540,7 +598,10 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let bl_html = if bl_key_raw.is_empty() {
|
||||
r##"<span class="text-slate-500">— (not reported; non-Pro SKU, not encrypted, or no admin rights)</span>"##.to_string()
|
||||
format!(
|
||||
r##"<span class="text-slate-500">{}</span>"##,
|
||||
t(lang, "devices.bitlocker_unavailable"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<code class="block font-mono text-xs text-amber-300 bg-slate-950 px-2 py-1 rounded border border-slate-800 select-all break-all">{}</code>"##,
|
||||
@@ -548,14 +609,17 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
)
|
||||
};
|
||||
|
||||
let nics_html = render_nics(inv.get("network_interfaces"));
|
||||
let wifi_html = render_wifi(inv.get("wifi_current"), inv.get("wifi_nearby"));
|
||||
let nics_html = render_nics(lang, inv.get("network_interfaces"));
|
||||
let wifi_html = render_wifi(lang, inv.get("wifi_current"), inv.get("wifi_nearby"));
|
||||
let public_ip_raw = inv
|
||||
.get("public_ip")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let public_ip_html = if public_ip_raw.is_empty() {
|
||||
r##"<span class="text-slate-500">— (lookup failed or blocked)</span>"##.to_string()
|
||||
format!(
|
||||
r##"<span class="text-slate-500">{}</span>"##,
|
||||
t(lang, "devices.public_ip_failed"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<code class="font-mono text-xs text-sky-300 bg-slate-950 px-2 py-1 rounded border border-slate-800 select-all">{}</code>"##,
|
||||
@@ -583,40 +647,44 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">Disks</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_disks}</h4>
|
||||
<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2">
|
||||
{disks}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">Network interfaces</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_nics}</h4>
|
||||
{nics}
|
||||
</div>
|
||||
{wifi}
|
||||
<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">Public IP (egress, last lookup)</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_pip}</h4>
|
||||
{public_ip}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">BitLocker recovery key (system drive)</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_bl}</h4>
|
||||
{bl}
|
||||
</div>
|
||||
</div>"##,
|
||||
sn = row("Serial number", "serial_number"),
|
||||
mfr = row("Manufacturer", "manufacturer"),
|
||||
model = row("Model", "model"),
|
||||
dom = row("Windows domain", "domain"),
|
||||
os_d = row("OS distribution", "os_distro"),
|
||||
os_r = row("OS release", "os_release"),
|
||||
cpu_m = row("CPU model", "cpu_model"),
|
||||
cpu_s = row("CPU speed (GHz)", "cpu_speed_ghz"),
|
||||
cpu_pc = row("CPU physical cores", "cpu_cores_physical"),
|
||||
cpu_lc = row("CPU logical cores", "cpu_cores_logical"),
|
||||
ram = row("RAM (GB)", "ram_gb"),
|
||||
sn = row(t(lang, "devices.serial_number"), "serial_number"),
|
||||
mfr = row(t(lang, "devices.manufacturer"), "manufacturer"),
|
||||
model = row(t(lang, "devices.model"), "model"),
|
||||
dom = row(t(lang, "devices.windows_domain"), "domain"),
|
||||
os_d = row(t(lang, "devices.os_distro"), "os_distro"),
|
||||
os_r = row(t(lang, "devices.os_release"), "os_release"),
|
||||
cpu_m = row(t(lang, "devices.cpu_model"), "cpu_model"),
|
||||
cpu_s = row(t(lang, "devices.cpu_speed"), "cpu_speed_ghz"),
|
||||
cpu_pc = row(t(lang, "devices.cpu_phys_cores"), "cpu_cores_physical"),
|
||||
cpu_lc = row(t(lang, "devices.cpu_logical_cores"), "cpu_cores_logical"),
|
||||
ram = row(t(lang, "devices.ram_gb"), "ram_gb"),
|
||||
disks = disks_html,
|
||||
nics = nics_html,
|
||||
wifi = wifi_html,
|
||||
public_ip = public_ip_html,
|
||||
l_disks = t(lang, "devices.disks"),
|
||||
l_nics = t(lang, "devices.network_interfaces"),
|
||||
l_pip = t(lang, "devices.public_ip"),
|
||||
l_bl = t(lang, "devices.bitlocker"),
|
||||
bl = bl_html,
|
||||
)
|
||||
}
|
||||
@@ -624,15 +692,18 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
/// Render the network-interfaces array as a table (one row per NIC).
|
||||
/// Wi-Fi NICs get a small badge so the operator can spot them at a glance
|
||||
/// next to the Wi-Fi-current section. Empty input → dash.
|
||||
fn render_nics(nics: Option<&serde_json::Value>) -> String {
|
||||
fn render_nics(lang: Lang, nics: Option<&serde_json::Value>) -> String {
|
||||
let arr = match nics {
|
||||
Some(serde_json::Value::Array(a)) if !a.is_empty() => a,
|
||||
_ => {
|
||||
return r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-2"><span class="text-slate-500">—</span></div>"##.to_string();
|
||||
}
|
||||
};
|
||||
let mut s = String::from(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">Name</th><th class="text-left font-medium px-2 py-1">Description</th><th class="text-left font-medium px-2 py-1">MAC</th><th class="text-left font-medium px-2 py-1">Status</th><th class="text-left font-medium px-2 py-1">IPv4</th><th class="text-left font-medium px-2 py-1">IPv6</th><th class="text-right font-medium px-2 py-1">Mbps</th></tr></thead><tbody>"##,
|
||||
let mut s = format!(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_desc}</th><th class="text-left font-medium px-2 py-1">MAC</th><th class="text-left font-medium px-2 py-1">{c_status}</th><th class="text-left font-medium px-2 py-1">IPv4</th><th class="text-left font-medium px-2 py-1">IPv6</th><th class="text-right font-medium px-2 py-1">Mbps</th></tr></thead><tbody>"##,
|
||||
c_name = t(lang, "devices.disk_name"),
|
||||
c_desc = t(lang, "devices.nic_description"),
|
||||
c_status = t(lang, "devices.nic_status"),
|
||||
);
|
||||
for nic in arr {
|
||||
let name = fmt_inv_value(nic.get("name"));
|
||||
@@ -687,6 +758,7 @@ fn render_ip_list(v: Option<&serde_json::Value>) -> String {
|
||||
/// Returns an empty string when neither is present, so the surrounding
|
||||
/// detail page can omit the heading entirely.
|
||||
fn render_wifi(
|
||||
lang: Lang,
|
||||
current: Option<&serde_json::Value>,
|
||||
nearby: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
@@ -726,10 +798,10 @@ fn render_wifi(
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-1 text-xs md:grid-cols-3">
|
||||
<div><dt class="text-slate-500">SSID</dt><dd class="text-slate-200 font-mono">{ssid}</dd></div>
|
||||
<div><dt class="text-slate-500">BSSID</dt><dd class="text-slate-300 font-mono">{bssid}</dd></div>
|
||||
<div><dt class="text-slate-500">Signal</dt><dd class="text-slate-200">{sig}</dd></div>
|
||||
<div><dt class="text-slate-500">Authentication</dt><dd class="text-slate-300">{auth}</dd></div>
|
||||
<div><dt class="text-slate-500">Cipher</dt><dd class="text-slate-300">{cipher}</dd></div>
|
||||
<div><dt class="text-slate-500">Rate</dt><dd class="text-slate-300">{rate}</dd></div>
|
||||
<div><dt class="text-slate-500">{l_signal}</dt><dd class="text-slate-200">{sig}</dd></div>
|
||||
<div><dt class="text-slate-500">{l_auth}</dt><dd class="text-slate-300">{auth}</dd></div>
|
||||
<div><dt class="text-slate-500">{l_cipher}</dt><dd class="text-slate-300">{cipher}</dd></div>
|
||||
<div><dt class="text-slate-500">{l_rate}</dt><dd class="text-slate-300">{rate}</dd></div>
|
||||
</dl>
|
||||
</div>"##,
|
||||
ssid = fmt_inv_value(c.get("ssid")),
|
||||
@@ -738,18 +810,28 @@ fn render_wifi(
|
||||
auth = fmt_inv_value(c.get("auth")),
|
||||
cipher = fmt_inv_value(c.get("cipher")),
|
||||
rate = rate_mbps,
|
||||
l_signal = t(lang, "devices.wifi_signal"),
|
||||
l_auth = t(lang, "devices.wifi_auth"),
|
||||
l_cipher = t(lang, "devices.wifi_cipher"),
|
||||
l_rate = t(lang, "devices.wifi_rate"),
|
||||
)
|
||||
} else {
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">Not connected to a Wi-Fi network.</div>"##.to_string()
|
||||
format!(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">{}</div>"##,
|
||||
t(lang, "devices.wifi_not_connected"),
|
||||
)
|
||||
};
|
||||
|
||||
let nearby_html = if let Some(arr) = nearby_arr {
|
||||
let mut s = String::from(
|
||||
let mut s = format!(
|
||||
r##"<details class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">Nearby SSIDs ({n})</summary>
|
||||
<div class="p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">SSID</th><th class="text-left font-medium px-2 py-1">Authentication</th><th class="text-left font-medium px-2 py-1">Cipher</th><th class="text-right font-medium px-2 py-1">Signal</th></tr></thead><tbody>"##,
|
||||
<summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">{nearby_label}</summary>
|
||||
<div class="p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">SSID</th><th class="text-left font-medium px-2 py-1">{l_auth}</th><th class="text-left font-medium px-2 py-1">{l_cipher}</th><th class="text-right font-medium px-2 py-1">{l_signal}</th></tr></thead><tbody>"##,
|
||||
nearby_label = tf1(lang, "devices.wifi_nearby", &arr.len().to_string()),
|
||||
l_auth = t(lang, "devices.wifi_auth"),
|
||||
l_cipher = t(lang, "devices.wifi_cipher"),
|
||||
l_signal = t(lang, "devices.wifi_signal"),
|
||||
);
|
||||
s = s.replace("{n}", &arr.len().to_string());
|
||||
for net in arr {
|
||||
let _ = write!(
|
||||
&mut s,
|
||||
@@ -768,10 +850,11 @@ fn render_wifi(
|
||||
|
||||
format!(
|
||||
r##"<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">Wi-Fi (current connection)</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{wifi_current}</h4>
|
||||
{current}
|
||||
{nearby}
|
||||
</div>"##,
|
||||
wifi_current = t(lang, "devices.wifi_current"),
|
||||
current = current_html,
|
||||
nearby = if nearby_html.is_empty() {
|
||||
String::new()
|
||||
@@ -798,11 +881,12 @@ fn fmt_age(secs: i64) -> String {
|
||||
|
||||
async fn notice_then_table(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
kind: &str,
|
||||
msg: &str,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let mut html = notice_html(kind, msg);
|
||||
html.push_str(&render_table(state).await?);
|
||||
html.push_str(&render_table(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the canonical place to manage who can see whose devices.
|
||||
|
||||
use super::shared::{html_escape, notice_html, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -15,9 +16,10 @@ use std::sync::Arc;
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_full(&state).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -28,23 +30,25 @@ pub struct CreateForm {
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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;
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.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
|
||||
notice_then(&state, lang, "ok", &tf1(lang, "groups.created", &form.name)).await
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -55,8 +59,9 @@ pub async fn delete(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { "Group deleted." } else { "Already gone." },
|
||||
if ok { t(lang, "groups.deleted") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -69,6 +74,7 @@ pub struct MemberForm {
|
||||
pub async fn add_member(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<MemberForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
@@ -78,12 +84,13 @@ pub async fn add_member(
|
||||
.device_group_add_member(id, form.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
pub async fn remove_member(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((id, user_id)): Path<(i64, i64)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -92,7 +99,7 @@ pub async fn remove_member(
|
||||
.device_group_remove_member(id, user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -103,13 +110,14 @@ pub struct PeerForm {
|
||||
pub async fn add_peer(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<PeerForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let peer_id = form.peer_id.trim();
|
||||
if peer_id.is_empty() {
|
||||
return notice_then(&state, "error", "Device ID required").await;
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await;
|
||||
}
|
||||
let exists = state
|
||||
.db
|
||||
@@ -119,8 +127,9 @@ pub async fn add_peer(
|
||||
if !exists {
|
||||
return notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"error",
|
||||
&format!("No device '{}' has reported in yet.", peer_id),
|
||||
&tf1(lang, "groups.no_device_yet", peer_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -129,12 +138,13 @@ pub async fn add_peer(
|
||||
.device_group_add_peer(id, peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
pub async fn remove_peer(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((id, peer_id)): Path<(i64, String)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -143,7 +153,7 @@ pub async fn remove_peer(
|
||||
.device_group_remove_peer(id, &peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
@@ -169,15 +179,16 @@ fn url_encode(s: &str) -> String {
|
||||
|
||||
async fn notice_then(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
kind: &str,
|
||||
msg: &str,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let mut html = notice_html(kind, msg);
|
||||
html.push_str(&render_full(state).await?);
|
||||
html.push_str(&render_full(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let groups = state
|
||||
.db
|
||||
.device_groups_list_all()
|
||||
@@ -190,22 +201,29 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str(
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="groups-region" class="space-y-6">
|
||||
<header><h2 class="text-lg font-semibold">Device groups</h2></header>
|
||||
<header><h2 class="text-lg font-semibold">{heading}</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>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</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>
|
||||
<input name="name" placeholder="{ph}" 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>
|
||||
"##,
|
||||
heading = t(lang, "groups.heading"),
|
||||
create_heading = t(lang, "groups.create_heading"),
|
||||
ph = t(lang, "groups.group_name"),
|
||||
create = t(lang, "common.create"),
|
||||
);
|
||||
|
||||
if groups.is_empty() {
|
||||
s.push_str(
|
||||
r##"<p class="text-slate-500 text-sm">No device groups yet.</p>"##,
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p>"##,
|
||||
t(lang, "groups.no_groups"),
|
||||
);
|
||||
}
|
||||
for g in &groups {
|
||||
@@ -226,18 +244,23 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<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>
|
||||
hx-confirm="{confirm}"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">{delete_group}</button>
|
||||
</header>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Users</h4>
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">{users}</h4>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##,
|
||||
id = g.id,
|
||||
name = html_escape(&g.name)
|
||||
name = html_escape(&g.name),
|
||||
confirm = html_escape(&tf1(lang, "groups.confirm_delete", &g.name)),
|
||||
delete_group = t(lang, "groups.delete_group"),
|
||||
users = t(lang, "groups.users"),
|
||||
);
|
||||
if members.is_empty() {
|
||||
s.push_str(
|
||||
r##"<li class="py-2 text-slate-500 text-xs">No user members yet.</li>"##,
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
|
||||
t(lang, "groups.no_user_members"),
|
||||
);
|
||||
}
|
||||
for u in &members {
|
||||
@@ -247,11 +270,12 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<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>
|
||||
hx-target="#groups-region" hx-swap="outerHTML">{remove}</button>
|
||||
</li>"##,
|
||||
username = html_escape(&u.username),
|
||||
gid = g.id,
|
||||
uid = u.id
|
||||
uid = u.id,
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
@@ -278,10 +302,12 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
username = html_escape(&u.username)
|
||||
);
|
||||
}
|
||||
s.push_str(
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"</select>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add user</button>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{add_user}</button>
|
||||
</form>"##,
|
||||
add_user = t(lang, "groups.add_user"),
|
||||
);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
@@ -290,21 +316,27 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div>
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Devices</h4>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">{devices}</h4>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##,
|
||||
devices = t(lang, "groups.devices_section"),
|
||||
);
|
||||
if peer_members.is_empty() {
|
||||
s.push_str(
|
||||
r##"<li class="py-2 text-slate-500 text-xs">No devices added directly. (Devices owned by user members above are also visible to them.)</li>"##,
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
|
||||
t(lang, "groups.no_peer_members"),
|
||||
);
|
||||
}
|
||||
for (peer_id, owner) in &peer_members {
|
||||
let owner_label = if owner.is_empty() {
|
||||
String::from(r##"<span class="text-slate-500">unowned</span>"##)
|
||||
format!(
|
||||
r##"<span class="text-slate-500">{}</span>"##,
|
||||
t(lang, "groups.unowned"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<span class="text-slate-500">owner: {}</span>"##,
|
||||
html_escape(owner)
|
||||
r##"<span class="text-slate-500">{}</span>"##,
|
||||
html_escape(&tf1(lang, "groups.owner_label", owner)),
|
||||
)
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -315,13 +347,14 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
{owner_label}
|
||||
<button class="text-slate-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/groups/{gid}/peers/{pid_url}/remove"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">Remove</button>
|
||||
hx-target="#groups-region" hx-swap="outerHTML">{remove}</button>
|
||||
</span>
|
||||
</li>"##,
|
||||
pid = html_escape(peer_id),
|
||||
pid_url = url_encode(peer_id),
|
||||
gid = g.id,
|
||||
owner_label = owner_label,
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
@@ -330,11 +363,13 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
|
||||
hx-post="/admin/pages/groups/{id}/peers/add"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">
|
||||
<input name="peer_id" placeholder="Device ID (e.g. 123456789)" required
|
||||
<input name="peer_id" placeholder="{ph}" required
|
||||
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add device</button>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{add_device}</button>
|
||||
</form></div>"##,
|
||||
id = g.id
|
||||
id = g.id,
|
||||
ph = t(lang, "groups.peer_id_placeholder"),
|
||||
add_device = t(lang, "groups.add_device"),
|
||||
);
|
||||
|
||||
s.push_str("</section>");
|
||||
|
||||
+126
-58
@@ -16,6 +16,7 @@
|
||||
//! is written to `user_totp_secrets`. This means a half-finished enroll
|
||||
//! (user closes the tab) leaves no garbage state.
|
||||
|
||||
use crate::api::admin::i18n::{t, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -32,8 +33,9 @@ use totp_rs::Secret;
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
Ok(Html(render_full_page(&state, &user, None).await?))
|
||||
Ok(Html(render_full_page(&state, lang, &user, None).await?))
|
||||
}
|
||||
|
||||
// ---------- update profile info ----------
|
||||
@@ -49,6 +51,7 @@ pub struct InfoForm {
|
||||
pub async fn update_info(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<InfoForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
state
|
||||
@@ -62,7 +65,13 @@ pub async fn update_info(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(&state, &user, Some(("ok", "Profile updated."))).await?,
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.profile_updated"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -78,6 +87,7 @@ pub struct PasswordForm {
|
||||
pub async fn change_password(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<PasswordForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let row = state
|
||||
@@ -90,11 +100,9 @@ pub async fn change_password(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some((
|
||||
"error",
|
||||
"Your account signs in via the identity provider — change the password there.",
|
||||
)),
|
||||
Some(("error", t(lang, "profile.password_oidc_change"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -103,8 +111,9 @@ pub async fn change_password(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", "New password must be at least 4 characters.")),
|
||||
Some(("error", t(lang, "profile.password_min"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -113,8 +122,9 @@ pub async fn change_password(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", "New password and confirmation don't match.")),
|
||||
Some(("error", t(lang, "profile.password_mismatch"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -126,8 +136,9 @@ pub async fn change_password(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", "Current password is incorrect.")),
|
||||
Some(("error", t(lang, "profile.current_incorrect"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -141,7 +152,13 @@ pub async fn change_password(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(&state, &user, Some(("ok", "Password updated."))).await?,
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.password_updated"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -153,6 +170,7 @@ pub async fn change_password(
|
||||
pub async fn totp_start(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
// Reject if the user already has TOTP — they should remove it first.
|
||||
let already = state
|
||||
@@ -164,8 +182,9 @@ pub async fn totp_start(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", "You already have TOTP enrolled. Disable it first if you want to re-enroll.")),
|
||||
Some(("error", t(lang, "profile.tfa_already"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -185,6 +204,7 @@ pub async fn totp_start(
|
||||
|
||||
Ok(Html(render_totp_enroll_panel(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
&secret_b32,
|
||||
&qr_svg,
|
||||
@@ -204,6 +224,7 @@ pub struct TotpConfirmForm {
|
||||
pub async fn totp_confirm(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<TotpConfirmForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let code = form.code.trim();
|
||||
@@ -212,8 +233,9 @@ pub async fn totp_confirm(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", "Missing secret in confirm form.")),
|
||||
Some(("error", t(lang, "profile.tfa_missing_secret"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -236,10 +258,11 @@ pub async fn totp_confirm(
|
||||
return Ok(Html(
|
||||
render_totp_enroll_panel(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
secret,
|
||||
&qr_svg,
|
||||
Some(("error", "Code didn't match. Try again — make sure the time on the authenticator device is in sync.")),
|
||||
Some(("error", t(lang, "profile.tfa_bad_code"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -252,8 +275,9 @@ pub async fn totp_confirm(
|
||||
Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", "TOTP enrolled. Future sign-ins will require a 6-digit code.")),
|
||||
Some(("ok", t(lang, "profile.tfa_enrolled"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
@@ -269,6 +293,7 @@ pub struct TotpRemoveForm {
|
||||
pub async fn totp_remove(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<TotpRemoveForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let row = state
|
||||
@@ -284,8 +309,9 @@ pub async fn totp_remove(
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", "Current password is incorrect — TOTP not removed.")),
|
||||
Some(("error", t(lang, "profile.tfa_current_pw_incorrect"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
@@ -296,7 +322,13 @@ pub async fn totp_remove(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(&state, &user, Some(("ok", "TOTP removed."))).await?,
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.tfa_removed"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -304,10 +336,11 @@ pub async fn totp_remove(
|
||||
|
||||
async fn render_full_page(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
user: &AuthedUser,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
render_full_page_with_totp_override(state, user, notice, None).await
|
||||
render_full_page_with_totp_override(state, lang, user, notice, None).await
|
||||
}
|
||||
|
||||
/// `totp_panel_override = Some(html)` swaps in a custom TOTP block —
|
||||
@@ -316,6 +349,7 @@ async fn render_full_page(
|
||||
/// stay visible above it.
|
||||
async fn render_full_page_with_totp_override(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
user: &AuthedUser,
|
||||
notice: Option<(&str, &str)>,
|
||||
totp_panel_override: Option<String>,
|
||||
@@ -339,16 +373,20 @@ async fn render_full_page_with_totp_override(
|
||||
// local TOTP both moot. Replace those sections with a short note.
|
||||
let oidc_linked = row.is_oidc_linked();
|
||||
let password_section = if oidc_linked {
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Password</h3>
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<p class="text-sm text-slate-400">
|
||||
Your account signs in via your organisation's identity provider — there's no local password to change here.
|
||||
{msg}
|
||||
</p>
|
||||
</section>"##
|
||||
.to_string()
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.password_oidc"),
|
||||
msg = t(lang, "profile.password_oidc_msg"),
|
||||
)
|
||||
} else {
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">Change password</h3>
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{heading}</h3>
|
||||
<form
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm"
|
||||
hx-post="/admin/pages/profile/change-password"
|
||||
@@ -356,69 +394,89 @@ async fn render_full_page_with_totp_override(
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
>
|
||||
<input name="current_password" type="password" required placeholder="current password" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<input name="new_password" type="password" required minlength="4" placeholder="new password" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<input name="confirm_password" type="password" required minlength="4" placeholder="confirm new" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<button type="submit" class="sm:col-span-3 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">Update password</button>
|
||||
<input name="current_password" type="password" required placeholder="{cur}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<input name="new_password" type="password" required minlength="4" placeholder="{new}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<input name="confirm_password" type="password" required minlength="4" placeholder="{conf}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<button type="submit" class="sm:col-span-3 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{btn}</button>
|
||||
</form>
|
||||
</section>"##.to_string()
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.password_heading"),
|
||||
cur = t(lang, "profile.current_password"),
|
||||
new = t(lang, "profile.new_password"),
|
||||
conf = t(lang, "profile.confirm_new"),
|
||||
btn = t(lang, "profile.update_password"),
|
||||
)
|
||||
};
|
||||
|
||||
let totp_section = if let Some(panel) = totp_panel_override {
|
||||
panel
|
||||
} else if oidc_linked {
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Two-factor authentication</h3>
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<p class="text-sm text-slate-400">
|
||||
MFA is managed by your identity provider — local TOTP isn't used for OIDC sign-ins.
|
||||
{msg}
|
||||
</p>
|
||||
</section>"##
|
||||
.to_string()
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa"),
|
||||
msg = t(lang, "profile.tfa_oidc_msg"),
|
||||
)
|
||||
} else if has_totp {
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Two-factor authentication</h3>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<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>
|
||||
<span class="text-xs text-slate-400">Sign-ins require a 6-digit code from your authenticator.</span>
|
||||
<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>
|
||||
<span class="text-xs text-slate-400">{tfa_msg}</span>
|
||||
</div>
|
||||
<form
|
||||
class="flex gap-2 items-center text-sm"
|
||||
hx-post="/admin/pages/profile/totp/remove"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Remove TOTP from your account?"
|
||||
hx-confirm="{confirm}"
|
||||
>
|
||||
<input name="current_password" type="password" required placeholder="current password" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 flex-1 max-w-xs"/>
|
||||
<button class="bg-rose-700 hover:bg-rose-600 rounded px-3 py-1.5 text-white text-xs">Disable TOTP</button>
|
||||
<input name="current_password" type="password" required placeholder="{cur}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 flex-1 max-w-xs"/>
|
||||
<button class="bg-rose-700 hover:bg-rose-600 rounded px-3 py-1.5 text-white text-xs">{btn}</button>
|
||||
</form>
|
||||
</section>"##
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa"),
|
||||
enrolled = t(lang, "users.totp_enrolled"),
|
||||
tfa_msg = t(lang, "profile.tfa_enrolled_msg"),
|
||||
confirm = t(lang, "profile.tfa_confirm_remove"),
|
||||
cur = t(lang, "profile.current_password"),
|
||||
btn = t(lang, "profile.tfa_disable"),
|
||||
)
|
||||
} else {
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Two-factor authentication</h3>
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<p class="text-sm text-slate-400 mb-3">
|
||||
Add a TOTP authenticator (1Password, Authy, Google Authenticator, etc.) so sign-ins also require a 6-digit code.
|
||||
{intro}
|
||||
</p>
|
||||
<button
|
||||
class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm"
|
||||
hx-post="/admin/pages/profile/totp/start"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
>Enroll TOTP</button>
|
||||
</section>"##.to_string()
|
||||
>{btn}</button>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa"),
|
||||
intro = t(lang, "profile.tfa_intro"),
|
||||
btn = t(lang, "profile.tfa_enroll"),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
r##"<div class="space-y-6 max-w-3xl">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">Profile</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Signed in as <span class="text-slate-300">{username}</span></p>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{signed_in_as} <span class="text-slate-300">{username}</span></p>
|
||||
</header>
|
||||
{notice}
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">Profile info</h3>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{info_heading}</h3>
|
||||
<form
|
||||
class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
|
||||
hx-post="/admin/pages/profile/update-info"
|
||||
@@ -426,14 +484,14 @@ async fn render_full_page_with_totp_override(
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">Display name</span>
|
||||
<span class="text-xs text-slate-400">{display_name_l}</span>
|
||||
<input name="display_name" value="{display_name}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">Email</span>
|
||||
<span class="text-xs text-slate-400">{email_l}</span>
|
||||
<input name="email" type="email" value="{email}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
</label>
|
||||
<button type="submit" class="sm:col-span-2 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">Save</button>
|
||||
<button type="submit" class="sm:col-span-2 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{save}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -441,6 +499,12 @@ async fn render_full_page_with_totp_override(
|
||||
|
||||
{totp_section}
|
||||
</div>"##,
|
||||
heading = t(lang, "profile.heading"),
|
||||
signed_in_as = t(lang, "profile.signed_in_as"),
|
||||
info_heading = t(lang, "profile.info_heading"),
|
||||
display_name_l = t(lang, "profile.display_name"),
|
||||
email_l = t(lang, "profile.email"),
|
||||
save = t(lang, "common.save"),
|
||||
username = html_escape(&user.name),
|
||||
display_name = html_escape(&row.display_name),
|
||||
email = html_escape(&row.email),
|
||||
@@ -452,6 +516,7 @@ async fn render_full_page_with_totp_override(
|
||||
|
||||
async fn render_totp_enroll_panel(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
user: &AuthedUser,
|
||||
secret_b32: &str,
|
||||
qr_svg: &str,
|
||||
@@ -459,16 +524,15 @@ async fn render_totp_enroll_panel(
|
||||
) -> Result<String, ApiError> {
|
||||
let panel = format!(
|
||||
r##"<section class="rounded-md border border-sky-700/60 bg-sky-900/20 p-4">
|
||||
<h3 class="text-sm font-semibold text-sky-200 mb-2">Confirm TOTP enrollment</h3>
|
||||
<h3 class="text-sm font-semibold text-sky-200 mb-2">{heading}</h3>
|
||||
<p class="text-xs text-sky-200/80 mb-3">
|
||||
Scan the QR code with your authenticator app, then enter the 6-digit code it shows to confirm.
|
||||
Nothing is enrolled until you submit a valid code.
|
||||
{intro}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-start">
|
||||
<div class="bg-white p-2 rounded inline-block">{qr}</div>
|
||||
<div class="flex-1 space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-xs text-slate-400">Secret (manual entry):</span>
|
||||
<span class="text-xs text-slate-400">{secret_label}</span>
|
||||
<code class="block mt-1 break-all text-emerald-200 bg-slate-950 px-2 py-1.5 rounded text-xs">{secret}</code>
|
||||
</div>
|
||||
<form
|
||||
@@ -489,15 +553,19 @@ async fn render_totp_enroll_panel(
|
||||
placeholder="123456"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 w-32 font-mono tracking-widest"
|
||||
/>
|
||||
<button type="submit" class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">Confirm</button>
|
||||
<button type="submit" class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{confirm}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa_confirm_heading"),
|
||||
intro = t(lang, "profile.tfa_confirm_intro"),
|
||||
secret_label = t(lang, "profile.tfa_secret_manual"),
|
||||
confirm = t(lang, "common.confirm"),
|
||||
qr = qr_svg,
|
||||
secret = html_escape(secret_b32),
|
||||
);
|
||||
render_full_page_with_totp_override(state, user, notice, Some(panel)).await
|
||||
render_full_page_with_totp_override(state, lang, user, notice, Some(panel)).await
|
||||
}
|
||||
|
||||
fn render_qr_svg(payload: &str) -> String {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! full assignment matrix UI is a follow-up.
|
||||
|
||||
use super::shared::{html_escape, notice_html, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -15,9 +16,10 @@ use std::sync::Arc;
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_full(&state).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -30,11 +32,12 @@ pub struct CreateForm {
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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;
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
|
||||
}
|
||||
let cfg = if form.config_options_json.trim().is_empty() {
|
||||
"{}".to_string()
|
||||
@@ -44,9 +47,17 @@ pub async fn create(
|
||||
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
|
||||
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
||||
}
|
||||
Err(e) => {
|
||||
return notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"error",
|
||||
&tf1(lang, "strategies.invalid_json", &e.to_string()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => return notice_then(&state, "error", &format!("invalid JSON: {}", e)).await,
|
||||
}
|
||||
};
|
||||
state
|
||||
@@ -56,8 +67,9 @@ pub async fn create(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&format!("Strategy '{}' created.", form.name),
|
||||
&tf1(lang, "strategies.created", &form.name),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -70,6 +82,7 @@ pub struct UpdateForm {
|
||||
pub async fn update(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
@@ -77,7 +90,7 @@ pub async fn update(
|
||||
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
|
||||
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
||||
}
|
||||
};
|
||||
state
|
||||
@@ -85,12 +98,13 @@ pub async fn update(
|
||||
.strategy_update_config(id, &cfg)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(&state, "ok", "Strategy updated.").await
|
||||
notice_then(&state, lang, "ok", t(lang, "strategies.updated")).await
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -101,8 +115,9 @@ pub async fn delete(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { "Strategy deleted." } else { "Already gone." },
|
||||
if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -111,40 +126,51 @@ pub async fn delete(
|
||||
|
||||
async fn notice_then(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
kind: &str,
|
||||
msg: &str,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let mut html = notice_html(kind, msg);
|
||||
html.push_str(&render_full(state).await?);
|
||||
html.push_str(&render_full(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_full(state: &Arc<AppState>, lang: Lang) -> 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(
|
||||
let _ = write!(
|
||||
s,
|
||||
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>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{tagline}</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>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</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": ""}'
|
||||
<input name="name" placeholder="{ph}" 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>
|
||||
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
|
||||
</form>
|
||||
</section>
|
||||
"##,
|
||||
heading = t(lang, "strategies.heading"),
|
||||
tagline = t(lang, "strategies.tagline"),
|
||||
create_heading = t(lang, "strategies.create_heading"),
|
||||
ph = t(lang, "strategies.name_unique"),
|
||||
create = t(lang, "common.create"),
|
||||
);
|
||||
if strategies.is_empty() {
|
||||
s.push_str(r##"<p class="text-slate-500 text-sm">No strategies yet.</p>"##);
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p>"##,
|
||||
t(lang, "strategies.no_strategies"),
|
||||
);
|
||||
}
|
||||
for str_ in &strategies {
|
||||
let _ = write!(
|
||||
@@ -153,26 +179,30 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<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>
|
||||
<p class="text-xs text-slate-500">{meta}</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>
|
||||
hx-confirm="{confirm}"
|
||||
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>
|
||||
<label class="block text-xs text-slate-400">{cfg_label}</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>
|
||||
<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,
|
||||
meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()),
|
||||
cfg = html_escape(&str_.config_options_json),
|
||||
confirm = html_escape(&tf1(lang, "strategies.confirm_delete", &str_.name)),
|
||||
delete = t(lang, "common.delete"),
|
||||
cfg_label = t(lang, "strategies.config_label"),
|
||||
save = t(lang, "common.save"),
|
||||
);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
+149
-100
@@ -1,6 +1,7 @@
|
||||
//! Users page — list / create / set-password / toggle-admin / toggle-status
|
||||
//! / TOTP enroll-unenroll / delete.
|
||||
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
@@ -20,9 +21,10 @@ const PAGE_SIZE: i64 = 50;
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_full_page(&state).await?))
|
||||
Ok(Html(render_full_page(&state, lang).await?))
|
||||
}
|
||||
|
||||
// ---------- create ----------
|
||||
@@ -43,19 +45,15 @@ pub struct CreateForm {
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.username_required")).await;
|
||||
}
|
||||
if form.password.len() < 4 {
|
||||
return notice_then_table(
|
||||
&state,
|
||||
"error",
|
||||
"Password must be at least 4 characters",
|
||||
)
|
||||
.await;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.password_min")).await;
|
||||
}
|
||||
let hash = hash_password(form.password)
|
||||
.await
|
||||
@@ -73,7 +71,7 @@ pub async fn create(
|
||||
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
|
||||
notice_then_table(&state, lang, "ok", &tf1(lang, "users.created", &form.username)).await
|
||||
}
|
||||
|
||||
async fn set_email_inline(
|
||||
@@ -101,6 +99,7 @@ pub struct UpdateInfoForm {
|
||||
pub async fn update_info(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateInfoForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
@@ -115,7 +114,7 @@ pub async fn update_info(
|
||||
.raw_update_user_email(id, form.email.trim())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(&state, "ok", "Profile updated.").await
|
||||
notice_then_table(&state, lang, "ok", t(lang, "users.profile_updated")).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -126,6 +125,7 @@ pub struct PasswordResetForm {
|
||||
pub async fn reset_password(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<PasswordResetForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
@@ -141,20 +141,10 @@ pub async fn reset_password(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
if target.is_oidc_linked() {
|
||||
return notice_then_table(
|
||||
&state,
|
||||
"error",
|
||||
"This account is linked to OIDC — set the password at the identity provider instead.",
|
||||
)
|
||||
.await;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.oidc_password_disabled")).await;
|
||||
}
|
||||
if form.password.len() < 4 {
|
||||
return notice_then_table(
|
||||
&state,
|
||||
"error",
|
||||
"Password must be at least 4 characters",
|
||||
)
|
||||
.await;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.password_min")).await;
|
||||
}
|
||||
let hash = hash_password(form.password)
|
||||
.await
|
||||
@@ -166,8 +156,9 @@ pub async fn reset_password(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { "Password updated." } else { "User not found." },
|
||||
if ok { t(lang, "users.password_updated") } else { t(lang, "users.user_not_found") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -175,16 +166,12 @@ pub async fn reset_password(
|
||||
pub async fn toggle_admin(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.cant_revoke_self")).await;
|
||||
}
|
||||
let user = state
|
||||
.db
|
||||
@@ -197,22 +184,18 @@ pub async fn toggle_admin(
|
||||
.user_set_admin(id, !user.is_admin)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_table(&state).await?))
|
||||
Ok(Html(render_table(&state, lang).await?))
|
||||
}
|
||||
|
||||
pub async fn toggle_status(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.cant_disable_self")).await;
|
||||
}
|
||||
let user = state
|
||||
.db
|
||||
@@ -226,22 +209,18 @@ pub async fn toggle_status(
|
||||
.user_set_status(id, new_status)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_table(&state).await?))
|
||||
Ok(Html(render_table(&state, lang).await?))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
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;
|
||||
return notice_then_table(&state, lang, "error", t(lang, "users.cant_delete_self")).await;
|
||||
}
|
||||
let ok = state
|
||||
.db
|
||||
@@ -250,8 +229,9 @@ pub async fn delete(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { "User deleted." } else { "Already gone." },
|
||||
if ok { t(lang, "users.user_deleted") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -261,6 +241,7 @@ pub async fn delete(
|
||||
pub async fn totp_enroll(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -286,26 +267,30 @@ pub async fn totp_enroll(
|
||||
);
|
||||
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="font-semibold text-emerald-300 mb-1">{enrolled_for}</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><span class="text-slate-400">{secret_label}</span> <code class="text-emerald-200">{secret}</code></div>
|
||||
<div><span class="text-slate-400">{url_label}</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.
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
</div>"##,
|
||||
user = html_escape(&user.username),
|
||||
enrolled_for = html_escape(&tf1(lang, "users.totp_enrolled_for", &user.username)),
|
||||
secret_label = t(lang, "users.secret_b32"),
|
||||
url_label = t(lang, "users.otpauth_url"),
|
||||
secret = html_escape(&secret_b32),
|
||||
otpauth = html_escape(&otpauth),
|
||||
hint = t(lang, "users.otpauth_hint"),
|
||||
);
|
||||
html.push_str(&render_table(&state).await?);
|
||||
html.push_str(&render_table(&state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
pub async fn totp_unenroll(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
@@ -316,8 +301,9 @@ pub async fn totp_unenroll(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
if removed { "ok" } else { "error" },
|
||||
if removed { "TOTP removed." } else { "User had no TOTP." },
|
||||
if removed { t(lang, "users.totp_removed") } else { t(lang, "users.no_totp") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -326,24 +312,25 @@ pub async fn totp_unenroll(
|
||||
|
||||
async fn notice_then_table(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
kind: &str,
|
||||
msg: &str,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let mut html = notice_html(kind, msg);
|
||||
html.push_str(&render_table(state).await?);
|
||||
html.push_str(&render_table(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn render_full_page(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
let table = render_table(state).await?;
|
||||
async fn render_full_page(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let table = render_table(state, lang).await?;
|
||||
Ok(format!(
|
||||
r##"<div class="space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Users</h2>
|
||||
<h2 class="text-lg font-semibold">{heading}</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>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
|
||||
<form
|
||||
class="grid grid-cols-1 sm:grid-cols-6 gap-3 text-sm"
|
||||
hx-post="/admin/pages/users/create"
|
||||
@@ -351,23 +338,32 @@ async fn render_full_page(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
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>
|
||||
<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}" 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>"##
|
||||
</div>"##,
|
||||
heading = t(lang, "users.heading"),
|
||||
create_heading = t(lang, "users.create_heading"),
|
||||
username = t(lang, "users.username"),
|
||||
display_name = t(lang, "users.display_name"),
|
||||
email = t(lang, "users.email_optional"),
|
||||
password = t(lang, "users.password"),
|
||||
admin = t(lang, "users.admin_label"),
|
||||
create = t(lang, "common.create"),
|
||||
table = table,
|
||||
))
|
||||
}
|
||||
|
||||
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let (_total, users) = state
|
||||
.db
|
||||
.users_list_all(0, PAGE_SIZE)
|
||||
@@ -391,26 +387,36 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
// No `overflow-hidden` on the table wrapper: the per-row action menu is
|
||||
// an absolutely-positioned `<details>` popover inside a <td>, and the
|
||||
// wrapper's clipping was hiding the bottom half of the menu.
|
||||
s.push_str(
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
|
||||
<tr>
|
||||
<th class="text-left font-medium px-3 py-2">Username</th>
|
||||
<th class="text-left font-medium px-3 py-2">Display name</th>
|
||||
<th class="text-left font-medium px-3 py-2">Email</th>
|
||||
<th class="text-left font-medium px-3 py-2">Status</th>
|
||||
<th class="text-left font-medium px-3 py-2">Admin</th>
|
||||
<th class="text-left font-medium px-3 py-2">TOTP</th>
|
||||
<th class="text-left font-medium px-3 py-2">Last seen</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_username}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_display_name}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_email}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_status}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_admin}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_totp}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_last_seen}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_username = t(lang, "users.col_username"),
|
||||
c_display_name = t(lang, "users.col_display_name"),
|
||||
c_email = t(lang, "users.col_email"),
|
||||
c_status = t(lang, "users.col_status"),
|
||||
c_admin = t(lang, "users.col_admin"),
|
||||
c_totp = t(lang, "users.col_totp"),
|
||||
c_last_seen = t(lang, "users.col_last_seen"),
|
||||
c_actions = t(lang, "common.actions"),
|
||||
);
|
||||
for u in &users {
|
||||
render_user_row(
|
||||
&mut s,
|
||||
lang,
|
||||
u,
|
||||
*totp.get(&u.id).unwrap_or(&false),
|
||||
last_seen.get(&u.id).map(String::as_str),
|
||||
@@ -420,50 +426,75 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Option<&str>) {
|
||||
fn render_user_row(
|
||||
s: &mut String,
|
||||
lang: Lang,
|
||||
u: &UserRow,
|
||||
has_totp: bool,
|
||||
last_seen: Option<&str>,
|
||||
) {
|
||||
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>"#,
|
||||
_ => "",
|
||||
1 => format!(
|
||||
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">{}</span>"#,
|
||||
t(lang, "users.status_active")
|
||||
),
|
||||
0 => format!(
|
||||
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">{}</span>"#,
|
||||
t(lang, "users.status_disabled")
|
||||
),
|
||||
-1 => format!(
|
||||
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">{}</span>"#,
|
||||
t(lang, "users.status_unverified")
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
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>"#
|
||||
format!(
|
||||
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">{}</span>"#,
|
||||
t(lang, "users.admin_label"),
|
||||
)
|
||||
} else {
|
||||
""
|
||||
String::new()
|
||||
};
|
||||
// The TOTP column doubles as an "auth path" indicator: OIDC-linked
|
||||
// users get an "OIDC" badge (their MFA lives at the IdP — local
|
||||
// TOTP is moot), and OIDC takes precedence over local TOTP if both
|
||||
// somehow exist.
|
||||
let totp_badge = if u.is_oidc_linked() {
|
||||
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-cyan-900/50 border border-cyan-700/50 text-cyan-300 text-xs">OIDC</span>"#
|
||||
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-cyan-900/50 border border-cyan-700/50 text-cyan-300 text-xs">OIDC</span>"#.to_string()
|
||||
} else 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>"#
|
||||
format!(
|
||||
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">{}</span>"#,
|
||||
t(lang, "users.totp_enrolled")
|
||||
)
|
||||
} else {
|
||||
""
|
||||
String::new()
|
||||
};
|
||||
let oidc_linked = u.is_oidc_linked();
|
||||
// OIDC-linked users sign in via the IdP — adding a local password
|
||||
// would let them bypass the IdP (and any MFA enforced there). Show
|
||||
// a note instead of the password-reset form for these accounts.
|
||||
let password_form = if oidc_linked {
|
||||
r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded">
|
||||
Linked to OIDC — password sign-in is disabled.
|
||||
</div>"##
|
||||
.to_string()
|
||||
format!(
|
||||
r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded">
|
||||
{msg}
|
||||
</div>"##,
|
||||
msg = t(lang, "users.oidc_disabled"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<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>
|
||||
<input name="password" type="password" required minlength="4" placeholder="{ph}" 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>"##,
|
||||
id = u.id,
|
||||
ph = t(lang, "users.new_password"),
|
||||
set = t(lang, "users.password_set"),
|
||||
)
|
||||
};
|
||||
let (last_seen_rel, last_seen_abs) = match last_seen {
|
||||
Some(ts) => (relative_ts(ts), html_escape(ts)),
|
||||
None => ("never".to_string(), String::new()),
|
||||
None => (t(lang, "common.never").to_string(), String::new()),
|
||||
};
|
||||
// TOTP enrollment is self-service (the user does it on their
|
||||
// profile page so they can scan the QR + verify a code before
|
||||
@@ -473,11 +504,12 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Optio
|
||||
format!(
|
||||
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/users/{id}/totp-unenroll" hx-target="#users-region" hx-swap="innerHTML"
|
||||
hx-confirm="Disable TOTP for {username}? They'll be able to sign in without a 6-digit code until they re-enroll.">
|
||||
Disable TOTP
|
||||
hx-confirm="{confirm}">
|
||||
{label}
|
||||
</button>"##,
|
||||
id = u.id,
|
||||
username = html_escape(&u.username),
|
||||
confirm = html_escape_attr(&tf1(lang, "users.confirm_disable_totp", &u.username)),
|
||||
label = t(lang, "users.disable_totp"),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
@@ -497,9 +529,9 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Optio
|
||||
<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-64 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<form class="space-y-1" hx-post="/admin/pages/users/{id}/update-info" hx-target="#users-region" hx-swap="innerHTML">
|
||||
<input name="display_name" value="{display_name}" placeholder="display name" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<input name="email" type="email" value="{email}" placeholder="email" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<button class="w-full bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Save profile</button>
|
||||
<input name="display_name" value="{display_name}" placeholder="{ph_dn}" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<input name="email" type="email" value="{email}" placeholder="{ph_email}" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<button class="w-full bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">{save_profile}</button>
|
||||
</form>
|
||||
{password_form}
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
@@ -513,9 +545,9 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Optio
|
||||
{totp_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-confirm="{confirm_delete}"
|
||||
hx-target="#users-region" hx-swap="innerHTML">
|
||||
Delete user
|
||||
{delete_user}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
@@ -528,15 +560,32 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Optio
|
||||
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" },
|
||||
ph_dn = t(lang, "users.display_name"),
|
||||
ph_email = t(lang, "users.col_email"),
|
||||
save_profile = t(lang, "users.save_profile"),
|
||||
admin_label = if u.is_admin {
|
||||
t(lang, "users.revoke_admin")
|
||||
} else {
|
||||
t(lang, "users.grant_admin")
|
||||
},
|
||||
status_label = if u.status == 1 {
|
||||
t(lang, "users.disable_user")
|
||||
} else {
|
||||
t(lang, "users.enable_user")
|
||||
},
|
||||
totp_button = totp_button,
|
||||
last_seen_rel = last_seen_rel,
|
||||
last_seen_abs = last_seen_abs,
|
||||
password_form = password_form,
|
||||
confirm_delete = html_escape_attr(&tf1(lang, "users.confirm_delete", &u.username)),
|
||||
delete_user = t(lang, "users.delete_user"),
|
||||
);
|
||||
}
|
||||
|
||||
fn html_escape_attr(s: &str) -> String {
|
||||
html_escape(s)
|
||||
}
|
||||
|
||||
/// Format a SQLite `current_timestamp` string ("YYYY-MM-DD HH:MM:SS",
|
||||
/// always UTC) as a relative time-ago label. Renders short forms — "5m
|
||||
/// ago", "3h ago", "2d ago" — for the at-a-glance column; the absolute
|
||||
|
||||
Reference in New Issue
Block a user