//! Admin dashboard router. Mounted at `/admin/*` by `api::router` when //! the operator hasn't disabled it via `--admin-ui-dir=` (empty). //! //! Static HTML/CSS lives in `admin_ui/` next to the source tree and is //! embedded into the binary at build time via `include_str!` — no separate //! deploy artifact, no ServeDir wildcard route conflicting with the //! literal /admin/login etc. The ASSETS table at the bottom is the //! authoritative list of files we ship. //! //! Layout served at runtime: //! /admin/ ← index.html (the SPA shell) //! /admin/login.html ← login form //! /admin/login POST handler (form-encoded, sets session cookie) //! /admin/logout POST handler (clears session cookie) //! /admin/me GET fragment (current user, sidebar widget) //! /admin/pages/* GET fragments (one per page) pub mod auth; pub mod i18n; pub mod me; pub mod oidc_login; pub mod pages; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; use std::sync::Arc; use crate::api::admin::i18n::{lang_from_headers, t, Lang}; /// Files embedded into the binary. Paths are relative to this source file /// per `include_str!`. Adding a new HTML asset = one new entry here. const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html"); const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html"); /// Third-party JS dependencies vendored under `admin_ui/assets/` so the /// dashboard doesn't fetch from cdn.tailwindcss.com / unpkg.com at runtime. /// See docs/CONFIGURATION.md "Web client" for the upgrade procedure. const TAILWIND_JS: &[u8] = include_bytes!("../../../admin_ui/assets/tailwindcss.js"); const HTMX_JS: &[u8] = include_bytes!("../../../admin_ui/assets/htmx.min.js"); pub fn build(state: Arc) -> Option { if state.cfg.admin_ui_dir.is_empty() { // Operator opted out by setting the flag to empty. return None; } let r = Router::new() // Static HTML pages — explicit routes per file, no wildcard. .route("/admin", get(serve_index)) .route("/admin/", get(serve_index)) .route("/admin/index.html", get(serve_index)) .route("/admin/login.html", get(serve_login)) // Vendored third-party JS — versions pinned in source, so we can // cache aggressively (immutable + 1-year max-age). .route("/admin/assets/tailwindcss.js", get(serve_tailwind)) .route("/admin/assets/htmx.min.js", get(serve_htmx)) // Dynamic dashboard endpoints. .route("/admin/login", post(auth::login)) .route("/admin/logout", post(auth::logout)) .route("/admin/me", get(me::me)) // OIDC entry points consumed by login.html (unauthenticated — they // *initiate* a sign-in). The matching /oidc/callback is mounted by // the public api router and finishes both desktop and admin flows. .route("/admin/oidc/providers", get(oidc_login::list_providers)) .route("/admin/login/oidc/:provider", get(oidc_login::start_login)) // Page fragments — one per sidebar entry. .route("/admin/pages/users", get(pages::users::index)) .route("/admin/pages/users/create", post(pages::users::create)) .route( "/admin/pages/users/:id/update-info", post(pages::users::update_info), ) .route( "/admin/pages/users/:id/password-reset", post(pages::users::reset_password), ) .route( "/admin/pages/users/:id/toggle-admin", post(pages::users::toggle_admin), ) .route( "/admin/pages/users/:id/toggle-status", post(pages::users::toggle_status), ) .route( "/admin/pages/users/:id/totp-enroll", post(pages::users::totp_enroll), ) .route( "/admin/pages/users/:id/totp-unenroll", post(pages::users::totp_unenroll), ) .route("/admin/pages/users/:id/delete", post(pages::users::delete)) // Devices .route( "/admin/pages/devices/list-fragment", get(pages::devices::list_fragment), ) .route( "/admin/pages/devices/:peer_id/detail", get(pages::devices::detail), ) .route( "/admin/pages/devices/:peer_id/disconnect", post(pages::devices::force_disconnect), ) .route( "/admin/pages/devices/:peer_id/sysinfo-refresh", post(pages::devices::force_sysinfo), ) .route( "/admin/pages/devices/:peer_id/delete", post(pages::devices::delete), ) // Groups .route("/admin/pages/groups/create", post(pages::groups::create)) .route("/admin/pages/groups/:id/delete", post(pages::groups::delete)) .route( "/admin/pages/groups/:id/members/add", post(pages::groups::add_member), ) .route( "/admin/pages/groups/:id/members/:user_id/remove", post(pages::groups::remove_member), ) .route( "/admin/pages/groups/:id/peers/add", post(pages::groups::add_peer), ) .route( "/admin/pages/groups/:id/peers/:peer_id/remove", post(pages::groups::remove_peer), ) // Strategies .route( "/admin/pages/strategies/create", post(pages::strategies::create), ) .route( "/admin/pages/strategies/:id/update", post(pages::strategies::update), ) .route( "/admin/pages/strategies/:id/delete", post(pages::strategies::delete), ) .route( "/admin/pages/strategies/:id/assignments/group", post(pages::strategies::assign_group), ) .route( "/admin/pages/strategies/:id/assignments/peer", post(pages::strategies::assign_peer), ) .route( "/admin/pages/strategies/:id/assignments/:assignment_id/delete", post(pages::strategies::unassign), ) .route("/admin/pages/deploy", get(pages::deploy::index)) .route( "/admin/pages/deploy/generate", post(pages::deploy::generate), ) // Web client (M6) — full-page SPA, NOT an HTMX fragment. Mounted // outside /admin/pages/ because it's a standalone document the // operator opens in a new tab from the Devices action menu. .route( "/admin/connect/:peer_id", get(pages::connect::index), ) .route( "/admin/connect/assets/bundle.js", get(pages::connect::bundle_js), ) .route( "/admin/connect/assets/bundle.css", get(pages::connect::bundle_css), ) .route("/admin/pages/devices", get(pages::devices::index)) .route("/admin/pages/groups", get(pages::groups::index)) .route("/admin/pages/strategies", get(pages::strategies::index)) .route( "/admin/pages/address-books", get(pages::address_books::index), ) .route( "/admin/pages/address-books/create", post(pages::address_books::create), ) .route( "/admin/pages/address-books/:guid/delete", post(pages::address_books::delete), ) .route( "/admin/pages/address-books/:guid/manage", get(pages::address_books::manage), ) .route( "/admin/pages/address-books/:guid/shares/add", post(pages::address_books::share_add), ) .route( "/admin/pages/address-books/:guid/shares/:user_id/remove", post(pages::address_books::share_remove), ) // Self-service profile — cookie-only, no admin gate. .route("/admin/pages/profile", get(pages::profile::index)) .route( "/admin/pages/profile/update-info", post(pages::profile::update_info), ) .route( "/admin/pages/profile/change-password", post(pages::profile::change_password), ) .route( "/admin/pages/profile/totp/start", post(pages::profile::totp_start), ) .route( "/admin/pages/profile/totp/confirm", post(pages::profile::totp_confirm), ) .route( "/admin/pages/profile/totp/remove", post(pages::profile::totp_remove), ) .route("/admin/pages/audit", get(pages::audit::index)); hbb_common::log::info!( "admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)" ); Some(r) } async fn serve_index(headers: HeaderMap) -> Response { let lang = lang_from_headers(&headers); html_response_owned(render_index(lang)) } async fn serve_login(headers: HeaderMap) -> Response { let lang = lang_from_headers(&headers); html_response_owned(render_login(lang)) } /// Apply i18n placeholders to the embedded `index.html` template. fn render_index(lang: Lang) -> String { let body = INDEX_HTML .replace("{{LANG_CODE}}", lang.code()) .replace("{{T_APP_TITLE}}", t(lang, "shell.app_title")) .replace("{{T_NAV_USERS}}", t(lang, "nav.users")) .replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices")) .replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups")) .replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies")) .replace("{{T_NAV_AB}}", t(lang, "nav.address_books")) .replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit")) .replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy")) .replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile")) .replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout")) .replace("{{T_LANGUAGE}}", t(lang, "common.language")) .replace("{{T_LOADING}}", t(lang, "common.loading")); apply_lang_selected(body, lang) } /// Apply i18n placeholders to the embedded `login.html` template. fn render_login(lang: Lang) -> String { let body = LOGIN_HTML .replace("{{LANG_CODE}}", lang.code()) .replace("{{T_TITLE}}", t(lang, "login.title")) .replace("{{T_SUBTITLE}}", t(lang, "login.subtitle")) .replace("{{T_USERNAME}}", t(lang, "login.username")) .replace("{{T_PASSWORD}}", t(lang, "login.password")) .replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label")) .replace("{{T_SIGNIN}}", t(lang, "login.signin")) .replace("{{T_OR}}", t(lang, "login.or")) .replace("{{T_LANGUAGE}}", t(lang, "common.language")) .replace( "{{T_SIGNIN_WITH_JSON}}", &json_string(t(lang, "login.signin_with")), ); apply_lang_selected(body, lang) } /// Inject `selected` into the matching `