404 et traductions avant refont UI
EBO <eric@ebii.fr> committé le 2026-09-23 07:33
12967fc68e7aa3215c8a344f1e14ea208c4f0c21
1 parent(s)
10 fichiers modifiés
+345
-1
M
crates/rustwarden-ui/locales/de.yml
+8
-0
@@ -852,3 +852,11 @@ fediverse_invite_landing:
| 852 | 852 | reject_button: "Ablehnen" |
| 853 | 853 | already_decided: "Diese Einladung wurde bereits bearbeitet." |
| 854 | 854 | cannot_accept_note: "Um anzunehmen, müssen Sie über eine föderierte Identität angemeldet sein (Anmeldung von Ihrer Ursprungsinstanz). Sie können von diesem lokalen Konto aus ablehnen, aber die Annahme erfolgt über den AP-nativen Flow." |
| 855 | + | |
| 856 | +# 404-Seite (unbekannte Route) | |
| 857 | +http_errors: | |
| 858 | + not_found_title: "Seite nicht gefunden" | |
| 859 | + not_found_message: "Diese Adresse entspricht keiner Seite." | |
| 860 | + code_label: "Fehler 404" | |
| 861 | + home: "Zur Startseite" | |
| 862 | + login: "Anmelden" |
M
crates/rustwarden-ui/locales/en.yml
+8
-0
@@ -865,3 +865,11 @@ fediverse_invite_landing:
| 865 | 865 | reject_button: "Reject" |
| 866 | 866 | already_decided: "This invitation has already been handled." |
| 867 | 867 | cannot_accept_note: "To accept, you must be signed in via a federated identity (login from your origin instance). You can reject from this local account, but accepting goes through the AP-native flow." |
| 868 | + | |
| 869 | +# 404 page (unknown route) | |
| 870 | +http_errors: | |
| 871 | + not_found_title: "Page not found" | |
| 872 | + not_found_message: "This address does not match any page." | |
| 873 | + code_label: "Error 404" | |
| 874 | + home: "Back to home" | |
| 875 | + login: "Sign in" |
M
crates/rustwarden-ui/locales/es.yml
+8
-0
@@ -851,3 +851,11 @@ fediverse_invite_landing:
| 851 | 851 | reject_button: "Rechazar" |
| 852 | 852 | already_decided: "Esta invitación ya ha sido procesada." |
| 853 | 853 | cannot_accept_note: "Para aceptar, debe estar conectado mediante una identidad federada (inicio de sesión desde su instancia de origen). Puede rechazarla desde esta cuenta local, pero la aceptación pasa por el flujo nativo AP." |
| 854 | + | |
| 855 | +# Página 404 (ruta desconocida) | |
| 856 | +http_errors: | |
| 857 | + not_found_title: "Página no encontrada" | |
| 858 | + not_found_message: "Esta dirección no corresponde a ninguna página." | |
| 859 | + code_label: "Error 404" | |
| 860 | + home: "Volver al inicio" | |
| 861 | + login: "Iniciar sesión" |
M
crates/rustwarden-ui/locales/fr.yml
+8
-0
@@ -966,3 +966,11 @@ fediverse_invite_landing:
| 966 | 966 | reject_button: "Refuser" |
| 967 | 967 | already_decided: "Cette invitation a déjà été traitée." |
| 968 | 968 | cannot_accept_note: "Pour accepter, tu dois être connecté·e via une identité fédérée (login depuis ton instance d'origine). Tu peux refuser depuis ce compte local, mais l'acceptation passe par le flow AP-native." |
| 969 | + | |
| 970 | +# Page 404 (route inconnue) | |
| 971 | +http_errors: | |
| 972 | + not_found_title: "Page introuvable" | |
| 973 | + not_found_message: "Cette adresse ne correspond à aucune page." | |
| 974 | + code_label: "Erreur 404" | |
| 975 | + home: "Retour à l'accueil" | |
| 976 | + login: "Se connecter" |
M
crates/rustwarden-ui/locales/it.yml
+8
-0
@@ -958,3 +958,11 @@ fediverse_invite_landing:
| 958 | 958 | reject_button: "Rifiuta" |
| 959 | 959 | already_decided: "Questo invito è già stato gestito." |
| 960 | 960 | cannot_accept_note: "Per accettare, devi essere connesso con un'identità federata (accesso dalla tua istanza di origine). Puoi rifiutare da questo account locale, ma l'accettazione passa attraverso il flow AP-nativo." |
| 961 | + | |
| 962 | +# Pagina 404 (route sconosciuta) | |
| 963 | +http_errors: | |
| 964 | + not_found_title: "Pagina non trovata" | |
| 965 | + not_found_message: "Questo indirizzo non corrisponde a nessuna pagina." | |
| 966 | + code_label: "Errore 404" | |
| 967 | + home: "Torna alla home" | |
| 968 | + login: "Accedi" |
M
crates/rustwarden-ui/locales/pt.yml
+8
-0
@@ -958,3 +958,11 @@ fediverse_invite_landing:
| 958 | 958 | reject_button: "Recusar" |
| 959 | 959 | already_decided: "Este convite já foi processado." |
| 960 | 960 | cannot_accept_note: "Para aceitar, você deve estar conectado por meio de uma identidade federada (login da sua instância de origem). Pode recusar desta conta local, mas a aceitação passa pelo fluxo AP-nativo." |
| 961 | + | |
| 962 | +# Página 404 (rota desconhecida) | |
| 963 | +http_errors: | |
| 964 | + not_found_title: "Página não encontrada" | |
| 965 | + not_found_message: "Este endereço não corresponde a nenhuma página." | |
| 966 | + code_label: "Erro 404" | |
| 967 | + home: "Voltar ao início" | |
| 968 | + login: "Entrar" |
A
crates/rustwarden-ui/src/handlers/error_page.rs
+239
-0
@@ -0,0 +1,239 @@
| 1 | +//! HTML 404 page for unmatched browser routes. | |
| 2 | +//! | |
| 3 | +//! API paths and JSON-only clients still receive a JSON body. | |
| 4 | + | |
| 5 | +use askama::Template; | |
| 6 | +use axum::body::Body; | |
| 7 | +use axum::extract::Request; | |
| 8 | +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; | |
| 9 | +use axum::response::{Html, IntoResponse, Response}; | |
| 10 | +use std::sync::Arc; | |
| 11 | + | |
| 12 | +use rustwarden_core::i18n::extractor::Locale; | |
| 13 | +use rustwarden_core::i18n::service::I18nService; | |
| 14 | +use rustwarden_core::templates::LayoutContext; | |
| 15 | + | |
| 16 | +const SUPPORTED_LOCALES: [&str; 6] = ["fr", "en", "de", "es", "it", "pt"]; | |
| 17 | + | |
| 18 | +/// Unmatched-route page. Copy is filled from i18n when the app has loaded it. | |
| 19 | +#[derive(Template)] | |
| 20 | +#[template(path = "errors/404.html")] | |
| 21 | +struct NotFoundTemplate { | |
| 22 | + fragment: bool, | |
| 23 | + locale: String, | |
| 24 | + app_name: String, | |
| 25 | + page_title: String, | |
| 26 | + code_label: String, | |
| 27 | + title: String, | |
| 28 | + message: String, | |
| 29 | + path: String, | |
| 30 | + home_label: String, | |
| 31 | + login_label: String, | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// Fallback for routes that match nothing on the UI router. | |
| 35 | +/// | |
| 36 | +/// Browsers and HTMX receive the HTML page (status 404). Paths under `/api/` | |
| 37 | +/// and clients that ask only for `application/json` receive JSON. | |
| 38 | +pub async fn not_found(request: Request<Body>) -> Response { | |
| 39 | + let path = request.uri().path(); | |
| 40 | + let headers = request.headers(); | |
| 41 | + if wants_json(path, headers) { | |
| 42 | + return ( | |
| 43 | + StatusCode::NOT_FOUND, | |
| 44 | + axum::Json(serde_json::json!({ | |
| 45 | + "error": "Not found", | |
| 46 | + "status": 404, | |
| 47 | + })), | |
| 48 | + ) | |
| 49 | + .into_response(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + let locale = locale_from_request(&request); | |
| 53 | + let i18n = request.extensions().get::<Arc<I18nService>>().cloned(); | |
| 54 | + let fragment = headers | |
| 55 | + .get("hx-request") | |
| 56 | + .and_then(|v| v.to_str().ok()) | |
| 57 | + .is_some_and(|v| v.eq_ignore_ascii_case("true")); | |
| 58 | + | |
| 59 | + let title = tr( | |
| 60 | + i18n.as_deref(), | |
| 61 | + "http_errors.not_found_title", | |
| 62 | + &locale, | |
| 63 | + "Page introuvable", | |
| 64 | + ) | |
| 65 | + .await; | |
| 66 | + let message = tr( | |
| 67 | + i18n.as_deref(), | |
| 68 | + "http_errors.not_found_message", | |
| 69 | + &locale, | |
| 70 | + "Cette adresse ne correspond à aucune page.", | |
| 71 | + ) | |
| 72 | + .await; | |
| 73 | + let code_label = tr( | |
| 74 | + i18n.as_deref(), | |
| 75 | + "http_errors.code_label", | |
| 76 | + &locale, | |
| 77 | + "Erreur 404", | |
| 78 | + ) | |
| 79 | + .await; | |
| 80 | + let home_label = tr( | |
| 81 | + i18n.as_deref(), | |
| 82 | + "http_errors.home", | |
| 83 | + &locale, | |
| 84 | + "Retour à l'accueil", | |
| 85 | + ) | |
| 86 | + .await; | |
| 87 | + let login_label = tr( | |
| 88 | + i18n.as_deref(), | |
| 89 | + "http_errors.login", | |
| 90 | + &locale, | |
| 91 | + "Se connecter", | |
| 92 | + ) | |
| 93 | + .await; | |
| 94 | + let layout = LayoutContext::new("", ""); | |
| 95 | + let page_title = format!("404 — {title} — {}", layout.app_name); | |
| 96 | + let shown_path: String = path.chars().take(200).collect(); | |
| 97 | + | |
| 98 | + let template = NotFoundTemplate { | |
| 99 | + fragment, | |
| 100 | + locale, | |
| 101 | + app_name: layout.app_name, | |
| 102 | + page_title, | |
| 103 | + code_label, | |
| 104 | + title, | |
| 105 | + message, | |
| 106 | + path: shown_path, | |
| 107 | + home_label, | |
| 108 | + login_label, | |
| 109 | + }; | |
| 110 | + | |
| 111 | + let html = match template.render() { | |
| 112 | + Ok(html) => html, | |
| 113 | + Err(err) => { | |
| 114 | + tracing::error!("404 template render failed: {err}"); | |
| 115 | + return (StatusCode::NOT_FOUND, Html(FALLBACK_HTML.to_string())).into_response(); | |
| 116 | + } | |
| 117 | + }; | |
| 118 | + | |
| 119 | + let mut response = (StatusCode::NOT_FOUND, Html(html)).into_response(); | |
| 120 | + if fragment { | |
| 121 | + let headers = response.headers_mut(); | |
| 122 | + headers.insert( | |
| 123 | + HeaderName::from_static("hx-retarget"), | |
| 124 | + HeaderValue::from_static("body"), | |
| 125 | + ); | |
| 126 | + headers.insert( | |
| 127 | + HeaderName::from_static("hx-reswap"), | |
| 128 | + HeaderValue::from_static("innerHTML"), | |
| 129 | + ); | |
| 130 | + } | |
| 131 | + response | |
| 132 | +} | |
| 133 | + | |
| 134 | +fn wants_json(path: &str, headers: &HeaderMap) -> bool { | |
| 135 | + if path.starts_with("/api/") { | |
| 136 | + return true; | |
| 137 | + } | |
| 138 | + let accept = headers | |
| 139 | + .get(header::ACCEPT) | |
| 140 | + .and_then(|value| value.to_str().ok()) | |
| 141 | + .unwrap_or(""); | |
| 142 | + accept.contains("application/json") && !accept.contains("text/html") | |
| 143 | +} | |
| 144 | + | |
| 145 | +fn locale_from_request(request: &Request<Body>) -> String { | |
| 146 | + if let Some(Locale(value)) = request.extensions().get::<Locale>() { | |
| 147 | + let code = value.split(['_', '-']).next().unwrap_or(value); | |
| 148 | + if SUPPORTED_LOCALES.contains(&code) { | |
| 149 | + return code.to_string(); | |
| 150 | + } | |
| 151 | + } | |
| 152 | + let header = request | |
| 153 | + .headers() | |
| 154 | + .get(header::ACCEPT_LANGUAGE) | |
| 155 | + .and_then(|value| value.to_str().ok()) | |
| 156 | + .unwrap_or(""); | |
| 157 | + for part in header.split(',') { | |
| 158 | + let token = part.split(';').next().unwrap_or("").trim(); | |
| 159 | + let code = token.split(['_', '-']).next().unwrap_or(""); | |
| 160 | + if SUPPORTED_LOCALES.contains(&code) { | |
| 161 | + return code.to_string(); | |
| 162 | + } | |
| 163 | + } | |
| 164 | + "fr".to_string() | |
| 165 | +} | |
| 166 | + | |
| 167 | +async fn tr(i18n: Option<&I18nService>, key: &str, locale: &str, fallback: &str) -> String { | |
| 168 | + let Some(i18n) = i18n else { | |
| 169 | + return fallback.to_string(); | |
| 170 | + }; | |
| 171 | + let value = i18n.translate(key, locale).await; | |
| 172 | + if value == key { | |
| 173 | + fallback.to_string() | |
| 174 | + } else { | |
| 175 | + value | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +const FALLBACK_HTML: &str = r#"<!DOCTYPE html> | |
| 180 | +<html lang="fr"><head><meta charset="utf-8"><title>404</title></head> | |
| 181 | +<body><h1>Page introuvable</h1><p><a href="/">Retour à l'accueil</a></p></body></html>"#; | |
| 182 | + | |
| 183 | +#[cfg(test)] | |
| 184 | +mod tests { | |
| 185 | + use super::*; | |
| 186 | + use axum::body::to_bytes; | |
| 187 | + | |
| 188 | + #[tokio::test] | |
| 189 | + async fn browser_gets_html_404() { | |
| 190 | + let request = Request::builder() | |
| 191 | + .uri("/cette-page-nexiste-pas") | |
| 192 | + .header(header::ACCEPT, "text/html") | |
| 193 | + .body(Body::empty()) | |
| 194 | + .expect("request"); | |
| 195 | + let response = not_found(request).await; | |
| 196 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 197 | + let bytes = to_bytes(response.into_body(), 64 * 1024) | |
| 198 | + .await | |
| 199 | + .expect("body"); | |
| 200 | + let html = String::from_utf8_lossy(&bytes); | |
| 201 | + assert!(html.contains("Page introuvable"), "{html}"); | |
| 202 | + assert!(html.contains("/cette-page-nexiste-pas"), "{html}"); | |
| 203 | + assert!(html.contains("l'accueil"), "{html}"); | |
| 204 | + } | |
| 205 | + | |
| 206 | + #[tokio::test] | |
| 207 | + async fn api_path_stays_json() { | |
| 208 | + let request = Request::builder() | |
| 209 | + .uri("/api/v1/does-not-exist") | |
| 210 | + .header(header::ACCEPT, "text/html") | |
| 211 | + .body(Body::empty()) | |
| 212 | + .expect("request"); | |
| 213 | + let response = not_found(request).await; | |
| 214 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 215 | + let bytes = to_bytes(response.into_body(), 8 * 1024) | |
| 216 | + .await | |
| 217 | + .expect("body"); | |
| 218 | + let body = String::from_utf8_lossy(&bytes); | |
| 219 | + assert!(body.contains("\"status\":404"), "{body}"); | |
| 220 | + assert!(!body.contains("<html"), "{body}"); | |
| 221 | + } | |
| 222 | + | |
| 223 | + #[tokio::test] | |
| 224 | + async fn json_accept_stays_json() { | |
| 225 | + let request = Request::builder() | |
| 226 | + .uri("/nope") | |
| 227 | + .header(header::ACCEPT, "application/json") | |
| 228 | + .body(Body::empty()) | |
| 229 | + .expect("request"); | |
| 230 | + let response = not_found(request).await; | |
| 231 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 232 | + let content_type = response | |
| 233 | + .headers() | |
| 234 | + .get(header::CONTENT_TYPE) | |
| 235 | + .and_then(|v| v.to_str().ok()) | |
| 236 | + .unwrap_or(""); | |
| 237 | + assert!(content_type.contains("application/json"), "{content_type}"); | |
| 238 | + } | |
| 239 | +} |
M
crates/rustwarden-ui/src/handlers/mod.rs
+2
-0
@@ -8,6 +8,8 @@ pub mod admin_handler;
| 8 | 8 | pub mod dashboard_handler; |
| 9 | 9 | /// Email delivery status handlers. |
| 10 | 10 | pub mod email_handler; |
| 11 | +/// HTML 404 page for unmatched routes. | |
| 12 | +pub mod error_page; | |
| 11 | 13 | /// Health-check endpoint handler. |
| 12 | 14 | pub mod health; |
| 13 | 15 | /// Home page handler. |
M
crates/rustwarden-ui/src/routes.rs
+3
-1
@@ -7,7 +7,8 @@ use tower_http::services::ServeDir;
| 7 | 7 | |
| 8 | 8 | use crate::handlers::settings::{preferences, profile, security}; |
| 9 | 9 | use crate::handlers::{ |
| 10 | - admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler, | |
| 10 | + admin_handler, error_page, health, home_handler, login_handler, root_handler, totp_handler, | |
| 11 | + user_handler, | |
| 11 | 12 | }; |
| 12 | 13 | |
| 13 | 14 | /// Build the **browser** family of UI routes (SSR, session-cookie driven). |
@@ -114,6 +115,7 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
| 114 | 115 | .merge(ui_routes) |
| 115 | 116 | .merge(admin_routes) |
| 116 | 117 | .nest_service("/static", static_service) |
| 118 | + .fallback(error_page::not_found) | |
| 117 | 119 | } |
| 118 | 120 | |
| 119 | 121 | /// Build the **machine** family of API routes (`/api/v1/*`). |
A
crates/rustwarden-ui/templates/errors/404.html
+53
-0
@@ -0,0 +1,53 @@
| 1 | +{% if !fragment %} | |
| 2 | +<!DOCTYPE html> | |
| 3 | +<html lang="{{ locale }}" data-theme="light"> | |
| 4 | +<head> | |
| 5 | + <script> | |
| 6 | + (function() { | |
| 7 | + var lm = document.cookie.match(/(?:^|;\s*)locale=([^;]*)/); | |
| 8 | + if (lm && lm[1]) document.documentElement.lang = lm[1]; | |
| 9 | + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/); | |
| 10 | + var t = m ? m[1] : 'auto'; | |
| 11 | + if (t === 'dark') { | |
| 12 | + document.documentElement.setAttribute('data-theme', 'dark'); | |
| 13 | + } else if (t === 'light') { | |
| 14 | + document.documentElement.setAttribute('data-theme', 'light'); | |
| 15 | + } else { | |
| 16 | + var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| 17 | + document.documentElement.setAttribute('data-theme', d); | |
| 18 | + } | |
| 19 | + })(); | |
| 20 | + </script> | |
| 21 | + <meta charset="UTF-8"> | |
| 22 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 23 | + <meta name="robots" content="noindex"> | |
| 24 | + <title>{{ page_title }}</title> | |
| 25 | + <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg"> | |
| 26 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 27 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 28 | +</head> | |
| 29 | +<body class="bg-base-200 min-h-screen text-base-content"> | |
| 30 | +{% endif %} | |
| 31 | +<main class="min-h-screen flex items-center justify-center px-4 py-10" id="http-error"> | |
| 32 | + <div class="max-w-md w-full text-center"> | |
| 33 | + <a href="/" class="inline-flex items-center justify-center mb-6"> | |
| 34 | + <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-12 w-12"> | |
| 35 | + </a> | |
| 36 | + <p class="text-sm font-medium uppercase tracking-wide text-base-content/60 mb-2">{{ code_label }}</p> | |
| 37 | + <h1 class="text-3xl font-bold mb-3">{{ title }}</h1> | |
| 38 | + <p class="text-base-content/70 mb-4">{{ message }}</p> | |
| 39 | + {% if path != "" %} | |
| 40 | + <p class="mb-8"> | |
| 41 | + <code class="text-sm break-all bg-base-100 rounded px-2 py-1">{{ path }}</code> | |
| 42 | + </p> | |
| 43 | + {% endif %} | |
| 44 | + <div class="space-y-3"> | |
| 45 | + <a href="/" class="btn btn-primary w-full">{{ home_label }}</a> | |
| 46 | + <a href="/login" class="btn btn-ghost w-full">{{ login_label }}</a> | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | +</main> | |
| 50 | +{% if !fragment %} | |
| 51 | +</body> | |
| 52 | +</html> | |
| 53 | +{% endif %} |
GitRust