This commit is contained in:
+99
-7
@@ -16,16 +16,19 @@
|
||||
//! /admin/pages/* GET fragments (one per page)
|
||||
|
||||
pub mod auth;
|
||||
pub mod i18n;
|
||||
pub mod me;
|
||||
pub mod oidc_login;
|
||||
pub mod pages;
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::admin::i18n::{lang_from_headers, t, Lang};
|
||||
|
||||
/// Files embedded into the binary. Paths are relative to this source file
|
||||
/// per `include_str!`. Adding a new HTML asset = one new entry here.
|
||||
const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html");
|
||||
@@ -218,16 +221,105 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
async fn serve_index() -> Response {
|
||||
html_response(INDEX_HTML)
|
||||
async fn serve_index(headers: HeaderMap) -> Response {
|
||||
let lang = lang_from_headers(&headers);
|
||||
html_response_owned(render_index(lang))
|
||||
}
|
||||
|
||||
async fn serve_login() -> Response {
|
||||
html_response(LOGIN_HTML)
|
||||
async fn serve_login(headers: HeaderMap) -> Response {
|
||||
let lang = lang_from_headers(&headers);
|
||||
html_response_owned(render_login(lang))
|
||||
}
|
||||
|
||||
fn html_response(body: &'static str) -> Response {
|
||||
// We hand back `Html<&'static str>` so axum sets `text/html` for us.
|
||||
/// Apply i18n placeholders to the embedded `index.html` template.
|
||||
fn render_index(lang: Lang) -> String {
|
||||
let body = INDEX_HTML
|
||||
.replace("{{LANG_CODE}}", lang.code())
|
||||
.replace("{{T_APP_TITLE}}", t(lang, "shell.app_title"))
|
||||
.replace("{{T_NAV_USERS}}", t(lang, "nav.users"))
|
||||
.replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices"))
|
||||
.replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups"))
|
||||
.replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies"))
|
||||
.replace("{{T_NAV_AB}}", t(lang, "nav.address_books"))
|
||||
.replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit"))
|
||||
.replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy"))
|
||||
.replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile"))
|
||||
.replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout"))
|
||||
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
|
||||
.replace("{{T_LOADING}}", t(lang, "common.loading"));
|
||||
apply_lang_selected(body, lang)
|
||||
}
|
||||
|
||||
/// Apply i18n placeholders to the embedded `login.html` template.
|
||||
fn render_login(lang: Lang) -> String {
|
||||
let body = LOGIN_HTML
|
||||
.replace("{{LANG_CODE}}", lang.code())
|
||||
.replace("{{T_TITLE}}", t(lang, "login.title"))
|
||||
.replace("{{T_SUBTITLE}}", t(lang, "login.subtitle"))
|
||||
.replace("{{T_USERNAME}}", t(lang, "login.username"))
|
||||
.replace("{{T_PASSWORD}}", t(lang, "login.password"))
|
||||
.replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label"))
|
||||
.replace("{{T_SIGNIN}}", t(lang, "login.signin"))
|
||||
.replace("{{T_OR}}", t(lang, "login.or"))
|
||||
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
|
||||
.replace(
|
||||
"{{T_SIGNIN_WITH_JSON}}",
|
||||
&json_string(t(lang, "login.signin_with")),
|
||||
);
|
||||
apply_lang_selected(body, lang)
|
||||
}
|
||||
|
||||
/// Inject `selected` into the matching `<option>` for the active language and
|
||||
/// blank out the others. Both templates use the same `{{LANG_SEL_XX}}` markers.
|
||||
fn apply_lang_selected(body: String, lang: Lang) -> String {
|
||||
let mut sel_en = "";
|
||||
let mut sel_de = "";
|
||||
let mut sel_fr = "";
|
||||
let mut sel_ro = "";
|
||||
let mut sel_es = "";
|
||||
match lang {
|
||||
Lang::En => sel_en = " selected",
|
||||
Lang::De => sel_de = " selected",
|
||||
Lang::Fr => sel_fr = " selected",
|
||||
Lang::Ro => sel_ro = " selected",
|
||||
Lang::Es => sel_es = " selected",
|
||||
}
|
||||
body.replace("{{LANG_SEL_EN}}", sel_en)
|
||||
.replace("{{LANG_SEL_DE}}", sel_de)
|
||||
.replace("{{LANG_SEL_FR}}", sel_fr)
|
||||
.replace("{{LANG_SEL_RO}}", sel_ro)
|
||||
.replace("{{LANG_SEL_ES}}", sel_es)
|
||||
}
|
||||
|
||||
/// JSON-encode a string so it can be embedded inside a `<script>` block as a
|
||||
/// JS string literal. We only need to escape `"`, `\`, and the control chars
|
||||
/// that show up in our translations — none of them realistically contain
|
||||
/// newlines or `</script>`, but escape them defensively anyway.
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'<' => out.push_str("\\u003c"),
|
||||
'>' => out.push_str("\\u003e"),
|
||||
'&' => out.push_str("\\u0026"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn html_response_owned(body: String) -> Response {
|
||||
// Cache-Control: no-cache so the operator sees fresh HTML after a
|
||||
// server upgrade without having to bump asset URLs.
|
||||
let mut resp = Html(body).into_response();
|
||||
|
||||
Reference in New Issue
Block a user