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&#39;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 %}