Implementing multi-language Admin UI
build / build-linux-amd64 (push) Successful in 2m2s

This commit is contained in:
2026-05-09 16:58:20 +02:00
parent a7b3e83f02
commit 1e961cdd92
14 changed files with 2989 additions and 487 deletions
+26 -13
View File
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html lang="en" class="h-full"> <html lang="{{LANG_CODE}}" class="h-full">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>RustDesk Admin</title> <title>{{T_APP_TITLE}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/admin/assets/tailwindcss.js"></script> <script src="/admin/assets/tailwindcss.js"></script>
<script src="/admin/assets/htmx.min.js"></script> <script src="/admin/assets/htmx.min.js"></script>
@@ -23,38 +23,51 @@
<div class="min-h-full flex"> <div class="min-h-full flex">
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col"> <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"> <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> <p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML"></p>
</div> </div>
<nav class="flex-1 px-2 py-3 space-y-1"> <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" <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" <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" <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" <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" <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" <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" <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> </nav>
<div class="px-2 py-3 border-t border-slate-800 space-y-1"> <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" <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 <button
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800" 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-post="/admin/logout"
hx-on::after-request="window.location.href = '/admin/login.html'" 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> </div>
</aside> </aside>
<main id="main" class="flex-1 p-6 overflow-x-hidden"> <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> </main>
</div> </div>
+25 -10
View File
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html lang="en" class="h-full"> <html lang="{{LANG_CODE}}" class="h-full">
<head> <head>
<meta charset="utf-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/admin/assets/tailwindcss.js"></script> <script src="/admin/assets/tailwindcss.js"></script>
<script src="/admin/assets/htmx.min.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"> <body class="h-full bg-slate-950 text-slate-100 flex items-center justify-center">
<main class="w-full max-w-sm px-6"> <main class="w-full max-w-sm px-6">
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 class="text-2xl font-semibold">RustDesk Admin</h1> <h1 class="text-2xl font-semibold">{{T_TITLE}}</h1>
<p class="text-slate-400 text-sm mt-1">Sign in to manage the server.</p> <p class="text-slate-400 text-sm mt-1">{{T_SUBTITLE}}</p>
</div> </div>
<form <form
@@ -42,7 +42,7 @@
" "
> >
<div> <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 <input
id="username" name="username" type="text" required autocomplete="username" 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" 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>
<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 <input
id="password" name="password" type="password" required autocomplete="current-password" 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" 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>
<div id="tfa-section" class="hidden"> <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 <input
id="tfaCode" name="tfaCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" 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" 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" type="submit"
class="w-full bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition" 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> </button>
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div> <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 id="oidc-block" class="mt-6 hidden">
<div class="flex items-center gap-3 mb-3"> <div class="flex items-center gap-3 mb-3">
<div class="flex-1 h-px bg-slate-800"></div> <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 class="flex-1 h-px bg-slate-800"></div>
</div> </div>
<div id="oidc-buttons" class="space-y-2"></div> <div id="oidc-buttons" class="space-y-2"></div>
</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> </main>
<script> <script>
@@ -92,6 +106,7 @@
// navigates to /admin/login/oidc/<name>, which 302s the browser to the // navigates to /admin/login/oidc/<name>, which 302s the browser to the
// IdP. After the IdP redirects to /oidc/callback, the server sets our // IdP. After the IdP redirects to /oidc/callback, the server sets our
// session cookie and redirects to /admin/. // session cookie and redirects to /admin/.
var SIGNIN_WITH = {{T_SIGNIN_WITH_JSON}};
fetch('/admin/oidc/providers').then(r => r.json()).then(list => { fetch('/admin/oidc/providers').then(r => r.json()).then(list => {
if (!Array.isArray(list) || list.length === 0) return; if (!Array.isArray(list) || list.length === 0) return;
const block = document.getElementById('oidc-block'); const block = document.getElementById('oidc-block');
@@ -100,7 +115,7 @@
const a = document.createElement('a'); const a = document.createElement('a');
a.href = '/admin/login/oidc/' + encodeURIComponent(p.name); 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.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); root.appendChild(a);
}); });
block.classList.remove('hidden'); block.classList.remove('hidden');
+11 -8
View File
@@ -4,6 +4,7 @@
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already //! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
//! accepts both `Authorization: Bearer …` and the cookie. //! accepts both `Authorization: Bearer …` and the cookie.
use crate::api::admin::i18n::{t, Lang};
use crate::api::auth::mint_token; use crate::api::auth::mint_token;
use crate::api::middleware::{sha256_token, SESSION_COOKIE}; use crate::api::middleware::{sha256_token, SESSION_COOKIE};
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -32,6 +33,7 @@ pub struct LoginForm {
pub async fn login( pub async fn login(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
lang: Lang,
Form(form): Form<LoginForm>, Form(form): Form<LoginForm>,
) -> Response { ) -> Response {
// First leg: password verify. Same DB call paths as `/api/login` — // 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. // diverge from the API's auth contract.
let user = match state.db.user_find_by_username(&form.username).await { let user = match state.db.user_find_by_username(&form.username).await {
Ok(Some(u)) => u, 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)), Err(e) => return error_fragment(&format!("internal: {}", e)),
}; };
let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await { 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)), Err(e) => return error_fragment(&format!("internal: {}", e)),
}; };
if !pw_ok { if !pw_ok {
return error_fragment("Bad credentials"); return error_fragment(t(lang, "login.bad_credentials"));
} }
if user.status == 0 { if user.status == 0 {
return error_fragment("Account disabled"); return error_fragment(t(lang, "login.account_disabled"));
} }
if !user.is_admin { if !user.is_admin {
// Only admins can use the dashboard. Non-admin users still get // Only admins can use the dashboard. Non-admin users still get
// tokens via `/api/login` for the desktop client; they just don't // tokens via `/api/login` for the desktop client; they just don't
// see the management surface. // 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 // 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. // 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 // leg: it watches for the special marker via HX-Trigger and
// reveals the #tfa-section. // reveals the #tfa-section.
let frag = format!( 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> <script>
document.getElementById('tfa-section').classList.remove('hidden'); document.getElementById('tfa-section').classList.remove('hidden');
document.getElementById('tfaCode').focus(); document.getElementById('tfaCode').focus();
</script>"# </script>"#,
msg = t(lang, "login.totp_required"),
); );
// We don't need a session yet — caller will resubmit with the // We don't need a session yet — caller will resubmit with the
// same username/password plus the code. (No nonce involved on // same username/password plus the code. (No nonce involved on
@@ -88,10 +91,10 @@ pub async fn login(
// Verify the supplied code. // Verify the supplied code.
let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) { let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) {
Ok(b) => b, Ok(b) => b,
Err(_) => return error_fragment("Internal TOTP error"), Err(_) => return error_fragment(t(lang, "login.internal_totp")),
}; };
if !ok { 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
View File
@@ -3,14 +3,16 @@
//! the cookie isn't valid, the AuthedUser extractor 401s and the page-level //! the cookie isn't valid, the AuthedUser extractor 401s and the page-level
//! HTMX response handler bounces back to the login form. //! HTMX response handler bounces back to the login form.
use crate::api::admin::i18n::{t, Lang};
use crate::api::error::ApiError; use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use axum::response::Html; 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!( Ok(Html(format!(
"Signed in as <span class=\"text-slate-300\">{}</span>", "{label} <span class=\"text-slate-300\">{name}</span>",
html_escape(&user.name) label = t(lang, "nav.signed_in_as"),
name = html_escape(&user.name),
))) )))
} }
+99 -7
View File
@@ -16,16 +16,19 @@
//! /admin/pages/* GET fragments (one per page) //! /admin/pages/* GET fragments (one per page)
pub mod auth; pub mod auth;
pub mod i18n;
pub mod me; pub mod me;
pub mod oidc_login; pub mod oidc_login;
pub mod pages; pub mod pages;
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use std::sync::Arc; 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 /// Files embedded into the binary. Paths are relative to this source file
/// per `include_str!`. Adding a new HTML asset = one new entry here. /// per `include_str!`. Adding a new HTML asset = one new entry here.
const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html"); 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) Some(r)
} }
async fn serve_index() -> Response { async fn serve_index(headers: HeaderMap) -> Response {
html_response(INDEX_HTML) let lang = lang_from_headers(&headers);
html_response_owned(render_index(lang))
} }
async fn serve_login() -> Response { async fn serve_login(headers: HeaderMap) -> Response {
html_response(LOGIN_HTML) let lang = lang_from_headers(&headers);
html_response_owned(render_login(lang))
} }
fn html_response(body: &'static str) -> Response { /// Apply i18n placeholders to the embedded `index.html` template.
// We hand back `Html<&'static str>` so axum sets `text/html` for us. 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 // Cache-Control: no-cache so the operator sees fresh HTML after a
// server upgrade without having to bump asset URLs. // server upgrade without having to bump asset URLs.
let mut resp = Html(body).into_response(); let mut resp = Html(body).into_response();
+126 -61
View File
@@ -6,6 +6,7 @@
//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s). //! 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 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::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -18,9 +19,10 @@ use std::sync::Arc;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
Ok(Html(render_index(&state, None).await?)) Ok(Html(render_index(&state, lang, None).await?))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -31,35 +33,39 @@ pub struct CreateForm {
pub async fn create( pub async fn create(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Form(f): Form<CreateForm>, Form(f): Form<CreateForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
let name = f.name.trim(); let name = f.name.trim();
if name.is_empty() { 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 res = state.db.ab_create_shared(admin.user_id, name).await;
let notice = match res { let notice = match res {
Ok(_) => Some(("ok", format!("Shared address book '{}' created.", name))), Ok(_) => Some(("ok", tf1(lang, "ab.created", name))),
Err(e) => { Err(e) => {
// The unique index trips when the same admin creates two books // The unique index trips when the same admin creates two books
// with the same name. Surface that cleanly instead of leaking // with the same name. Surface that cleanly instead of leaking
// the raw SQL error. // the raw SQL error.
let msg = if e.to_string().to_lowercase().contains("unique") { 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 { } else {
format!("Create failed: {}", e) tf1(lang, "ab.create_failed", &e.to_string())
}; };
Some(("error", msg)) Some(("error", msg))
} }
}; };
let n = notice.as_ref().map(|(k, m)| (*k, m.as_str())); 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( pub async fn delete(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(guid): Path<String>, Path(guid): Path<String>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -69,20 +75,21 @@ pub async fn delete(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let notice = if ok { let notice = if ok {
("ok", "Deleted.") ("ok", t(lang, "ab.deleted"))
} else { } 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( pub async fn manage(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(guid): Path<String>, Path(guid): Path<String>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
Ok(Html(render_manage(&state, &guid, None).await?)) Ok(Html(render_manage(&state, lang, &guid, None).await?))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -94,24 +101,30 @@ pub struct ShareForm {
pub async fn share_add( pub async fn share_add(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(guid): Path<String>, Path(guid): Path<String>,
Form(f): Form<ShareForm>, Form(f): Form<ShareForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if !(1..=3).contains(&f.rule) { 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 state
.db .db
.ab_share_set(&guid, f.user_id, f.rule) .ab_share_set(&guid, f.user_id, f.rule)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn share_remove(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path((guid, user_id)): Path<(String, i64)>, Path((guid, user_id)): Path<(String, i64)>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -120,13 +133,16 @@ pub async fn share_remove(
.ab_share_remove(&guid, user_id) .ab_share_remove(&guid, user_id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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 ---------- // ---------- rendering ----------
async fn render_index( async fn render_index(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
notice: Option<(&str, &str)>, notice: Option<(&str, &str)>,
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
let books = state let books = state
@@ -140,8 +156,8 @@ async fn render_index(
s, s,
r##"<div id="ab-region" class="space-y-6"> r##"<div id="ab-region" class="space-y-6">
<header> <header>
<h2 class="text-lg font-semibold">Address books</h2> <h2 class="text-lg font-semibold">{heading}</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> <p class="text-xs text-slate-500 mt-1">{tagline}</p>
</header> </header>
{notice_html} {notice_html}
@@ -152,41 +168,63 @@ async fn render_index(
hx-target="#ab-region" hx-swap="outerHTML" hx-target="#ab-region" hx-swap="outerHTML"
> >
<div class="flex-1"> <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 <input id="ab-name" name="name" type="text" required
placeholder="Engineering laptops" 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" /> class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
</div> </div>
<button type="submit" <button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"> class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
Create {create}
</button> </button>
</form> </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() { 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); return Ok(s);
} }
s.push_str( let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900"> r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr> <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">{c_owner}</th>
<th class="text-left font-medium px-3 py-2">Kind</th> <th class="text-left font-medium px-3 py-2">{c_kind}</th>
<th class="text-left font-medium px-3 py-2">Name</th> <th class="text-left font-medium px-3 py-2">{c_name}</th>
<th class="text-left font-medium px-3 py-2">Peers</th> <th class="text-left font-medium px-3 py-2">{c_peers}</th>
<th class="text-left font-medium px-3 py-2">GUID</th> <th class="text-left font-medium px-3 py-2">{c_guid}</th>
<th class="text-left font-medium px-3 py-2">Created</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">Actions</th> <th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr></thead> </tr></thead>
<tbody class="divide-y divide-slate-800">"##, <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 { for b in &books {
let kind_pill = match b.kind { 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>"#, 0 => format!(
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>"#, 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 // Both kinds get a delete action. Shared books additionally get
// "Manage shares". Personal books carry an extra warning in the // "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" <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-get="/admin/pages/address-books/{guid}/manage"
hx-target="#ab-region" hx-swap="outerHTML"> hx-target="#ab-region" hx-swap="outerHTML">
Manage shares {manage}
</button> </button>
<hr class="border-slate-700 my-1" /> <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" <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-post="/admin/pages/address-books/{guid}/delete"
hx-target="#ab-region" hx-swap="outerHTML" hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="Delete shared book '{name}'? Peers, tags, and shares will be removed."> hx-confirm="{confirm}">
Delete book {delete}
</button> </button>
</div> </div>
</details>"##, </details>"##,
guid = html_escape(&b.guid), 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 { } else {
format!( 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" <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-post="/admin/pages/address-books/{guid}/delete"
hx-target="#ab-region" hx-swap="outerHTML" 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)."> hx-confirm="{confirm}">
Delete book {delete}
</button> </button>
</div> </div>
</details>"##, </details>"##,
guid = html_escape(&b.guid), 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!( let _ = write!(
@@ -258,6 +299,7 @@ async fn render_index(
async fn render_manage( async fn render_manage(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
guid: &str, guid: &str,
notice: Option<(&str, &str)>, notice: Option<(&str, &str)>,
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
@@ -269,13 +311,13 @@ async fn render_manage(
let Some((_owner_id, kind)) = owner_kind else { let Some((_owner_id, kind)) = owner_kind else {
return Ok(format!( return Ok(format!(
r##"<div id="ab-region">{notice}</div>"##, 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 { if kind != 1 {
return Ok(format!( return Ok(format!(
r##"<div id="ab-region">{notice}</div>"##, 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 let shares = state
@@ -298,38 +340,47 @@ async fn render_manage(
r##"<div id="ab-region" class="space-y-6"> r##"<div id="ab-region" class="space-y-6">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<div> <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> <p class="text-xs text-slate-500 mt-1 font-mono">{guid}</p>
</div> </div>
<button <button
class="text-xs text-slate-300 hover:text-slate-100 px-2 py-1 rounded border border-slate-700 hover:border-slate-500" 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-get="/admin/pages/address-books"
hx-target="#ab-region" hx-swap="outerHTML"> hx-target="#ab-region" hx-swap="outerHTML">
← Back {back}
</button> </button>
</header> </header>
{notice_html} {notice_html}
<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3"> <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 <form
class="flex flex-wrap items-end gap-2" class="flex flex-wrap items-end gap-2"
hx-post="/admin/pages/address-books/{guid}/shares/add" hx-post="/admin/pages/address-books/{guid}/shares/add"
hx-target="#ab-region" hx-swap="outerHTML" hx-target="#ab-region" hx-swap="outerHTML"
> >
<div class="flex-1 min-w-[200px]"> <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 <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">"##, 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), guid = html_escape(guid),
notice_html = notice_html, notice_html = notice_html,
); );
if users.is_empty() { 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 { 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!( let _ = write!(
s, s,
r#"<option value="{id}">{name}{already}</option>"#, r#"<option value="{id}">{name}{already}</option>"#,
@@ -343,46 +394,58 @@ async fn render_manage(
r##"</select> r##"</select>
</div> </div>
<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" <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"> 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="1">{r_read}</option>
<option value="2" selected>Read + write</option> <option value="2" selected>{r_rw}</option>
<option value="3">Full</option> <option value="3">{r_full}</option>
</select> </select>
</div> </div>
<button type="submit" <button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"> class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
Save {save}
</button> </button>
</form> </form>
</section> </section>
<section class="rounded-md border border-slate-800 bg-slate-900"> <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"> <header class="px-4 py-2 text-xs uppercase text-slate-500 border-b border-slate-800">
Current shares ({n}) {current_shares}
</header> </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() { 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); return Ok(s);
} }
s.push_str( let _ = write!(
s,
r##"<table class="w-full text-sm"> r##"<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr> <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">{user_l}</th>
<th class="text-left font-medium px-3 py-2">Rule</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> <th class="text-right font-medium px-3 py-2 w-1"></th>
</tr></thead> </tr></thead>
<tbody class="divide-y divide-slate-800">"##, <tbody class="divide-y divide-slate-800">"##,
user_l = t(lang, "ab.user_label"),
rule_l = t(lang, "ab.rule"),
); );
for sh in &shares { for sh in &shares {
let rule = match sh.rule { let rule = match sh.rule {
1 => "Read", 1 => t(lang, "ab.rule_read"),
2 => "Read + write", 2 => t(lang, "ab.rule_read_write"),
3 => "Full", 3 => t(lang, "ab.rule_full"),
_ => "?", _ => "?",
}; };
let _ = write!( 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" <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-post="/admin/pages/address-books/{guid}/shares/{uid}/remove"
hx-target="#ab-region" hx-swap="outerHTML" hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="Remove {user}'s access?"> hx-confirm="{confirm}">
Remove {remove}
</button> </button>
</td> </td>
</tr>"##, </tr>"##,
@@ -403,6 +466,8 @@ async fn render_manage(
rule = rule, rule = rule,
guid = html_escape(guid), guid = html_escape(guid),
uid = sh.user_id, 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>"); s.push_str("</tbody></table></section></div>");
+56 -35
View File
@@ -3,6 +3,7 @@
//! a follow-up if the operator outgrows this view. //! a follow-up if the operator outgrows this view.
use super::shared::{fmt_unix, html_escape, require_admin}; 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::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -23,14 +24,15 @@ pub struct TabQuery {
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Query(q): Query<TabQuery>, Query(q): Query<TabQuery>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
let tab = q.tab.as_deref().unwrap_or("conn"); let tab = q.tab.as_deref().unwrap_or("conn");
let body = match tab { let body = match tab {
"file" => render_file(&state).await?, "file" => render_file(&state, lang).await?,
"alarm" => render_alarm(&state).await?, "alarm" => render_alarm(&state, lang).await?,
_ => render_conn(&state).await?, _ => render_conn(&state, lang).await?,
}; };
let pill = |id: &str, label: &str| { let pill = |id: &str, label: &str| {
let active = id == tab; let active = id == tab;
@@ -49,42 +51,50 @@ pub async fn index(
Ok(Html(format!( Ok(Html(format!(
r##"<div class="space-y-4"> r##"<div class="space-y-4">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Audit log</h2> <h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500">Latest {n} rows.</p> <p class="text-xs text-slate-500">{latest}</p>
</header> </header>
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div> <div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
{body} {body}
</div>"##, </div>"##,
n = PAGE_SIZE, heading = t(lang, "audit.heading"),
pill_conn = pill("conn", "Connections"), latest = tf1(lang, "audit.latest", &PAGE_SIZE.to_string()),
pill_file = pill("file", "File transfers"), pill_conn = pill("conn", t(lang, "audit.tab_conn")),
pill_alarm = pill("alarm", "Alarms"), pill_file = pill("file", t(lang, "audit.tab_file")),
pill_alarm = pill("alarm", t(lang, "audit.tab_alarm")),
body = body, 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 let rows = state
.db .db
.audit_conn_list(PAGE_SIZE) .audit_conn_list(PAGE_SIZE)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() { 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(); 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"> r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr> <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">{c_when}</th>
<th class="text-left font-medium px-3 py-2">Peer</th> <th class="text-left font-medium px-3 py-2">{c_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">{c_conn}</th>
<th class="text-left font-medium px-3 py-2">IP</th> <th class="text-left font-medium px-3 py-2">{c_ip}</th>
<th class="text-left font-medium px-3 py-2">Action</th> <th class="text-left font-medium px-3 py-2">{c_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_note}</th>
</tr></thead> </tr></thead>
<tbody class="divide-y divide-slate-800">"##, <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 { for r in &rows {
let _ = write!( let _ = write!(
@@ -110,32 +120,38 @@ async fn render_conn(state: &Arc<AppState>) -> Result<String, ApiError> {
Ok(s) 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 let rows = state
.db .db
.audit_file_list(PAGE_SIZE) .audit_file_list(PAGE_SIZE)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() { 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(); 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"> r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr> <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">{c_when}</th>
<th class="text-left font-medium px-3 py-2">Peer</th> <th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">Direction</th> <th class="text-left font-medium px-3 py-2">{c_dir}</th>
<th class="text-left font-medium px-3 py-2">Path</th> <th class="text-left font-medium px-3 py-2">{c_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_remote}</th>
</tr></thead> </tr></thead>
<tbody class="divide-y divide-slate-800">"##, <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 { for r in &rows {
let dir = match r.direction { let dir = match r.direction {
0 => "→ remote", 0 => t(lang, "audit.dir_to_remote"),
1 => "← remote", 1 => t(lang, "audit.dir_from_remote"),
_ => "?", _ => "?",
}; };
let _ = write!( let _ = write!(
@@ -158,26 +174,31 @@ async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
Ok(s) 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 let rows = state
.db .db
.audit_alarm_list(PAGE_SIZE) .audit_alarm_list(PAGE_SIZE)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() { 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(); 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"> r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr> <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">{c_when}</th>
<th class="text-left font-medium px-3 py-2">Peer</th> <th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">Type</th> <th class="text-left font-medium px-3 py-2">{c_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_info}</th>
</tr></thead> </tr></thead>
<tbody class="divide-y divide-slate-800">"##, <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 { for r in &rows {
let typ = match r.typ { let typ = match r.typ {
+54 -25
View File
@@ -6,6 +6,7 @@
//! signature verification, so we don't need the Pro private key. //! signature verification, so we don't need the Pro private key.
use super::shared::{html_escape, require_admin}; use super::shared::{html_escape, require_admin};
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError; use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use axum::extract::Form; use axum::extract::Form;
@@ -14,7 +15,11 @@ use axum::response::Html;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; 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)?; require_admin(&admin)?;
let pubkey = read_pubkey(); let pubkey = read_pubkey();
// Best-effort prefill: the Host the admin's browser is currently // 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()) (format!("https://{}", host_default), host_default.clone())
}; };
Ok(Html(render_form( Ok(Html(render_form(
lang,
&pubkey, &pubkey,
&host_default, &host_default,
&api_default, &api_default,
@@ -71,22 +77,25 @@ pub struct DeployForm {
pub async fn generate( pub async fn generate(
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Form(f): Form<DeployForm>, Form(f): Form<DeployForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if f.host.trim().is_empty() { if f.host.trim().is_empty() {
return Ok(Html(render_form( return Ok(Html(render_form(
lang,
&f.key, &f.key,
&f.host, &f.host,
&f.api, &f.api,
&f.relay, &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 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( Ok(Html(render_form(
lang,
&f.key, &f.key,
&f.host, &f.host,
&f.api, &f.api,
@@ -125,6 +134,7 @@ fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String {
} }
fn render_form( fn render_form(
lang: Lang,
key: &str, key: &str,
host: &str, host: &str,
api: &str, api: &str,
@@ -139,8 +149,8 @@ fn render_form(
format!( format!(
r##"<div class="space-y-6"> r##"<div class="space-y-6">
<header> <header>
<h2 class="text-lg font-semibold">Deploy</h2> <h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-1">Generate a config blob the stock client accepts via <code>rustdesk --config &lt;blob&gt;</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> <p class="text-xs text-slate-500 mt-1">{intro}</p>
</header> </header>
{notice_html} {notice_html}
@@ -152,7 +162,7 @@ fn render_form(
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<div> <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}" <input id="host" name="host" type="text" required value="{host}"
placeholder="rustdesk.example.com or 203.0.113.10" placeholder="rustdesk.example.com or 203.0.113.10"
oninput=" oninput="
@@ -164,42 +174,53 @@ fn render_form(
api.placeholder = h ? 'https://' + h : 'https://rustdesk.example.com'; 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" /> 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>
<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}" <input id="api" name="api" type="text" value="{api}"
placeholder="https://rustdesk.example.com" placeholder="https://rustdesk.example.com"
oninput="this.dataset.derived = '0';" 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" /> 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://&lt;host&gt;</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>
<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}" <input id="relay" name="relay" type="text" value="{relay}"
placeholder="rustdesk.example.com" placeholder="rustdesk.example.com"
oninput="this.dataset.derived = '0';" 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" /> 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>
<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" <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> 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> </div>
<button type="submit" <button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"> class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
Generate {generate}
</button> </button>
</form> </form>
{result_html} {result_html}
</div>"##, </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), host = html_escape(host),
api = html_escape(api), api = html_escape(api),
relay = html_escape(relay), 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 // Build the rename-the-installer alternative. Windows filenames disallow
// `:` and `/`, which the API URL is full of (`http://host:21114`). The // `:` and `/`, which the API URL is full of (`http://host:21114`). The
// client falls back to `http://<host>:21114` when `api` is empty // 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!( format!(
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4"> r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
<header> <header>
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3> <h3 class="text-sm font-semibold text-slate-200">{artifact_heading}</h3>
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. All three produce the same client config.</p> <p class="text-xs text-slate-500 mt-1">{artifact_intro}</p>
</header> </header>
<div> <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> <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>
<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> <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} {renamed_note}
</div> </div>
<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> <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> </div>
<details class="text-xs text-slate-400"> <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> <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> </details>
</section>"##, </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_win = html_escape(&cmd_win),
cmd_unix = html_escape(&cmd_unix),
cmd_hello = html_escape(&cmd_hello), cmd_hello = html_escape(&cmd_hello),
renamed = html_escape(&renamed), renamed = html_escape(&renamed),
renamed_note = renamed_note, renamed_note = renamed_note,
+186 -102
View File
@@ -2,6 +2,7 @@
//! force-disconnect (queues a `heartbeat_commands` row consumed on the //! force-disconnect (queues a `heartbeat_commands` row consumed on the
//! peer's next /api/heartbeat tick) and force-sysinfo refresh. //! 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::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -21,25 +22,30 @@ const ONLINE_THRESHOLD_SECS: i64 = 45;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
let table = render_table(&state).await?; let table = render_table(&state, lang).await?;
Ok(Html(format!( Ok(Html(format!(
r##"<div class="space-y-6"> r##"<div class="space-y-6">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Devices</h2> <h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p> <p class="text-xs text-slate-500">{tagline}</p>
</header> </header>
<section id="devices-region"> <section id="devices-region">
{table} {table}
</section> </section>
</div>"## </div>"##,
heading = t(lang, "devices.heading"),
tagline = t(lang, "devices.tagline"),
table = table,
))) )))
} }
pub async fn force_disconnect( pub async fn force_disconnect(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>, Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -55,8 +61,9 @@ pub async fn force_disconnect(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table( notice_then_table(
&state, &state,
lang,
"ok", "ok",
&format!("Queued disconnect for {} (conns={})", peer_id, conns), &tf2(lang, "devices.queued_disconnect", &peer_id, &conns),
) )
.await .await
} }
@@ -64,6 +71,7 @@ pub async fn force_disconnect(
pub async fn force_sysinfo( pub async fn force_sysinfo(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>, Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -74,8 +82,9 @@ pub async fn force_sysinfo(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table( notice_then_table(
&state, &state,
lang,
"ok", "ok",
&format!("Queued sysinfo refresh for {}", peer_id), &tf1(lang, "devices.queued_sysinfo", &peer_id),
) )
.await .await
} }
@@ -83,6 +92,7 @@ pub async fn force_sysinfo(
pub async fn delete( pub async fn delete(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>, Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -92,11 +102,11 @@ pub async fn delete(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let msg = if ok { let msg = if ok {
format!("Deleted device {}.", peer_id) tf1(lang, "devices.deleted", &peer_id)
} else { } 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 /// Per-device detail page: hardware / OS inventory reported by hello-agent
@@ -107,6 +117,7 @@ pub async fn delete(
pub async fn detail( pub async fn detail(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>, Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -116,15 +127,17 @@ pub async fn detail(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let html = match row { let html = match row {
Some(d) => render_detail(&d), Some(d) => render_detail(lang, &d),
None => format!( None => format!(
r##"<div class="space-y-4"> r##"<div class="space-y-4">
{back} {back}
<div class="rounded border border-rose-700/50 bg-rose-900/30 p-3 text-sm text-rose-300"> <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>
</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), 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 let (total, devices) = state
.db .db
.devices_list_all(0, PAGE_SIZE) .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"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"> <thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr> <tr>
<th class="text-left font-medium px-3 py-2">Peer ID</th> <th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">Owner</th> <th class="text-left font-medium px-3 py-2">{c_owner}</th>
<th class="text-left font-medium px-3 py-2">Hostname</th> <th class="text-left font-medium px-3 py-2">{c_host}</th>
<th class="text-left font-medium px-3 py-2">User</th> <th class="text-left font-medium px-3 py-2">{c_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">{c_pwd}</th>
<th class="text-left font-medium px-3 py-2">OS</th> <th class="text-left font-medium px-3 py-2">{c_os}</th>
<th class="text-left font-medium px-3 py-2">Version</th> <th class="text-left font-medium px-3 py-2">{c_ver}</th>
<th class="text-left font-medium px-3 py-2">Last heartbeat</th> <th class="text-left font-medium px-3 py-2">{c_last}</th>
<th class="text-left font-medium px-3 py-2">Conns</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">Actions</th> <th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr> </tr>
</thead> </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() { if devices.is_empty() {
s.push_str( let _ = write!(
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##, 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 { for d in &devices {
render_device_row(&mut s, d, now); render_device_row(&mut s, lang, d, now);
} }
let _ = write!( let _ = write!(
s, s,
r##"</tbody> r##"</tbody>
</table> </table>
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div> <div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{count}</div>
</div>"## </div>"##,
count = tf1(lang, "devices.devices_count", &total.to_string()),
); );
Ok(s) 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 = let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
let pick = |k: &str| -> String { 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 { let (dot_class, tooltip) = if is_online {
( (
"bg-emerald-400", "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 { } 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 { } else {
( (
"bg-rose-500", "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 // 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"> <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" <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"> href="/admin/connect/{id}" target="_blank" rel="noopener">
Connect (web client) {connect_web}
</a> </a>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <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-get="/admin/pages/devices/{id}/detail"
hx-target="#devices-region" hx-swap="innerHTML"> hx-target="#devices-region" hx-swap="innerHTML">
Details &amp; inventory {details}
</button> </button>
<hr class="border-slate-700 my-1" /> <hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <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-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML" hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="Disconnect all active sessions on {id}?"> hx-confirm="{confirm_disc}">
Force disconnect {force_disc}
</button> </button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <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-post="/admin/pages/devices/{id}/sysinfo-refresh"
hx-target="#devices-region" hx-swap="innerHTML"> hx-target="#devices-region" hx-swap="innerHTML">
Force sysinfo refresh {force_sysinfo}
</button> </button>
<hr class="border-slate-700 my-1" /> <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" <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-post="/admin/pages/devices/{id}/delete"
hx-target="#devices-region" hx-swap="innerHTML" 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."> hx-confirm="{confirm_delete}">
Delete device {delete_device}
</button> </button>
</div> </div>
</details> </details>
@@ -347,7 +378,14 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
os = html_escape(&os), os = html_escape(&os),
ver = html_escape(&version_label), ver = html_escape(&version_label),
last = html_escape(&d.last_heartbeat_at), 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( pub async fn list_fragment(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; 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 /// "Back to devices" — refetches the devices table fragment via HTMX
/// and swaps it back into `#devices-region`. Used by the detail page. /// and swaps it back into `#devices-region`. Used by the detail page.
fn back_button() -> String { fn back_button(lang: Lang) -> String {
format!(
r##"<button class="text-xs text-sky-300 hover:text-sky-200" r##"<button class="text-xs text-sky-300 hover:text-sky-200"
hx-get="/admin/pages/devices/list-fragment" hx-get="/admin/pages/devices/list-fragment"
hx-target="#devices-region" hx-target="#devices-region"
hx-swap="innerHTML">&larr; Back to devices</button>"## hx-swap="innerHTML">{label}</button>"##,
.to_string() label = t(lang, "devices.back"),
)
} }
/// Pretty-print a JSON value for the inventory table cells. Strings are /// 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 = let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
let pick = |k: &str| -> String { let pick = |k: &str| -> String {
@@ -426,20 +467,29 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
let header = format!( let header = format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4"> r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
<div class="flex items-baseline justify-between"> <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> <span class="text-xs text-slate-500">UUID <code class="font-mono">{uuid}</code></span>
</div> </div>
<dl class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-sm md:grid-cols-3"> <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">{l_host}</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">{l_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">{l_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">{l_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">{l_os_rt}</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">{l_last}</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">{l_cpu_rt}</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_mem_rt}</dt><dd class="text-slate-200">{mem_rt}</dd></div>
</dl> </dl>
</div>"##, </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), id = html_escape(&d.id),
uuid = html_escape(&d.uuid), uuid = html_escape(&d.uuid),
host = html_escape(if hostname.is_empty() { "" } else { &hostname }), 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. // hello-agent's main.rs; absence means vanilla rustdesk.
let inventory_section = if agent_name == "HelloAgent" { let inventory_section = if agent_name == "HelloAgent" {
match parsed.get("inventory") { 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!( _ => format!(
r##"<div class="rounded-md border border-amber-700/50 bg-amber-900/20 p-3 text-sm text-amber-300"> 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 {msg}
uploads on the next sysinfo cycle (≤120 s). </div>"##,
</div>"## msg = t(lang, "devices.inventory_pending"),
), ),
} }
} else { } else {
format!( format!(
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400"> 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 {msg}
<span class="text-slate-200">{ident}</span>; the standard RustDesk client
does not collect hardware or BitLocker inventory.
</div>"##, </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"> r##"<div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
{back} {back}
<div class="text-xs text-slate-500">Detail view</div> <div class="text-xs text-slate-500">{detail_view}</div>
</div> </div>
{header} {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} {inv}
</div>"##, </div>"##,
back = back_button(), back = back_button(lang),
detail_view = t(lang, "devices.detail_view"),
inventory = t(lang, "devices.inventory"),
header = header, header = header,
inv = inventory_section, 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| { let row = |label: &str, key: &str| {
format!( format!(
r##"<tr class="border-b border-slate-800"> 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. // Disks need their own renderer — they're an array of objects.
let disks_html = match inv.get("disks") { let disks_html = match inv.get("disks") {
Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
let mut s = String::from( 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">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>"##, 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 { for disk in arr {
let name = fmt_inv_value(disk.get("name")); 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()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let bl_html = if bl_key_raw.is_empty() { 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 { } else {
format!( 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>"##, 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 nics_html = render_nics(lang, inv.get("network_interfaces"));
let wifi_html = render_wifi(inv.get("wifi_current"), inv.get("wifi_nearby")); let wifi_html = render_wifi(lang, inv.get("wifi_current"), inv.get("wifi_nearby"));
let public_ip_raw = inv let public_ip_raw = inv
.get("public_ip") .get("public_ip")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let public_ip_html = if public_ip_raw.is_empty() { 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 { } else {
format!( 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>"##, 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> </table>
</div> </div>
<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"> <div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2">
{disks} {disks}
</div> </div>
</div> </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} {nics}
</div> </div>
{wifi} {wifi}
<div> <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} {public_ip}
</div> </div>
<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} {bl}
</div> </div>
</div>"##, </div>"##,
sn = row("Serial number", "serial_number"), sn = row(t(lang, "devices.serial_number"), "serial_number"),
mfr = row("Manufacturer", "manufacturer"), mfr = row(t(lang, "devices.manufacturer"), "manufacturer"),
model = row("Model", "model"), model = row(t(lang, "devices.model"), "model"),
dom = row("Windows domain", "domain"), dom = row(t(lang, "devices.windows_domain"), "domain"),
os_d = row("OS distribution", "os_distro"), os_d = row(t(lang, "devices.os_distro"), "os_distro"),
os_r = row("OS release", "os_release"), os_r = row(t(lang, "devices.os_release"), "os_release"),
cpu_m = row("CPU model", "cpu_model"), cpu_m = row(t(lang, "devices.cpu_model"), "cpu_model"),
cpu_s = row("CPU speed (GHz)", "cpu_speed_ghz"), cpu_s = row(t(lang, "devices.cpu_speed"), "cpu_speed_ghz"),
cpu_pc = row("CPU physical cores", "cpu_cores_physical"), cpu_pc = row(t(lang, "devices.cpu_phys_cores"), "cpu_cores_physical"),
cpu_lc = row("CPU logical cores", "cpu_cores_logical"), cpu_lc = row(t(lang, "devices.cpu_logical_cores"), "cpu_cores_logical"),
ram = row("RAM (GB)", "ram_gb"), ram = row(t(lang, "devices.ram_gb"), "ram_gb"),
disks = disks_html, disks = disks_html,
nics = nics_html, nics = nics_html,
wifi = wifi_html, wifi = wifi_html,
public_ip = public_ip_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, 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). /// 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 /// 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. /// 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 { let arr = match nics {
Some(serde_json::Value::Array(a)) if !a.is_empty() => a, 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(); 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( 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">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>"##, 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 { for nic in arr {
let name = fmt_inv_value(nic.get("name")); 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 /// Returns an empty string when neither is present, so the surrounding
/// detail page can omit the heading entirely. /// detail page can omit the heading entirely.
fn render_wifi( fn render_wifi(
lang: Lang,
current: Option<&serde_json::Value>, current: Option<&serde_json::Value>,
nearby: Option<&serde_json::Value>, nearby: Option<&serde_json::Value>,
) -> String { ) -> 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"> <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">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">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">{l_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">{l_auth}</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">{l_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_rate}</dt><dd class="text-slate-300">{rate}</dd></div>
</dl> </dl>
</div>"##, </div>"##,
ssid = fmt_inv_value(c.get("ssid")), ssid = fmt_inv_value(c.get("ssid")),
@@ -738,18 +810,28 @@ fn render_wifi(
auth = fmt_inv_value(c.get("auth")), auth = fmt_inv_value(c.get("auth")),
cipher = fmt_inv_value(c.get("cipher")), cipher = fmt_inv_value(c.get("cipher")),
rate = rate_mbps, 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 { } 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 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"> 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> <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">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>"##, <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 { for net in arr {
let _ = write!( let _ = write!(
&mut s, &mut s,
@@ -768,10 +850,11 @@ fn render_wifi(
format!( format!(
r##"<div> 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} {current}
{nearby} {nearby}
</div>"##, </div>"##,
wifi_current = t(lang, "devices.wifi_current"),
current = current_html, current = current_html,
nearby = if nearby_html.is_empty() { nearby = if nearby_html.is_empty() {
String::new() String::new()
@@ -798,11 +881,12 @@ fn fmt_age(secs: i64) -> String {
async fn notice_then_table( async fn notice_then_table(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
kind: &str, kind: &str,
msg: &str, msg: &str,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg); 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)) Ok(Html(html))
} }
+75 -40
View File
@@ -3,6 +3,7 @@
//! the canonical place to manage who can see whose devices. //! the canonical place to manage who can see whose devices.
use super::shared::{html_escape, notice_html, require_admin}; 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::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -15,9 +16,10 @@ use std::sync::Arc;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
Ok(Html(render_full(&state).await?)) Ok(Html(render_full(&state, lang).await?))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -28,23 +30,25 @@ pub struct CreateForm {
pub async fn create( pub async fn create(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Form(form): Form<CreateForm>, Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if form.name.trim().is_empty() { 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 state
.db .db
.device_group_create(form.name.trim()) .device_group_create(form.name.trim())
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn delete(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -55,8 +59,9 @@ pub async fn delete(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then( notice_then(
&state, &state,
lang,
if ok { "ok" } else { "error" }, 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 .await
} }
@@ -69,6 +74,7 @@ pub struct MemberForm {
pub async fn add_member( pub async fn add_member(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
Form(form): Form<MemberForm>, Form(form): Form<MemberForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
@@ -78,12 +84,13 @@ pub async fn add_member(
.device_group_add_member(id, form.user_id) .device_group_add_member(id, form.user_id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn remove_member(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path((id, user_id)): Path<(i64, i64)>, Path((id, user_id)): Path<(i64, i64)>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -92,7 +99,7 @@ pub async fn remove_member(
.device_group_remove_member(id, user_id) .device_group_remove_member(id, user_id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state).await?)) Ok(Html(render_full(&state, lang).await?))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -103,13 +110,14 @@ pub struct PeerForm {
pub async fn add_peer( pub async fn add_peer(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
Form(form): Form<PeerForm>, Form(form): Form<PeerForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
let peer_id = form.peer_id.trim(); let peer_id = form.peer_id.trim();
if peer_id.is_empty() { 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 let exists = state
.db .db
@@ -119,8 +127,9 @@ pub async fn add_peer(
if !exists { if !exists {
return notice_then( return notice_then(
&state, &state,
lang,
"error", "error",
&format!("No device '{}' has reported in yet.", peer_id), &tf1(lang, "groups.no_device_yet", peer_id),
) )
.await; .await;
} }
@@ -129,12 +138,13 @@ pub async fn add_peer(
.device_group_add_peer(id, peer_id) .device_group_add_peer(id, peer_id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn remove_peer(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path((id, peer_id)): Path<(i64, String)>, Path((id, peer_id)): Path<(i64, String)>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -143,7 +153,7 @@ pub async fn remove_peer(
.device_group_remove_peer(id, &peer_id) .device_group_remove_peer(id, &peer_id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state).await?)) Ok(Html(render_full(&state, lang).await?))
} }
// ---------- rendering ---------- // ---------- rendering ----------
@@ -169,15 +179,16 @@ fn url_encode(s: &str) -> String {
async fn notice_then( async fn notice_then(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
kind: &str, kind: &str,
msg: &str, msg: &str,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg); 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)) 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 let groups = state
.db .db
.device_groups_list_all() .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()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new(); let mut s = String::new();
s.push_str( let _ = write!(
s,
r##"<div id="groups-region" class="space-y-6"> 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"> <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"> <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"/> <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> <button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
</form> </form>
</section> </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() { if groups.is_empty() {
s.push_str( let _ = write!(
r##"<p class="text-slate-500 text-sm">No device groups yet.</p>"##, s,
r##"<p class="text-slate-500 text-sm">{}</p>"##,
t(lang, "groups.no_groups"),
); );
} }
for g in &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> <h3 class="font-semibold">{name}</h3>
<button class="text-xs text-rose-400 hover:text-rose-300" <button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{id}/delete" hx-post="/admin/pages/groups/{id}/delete"
hx-confirm="Delete group {name}? Members aren't deleted; just unassigned." hx-confirm="{confirm}"
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button> hx-target="#groups-region" hx-swap="outerHTML">{delete_group}</button>
</header> </header>
<div> <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">"##, <ul class="text-sm divide-y divide-slate-800">"##,
id = g.id, 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() { if members.is_empty() {
s.push_str( let _ = write!(
r##"<li class="py-2 text-slate-500 text-xs">No user members yet.</li>"##, s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "groups.no_user_members"),
); );
} }
for u in &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> <span class="text-slate-200">{username}</span>
<button class="text-xs text-slate-400 hover:text-rose-300" <button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/members/{uid}/remove" 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>"##, </li>"##,
username = html_escape(&u.username), username = html_escape(&u.username),
gid = g.id, gid = g.id,
uid = u.id uid = u.id,
remove = t(lang, "common.remove"),
); );
} }
s.push_str("</ul>"); s.push_str("</ul>");
@@ -278,10 +302,12 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
username = html_escape(&u.username) username = html_escape(&u.username)
); );
} }
s.push_str( let _ = write!(
s,
r##"</select> 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>"##, </form>"##,
add_user = t(lang, "groups.add_user"),
); );
} }
s.push_str("</div>"); s.push_str("</div>");
@@ -290,21 +316,27 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
let _ = write!( let _ = write!(
s, s,
r##"<div> r##"<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Devices</h4> <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">"## <ul class="text-sm divide-y divide-slate-800">"##,
devices = t(lang, "groups.devices_section"),
); );
if peer_members.is_empty() { if peer_members.is_empty() {
s.push_str( let _ = write!(
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>"##, 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 { for (peer_id, owner) in &peer_members {
let owner_label = if owner.is_empty() { 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 { } else {
format!( format!(
r##"<span class="text-slate-500">owner: {}</span>"##, r##"<span class="text-slate-500">{}</span>"##,
html_escape(owner) html_escape(&tf1(lang, "groups.owner_label", owner)),
) )
}; };
let _ = write!( let _ = write!(
@@ -315,13 +347,14 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
{owner_label} {owner_label}
<button class="text-slate-400 hover:text-rose-300" <button class="text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/peers/{pid_url}/remove" 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> </span>
</li>"##, </li>"##,
pid = html_escape(peer_id), pid = html_escape(peer_id),
pid_url = url_encode(peer_id), pid_url = url_encode(peer_id),
gid = g.id, gid = g.id,
owner_label = owner_label, owner_label = owner_label,
remove = t(lang, "common.remove"),
); );
} }
s.push_str("</ul>"); 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" r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/peers/add" hx-post="/admin/pages/groups/{id}/peers/add"
hx-target="#groups-region" hx-swap="outerHTML"> 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"/> 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>"##, </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>"); s.push_str("</section>");
+122 -54
View File
@@ -16,6 +16,7 @@
//! is written to `user_totp_secrets`. This means a half-finished enroll //! is written to `user_totp_secrets`. This means a half-finished enroll
//! (user closes the tab) leaves no garbage state. //! (user closes the tab) leaves no garbage state.
use crate::api::admin::i18n::{t, Lang};
use crate::api::error::ApiError; use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -32,8 +33,9 @@ use totp_rs::Secret;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
user: AuthedUser, user: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> 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 ---------- // ---------- update profile info ----------
@@ -49,6 +51,7 @@ pub struct InfoForm {
pub async fn update_info( pub async fn update_info(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
user: AuthedUser, user: AuthedUser,
lang: Lang,
Form(form): Form<InfoForm>, Form(form): Form<InfoForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
state state
@@ -62,7 +65,13 @@ pub async fn update_info(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html( 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( pub async fn change_password(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
user: AuthedUser, user: AuthedUser,
lang: Lang,
Form(form): Form<PasswordForm>, Form(form): Form<PasswordForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let row = state let row = state
@@ -90,11 +100,9 @@ pub async fn change_password(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(( Some(("error", t(lang, "profile.password_oidc_change"))),
"error",
"Your account signs in via the identity provider — change the password there.",
)),
) )
.await?, .await?,
)); ));
@@ -103,8 +111,9 @@ pub async fn change_password(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(("error", "New password must be at least 4 characters.")), Some(("error", t(lang, "profile.password_min"))),
) )
.await?, .await?,
)); ));
@@ -113,8 +122,9 @@ pub async fn change_password(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(("error", "New password and confirmation don't match.")), Some(("error", t(lang, "profile.password_mismatch"))),
) )
.await?, .await?,
)); ));
@@ -126,8 +136,9 @@ pub async fn change_password(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(("error", "Current password is incorrect.")), Some(("error", t(lang, "profile.current_incorrect"))),
) )
.await?, .await?,
)); ));
@@ -141,7 +152,13 @@ pub async fn change_password(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html( 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( pub async fn totp_start(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
user: AuthedUser, user: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
// Reject if the user already has TOTP — they should remove it first. // Reject if the user already has TOTP — they should remove it first.
let already = state let already = state
@@ -164,8 +182,9 @@ pub async fn totp_start(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &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?, .await?,
)); ));
@@ -185,6 +204,7 @@ pub async fn totp_start(
Ok(Html(render_totp_enroll_panel( Ok(Html(render_totp_enroll_panel(
&state, &state,
lang,
&user, &user,
&secret_b32, &secret_b32,
&qr_svg, &qr_svg,
@@ -204,6 +224,7 @@ pub struct TotpConfirmForm {
pub async fn totp_confirm( pub async fn totp_confirm(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
user: AuthedUser, user: AuthedUser,
lang: Lang,
Form(form): Form<TotpConfirmForm>, Form(form): Form<TotpConfirmForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let code = form.code.trim(); let code = form.code.trim();
@@ -212,8 +233,9 @@ pub async fn totp_confirm(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(("error", "Missing secret in confirm form.")), Some(("error", t(lang, "profile.tfa_missing_secret"))),
) )
.await?, .await?,
)); ));
@@ -236,10 +258,11 @@ pub async fn totp_confirm(
return Ok(Html( return Ok(Html(
render_totp_enroll_panel( render_totp_enroll_panel(
&state, &state,
lang,
&user, &user,
secret, secret,
&qr_svg, &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?, .await?,
)); ));
@@ -252,8 +275,9 @@ pub async fn totp_confirm(
Ok(Html( Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(("ok", "TOTP enrolled. Future sign-ins will require a 6-digit code.")), Some(("ok", t(lang, "profile.tfa_enrolled"))),
) )
.await?, .await?,
)) ))
@@ -269,6 +293,7 @@ pub struct TotpRemoveForm {
pub async fn totp_remove( pub async fn totp_remove(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
user: AuthedUser, user: AuthedUser,
lang: Lang,
Form(form): Form<TotpRemoveForm>, Form(form): Form<TotpRemoveForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let row = state let row = state
@@ -284,8 +309,9 @@ pub async fn totp_remove(
return Ok(Html( return Ok(Html(
render_full_page( render_full_page(
&state, &state,
lang,
&user, &user,
Some(("error", "Current password is incorrect — TOTP not removed.")), Some(("error", t(lang, "profile.tfa_current_pw_incorrect"))),
) )
.await?, .await?,
)); ));
@@ -296,7 +322,13 @@ pub async fn totp_remove(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html( 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( async fn render_full_page(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
user: &AuthedUser, user: &AuthedUser,
notice: Option<(&str, &str)>, notice: Option<(&str, &str)>,
) -> Result<String, ApiError> { ) -> 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 — /// `totp_panel_override = Some(html)` swaps in a custom TOTP block —
@@ -316,6 +349,7 @@ async fn render_full_page(
/// stay visible above it. /// stay visible above it.
async fn render_full_page_with_totp_override( async fn render_full_page_with_totp_override(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
user: &AuthedUser, user: &AuthedUser,
notice: Option<(&str, &str)>, notice: Option<(&str, &str)>,
totp_panel_override: Option<String>, 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. // local TOTP both moot. Replace those sections with a short note.
let oidc_linked = row.is_oidc_linked(); let oidc_linked = row.is_oidc_linked();
let password_section = if oidc_linked { let password_section = if oidc_linked {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> 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> <h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
<p class="text-sm text-slate-400"> <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> </p>
</section>"## </section>"##,
.to_string() heading = t(lang, "profile.password_oidc"),
msg = t(lang, "profile.password_oidc_msg"),
)
} else { } else {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> 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> <h3 class="text-sm font-semibold text-slate-300 mb-3">{heading}</h3>
<form <form
class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm" class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm"
hx-post="/admin/pages/profile/change-password" hx-post="/admin/pages/profile/change-password"
@@ -356,69 +394,89 @@ async fn render_full_page_with_totp_override(
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()" 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="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 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}" 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"/> <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">Update password</button> <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> </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 { let totp_section = if let Some(panel) = totp_panel_override {
panel panel
} else if oidc_linked { } else if oidc_linked {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> 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>
<p class="text-sm text-slate-400"> <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> </p>
</section>"## </section>"##,
.to_string() heading = t(lang, "profile.tfa"),
msg = t(lang, "profile.tfa_oidc_msg"),
)
} else if has_totp { } else if has_totp {
format!( format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> 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"> <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="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="text-xs text-slate-400">{tfa_msg}</span>
</div> </div>
<form <form
class="flex gap-2 items-center text-sm" class="flex gap-2 items-center text-sm"
hx-post="/admin/pages/profile/totp/remove" hx-post="/admin/pages/profile/totp/remove"
hx-target="#main" hx-target="#main"
hx-swap="innerHTML" 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"/> <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">Disable TOTP</button> <button class="bg-rose-700 hover:bg-rose-600 rounded px-3 py-1.5 text-white text-xs">{btn}</button>
</form> </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 { } else {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> 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>
<p class="text-sm text-slate-400 mb-3"> <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> </p>
<button <button
class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm" 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-post="/admin/pages/profile/totp/start"
hx-target="#main" hx-target="#main"
hx-swap="innerHTML" hx-swap="innerHTML"
>Enroll TOTP</button> >{btn}</button>
</section>"##.to_string() </section>"##,
heading = t(lang, "profile.tfa"),
intro = t(lang, "profile.tfa_intro"),
btn = t(lang, "profile.tfa_enroll"),
)
}; };
Ok(format!( Ok(format!(
r##"<div class="space-y-6 max-w-3xl"> r##"<div class="space-y-6 max-w-3xl">
<header> <header>
<h2 class="text-lg font-semibold">Profile</h2> <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> <p class="text-xs text-slate-500 mt-0.5">{signed_in_as} <span class="text-slate-300">{username}</span></p>
</header> </header>
{notice} {notice}
<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> <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 <form
class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm" class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
hx-post="/admin/pages/profile/update-info" hx-post="/admin/pages/profile/update-info"
@@ -426,14 +484,14 @@ async fn render_full_page_with_totp_override(
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<label class="block"> <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"/> <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>
<label class="block"> <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"/> <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> </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> </form>
</section> </section>
@@ -441,6 +499,12 @@ async fn render_full_page_with_totp_override(
{totp_section} {totp_section}
</div>"##, </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), username = html_escape(&user.name),
display_name = html_escape(&row.display_name), display_name = html_escape(&row.display_name),
email = html_escape(&row.email), email = html_escape(&row.email),
@@ -452,6 +516,7 @@ async fn render_full_page_with_totp_override(
async fn render_totp_enroll_panel( async fn render_totp_enroll_panel(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
user: &AuthedUser, user: &AuthedUser,
secret_b32: &str, secret_b32: &str,
qr_svg: &str, qr_svg: &str,
@@ -459,16 +524,15 @@ async fn render_totp_enroll_panel(
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
let panel = format!( let panel = format!(
r##"<section class="rounded-md border border-sky-700/60 bg-sky-900/20 p-4"> 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"> <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. {intro}
Nothing is enrolled until you submit a valid code.
</p> </p>
<div class="flex flex-col sm:flex-row gap-4 items-start"> <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="bg-white p-2 rounded inline-block">{qr}</div>
<div class="flex-1 space-y-2 text-sm"> <div class="flex-1 space-y-2 text-sm">
<div> <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> <code class="block mt-1 break-all text-emerald-200 bg-slate-950 px-2 py-1.5 rounded text-xs">{secret}</code>
</div> </div>
<form <form
@@ -489,15 +553,19 @@ async fn render_totp_enroll_panel(
placeholder="123456" placeholder="123456"
class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 w-32 font-mono tracking-widest" 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> </form>
</div> </div>
</div> </div>
</section>"##, </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, qr = qr_svg,
secret = html_escape(secret_b32), 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 { fn render_qr_svg(payload: &str) -> String {
+54 -24
View File
@@ -3,6 +3,7 @@
//! full assignment matrix UI is a follow-up. //! full assignment matrix UI is a follow-up.
use super::shared::{html_escape, notice_html, require_admin}; 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::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -15,9 +16,10 @@ use std::sync::Arc;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
Ok(Html(render_full(&state).await?)) Ok(Html(render_full(&state, lang).await?))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -30,11 +32,12 @@ pub struct CreateForm {
pub async fn create( pub async fn create(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Form(form): Form<CreateForm>, Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if form.name.trim().is_empty() { 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() { let cfg = if form.config_options_json.trim().is_empty() {
"{}".to_string() "{}".to_string()
@@ -44,9 +47,17 @@ pub async fn create(
match serde_json::from_str::<serde_json::Value>(&form.config_options_json) { match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(), Ok(v) if v.is_object() => form.config_options_json.clone(),
Ok(_) => { 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 state
@@ -56,8 +67,9 @@ pub async fn create(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then( notice_then(
&state, &state,
lang,
"ok", "ok",
&format!("Strategy '{}' created.", form.name), &tf1(lang, "strategies.created", &form.name),
) )
.await .await
} }
@@ -70,6 +82,7 @@ pub struct UpdateForm {
pub async fn update( pub async fn update(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
Form(form): Form<UpdateForm>, Form(form): Form<UpdateForm>,
) -> Result<Html<String>, ApiError> { ) -> 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) { 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(), 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 state
@@ -85,12 +98,13 @@ pub async fn update(
.strategy_update_config(id, &cfg) .strategy_update_config(id, &cfg)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn delete(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -101,8 +115,9 @@ pub async fn delete(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then( notice_then(
&state, &state,
lang,
if ok { "ok" } else { "error" }, 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 .await
} }
@@ -111,40 +126,51 @@ pub async fn delete(
async fn notice_then( async fn notice_then(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
kind: &str, kind: &str,
msg: &str, msg: &str,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg); 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)) 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 let strategies = state
.db .db
.strategies_list_all() .strategies_list_all()
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new(); let mut s = String::new();
s.push_str( let _ = write!(
s,
r##"<div id="strategies-region" class="space-y-6"> r##"<div id="strategies-region" class="space-y-6">
<header> <header>
<h2 class="text-lg font-semibold">Strategies</h2> <h2 class="text-lg font-semibold">{heading}</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> <p class="text-xs text-slate-500 mt-1">{tagline}</p>
</header> </header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> <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"> <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"/> <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": ""}' <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> 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> </form>
</section> </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() { 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 { for str_ in &strategies {
let _ = write!( let _ = write!(
@@ -153,26 +179,30 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<div> <div>
<h3 class="font-semibold">{name}</h3> <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> </div>
<button class="text-xs text-rose-400 hover:text-rose-300" <button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{id}/delete" hx-post="/admin/pages/strategies/{id}/delete"
hx-confirm="Delete strategy {name}? Assignments will be cleaned up too." hx-confirm="{confirm}"
hx-target="#strategies-region" hx-swap="outerHTML">Delete</button> hx-target="#strategies-region" hx-swap="outerHTML">{delete}</button>
</header> </header>
<form class="space-y-2 text-sm" <form class="space-y-2 text-sm"
hx-post="/admin/pages/strategies/{id}/update" hx-post="/admin/pages/strategies/{id}/update"
hx-target="#strategies-region" hx-swap="outerHTML"> 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" <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> 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> </form>
</section>"##, </section>"##,
id = str_.id, id = str_.id,
name = html_escape(&str_.name), 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), 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>"); s.push_str("</div>");
+148 -99
View File
@@ -1,6 +1,7 @@
//! Users page — list / create / set-password / toggle-admin / toggle-status //! Users page — list / create / set-password / toggle-admin / toggle-status
//! / TOTP enroll-unenroll / delete. //! / TOTP enroll-unenroll / delete.
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError; use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser; use crate::api::middleware::AuthedUser;
use crate::api::state::AppState; use crate::api::state::AppState;
@@ -20,9 +21,10 @@ const PAGE_SIZE: i64 = 50;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
Ok(Html(render_full_page(&state).await?)) Ok(Html(render_full_page(&state, lang).await?))
} }
// ---------- create ---------- // ---------- create ----------
@@ -43,19 +45,15 @@ pub struct CreateForm {
pub async fn create( pub async fn create(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Form(form): Form<CreateForm>, Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if form.username.trim().is_empty() { 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 { if form.password.len() < 4 {
return notice_then_table( return notice_then_table(&state, lang, "error", t(lang, "users.password_min")).await;
&state,
"error",
"Password must be at least 4 characters",
)
.await;
} }
let hash = hash_password(form.password) let hash = hash_password(form.password)
.await .await
@@ -73,7 +71,7 @@ pub async fn create(
if !form.email.trim().is_empty() { if !form.email.trim().is_empty() {
let _ = set_email_inline(&state, id, form.email.trim()).await; 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( async fn set_email_inline(
@@ -101,6 +99,7 @@ pub struct UpdateInfoForm {
pub async fn update_info( pub async fn update_info(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
Form(form): Form<UpdateInfoForm>, Form(form): Form<UpdateInfoForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
@@ -115,7 +114,7 @@ pub async fn update_info(
.raw_update_user_email(id, form.email.trim()) .raw_update_user_email(id, form.email.trim())
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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)] #[derive(Debug, Deserialize)]
@@ -126,6 +125,7 @@ pub struct PasswordResetForm {
pub async fn reset_password( pub async fn reset_password(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
Form(form): Form<PasswordResetForm>, Form(form): Form<PasswordResetForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
@@ -141,20 +141,10 @@ pub async fn reset_password(
.map_err(|e| ApiError::Internal(e.to_string()))? .map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
if target.is_oidc_linked() { if target.is_oidc_linked() {
return notice_then_table( return notice_then_table(&state, lang, "error", t(lang, "users.oidc_password_disabled")).await;
&state,
"error",
"This account is linked to OIDC — set the password at the identity provider instead.",
)
.await;
} }
if form.password.len() < 4 { if form.password.len() < 4 {
return notice_then_table( return notice_then_table(&state, lang, "error", t(lang, "users.password_min")).await;
&state,
"error",
"Password must be at least 4 characters",
)
.await;
} }
let hash = hash_password(form.password) let hash = hash_password(form.password)
.await .await
@@ -166,8 +156,9 @@ pub async fn reset_password(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table( notice_then_table(
&state, &state,
lang,
if ok { "ok" } else { "error" }, 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 .await
} }
@@ -175,16 +166,12 @@ pub async fn reset_password(
pub async fn toggle_admin( pub async fn toggle_admin(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if id == admin.user_id { if id == admin.user_id {
return notice_then_table( return notice_then_table(&state, lang, "error", t(lang, "users.cant_revoke_self")).await;
&state,
"error",
"You can't revoke your own admin flag here. Edit another admin's row instead.",
)
.await;
} }
let user = state let user = state
.db .db
@@ -197,22 +184,18 @@ pub async fn toggle_admin(
.user_set_admin(id, !user.is_admin) .user_set_admin(id, !user.is_admin)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn toggle_status(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if id == admin.user_id { if id == admin.user_id {
return notice_then_table( return notice_then_table(&state, lang, "error", t(lang, "users.cant_disable_self")).await;
&state,
"error",
"You can't disable your own account from here.",
)
.await;
} }
let user = state let user = state
.db .db
@@ -226,22 +209,18 @@ pub async fn toggle_status(
.user_set_status(id, new_status) .user_set_status(id, new_status)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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( pub async fn delete(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
if id == admin.user_id { if id == admin.user_id {
return notice_then_table( return notice_then_table(&state, lang, "error", t(lang, "users.cant_delete_self")).await;
&state,
"error",
"You can't delete the account you're signed in with.",
)
.await;
} }
let ok = state let ok = state
.db .db
@@ -250,8 +229,9 @@ pub async fn delete(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table( notice_then_table(
&state, &state,
lang,
if ok { "ok" } else { "error" }, 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 .await
} }
@@ -261,6 +241,7 @@ pub async fn delete(
pub async fn totp_enroll( pub async fn totp_enroll(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -286,26 +267,30 @@ pub async fn totp_enroll(
); );
let mut html = format!( let mut html = format!(
r##"<div class="rounded border border-emerald-700/50 bg-emerald-900/30 p-4 mb-4 text-sm"> 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 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">{secret_label}</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">{url_label}</span> <code class="text-emerald-200 break-all">{otpauth}</code></div>
<div class="text-xs text-slate-400 pt-1"> <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> </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), secret = html_escape(&secret_b32),
otpauth = html_escape(&otpauth), 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)) Ok(Html(html))
} }
pub async fn totp_unenroll( pub async fn totp_unenroll(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
@@ -316,8 +301,9 @@ pub async fn totp_unenroll(
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table( notice_then_table(
&state, &state,
lang,
if removed { "ok" } else { "error" }, 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 .await
} }
@@ -326,24 +312,25 @@ pub async fn totp_unenroll(
async fn notice_then_table( async fn notice_then_table(
state: &Arc<AppState>, state: &Arc<AppState>,
lang: Lang,
kind: &str, kind: &str,
msg: &str, msg: &str,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg); 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)) Ok(Html(html))
} }
async fn render_full_page(state: &Arc<AppState>) -> Result<String, ApiError> { async fn render_full_page(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let table = render_table(state).await?; let table = render_table(state, lang).await?;
Ok(format!( Ok(format!(
r##"<div class="space-y-6"> r##"<div class="space-y-6">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Users</h2> <h2 class="text-lg font-semibold">{heading}</h2>
</header> </header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4"> <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 <form
class="grid grid-cols-1 sm:grid-cols-6 gap-3 text-sm" class="grid grid-cols-1 sm:grid-cols-6 gap-3 text-sm"
hx-post="/admin/pages/users/create" 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-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()" 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="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="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="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"/> <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> <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> <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> </form>
</section> </section>
<section id="users-region"> <section id="users-region">
{table} {table}
</section> </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 let (_total, users) = state
.db .db
.users_list_all(0, PAGE_SIZE) .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 // No `overflow-hidden` on the table wrapper: the per-row action menu is
// an absolutely-positioned `<details>` popover inside a <td>, and the // an absolutely-positioned `<details>` popover inside a <td>, and the
// wrapper's clipping was hiding the bottom half of the menu. // 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"> r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"> <thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr> <tr>
<th class="text-left font-medium px-3 py-2">Username</th> <th class="text-left font-medium px-3 py-2">{c_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">{c_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">{c_email}</th>
<th class="text-left font-medium px-3 py-2">Status</th> <th class="text-left font-medium px-3 py-2">{c_status}</th>
<th class="text-left font-medium px-3 py-2">Admin</th> <th class="text-left font-medium px-3 py-2">{c_admin}</th>
<th class="text-left font-medium px-3 py-2">TOTP</th> <th class="text-left font-medium px-3 py-2">{c_totp}</th>
<th class="text-left font-medium px-3 py-2">Last seen</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">Actions</th> <th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800">"##, <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 { for u in &users {
render_user_row( render_user_row(
&mut s, &mut s,
lang,
u, u,
*totp.get(&u.id).unwrap_or(&false), *totp.get(&u.id).unwrap_or(&false),
last_seen.get(&u.id).map(String::as_str), 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) 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 { 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>"#, 1 => format!(
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>"#, 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>"#,
-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>"#, 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 { 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 { } else {
"" String::new()
}; };
// The TOTP column doubles as an "auth path" indicator: OIDC-linked // The TOTP column doubles as an "auth path" indicator: OIDC-linked
// users get an "OIDC" badge (their MFA lives at the IdP — local // users get an "OIDC" badge (their MFA lives at the IdP — local
// TOTP is moot), and OIDC takes precedence over local TOTP if both // TOTP is moot), and OIDC takes precedence over local TOTP if both
// somehow exist. // somehow exist.
let totp_badge = if u.is_oidc_linked() { 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 { } 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 { } else {
"" String::new()
}; };
let oidc_linked = u.is_oidc_linked(); let oidc_linked = u.is_oidc_linked();
// OIDC-linked users sign in via the IdP — adding a local password // OIDC-linked users sign in via the IdP — adding a local password
// would let them bypass the IdP (and any MFA enforced there). Show // would let them bypass the IdP (and any MFA enforced there). Show
// a note instead of the password-reset form for these accounts. // a note instead of the password-reset form for these accounts.
let password_form = if oidc_linked { let password_form = if oidc_linked {
format!(
r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded"> 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. {msg}
</div>"## </div>"##,
.to_string() msg = t(lang, "users.oidc_disabled"),
)
} else { } else {
format!( format!(
r##"<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML"> 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"/> <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> <button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">{set}</button>
</form>"##, </form>"##,
id = u.id, 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 { let (last_seen_rel, last_seen_abs) = match last_seen {
Some(ts) => (relative_ts(ts), html_escape(ts)), 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 // TOTP enrollment is self-service (the user does it on their
// profile page so they can scan the QR + verify a code before // 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!( format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" 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-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."> hx-confirm="{confirm}">
Disable TOTP {label}
</button>"##, </button>"##,
id = u.id, 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 { } else {
String::new() 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> <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"> <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"> <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="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="email" 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> <button class="w-full bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">{save_profile}</button>
</form> </form>
{password_form} {password_form}
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <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} {totp_button}
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded" <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-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"> hx-target="#users-region" hx-swap="innerHTML">
Delete user {delete_user}
</button> </button>
</div> </div>
</details> </details>
@@ -528,15 +560,32 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Optio
status = status_badge, status = status_badge,
admin = admin_badge, admin = admin_badge,
totp = totp_badge, totp = totp_badge,
admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" }, ph_dn = t(lang, "users.display_name"),
status_label = if u.status == 1 { "Disable user" } else { "Enable user" }, 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, totp_button = totp_button,
last_seen_rel = last_seen_rel, last_seen_rel = last_seen_rel,
last_seen_abs = last_seen_abs, last_seen_abs = last_seen_abs,
password_form = password_form, 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", /// Format a SQLite `current_timestamp` string ("YYYY-MM-DD HH:MM:SS",
/// always UTC) as a relative time-ago label. Renders short forms — "5m /// 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 /// ago", "3h ago", "2d ago" — for the at-a-glance column; the absolute