check crates UI +imap

EBO <eric@ebii.fr> committé le 2026-10-01 21:58

9f3c757873096e2e101facf168b60dddd24a5797

1 parent(s)

71 fichiers modifiés +2674 -2917
M CHANGELOG.md
+7 -0
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8 8 ## [Unreleased]
9 9
10 +### Security
11 +
12 +- **RFC 0039 phase 2.** `SecurityHeadersConfig::default` n'émet plus
13 + `'unsafe-eval'`. L'UI charge le build Alpine CSP. `'unsafe-inline'` est
14 + conservé. La politique 1.0.1 reste disponible via
15 + `DEFAULT_CONTENT_SECURITY_POLICY` et `RustwardenBuilder::security_headers`.
16 +
10 17 ### Added
11 18
12 19 - **Mesure d'audience générique** (`config::analytics`). Support d'un script de suivi
M crates/rustwarden-core/src/app.rs
+13 -13
@@ -32,11 +32,11 @@ use tower::ServiceBuilder;
32 32 use tower_http::cors::{AllowOrigin, Any, CorsLayer};
33 33
34 34 use crate::auth::session::SessionConfig;
35 -use crate::config::auth::AuthConfig;
36 35 use crate::config::analytics::{AnalyticsConfig, AnalyticsState};
36 +use crate::config::auth::AuthConfig;
37 37 use crate::config::database;
38 38 use crate::config::email::EmailConfig;
39 -use crate::config::security_headers::{DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig};
39 +use crate::config::security_headers::{CSP_WITHOUT_UNSAFE_EVAL, SecurityHeadersConfig};
40 40 use crate::config::server::ServerConfig;
41 41 use crate::dashboard::DashboardCardDTO;
42 42 use crate::hooks::{NoopHooks, RustwardenHooks};
@@ -981,13 +981,13 @@ async fn security_headers_middleware(
981 981 );
982 982 // SEC-FIX: CSP header to mitigate XSS attacks. Value comes from the
983 983 // configured policy; an invalid header value (e.g. control characters)
984 - // falls back to the 1.0.1 default instead of panicking (RFC 0039).
984 + // falls back to the default policy (no 'unsafe-eval') instead of panicking.
985 985 let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()).unwrap_or_else(|_| {
986 986 tracing::warn!(
987 987 "Invalid Content-Security-Policy value in SecurityHeadersConfig; \
988 988 falling back to the default policy"
989 989 );
990 - HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY)
990 + HeaderValue::from_static(CSP_WITHOUT_UNSAFE_EVAL)
991 991 });
992 992 headers.insert(HeaderName::from_static("content-security-policy"), csp);
993 993 // RFC 0039: opt-in cross-origin isolation headers. `None` (the default)
@@ -1112,7 +1112,7 @@ mod builder_tests {
1112 1112 let builder = RustwardenBuilder::default();
1113 1113 assert_eq!(
1114 1114 builder.security_headers.content_security_policy,
1115 - DEFAULT_CONTENT_SECURITY_POLICY
1115 + CSP_WITHOUT_UNSAFE_EVAL
1116 1116 );
1117 1117 assert!(
1118 1118 builder
@@ -1193,7 +1193,7 @@ mod security_headers_middleware_tests {
1193 1193 headers
1194 1194 .get("content-security-policy")
1195 1195 .expect("csp header present"),
1196 - DEFAULT_CONTENT_SECURITY_POLICY
1196 + CSP_WITHOUT_UNSAFE_EVAL
1197 1197 );
1198 1198 assert!(headers.get("cross-origin-opener-policy").is_none());
1199 1199 assert!(headers.get("cross-origin-resource-policy").is_none());
@@ -1208,7 +1208,7 @@ mod security_headers_middleware_tests {
1208 1208 headers
1209 1209 .get("content-security-policy")
1210 1210 .expect("csp header present"),
1211 - DEFAULT_CONTENT_SECURITY_POLICY
1211 + CSP_WITHOUT_UNSAFE_EVAL
1212 1212 );
1213 1213 assert_eq!(
1214 1214 headers
@@ -1236,8 +1236,8 @@ mod security_headers_middleware_tests {
1236 1236 resp.headers()
1237 1237 .get("content-security-policy")
1238 1238 .expect("csp header present"),
1239 - DEFAULT_CONTENT_SECURITY_POLICY,
1240 - "invalid CSP must fall back to the 1.0.1 default"
1239 + CSP_WITHOUT_UNSAFE_EVAL,
1240 + "invalid CSP must fall back to the default policy"
1241 1241 );
1242 1242 }
1243 1243
@@ -1257,7 +1257,7 @@ mod security_headers_middleware_tests {
1257 1257 headers
1258 1258 .get("content-security-policy")
1259 1259 .expect("csp header present"),
1260 - DEFAULT_CONTENT_SECURITY_POLICY
1260 + CSP_WITHOUT_UNSAFE_EVAL
1261 1261 );
1262 1262 }
1263 1263
@@ -1313,8 +1313,8 @@ mod security_headers_middleware_tests {
1313 1313
1314 1314 #[tokio::test]
1315 1315 async fn analytics_disabled_leaves_the_csp_byte_identical() {
1316 - // The whole point of the default: a consumer that never configures
1317 - // analytics must see the historical header, unchanged.
1316 + // A consumer that never configures analytics must see the default
1317 + // policy (RFC 0039 phase 2: no 'unsafe-eval'), unchanged.
1318 1318 let csp = headers_for(
1319 1319 &AnalyticsConfig::default(),
1320 1320 SecurityHeadersConfig::default(),
@@ -1328,7 +1328,7 @@ mod security_headers_middleware_tests {
1328 1328 resp.headers()
1329 1329 .get("content-security-policy")
1330 1330 .expect("csp header present"),
1331 - DEFAULT_CONTENT_SECURITY_POLICY
1331 + CSP_WITHOUT_UNSAFE_EVAL
1332 1332 );
1333 1333 }
1334 1334
M crates/rustwarden-core/src/config/analytics.rs
+39 -10
@@ -151,7 +151,10 @@ impl AnalyticsConfig {
151 151 let mut cfg = Self::default();
152 152
153 153 if let Ok(raw) = std::env::var("ANALYTICS_ENABLED") {
154 - cfg.enabled = matches!(raw.trim().to_ascii_lowercase().as_str(), "true" | "1" | "on");
154 + cfg.enabled = matches!(
155 + raw.trim().to_ascii_lowercase().as_str(),
156 + "true" | "1" | "on"
157 + );
155 158 }
156 159 if let Ok(url) = std::env::var("ANALYTICS_SCRIPT_URL") {
157 160 let url = url.trim().to_string();
@@ -183,7 +186,10 @@ impl AnalyticsConfig {
183 186 }
184 187 }
185 188 if let Ok(raw) = std::env::var("ANALYTICS_DEFER") {
186 - cfg.defer = !matches!(raw.trim().to_ascii_lowercase().as_str(), "false" | "0" | "off");
189 + cfg.defer = !matches!(
190 + raw.trim().to_ascii_lowercase().as_str(),
191 + "false" | "0" | "off"
192 + );
187 193 }
188 194
189 195 cfg.validate()?;
@@ -583,7 +589,8 @@ impl AnalyticsState {
583 589 .await
584 590 .unwrap_or(base.enabled);
585 591
586 - if let Some(setting) = AppSettingsService::get_setting(db, SETTING_ANALYTICS_SITE_ID).await?
592 + if let Some(setting) =
593 + AppSettingsService::get_setting(db, SETTING_ANALYTICS_SITE_ID).await?
587 594 {
588 595 let value = setting.value.trim().to_string();
589 596 cfg.site_id = if value.is_empty() { None } else { Some(value) };
@@ -687,7 +694,9 @@ where
687 694 parts: &mut axum::http::request::Parts,
688 695 _state: &S,
689 696 ) -> Result<Self, Self::Rejection> {
690 - Ok(Self::from_state(parts.extensions.get::<Arc<AnalyticsState>>()))
697 + Ok(Self::from_state(
698 + parts.extensions.get::<Arc<AnalyticsState>>(),
699 + ))
691 700 }
692 701 }
693 702
@@ -897,14 +906,28 @@ mod tests {
897 906 #[test]
898 907 fn script_origin_drops_path_and_keeps_port() {
899 908 let cases = [
900 - ("https://stats.example.org/api/script.js", "https://stats.example.org"),
901 - ("https://stats.example.org:8443/s.js", "https://stats.example.org:8443"),
902 - ("https://a.b.c.example.org/s.js?v=1", "https://a.b.c.example.org"),
909 + (
910 + "https://stats.example.org/api/script.js",
911 + "https://stats.example.org",
912 + ),
913 + (
914 + "https://stats.example.org:8443/s.js",
915 + "https://stats.example.org:8443",
916 + ),
917 + (
918 + "https://a.b.c.example.org/s.js?v=1",
919 + "https://a.b.c.example.org",
920 + ),
903 921 ("http://localhost:3001/s.js", "http://localhost:3001"),
904 922 ];
905 923 for (url, expected) in cases {
906 924 let cfg = AnalyticsConfig::default().with_script_url(url);
907 - assert_eq!(cfg.script_origin().as_deref(), Some(expected), "for {}", url);
925 + assert_eq!(
926 + cfg.script_origin().as_deref(),
927 + Some(expected),
928 + "for {}",
929 + url
930 + );
908 931 }
909 932 }
910 933
@@ -938,7 +961,10 @@ mod tests {
938 961 #[test]
939 962 fn attributes_json_is_parsed() {
940 963 let parsed = parse_attributes_json(r#"{"data-consent":"opt-in"}"#).unwrap_or_default();
941 - assert_eq!(parsed.get("data-consent").map(String::as_str), Some("opt-in"));
964 + assert_eq!(
965 + parsed.get("data-consent").map(String::as_str),
966 + Some("opt-in")
967 + );
942 968 }
943 969
944 970 #[test]
@@ -954,7 +980,10 @@ mod tests {
954 980 fn state_pre_renders_the_snippet() {
955 981 let state = AnalyticsState::from_config(enabled_config());
956 982 assert!(state.snippet().is_some());
957 - assert_eq!(state.script_url(), Some("https://stats.example.org/api/script.js"));
983 + assert_eq!(
984 + state.script_url(),
985 + Some("https://stats.example.org/api/script.js")
986 + );
958 987 }
959 988
960 989 #[test]
M crates/rustwarden-core/src/config/email.rs
+36 -5
@@ -16,7 +16,7 @@
16 16 //!
17 17 //! IMAP configuration for bounce processing (Feature 012):
18 18 //! - IMAP_HOST: IMAP server hostname (optional, enables bounce processing if set)
19 -//! - IMAP_PORT: IMAP server port (default: 993)
19 +//! - IMAP_PORT: IMAP server port (default: 993). Ports 25, 465 and 587 are rejected: they are SMTP.
20 20 //! - IMAP_USER: IMAP username (defaults to SMTP_USER)
21 21 //! - IMAP_PASSWORD: IMAP password (defaults to SMTP_PASSWORD)
22 22 //! - IMAP_USE_TLS: Use TLS for IMAP connection (default: true)
@@ -265,11 +265,19 @@ impl EmailConfig {
265 265 }
266 266
267 267 if let Some(ref host) = imap_host {
268 + // 25 (SMTP), 465 (SMTPS) and 587 (submission) speak SMTP. An IMAP
269 + // client cannot parse their greeting, which surfaces as a STARTTLS
270 + // "Unable to parse status response".
271 + Self::validate_imap_port(imap_port)?;
272 + let mode = if imap_use_tls {
273 + "direct TLS"
274 + } else if imap_use_starttls {
275 + "STARTTLS"
276 + } else {
277 + "plain"
278 + };
268 279 tracing::info!(
269 - "IMAP bounce processing enabled: host={}, mailbox={}, poll interval={}s",
270 - host,
271 - imap_mailbox,
272 - imap_poll_interval_seconds
280 + "IMAP bounce processing enabled: host={host}:{imap_port}, mode={mode}, mailbox={imap_mailbox}, poll interval={imap_poll_interval_seconds}s"
273 281 );
274 282 }
275 283
@@ -366,6 +374,19 @@ impl EmailConfig {
366 374 Ok(())
367 375 }
368 376
377 + /// Reject SMTP ports for the IMAP bounce reader.
378 + ///
379 + /// Ports 25, 465 and 587 speak SMTP. Connecting an IMAP client there fails
380 + /// while parsing the greeting.
381 + fn validate_imap_port(port: u16) -> Result<(), String> {
382 + if matches!(port, 25 | 465 | 587) {
383 + return Err(format!(
384 + "IMAP_PORT {port} is an SMTP port. Use 143 with IMAP_USE_STARTTLS=true or 993 with IMAP_USE_TLS=true"
385 + ));
386 + }
387 + Ok(())
388 + }
389 +
369 390 /// Basic email format validation
370 391 ///
371 392 /// Validates that the email contains an @ symbol and has a valid format.
@@ -410,4 +431,14 @@ mod tests {
410 431 assert!(!EmailConfig::is_valid_email("user@"));
411 432 assert!(!EmailConfig::is_valid_email(""));
412 433 }
434 +
435 + #[test]
436 + fn test_validate_imap_port_rejects_smtp_ports() {
437 + for port in [25_u16, 465, 587] {
438 + let err = EmailConfig::validate_imap_port(port).unwrap_err();
439 + assert!(err.contains("SMTP port"), "{err}");
440 + }
441 + assert!(EmailConfig::validate_imap_port(143).is_ok());
442 + assert!(EmailConfig::validate_imap_port(993).is_ok());
443 + }
413 444 }
M crates/rustwarden-core/src/config/security_headers.rs
+31 -32
@@ -4,10 +4,11 @@
4 4 //! `RustwardenApp::builder()`: a configurable `Content-Security-Policy`
5 5 //! plus opt-in `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy`.
6 6 //!
7 -//! The [`Default`] implementation is **bit-identical** to the headers
8 -//! shipped since patch 1.0.1: the default CSP keeps `'unsafe-inline'` and
9 -//! `'unsafe-eval'` (required by the stock Alpine.js build used by the admin
10 -//! UI), and COOP/CORP are not emitted at all.
7 +//! The [`Default`] implementation ships the phase-2 CSP: `'unsafe-inline'`
8 +//! stays (inline boot scripts and HTMX), `'unsafe-eval'` is gone because the
9 +//! reference UI loads Alpine's CSP build. The historical 1.0.1 policy remains
10 +//! available as [`DEFAULT_CONTENT_SECURITY_POLICY`] for consumers that still
11 +//! need `eval`. COOP/CORP are not emitted at all.
11 12 //!
12 13 //! The other hardcoded headers (`X-Content-Type-Options`, `X-Frame-Options`,
13 14 //! `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS) are
@@ -15,12 +16,15 @@
15 16
16 17 use std::borrow::Cow;
17 18
18 -/// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first
19 -/// implementation PR (Alpine.js still needs 'unsafe-eval').
19 +/// Historical CSP shipped in 1.0.1, including `'unsafe-eval'`.
20 +///
21 +/// Kept so a consumer that still evaluates Alpine expressions with the stock
22 +/// build can opt back in:
23 +/// `SecurityHeadersConfig { content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY), ..SecurityHeadersConfig::default() }`.
20 24 pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
21 25
22 -/// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build).
23 -/// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of RFC 0039.
26 +/// Default CSP since RFC 0039 phase 2 (Alpine CSP build, no `'unsafe-eval'`).
27 +/// `'unsafe-inline'` remains until a later CSP phase.
24 28 pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
25 29
26 30 /// Configurable security headers applied by the framework's security
@@ -30,13 +34,12 @@ pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self'
30 34 ///
31 35 /// ```
32 36 /// use rustwarden_core::config::security_headers::{
33 -/// DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig,
37 +/// CSP_WITHOUT_UNSAFE_EVAL, SecurityHeadersConfig,
34 38 /// };
35 39 ///
36 -/// // The default configuration matches the 1.0.1 behaviour exactly:
37 -/// // legacy CSP, no COOP, no CORP.
40 +/// // The default CSP has no 'unsafe-eval'. COOP and CORP stay opt-in.
38 41 /// let cfg = SecurityHeadersConfig::default();
39 -/// assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
42 +/// assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL);
40 43 /// assert!(cfg.cross_origin_opener_policy.is_none());
41 44 /// assert!(cfg.cross_origin_resource_policy.is_none());
42 45 ///
@@ -47,7 +50,7 @@ pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self'
47 50 /// ```
48 51 #[derive(Clone, Debug)]
49 52 pub struct SecurityHeadersConfig {
50 - /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval).
53 + /// `Content-Security-Policy` value. Default is [`CSP_WITHOUT_UNSAFE_EVAL`].
51 54 pub content_security_policy: Cow<'static, str>,
52 55 /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in).
53 56 pub cross_origin_opener_policy: Option<Cow<'static, str>>,
@@ -58,7 +61,7 @@ pub struct SecurityHeadersConfig {
58 61 impl Default for SecurityHeadersConfig {
59 62 fn default() -> Self {
60 63 Self {
61 - content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY),
64 + content_security_policy: Cow::Borrowed(CSP_WITHOUT_UNSAFE_EVAL),
62 65 cross_origin_opener_policy: None,
63 66 cross_origin_resource_policy: None,
64 67 }
@@ -99,7 +102,7 @@ impl SecurityHeadersConfig {
99 102 /// .allowing_script_origin("https://stats.example.org");
100 103 ///
101 104 /// assert!(cfg.content_security_policy.contains(
102 - /// "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://stats.example.org"
105 + /// "script-src 'self' 'unsafe-inline' https://stats.example.org"
103 106 /// ));
104 107 /// assert!(cfg.content_security_policy.contains(
105 108 /// "connect-src 'self' https://stats.example.org"
@@ -176,17 +179,16 @@ fn add_source_to_directives(policy: &str, source: &str, directives: &[&str]) ->
176 179 mod tests {
177 180 use super::*;
178 181
179 - /// Non-drift guard (RFC 0039 testing plan): the default CSP must stay
180 - /// strictly equal to the literal string historically hardcoded in
181 - /// `security_headers_middleware` (app.rs, patch 1.0.1). If this test
182 - /// breaks, the default behaviour changed — that is Phase 2 territory
183 - /// and requires its own PR + CHANGELOG `Security` entry.
182 + /// Phase 2 (RFC 0039): the default policy is the CSP build, without
183 + /// `'unsafe-eval'`. The historical 1.0.1 literal stays on the constant
184 + /// so consumers can opt back in.
184 185 #[test]
185 - fn default_csp_matches_the_historical_literal() {
186 + fn default_csp_omits_unsafe_eval() {
186 187 let cfg = SecurityHeadersConfig::default();
187 - assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
188 + assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL);
189 + assert!(!cfg.content_security_policy.contains("unsafe-eval"));
188 190 assert_eq!(
189 - cfg.content_security_policy,
191 + DEFAULT_CONTENT_SECURITY_POLICY,
190 192 "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
191 193 );
192 194 }
@@ -210,7 +212,7 @@ mod tests {
210 212 Some("same-origin")
211 213 );
212 214 // The CSP is untouched by the isolation helper.
213 - assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
215 + assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL);
214 216 }
215 217
216 218 /// The Phase 2 candidate constant only differs from the default by the
@@ -258,12 +260,7 @@ mod tests {
258 260 // Existing script-src sources are kept, the origin is appended.
259 261 assert_eq!(
260 262 sources_of(policy, "script-src"),
261 - vec![
262 - "'self'",
263 - "'unsafe-inline'",
264 - "'unsafe-eval'",
265 - "https://stats.example.org"
266 - ]
263 + vec!["'self'", "'unsafe-inline'", "https://stats.example.org"]
267 264 );
268 265 }
269 266
@@ -271,7 +268,9 @@ mod tests {
271 268 fn allowing_script_origin_is_idempotent() {
272 269 let once =
273 270 SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org");
274 - let twice = once.clone().allowing_script_origin("https://stats.example.org");
271 + let twice = once
272 + .clone()
273 + .allowing_script_origin("https://stats.example.org");
275 274 assert_eq!(once.content_security_policy, twice.content_security_policy);
276 275 }
277 276
@@ -307,6 +306,6 @@ mod tests {
307 306 #[test]
308 307 fn allowing_empty_origin_is_a_noop() {
309 308 let cfg = SecurityHeadersConfig::default().allowing_script_origin(" ");
310 - assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
309 + assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL);
311 310 }
312 311 }
M crates/rustwarden-core/src/services/imap_bounce_service.rs
+7 -4
@@ -345,11 +345,14 @@ impl ImapBounceService {
345 345 .select(mailbox)
346 346 .map_err(|e| AppError::Internal(format!("IMAP select error: {}", e)))?;
347 347
348 - // Search for unread DSN messages (Content-Type contains "delivery-status")
349 - // We search for messages with specific subjects indicating bounces
348 + // RFC 3464: a DSN is multipart/report with report-type=delivery-status.
349 + // HEADER matches the top-level Content-Type, so both tokens are needed.
350 + // IMAP OR is a prefix operator, not an infix one.
350 351 let search_result = session
351 - .search("UNSEEN SUBJECT \"Delivery Status Notification\" OR UNSEEN SUBJECT \"Undelivered\" OR UNSEEN SUBJECT \"Mail delivery failed\"")
352 - .unwrap_or_default();
352 + .search(
353 + r#"OR (UNSEEN HEADER Content-Type "delivery-status") (UNSEEN HEADER Content-Type "multipart/report")"#,
354 + )
355 + .map_err(|e| AppError::Internal(format!("IMAP search error: {e}")))?;
353 356
354 357 let mut bounces = Vec::new();
355 358
M crates/rustwarden-fediverse-ui/src/handlers/admin.rs
+73 -0
@@ -156,6 +156,8 @@ pub struct FediverseSettingsTemplate {
156 156 pub app_domain: String,
157 157 /// Short app description (header).
158 158 pub app_description: String,
159 + /// Audience-measurement opt-out. Empty renders nothing.
160 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
159 161 /// Username for the navbar avatar.
160 162 pub username: String,
161 163 /// Email for the navbar tooltip.
@@ -245,6 +247,7 @@ pub async fn get_settings(
245 247 Extension(claims): Extension<Claims>,
246 248 Extension(i18n_service): Extension<Arc<I18nService>>,
247 249 Extension(locale): Extension<Locale>,
250 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
248 251 State(db): State<DatabaseConnection>,
249 252 session: Session,
250 253 axum::extract::Query(query): axum::extract::Query<SavedQuery>,
@@ -283,6 +286,12 @@ pub async fn get_settings(
283 286 app_version: base_context.app_version,
284 287 app_domain: base_context.app_domain,
285 288 app_description: base_context.app_description,
289 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
290 + &analytics,
291 + &i18n_service,
292 + locale.as_str(),
293 + )
294 + .await,
286 295 username: base_context.username,
287 296 email: base_context.email,
288 297 initials: base_context.initials,
@@ -463,6 +472,8 @@ pub struct FediverseTrustListTemplate {
463 472 pub app_version: String,
464 473 pub app_domain: String,
465 474 pub app_description: String,
475 + /// Audience-measurement opt-out. Empty renders nothing.
476 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
466 477 pub username: String,
467 478 pub email: String,
468 479 pub initials: String,
@@ -532,6 +543,7 @@ pub async fn get_trust_list(
532 543 Extension(claims): Extension<Claims>,
533 544 Extension(i18n_service): Extension<Arc<I18nService>>,
534 545 Extension(locale): Extension<Locale>,
546 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
535 547 State(db): State<DatabaseConnection>,
536 548 session: Session,
537 549 axum::extract::Query(query): axum::extract::Query<SavedQuery>,
@@ -598,6 +610,12 @@ pub async fn get_trust_list(
598 610 app_version: base_context.app_version,
599 611 app_domain: base_context.app_domain,
600 612 app_description: base_context.app_description,
613 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
614 + &analytics,
615 + &i18n_service,
616 + locale.as_str(),
617 + )
618 + .await,
601 619 username: base_context.username,
602 620 email: base_context.email,
603 621 initials: base_context.initials,
@@ -790,6 +808,8 @@ pub struct FediverseQueueTemplate {
790 808 pub app_version: String,
791 809 pub app_domain: String,
792 810 pub app_description: String,
811 + /// Audience-measurement opt-out. Empty renders nothing.
812 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
793 813 pub username: String,
794 814 pub email: String,
795 815 pub initials: String,
@@ -854,6 +874,7 @@ pub async fn get_queue(
854 874 Extension(claims): Extension<Claims>,
855 875 Extension(i18n_service): Extension<Arc<I18nService>>,
856 876 Extension(locale): Extension<Locale>,
877 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
857 878 State(db): State<DatabaseConnection>,
858 879 session: Session,
859 880 ) -> Result<axum::response::Response, AppError> {
@@ -924,6 +945,12 @@ pub async fn get_queue(
924 945 app_version: base_context.app_version,
925 946 app_domain: base_context.app_domain,
926 947 app_description: base_context.app_description,
948 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
949 + &analytics,
950 + &i18n_service,
951 + locale.as_str(),
952 + )
953 + .await,
927 954 username: base_context.username,
928 955 email: base_context.email,
929 956 initials: base_context.initials,
@@ -1020,6 +1047,8 @@ pub struct FediverseSessionsRemoteTemplate {
1020 1047 pub app_version: String,
1021 1048 pub app_domain: String,
1022 1049 pub app_description: String,
1050 + /// Audience-measurement opt-out. Empty renders nothing.
1051 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
1023 1052 pub username: String,
1024 1053 pub email: String,
1025 1054 pub initials: String,
@@ -1073,6 +1102,7 @@ pub async fn get_sessions_remote(
1073 1102 Extension(claims): Extension<Claims>,
1074 1103 Extension(i18n_service): Extension<Arc<I18nService>>,
1075 1104 Extension(locale): Extension<Locale>,
1105 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1076 1106 State(db): State<DatabaseConnection>,
1077 1107 session: Session,
1078 1108 axum::extract::Query(query): axum::extract::Query<SavedQuery>,
@@ -1129,6 +1159,12 @@ pub async fn get_sessions_remote(
1129 1159 app_version: base_context.app_version,
1130 1160 app_domain: base_context.app_domain,
1131 1161 app_description: base_context.app_description,
1162 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
1163 + &analytics,
1164 + &i18n_service,
1165 + locale.as_str(),
1166 + )
1167 + .await,
1132 1168 username: base_context.username,
1133 1169 email: base_context.email,
1134 1170 initials: base_context.initials,
@@ -1269,6 +1305,8 @@ pub struct FediverseNodeInfoTemplate {
1269 1305 pub app_version: String,
1270 1306 pub app_domain: String,
1271 1307 pub app_description: String,
1308 + /// Audience-measurement opt-out. Empty renders nothing.
1309 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
1272 1310 pub username: String,
1273 1311 pub email: String,
1274 1312 pub initials: String,
@@ -1333,6 +1371,7 @@ pub async fn get_nodeinfo(
1333 1371 Extension(claims): Extension<Claims>,
1334 1372 Extension(i18n_service): Extension<Arc<I18nService>>,
1335 1373 Extension(locale): Extension<Locale>,
1374 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1336 1375 State(db): State<DatabaseConnection>,
1337 1376 session: Session,
1338 1377 ) -> Result<axum::response::Response, AppError> {
@@ -1405,6 +1444,12 @@ pub async fn get_nodeinfo(
1405 1444 app_version: base_context.app_version,
1406 1445 app_domain: base_context.app_domain,
1407 1446 app_description: base_context.app_description,
1447 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
1448 + &analytics,
1449 + &i18n_service,
1450 + locale.as_str(),
1451 + )
1452 + .await,
1408 1453 username: base_context.username,
1409 1454 email: base_context.email,
1410 1455 initials: base_context.initials,
@@ -1511,6 +1556,8 @@ pub struct FediverseTeamInvitesTemplate {
1511 1556 pub app_version: String,
1512 1557 pub app_domain: String,
1513 1558 pub app_description: String,
1559 + /// Audience-measurement opt-out. Empty renders nothing.
1560 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
1514 1561 pub username: String,
1515 1562 pub email: String,
1516 1563 pub initials: String,
@@ -1566,10 +1613,12 @@ rustwarden_core::impl_template_response!(FediverseTeamInvitesTemplate);
1566 1613 const INVITE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
1567 1614
1568 1615 /// `GET /admin/fediverse/invites/{team_id}` — list pending invites.
1616 +#[allow(clippy::too_many_arguments)]
1569 1617 pub async fn get_team_invites(
1570 1618 Extension(claims): Extension<Claims>,
1571 1619 Extension(i18n_service): Extension<Arc<I18nService>>,
1572 1620 Extension(locale): Extension<Locale>,
1621 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1573 1622 State(db): State<DatabaseConnection>,
1574 1623 session: Session,
1575 1624 axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
@@ -1624,6 +1673,12 @@ pub async fn get_team_invites(
1624 1673 app_version: base_context.app_version,
1625 1674 app_domain: base_context.app_domain,
1626 1675 app_description: base_context.app_description,
1676 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
1677 + &analytics,
1678 + &i18n_service,
1679 + locale.as_str(),
1680 + )
1681 + .await,
1627 1682 username: base_context.username,
1628 1683 email: base_context.email,
1629 1684 initials: base_context.initials,
@@ -1767,6 +1822,8 @@ pub struct FediverseHubTemplate {
1767 1822 pub app_version: String,
1768 1823 pub app_domain: String,
1769 1824 pub app_description: String,
1825 + /// Audience-measurement opt-out. Empty renders nothing.
1826 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
1770 1827 pub username: String,
1771 1828 pub email: String,
1772 1829 pub initials: String,
@@ -1799,6 +1856,7 @@ pub async fn get_hub(
1799 1856 Extension(claims): Extension<Claims>,
1800 1857 Extension(i18n_service): Extension<Arc<I18nService>>,
1801 1858 Extension(locale): Extension<Locale>,
1859 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1802 1860 State(db): State<DatabaseConnection>,
1803 1861 session: Session,
1804 1862 ) -> Result<impl IntoResponse, AppError> {
@@ -1870,6 +1928,12 @@ pub async fn get_hub(
1870 1928 app_version: base_context.app_version,
1871 1929 app_domain: base_context.app_domain,
1872 1930 app_description: base_context.app_description,
1931 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
1932 + &analytics,
1933 + &i18n_service,
1934 + locale.as_str(),
1935 + )
1936 + .await,
1873 1937 username: base_context.username,
1874 1938 email: base_context.email,
1875 1939 initials: base_context.initials,
@@ -1918,6 +1982,8 @@ pub struct FediverseTeamsForInvitesTemplate {
1918 1982 pub app_version: String,
1919 1983 pub app_domain: String,
1920 1984 pub app_description: String,
1985 + /// Audience-measurement opt-out. Empty renders nothing.
1986 + pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
1921 1987 pub username: String,
1922 1988 pub email: String,
1923 1989 pub initials: String,
@@ -1952,6 +2018,7 @@ pub async fn get_teams_for_invites(
1952 2018 Extension(claims): Extension<Claims>,
1953 2019 Extension(i18n_service): Extension<Arc<I18nService>>,
1954 2020 Extension(locale): Extension<Locale>,
2021 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1955 2022 State(db): State<DatabaseConnection>,
1956 2023 session: Session,
1957 2024 ) -> Result<axum::response::Response, AppError> {
@@ -1993,6 +2060,12 @@ pub async fn get_teams_for_invites(
1993 2060 app_version: base_context.app_version,
1994 2061 app_domain: base_context.app_domain,
1995 2062 app_description: base_context.app_description,
2063 + analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build(
2064 + &analytics,
2065 + &i18n_service,
2066 + locale.as_str(),
2067 + )
2068 + .await,
1996 2069 username: base_context.username,
1997 2070 email: base_context.email,
1998 2071 initials: base_context.initials,
M crates/rustwarden-fediverse-ui/templates/admin/sessions_remote.html
+4 -5
@@ -7,7 +7,7 @@
7 7 {% endblock %}
8 8
9 9 {% block content %}
10 -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, revokeTarget: null, killDomain: null }">
10 +<div class="container mx-auto px-4 py-8" x-data="fediversePage" data-saved="{% if saved_flash %}true{% else %}false{% endif %}">
11 11
12 12 <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb">
13 13 <ul>
@@ -28,7 +28,6 @@
28 28 {# Toast on save #}
29 29 <div
30 30 x-show="saved"
31 - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })"
32 31 x-transition
33 32 role="status"
34 33 aria-live="polite"
@@ -58,7 +57,7 @@
58 57 type="button"
59 58 class="btn btn-error"
60 59 :disabled="!killDomain"
61 - @click="if (killDomain) { document.getElementById('kill-domain-modal').showModal() }"
60 + @click="openKillModal()"
62 61 >
63 62 {{ kill_switch_button }}
64 63 </button>
@@ -128,7 +127,7 @@
128 127 <h3 id="revoke-title" class="font-bold text-lg">{{ revoke_confirm_title }}</h3>
129 128 <p class="py-4">
130 129 {{ revoke_confirm_body }}
131 - <span class="font-mono block mt-2" x-text="revokeTarget ? revokeTarget.split('|')[1] : ''"></span>
130 + <span class="font-mono block mt-2" x-text="revokeHandle()"></span>
132 131 </p>
133 132 <form method="POST" action="/admin/fediverse/sessions_remote/revoke" class="modal-action">
134 133 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -153,7 +152,7 @@
153 152 <form method="POST" action="/admin/fediverse/sessions_remote/kill_domain" class="modal-action">
154 153 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
155 154 <input type="hidden" name="domain" :value="killDomain">
156 - <button type="button" class="btn btn-ghost" @click="document.getElementById('kill-domain-modal').close()">{{ modal_cancel }}</button>
155 + <button type="button" class="btn btn-ghost" @click="closeKillModal()">{{ modal_cancel }}</button>
157 156 <button type="submit" class="btn btn-error">{{ modal_confirm }}</button>
158 157 </form>
159 158 </div>
M crates/rustwarden-fediverse-ui/templates/admin/settings.html
+1 -2
@@ -7,7 +7,7 @@
7 7 {% endblock %}
8 8
9 9 {% block content %}
10 -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %} }">
10 +<div class="container mx-auto px-4 py-8" x-data="fediversePage" data-saved="{% if saved_flash %}true{% else %}false{% endif %}">
11 11
12 12 {# Breadcrumb (a11y P7: parent landmarks) #}
13 13 <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb">
@@ -30,7 +30,6 @@
30 30 {# Save toast (Plan UI §3 P3) — auto-dismisses after 4 s #}
31 31 <div
32 32 x-show="saved"
33 - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })"
34 33 x-transition
35 34 role="status"
36 35 aria-live="polite"
M crates/rustwarden-fediverse-ui/templates/admin/team_invites.html
+2 -3
@@ -7,7 +7,7 @@
7 7 {% endblock %}
8 8
9 9 {% block content %}
10 -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, revokeTarget: null }">
10 +<div class="container mx-auto px-4 py-8" x-data="fediversePage" data-saved="{% if saved_flash %}true{% else %}false{% endif %}">
11 11
12 12 <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb">
13 13 <ul>
@@ -27,7 +27,6 @@
27 27
28 28 <div
29 29 x-show="saved"
30 - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })"
31 30 x-transition
32 31 role="status"
33 32 aria-live="polite"
@@ -153,7 +152,7 @@
153 152 <h3 id="revoke-title" class="font-bold text-lg">{{ revoke_confirm_title }}</h3>
154 153 <p class="py-4">
155 154 {{ revoke_confirm_body }}
156 - <span class="font-mono block mt-2" x-text="revokeTarget ? revokeTarget.split('|')[1] : ''"></span>
155 + <span class="font-mono block mt-2" x-text="revokeHandle()"></span>
157 156 </p>
158 157 <form method="POST" action="/admin/fediverse/invites/{{ team_id }}/revoke" class="modal-action">
159 158 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
M crates/rustwarden-fediverse-ui/templates/admin/trust_list.html
+1 -2
@@ -7,7 +7,7 @@
7 7 {% endblock %}
8 8
9 9 {% block content %}
10 -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, deleteTarget: null }">
10 +<div class="container mx-auto px-4 py-8" x-data="fediversePage" data-saved="{% if saved_flash %}true{% else %}false{% endif %}">
11 11
12 12 {# Breadcrumb #}
13 13 <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb">
@@ -37,7 +37,6 @@
37 37 {# Toast on save (Plan UI §3 P3) #}
38 38 <div
39 39 x-show="saved"
40 - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })"
41 40 x-transition
42 41 role="status"
43 42 aria-live="polite"
M crates/rustwarden-ui/locales/de.yml
+47 -15
@@ -4,9 +4,9 @@
4 4 # Anmeldung
5 5 login:
6 6 title: "Anmeldung"
7 - page_title: "Anmeldung - RustCloud"
8 - page_description: "Anmeldung bei RustCloud"
9 - subtitle: "Melden Sie sich in Ihrem Cloud-Bereich an"
7 + page_title: "Anmeldung"
8 + page_description: "Anmeldung bei Ihrem Konto"
9 + subtitle: "Melden Sie sich bei Ihrem Konto an"
10 10 username: "Benutzername oder E-Mail"
11 11 password: "Passwort"
12 12 submit: "Anmelden"
@@ -36,7 +36,21 @@ login:
36 36 register_success: "Konto erfolgreich erstellt! Bitte überprüfen Sie Ihre E-Mail."
37 37 register_error: "Registrierungsfehler"
38 38 email_label: "E-Mail"
39 - footer_text: "Nextcloud Fork in Rust"
39 + footer_text: "Konten und Authentifizierung"
40 +
41 +# Link zum Zurücksetzen anfordern
42 +forgot_password:
43 + page_title: "Passwort vergessen"
44 + title: "Passwort vergessen"
45 + subtitle: "Geben Sie Ihre E-Mail-Adresse ein. Wenn ein Konto existiert, wird ein Link zum Zurücksetzen gesendet."
46 + email_label: "E-Mail-Adresse"
47 + email_placeholder: "sie@beispiel.de"
48 + submit: "Link senden"
49 + back_to_login: "Zurück zur Anmeldung"
50 + error_default: "Die Anfrage konnte nicht gesendet werden."
51 + success_default: "Wenn ein Konto für diese Adresse existiert, wurde eine E-Mail zum Zurücksetzen gesendet."
52 + error_missing_email: "Bitte geben Sie Ihre E-Mail ein."
53 + error_cannot_send: "Die Anfrage kann derzeit nicht gesendet werden."
40 54
41 55 # Bestätigungs-E-Mail erneut senden
42 56 resend_verification:
@@ -72,7 +86,7 @@ password_reset:
72 86
73 87 # 2FA-Anmeldeseite
74 88 login_2fa:
75 - page_title: "2FA-Überprüfung - RustCloud"
89 + page_title: "2FA-Überprüfung"
76 90 title: "Zwei-Schritt-Überprüfung"
77 91 subtitle: "Geben Sie den Code aus Ihrer Authenticator-App ein"
78 92 code_label: "Authentifizierungscode"
@@ -147,7 +161,7 @@ home:
147 161 # Präferenzen
148 162 preferences:
149 163 title: "Anzeigeeinstellungen"
150 - page_title: "Anzeigeeinstellungen - RustCloud"
164 + page_title: "Anzeigeeinstellungen"
151 165 subtitle: "Passen Sie das Erscheinungsbild der Benutzeroberfläche an"
152 166 theme: "Design"
153 167 theme_light: "Hell"
@@ -181,7 +195,7 @@ preferences:
181 195 error: "Fehler: {message}"
182 196 network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
183 197 sync_title: "Ihre Einstellungen sind synchronisiert"
184 - sync_desc: "Ihre Anzeigeeinstellungen werden gespeichert und automatisch auf allen Geräten angewendet, die mit Ihrem RustCloud-Konto verbunden sind."
198 + sync_desc: "Ihre Anzeigeeinstellungen werden gespeichert und automatisch auf allen Geräten angewendet, die mit Ihrem Konto verbunden sind."
185 199 preview_dark: "Vorschau: Dunkles Design wird angewendet"
186 200 preview_light: "Vorschau: Helles Design wird angewendet"
187 201 preview_auto: "Vorschau: Automatisches Design wird angewendet"
@@ -189,7 +203,7 @@ preferences:
189 203 # Profil
190 204 profile:
191 205 title: "Mein Profil"
192 - page_title: "Mein Profil - RustCloud"
206 + page_title: "Mein Profil"
193 207 subtitle: "Bearbeiten Sie Ihre persönlichen Informationen"
194 208 account_info: "Kontoinformationen"
195 209 username: "Benutzername"
@@ -204,8 +218,14 @@ profile:
204 218 error: "Fehler beim Aktualisieren des Profils"
205 219 email_label: "E-Mail-Adresse"
206 220 email_used_for: "Wird für die Anmeldung verwendet"
207 - email_warning: "⚠️ Sie werden nach der Änderung abgemeldet"
208 - email_change_info: "E-Mail-Änderungen erfordern aus Sicherheitsgründen eine erneute Anmeldung."
221 + email_warning: "Die aktuelle Adresse wird hier nicht geändert."
222 + email_change_info: "Ein Bestätigungslink geht an die neue Adresse. Die aktuelle Adresse erhält einen Hinweis. Das Konto ändert sich erst nach der Bestätigung."
223 + email_change_title: "E-Mail-Adresse ändern"
224 + new_email_label: "Neue Adresse"
225 + current_password_label: "Aktuelles Passwort"
226 + request_button: "Änderung anfordern"
227 + email_change_error: "Die Anfrage konnte nicht gesendet werden."
228 + network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
209 229 username_help: "3 bis 39 Zeichen (a-z, 0-9, -)"
210 230 additional_info: "Zusätzliche Informationen"
211 231 account_created: "Konto erstellt am"
@@ -247,6 +267,9 @@ admin:
247 267 audit_last_24h: "Letzte 24h"
248 268 audit_by_level: "Nach Stufe"
249 269 chart_label: "Logs nach Stufe"
270 + view_logs: "Logs ansehen"
271 + logs_card_title: "Audit-Logs"
272 + logs_card_desc: "Letzte aufgezeichnete Aktionen: E-Mail-Änderungen, blockierte Anmeldungen."
250 273 roles_card_title: "Rollenverwaltung"
251 274 roles_card_desc: "Rollen und granulare Berechtigungen verwalten"
252 275 roles_stat_title: "Rollen"
@@ -258,6 +281,14 @@ admin:
258 281 orgs_card_desc: "Organisationen und deren Mitglieder erstellen und verwalten"
259 282 orgs_stat_title: "Organisationen"
260 283 orgs_members_stat_title: "Mitglieder gesamt"
284 + logs:
285 + page_title: "Audit-Logs"
286 + heading: "Audit-Logs"
287 + empty: "Noch keine Einträge."
288 + col_date: "Datum"
289 + col_level: "Stufe"
290 + col_action: "Aktion"
291 + col_message: "Nachricht"
261 292 roles_list:
262 293 page_title: "Rollenverwaltung"
263 294 breadcrumb_roles: "Rollen"
@@ -367,6 +398,7 @@ admin:
367 398 username_readonly_hint: "Der Benutzername kann nach der Erstellung nicht geändert werden"
368 399 email_label: "E-Mail"
369 400 email_placeholder: "benutzer@example.com"
401 + email_change_hint: "Das Ändern der Adresse schreibt sie sofort, markiert die E-Mail als bestätigt und beendet die Sitzungen dieses Benutzers."
370 402 password_label: "Passwort"
371 403 password_optional: "Optional"
372 404 password_placeholder: "Leer lassen, um ein zufälliges Passwort zu generieren"
@@ -380,8 +412,8 @@ admin:
380 412 submit_edit: "Änderungen speichern"
381 413 settings:
382 414 title: "Anwendungseinstellungen"
383 - page_title: "Anwendungseinstellungen - Admin - RustCloud"
384 - subtitle: "Globale RustCloud-Konfiguration"
415 + page_title: "Anwendungseinstellungen - Admin"
416 + subtitle: "Globale Anwendungskonfiguration"
385 417 general: "Allgemein"
386 418 app_name: "Anwendungsname"
387 419 app_domain: "Domäne"
@@ -468,7 +500,7 @@ admin:
468 500 analytics_script_url_desc: 'Schreibgeschützt: Diese Adresse fließt in die Content Security Policy ein, die beim Start festgelegt wird. Konfiguration über ANALYTICS_SCRIPT_URL.'
469 501 analytics_not_configured: 'Es ist kein Skript zur Reichweitenmessung konfiguriert. Setzen Sie ANALYTICS_SCRIPT_URL und starten Sie die Anwendung neu, um diesen Bereich zu aktivieren.'
470 502 users_list:
471 - page_title: "Benutzerverwaltung - Admin - RustCloud"
503 + page_title: "Benutzerverwaltung - Admin"
472 504 title: "Benutzerverwaltung"
473 505 breadcrumb_users: "Benutzer"
474 506 create_user: "Benutzer erstellen"
@@ -523,7 +555,7 @@ admin:
523 555 # Passwort ändern
524 556 password:
525 557 title: "Mein Passwort ändern"
526 - page_title: "Mein Passwort ändern - RustCloud"
558 + page_title: "Mein Passwort ändern"
527 559 subtitle: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern. Eine Bestätigungs-E-Mail wird gesendet, um die Änderung zu validieren."
528 560 subtitle_no_email: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern."
529 561 must_change_message: "⚠️ Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können."
@@ -572,7 +604,7 @@ password:
572 604 # Sitzungen
573 605 sessions:
574 606 title: "Aktive Sitzungen"
575 - page_title: "Aktive Sitzungen - RustCloud"
607 + page_title: "Aktive Sitzungen"
576 608 subtitle: "Verwalten Sie Geräte, die mit Ihrem Konto verbunden sind"
577 609 current_session: "Aktuelle Sitzung"
578 610 current_badge: "Aktuell"
M crates/rustwarden-ui/locales/en.yml
+47 -15
@@ -4,9 +4,9 @@
4 4 # Login
5 5 login:
6 6 title: "Login"
7 - page_title: "Login - RustCloud"
8 - page_description: "Login to RustCloud"
9 - subtitle: "Sign in to your cloud space"
7 + page_title: "Login"
8 + page_description: "Sign in to your account"
9 + subtitle: "Sign in to your account"
10 10 username: "Username or email"
11 11 password: "Password"
12 12 submit: "Sign in"
@@ -36,7 +36,21 @@ login:
36 36 register_success: "Account created successfully! Please check your email."
37 37 register_error: "Registration error"
38 38 email_label: "Email"
39 - footer_text: "Nextcloud fork in Rust"
39 + footer_text: "Accounts and authentication"
40 +
41 +# Request a password-reset link
42 +forgot_password:
43 + page_title: "Forgot password"
44 + title: "Forgot password"
45 + subtitle: "Enter your email address. If an account exists, a reset link will be sent."
46 + email_label: "Email address"
47 + email_placeholder: "you@example.com"
48 + submit: "Send reset link"
49 + back_to_login: "Back to sign in"
50 + error_default: "Unable to send the request."
51 + success_default: "If an account exists for this address, a reset email has been sent."
52 + error_missing_email: "Please enter your email."
53 + error_cannot_send: "Unable to send the request right now."
40 54
41 55 # Resend verification email
42 56 resend_verification:
@@ -72,7 +86,7 @@ password_reset:
72 86
73 87 # 2FA Login page
74 88 login_2fa:
75 - page_title: "2FA Verification - RustCloud"
89 + page_title: "2FA Verification"
76 90 title: "Two-Step Verification"
77 91 subtitle: "Enter the code from your authenticator app"
78 92 code_label: "Authentication code"
@@ -147,7 +161,7 @@ home:
147 161 # Preferences
148 162 preferences:
149 163 title: "Display Preferences"
150 - page_title: "Display Preferences - RustCloud"
164 + page_title: "Display Preferences"
151 165 subtitle: "Customize the interface appearance"
152 166 theme: "Theme"
153 167 theme_light: "Light"
@@ -181,7 +195,7 @@ preferences:
181 195 error: "Error: {message}"
182 196 network_error: "Network error. Please try again."
183 197 sync_title: "Your preferences are synchronized"
184 - sync_desc: "Your display preferences are saved and automatically apply to all devices connected to your RustCloud account."
198 + sync_desc: "Your display preferences are saved and automatically apply to all devices connected to your account."
185 199 preview_dark: "Preview: Dark theme will be applied"
186 200 preview_light: "Preview: Light theme will be applied"
187 201 preview_auto: "Preview: Automatic theme will be applied"
@@ -189,7 +203,7 @@ preferences:
189 203 # Profile
190 204 profile:
191 205 title: "My Profile"
192 - page_title: "My Profile - RustCloud"
206 + page_title: "My Profile"
193 207 subtitle: "Edit your personal information"
194 208 account_info: "Account Information"
195 209 username: "Username"
@@ -204,8 +218,14 @@ profile:
204 218 error: "Error updating profile"
205 219 email_label: "Email address"
206 220 email_used_for: "Used for login"
207 - email_warning: "⚠️ You will be logged out after modification"
208 - email_change_info: "Email changes require reconnection for security reasons."
221 + email_warning: "The current address is not changed here."
222 + email_change_info: "A confirmation link is sent to the new address. The current address receives a notice. The account changes only after confirmation."
223 + email_change_title: "Change email address"
224 + new_email_label: "New address"
225 + current_password_label: "Current password"
226 + request_button: "Request change"
227 + email_change_error: "Unable to send the request."
228 + network_error: "Network error. Please try again."
209 229 username_help: "3 to 39 characters (a-z, 0-9, -)"
210 230 additional_info: "Additional Information"
211 231 account_created: "Account created on"
@@ -247,6 +267,9 @@ admin:
247 267 audit_last_24h: "Last 24h"
248 268 audit_by_level: "By level"
249 269 chart_label: "Logs by level"
270 + view_logs: "View logs"
271 + logs_card_title: "Audit logs"
272 + logs_card_desc: "Latest recorded actions: email changes, blocked sign-ins."
250 273 roles_card_title: "Roles Management"
251 274 roles_card_desc: "Manage roles and granular permissions"
252 275 roles_stat_title: "Roles"
@@ -260,6 +283,14 @@ admin:
260 283 orgs_card_desc: "Create and manage organisations and their members"
261 284 orgs_stat_title: "Organisations"
262 285 orgs_members_stat_title: "Total members"
286 + logs:
287 + page_title: "Audit logs"
288 + heading: "Audit logs"
289 + empty: "No entries yet."
290 + col_date: "Date"
291 + col_level: "Level"
292 + col_action: "Action"
293 + col_message: "Message"
263 294 roles_list:
264 295 page_title: "Roles Management"
265 296 breadcrumb_roles: "Roles"
@@ -369,6 +400,7 @@ admin:
369 400 username_readonly_hint: "Username cannot be changed after creation"
370 401 email_label: "Email"
371 402 email_placeholder: "user@example.com"
403 + email_change_hint: "Changing the address writes it immediately, marks the email as verified, and closes this user's sessions."
372 404 password_label: "Password"
373 405 password_optional: "Optional"
374 406 password_placeholder: "Leave empty to generate a random password"
@@ -382,8 +414,8 @@ admin:
382 414 submit_edit: "Save changes"
383 415 settings:
384 416 title: "Application Settings"
385 - page_title: "Application Settings - Admin - RustCloud"
386 - subtitle: "Global RustCloud configuration"
417 + page_title: "Application Settings - Admin"
418 + subtitle: "Global application configuration"
387 419 general: "General"
388 420 app_name: "Application name"
389 421 app_domain: "Domain"
@@ -470,7 +502,7 @@ admin:
470 502 analytics_script_url_desc: 'Read-only: this address feeds the Content Security Policy, which is frozen at startup. Configure it through ANALYTICS_SCRIPT_URL.'
471 503 analytics_not_configured: 'No audience-measurement script is configured. Set ANALYTICS_SCRIPT_URL and restart the application to enable this section.'
472 504 users_list:
473 - page_title: "User Management - Admin - RustCloud"
505 + page_title: "User Management - Admin"
474 506 title: "User Management"
475 507 breadcrumb_users: "Users"
476 508 create_user: "Create user"
@@ -525,7 +557,7 @@ admin:
525 557 # Password Change
526 558 password:
527 559 title: "Change my password"
528 - page_title: "Change my password - RustCloud"
560 + page_title: "Change my password"
529 561 subtitle: "Modify your password to secure your account. A confirmation email will be sent to validate the change."
530 562 subtitle_no_email: "Modify your password to secure your account."
531 563 must_change_message: "⚠️ You must change your password before continuing."
@@ -581,7 +613,7 @@ password:
581 613 # Sessions
582 614 sessions:
583 615 title: "Active sessions"
584 - page_title: "Active sessions - RustCloud"
616 + page_title: "Active sessions"
585 617 subtitle: "Manage devices connected to your account"
586 618 current_session: "Current session"
587 619 current_badge: "Current"
M crates/rustwarden-ui/locales/es.yml
+47 -15
@@ -4,9 +4,9 @@
4 4 # Inicio de sesión
5 5 login:
6 6 title: "Iniciar sesión"
7 - page_title: "Iniciar sesión - RustCloud"
8 - page_description: "Iniciar sesión en RustCloud"
9 - subtitle: "Inicie sesión en su espacio en la nube"
7 + page_title: "Iniciar sesión"
8 + page_description: "Inicie sesión en su cuenta"
9 + subtitle: "Inicie sesión en su cuenta"
10 10 username: "Nombre de usuario o email"
11 11 password: "Contraseña"
12 12 submit: "Iniciar sesión"
@@ -36,7 +36,21 @@ login:
36 36 register_success: "¡Cuenta creada con éxito! Por favor verifique su email."
37 37 register_error: "Error en el registro"
38 38 email_label: "Correo electrónico"
39 - footer_text: "Fork de Nextcloud en Rust"
39 + footer_text: "Cuentas y autenticación"
40 +
41 +# Solicitar enlace de restablecimiento
42 +forgot_password:
43 + page_title: "Contraseña olvidada"
44 + title: "Contraseña olvidada"
45 + subtitle: "Ingrese su correo electrónico. Si existe una cuenta, se enviará un enlace de restablecimiento."
46 + email_label: "Dirección de correo electrónico"
47 + email_placeholder: "usted@ejemplo.com"
48 + submit: "Enviar el enlace"
49 + back_to_login: "Volver al inicio de sesión"
50 + error_default: "No se pudo enviar la solicitud."
51 + success_default: "Si existe una cuenta para esta dirección, se ha enviado un email de restablecimiento."
52 + error_missing_email: "Por favor ingrese su correo electrónico."
53 + error_cannot_send: "No se puede enviar la solicitud en este momento."
40 54
41 55 # Reenviar email de verificación
42 56 resend_verification:
@@ -72,7 +86,7 @@ password_reset:
72 86
73 87 # Página de inicio de sesión 2FA
74 88 login_2fa:
75 - page_title: "Verificación 2FA - RustCloud"
89 + page_title: "Verificación 2FA"
76 90 title: "Verificación en dos pasos"
77 91 subtitle: "Ingrese el código de su aplicación de autenticación"
78 92 code_label: "Código de autenticación"
@@ -147,7 +161,7 @@ home:
147 161 # Preferencias
148 162 preferences:
149 163 title: "Preferencias de visualización"
150 - page_title: "Preferencias de visualización - RustCloud"
164 + page_title: "Preferencias de visualización"
151 165 subtitle: "Personalice la apariencia de la interfaz"
152 166 theme: "Tema"
153 167 theme_light: "Claro"
@@ -181,7 +195,7 @@ preferences:
181 195 error: "Error: {message}"
182 196 network_error: "Error de red. Por favor intente nuevamente."
183 197 sync_title: "Sus preferencias están sincronizadas"
184 - sync_desc: "Sus preferencias de visualización se guardan y se aplican automáticamente en todos los dispositivos conectados a su cuenta RustCloud."
198 + sync_desc: "Sus preferencias de visualización se guardan y se aplican automáticamente en todos los dispositivos conectados a su cuenta."
185 199 preview_dark: "Vista previa: Se aplicará el tema oscuro"
186 200 preview_light: "Vista previa: Se aplicará el tema claro"
187 201 preview_auto: "Vista previa: Se aplicará el tema automático"
@@ -189,7 +203,7 @@ preferences:
189 203 # Perfil
190 204 profile:
191 205 title: "Mi Perfil"
192 - page_title: "Mi Perfil - RustCloud"
206 + page_title: "Mi Perfil"
193 207 subtitle: "Edite su información personal"
194 208 account_info: "Información de la cuenta"
195 209 username: "Nombre de usuario"
@@ -204,8 +218,14 @@ profile:
204 218 error: "Error al actualizar el perfil"
205 219 email_label: "Dirección de correo electrónico"
206 220 email_used_for: "Se utiliza para iniciar sesión"
207 - email_warning: "⚠️ Será desconectado después de la modificación"
208 - email_change_info: "Los cambios de correo electrónico requieren reconexión por razones de seguridad."
221 + email_warning: "La dirección actual no se cambia aquí."
222 + email_change_info: "Se envía un enlace de confirmación a la nueva dirección. La dirección actual recibe un aviso. La cuenta cambia solo después de la confirmación."
223 + email_change_title: "Cambiar la dirección de email"
224 + new_email_label: "Nueva dirección"
225 + current_password_label: "Contraseña actual"
226 + request_button: "Solicitar el cambio"
227 + email_change_error: "No se pudo enviar la solicitud."
228 + network_error: "Error de red. Inténtelo de nuevo."
209 229 username_help: "De 3 a 39 caracteres (a-z, 0-9, -)"
210 230 additional_info: "Información adicional"
211 231 account_created: "Cuenta creada el"
@@ -247,6 +267,9 @@ admin:
247 267 audit_last_24h: "Últimas 24h"
248 268 audit_by_level: "Por nivel"
249 269 chart_label: "Logs por nivel"
270 + view_logs: "Ver los logs"
271 + logs_card_title: "Logs de auditoría"
272 + logs_card_desc: "Últimas acciones registradas: cambios de email, inicios de sesión bloqueados."
250 273 roles_card_title: "Gestión de Roles"
251 274 roles_card_desc: "Gestionar roles y permisos granulares"
252 275 roles_stat_title: "Roles"
@@ -258,6 +281,14 @@ admin:
258 281 orgs_card_desc: "Crear y gestionar organizaciones y sus miembros"
259 282 orgs_stat_title: "Organizaciones"
260 283 orgs_members_stat_title: "Miembros totales"
284 + logs:
285 + page_title: "Logs de auditoría"
286 + heading: "Logs de auditoría"
287 + empty: "Todavía no hay entradas."
288 + col_date: "Fecha"
289 + col_level: "Nivel"
290 + col_action: "Acción"
291 + col_message: "Mensaje"
261 292 roles_list:
262 293 page_title: "Gestión de roles"
263 294 breadcrumb_roles: "Roles"
@@ -367,6 +398,7 @@ admin:
367 398 username_readonly_hint: "El nombre de usuario no se puede cambiar después de la creación"
368 399 email_label: "Email"
369 400 email_placeholder: "usuario@example.com"
401 + email_change_hint: "Cambiar la dirección la escribe de inmediato, marca el email como verificado y cierra las sesiones de este usuario."
370 402 password_label: "Contraseña"
371 403 password_optional: "Opcional"
372 404 password_placeholder: "Dejar vacío para generar una contraseña aleatoria"
@@ -380,8 +412,8 @@ admin:
380 412 submit_edit: "Guardar cambios"
381 413 settings:
382 414 title: "Configuración de la aplicación"
383 - page_title: "Configuración de la aplicación - Admin - RustCloud"
384 - subtitle: "Configuración global de RustCloud"
415 + page_title: "Configuración de la aplicación - Admin"
416 + subtitle: "Configuración global de la aplicación"
385 417 general: "General"
386 418 app_name: "Nombre de la aplicación"
387 419 app_domain: "Dominio"
@@ -468,7 +500,7 @@ admin:
468 500 analytics_script_url_desc: 'Solo lectura: esta dirección alimenta la política de seguridad de contenido, fijada al arrancar. Configúrela mediante ANALYTICS_SCRIPT_URL.'
469 501 analytics_not_configured: 'No hay ningún script de medición de audiencia configurado. Defina ANALYTICS_SCRIPT_URL y reinicie la aplicación para activar esta sección.'
470 502 users_list:
471 - page_title: "Gestión de Usuarios - Admin - RustCloud"
503 + page_title: "Gestión de Usuarios - Admin"
472 504 title: "Gestión de Usuarios"
473 505 breadcrumb_users: "Usuarios"
474 506 create_user: "Crear usuario"
@@ -523,7 +555,7 @@ admin:
523 555 # Cambio de contraseña
524 556 password:
525 557 title: "Cambiar mi contraseña"
526 - page_title: "Cambiar mi contraseña - RustCloud"
558 + page_title: "Cambiar mi contraseña"
527 559 subtitle: "Modifique su contraseña para asegurar su cuenta. Se enviará un correo electrónico de confirmación para validar el cambio."
528 560 subtitle_no_email: "Modifique su contraseña para asegurar su cuenta."
529 561 must_change_message: "⚠️ Debe cambiar su contraseña antes de continuar."
@@ -572,7 +604,7 @@ password:
572 604 # Sesiones
573 605 sessions:
574 606 title: "Sesiones activas"
575 - page_title: "Sesiones activas - RustCloud"
607 + page_title: "Sesiones activas"
576 608 subtitle: "Gestione los dispositivos conectados a su cuenta"
577 609 current_session: "Sesión actual"
578 610 current_badge: "Actual"
M crates/rustwarden-ui/locales/fr.yml
+47 -15
@@ -4,9 +4,9 @@
4 4 # Connexion
5 5 login:
6 6 title: "Connexion"
7 - page_title: "Connexion - RustCloud"
8 - page_description: "Connexion à RustCloud"
9 - subtitle: "Connectez-vous à votre espace cloud"
7 + page_title: "Connexion"
8 + page_description: "Connexion à votre compte"
9 + subtitle: "Connectez-vous à votre compte"
10 10 username: "Nom d'utilisateur ou email"
11 11 password: "Mot de passe"
12 12 submit: "Se connecter"
@@ -36,7 +36,21 @@ login:
36 36 register_success: "Compte créé avec succès ! Veuillez vérifier votre email."
37 37 register_error: "Erreur lors de l'inscription"
38 38 email_label: "Email"
39 - footer_text: "Fork de Nextcloud en Rust"
39 + footer_text: "Comptes et authentification"
40 +
41 +# Demande de lien de réinitialisation
42 +forgot_password:
43 + page_title: "Mot de passe oublié"
44 + title: "Mot de passe oublié"
45 + subtitle: "Entrez votre adresse email. Si un compte existe, un lien de réinitialisation vous sera envoyé."
46 + email_label: "Adresse email"
47 + email_placeholder: "vous@example.com"
48 + submit: "Envoyer le lien"
49 + back_to_login: "Revenir à la connexion"
50 + error_default: "Impossible d'envoyer la demande."
51 + success_default: "Si un compte existe pour cette adresse, un email de réinitialisation a été envoyé."
52 + error_missing_email: "Veuillez renseigner votre email."
53 + error_cannot_send: "Impossible d'envoyer la demande pour l'instant."
40 54
41 55 # Renvoyer l'email de validation
42 56 resend_verification:
@@ -72,7 +86,7 @@ password_reset:
72 86
73 87 # Page 2FA login
74 88 login_2fa:
75 - page_title: "Vérification 2FA - RustCloud"
89 + page_title: "Vérification 2FA"
76 90 title: "Vérification en deux étapes"
77 91 subtitle: "Entrez le code de votre application d'authentification"
78 92 code_label: "Code d'authentification"
@@ -147,7 +161,7 @@ home:
147 161 # Préférences
148 162 preferences:
149 163 title: "Préférences d'affichage"
150 - page_title: "Préférences d'affichage - RustCloud"
164 + page_title: "Préférences d'affichage"
151 165 subtitle: "Personnalisez l'apparence de l'interface"
152 166 theme: "Thème"
153 167 theme_light: "Clair"
@@ -181,7 +195,7 @@ preferences:
181 195 error: "Erreur : {message}"
182 196 network_error: "Erreur réseau. Veuillez réessayer."
183 197 sync_title: "Vos préférences sont synchronisées"
184 - sync_desc: "Vos préférences d'affichage sont enregistrées et s'appliquent automatiquement sur tous vos appareils connectés à votre compte RustCloud."
198 + sync_desc: "Vos préférences d'affichage sont enregistrées et s'appliquent automatiquement sur tous vos appareils connectés à votre compte."
185 199 preview_dark: "Aperçu : Thème sombre sera appliqué"
186 200 preview_light: "Aperçu : Thème clair sera appliqué"
187 201 preview_auto: "Aperçu : Thème automatique sera appliqué"
@@ -189,7 +203,7 @@ preferences:
189 203 # Profil
190 204 profile:
191 205 title: "Mon Profil"
192 - page_title: "Mon Profil - RustCloud"
206 + page_title: "Mon Profil"
193 207 subtitle: "Modifiez vos informations personnelles"
194 208 account_info: "Informations du compte"
195 209 username: "Nom d'utilisateur"
@@ -204,8 +218,14 @@ profile:
204 218 error: "Erreur lors de la mise à jour du profil"
205 219 email_label: "Adresse email"
206 220 email_used_for: "Utilisée pour la connexion"
207 - email_warning: "⚠️ Vous serez déconnecté après la modification"
208 - email_change_info: "Les modifications d'email nécessitent une reconnexion pour des raisons de sécurité."
221 + email_warning: "L'adresse actuelle ne se modifie pas ici."
222 + email_change_info: "Un lien de confirmation est envoyé à la nouvelle adresse. L'adresse actuelle reçoit un avis. Le compte ne change qu'après confirmation."
223 + email_change_title: "Changer l'adresse email"
224 + new_email_label: "Nouvelle adresse"
225 + current_password_label: "Mot de passe actuel"
226 + request_button: "Demander le changement"
227 + email_change_error: "Impossible d'envoyer la demande."
228 + network_error: "Erreur réseau. Veuillez réessayer."
209 229 username_help: "3 à 39 caractères (a-z, 0-9, -)"
210 230 additional_info: "Informations supplémentaires"
211 231 account_created: "Compte créé le"
@@ -248,6 +268,9 @@ admin:
248 268 audit_last_24h: "Dernières 24h"
249 269 audit_by_level: "Par niveau"
250 270 chart_label: "Logs par niveau"
271 + view_logs: "Voir les logs"
272 + logs_card_title: "Logs d'audit"
273 + logs_card_desc: "Dernières actions enregistrées : changements d'email, connexions bloquées."
251 274 roles_card_title: "Gestion des Rôles"
252 275 roles_card_desc: "Gérer les rôles et les permissions granulaires"
253 276 roles_stat_title: "Rôles"
@@ -261,6 +284,14 @@ admin:
261 284 orgs_card_desc: "Créer et gérer les organisations et leurs membres"
262 285 orgs_stat_title: "Organisations"
263 286 orgs_members_stat_title: "Membres totaux"
287 + logs:
288 + page_title: "Logs d'audit"
289 + heading: "Logs d'audit"
290 + empty: "Aucune entrée pour le moment."
291 + col_date: "Date"
292 + col_level: "Niveau"
293 + col_action: "Action"
294 + col_message: "Message"
264 295 roles_list:
265 296 page_title: "Gestion des rôles"
266 297 breadcrumb_roles: "Rôles"
@@ -370,6 +401,7 @@ admin:
370 401 username_readonly_hint: "Le nom d'utilisateur ne peut pas être modifié après création"
371 402 email_label: "Email"
372 403 email_placeholder: "utilisateur@example.com"
404 + email_change_hint: "Changer l'adresse l'écrit tout de suite, marque l'email comme vérifié et ferme les sessions de cet utilisateur."
373 405 password_label: "Mot de passe"
374 406 password_optional: "Optionnel"
375 407 password_placeholder: "Laisser vide pour générer un mot de passe aléatoire"
@@ -383,8 +415,8 @@ admin:
383 415 submit_edit: "Enregistrer les modifications"
384 416 settings:
385 417 title: "Paramètres de l'Application"
386 - page_title: "Paramètres de l'Application - Admin - RustCloud"
387 - subtitle: "Configuration globale de RustCloud"
418 + page_title: "Paramètres de l'Application - Admin"
419 + subtitle: "Configuration globale de l'application"
388 420 general: "Général"
389 421 app_name: "Nom de l'application"
390 422 app_domain: "Domaine"
@@ -470,7 +502,7 @@ admin:
470 502 analytics_script_url_desc: 'Lecture seule : cette adresse alimente la politique de sécurité du contenu, figée au démarrage. Elle se configure via ANALYTICS_SCRIPT_URL.'
471 503 analytics_not_configured: 'Aucun script de mesure d''audience n''est configuré. Définissez ANALYTICS_SCRIPT_URL puis redémarrez l''application pour activer cette section.'
472 504 users_list:
473 - page_title: "Gestion des Utilisateurs - Admin - RustCloud"
505 + page_title: "Gestion des Utilisateurs - Admin"
474 506 title: "Gestion des Utilisateurs"
475 507 breadcrumb_users: "Utilisateurs"
476 508 create_user: "Créer un utilisateur"
@@ -526,7 +558,7 @@ admin:
526 558 # Password Change
527 559 password:
528 560 title: "Changer mon mot de passe"
529 - page_title: "Changer mon mot de passe - RustCloud"
561 + page_title: "Changer mon mot de passe"
530 562 subtitle: "Modifiez votre mot de passe pour sécuriser votre compte. Un email de confirmation vous sera envoyé pour valider le changement."
531 563 subtitle_no_email: "Modifiez votre mot de passe pour sécuriser votre compte."
532 564 must_change_message: "⚠️ Vous devez changer votre mot de passe avant de continuer."
@@ -582,7 +614,7 @@ password:
582 614 # Sessions
583 615 sessions:
584 616 title: "Sessions actives"
585 - page_title: "Sessions actives - RustCloud"
617 + page_title: "Sessions actives"
586 618 subtitle: "Gérez les appareils connectés à votre compte"
587 619 current_session: "Session actuelle"
588 620 current_badge: "Actuelle"
M crates/rustwarden-ui/locales/it.yml
+47 -15
@@ -4,9 +4,9 @@
4 4 # Accesso
5 5 login:
6 6 title: "Accesso"
7 - page_title: "Accesso - RustCloud"
8 - page_description: "Accesso a RustCloud"
9 - subtitle: "Accedi al tuo spazio cloud"
7 + page_title: "Accesso"
8 + page_description: "Accesso al tuo account"
9 + subtitle: "Accedi al tuo account"
10 10 username: "Nome utente o email"
11 11 password: "Password"
12 12 submit: "Accedi"
@@ -36,7 +36,21 @@ login:
36 36 register_success: "Account creato con successo! Controlla la tua email."
37 37 register_error: "Errore durante la registrazione"
38 38 email_label: "Email"
39 - footer_text: "Fork di Nextcloud in Rust"
39 + footer_text: "Account e autenticazione"
40 +
41 +# Richiesta del link di reimpostazione
42 +forgot_password:
43 + page_title: "Password dimenticata"
44 + title: "Password dimenticata"
45 + subtitle: "Inserisci il tuo indirizzo email. Se esiste un account, verrà inviato un link di reimpostazione."
46 + email_label: "Indirizzo email"
47 + email_placeholder: "tu@example.com"
48 + submit: "Invia il link"
49 + back_to_login: "Torna all'accesso"
50 + error_default: "Impossibile inviare la richiesta."
51 + success_default: "Se esiste un account per questo indirizzo, è stata inviata un'email di reimpostazione."
52 + error_missing_email: "Inserisci la tua email."
53 + error_cannot_send: "Impossibile inviare la richiesta al momento."
40 54
41 55 # Reinvio email di verifica
42 56 resend_verification:
@@ -72,7 +86,7 @@ password_reset:
72 86
73 87 # Pagina di accesso 2FA
74 88 login_2fa:
75 - page_title: "Verifica 2FA - RustCloud"
89 + page_title: "Verifica 2FA"
76 90 title: "Verifica in due passaggi"
77 91 subtitle: "Inserisci il codice dalla tua app di autenticazione"
78 92 code_label: "Codice di autenticazione"
@@ -147,7 +161,7 @@ home:
147 161 # Preferenze
148 162 preferences:
149 163 title: "Preferenze di visualizzazione"
150 - page_title: "Preferenze di visualizzazione - RustCloud"
164 + page_title: "Preferenze di visualizzazione"
151 165 subtitle: "Personalizza l'aspetto dell'interfaccia"
152 166 theme: "Tema"
153 167 theme_light: "Chiaro"
@@ -181,7 +195,7 @@ preferences:
181 195 error: "Errore: {message}"
182 196 network_error: "Errore di rete. Riprova."
183 197 sync_title: "Le tue preferenze sono sincronizzate"
184 - sync_desc: "Le tue preferenze di visualizzazione sono salvate e si applicano automaticamente su tutti i dispositivi connessi al tuo account RustCloud."
198 + sync_desc: "Le tue preferenze di visualizzazione sono salvate e si applicano automaticamente su tutti i dispositivi connessi al tuo account."
185 199 preview_dark: "Anteprima: Tema scuro verrà applicato"
186 200 preview_light: "Anteprima: Tema chiaro verrà applicato"
187 201 preview_auto: "Anteprima: Tema automatico verrà applicato"
@@ -189,7 +203,7 @@ preferences:
189 203 # Profilo
190 204 profile:
191 205 title: "Il mio Profilo"
192 - page_title: "Il mio Profilo - RustCloud"
206 + page_title: "Il mio Profilo"
193 207 subtitle: "Modifica le tue informazioni personali"
194 208 account_info: "Informazioni dell'account"
195 209 username: "Nome utente"
@@ -204,8 +218,14 @@ profile:
204 218 error: "Errore durante l'aggiornamento del profilo"
205 219 email_label: "Indirizzo email"
206 220 email_used_for: "Utilizzato per l'accesso"
207 - email_warning: "⚠️ Verrai disconnesso dopo la modifica"
208 - email_change_info: "Le modifiche all'email richiedono una nuova connessione per motivi di sicurezza."
221 + email_warning: "L'indirizzo attuale non si modifica qui."
222 + email_change_info: "Un link di conferma viene inviato al nuovo indirizzo. L'indirizzo attuale riceve un avviso. L'account cambia solo dopo la conferma."
223 + email_change_title: "Cambiare l'indirizzo email"
224 + new_email_label: "Nuovo indirizzo"
225 + current_password_label: "Password attuale"
226 + request_button: "Richiedere la modifica"
227 + email_change_error: "Impossibile inviare la richiesta."
228 + network_error: "Errore di rete. Riprova."
209 229 username_help: "Da 3 a 39 caratteri (a-z, 0-9, -)"
210 230 additional_info: "Informazioni aggiuntive"
211 231 account_created: "Account creato il"
@@ -248,6 +268,9 @@ admin:
248 268 audit_last_24h: "Ultime 24h"
249 269 audit_by_level: "Per livello"
250 270 chart_label: "Log per livello"
271 + view_logs: "Vedi i log"
272 + logs_card_title: "Log di audit"
273 + logs_card_desc: "Ultime azioni registrate: cambi di email, accessi bloccati."
251 274 roles_card_title: "Gestione Ruoli"
252 275 roles_card_desc: "Gestisci i ruoli e i permessi granulari"
253 276 roles_stat_title: "Ruoli"
@@ -259,6 +282,14 @@ admin:
259 282 orgs_card_desc: "Crea e gestisci le organizzazioni e i loro membri"
260 283 orgs_stat_title: "Organizzazioni"
261 284 orgs_members_stat_title: "Membri totali"
285 + logs:
286 + page_title: "Log di audit"
287 + heading: "Log di audit"
288 + empty: "Nessuna voce per ora."
289 + col_date: "Data"
290 + col_level: "Livello"
291 + col_action: "Azione"
292 + col_message: "Messaggio"
262 293 roles_list:
263 294 page_title: "Gestione dei ruoli"
264 295 breadcrumb_roles: "Ruoli"
@@ -368,6 +399,7 @@ admin:
368 399 username_readonly_hint: "Il nome utente non può essere modificato dopo la creazione"
369 400 email_label: "Email"
370 401 email_placeholder: "utente@example.com"
402 + email_change_hint: "Cambiare l'indirizzo lo scrive subito, segna l'email come verificata e chiude le sessioni di questo utente."
371 403 password_label: "Password"
372 404 password_optional: "Opzionale"
373 405 password_placeholder: "Lascia vuoto per generare una password casuale"
@@ -381,8 +413,8 @@ admin:
381 413 submit_edit: "Salva modifiche"
382 414 settings:
383 415 title: "Impostazioni dell'Applicazione"
384 - page_title: "Impostazioni dell'Applicazione - Admin - RustCloud"
385 - subtitle: "Configurazione globale di RustCloud"
416 + page_title: "Impostazioni dell'Applicazione - Admin"
417 + subtitle: "Configurazione globale dell'applicazione"
386 418 general: "Generale"
387 419 app_name: "Nome dell'applicazione"
388 420 app_domain: "Dominio"
@@ -468,7 +500,7 @@ admin:
468 500 analytics_script_url_desc: 'Sola lettura: questo indirizzo alimenta la Content Security Policy, fissata all''avvio. Si configura tramite ANALYTICS_SCRIPT_URL.'
469 501 analytics_not_configured: 'Nessuno script di misurazione del pubblico è configurato. Imposta ANALYTICS_SCRIPT_URL e riavvia l''applicazione per attivare questa sezione.'
470 502 users_list:
471 - page_title: "Gestione Utenti - Admin - RustCloud"
503 + page_title: "Gestione Utenti - Admin"
472 504 title: "Gestione Utenti"
473 505 breadcrumb_users: "Utenti"
474 506 create_user: "Crea un utente"
@@ -524,7 +556,7 @@ admin:
524 556 # Cambio password
525 557 password:
526 558 title: "Cambia la mia password"
527 - page_title: "Cambia la mia password - RustCloud"
559 + page_title: "Cambia la mia password"
528 560 subtitle: "Modifica la tua password per proteggere il tuo account. Un'email di conferma ti verrà inviata per validare la modifica."
529 561 subtitle_no_email: "Modifica la tua password per proteggere il tuo account."
530 562 must_change_message: "⚠️ Devi cambiare la tua password prima di continuare."
@@ -580,7 +612,7 @@ password:
580 612 # Sessioni
581 613 sessions:
582 614 title: "Sessioni attive"
583 - page_title: "Sessioni attive - RustCloud"
615 + page_title: "Sessioni attive"
584 616 subtitle: "Gestisci i dispositivi connessi al tuo account"
585 617 current_session: "Sessione attuale"
586 618 current_badge: "Attuale"
M crates/rustwarden-ui/locales/pt.yml
+47 -15
@@ -4,9 +4,9 @@
4 4 # Acesso
5 5 login:
6 6 title: "Entrar"
7 - page_title: "Entrar - RustCloud"
8 - page_description: "Acesso ao RustCloud"
9 - subtitle: "Acesse seu espaço na nuvem"
7 + page_title: "Entrar"
8 + page_description: "Acesso à sua conta"
9 + subtitle: "Acesse sua conta"
10 10 username: "Nome de usuário ou email"
11 11 password: "Senha"
12 12 submit: "Entrar"
@@ -36,7 +36,21 @@ login:
36 36 register_success: "Conta criada com sucesso! Verifique seu email."
37 37 register_error: "Erro durante o registro"
38 38 email_label: "Email"
39 - footer_text: "Fork do Nextcloud em Rust"
39 + footer_text: "Contas e autenticação"
40 +
41 +# Pedido de link de redefinição
42 +forgot_password:
43 + page_title: "Senha esquecida"
44 + title: "Senha esquecida"
45 + subtitle: "Insira seu endereço de email. Se existir uma conta, um link de redefinição será enviado."
46 + email_label: "Endereço de email"
47 + email_placeholder: "voce@exemplo.com"
48 + submit: "Enviar o link"
49 + back_to_login: "Voltar ao login"
50 + error_default: "Não foi possível enviar a solicitação."
51 + success_default: "Se existir uma conta para este endereço, um email de redefinição foi enviado."
52 + error_missing_email: "Insira seu email."
53 + error_cannot_send: "Não foi possível enviar a solicitação no momento."
40 54
41 55 # Reenvio de email de verificação
42 56 resend_verification:
@@ -72,7 +86,7 @@ password_reset:
72 86
73 87 # Página de login 2FA
74 88 login_2fa:
75 - page_title: "Verificação 2FA - RustCloud"
89 + page_title: "Verificação 2FA"
76 90 title: "Verificação em duas etapas"
77 91 subtitle: "Insira o código do seu aplicativo de autenticação"
78 92 code_label: "Código de autenticação"
@@ -147,7 +161,7 @@ home:
147 161 # Preferências
148 162 preferences:
149 163 title: "Preferências de exibição"
150 - page_title: "Preferências de exibição - RustCloud"
164 + page_title: "Preferências de exibição"
151 165 subtitle: "Personalize a aparência da interface"
152 166 theme: "Tema"
153 167 theme_light: "Claro"
@@ -181,7 +195,7 @@ preferences:
181 195 error: "Erro: {message}"
182 196 network_error: "Erro de rede. Tente novamente."
183 197 sync_title: "Suas preferências estão sincronizadas"
184 - sync_desc: "Suas preferências de exibição estão salvas e se aplicam automaticamente em todos os dispositivos conectados à sua conta RustCloud."
198 + sync_desc: "Suas preferências de exibição estão salvas e se aplicam automaticamente em todos os dispositivos conectados à sua conta."
185 199 preview_dark: "Prévia: Tema escuro será aplicado"
186 200 preview_light: "Prévia: Tema claro será aplicado"
187 201 preview_auto: "Prévia: Tema automático será aplicado"
@@ -189,7 +203,7 @@ preferences:
189 203 # Perfil
190 204 profile:
191 205 title: "Meu Perfil"
192 - page_title: "Meu Perfil - RustCloud"
206 + page_title: "Meu Perfil"
193 207 subtitle: "Edite suas informações pessoais"
194 208 account_info: "Informações da conta"
195 209 username: "Nome de usuário"
@@ -204,8 +218,14 @@ profile:
204 218 error: "Erro ao atualizar o perfil"
205 219 email_label: "Endereço de email"
206 220 email_used_for: "Usado para login"
207 - email_warning: "⚠️ Você será desconectado após a alteração"
208 - email_change_info: "Alterações no email requerem uma nova conexão por motivos de segurança."
221 + email_warning: "O endereço atual não é alterado aqui."
222 + email_change_info: "Um link de confirmação é enviado ao novo endereço. O endereço atual recebe um aviso. A conta só muda após a confirmação."
223 + email_change_title: "Alterar o endereço de email"
224 + new_email_label: "Novo endereço"
225 + current_password_label: "Senha atual"
226 + request_button: "Pedir a alteração"
227 + email_change_error: "Não foi possível enviar a solicitação."
228 + network_error: "Erro de rede. Tente novamente."
209 229 username_help: "De 3 a 39 caracteres (a-z, 0-9, -)"
210 230 additional_info: "Informações adicionais"
211 231 account_created: "Conta criada em"
@@ -248,6 +268,9 @@ admin:
248 268 audit_last_24h: "Últimas 24h"
249 269 audit_by_level: "Por nível"
250 270 chart_label: "Logs por nível"
271 + view_logs: "Ver os logs"
272 + logs_card_title: "Logs de auditoria"
273 + logs_card_desc: "Últimas ações registradas: alterações de email, logins bloqueados."
251 274 roles_card_title: "Gerenciamento de Funções"
252 275 roles_card_desc: "Gerencie as funções e permissões granulares"
253 276 roles_stat_title: "Funções"
@@ -259,6 +282,14 @@ admin:
259 282 orgs_card_desc: "Crie e gerencie organizações e seus membros"
260 283 orgs_stat_title: "Organizações"
261 284 orgs_members_stat_title: "Membros totais"
285 + logs:
286 + page_title: "Logs de auditoria"
287 + heading: "Logs de auditoria"
288 + empty: "Nenhuma entrada por enquanto."
289 + col_date: "Data"
290 + col_level: "Nível"
291 + col_action: "Ação"
292 + col_message: "Mensagem"
262 293 roles_list:
263 294 page_title: "Gerenciamento de funções"
264 295 breadcrumb_roles: "Funções"
@@ -368,6 +399,7 @@ admin:
368 399 username_readonly_hint: "O nome de usuário não pode ser alterado após a criação"
369 400 email_label: "Email"
370 401 email_placeholder: "usuario@exemplo.com"
402 + email_change_hint: "Alterar o endereço grava-o de imediato, marca o email como verificado e encerra as sessões deste usuário."
371 403 password_label: "Senha"
372 404 password_optional: "Opcional"
373 405 password_placeholder: "Deixe em branco para gerar uma senha aleatória"
@@ -381,8 +413,8 @@ admin:
381 413 submit_edit: "Salvar alterações"
382 414 settings:
383 415 title: "Configurações da Aplicação"
384 - page_title: "Configurações da Aplicação - Admin - RustCloud"
385 - subtitle: "Configuração global do RustCloud"
416 + page_title: "Configurações da Aplicação - Admin"
417 + subtitle: "Configuração global da aplicação"
386 418 general: "Geral"
387 419 app_name: "Nome da aplicação"
388 420 app_domain: "Domínio"
@@ -468,7 +500,7 @@ admin:
468 500 analytics_script_url_desc: 'Apenas leitura: este endereço alimenta a Content Security Policy, fixada no arranque. Configure-o através de ANALYTICS_SCRIPT_URL.'
469 501 analytics_not_configured: 'Nenhum script de medição de audiência está configurado. Defina ANALYTICS_SCRIPT_URL e reinicie a aplicação para ativar esta secção.'
470 502 users_list:
471 - page_title: "Gerenciamento de Usuários - Admin - RustCloud"
503 + page_title: "Gerenciamento de Usuários - Admin"
472 504 title: "Gerenciamento de Usuários"
473 505 breadcrumb_users: "Usuários"
474 506 create_user: "Criar um usuário"
@@ -524,7 +556,7 @@ admin:
524 556 # Alteração de senha
525 557 password:
526 558 title: "Alterar minha senha"
527 - page_title: "Alterar minha senha - RustCloud"
559 + page_title: "Alterar minha senha"
528 560 subtitle: "Altere sua senha para proteger sua conta. Um email de confirmação será enviado para validar a alteração."
529 561 subtitle_no_email: "Altere sua senha para proteger sua conta."
530 562 must_change_message: "⚠️ Você deve alterar sua senha antes de continuar."
@@ -580,7 +612,7 @@ password:
580 612 # Sessões
581 613 sessions:
582 614 title: "Sessões ativas"
583 - page_title: "Sessões ativas - RustCloud"
615 + page_title: "Sessões ativas"
584 616 subtitle: "Gerencie os dispositivos conectados à sua conta"
585 617 current_session: "Sessão atual"
586 618 current_badge: "Atual"
M crates/rustwarden-ui/src/handlers/admin_handler.rs
+316 -12
@@ -23,7 +23,7 @@ use rustwarden_core::impl_template_response;
23 23 use rustwarden_core::middleware::csrf;
24 24 use rustwarden_core::models::{role, user_role};
25 25 use rustwarden_core::services::app_settings_service::AppSettingsService;
26 -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
26 +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
27 27
28 28 #[derive(Debug, Clone)]
29 29 struct NavItem {
@@ -75,7 +75,7 @@ async fn load_app_domain_and_description(
75 75 let app_description = AppSettingsService::get_setting(db, "app_description")
76 76 .await?
77 77 .map(|s| s.value)
78 - .unwrap_or_else(|| "Secure password manager".to_string());
78 + .unwrap_or_else(|| "User accounts and authentication".to_string());
79 79 Ok((app_domain, app_description))
80 80 }
81 81
@@ -162,6 +162,7 @@ struct AdminDashboardTemplate {
162 162 app_version: String,
163 163 app_domain: String,
164 164 app_description: String,
165 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
165 166 // User fields
166 167 username: String,
167 168 email: String,
@@ -205,6 +206,9 @@ struct AdminDashboardTemplate {
205 206 audit_last_24h: String,
206 207 audit_by_level: String,
207 208 chart_label: String,
209 + view_logs: String,
210 + logs_card_title: String,
211 + logs_card_desc: String,
208 212 roles_card_title: String,
209 213 roles_card_desc: String,
210 214 roles_stat_title: String,
@@ -241,6 +245,7 @@ pub async fn admin_dashboard(
241 245 Extension(claims): Extension<Claims>,
242 246 Extension(i18n_service): Extension<Arc<I18nService>>,
243 247 Extension(locale): Extension<Locale>,
248 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
244 249 State(db): State<DatabaseConnection>,
245 250 extra_cards: Option<Extension<Vec<DashboardCardDTO>>>,
246 251 session: Session,
@@ -279,6 +284,7 @@ pub async fn admin_dashboard(
279 284 let total_roles = all_roles_list.len() as u64;
280 285
281 286 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
287 + let audit_stats = load_audit_stats(&db).await;
282 288
283 289 let template = AdminDashboardTemplate {
284 290 page_title: i18n_service
@@ -289,6 +295,12 @@ pub async fn admin_dashboard(
289 295 app_version: base_context.app_version,
290 296 app_domain,
291 297 app_description,
298 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
299 + &analytics,
300 + &i18n_service,
301 + locale.as_str(),
302 + )
303 + .await,
292 304 username: base_context.username,
293 305 email: base_context.email,
294 306 initials: base_context.initials,
@@ -300,13 +312,13 @@ pub async fn admin_dashboard(
300 312 active_sessions,
301 313 total_admins,
302 314 last_activity: "N/A".to_string(),
303 - has_audit_logs: false,
304 - audit_total_logs: 0,
305 - audit_logs_last_24h: 0,
306 - audit_debug_count: 0,
307 - audit_info_count: 0,
308 - audit_warn_count: 0,
309 - audit_error_count: 0,
315 + has_audit_logs: audit_stats.has_rows,
316 + audit_total_logs: audit_stats.total,
317 + audit_logs_last_24h: audit_stats.last_24h,
318 + audit_debug_count: audit_stats.debug,
319 + audit_info_count: audit_stats.info,
320 + audit_warn_count: audit_stats.warn,
321 + audit_error_count: audit_stats.error,
310 322 total_roles,
311 323 user_menu_profile: i18n_service
312 324 .translate("user_menu.profile", locale_str)
@@ -360,6 +372,15 @@ pub async fn admin_dashboard(
360 372 chart_label: i18n_service
361 373 .translate("admin.dashboard.chart_label", locale_str)
362 374 .await,
375 + view_logs: i18n_service
376 + .translate("admin.dashboard.view_logs", locale_str)
377 + .await,
378 + logs_card_title: i18n_service
379 + .translate("admin.dashboard.logs_card_title", locale_str)
380 + .await,
381 + logs_card_desc: i18n_service
382 + .translate("admin.dashboard.logs_card_desc", locale_str)
383 + .await,
363 384 roles_card_title: i18n_service
364 385 .translate("admin.dashboard.roles_card_title", locale_str)
365 386 .await,
@@ -399,6 +420,182 @@ pub async fn admin_dashboard(
399 420 Ok(template)
400 421 }
401 422
423 +struct AuditStats {
424 + has_rows: bool,
425 + total: u64,
426 + last_24h: u64,
427 + debug: u64,
428 + info: u64,
429 + warn: u64,
430 + error: u64,
431 +}
432 +
433 +/// Counts rows in `audit_logs`. A missing table leaves the dashboard section hidden.
434 +async fn load_audit_stats(db: &DatabaseConnection) -> AuditStats {
435 + use rustwarden_core::models::audit_log;
436 +
437 + let total = match audit_log::Entity::find().count(db).await {
438 + Ok(n) => n,
439 + Err(e) => {
440 + tracing::warn!("audit_logs count failed: {e}");
441 + return AuditStats {
442 + has_rows: false,
443 + total: 0,
444 + last_24h: 0,
445 + debug: 0,
446 + info: 0,
447 + warn: 0,
448 + error: 0,
449 + };
450 + }
451 + };
452 + let since = chrono::Utc::now() - chrono::Duration::hours(24);
453 + let last_24h = audit_log::Entity::find()
454 + .filter(audit_log::Column::CreatedAt.gte(since))
455 + .count(db)
456 + .await
457 + .unwrap_or(0);
458 + let count_level = |level: &'static str| async move {
459 + audit_log::Entity::find()
460 + .filter(audit_log::Column::Level.eq(level))
461 + .count(db)
462 + .await
463 + .unwrap_or(0)
464 + };
465 + AuditStats {
466 + has_rows: total > 0,
467 + total,
468 + last_24h,
469 + debug: count_level("DEBUG").await,
470 + info: count_level("INFO").await,
471 + warn: count_level("WARN").await,
472 + error: count_level("ERROR").await,
473 + }
474 +}
475 +
476 +struct AuditLogRow {
477 + created_at: String,
478 + level: String,
479 + action: String,
480 + message: String,
481 +}
482 +
483 +#[derive(Template)]
484 +#[template(path = "admin/logs.html")]
485 +struct AuditLogsTemplate {
486 + page_title: String,
487 + csrf_token: String,
488 + app_name: String,
489 + app_version: String,
490 + app_domain: String,
491 + app_description: String,
492 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
493 + username: String,
494 + email: String,
495 + initials: String,
496 + avatar_color: String,
497 + roles_text: String,
498 + nav_items: Vec<NavItem>,
499 + has_pending_password_change: bool,
500 + user_menu_profile: String,
501 + user_menu_logout: String,
502 + breadcrumb_home: String,
503 + breadcrumb_admin: String,
504 + heading: String,
505 + empty: String,
506 + col_date: String,
507 + col_level: String,
508 + col_action: String,
509 + col_message: String,
510 + rows: Vec<AuditLogRow>,
511 + has_rows: bool,
512 +}
513 +
514 +rustwarden_core::impl_template_response!(AuditLogsTemplate);
515 +
516 +/// GET /admin/logs — latest audit_logs rows (PROTECTED - Admin only).
517 +pub async fn admin_logs(
518 + Extension(claims): Extension<Claims>,
519 + Extension(i18n_service): Extension<Arc<I18nService>>,
520 + Extension(locale): Extension<Locale>,
521 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
522 + State(db): State<DatabaseConnection>,
523 + session: Session,
524 +) -> Result<impl IntoResponse, AppError> {
525 + use rustwarden_core::models::audit_log;
526 +
527 + let locale_str = locale.as_str();
528 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
529 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
530 + let (app_domain, app_description) = load_app_domain_and_description(&db).await?;
531 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
532 +
533 + let logs = audit_log::Entity::find()
534 + .order_by_desc(audit_log::Column::CreatedAt)
535 + .limit(100)
536 + .all(&db)
537 + .await
538 + .unwrap_or_default();
539 + let rows: Vec<AuditLogRow> = logs
540 + .into_iter()
541 + .map(|log| AuditLogRow {
542 + created_at: log.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
543 + level: log.level,
544 + action: log.action,
545 + message: log.message,
546 + })
547 + .collect();
548 + let has_rows = !rows.is_empty();
549 +
550 + Ok(AuditLogsTemplate {
551 + page_title: i18n_service
552 + .translate("admin.logs.page_title", locale_str)
553 + .await,
554 + csrf_token,
555 + app_name: base_context.app_name,
556 + app_version: base_context.app_version,
557 + app_domain,
558 + app_description,
559 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
560 + &analytics,
561 + &i18n_service,
562 + locale.as_str(),
563 + )
564 + .await,
565 + username: base_context.username,
566 + email: base_context.email,
567 + initials: base_context.initials,
568 + avatar_color: base_context.avatar_color,
569 + roles_text: base_context.roles.join(", "),
570 + nav_items,
571 + has_pending_password_change: false,
572 + user_menu_profile: i18n_service
573 + .translate("user_menu.profile", locale_str)
574 + .await,
575 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
576 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
577 + breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
578 + heading: i18n_service
579 + .translate("admin.logs.heading", locale_str)
580 + .await,
581 + empty: i18n_service.translate("admin.logs.empty", locale_str).await,
582 + col_date: i18n_service
583 + .translate("admin.logs.col_date", locale_str)
584 + .await,
585 + col_level: i18n_service
586 + .translate("admin.logs.col_level", locale_str)
587 + .await,
588 + col_action: i18n_service
589 + .translate("admin.logs.col_action", locale_str)
590 + .await,
591 + col_message: i18n_service
592 + .translate("admin.logs.col_message", locale_str)
593 + .await,
594 + rows,
595 + has_rows,
596 + })
597 +}
598 +
402 599 /// Query parameters for user list endpoint
403 600 #[derive(Debug, Deserialize)]
404 601 pub struct UserListQuery {
@@ -453,6 +650,7 @@ struct UserListTemplate {
453 650 app_version: String,
454 651 app_domain: String,
455 652 app_description: String,
653 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
456 654 username: String,
457 655 email: String,
458 656 initials: String,
@@ -547,6 +745,7 @@ struct UserFormTemplate {
547 745 app_version: String,
548 746 app_domain: String,
549 747 app_description: String,
748 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
550 749 username: String,
551 750 email: String,
552 751 initials: String,
@@ -577,6 +776,7 @@ struct UserFormTemplate {
577 776 username_readonly_hint: String,
578 777 email_label: String,
579 778 email_placeholder: String,
779 + email_change_hint: String,
580 780 password_label: String,
581 781 password_optional: String,
582 782 password_placeholder: String,
@@ -598,6 +798,7 @@ pub async fn list_users(
598 798 Extension(claims): Extension<Claims>,
599 799 Extension(i18n_service): Extension<Arc<I18nService>>,
600 800 Extension(locale): Extension<Locale>,
801 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
601 802 State(db): State<DatabaseConnection>,
602 803 session: Session,
603 804 Query(params): Query<UserListQuery>,
@@ -738,6 +939,12 @@ pub async fn list_users(
738 939 app_version: base_context.app_version,
739 940 app_domain,
740 941 app_description,
942 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
943 + &analytics,
944 + &i18n_service,
945 + locale.as_str(),
946 + )
947 + .await,
741 948 username: base_context.username,
742 949 email: base_context.email,
743 950 initials: base_context.initials,
@@ -929,6 +1136,7 @@ pub async fn create_user_form(
929 1136 Extension(claims): Extension<Claims>,
930 1137 Extension(i18n_service): Extension<Arc<I18nService>>,
931 1138 Extension(locale): Extension<Locale>,
1139 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
932 1140 State(db): State<DatabaseConnection>,
933 1141 session: Session,
934 1142 ) -> Result<impl IntoResponse, AppError> {
@@ -960,6 +1168,12 @@ pub async fn create_user_form(
960 1168 app_version: base_context.app_version,
961 1169 app_domain,
962 1170 app_description,
1171 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
1172 + &analytics,
1173 + &i18n_service,
1174 + locale.as_str(),
1175 + )
1176 + .await,
963 1177 username: base_context.username,
964 1178 email: base_context.email,
965 1179 initials: base_context.initials,
@@ -1009,6 +1223,9 @@ pub async fn create_user_form(
1009 1223 email_placeholder: i18n_service
1010 1224 .translate("admin.user_form.email_placeholder", locale_str)
1011 1225 .await,
1226 + email_change_hint: i18n_service
1227 + .translate("admin.user_form.email_change_hint", locale_str)
1228 + .await,
1012 1229 password_label: i18n_service
1013 1230 .translate("admin.user_form.password_label", locale_str)
1014 1231 .await,
@@ -1122,6 +1339,7 @@ pub async fn edit_user_form(
1122 1339 Extension(claims): Extension<Claims>,
1123 1340 Extension(i18n_service): Extension<Arc<I18nService>>,
1124 1341 Extension(locale): Extension<Locale>,
1342 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1125 1343 State(db): State<DatabaseConnection>,
1126 1344 session: Session,
1127 1345 axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
@@ -1173,6 +1391,12 @@ pub async fn edit_user_form(
1173 1391 app_version: base_context.app_version,
1174 1392 app_domain,
1175 1393 app_description,
1394 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
1395 + &analytics,
1396 + &i18n_service,
1397 + locale.as_str(),
1398 + )
1399 + .await,
1176 1400 username: base_context.username,
1177 1401 email: base_context.email,
1178 1402 initials: base_context.initials,
@@ -1222,6 +1446,9 @@ pub async fn edit_user_form(
1222 1446 email_placeholder: i18n_service
1223 1447 .translate("admin.user_form.email_placeholder", locale_str)
1224 1448 .await,
1449 + email_change_hint: i18n_service
1450 + .translate("admin.user_form.email_change_hint", locale_str)
1451 + .await,
1225 1452 password_label: i18n_service
1226 1453 .translate("admin.user_form.password_label", locale_str)
1227 1454 .await,
@@ -1288,9 +1515,26 @@ pub async fn update_user(
1288 1515 Some(form_data.email)
1289 1516 };
1290 1517
1291 - // Update email if provided
1518 + // RFC 0041: an admin address write goes through admin_force_change.
1519 + // update_profile refuses any real change. The same address is a no-op,
1520 + // so an unchanged field must not require SMTP.
1521 + // Changing one's own address revokes that account's sessions.
1522 + let mut self_email_change = false;
1292 1523 if let Some(ref new_email) = email {
1293 - UserService::update_profile(&db, user_id, None, Some(new_email.clone())).await?;
1524 + let current = UserService::get_user_by_id(&db, user_id)
1525 + .await?
1526 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
1527 + if new_email.trim() != current.email.as_str() {
1528 + use rustwarden_core::config::email::EmailConfig;
1529 + use rustwarden_core::services::email_change_service::EmailChangeService;
1530 +
1531 + let email_config = EmailConfig::from_env()
1532 + .map_err(|e| AppError::Config(format!("Email configuration error: {e}")))?;
1533 + EmailChangeService::new(email_config)
1534 + .admin_force_change(&db, claims.sub, user_id, new_email, None)
1535 + .await?;
1536 + self_email_change = claims.sub == user_id;
1537 + }
1294 1538 }
1295 1539
1296 1540 // Update must_change_password if provided
@@ -1322,7 +1566,29 @@ pub async fn update_user(
1322 1566 }
1323 1567
1324 1568 tracing::info!("Admin {} updated user {}", claims.sub, user_id);
1325 - Ok(axum::response::Redirect::to("/admin/users"))
1569 +
1570 + // The edit form is an HTMX request targeting #form-messages. A 303 would
1571 + // be followed and the next page (the user list, or the login page once
1572 + // this account's sessions are revoked) would be inserted into that div.
1573 + // HX-Redirect navigates the whole window instead.
1574 + let (message, redirect) = if self_email_change {
1575 + (
1576 + "Adresse mise à jour. Reconnectez-vous : les sessions de ce compte ont été fermées.",
1577 + "/login",
1578 + )
1579 + } else {
1580 + ("Utilisateur mis à jour.", "/admin/users")
1581 + };
1582 + let success_template = FormMessageTemplate {
1583 + message_type: "success".to_string(),
1584 + message: message.to_string(),
1585 + };
1586 + let mut response = success_template.into_response();
1587 + response.headers_mut().insert(
1588 + axum::http::HeaderName::from_static("hx-redirect"),
1589 + axum::http::HeaderValue::from_static(redirect),
1590 + );
1591 + Ok(response)
1326 1592 }
1327 1593
1328 1594 /// Handler for DELETE /admin/users/:id - Delete user (PROTECTED - Admin only)
@@ -1479,6 +1745,7 @@ struct AdminSettingsTemplate {
1479 1745 app_version: String,
1480 1746 app_domain: String,
1481 1747 app_description: String,
1748 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
1482 1749 username: String,
1483 1750 email: String,
1484 1751 initials: String,
@@ -1722,6 +1989,12 @@ pub async fn get_app_settings(
1722 1989 app_version: base_context.app_version,
1723 1990 app_domain,
1724 1991 app_description,
1992 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
1993 + &analytics,
1994 + &i18n_service,
1995 + locale.as_str(),
1996 + )
1997 + .await,
1725 1998 username: base_context.username,
1726 1999 email: base_context.email,
1727 2000 initials: base_context.initials,
@@ -2357,6 +2630,7 @@ struct RolesListTemplate {
2357 2630 app_version: String,
2358 2631 app_domain: String,
2359 2632 app_description: String,
2633 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
2360 2634 username: String,
2361 2635 email: String,
2362 2636 initials: String,
@@ -2401,6 +2675,7 @@ struct RoleFormTemplate {
2401 2675 app_version: String,
2402 2676 app_domain: String,
2403 2677 app_description: String,
2678 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
2404 2679 username: String,
2405 2680 email: String,
2406 2681 initials: String,
@@ -2440,6 +2715,7 @@ pub async fn list_roles(
2440 2715 Extension(claims): Extension<Claims>,
2441 2716 Extension(i18n_service): Extension<Arc<I18nService>>,
2442 2717 Extension(locale): Extension<Locale>,
2718 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
2443 2719 State(db): State<DatabaseConnection>,
2444 2720 session: Session,
2445 2721 ) -> Result<impl IntoResponse, AppError> {
@@ -2479,6 +2755,12 @@ pub async fn list_roles(
2479 2755 app_version: base_context.app_version,
2480 2756 app_domain,
2481 2757 app_description,
2758 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
2759 + &analytics,
2760 + &i18n_service,
2761 + locale.as_str(),
2762 + )
2763 + .await,
2482 2764 username: base_context.username,
2483 2765 email: base_context.email,
2484 2766 initials: base_context.initials,
@@ -2580,6 +2862,7 @@ pub async fn create_role_form(
2580 2862 Extension(claims): Extension<Claims>,
2581 2863 Extension(i18n_service): Extension<Arc<I18nService>>,
2582 2864 Extension(locale): Extension<Locale>,
2865 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
2583 2866 State(db): State<DatabaseConnection>,
2584 2867 session: Session,
2585 2868 ) -> Result<impl IntoResponse, AppError> {
@@ -2600,6 +2883,12 @@ pub async fn create_role_form(
2600 2883 app_version: base_context.app_version,
2601 2884 app_domain,
2602 2885 app_description,
2886 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
2887 + &analytics,
2888 + &i18n_service,
2889 + locale.as_str(),
2890 + )
2891 + .await,
2603 2892 username: base_context.username,
2604 2893 email: base_context.email,
2605 2894 initials: base_context.initials,
@@ -2730,6 +3019,7 @@ pub async fn edit_role_form(
2730 3019 Extension(claims): Extension<Claims>,
2731 3020 Extension(i18n_service): Extension<Arc<I18nService>>,
2732 3021 Extension(locale): Extension<Locale>,
3022 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
2733 3023 State(db): State<DatabaseConnection>,
2734 3024 session: Session,
2735 3025 axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
@@ -2765,6 +3055,12 @@ pub async fn edit_role_form(
2765 3055 app_version: base_context.app_version,
2766 3056 app_domain,
2767 3057 app_description,
3058 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
3059 + &analytics,
3060 + &i18n_service,
3061 + locale.as_str(),
3062 + )
3063 + .await,
2768 3064 username: base_context.username,
2769 3065 email: base_context.email,
2770 3066 initials: base_context.initials,
@@ -2903,6 +3199,7 @@ struct AdminErrorsTemplate {
2903 3199 app_version: String,
2904 3200 app_domain: String,
2905 3201 app_description: String,
3202 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
2906 3203 username: String,
2907 3204 email: String,
2908 3205 initials: String,
@@ -2943,6 +3240,7 @@ pub async fn admin_errors(
2943 3240 Extension(claims): Extension<Claims>,
2944 3241 Extension(i18n_service): Extension<Arc<I18nService>>,
2945 3242 Extension(locale): Extension<Locale>,
3243 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
2946 3244 session: Session,
2947 3245 State(db): State<DatabaseConnection>,
2948 3246 ) -> Result<impl IntoResponse, AppError> {
@@ -3084,6 +3382,12 @@ pub async fn admin_errors(
3084 3382 app_version: base_context.app_version,
3085 3383 app_domain,
3086 3384 app_description,
3385 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
3386 + &analytics,
3387 + &i18n_service,
3388 + locale.as_str(),
3389 + )
3390 + .await,
3087 3391 username: base_context.username,
3088 3392 email: base_context.email,
3089 3393 initials: base_context.initials,
D crates/rustwarden-ui/src/handlers/dashboard_handler.rs
+0 -34
@@ -1,34 +0,0 @@
1 -//! Dashboard Handler
2 -//!
3 -//! Handles the dashboard widgets API endpoint.
4 -//! Simplified for Rustwarden - no modular app system.
5 -
6 -use axum::{Json, response::IntoResponse};
7 -use serde::Serialize;
8 -
9 -/// DTO for a dashboard widget (API response)
10 -#[derive(Debug, Clone, Serialize)]
11 -pub struct DashboardWidgetDto {
12 - /// Unique widget identifier.
13 - pub id: String,
14 - /// Identifier of the application that owns this widget.
15 - pub app_id: String,
16 - /// Display title shown in the widget header.
17 - pub title: String,
18 - /// Front-end component name used to render the widget.
19 - pub component: String,
20 - /// Display order relative to other widgets.
21 - pub order: i32,
22 - /// Size class of the widget (e.g. `"small"`, `"large"`).
23 - pub size: String,
24 -}
25 -
26 -/// List dashboard widgets for active apps.
27 -///
28 -/// GET /api/v1/dashboard/widgets
29 -///
30 -/// Returns empty list (no modular app system in Rustwarden).
31 -pub async fn get_dashboard_widgets() -> impl IntoResponse {
32 - let widgets: Vec<DashboardWidgetDto> = vec![];
33 - Json(widgets)
34 -}
D crates/rustwarden-ui/src/handlers/email_handler.rs
+0 -275
@@ -1,275 +0,0 @@
1 -//! Email handler for admin email queue management
2 -//!
3 -//! This module provides admin endpoints for managing the email queue:
4 -//! - List emails (with pagination and filtering)
5 -//! - Get email details
6 -//! - Retry failed emails
7 -
8 -use crate::dto::{
9 - EmailDeliveryStatusListResponse, EmailDeliveryStatusResponse, EmailQueueDetailResponse,
10 - EmailQueueEntry, EmailQueueListResponse, RetryEmailResponse,
11 -};
12 -use axum::{
13 - Json,
14 - extract::{Path, Query, State},
15 - http::StatusCode,
16 - response::IntoResponse,
17 -};
18 -use chrono::{DateTime, Utc};
19 -use rustwarden_core::error::AppError;
20 -use rustwarden_core::services::email_queue_service::EmailQueueService;
21 -use sea_orm::DatabaseConnection;
22 -use serde::Deserialize;
23 -use uuid::Uuid;
24 -
25 -/// Query parameters for listing emails
26 -#[derive(Debug, Deserialize)]
27 -pub struct ListEmailsQuery {
28 - /// Status filter (pending, sent, failed)
29 - pub status: Option<String>,
30 - /// Email type filter
31 - pub email_type: Option<String>,
32 - /// Limit (default: 50)
33 - pub limit: Option<usize>,
34 - /// Offset (default: 0)
35 - pub offset: Option<usize>,
36 -}
37 -
38 -/// Query parameters for listing delivery statuses
39 -#[derive(Debug, Deserialize)]
40 -pub struct ListEmailStatusQuery {
41 - /// Recipient filter
42 - pub recipient: Option<String>,
43 - /// Email type filter
44 - pub email_type: Option<String>,
45 - /// State filter
46 - pub state: Option<String>,
47 - /// Since filter (ISO8601)
48 - pub since: Option<DateTime<Utc>>,
49 - /// Limit (default: 50)
50 - pub limit: Option<usize>,
51 -}
52 -
53 -/// List emails from the queue (admin only)
54 -///
55 -/// GET /api/v1/admin/emails
56 -///
57 -/// # Query Parameters
58 -///
59 -/// - `status`: Filter by status (pending, sent, failed)
60 -/// - `email_type`: Filter by email type
61 -/// - `limit`: Maximum number of results (default: 50)
62 -/// - `offset`: Offset for pagination (default: 0)
63 -///
64 -/// # Returns
65 -///
66 -/// List of email queue entries with pagination info
67 -pub async fn list_emails(
68 - State(db): State<DatabaseConnection>,
69 - Query(params): Query<ListEmailsQuery>,
70 -) -> Result<impl IntoResponse, AppError> {
71 - let limit = params.limit.unwrap_or(50).min(100); // Max 100 per page
72 - let offset = params.offset.unwrap_or(0);
73 -
74 - // Get emails with filters applied at DB level
75 - let emails = EmailQueueService::get_all(
76 - &db,
77 - params.status.as_deref(),
78 - params.email_type.as_deref(),
79 - limit,
80 - offset,
81 - )
82 - .await?;
83 -
84 - // Convert to DTOs
85 - let email_entries: Vec<EmailQueueEntry> = emails
86 - .into_iter()
87 - .map(|e| EmailQueueEntry {
88 - id: e.id,
89 - to_email: e.to_email,
90 - to_name: e.to_name,
91 - subject: e.subject,
92 - email_type: e.email_type,
93 - status: e.status,
94 - attempts: e.attempts,
95 - smtp_code: e.smtp_code,
96 - smtp_response: e.smtp_response,
97 - created_at: e.created_at,
98 - sent_at: e.sent_at,
99 - })
100 - .collect();
101 -
102 - // TODO: Get total count for pagination (requires separate query)
103 - let total = email_entries.len() as u64; // Approximate - should query DB for exact count
104 -
105 - Ok((
106 - StatusCode::OK,
107 - Json(EmailQueueListResponse {
108 - emails: email_entries,
109 - total,
110 - limit,
111 - offset,
112 - }),
113 - ))
114 -}
115 -
116 -/// List email delivery statuses (admin)
117 -///
118 -/// GET /api/v1/admin/emails/status
119 -pub async fn list_email_status(
120 - State(db): State<DatabaseConnection>,
121 - Query(params): Query<ListEmailStatusQuery>,
122 -) -> Result<impl IntoResponse, AppError> {
123 - let limit = params.limit.unwrap_or(50).min(100);
124 -
125 - let items = EmailQueueService::list_delivery_statuses(
126 - &db,
127 - params.recipient.as_deref(),
128 - params.email_type.as_deref(),
129 - params.state.as_deref(),
130 - params.since,
131 - limit,
132 - )
133 - .await?;
134 -
135 - let dto_items: Vec<EmailDeliveryStatusResponse> = items
136 - .into_iter()
137 - .map(|s| EmailDeliveryStatusResponse {
138 - id: s.id,
139 - email_type: s.email_type,
140 - recipient: s.recipient,
141 - template: s.template,
142 - state: crate::dto::email_dto::EmailDeliveryState::parse(&s.state),
143 - smtp_code: s.smtp_code,
144 - smtp_response: s.smtp_response,
145 - attempt_count: s.attempt_count,
146 - last_attempt_at: s.last_attempt_at,
147 - last_state_at: s.last_state_at,
148 - correlation_id: s.correlation_id,
149 - created_at: s.created_at,
150 - updated_at: s.updated_at,
151 - })
152 - .collect();
153 -
154 - Ok((
155 - StatusCode::OK,
156 - Json(EmailDeliveryStatusListResponse {
157 - items: dto_items,
158 - next_cursor: None,
159 - }),
160 - ))
161 -}
162 -
163 -/// Get email delivery status by ID (admin)
164 -///
165 -/// GET /api/v1/admin/emails/{id}/status
166 -pub async fn get_email_status(
167 - State(db): State<DatabaseConnection>,
168 - Path(id): Path<Uuid>,
169 -) -> Result<impl IntoResponse, AppError> {
170 - let status = EmailQueueService::get_delivery_status_by_id(&db, id)
171 - .await?
172 - .ok_or_else(|| AppError::NotFound(format!("Email delivery status {} not found", id)))?;
173 -
174 - let dto = EmailDeliveryStatusResponse {
175 - id: status.id,
176 - email_type: status.email_type,
177 - recipient: status.recipient,
178 - template: status.template,
179 - state: crate::dto::email_dto::EmailDeliveryState::parse(&status.state),
180 - smtp_code: status.smtp_code,
181 - smtp_response: status.smtp_response,
182 - attempt_count: status.attempt_count,
183 - last_attempt_at: status.last_attempt_at,
184 - last_state_at: status.last_state_at,
185 - correlation_id: status.correlation_id,
186 - created_at: status.created_at,
187 - updated_at: status.updated_at,
188 - };
189 -
190 - Ok((StatusCode::OK, Json(dto)))
191 -}
192 -
193 -/// Get email details by ID (admin only)
194 -///
195 -/// GET /api/v1/admin/emails/:id
196 -///
197 -/// # Returns
198 -///
199 -/// Email queue entry details
200 -pub async fn get_email(
201 - State(db): State<DatabaseConnection>,
202 - Path(id): Path<Uuid>,
203 -) -> Result<impl IntoResponse, AppError> {
204 - let email = EmailQueueService::get_by_id(&db, id)
205 - .await?
206 - .ok_or_else(|| AppError::NotFound(format!("Email queue entry {} not found", id)))?;
207 -
208 - Ok((
209 - StatusCode::OK,
210 - Json(EmailQueueDetailResponse {
211 - id: email.id,
212 - to_email: email.to_email,
213 - to_name: email.to_name,
214 - from_email: email.from_email,
215 - from_name: email.from_name,
216 - subject: email.subject,
217 - body_html: email.body_html,
218 - body_text: email.body_text,
219 - email_type: email.email_type,
220 - status: email.status,
221 - attempts: email.attempts,
222 - last_attempt_at: email.last_attempt_at,
223 - sent_at: email.sent_at,
224 - error_message: email.error_message,
225 - smtp_code: email.smtp_code,
226 - smtp_response: email.smtp_response,
227 - created_at: email.created_at,
228 - updated_at: email.updated_at,
229 - }),
230 - ))
231 -}
232 -
233 -/// Retry a failed email (admin only)
234 -///
235 -/// POST /api/v1/admin/emails/:id/retry
236 -///
237 -/// # Returns
238 -///
239 -/// Success message with email status
240 -pub async fn retry_email(
241 - State(db): State<DatabaseConnection>,
242 - Path(id): Path<Uuid>,
243 -) -> Result<impl IntoResponse, AppError> {
244 - let email = EmailQueueService::get_by_id(&db, id)
245 - .await?
246 - .ok_or_else(|| AppError::NotFound(format!("Email queue entry {} not found", id)))?;
247 -
248 - // Only allow retrying failed emails
249 - if email.status != "failed" {
250 - return Err(AppError::Validation(format!(
251 - "Cannot retry email with status '{}'. Only 'failed' emails can be retried.",
252 - email.status
253 - )));
254 - }
255 -
256 - // Reset status to pending and clear error message
257 - use rustwarden_core::models::email_queue;
258 - use sea_orm::{ActiveModelTrait, Set};
259 -
260 - let mut email_active: email_queue::ActiveModel = email.into();
261 - email_active.status = Set("pending".to_string());
262 - email_active.error_message = Set(None);
263 - email_active.updated_at = Set(chrono::Utc::now());
264 -
265 - let updated_email = email_active.update(&db).await?;
266 -
267 - Ok((
268 - StatusCode::OK,
269 - Json(RetryEmailResponse {
270 - message: "Email queued for retry".to_string(),
271 - email_id: updated_email.id,
272 - status: updated_email.status,
273 - }),
274 - ))
275 -}
M crates/rustwarden-ui/src/handlers/home_handler.rs
+9 -1
@@ -31,6 +31,7 @@ struct HomeTemplate {
31 31 app_version: String,
32 32 app_domain: String,
33 33 app_description: String,
34 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
34 35 username: String,
35 36 email: String,
36 37 initials: String,
@@ -70,6 +71,7 @@ pub async fn home(
70 71 Extension(claims): Extension<Claims>,
71 72 Extension(i18n_service): Extension<Arc<I18nService>>,
72 73 Extension(locale): Extension<Locale>,
74 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
73 75 State(db): State<DatabaseConnection>,
74 76 ) -> Result<impl IntoResponse, AppError> {
75 77 let username = claims.username.clone();
@@ -148,7 +150,7 @@ pub async fn home(
148 150 let app_description = AppSettingsService::get_setting(&db, "app_description")
149 151 .await?
150 152 .map(|s| s.value)
151 - .unwrap_or_else(|| "Secure password manager".to_string());
153 + .unwrap_or_else(|| "User accounts and authentication".to_string());
152 154
153 155 let layout = rustwarden_core::templates::LayoutContext::new("", "");
154 156
@@ -159,6 +161,12 @@ pub async fn home(
159 161 app_version: layout.app_version.clone(),
160 162 app_domain,
161 163 app_description,
164 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
165 + &analytics,
166 + &i18n_service,
167 + locale.as_str(),
168 + )
169 + .await,
162 170 username: user.username.clone(),
163 171 email: user.email.clone(),
164 172 initials: user.initials.clone(),
M crates/rustwarden-ui/src/handlers/login_handler.rs
+133 -2
@@ -68,6 +68,7 @@ struct ResendVerificationTemplate {
68 68 app_version: String,
69 69 app_domain: String,
70 70 app_description: String,
71 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
71 72 page_title: String,
72 73 #[allow(dead_code)] // utilise par base.html (template parent)
73 74 csrf_token: String,
@@ -91,6 +92,7 @@ struct ResetPasswordTemplate {
91 92 app_version: String,
92 93 app_domain: String,
93 94 app_description: String,
95 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
94 96 page_title: String,
95 97 #[allow(dead_code)] // utilise par base.html (template parent)
96 98 csrf_token: String,
@@ -116,6 +118,7 @@ struct ResetPasswordTemplate {
116 118
117 119 rustwarden_core::impl_template_response!(LoginTemplate);
118 120 rustwarden_core::impl_template_response!(ResendVerificationTemplate);
121 +rustwarden_core::impl_template_response!(ForgotPasswordTemplate);
119 122 rustwarden_core::impl_template_response!(ResetPasswordTemplate);
120 123
121 124 /// Handler for GET /login - Login page (PUBLIC)
@@ -313,11 +316,126 @@ pub async fn login_page(
313 316 Ok(template)
314 317 }
315 318
319 +/// Page asking for an email so the API can send a password-reset link.
320 +#[derive(Template)]
321 +#[template(path = "forgot_password.html")]
322 +struct ForgotPasswordTemplate {
323 + app_name: String,
324 + app_version: String,
325 + app_domain: String,
326 + app_description: String,
327 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
328 + page_title: String,
329 + #[allow(dead_code)] // used by the parent base.html
330 + csrf_token: String,
331 + title: String,
332 + subtitle: String,
333 + email_label: String,
334 + email_placeholder: String,
335 + submit: String,
336 + back_to_login: String,
337 + error_default: String,
338 + success_default: String,
339 + error_missing_email: String,
340 + error_cannot_send: String,
341 +}
342 +
343 +/// Handler for GET /forgot-password (PUBLIC).
344 +///
345 +/// The form posts to `POST /api/v1/auth/forgot-password`, which always
346 +/// answers 200 so the page does not reveal whether the address exists.
347 +pub async fn forgot_password_page(
348 + headers: HeaderMap,
349 + State(db): State<DatabaseConnection>,
350 + Extension(i18n_service): Extension<Arc<I18nService>>,
351 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
352 +) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
353 + let jar = CookieJar::from_headers(&headers);
354 + let current_locale = jar
355 + .get("locale")
356 + .and_then(|c| {
357 + let value = c.value();
358 + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
359 + Some(value.to_string())
360 + } else {
361 + None
362 + }
363 + })
364 + .unwrap_or_else(|| "fr".to_string());
365 + let locale_str = current_locale.as_str();
366 +
367 + let layout = LayoutContext::new("", "");
368 + let app_domain =
369 + rustwarden_core::services::app_settings_service::AppSettingsService::get_setting(
370 + &db,
371 + "app_domain",
372 + )
373 + .await?
374 + .map(|s| s.value)
375 + .unwrap_or_else(|| "localhost".to_string());
376 + let app_description =
377 + rustwarden_core::services::app_settings_service::AppSettingsService::get_setting(
378 + &db,
379 + "app_description",
380 + )
381 + .await?
382 + .map(|s| s.value)
383 + .unwrap_or_else(|| "User accounts and authentication".to_string());
384 +
385 + Ok(ForgotPasswordTemplate {
386 + app_name: layout.app_name.clone(),
387 + app_version: layout.app_version.clone(),
388 + app_domain,
389 + app_description,
390 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
391 + &analytics,
392 + &i18n_service,
393 + locale_str,
394 + )
395 + .await,
396 + page_title: i18n_service
397 + .translate("forgot_password.page_title", locale_str)
398 + .await,
399 + csrf_token: String::new(),
400 + title: i18n_service
401 + .translate("forgot_password.title", locale_str)
402 + .await,
403 + subtitle: i18n_service
404 + .translate("forgot_password.subtitle", locale_str)
405 + .await,
406 + email_label: i18n_service
407 + .translate("forgot_password.email_label", locale_str)
408 + .await,
409 + email_placeholder: i18n_service
410 + .translate("forgot_password.email_placeholder", locale_str)
411 + .await,
412 + submit: i18n_service
413 + .translate("forgot_password.submit", locale_str)
414 + .await,
415 + back_to_login: i18n_service
416 + .translate("forgot_password.back_to_login", locale_str)
417 + .await,
418 + error_default: i18n_service
419 + .translate("forgot_password.error_default", locale_str)
420 + .await,
421 + success_default: i18n_service
422 + .translate("forgot_password.success_default", locale_str)
423 + .await,
424 + error_missing_email: i18n_service
425 + .translate("forgot_password.error_missing_email", locale_str)
426 + .await,
427 + error_cannot_send: i18n_service
428 + .translate("forgot_password.error_cannot_send", locale_str)
429 + .await,
430 + })
431 +}
432 +
316 433 /// Handler for GET /resend-verification - Resend email verification page (PUBLIC)
317 434 pub async fn resend_verification_page(
318 435 headers: HeaderMap,
319 436 State(db): State<DatabaseConnection>,
320 437 Extension(i18n_service): Extension<Arc<I18nService>>,
438 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
321 439 ) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
322 440 tracing::debug!("Rendering resend verification page");
323 441
@@ -379,13 +497,19 @@ pub async fn resend_verification_page(
379 497 let app_description = AppSettingsService::get_setting(&db, "app_description")
380 498 .await?
381 499 .map(|s| s.value)
382 - .unwrap_or_else(|| "Secure password manager".to_string());
500 + .unwrap_or_else(|| "User accounts and authentication".to_string());
383 501
384 502 let template = ResendVerificationTemplate {
385 503 app_name: layout.app_name.clone(),
386 504 app_version: layout.app_version.clone(),
387 505 app_domain,
388 506 app_description,
507 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
508 + &analytics,
509 + &i18n_service,
510 + current_locale.as_str(),
511 + )
512 + .await,
389 513 page_title,
390 514 csrf_token: String::new(),
391 515 title,
@@ -415,6 +539,7 @@ pub async fn reset_password_page(
415 539 headers: HeaderMap,
416 540 State(db): State<DatabaseConnection>,
417 541 Extension(i18n_service): Extension<Arc<I18nService>>,
542 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
418 543 axum::extract::Query(_params): axum::extract::Query<ResetPasswordQuery>,
419 544 ) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
420 545 tracing::debug!("Rendering reset password page");
@@ -499,13 +624,19 @@ pub async fn reset_password_page(
499 624 let app_description = AppSettingsService::get_setting(&db, "app_description")
500 625 .await?
501 626 .map(|s| s.value)
502 - .unwrap_or_else(|| "Secure password manager".to_string());
627 + .unwrap_or_else(|| "User accounts and authentication".to_string());
503 628
504 629 let template = ResetPasswordTemplate {
505 630 app_name: layout.app_name.clone(),
506 631 app_version: layout.app_version.clone(),
507 632 app_domain,
508 633 app_description,
634 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
635 + &analytics,
636 + &i18n_service,
637 + current_locale.as_str(),
638 + )
639 + .await,
509 640 page_title,
510 641 csrf_token: String::new(),
511 642 reset_password_title,
M crates/rustwarden-ui/src/handlers/mod.rs
+0 -6
@@ -4,10 +4,6 @@
4 4
5 5 /// Admin panel handlers (users, roles, settings).
6 6 pub mod admin_handler;
7 -/// Dashboard widget handlers.
8 -pub mod dashboard_handler;
9 -/// Email delivery status handlers.
10 -pub mod email_handler;
11 7 /// HTML 404 page for unmatched routes.
12 8 pub mod error_page;
13 9 /// Health-check endpoint handler.
@@ -16,8 +12,6 @@ pub mod health;
16 12 pub mod home_handler;
17 13 /// Login and logout handlers.
18 14 pub mod login_handler;
19 -/// Navigation menu handler.
20 -pub mod navigation_handler;
21 15 /// Root redirect handler.
22 16 pub mod root_handler;
23 17 /// Settings sub-handlers (profile, security, preferences).
D crates/rustwarden-ui/src/handlers/navigation_handler.rs
+0 -53
@@ -1,53 +0,0 @@
1 -//! Navigation Handler
2 -//!
3 -//! Handles the navigation items API endpoint.
4 -//! Simplified for Rustwarden - no modular app system.
5 -
6 -use axum::{Json, response::IntoResponse};
7 -use serde::Serialize;
8 -
9 -/// DTO for a navigation item (API response)
10 -#[derive(Debug, Clone, Serialize)]
11 -pub struct NavigationItemDto {
12 - /// Unique identifier for this navigation item.
13 - pub id: String,
14 - /// Display label shown in the navigation menu.
15 - pub name: String,
16 - /// Icon class or name used by the front-end.
17 - pub icon: String,
18 - /// Target route path for this navigation item.
19 - pub route: String,
20 - /// Display order relative to sibling items.
21 - pub order: i32,
22 - /// Parent item identifier for nested menus.
23 - #[serde(skip_serializing_if = "Option::is_none")]
24 - pub parent_id: Option<String>,
25 -}
26 -
27 -/// List navigation items for the current user.
28 -///
29 -/// GET /api/v1/navigation
30 -///
31 -/// Returns static navigation items (no modular app system in Rustwarden).
32 -pub async fn get_navigation() -> impl IntoResponse {
33 - let items = vec![
34 - NavigationItemDto {
35 - id: "home".to_string(),
36 - name: "Accueil".to_string(),
37 - icon: "home".to_string(),
38 - route: "/home".to_string(),
39 - order: 0,
40 - parent_id: None,
41 - },
42 - NavigationItemDto {
43 - id: "admin".to_string(),
44 - name: "Administration".to_string(),
45 - icon: "shield".to_string(),
46 - route: "/admin".to_string(),
47 - order: 100,
48 - parent_id: None,
49 - },
50 - ];
51 -
52 - Json(items)
53 -}
M crates/rustwarden-ui/src/handlers/settings/preferences.rs
+9 -1
@@ -124,6 +124,7 @@ struct PreferencesTemplate {
124 124 app_version: String,
125 125 app_domain: String,
126 126 app_description: String,
127 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
127 128 username: String,
128 129 email: String,
129 130 roles_text: String,
@@ -203,6 +204,7 @@ pub async fn get_preferences_page(
203 204 Extension(claims): Extension<Claims>,
204 205 Extension(i18n_service): Extension<Arc<I18nService>>,
205 206 Extension(locale): Extension<Locale>,
207 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
206 208 State(db): State<DatabaseConnection>,
207 209 session: Session,
208 210 ) -> Result<impl IntoResponse, AppError> {
@@ -269,7 +271,7 @@ pub async fn get_preferences_page(
269 271 let app_description = AppSettingsService::get_setting(&db, "app_description")
270 272 .await?
271 273 .map(|s| s.value)
272 - .unwrap_or_else(|| "Secure password manager".to_string());
274 + .unwrap_or_else(|| "User accounts and authentication".to_string());
273 275
274 276 let save_button = i18n_service.translate("preferences.save", locale_str).await;
275 277 let saved_success = i18n_service
@@ -303,6 +305,12 @@ pub async fn get_preferences_page(
303 305 app_version: layout.app_version.clone(),
304 306 app_domain,
305 307 app_description,
308 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
309 + &analytics,
310 + &i18n_service,
311 + locale.as_str(),
312 + )
313 + .await,
306 314 username: user.username.clone(),
307 315 email: user.email.clone(),
308 316 roles_text: user.roles.join(", "),
M crates/rustwarden-ui/src/handlers/settings/profile.rs
+42 -2
@@ -24,7 +24,9 @@ use rustwarden_core::services::user_service::UserService;
24 24 pub struct UpdateProfileRequest {
25 25 /// New username, if the user wants to change it.
26 26 pub username: Option<String>,
27 - /// New email address, if the user wants to change it.
27 + /// Ignored for writes. A different address is refused by `update_profile`
28 + /// (RFC 0041). The profile page starts a change with
29 + /// `POST /api/v1/auth/email-change` instead.
28 30 pub email: Option<String>,
29 31 }
30 32 /// PUT /settings/profile — Update the authenticated user's profile.
@@ -115,6 +117,7 @@ struct ProfileTemplate {
115 117 app_version: String,
116 118 app_domain: String,
117 119 app_description: String,
120 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
118 121 username: String,
119 122 email: String,
120 123 user_id: String,
@@ -142,6 +145,15 @@ struct ProfileTemplate {
142 145 email_warning: String,
143 146 email_placeholder: String,
144 147 email_change_info: String,
148 + email_change_title: String,
149 + new_email_label: String,
150 + current_password_label: String,
151 + request_button: String,
152 + email_change_error: String,
153 + profile_saved: String,
154 + profile_error: String,
155 + network_error: String,
156 + saving: String,
145 157 save_button: String,
146 158 cancel: String,
147 159 additional_info: String,
@@ -159,6 +171,7 @@ pub async fn get_profile_page(
159 171 Extension(claims): Extension<Claims>,
160 172 Extension(i18n_service): Extension<Arc<I18nService>>,
161 173 Extension(locale): Extension<Locale>,
174 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
162 175 State(db): State<DatabaseConnection>,
163 176 session: Session,
164 177 ) -> Result<impl IntoResponse, AppError> {
@@ -186,7 +199,7 @@ pub async fn get_profile_page(
186 199 let app_description = AppSettingsService::get_setting(&db, "app_description")
187 200 .await?
188 201 .map(|s| s.value)
189 - .unwrap_or_else(|| "Secure password manager".to_string());
202 + .unwrap_or_else(|| "User accounts and authentication".to_string());
190 203
191 204 let template = ProfileTemplate {
192 205 page_title: layout.page_title.clone(),
@@ -195,6 +208,12 @@ pub async fn get_profile_page(
195 208 app_version: layout.app_version.clone(),
196 209 app_domain,
197 210 app_description,
211 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
212 + &analytics,
213 + &i18n_service,
214 + locale.as_str(),
215 + )
216 + .await,
198 217 username: user.username.clone(),
199 218 email: user.email.clone(),
200 219 user_id: user.id.to_string(),
@@ -243,6 +262,27 @@ pub async fn get_profile_page(
243 262 email_change_info: i18n_service
244 263 .translate("profile.email_change_info", locale_str)
245 264 .await,
265 + email_change_title: i18n_service
266 + .translate("profile.email_change_title", locale_str)
267 + .await,
268 + new_email_label: i18n_service
269 + .translate("profile.new_email_label", locale_str)
270 + .await,
271 + current_password_label: i18n_service
272 + .translate("profile.current_password_label", locale_str)
273 + .await,
274 + request_button: i18n_service
275 + .translate("profile.request_button", locale_str)
276 + .await,
277 + email_change_error: i18n_service
278 + .translate("profile.email_change_error", locale_str)
279 + .await,
280 + profile_saved: i18n_service.translate("profile.saved", locale_str).await,
281 + profile_error: i18n_service.translate("profile.error", locale_str).await,
282 + network_error: i18n_service
283 + .translate("profile.network_error", locale_str)
284 + .await,
285 + saving: i18n_service.translate("profile.saving", locale_str).await,
246 286 save_button: i18n_service.translate("profile.save", locale_str).await,
247 287 cancel: i18n_service.translate("common.cancel", locale_str).await,
248 288 additional_info: i18n_service
M crates/rustwarden-ui/src/handlers/settings/security.rs
+18 -2
@@ -315,6 +315,7 @@ struct PasswordTemplate {
315 315 app_version: String,
316 316 app_domain: String,
317 317 app_description: String,
318 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
318 319 username: String,
319 320 email: String,
320 321 roles_text: String,
@@ -412,6 +413,7 @@ struct SessionsTemplate {
412 413 app_version: String,
413 414 app_domain: String,
414 415 app_description: String,
416 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
415 417 username: String,
416 418 email: String,
417 419 roles_text: String,
@@ -460,6 +462,7 @@ pub async fn get_password_page(
460 462 Extension(claims): Extension<Claims>,
461 463 Extension(i18n_service): Extension<Arc<I18nService>>,
462 464 Extension(locale): Extension<Locale>,
465 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
463 466 State(db): State<DatabaseConnection>,
464 467 session: Session,
465 468 Query(query): Query<PasswordPageQuery>,
@@ -526,7 +529,7 @@ pub async fn get_password_page(
526 529 let app_description = AppSettingsService::get_setting(&db, "app_description")
527 530 .await?
528 531 .map(|s| s.value)
529 - .unwrap_or_else(|| "Secure password manager".to_string());
532 + .unwrap_or_else(|| "User accounts and authentication".to_string());
530 533
531 534 let page_title = i18n_service
532 535 .translate("password.page_title", locale_str)
@@ -539,6 +542,12 @@ pub async fn get_password_page(
539 542 app_version: layout.app_version.clone(),
540 543 app_domain,
541 544 app_description,
545 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
546 + &analytics,
547 + &i18n_service,
548 + locale.as_str(),
549 + )
550 + .await,
542 551 username: user.username.clone(),
543 552 email: user.email.clone(),
544 553 roles_text: user.roles.join(", "),
@@ -707,6 +716,7 @@ pub async fn get_sessions_page(
707 716 Extension(claims): Extension<Claims>,
708 717 Extension(i18n_service): Extension<Arc<I18nService>>,
709 718 Extension(locale): Extension<Locale>,
719 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
710 720 State(db): State<DatabaseConnection>,
711 721 session: Session,
712 722 ) -> Result<impl IntoResponse, AppError> {
@@ -764,7 +774,7 @@ pub async fn get_sessions_page(
764 774 let app_description = AppSettingsService::get_setting(&db, "app_description")
765 775 .await?
766 776 .map(|s| s.value)
767 - .unwrap_or_else(|| "Secure password manager".to_string());
777 + .unwrap_or_else(|| "User accounts and authentication".to_string());
768 778
769 779 let page_title = i18n_service
770 780 .translate("sessions.page_title", locale_str)
@@ -777,6 +787,12 @@ pub async fn get_sessions_page(
777 787 app_version: layout.app_version.clone(),
778 788 app_domain,
779 789 app_description,
790 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
791 + &analytics,
792 + &i18n_service,
793 + locale.as_str(),
794 + )
795 + .await,
780 796 username: user.username.clone(),
781 797 email: user.email.clone(),
782 798 roles_text: user.roles.join(", "),
M crates/rustwarden-ui/src/handlers/totp_handler.rs
+9 -1
@@ -72,6 +72,7 @@ struct SecurityTemplate {
72 72 app_version: String,
73 73 app_domain: String,
74 74 app_description: String,
75 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
75 76 csrf_token: String,
76 77 username: String,
77 78 email: String,
@@ -127,6 +128,7 @@ pub async fn security_page(
127 128 State(db): State<DatabaseConnection>,
128 129 Extension(i18n_service): Extension<Arc<I18nService>>,
129 130 locale: Locale,
131 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
130 132 session: Session,
131 133 ) -> Result<impl IntoResponse, AppError> {
132 134 let user_model = UserService::get_user_by_username(&db, &claims.username)
@@ -148,7 +150,7 @@ pub async fn security_page(
148 150 let app_description = AppSettingsService::get_setting(&db, "app_description")
149 151 .await?
150 152 .map(|s| s.value)
151 - .unwrap_or_else(|| "Secure password manager".to_string());
153 + .unwrap_or_else(|| "User accounts and authentication".to_string());
152 154
153 155 // Check TOTP status
154 156 let totp_info = TotpService::get_totp_info(&db, claims.sub).await?;
@@ -175,6 +177,12 @@ pub async fn security_page(
175 177 app_version: layout.app_version.clone(),
176 178 app_domain,
177 179 app_description,
180 + analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
181 + &analytics,
182 + &i18n_service,
183 + locale.as_str(),
184 + )
185 + .await,
178 186 csrf_token,
179 187 username: user.username.clone(),
180 188 email: user.email.clone(),
M crates/rustwarden-ui/src/routes.rs
+2 -0
@@ -27,6 +27,7 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
27 27 "/resend-verification",
28 28 get(login_handler::resend_verification_page),
29 29 )
30 + .route("/forgot-password", get(login_handler::forgot_password_page))
30 31 .route("/reset-password", get(login_handler::reset_password_page))
31 32 .route(
32 33 "/confirm-password-change",
@@ -98,6 +99,7 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
98 99 .route("/admin/settings", get(admin_handler::get_app_settings))
99 100 .route("/admin/settings", put(admin_handler::update_app_settings))
100 101 .route("/admin/errors", get(admin_handler::admin_errors))
102 + .route("/admin/logs", get(admin_handler::admin_logs))
101 103 .route(
102 104 "/admin/users/{id}/reset-totp",
103 105 post(admin_handler::reset_user_totp),
M crates/rustwarden-ui/src/template_helpers.rs
+0 -1
@@ -427,7 +427,6 @@ fn translate_path_segment(segment: &str) -> String {
427 427 "settings" => "Paramètres".to_string(),
428 428 "account" => "Compte".to_string(),
429 429 "profile" => "Profil".to_string(),
430 - "files" => "Fichiers".to_string(),
431 430 "admin" => "Administration".to_string(),
432 431 _ => {
433 432 // Capitalize first letter
M crates/rustwarden-ui/templates/admin/dashboard.html
+13 -0
@@ -93,12 +93,25 @@
93 93 </div>
94 94 </div>
95 95 </div>
96 + <div class="card-actions justify-end mt-3">
97 + <a href="/admin/logs" class="btn btn-sm btn-outline">{{ view_logs }}</a>
98 + </div>
96 99 </div>
97 100 </div>
98 101 {% endif %}
99 102
100 103 <!-- Navigation Links -->
101 104 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
105 + <a href="/admin/logs" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
106 + <div class="card-body p-4">
107 + <h2 class="card-title text-info text-base">{{ logs_card_title }}</h2>
108 + <p class="text-sm">{{ logs_card_desc }}</p>
109 + <div class="card-actions justify-end mt-2">
110 + <span class="btn btn-info btn-sm">{{ view_logs }}</span>
111 + </div>
112 + </div>
113 + </a>
114 +
102 115 <a href="/admin/roles" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
103 116 <div class="card-body p-4">
104 117 <h2 class="card-title text-warning text-base">🛡️ {{ roles_card_title }}</h2>
A crates/rustwarden-ui/templates/admin/logs.html
+50 -0
@@ -0,0 +1,50 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
9 +{% block content %}
10 +<div class="container mx-auto px-4 py-4">
11 + <div class="text-sm breadcrumbs mb-3">
12 + <ul>
13 + <li><a href="/">{{ breadcrumb_home }}</a></li>
14 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
15 + <li>{{ heading }}</li>
16 + </ul>
17 + </div>
18 +
19 + <h1 class="text-2xl font-bold mb-4">{{ heading }}</h1>
20 +
21 + {% if has_rows %}
22 + <div class="overflow-x-auto bg-base-100 shadow-md rounded-box">
23 + <table class="table table-sm">
24 + <thead>
25 + <tr>
26 + <th>{{ col_date }}</th>
27 + <th>{{ col_level }}</th>
28 + <th>{{ col_action }}</th>
29 + <th>{{ col_message }}</th>
30 + </tr>
31 + </thead>
32 + <tbody>
33 + {% for row in rows %}
34 + <tr>
35 + <td class="whitespace-nowrap">{{ row.created_at }}</td>
36 + <td>{{ row.level }}</td>
37 + <td>{{ row.action }}</td>
38 + <td>{{ row.message }}</td>
39 + </tr>
40 + {% endfor %}
41 + </tbody>
42 + </table>
43 + </div>
44 + {% else %}
45 + <div class="alert">
46 + <span>{{ empty }}</span>
47 + </div>
48 + {% endif %}
49 +</div>
50 +{% endblock %}
M crates/rustwarden-ui/templates/admin/user_form.html
+5 -0
@@ -104,6 +104,11 @@
104 104 class="input input-bordered w-full"
105 105 required
106 106 >
107 + {% if mode == "edit" %}
108 + <label class="label">
109 + <span class="label-text-alt">{{ email_change_hint }}</span>
110 + </label>
111 + {% endif %}
107 112 </div>
108 113
109 114 <!-- Password Field (only for create mode or if resetting) -->
M crates/rustwarden-ui/templates/base.html
+4 -2
@@ -38,8 +38,9 @@
38 38 <!-- HTMX (local) -->
39 39 <script src="/static/js/htmx.min.js"></script>
40 40
41 - <!-- Alpine.js (local) -->
42 - <script src="/static/js/alpine.min.js" defer></script>
41 + <!-- Alpine.js CSP build (no unsafe-eval). Components register on alpine:init. -->
42 + <script src="/static/js/alpine-components.js" defer></script>
43 + <script src="/static/js/alpine-csp.min.js" defer></script>
43 44
44 45 {% block head_extra %}{% endblock %}
45 46 </head>
@@ -75,6 +76,7 @@
75 76 {% if app_description != "" %}
76 77 <p class="text-base-content/70 mt-1">{{ app_description }}</p>
77 78 {% endif %}
79 + {% include "components/analytics_opt_out.html" %}
78 80 </div>
79 81 </footer>
80 82 {% endblock %}
D crates/rustwarden-ui/templates/base_with_theme.html
+0 -10
@@ -1,10 +0,0 @@
1 -<!-- T155: Theme application in base.html -->
2 -<!-- Note: This requires modifying base.html to apply data-theme attribute -->
3 -<!-- Add to <html> tag: data-theme="{{ user_theme | default(value='light') }}" -->
4 -<!-- DaisyUI will automatically apply the theme based on this attribute -->
5 -
6 -<!-- Example integration:
7 -<html lang="fr" data-theme="{% if user_theme %}{{ user_theme }}{% else %}light{% endif %}">
8 -
9 -This allows dynamic theme switching based on user preferences.
10 --->
M crates/rustwarden-ui/templates/components/analytics_opt_out.html
+2 -11
@@ -20,17 +20,8 @@
20 20 #}
21 21 {% if analytics_opt_out.has_key() %}
22 22 <div class="mt-4 pt-4 border-t border-base-300"
23 - x-data="{
24 - storageKey: '{{ analytics_opt_out.storage_key }}',
25 - optedOut: false,
26 - init() { try { this.optedOut = localStorage.getItem(this.storageKey) !== null; } catch (e) {} },
27 - toggle() {
28 - try {
29 - if (this.optedOut) { localStorage.removeItem(this.storageKey); this.optedOut = false; }
30 - else { localStorage.setItem(this.storageKey, '1'); this.optedOut = true; }
31 - } catch (e) {}
32 - }
33 - }">
23 + x-data="analyticsOptOut"
24 + data-storage-key="{{ analytics_opt_out.storage_key }}">
34 25 <p class="text-sm text-base-content/70 mb-2">{{ analytics_opt_out.hint }}</p>
35 26 <div class="flex items-center gap-3 flex-wrap">
36 27 <button type="button" class="btn btn-sm btn-outline" @click="toggle()">
D crates/rustwarden-ui/templates/components/breadcrumb.html
+0 -37
@@ -1,37 +0,0 @@
1 -<!-- Breadcrumb Component for Feature 007 -->
2 -<!-- Uses DaisyUI breadcrumbs classes -->
3 -
4 -<div class="container mx-auto px-4 py-2 bg-base-200 border-b border-base-300">
5 - <ul class="breadcrumbs text-sm">
6 - {% for item in breadcrumb_items %}
7 - <li>
8 - {% if item.is_current %}
9 - <span class="text-base-content font-semibold">{{ item.label }}</span>
10 - {% else %}
11 - <a href="{{ item.url }}" class="text-primary hover:text-primary-focus">
12 - {{ item.label }}
13 - </a>
14 - {% endif %}
15 - </li>
16 - {% endfor %}
17 - </ul>
18 -</div>
19 -
20 -
21 -
22 -
23 -
24 -
25 -
26 -
27 -
28 -
29 -
30 -
31 -
32 -
33 -
34 -
35 -
36 -
37 -
D crates/rustwarden-ui/templates/components/header.html
+0 -54
@@ -1,54 +0,0 @@
1 -<header class="navbar bg-base-100 border-b border-base-300 shadow-sm">
2 - <div class="container mx-auto px-4">
3 - <div class="flex items-center justify-between h-16">
4 - <!-- Logo -->
5 - <div class="flex items-center">
6 - <a href="/home" class="flex items-center space-x-2 hover:opacity-80 transition">
7 - <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-8 w-8">
8 - <span class="text-xl font-bold text-primary">{{ app_name }}</span>
9 - </a>
10 - </div>
11 -
12 - <!-- Desktop Navigation -->
13 - <div class="hidden md:flex items-center flex-1 justify-center">
14 - {% include "components/navigation.html" %}
15 - </div>
16 -
17 - <!-- User Menu (Desktop) -->
18 - <div class="hidden md:block">
19 - {% include "components/user_menu.html" %}
20 - </div>
21 -
22 - <!-- Mobile Menu Button -->
23 - <div class="md:hidden" x-data="{ mobileMenuOpen: false }">
24 - <button @click="mobileMenuOpen = !mobileMenuOpen"
25 - class="btn btn-ghost btn-sm">
26 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27 - <path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
28 - <path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
29 - </svg>
30 - </button>
31 -
32 - <!-- Mobile Menu -->
33 - <div x-show="mobileMenuOpen"
34 - @click.away="mobileMenuOpen = false"
35 - x-transition:enter="transition ease-out duration-200"
36 - x-transition:enter-start="opacity-0 scale-95"
37 - x-transition:enter-end="opacity-100 scale-100"
38 - x-transition:leave="transition ease-in duration-150"
39 - x-transition:leave-start="opacity-100 scale-100"
40 - x-transition:leave-end="opacity-0 scale-95"
41 - class="absolute top-16 right-0 mt-2 w-64 card bg-base-100 shadow-xl border border-base-300 py-2 z-50">
42 - <!-- Mobile Navigation -->
43 - <div class="px-4 py-2">
44 - {% include "components/navigation.html" %}
45 - </div>
46 - <!-- Mobile User Info -->
47 - <div class="border-t border-base-300 mt-2 pt-2 px-4 py-2">
48 - {% include "fragments/user_menu_dropdown.html" %}
49 - </div>
50 - </div>
51 - </div>
52 - </div>
53 - </div>
54 -</header>
D crates/rustwarden-ui/templates/components/password_change_badge.html
+0 -6
@@ -1,6 +0,0 @@
1 -{% if has_pending_password_change %}
2 -<span class="badge badge-sm badge-warning" title="Changement de mot de passe en attente de confirmation">
3 - ⚠️
4 -</span>
5 -{% endif %}
6 -
D crates/rustwarden-ui/templates/components/sidebar.html
+0 -155
@@ -1,155 +0,0 @@
1 -<!-- Sidebar Component for Feature 007 -->
2 -<!-- Uses DaisyUI sidebar and menu classes -->
3 -
4 -<aside class="w-64 bg-base-200 border-r border-base-300 hidden lg:block overflow-y-auto"
5 - x-data="{ sidebarOpen: false }">
6 - <div class="p-4">
7 - {% for section in sidebar_sections %}
8 - <div class="mb-6">
9 - <h3 class="text-sm font-semibold text-base-content uppercase tracking-wider mb-2">
10 - {{ section.title }}
11 - </h3>
12 - <ul class="menu menu-vertical w-full">
13 - {% for item in section.items %}
14 - <li>
15 - {% if item.enabled %}
16 - {% if item.has_url %}
17 - <a href="{{ item.url }}"
18 - class="flex items-center gap-2 hover:bg-base-300">
19 - <span>{{ item.icon }}</span>
20 - <span>{{ item.label }}</span>
21 - {% if !item.badge.is_empty() %}
22 - <span class="badge badge-primary badge-sm ml-auto">{{ item.badge }}</span>
23 - {% endif %}
24 - </a>
25 - {% else %}
26 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
27 - <span>{{ item.icon }}</span>
28 - <span>{{ item.label }}</span>
29 - {% if !item.badge.is_empty() %}
30 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
31 - {% endif %}
32 - </div>
33 - {% endif %}
34 - {% else %}
35 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
36 - <span>{{ item.icon }}</span>
37 - <span>{{ item.label }}</span>
38 - {% if !item.badge.is_empty() %}
39 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
40 - {% endif %}
41 - </div>
42 - {% endif %}
43 - </li>
44 - {% endfor %}
45 - </ul>
46 - </div>
47 - {% endfor %}
48 - </div>
49 -</aside>
50 -
51 -<!-- Mobile Sidebar Toggle Button (visible only on mobile) -->
52 -<div class="lg:hidden fixed bottom-4 right-4 z-50" x-data="{ sidebarOpen: false }">
53 - <button @click="sidebarOpen = !sidebarOpen"
54 - class="btn btn-primary btn-circle shadow-lg">
55 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
57 - </svg>
58 - </button>
59 -
60 - <!-- Mobile Sidebar Overlay -->
61 - <div x-show="sidebarOpen"
62 - @click.away="sidebarOpen = false"
63 - x-transition:enter="transition ease-out duration-300"
64 - x-transition:enter-start="opacity-0"
65 - x-transition:enter-end="opacity-100"
66 - x-transition:leave="transition ease-in duration-200"
67 - x-transition:leave-start="opacity-100"
68 - x-transition:leave-end="opacity-0"
69 - class="fixed inset-0 bg-black bg-opacity-50 z-40"
70 - @click="sidebarOpen = false">
71 - </div>
72 -
73 - <!-- Mobile Sidebar Panel -->
74 - <div x-show="sidebarOpen"
75 - x-transition:enter="transition ease-out duration-300 transform"
76 - x-transition:enter-start="translate-x-full"
77 - x-transition:enter-end="translate-x-0"
78 - x-transition:leave="transition ease-in duration-200 transform"
79 - x-transition:leave-start="translate-x-0"
80 - x-transition:leave-end="translate-x-full"
81 - class="fixed top-0 right-0 h-full w-64 bg-base-200 border-l border-base-300 shadow-xl z-50 overflow-y-auto">
82 - <div class="p-4">
83 - <div class="flex justify-between items-center mb-4">
84 - <h2 class="text-lg font-bold text-base-content">Menu</h2>
85 - <button @click="sidebarOpen = false" class="btn btn-ghost btn-sm btn-circle">
86 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
88 - </svg>
89 - </button>
90 - </div>
91 -
92 - {% for section in sidebar_sections %}
93 - <div class="mb-6">
94 - <h3 class="text-sm font-semibold text-base-content uppercase tracking-wider mb-2">
95 - {{ section.title }}
96 - </h3>
97 - <ul class="menu menu-vertical w-full">
98 - {% for item in section.items %}
99 - <li>
100 - {% if item.enabled %}
101 - {% if item.has_url %}
102 - <a href="{{ item.url }}"
103 - class="flex items-center gap-2 hover:bg-base-300"
104 - @click="sidebarOpen = false">
105 - <span>{{ item.icon }}</span>
106 - <span>{{ item.label }}</span>
107 - {% if !item.badge.is_empty() %}
108 - <span class="badge badge-primary badge-sm ml-auto">{{ item.badge }}</span>
109 - {% endif %}
110 - </a>
111 - {% else %}
112 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
113 - <span>{{ item.icon }}</span>
114 - <span>{{ item.label }}</span>
115 - {% if !item.badge.is_empty() %}
116 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
117 - {% endif %}
118 - </div>
119 - {% endif %}
120 - {% else %}
121 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
122 - <span>{{ item.icon }}</span>
123 - <span>{{ item.label }}</span>
124 - {% if !item.badge.is_empty() %}
125 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
126 - {% endif %}
127 - </div>
128 - {% endif %}
129 - </li>
130 - {% endfor %}
131 - </ul>
132 - </div>
133 - {% endfor %}
134 - </div>
135 - </div>
136 -</div>
137 -
138 -
139 -
140 -
141 -
142 -
143 -
144 -
145 -
146 -
147 -
148 -
149 -
150 -
151 -
152 -
153 -
154 -
155 -
D crates/rustwarden-ui/templates/emails/account_modified.html
+0 -165
@@ -1,165 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Compte modifié{% else %}Account Modified{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .changes-list {
42 - margin: 20px 0;
43 - padding: 15px;
44 - background-color: #f3f4f6;
45 - border-radius: 6px;
46 - }
47 - .change-item {
48 - margin: 10px 0;
49 - padding: 10px;
50 - background-color: #ffffff;
51 - border-left: 3px solid #3b82f6;
52 - border-radius: 4px;
53 - }
54 - .change-label {
55 - font-weight: 600;
56 - color: #1f2937;
57 - }
58 - .change-value {
59 - color: #6b7280;
60 - margin-top: 5px;
61 - }
62 - .info-box {
63 - margin-top: 20px;
64 - padding: 15px;
65 - background-color: #fee2e2;
66 - border-left: 4px solid #ef4444;
67 - border-radius: 4px;
68 - font-size: 14px;
69 - }
70 - .footer {
71 - margin-top: 30px;
72 - padding-top: 20px;
73 - border-top: 1px solid #e5e7eb;
74 - font-size: 14px;
75 - color: #6b7280;
76 - text-align: center;
77 - }
78 - </style>
79 -</head>
80 -<body>
81 - <div class="email-container">
82 - <div class="header">
83 - <div class="logo">{{ app_name }}</div>
84 - </div>
85 -
86 - <h1>
87 - {% if locale == "fr" %}
88 - Compte modifié
89 - {% else %}
90 - Account Modified
91 - {% endif %}
92 - </h1>
93 -
94 - <div class="content">
95 - {% if locale == "fr" %}
96 - <p>Bonjour {{ username }},</p>
97 - <p>Nous vous informons que les informations de votre compte <strong>{{ app_name }}</strong> ont été modifiées.</p>
98 - <p><strong>Date et heure de modification :</strong> {{ modified_at }}</p>
99 - {% else %}
100 - <p>Hello {{ username }},</p>
101 - <p>We are informing you that your <strong>{{ app_name }}</strong> account information has been modified.</p>
102 - <p><strong>Date and time of modification:</strong> {{ modified_at }}</p>
103 - {% endif %}
104 - </div>
105 -
106 - {% if !changes.is_empty() %}
107 - <div class="changes-list">
108 - {% if locale == "fr" %}
109 - <p style="font-weight: 600; margin-bottom: 15px;">Modifications effectuées :</p>
110 - {% else %}
111 - <p style="font-weight: 600; margin-bottom: 15px;">Changes made:</p>
112 - {% endif %}
113 - {% for change in changes %}
114 - <div class="change-item">
115 - <div class="change-label">{{ change.field }}</div>
116 - {% if let Some(old_val) = change.old_value %}
117 - <div class="change-value">
118 - {% if locale == "fr" %}
119 - Ancien : {{ old_val }} → Nouveau : {{ change.new_value }}
120 - {% else %}
121 - Old: {{ old_val }} → New: {{ change.new_value }}
122 - {% endif %}
123 - </div>
124 - {% else %}
125 - <div class="change-value">{{ change.new_value }}</div>
126 - {% endif %}
127 - </div>
128 - {% endfor %}
129 - </div>
130 - {% endif %}
131 -
132 - <div class="info-box">
133 - {% if locale == "fr" %}
134 - <p><strong>⚠️ Sécurité :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Si vous n'avez pas effectué ces modifications, contactez immédiatement le support technique</li>
137 - <li>Vérifiez que vous pouvez toujours accéder à votre compte</li>
138 - <li>Si votre email a été modifié, vérifiez que vous recevez bien les emails à la nouvelle adresse</li>
139 - </ul>
140 - {% else %}
141 - <p><strong>⚠️ Security:</strong></p>
142 - <ul style="margin: 10px 0; padding-left: 20px;">
143 - <li>If you did not make these changes, contact technical support immediately</li>
144 - <li>Verify that you can still access your account</li>
145 - <li>If your email was changed, verify that you receive emails at the new address</li>
146 - </ul>
147 - {% endif %}
148 - </div>
149 -
150 - <div class="footer">
151 - {% if locale == "fr" %}
152 - <p>Cet email a été envoyé automatiquement pour votre sécurité.</p>
153 - <p>Si vous avez des questions, contactez le support technique.</p>
154 - {% else %}
155 - <p>This email was sent automatically for your security.</p>
156 - <p>If you have any questions, please contact technical support.</p>
157 - {% endif %}
158 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 - </p>
161 - </div>
162 - </div>
163 -</body>
164 -</html>
165 -
D crates/rustwarden-ui/templates/emails/admin_new_user.html
+0 -151
@@ -1,151 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Nouvel utilisateur inscrit{% else %}New User Registered{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .user-info {
42 - margin: 20px 0;
43 - padding: 15px;
44 - background-color: #f3f4f6;
45 - border-radius: 6px;
46 - }
47 - .info-row {
48 - margin: 10px 0;
49 - padding: 8px 0;
50 - border-bottom: 1px solid #e5e7eb;
51 - }
52 - .info-row:last-child {
53 - border-bottom: none;
54 - }
55 - .info-label {
56 - font-weight: 600;
57 - color: #1f2937;
58 - display: inline-block;
59 - width: 120px;
60 - }
61 - .info-value {
62 - color: #6b7280;
63 - }
64 - .action-box {
65 - margin-top: 20px;
66 - padding: 15px;
67 - background-color: #dbeafe;
68 - border-left: 4px solid #3b82f6;
69 - border-radius: 4px;
70 - font-size: 14px;
71 - }
72 - .footer {
73 - margin-top: 30px;
74 - padding-top: 20px;
75 - border-top: 1px solid #e5e7eb;
76 - font-size: 14px;
77 - color: #6b7280;
78 - text-align: center;
79 - }
80 - </style>
81 -</head>
82 -<body>
83 - <div class="email-container">
84 - <div class="header">
85 - <div class="logo">{{ app_name }}</div>
86 - </div>
87 -
88 - <h1>
89 - {% if locale == "fr" %}
90 - Nouvel utilisateur inscrit
91 - {% else %}
92 - New User Registered
93 - {% endif %}
94 - </h1>
95 -
96 - <div class="content">
97 - {% if locale == "fr" %}
98 - <p>Bonjour,</p>
99 - <p>Un nouvel utilisateur s'est inscrit sur <strong>{{ app_name }}</strong>.</p>
100 - {% else %}
101 - <p>Hello,</p>
102 - <p>A new user has registered on <strong>{{ app_name }}</strong>.</p>
103 - {% endif %}
104 - </div>
105 -
106 - <div class="user-info">
107 - {% if locale == "fr" %}
108 - <p style="font-weight: 600; margin-bottom: 15px;">Informations de l'utilisateur :</p>
109 - {% else %}
110 - <p style="font-weight: 600; margin-bottom: 15px;">User Information:</p>
111 - {% endif %}
112 - <div class="info-row">
113 - <span class="info-label">{% if locale == "fr" %}Nom d'utilisateur{% else %}Username{% endif %}:</span>
114 - <span class="info-value">{{ username }}</span>
115 - </div>
116 - <div class="info-row">
117 - <span class="info-label">Email:</span>
118 - <span class="info-value">{{ email }}</span>
119 - </div>
120 - <div class="info-row">
121 - <span class="info-label">{% if locale == "fr" %}Date d'inscription{% else %}Registration Date{% endif %}:</span>
122 - <span class="info-value">{{ registered_at }}</span>
123 - </div>
124 - </div>
125 -
126 - <div class="action-box">
127 - {% if locale == "fr" %}
128 - <p><strong>ℹ️ Action requise :</strong></p>
129 - <p>Vous pouvez consulter et gérer cet utilisateur depuis le panneau d'administration.</p>
130 - {% else %}
131 - <p><strong>ℹ️ Action Required:</strong></p>
132 - <p>You can view and manage this user from the admin panel.</p>
133 - {% endif %}
134 - </div>
135 -
136 - <div class="footer">
137 - {% if locale == "fr" %}
138 - <p>Cet email a été envoyé automatiquement aux administrateurs.</p>
139 - <p>Si vous avez des questions, contactez le support technique.</p>
140 - {% else %}
141 - <p>This email was sent automatically to administrators.</p>
142 - <p>If you have any questions, please contact technical support.</p>
143 - {% endif %}
144 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
145 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
146 - </p>
147 - </div>
148 - </div>
149 -</body>
150 -</html>
151 -
D crates/rustwarden-ui/templates/emails/email_validation.html
+0 -164
@@ -1,164 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Vérification de votre adresse email{% else %}Verify Your Email Address{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .button-container {
42 - text-align: center;
43 - margin: 30px 0;
44 - }
45 - .button {
46 - display: inline-block;
47 - padding: 12px 30px;
48 - background-color: #10b981;
49 - color: #ffffff !important;
50 - text-decoration: none;
51 - border-radius: 6px;
52 - font-weight: 600;
53 - font-size: 16px;
54 - }
55 - .button:hover {
56 - background-color: #059669;
57 - }
58 - .link-fallback {
59 - margin-top: 20px;
60 - padding: 15px;
61 - background-color: #f3f4f6;
62 - border-radius: 6px;
63 - word-break: break-all;
64 - font-size: 14px;
65 - color: #6b7280;
66 - }
67 - .footer {
68 - margin-top: 30px;
69 - padding-top: 20px;
70 - border-top: 1px solid #e5e7eb;
71 - font-size: 14px;
72 - color: #6b7280;
73 - text-align: center;
74 - }
75 - .info {
76 - margin-top: 20px;
77 - padding: 15px;
78 - background-color: #dbeafe;
79 - border-left: 4px solid #3b82f6;
80 - border-radius: 4px;
81 - font-size: 14px;
82 - }
83 - </style>
84 -</head>
85 -<body>
86 - <div class="email-container">
87 - <div class="header">
88 - <div class="logo">{{ app_name }}</div>
89 - </div>
90 -
91 - <h1>
92 - {% if locale == "fr" %}
93 - Vérification de votre adresse email
94 - {% else %}
95 - Verify Your Email Address
96 - {% endif %}
97 - </h1>
98 -
99 - <div class="content">
100 - {% if locale == "fr" %}
101 - <p>Bonjour {{ username }},</p>
102 - <p>Merci de vous être inscrit sur <strong>{{ app_name }}</strong>.</p>
103 - <p>Pour activer votre compte, veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
104 - {% else %}
105 - <p>Hello {{ username }},</p>
106 - <p>Thank you for signing up for <strong>{{ app_name }}</strong>.</p>
107 - <p>To activate your account, please verify your email address by clicking the button below:</p>
108 - {% endif %}
109 - </div>
110 -
111 - <div class="button-container">
112 - <a href="{{ validation_url }}" class="button">
113 - {% if locale == "fr" %}
114 - Vérifier mon email
115 - {% else %}
116 - Verify My Email
117 - {% endif %}
118 - </a>
119 - </div>
120 -
121 - <div class="link-fallback">
122 - {% if locale == "fr" %}
123 - <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 - <p>Copiez et collez ce lien dans votre navigateur :</p>
125 - {% else %}
126 - <p><strong>Button not working?</strong></p>
127 - <p>Copy and paste this link into your browser:</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; word-break: break-all;">{{ validation_url }}</p>
130 - </div>
131 -
132 - <div class="info">
133 - {% if locale == "fr" %}
134 - <p><strong>ℹ️ Important :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Ce lien est valide pendant <strong>24 heures</strong></li>
137 - <li>Si vous n'avez pas créé de compte, ignorez cet email</li>
138 - <li>Vous devez vérifier votre email avant de pouvoir vous connecter</li>
139 - </ul>
140 - {% else %}
141 - <p><strong>ℹ️ Important:</strong></p>
142 - <ul style="margin: 10px 0; padding-left: 20px;">
143 - <li>This link is valid for <strong>24 hours</strong></li>
144 - <li>If you did not create an account, please ignore this email</li>
145 - <li>You must verify your email before you can log in</li>
146 - </ul>
147 - {% endif %}
148 - </div>
149 -
150 - <div class="footer">
151 - {% if locale == "fr" %}
152 - <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
153 - <p>Si vous avez des questions, contactez le support technique.</p>
154 - {% else %}
155 - <p>This email was sent automatically, please do not reply.</p>
156 - <p>If you have any questions, please contact technical support.</p>
157 - {% endif %}
158 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 - </p>
161 - </div>
162 - </div>
163 -</body>
164 -</html>
D crates/rustwarden-ui/templates/emails/password_change_confirmation.html
+0 -167
@@ -1,167 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Confirmation de changement de mot de passe{% else %}Password Change Confirmation{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .button-container {
42 - text-align: center;
43 - margin: 30px 0;
44 - }
45 - .button {
46 - display: inline-block;
47 - padding: 12px 30px;
48 - background-color: #2563eb;
49 - color: #ffffff !important;
50 - text-decoration: none;
51 - border-radius: 6px;
52 - font-weight: 600;
53 - font-size: 16px;
54 - }
55 - .button:hover {
56 - background-color: #1d4ed8;
57 - }
58 - .link-fallback {
59 - margin-top: 20px;
60 - padding: 15px;
61 - background-color: #f3f4f6;
62 - border-radius: 6px;
63 - word-break: break-all;
64 - font-size: 14px;
65 - color: #6b7280;
66 - }
67 - .footer {
68 - margin-top: 30px;
69 - padding-top: 20px;
70 - border-top: 1px solid #e5e7eb;
71 - font-size: 14px;
72 - color: #6b7280;
73 - text-align: center;
74 - }
75 - .warning {
76 - margin-top: 20px;
77 - padding: 15px;
78 - background-color: #fef3c7;
79 - border-left: 4px solid #f59e0b;
80 - border-radius: 4px;
81 - font-size: 14px;
82 - }
83 - </style>
84 -</head>
85 -<body>
86 - <div class="email-container">
87 - <div class="header">
88 - <div class="logo">{{ app_name }}</div>
89 - </div>
90 -
91 - <h1>
92 - {% if locale == "fr" %}
93 - Confirmation de changement de mot de passe
94 - {% else %}
95 - Password Change Confirmation
96 - {% endif %}
97 - </h1>
98 -
99 - <div class="content">
100 - {% if locale == "fr" %}
101 - <p>Bonjour {{ username }},</p>
102 - <p>Vous avez demandé à changer votre mot de passe pour votre compte {{ app_name }}.</p>
103 - <p>Pour confirmer ce changement, cliquez sur le bouton ci-dessous :</p>
104 - {% else %}
105 - <p>Hello {{ username }},</p>
106 - <p>You have requested to change your password for your {{ app_name }} account.</p>
107 - <p>To confirm this change, click the button below:</p>
108 - {% endif %}
109 - </div>
110 -
111 - <div class="button-container">
112 - <a href="{{ confirmation_url }}" class="button">
113 - {% if locale == "fr" %}
114 - Confirmer le changement de mot de passe
115 - {% else %}
116 - Confirm Password Change
117 - {% endif %}
118 - </a>
119 - </div>
120 -
121 - <div class="link-fallback">
122 - {% if locale == "fr" %}
123 - <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 - <p>Copiez et collez ce lien dans votre navigateur :</p>
125 - {% else %}
126 - <p><strong>Button not working?</strong></p>
127 - <p>Copy and paste this link into your browser:</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; word-break: break-all;">{{ confirmation_url }}</p>
130 - </div>
131 -
132 - <div class="warning">
133 - {% if locale == "fr" %}
134 - <p><strong>⚠️ Important :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Ce lien est valide pendant <strong>{{ expires_in_hours }} heures</strong> uniquement</li>
137 - <li>Si vous n'avez pas demandé ce changement, ignorez cet email</li>
138 - <li>Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien</li>
139 - <li>Ce lien est à <strong>usage unique</strong> et ne peut être utilisé qu'une seule fois</li>
140 - </ul>
141 - {% else %}
142 - <p><strong>⚠️ Important:</strong></p>
143 - <ul style="margin: 10px 0; padding-left: 20px;">
144 - <li>This link is valid for <strong>{{ expires_in_hours }} hours</strong> only</li>
145 - <li>If you did not request this change, please ignore this email</li>
146 - <li>Your current password will remain unchanged if you don't use this link</li>
147 - <li>This link is <strong>single-use</strong> and can only be used once</li>
148 - </ul>
149 - {% endif %}
150 - </div>
151 -
152 - <div class="footer">
153 - {% if locale == "fr" %}
154 - <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
155 - <p>Si vous avez des questions, contactez le support technique.</p>
156 - {% else %}
157 - <p>This email was sent automatically, please do not reply.</p>
158 - <p>If you have any questions, please contact technical support.</p>
159 - {% endif %}
160 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
161 - © {{ current_year }} {{ app_name }}. {% if locale == "fr" %}Tous droits réservés.{% else %}All rights reserved.{% endif %}
162 - </p>
163 - </div>
164 - </div>
165 -</body>
166 -</html>
167 -
D crates/rustwarden-ui/templates/emails/password_changed.html
+0 -135
@@ -1,135 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Mot de passe modifié{% else %}Password Changed{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .info-box {
42 - margin-top: 20px;
43 - padding: 15px;
44 - background-color: #fef3c7;
45 - border-left: 4px solid #f59e0b;
46 - border-radius: 4px;
47 - font-size: 14px;
48 - }
49 - .warning-box {
50 - margin-top: 20px;
51 - padding: 15px;
52 - background-color: #fee2e2;
53 - border-left: 4px solid #ef4444;
54 - border-radius: 4px;
55 - font-size: 14px;
56 - }
57 - .footer {
58 - margin-top: 30px;
59 - padding-top: 20px;
60 - border-top: 1px solid #e5e7eb;
61 - font-size: 14px;
62 - color: #6b7280;
63 - text-align: center;
64 - }
65 - </style>
66 -</head>
67 -<body>
68 - <div class="email-container">
69 - <div class="header">
70 - <div class="logo">{{ app_name }}</div>
71 - </div>
72 -
73 - <h1>
74 - {% if locale == "fr" %}
75 - Mot de passe modifié
76 - {% else %}
77 - Password Changed
78 - {% endif %}
79 - </h1>
80 -
81 - <div class="content">
82 - {% if locale == "fr" %}
83 - <p>Bonjour {{ username }},</p>
84 - <p>Nous vous informons que le mot de passe de votre compte <strong>{{ app_name }}</strong> a été modifié avec succès.</p>
85 - <p><strong>Date et heure de modification :</strong> {{ changed_at }}</p>
86 - {% else %}
87 - <p>Hello {{ username }},</p>
88 - <p>We are informing you that the password for your <strong>{{ app_name }}</strong> account has been successfully changed.</p>
89 - <p><strong>Date and time of change:</strong> {{ changed_at }}</p>
90 - {% endif %}
91 - </div>
92 -
93 - <div class="info-box">
94 - {% if locale == "fr" %}
95 - <p><strong>ℹ️ Information :</strong></p>
96 - <p>Si vous n'avez pas effectué cette modification, veuillez contacter immédiatement le support technique et réinitialiser votre mot de passe.</p>
97 - {% else %}
98 - <p><strong>ℹ️ Information:</strong></p>
99 - <p>If you did not make this change, please contact technical support immediately and reset your password.</p>
100 - {% endif %}
101 - </div>
102 -
103 - <div class="warning-box">
104 - {% if locale == "fr" %}
105 - <p><strong>⚠️ Sécurité :</strong></p>
106 - <ul style="margin: 10px 0; padding-left: 20px;">
107 - <li>Ne partagez jamais votre mot de passe avec qui que ce soit</li>
108 - <li>Utilisez un mot de passe unique et fort</li>
109 - <li>Si vous suspectez une activité suspecte, changez votre mot de passe immédiatement</li>
110 - </ul>
111 - {% else %}
112 - <p><strong>⚠️ Security:</strong></p>
113 - <ul style="margin: 10px 0; padding-left: 20px;">
114 - <li>Never share your password with anyone</li>
115 - <li>Use a unique and strong password</li>
116 - <li>If you suspect suspicious activity, change your password immediately</li>
117 - </ul>
118 - {% endif %}
119 - </div>
120 -
121 - <div class="footer">
122 - {% if locale == "fr" %}
123 - <p>Cet email a été envoyé automatiquement pour votre sécurité.</p>
124 - <p>Si vous avez des questions, contactez le support technique.</p>
125 - {% else %}
126 - <p>This email was sent automatically for your security.</p>
127 - <p>If you have any questions, please contact technical support.</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
130 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
131 - </p>
132 - </div>
133 - </div>
134 -</body>
135 -</html>
D crates/rustwarden-ui/templates/emails/password_reset.html
+0 -164
@@ -1,164 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Réinitialisation de mot de passe{% else %}Password Reset{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .button-container {
42 - text-align: center;
43 - margin: 30px 0;
44 - }
45 - .button {
46 - display: inline-block;
47 - padding: 12px 30px;
48 - background-color: #2563eb;
49 - color: #ffffff !important;
50 - text-decoration: none;
51 - border-radius: 6px;
52 - font-weight: 600;
53 - font-size: 16px;
54 - }
55 - .button:hover {
56 - background-color: #1d4ed8;
57 - }
58 - .link-fallback {
59 - margin-top: 20px;
60 - padding: 15px;
61 - background-color: #f3f4f6;
62 - border-radius: 6px;
63 - word-break: break-all;
64 - font-size: 14px;
65 - color: #6b7280;
66 - }
67 - .footer {
68 - margin-top: 30px;
69 - padding-top: 20px;
70 - border-top: 1px solid #e5e7eb;
71 - font-size: 14px;
72 - color: #6b7280;
73 - text-align: center;
74 - }
75 - .warning {
76 - margin-top: 20px;
77 - padding: 15px;
78 - background-color: #fef3c7;
79 - border-left: 4px solid #f59e0b;
80 - border-radius: 4px;
81 - font-size: 14px;
82 - }
83 - </style>
84 -</head>
85 -<body>
86 - <div class="email-container">
87 - <div class="header">
88 - <div class="logo">{{ app_name }}</div>
89 - </div>
90 -
91 - <h1>
92 - {% if locale == "fr" %}
93 - Réinitialisation de votre mot de passe
94 - {% else %}
95 - Reset Your Password
96 - {% endif %}
97 - </h1>
98 -
99 - <div class="content">
100 - {% if locale == "fr" %}
101 - <p>Bonjour {{ username }},</p>
102 - <p>Vous avez demandé à réinitialiser votre mot de passe pour votre compte {{ app_name }}.</p>
103 - <p>Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe :</p>
104 - {% else %}
105 - <p>Hello {{ username }},</p>
106 - <p>You have requested to reset your password for your {{ app_name }} account.</p>
107 - <p>Click the button below to reset your password:</p>
108 - {% endif %}
109 - </div>
110 -
111 - <div class="button-container">
112 - <a href="{{ reset_url }}" class="button">
113 - {% if locale == "fr" %}
114 - Réinitialiser mon mot de passe
115 - {% else %}
116 - Reset My Password
117 - {% endif %}
118 - </a>
119 - </div>
120 -
121 - <div class="link-fallback">
122 - {% if locale == "fr" %}
123 - <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 - <p>Copiez et collez ce lien dans votre navigateur :</p>
125 - {% else %}
126 - <p><strong>Button not working?</strong></p>
127 - <p>Copy and paste this link into your browser:</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; word-break: break-all;">{{ reset_url }}</p>
130 - </div>
131 -
132 - <div class="warning">
133 - {% if locale == "fr" %}
134 - <p><strong>⚠️ Important :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Ce lien est valide pendant <strong>1 heure</strong> uniquement</li>
137 - <li>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email</li>
138 - <li>Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien</li>
139 - </ul>
140 - {% else %}
141 - <p><strong>⚠️ Important:</strong></p>
142 - <ul style="margin: 10px 0; padding-left: 20px;">
143 - <li>This link is valid for <strong>1 hour</strong> only</li>
144 - <li>If you did not request this reset, please ignore this email</li>
145 - <li>Your current password will remain unchanged if you don't use this link</li>
146 - </ul>
147 - {% endif %}
148 - </div>
149 -
150 - <div class="footer">
151 - {% if locale == "fr" %}
152 - <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
153 - <p>Si vous avez des questions, contactez le support technique.</p>
154 - {% else %}
155 - <p>This email was sent automatically, please do not reply.</p>
156 - <p>If you have any questions, please contact technical support.</p>
157 - {% endif %}
158 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 - </p>
161 - </div>
162 - </div>
163 -</body>
164 -</html>
D crates/rustwarden-ui/templates/emails/welcome.html
+0 -176
@@ -1,176 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Bienvenue{% else %}Welcome{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 28px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - .welcome-icon {
34 - font-size: 48px;
35 - margin-bottom: 20px;
36 - }
37 - h1 {
38 - color: #1f2937;
39 - font-size: 28px;
40 - margin-bottom: 20px;
41 - text-align: center;
42 - }
43 - .content {
44 - margin-bottom: 30px;
45 - }
46 - .button-container {
47 - text-align: center;
48 - margin: 30px 0;
49 - }
50 - .button {
51 - display: inline-block;
52 - padding: 14px 32px;
53 - background-color: #10b981;
54 - color: #ffffff !important;
55 - text-decoration: none;
56 - border-radius: 6px;
57 - font-weight: 600;
58 - font-size: 16px;
59 - }
60 - .button:hover {
61 - background-color: #059669;
62 - }
63 - .features-list {
64 - margin: 20px 0;
65 - padding: 20px;
66 - background-color: #f3f4f6;
67 - border-radius: 6px;
68 - }
69 - .feature-item {
70 - margin: 12px 0;
71 - padding-left: 25px;
72 - position: relative;
73 - }
74 - .feature-item::before {
75 - content: "✓";
76 - position: absolute;
77 - left: 0;
78 - color: #10b981;
79 - font-weight: bold;
80 - font-size: 18px;
81 - }
82 - .info-box {
83 - margin-top: 20px;
84 - padding: 15px;
85 - background-color: #dbeafe;
86 - border-left: 4px solid #3b82f6;
87 - border-radius: 4px;
88 - font-size: 14px;
89 - }
90 - .footer {
91 - margin-top: 30px;
92 - padding-top: 20px;
93 - border-top: 1px solid #e5e7eb;
94 - font-size: 14px;
95 - color: #6b7280;
96 - text-align: center;
97 - }
98 - </style>
99 -</head>
100 -<body>
101 - <div class="email-container">
102 - <div class="header">
103 - <div class="logo">{{ app_name }}</div>
104 - <div class="welcome-icon">🎉</div>
105 - </div>
106 -
107 - <h1>
108 - {% if locale == "fr" %}
109 - Bienvenue sur {{ app_name }} !
110 - {% else %}
111 - Welcome to {{ app_name }}!
112 - {% endif %}
113 - </h1>
114 -
115 - <div class="content">
116 - {% if locale == "fr" %}
117 - <p>Bonjour {{ username }},</p>
118 - <p>Nous sommes ravis de vous accueillir sur <strong>{{ app_name }}</strong> ! Votre compte a été activé avec succès.</p>
119 - <p>Vous pouvez maintenant profiter de toutes les fonctionnalités de la plateforme.</p>
120 - {% else %}
121 - <p>Hello {{ username }},</p>
122 - <p>We are delighted to welcome you to <strong>{{ app_name }}</strong>! Your account has been successfully activated.</p>
123 - <p>You can now enjoy all the platform's features.</p>
124 - {% endif %}
125 - </div>
126 -
127 - <div class="button-container">
128 - <a href="{{ login_url }}" class="button">
129 - {% if locale == "fr" %}
130 - Se connecter
131 - {% else %}
132 - Log In
133 - {% endif %}
134 - </a>
135 - </div>
136 -
137 - <div class="features-list">
138 - {% if locale == "fr" %}
139 - <p style="font-weight: 600; margin-bottom: 15px;">Ce que vous pouvez faire :</p>
140 - <div class="feature-item">Accéder à votre tableau de bord personnalisé</div>
141 - <div class="feature-item">Gérer votre profil et vos préférences</div>
142 - <div class="feature-item">Profiter de toutes les fonctionnalités disponibles</div>
143 - {% else %}
144 - <p style="font-weight: 600; margin-bottom: 15px;">What you can do:</p>
145 - <div class="feature-item">Access your personalized dashboard</div>
146 - <div class="feature-item">Manage your profile and preferences</div>
147 - <div class="feature-item">Enjoy all available features</div>
148 - {% endif %}
149 - </div>
150 -
151 - <div class="info-box">
152 - {% if locale == "fr" %}
153 - <p><strong>ℹ️ Besoin d'aide ?</strong></p>
154 - <p>Si vous avez des questions ou besoin d'assistance, n'hésitez pas à consulter notre documentation ou à contacter le support technique.</p>
155 - {% else %}
156 - <p><strong>ℹ️ Need Help?</strong></p>
157 - <p>If you have any questions or need assistance, feel free to check our documentation or contact technical support.</p>
158 - {% endif %}
159 - </div>
160 -
161 - <div class="footer">
162 - {% if locale == "fr" %}
163 - <p>Merci de nous faire confiance et bonne utilisation de {{ app_name }} !</p>
164 - <p>Si vous avez des questions, contactez le support technique.</p>
165 - {% else %}
166 - <p>Thank you for trusting us and enjoy using {{ app_name }}!</p>
167 - <p>If you have any questions, please contact technical support.</p>
168 - {% endif %}
169 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
170 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
171 - </p>
172 - </div>
173 - </div>
174 -</body>
175 -</html>
176 -
D crates/rustwarden-ui/templates/errors/401.html
+0 -36
@@ -1,36 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>401 - Non Authentifié</title>
7 - <link href="/static/css/tailwind.css" rel="stylesheet">
8 -</head>
9 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 - <div class="max-w-md w-full text-center">
11 - <!-- Error Icon -->
12 - <div class="mb-8">
13 - <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-error/20">
14 - <span class="text-6xl">🔒</span>
15 - </div>
16 - </div>
17 -
18 - <!-- Error Message -->
19 - <h1 class="text-4xl font-bold text-base-content mb-4">401</h1>
20 - <h2 class="text-2xl font-semibold text-base-content mb-4">Non Authentifié</h2>
21 - <p class="text-base-content/70 mb-8">
22 - Vous devez vous connecter pour accéder à cette page.
23 - </p>
24 -
25 - <!-- Actions -->
26 - <div class="space-y-3">
27 - <a href="/login" class="btn btn-primary w-full">
28 - Se Connecter
29 - </a>
30 - <a href="/" class="btn btn-ghost w-full">
31 - Retour à l'Accueil
32 - </a>
33 - </div>
34 - </div>
35 -</body>
36 -</html>
D crates/rustwarden-ui/templates/errors/403.html
+0 -36
@@ -1,36 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>403 - Accès Refusé</title>
7 - <link href="/static/css/tailwind.css" rel="stylesheet">
8 -</head>
9 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 - <div class="max-w-md w-full text-center">
11 - <!-- Error Icon -->
12 - <div class="mb-8">
13 - <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-warning/20">
14 - <span class="text-6xl">⛔</span>
15 - </div>
16 - </div>
17 -
18 - <!-- Error Message -->
19 - <h1 class="text-4xl font-bold text-base-content mb-4">403</h1>
20 - <h2 class="text-2xl font-semibold text-base-content mb-4">Accès Refusé</h2>
21 - <p class="text-base-content/70 mb-8">
22 - Vous n'avez pas les permissions nécessaires pour accéder à cette page.
23 - </p>
24 -
25 - <!-- Actions -->
26 - <div class="space-y-3">
27 - <a href="/home" class="btn btn-primary w-full">
28 - Retour à l'Accueil
29 - </a>
30 - <a href="/profile" class="btn btn-ghost w-full">
31 - Mon profil
32 - </a>
33 - </div>
34 - </div>
35 -</body>
36 -</html>
D crates/rustwarden-ui/templates/errors/500.html
+0 -41
@@ -1,41 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>500 - Erreur Serveur</title>
7 - <link href="/static/css/tailwind.css" rel="stylesheet">
8 -</head>
9 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 - <div class="max-w-md w-full text-center">
11 - <!-- Error Icon -->
12 - <div class="mb-8">
13 - <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-error/20">
14 - <span class="text-6xl">⚠️</span>
15 - </div>
16 - </div>
17 -
18 - <!-- Error Message -->
19 - <h1 class="text-4xl font-bold text-base-content mb-4">500</h1>
20 - <h2 class="text-2xl font-semibold text-base-content mb-4">Erreur Serveur</h2>
21 - <p class="text-base-content/70 mb-8">
22 - Une erreur interne est survenue. Nos équipes ont été notifiées et travaillent sur le problème.
23 - </p>
24 -
25 - <!-- Actions -->
26 - <div class="space-y-3">
27 - <button onclick="window.location.reload()" class="btn btn-primary w-full">
28 - Réessayer
29 - </button>
30 - <a href="/home" class="btn btn-ghost w-full">
31 - Retour à l'Accueil
32 - </a>
33 - </div>
34 -
35 - <!-- Support Info -->
36 - <div class="mt-8 text-sm text-base-content/50">
37 - <p>Si le problème persiste, contactez le support.</p>
38 - </div>
39 - </div>
40 -</body>
41 -</html>
A crates/rustwarden-ui/templates/forgot_password.html
+108 -0
@@ -0,0 +1,108 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="min-h-screen flex items-center justify-center bg-base-200 px-4">
7 + <div class="max-w-lg w-full space-y-6">
8 + <div class="text-center">
9 + <h1 class="text-3xl font-bold text-base-content mb-2">{{ title }}</h1>
10 + <p class="text-base-content/70">{{ subtitle }}</p>
11 + </div>
12 +
13 + <div id="alert-error" class="hidden alert alert-error">
14 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
15 + <span id="alert-error-text"></span>
16 + </div>
17 +
18 + <div id="alert-success" class="hidden alert alert-success">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
20 + <span id="alert-success-text"></span>
21 + </div>
22 +
23 + <div class="card bg-base-100 shadow-xl p-8">
24 + <form id="forgot-form" class="space-y-4"
25 + data-error-default="{{ error_default }}"
26 + data-success-default="{{ success_default }}"
27 + data-error-missing-email="{{ error_missing_email }}"
28 + data-error-cannot-send="{{ error_cannot_send }}">
29 + <div class="form-control">
30 + <label for="email" class="label">
31 + <span class="label-text">{{ email_label }}</span>
32 + </label>
33 + <input
34 + type="email"
35 + id="email"
36 + name="email"
37 + required
38 + autocomplete="email"
39 + class="input input-bordered w-full"
40 + placeholder="{{ email_placeholder }}">
41 + </div>
42 +
43 + <div class="form-control mt-6">
44 + <button type="submit" class="btn btn-primary w-full">
45 + {{ submit }}
46 + </button>
47 + </div>
48 + </form>
49 +
50 + <div class="mt-4 text-sm text-center">
51 + <a href="/login" class="text-primary hover:underline">{{ back_to_login }}</a>
52 + </div>
53 + </div>
54 + </div>
55 +</div>
56 +
57 +<script>
58 + const form = document.getElementById('forgot-form');
59 + const MSG_ERROR_DEFAULT = form?.dataset.errorDefault || "";
60 + const MSG_SUCCESS_DEFAULT = form?.dataset.successDefault || "";
61 + const MSG_ERROR_MISSING_EMAIL = form?.dataset.errorMissingEmail || "";
62 + const MSG_ERROR_CANNOT_SEND = form?.dataset.errorCannotSend || "";
63 + const alertError = document.getElementById('alert-error');
64 + const alertErrorText = document.getElementById('alert-error-text');
65 + const alertSuccess = document.getElementById('alert-success');
66 + const alertSuccessText = document.getElementById('alert-success-text');
67 +
68 + function showError(msg) {
69 + if (alertError && alertErrorText) {
70 + alertErrorText.textContent = msg || MSG_ERROR_DEFAULT;
71 + alertError.classList.remove('hidden');
72 + }
73 + if (alertSuccess) alertSuccess.classList.add('hidden');
74 + }
75 +
76 + function showSuccess(msg) {
77 + if (alertSuccess && alertSuccessText) {
78 + alertSuccessText.textContent = msg || MSG_SUCCESS_DEFAULT;
79 + alertSuccess.classList.remove('hidden');
80 + }
81 + if (alertError) alertError.classList.add('hidden');
82 + if (form) form.classList.add('hidden');
83 + }
84 +
85 + form?.addEventListener('submit', (e) => {
86 + e.preventDefault();
87 + const email = document.getElementById('email')?.value;
88 + if (!email) {
89 + showError(MSG_ERROR_MISSING_EMAIL);
90 + return;
91 + }
92 + fetch('/api/v1/auth/forgot-password', {
93 + method: 'POST',
94 + headers: { 'Content-Type': 'application/json' },
95 + body: JSON.stringify({ email }),
96 + credentials: 'include'
97 + })
98 + .then(async (resp) => {
99 + if (resp.ok) {
100 + showSuccess(MSG_SUCCESS_DEFAULT);
101 + } else {
102 + showError(MSG_ERROR_CANNOT_SEND);
103 + }
104 + })
105 + .catch(() => showError(MSG_ERROR_CANNOT_SEND));
106 + });
107 +</script>
108 +{% endblock %}
M crates/rustwarden-ui/templates/login.html
+2 -1
@@ -16,7 +16,8 @@
16 16 <script src="/static/js/htmx.min.js"></script>
17 17
18 18 <!-- Alpine.js (local) -->
19 - <script src="/static/js/alpine.min.js" defer></script>
19 + <script src="/static/js/alpine-components.js" defer></script>
20 + <script src="/static/js/alpine-csp.min.js" defer></script>
20 21 </head>
21 22 <body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
22 23 <div class="max-w-md w-full">
M crates/rustwarden-ui/templates/login_2fa.html
+4 -47
@@ -6,10 +6,11 @@
6 6 <title>{{ page_title }}</title>
7 7 <link href="/static/css/tailwind.css" rel="stylesheet">
8 8 <link href="/static/css/custom.css" rel="stylesheet">
9 - <script src="/static/js/alpine.min.js" defer></script>
9 + <script src="/static/js/alpine-components.js" defer></script>
10 + <script src="/static/js/alpine-csp.min.js" defer></script>
10 11 </head>
11 12 <body class="min-h-screen bg-base-200">
12 - <div class="min-h-screen flex items-center justify-center px-4" x-data="twoFactorPage()">
13 + <div class="min-h-screen flex items-center justify-center px-4" x-data="twoFactorPage" data-error-invalid="{{ error_invalid_code }}" data-error-network="{{ error_network }}">
13 14 <div class="card bg-base-100 shadow-xl w-full max-w-md">
14 15 <div class="card-body">
15 16 <div class="text-center mb-6">
@@ -61,7 +62,7 @@
61 62
62 63 <!-- Toggle backup code -->
63 64 <div class="text-center mt-4">
64 - <button class="btn btn-ghost btn-sm" @click="useBackupCode = !useBackupCode; code = '';">
65 + <button class="btn btn-ghost btn-sm" @click="toggleBackup()">
65 66 <span x-show="!useBackupCode">{{ use_backup_code }}</span>
66 67 <span x-show="useBackupCode">{{ use_totp_code }}</span>
67 68 </button>
@@ -75,49 +76,5 @@
75 76 </div>
76 77 </div>
77 78
78 - <script>
79 - function twoFactorPage() {
80 - return {
81 - code: '',
82 - error: '',
83 - verifying: false,
84 - useBackupCode: false,
85 - challengeToken: new URLSearchParams(window.location.search).get('challenge') || '',
86 -
87 - async verify() {
88 - if (!this.code || !this.challengeToken) return;
89 - this.verifying = true;
90 - this.error = '';
91 - try {
92 - const res = await fetch('/api/v1/auth/2fa/verify', {
93 - method: 'POST',
94 - headers: { 'Content-Type': 'application/json' },
95 - body: JSON.stringify({
96 - challenge_token: this.challengeToken,
97 - code: this.code,
98 - }),
99 - });
100 - const data = await res.json();
101 - if (res.ok) {
102 - if (data.refresh_token) {
103 - localStorage.setItem('refresh_token', data.refresh_token);
104 - }
105 - if (data.user && data.user.must_change_password === true) {
106 - window.location.href = '/settings/password?must_change=true';
107 - } else {
108 - window.location.href = '/home';
109 - }
110 - } else {
111 - this.error = data.error || '{{ error_invalid_code }}';
112 - this.code = '';
113 - }
114 - } catch (e) {
115 - this.error = '{{ error_network }}';
116 - }
117 - this.verifying = false;
118 - }
119 - };
120 - }
121 - </script>
122 79 </body>
123 80 </html>
M crates/rustwarden-ui/templates/profile.html
+118 -54
@@ -28,21 +28,25 @@
28 28 <div class="card-body">
29 29 <h2 class="card-title mb-4">{{ account_info }}</h2>
30 30
31 - <form id="profileForm" method="POST" action="/profile" class="space-y-6">
32 - <!-- CSRF Token -->
31 + <form id="profileForm" class="space-y-6"
32 + data-saved="{{ profile_saved }}"
33 + data-error="{{ profile_error }}"
34 + data-network="{{ network_error }}"
35 + data-saving="{{ saving }}"
36 + data-save-label="{{ save_button }}">
33 37 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
34 38
35 - <!-- Username -->
36 39 <div class="form-control">
37 - <label class="label">
40 + <label class="label" for="username">
38 41 <span class="label-text font-semibold">{{ username_label }}</span>
39 42 <span class="label-text-alt text-warning">{{ username_public }}</span>
40 43 </label>
41 - <input
42 - type="text"
43 - name="username"
44 - value="{{ username }}"
45 - placeholder="{{ username_placeholder }}"
44 + <input
45 + type="text"
46 + id="username"
47 + name="username"
48 + value="{{ username }}"
49 + placeholder="{{ username_placeholder }}"
46 50 class="input input-bordered w-full"
47 51 minlength="3"
48 52 maxlength="39"
@@ -55,38 +59,73 @@
55 59 </label>
56 60 </div>
57 61
58 - <!-- Email -->
59 62 <div class="form-control">
60 63 <label class="label">
61 64 <span class="label-text font-semibold">{{ email_label }}</span>
62 65 <span class="label-text-alt text-info">{{ email_used_for }}</span>
63 66 </label>
64 - <input
65 - type="email"
66 - name="email"
67 - value="{{ email }}"
68 - placeholder="{{ email_placeholder }}"
67 + <input
68 + type="email"
69 + value="{{ email }}"
69 70 class="input input-bordered w-full"
70 - required
71 + readonly
71 72 >
72 73 <label class="label">
73 74 <span class="label-text-alt">{{ email_warning }}</span>
74 75 </label>
75 76 </div>
76 77
77 - <!-- Alert Info -->
78 - <div class="alert alert-info">
79 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
80 - <span>{{ email_change_info }}</span>
78 + <div class="card-actions justify-end">
79 + <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
80 + <button type="submit" class="btn btn-primary">{{ save_button }}</button>
81 81 </div>
82 + </form>
83 + </div>
84 + </div>
82 85
83 - <!-- Form Actions -->
86 + <div class="card bg-base-100 shadow-xl max-w-2xl mt-6">
87 + <div class="card-body">
88 + <h2 class="card-title mb-4">{{ email_change_title }}</h2>
89 + <div class="alert alert-info mb-4">
90 + <span>{{ email_change_info }}</span>
91 + </div>
92 + <div id="email-change-alert" class="hidden alert mb-4">
93 + <span id="email-change-alert-text"></span>
94 + </div>
95 + <form id="emailChangeForm" class="space-y-4"
96 + data-error="{{ email_change_error }}"
97 + data-network="{{ network_error }}"
98 + data-saving="{{ saving }}"
99 + data-submit-label="{{ request_button }}">
100 + <div class="form-control">
101 + <label class="label" for="new-email">
102 + <span class="label-text font-semibold">{{ new_email_label }}</span>
103 + </label>
104 + <input
105 + type="email"
106 + id="new-email"
107 + name="new_email"
108 + placeholder="{{ email_placeholder }}"
109 + class="input input-bordered w-full"
110 + required
111 + autocomplete="email"
112 + >
113 + </div>
114 + <div class="form-control">
115 + <label class="label" for="current-password">
116 + <span class="label-text font-semibold">{{ current_password_label }}</span>
117 + </label>
118 + <input
119 + type="password"
120 + id="current-password"
121 + name="current_password"
122 + class="input input-bordered w-full"
123 + required
124 + autocomplete="current-password"
125 + >
126 + </div>
84 127 <div class="card-actions justify-end">
85 - <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
86 - <button type="submit" class="btn btn-primary">
87 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
88 - {{ save_button }}
89 - </button>
128 + <button type="submit" class="btn btn-primary">{{ request_button }}</button>
90 129 </div>
91 130 </form>
92 131 </div>
@@ -135,52 +174,77 @@
135 174 </div>
136 175
137 176 <script>
138 -// Form submission handling
139 177 document.getElementById('profileForm').addEventListener('submit', async function(e) {
140 178 e.preventDefault();
141 -
142 - const formData = new FormData(this);
143 179 const submitBtn = this.querySelector('button[type="submit"]');
144 -
145 - // Disable submit button
180 + const username = document.getElementById('username').value;
146 181 submitBtn.disabled = true;
147 - submitBtn.innerHTML = '<span class="loading loading-spinner"></span> Enregistrement...';
148 -
182 + submitBtn.textContent = this.dataset.saving;
149 183 try {
150 184 const response = await fetch('/profile', {
151 185 method: 'PUT',
152 186 headers: {
153 - 'Content-Type': 'application/x-www-form-urlencoded',
187 + 'Content-Type': 'application/json',
188 + 'Accept': 'application/json',
154 189 'X-CSRF-Token': '{{ csrf_token }}',
155 190 },
156 - body: new URLSearchParams(formData)
191 + credentials: 'include',
192 + body: JSON.stringify({ username })
157 193 });
158 -
159 - const result = await response.json();
160 -
194 + const result = await response.json().catch(() => ({}));
161 195 if (response.ok) {
162 - // Show success message
163 - alert('✅ Profil mis à jour avec succès !');
164 -
165 - // Reload if email changed (will redirect to login)
166 - const originalEmail = '{{ email }}';
167 - if (formData.get('email') !== originalEmail) {
168 - alert('⚠️ Votre email a été modifié. Vous allez être déconnecté.');
169 - window.location.href = '/login';
170 - } else {
171 - window.location.reload();
172 - }
196 + alert(this.dataset.saved);
197 + window.location.reload();
173 198 } else {
174 - alert('❌ Erreur : ' + (result.error || result.message || 'Échec de la mise à jour'));
199 + alert(result.error || result.message || this.dataset.error);
175 200 submitBtn.disabled = false;
176 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Enregistrer les modifications';
201 + submitBtn.textContent = this.dataset.saveLabel;
177 202 }
178 203 } catch (error) {
179 - console.error('Error:', error);
180 - alert('❌ Erreur réseau. Veuillez réessayer.');
204 + alert(this.dataset.network);
181 205 submitBtn.disabled = false;
182 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Enregistrer les modifications';
206 + submitBtn.textContent = this.dataset.saveLabel;
207 + }
208 +});
209 +
210 +document.getElementById('emailChangeForm').addEventListener('submit', async function(e) {
211 + e.preventDefault();
212 + const submitBtn = this.querySelector('button[type="submit"]');
213 + const alertBox = document.getElementById('email-change-alert');
214 + const alertText = document.getElementById('email-change-alert-text');
215 + submitBtn.disabled = true;
216 + submitBtn.textContent = this.dataset.saving;
217 + alertBox.classList.add('hidden');
218 + try {
219 + const response = await fetch('/api/v1/auth/email-change', {
220 + method: 'POST',
221 + headers: {
222 + 'Content-Type': 'application/json',
223 + 'Accept': 'application/json',
224 + },
225 + credentials: 'include',
226 + body: JSON.stringify({
227 + new_email: document.getElementById('new-email').value,
228 + current_password: document.getElementById('current-password').value
229 + })
230 + });
231 + const result = await response.json().catch(() => ({}));
232 + alertBox.classList.remove('hidden', 'alert-success', 'alert-error');
233 + if (response.ok) {
234 + alertBox.classList.add('alert-success');
235 + alertText.textContent = result.message || '';
236 + this.reset();
237 + } else {
238 + alertBox.classList.add('alert-error');
239 + alertText.textContent = result.error || result.message || this.dataset.error;
240 + }
241 + } catch (error) {
242 + alertBox.classList.remove('hidden', 'alert-success');
243 + alertBox.classList.add('alert-error');
244 + alertText.textContent = this.dataset.network;
183 245 }
246 + submitBtn.disabled = false;
247 + submitBtn.textContent = this.dataset.submitLabel;
184 248 });
185 249 </script>
186 250 {% endblock %}
M crates/rustwarden-ui/templates/settings/security.html
+4 -126
@@ -36,7 +36,7 @@
36 36 </div>
37 37 </div>
38 38
39 - <div x-data="securityPage()" class="space-y-6">
39 + <div x-data="securityPage" data-csrf="{{ csrf_token }}" data-regenerate-confirm="{{ regenerate_confirm }}" class="space-y-6">
40 40 <!-- Status Card -->
41 41 <div class="card bg-base-100 shadow-lg">
42 42 <div class="card-body">
@@ -148,10 +148,10 @@
148 148 </button>
149 149 </div>
150 150 <div class="modal-action">
151 - <button class="btn btn-primary" @click="showBackupCodes = false; window.location.reload();">{{ saved_codes_button }}</button>
151 + <button class="btn btn-primary" @click="acknowledgeBackupCodes()">{{ saved_codes_button }}</button>
152 152 </div>
153 153 </div>
154 - <form method="dialog" class="modal-backdrop" @click="showBackupCodes = false; window.location.reload();">
154 + <form method="dialog" class="modal-backdrop" @click="acknowledgeBackupCodes()">
155 155 <button>close</button>
156 156 </form>
157 157 </dialog>
@@ -166,7 +166,7 @@
166 166 </div>
167 167 <p x-show="disableError" class="text-error text-sm mt-2" x-text="disableError"></p>
168 168 <div class="modal-action">
169 - <button class="btn" @click="showDisableModal = false; disablePassword = ''; disableError = '';">{{ cancel_button }}</button>
169 + <button class="btn" @click="closeDisableModal()">{{ cancel_button }}</button>
170 170 <button class="btn btn-error" @click="disableTotp()" :disabled="!disablePassword || disabling">
171 171 <span x-show="!disabling">{{ disable_button }}</span>
172 172 <span x-show="disabling" class="loading loading-spinner loading-sm"></span>
@@ -179,126 +179,4 @@
179 179 </dialog>
180 180 </div>
181 181 </div>
182 -
183 -<script>
184 -function securityPage() {
185 - return {
186 - setupStarted: false,
187 - setupComplete: false,
188 - setupError: '',
189 - qrCode: '',
190 - secretBase32: '',
191 - verifyCode: '',
192 - verifying: false,
193 - showBackupCodes: false,
194 - backupCodes: [],
195 - regenerating: false,
196 - showDisableModal: false,
197 - disablePassword: '',
198 - disableError: '',
199 - disabling: false,
200 -
201 - async startSetup() {
202 - this.setupStarted = true;
203 - this.setupError = '';
204 - try {
205 - const res = await fetch('/settings/security/totp/enable', {
206 - method: 'POST',
207 - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
208 - });
209 - const data = await res.json();
210 - if (res.ok) {
211 - this.qrCode = data.qr_code_data_uri;
212 - this.secretBase32 = data.secret_base32;
213 - } else {
214 - this.setupError = data.error || 'Error';
215 - this.setupStarted = false;
216 - }
217 - } catch (e) {
218 - this.setupError = 'Network error';
219 - this.setupStarted = false;
220 - }
221 - },
222 -
223 - async verifyAndEnable() {
224 - if (this.verifyCode.length !== 6) return;
225 - this.verifying = true;
226 - this.setupError = '';
227 - try {
228 - const res = await fetch('/settings/security/totp/verify', {
229 - method: 'POST',
230 - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
231 - body: JSON.stringify({ code: this.verifyCode }),
232 - });
233 - const data = await res.json();
234 - if (res.ok) {
235 - this.setupComplete = true;
236 - this.backupCodes = data.backup_codes;
237 - this.showBackupCodes = true;
238 - } else {
239 - this.setupError = data.error || 'Invalid code';
240 - this.verifyCode = '';
241 - }
242 - } catch (e) {
243 - this.setupError = 'Network error';
244 - }
245 - this.verifying = false;
246 - },
247 -
248 - async regenerateBackupCodes() {
249 - if (!confirm('{{ regenerate_confirm }}')) return;
250 - this.regenerating = true;
251 - try {
252 - const res = await fetch('/settings/security/backup-codes', {
253 - method: 'POST',
254 - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
255 - });
256 - const data = await res.json();
257 - if (res.ok) {
258 - this.backupCodes = data.backup_codes;
259 - this.showBackupCodes = true;
260 - }
261 - } catch (e) {}
262 - this.regenerating = false;
263 - },
264 -
265 - async disableTotp() {
266 - this.disabling = true;
267 - this.disableError = '';
268 - try {
269 - const res = await fetch('/settings/security/totp/disable', {
270 - method: 'POST',
271 - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
272 - body: JSON.stringify({ password: this.disablePassword }),
273 - });
274 - if (res.ok) {
275 - window.location.reload();
276 - } else {
277 - const data = await res.json();
278 - this.disableError = data.error || 'Error';
279 - }
280 - } catch (e) {
281 - this.disableError = 'Network error';
282 - }
283 - this.disabling = false;
284 - },
285 -
286 - copyBackupCodes() {
287 - const text = this.backupCodes.join('\n');
288 - if (navigator.clipboard && navigator.clipboard.writeText) {
289 - navigator.clipboard.writeText(text);
290 - } else {
291 - const ta = document.createElement('textarea');
292 - ta.value = text;
293 - ta.style.position = 'fixed';
294 - ta.style.opacity = '0';
295 - document.body.appendChild(ta);
296 - ta.select();
297 - document.execCommand('copy');
298 - document.body.removeChild(ta);
299 - }
300 - }
301 - };
302 -}
303 -</script>
304 182 {% endblock %}
M docs/MIGRATION_GUIDE.md
+31 -0
@@ -9,6 +9,37 @@ Les entrees sont classees par date, de la plus recente a la plus ancienne.
9 9
10 10 ---
11 11
12 +## 2026-10-01 — RFC 0039 phase 2 : CSP par défaut sans `unsafe-eval`
13 +
14 +Le défaut de `SecurityHeadersConfig` est maintenant `CSP_WITHOUT_UNSAFE_EVAL`
15 +(`script-src 'self' 'unsafe-inline'`). `'unsafe-inline'` reste : les scripts
16 +de boot (thème, langue) et HTMX sont encore inline. HSTS, `X-Frame-Options`,
17 +COOP et CORP ne changent pas.
18 +
19 +L'UI de référence charge `@alpinejs/csp` (`/static/js/alpine-csp.min.js`) et
20 +enregistre ses composants dans `/static/js/alpine-components.js`. Les
21 +expressions Alpine qui appellent `window`, `document` ou une fonction globale
22 +sont interdites par ce build : elles vivent dans `Alpine.data(...)`.
23 +
24 +**Retour arrière** si une app dépend encore du build Alpine standard (`new Function`) :
25 +
26 +```rust
27 +use std::borrow::Cow;
28 +use rustwarden_core::config::security_headers::{
29 + DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig,
30 +};
31 +
32 +let headers = SecurityHeadersConfig {
33 + content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY),
34 + ..SecurityHeadersConfig::default()
35 +};
36 +// .security_headers(headers) sur RustwardenBuilder
37 +```
38 +
39 +`DEFAULT_CONTENT_SECURITY_POLICY` est la politique 1.0.1, avec `'unsafe-eval'`.
40 +
41 +---
42 +
12 43 ## 2026-09-18 — RFC 0036-0041 (1.1.0)
13 44
14 45 Minor de securite. Les RFC 0036 a 0040 sont **Accepted (2026-09-18)**, la RFC
M docs/rfcs/0032-fediverse-event-observer-delete-actor.md
+4 -3
@@ -1,10 +1,11 @@
1 -# RFC 0004 — Ajouter `on_delete_actor` à `FediverseEventObserver`
1 +# RFC 0032 — Ajouter `on_delete_actor` à `FediverseEventObserver`
2 2
3 -> Numéro local gitrust. Upstream assignera vraisemblablement 0032+ à la livraison.
3 +> Ancien numéro local gitrust : RFC 0004. Le numéro upstream est 0032.
4 4
5 5 - **Auteur** : EBO (gitrust)
6 6 - **Date** : 2026-05-17
7 -- **Statut** : Draft (à pousser upstream)
7 +- **Statut** : Implemented (2026-10-01)
8 +- **Signature livrée** : `on_delete_actor(domain, ap_id, remote_identity_id)` — pas `rows_disabled`. Appelée depuis `apply_delete_actor`.
8 9 - **Crate cible** : `rustwarden-fediverse`
9 10 - **Type** : extension trait additive (pas de breaking change pour les consommateurs existants — ajout d'une méthode `default` au trait `FediverseEventObserver`)
10 11
M docs/rfcs/0034-edition-2024-fmt-cleanup.md
+1 -1
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (gitrust)
4 4 - **Date** : 2026-05-18
5 -- **Statut** : Draft
5 +- **Statut** : Implemented (2026-10-01) — `cargo fmt --all` appliqué sur le workspace. Pas de commit dans ce train (le versioning reste manuel).
6 6 - **Apps consommatrices concernées** : gitrust (immédiat — blocage CI sur `cargo fmt --all -- --check`), tout futur consommateur qui pose une CI stricte sur le formatage du workspace.
7 7 - **Crate cible** : `rustwarden-core`, `rustwarden-fediverse`, `rustwarden-fediverse-guard` (les 3 crates upstream vendorées dans le workspace gitrust)
8 8
M docs/rfcs/0036-username-charset-validation.md
+1 -1
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / Rustwarden 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Accepted (2026-09-18)
5 +- **Statut** : Implemented (2026-10-01 ; Accepted 2026-09-18)
6 6 - **Apps consommatrices concernées** : Gitrust (newtype local à retirer après Accept + impl) ; tout consommateur qui appelle `UserService::create_user` / `update_profile` ou `POST /api/v1/auth/register`
7 7 - **Crate cible** : `rustwarden-core`
8 8 - **Origine** : constat M-1 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 13)
M docs/rfcs/0037-totp-last-used-step.md
+1 -1
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Accepted (2026-09-18)
5 +- **Statut** : Implemented (2026-10-01 ; Accepted 2026-09-18)
6 6 - **Apps consommatrices concernées** : Gitrust (déploiements multi-réplicas) ; tout consommateur de `rustwarden-core` qui appelle `TotpService::verify_code` / `verify_and_enable` (API `POST /api/v1/auth/2fa/verify`, UI login 2FA, apps headless)
7 7 - **Crate cible** : `rustwarden-core`
8 8 - **Origine** : constat M-7 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 14). RFC **obligatoire** : changement de schéma DB ([`RUSTWARDEN_EVOLUTION_PROCESS.md` §2](../RUSTWARDEN_EVOLUTION_PROCESS.md#2-quand-une-rfc-est-obligatoire)).
M docs/rfcs/0039-security-headers-config.md
+3 -3
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Accepted (2026-09-18)
5 +- **Statut** : Implemented (2026-10-01) — phase 1 (API) et phase 2 (défaut sans `'unsafe-eval'`). `'unsafe-inline'` reste.
6 6 - **Apps consommatrices concernées** : Gitrust (pourra retirer `SetResponseHeaderLayer::overriding`) ; `rustwarden-ui` / `rustwarden-app` ; tout consommateur de `RustwardenApp::builder()`
7 7 - **Crate cible** : `rustwarden-core`
8 8 - **Origine** : constats M-10 + F-UI du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 16). CSP Alpine : [`docs/plans/PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md).
@@ -220,9 +220,9 @@ async fn security_headers_middleware(
220 220
221 221 Constitution : pas d’`unwrap`/`expect` hors tests.
222 222
223 -### Phase 2 (après Accept de cette RFC **et** Alpine CSP)
223 +### Phase 2 (livrée le 2026-10-01)
224 224
225 -Prérequis : [`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1 (`@alpinejs/csp`, inventaire `x-data` inline → `Alpine.data`).
225 +Prérequis : [`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1 (`@alpinejs/csp`, inventaire `x-data` inline → `Alpine.data`). **Fait** : `static/js/alpine-csp.min.js`, composants dans `static/js/alpine-components.js`, `SecurityHeadersConfig::default` pointe sur `CSP_WITHOUT_UNSAFE_EVAL`. La constante `DEFAULT_CONTENT_SECURITY_POLICY` conserve la politique 1.0.1 pour un retour arrière explicite.
226 226
227 227 Alors seulement : `Default` pointe sur `CSP_WITHOUT_UNSAFE_EVAL` (retirer `'unsafe-eval'`, **garder** `'unsafe-inline'` jusqu’aux phases suivantes du plan). C’est un **changement de défaut** : PR séparé, mention CHANGELOG `Security`, éventuellement minor. Hors du premier PR d’implémentation de **cette** RFC.
228 228
M docs/rfcs/0040-authenticate-user-email-verified.md
+1 -1
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Accepted (2026-09-18)
5 +- **Statut** : Implemented (2026-10-01 ; Accepted 2026-09-18)
6 6 - **Apps consommatrices concernées** : Gitrust (SSR / appels directs au service) ; `rustwarden-ui` (déjà via `POST /api/v1/auth/login`) ; tout consommateur de `rustwarden-core` qui appelle `AuthService::authenticate_user`
7 7 - **Crate cible** : `rustwarden-core`
8 8 - **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 17). Contrat limiteur : finding E-2 / layer `auth_ip_rate_limit` 1.0.1.
M docs/rfcs/0041-pending-email-changes.md
+1 -1
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / suite de la RFC 0038)
4 4 - **Date** : 2026-09-19
5 -- **Statut** : Accepted (2026-09-19)
5 +- **Statut** : Implemented (2026-10-01 ; Accepted 2026-09-19)
6 6 - **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/{id}`) ; tout consommateur headless qui appelle `UserService::update_profile` avec un e-mail
7 7 - **Crate cible** : `rustwarden-core` (**core uniquement**)
8 8 - **Origine** : § « v2 (follow-up) » de la [RFC 0038](0038-email-change-revalidation.md) (L117-121), déclenchée par le mode de panne documenté dans [`docs/MIGRATION_GUIDE.md`](../MIGRATION_GUIDE.md) (§ RFC 0038, « Faute de frappe sur la nouvelle adresse = lock-out définitif sans recours self-service »)
M docs/rfcs/README.md
+7 -7
@@ -88,16 +88,16 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
88 88 | [0029](0029-fediverse-remote-resource-shares.md) | Fediverse — `RemoteResourceShareService` (partages cross-instance de ressources génériques) | **Accepted** (33/35, livré + 11 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
89 89 | [0030](0030-fediverse-remote-personal-access-tokens.md) | Fediverse — `RemotePersonalAccessTokenService` (PAT pour identités remote) | **Accepted** (34/35, livré + 20 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
90 90 | [0031](0031-auth-service-optional-fallback.md) | Auth — fallback `AuthConfig::from_env()` sur la branche optional de `AuthService` | **Accepted** (implémenté 2026-05-16) | EBO (gitrust) | 2026-05-16 |
91 -| [0032](0032-fediverse-event-observer-delete-actor.md) | Fediverse — `on_delete_actor` sur `FediverseEventObserver` | **Draft** | EBO (gitrust) | 2026-05-17 |
91 +| [0032](0032-fediverse-event-observer-delete-actor.md) | Fediverse — `on_delete_actor` sur `FediverseEventObserver` | **Implemented** | EBO (gitrust) | 2026-05-17 |
92 92 | [0033](0033-fediverse-delete-actor-hooks-unification.md) | Fediverse — unifier `apply_delete_actor*` derrière `DeleteActorHooks` | **Implemented** | EBO | 2026-05-17 |
93 -| [0034](0034-edition-2024-fmt-cleanup.md) | Workspace — cleanup rustfmt après migration édition 2024 | **Draft** | EBO (gitrust) | 2026-05-18 |
93 +| [0034](0034-edition-2024-fmt-cleanup.md) | Workspace — cleanup rustfmt après migration édition 2024 | **Implemented** | EBO (gitrust) | 2026-05-18 |
94 94 | [0035](0035-oauth-pkce-code-verifier.md) | OAuth — envoyer `code_verifier` PKCE à l'échange (GitHub & Discord) | **Implemented** (2026-06-18) | PasterMan | 2026-06-18 |
95 -| [0036](0036-username-charset-validation.md) | Auth — newtype `Username` (charset GitHub-like, audit M-1) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / Rustwarden 1.0.1) | 2026-09-18 |
96 -| [0037](0037-totp-last-used-step.md) | Auth — persister `last_used_step` TOTP (anti-rejeu multi-instance, audit M-7) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
95 +| [0036](0036-username-charset-validation.md) | Auth — newtype `Username` (charset GitHub-like, audit M-1) | **Implemented** | EBO (origine audit Gitrust / Rustwarden 1.0.1) | 2026-09-18 |
96 +| [0037](0037-totp-last-used-step.md) | Auth — persister `last_used_step` TOTP (anti-rejeu multi-instance, audit M-7) | **Implemented** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
97 97 | [0038](0038-email-change-revalidation.md) | Auth — revalidation e-mail à `UserService::update_profile` | **Superseded by [0041](0041-pending-email-changes.md)** (2026-09-19 ; était Accepted 2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
98 -| [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
99 -| [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
100 -| [0041](0041-pending-email-changes.md) | Auth — changement d'e-mail en deux temps (`pending_email_changes`) — **remplace la [0038](0038-email-change-revalidation.md)** | **Accepted** (2026-09-19) | EBO (origine audit Gitrust / suite de la RFC 0038) | 2026-09-19 |
98 +| [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Implemented** (phase 2, sans `unsafe-eval`) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
99 +| [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Implemented** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
100 +| [0041](0041-pending-email-changes.md) | Auth — changement d'e-mail en deux temps (`pending_email_changes`) — **remplace la [0038](0038-email-change-revalidation.md)** | **Implemented** | EBO (origine audit Gitrust / suite de la RFC 0038) | 2026-09-19 |
101 101
102 102 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
103 103
M static/css/custom.css
+1 -1
@@ -1,4 +1,4 @@
1 -/* RustCloud Custom Styles */
1 +/* Rustwarden custom styles */
2 2
3 3 :root {
4 4 --rw-primary: #0082c9;
M static/css/tailwind.css
+891 -548
@@ -10,10 +10,15 @@
10 10 --color-red-500: oklch(63.7% 0.237 25.331);
11 11 --color-gray-800: oklch(27.8% 0.033 256.848);
12 12 --color-black: #000;
13 + --color-white: #fff;
13 14 --spacing: 0.25rem;
15 + --container-xs: 20rem;
16 + --container-sm: 24rem;
14 17 --container-md: 28rem;
15 18 --container-lg: 32rem;
19 + --container-xl: 36rem;
16 20 --container-2xl: 42rem;
21 + --container-3xl: 48rem;
17 22 --container-4xl: 56rem;
18 23 --container-6xl: 72rem;
19 24 --text-xs: 0.75rem;
@@ -32,13 +37,17 @@
32 37 --text-3xl--line-height: calc(2.25 / 1.875);
33 38 --text-4xl: 2.25rem;
34 39 --text-4xl--line-height: calc(2.5 / 2.25);
40 + --text-5xl: 3rem;
41 + --text-5xl--line-height: 1;
35 42 --text-6xl: 3.75rem;
36 43 --text-6xl--line-height: 1;
37 44 --font-weight-normal: 400;
38 45 --font-weight-medium: 500;
39 46 --font-weight-semibold: 600;
40 47 --font-weight-bold: 700;
48 + --tracking-wide: 0.025em;
41 49 --tracking-wider: 0.05em;
50 + --tracking-widest: 0.1em;
42 51 --radius-lg: 0.5rem;
43 52 --ease-in: cubic-bezier(0.4, 0, 1, 1);
44 53 --ease-out: cubic-bezier(0, 0, 0.2, 1);
@@ -197,6 +206,59 @@
197 206 }
198 207 }
199 208 @layer utilities {
209 + .diff {
210 + @layer daisyui.l1.l2.l3 {
211 + position: relative;
212 + display: grid;
213 + width: 100%;
214 + overflow: hidden;
215 + webkit-user-select: none;
216 + user-select: none;
217 + grid-template-rows: 1fr 1.8rem 1fr;
218 + direction: ltr;
219 + container-type: inline-size;
220 + grid-template-columns: auto 1fr;
221 + &:focus-visible, &:has(.diff-item-1:focus-visible) {
222 + outline-style: var(--tw-outline-style);
223 + outline-width: 2px;
224 + outline-offset: 1px;
225 + outline-color: var(--color-base-content);
226 + }
227 + &:focus-visible {
228 + outline-style: var(--tw-outline-style);
229 + outline-width: 2px;
230 + outline-offset: 1px;
231 + outline-color: var(--color-base-content);
232 + .diff-resizer {
233 + min-width: 95cqi;
234 + max-width: 95cqi;
235 + }
236 + }
237 + &:has(.diff-item-1:focus-visible) {
238 + outline-style: var(--tw-outline-style);
239 + outline-width: 2px;
240 + outline-offset: 1px;
241 + .diff-resizer {
242 + min-width: 5cqi;
243 + max-width: 5cqi;
244 + }
245 + }
246 + @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
247 + &:focus {
248 + .diff-resizer {
249 + min-width: 5cqi;
250 + max-width: 5cqi;
251 + }
252 + }
253 + &:has(.diff-item-1:focus) {
254 + .diff-resizer {
255 + min-width: 95cqi;
256 + max-width: 95cqi;
257 + }
258 + }
259 + }
260 + }
261 + }
200 262 .modal {
201 263 @layer daisyui.l1.l2.l3 {
202 264 pointer-events: none;
@@ -583,6 +645,28 @@
583 645 }
584 646 }
585 647 }
648 + .collapse-arrow {
649 + @layer daisyui.l1.l2 {
650 + > .collapse-title:after {
651 + position: absolute;
652 + display: block;
653 + height: 0.5rem;
654 + width: 0.5rem;
655 + transform: translateY(-100%) rotate(45deg);
656 + @media (prefers-reduced-motion: no-preference) {
657 + transition-property: all;
658 + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
659 + transition-duration: 0.2s;
660 + }
661 + top: 50%;
662 + inset-inline-end: 1.4rem;
663 + content: "";
664 + transform-origin: 75% 75%;
665 + box-shadow: 2px 2px;
666 + pointer-events: none;
667 + }
668 + }
669 + }
586 670 .dropdown {
587 671 @layer daisyui.l1.l2.l3 {
588 672 position: relative;
@@ -1036,6 +1120,27 @@
1036 1120 }
1037 1121 }
1038 1122 }
1123 + .collapse-content {
1124 + @layer daisyui.l1.l2.l3 {
1125 + grid-column-start: 1;
1126 + grid-row-start: 1;
1127 + }
1128 + @layer daisyui.l1.l2.l3 {
1129 + content-visibility: hidden;
1130 + grid-column-start: 1;
1131 + grid-row-start: 2;
1132 + min-height: 0;
1133 + padding-left: 1rem;
1134 + padding-right: 1rem;
1135 + cursor: unset;
1136 + @supports not (content-visibility: hidden) {
1137 + visibility: hidden;
1138 + }
1139 + @media (prefers-reduced-motion: no-preference) {
1140 + transition: content-visibility 0.2s allow-discrete, visibility 0.2s allow-discrete, min-height 0.2s ease-out allow-discrete, padding 0.1s ease-out 20ms, background-color 0.2s ease-out;
1141 + }
1142 + }
1143 + }
1039 1144 .validator {
1040 1145 @layer daisyui.l1.l2.l3 {
1041 1146 &:user-valid, &:has(:user-valid) {
@@ -1062,6 +1167,9 @@
1062 1167 .collapse {
1063 1168 visibility: collapse;
1064 1169 }
1170 + .invisible {
1171 + visibility: hidden;
1172 + }
1065 1173 .visible {
1066 1174 visibility: visible;
1067 1175 }
@@ -1124,6 +1232,285 @@
1124 1232 }
1125 1233 }
1126 1234 }
1235 + .toast {
1236 + @layer daisyui.l1.l2.l3 {
1237 + position: fixed;
1238 + inset-inline-start: auto;
1239 + inset-inline-end: calc(0.25rem * 4);
1240 + top: auto;
1241 + bottom: calc(0.25rem * 4);
1242 + display: flex;
1243 + flex-direction: column;
1244 + gap: calc(0.25rem * 2);
1245 + background-color: transparent;
1246 + translate: var(--toast-x, 0) var(--toast-y, 0);
1247 + width: max-content;
1248 + max-width: calc(100vw - 2rem);
1249 + & > * {
1250 + @media (prefers-reduced-motion: no-preference) {
1251 + animation: toast 0.25s ease-out;
1252 + }
1253 + }
1254 + }
1255 + }
1256 + .toggle {
1257 + @layer daisyui.l1.l2.l3 {
1258 + border: var(--border) solid currentColor;
1259 + color: var(--input-color);
1260 + position: relative;
1261 + display: inline-grid;
1262 + flex-shrink: 0;
1263 + cursor: pointer;
1264 + appearance: none;
1265 + place-content: center;
1266 + vertical-align: middle;
1267 + webkit-user-select: none;
1268 + user-select: none;
1269 + grid-template-columns: 0fr 1fr 1fr;
1270 + --radius-selector-max: calc(
1271 + var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
1272 + );
1273 + border-radius: calc( var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)) );
1274 + padding: var(--toggle-p);
1275 + box-shadow: 0 1px currentColor inset;
1276 + @supports (color: color-mix(in lab, red, red)) {
1277 + box-shadow: 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;
1278 + }
1279 + transition: color 0.3s, grid-template-columns 0.2s;
1280 + --input-color: var(--color-base-content);
1281 + @supports (color: color-mix(in lab, red, red)) {
1282 + --input-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
1283 + }
1284 + --toggle-p: calc(var(--size) * 0.125);
1285 + --size: calc(var(--size-selector, 0.25rem) * 6);
1286 + width: calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);
1287 + height: var(--size);
1288 + > * {
1289 + z-index: 1;
1290 + grid-column: span 1 / span 1;
1291 + grid-column-start: 2;
1292 + grid-row-start: 1;
1293 + height: 100%;
1294 + cursor: pointer;
1295 + appearance: none;
1296 + background-color: transparent;
1297 + padding: calc(0.25rem * 0.5);
1298 + transition: opacity 0.2s, rotate 0.4s;
1299 + border: none;
1300 + &:focus {
1301 + --tw-outline-style: none;
1302 + outline-style: none;
1303 + @media (forced-colors: active) {
1304 + outline: 2px solid transparent;
1305 + outline-offset: 2px;
1306 + }
1307 + }
1308 + &:nth-child(2) {
1309 + color: var(--color-base-100);
1310 + rotate: 0deg;
1311 + }
1312 + &:nth-child(3) {
1313 + color: var(--color-base-100);
1314 + opacity: 0%;
1315 + rotate: -15deg;
1316 + }
1317 + }
1318 + &:has(:checked) {
1319 + > :nth-child(2) {
1320 + opacity: 0%;
1321 + rotate: 15deg;
1322 + }
1323 + > :nth-child(3) {
1324 + opacity: 100%;
1325 + rotate: 0deg;
1326 + }
1327 + }
1328 + &:before {
1329 + position: relative;
1330 + inset-inline-start: calc(0.25rem * 0);
1331 + grid-column-start: 2;
1332 + grid-row-start: 1;
1333 + aspect-ratio: 1 / 1;
1334 + height: 100%;
1335 + width: 100%;
1336 + border-radius: var(--radius-selector);
1337 + background-color: currentcolor;
1338 + translate: 0;
1339 + --tw-content: "";
1340 + content: var(--tw-content);
1341 + transition: background-color 0.1s, translate 0.2s, inset-inline-start 0.2s;
1342 + box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor;
1343 + @supports (color: color-mix(in lab, red, red)) {
1344 + box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);
1345 + }
1346 + background-size: auto, calc(var(--noise) * 100%);
1347 + background-image: none, var(--fx-noise);
1348 + }
1349 + @media (forced-colors: active) {
1350 + &:before {
1351 + outline-style: var(--tw-outline-style);
1352 + outline-width: 1px;
1353 + outline-offset: calc(1px * -1);
1354 + }
1355 + }
1356 + @media print {
1357 + &:before {
1358 + outline: 0.25rem solid;
1359 + outline-offset: -1rem;
1360 + }
1361 + }
1362 + &:focus-visible, &:has(:focus-visible) {
1363 + outline: 2px solid currentColor;
1364 + outline-offset: 2px;
1365 + }
1366 + &:checked, &[aria-checked="true"], &:has(> input:checked) {
1367 + grid-template-columns: 1fr 1fr 0fr;
1368 + background-color: var(--color-base-100);
1369 + --input-color: var(--color-base-content);
1370 + &:before {
1371 + background-color: currentcolor;
1372 + }
1373 + @starting-style {
1374 + &:before {
1375 + opacity: 0;
1376 + }
1377 + }
1378 + }
1379 + &:indeterminate {
1380 + grid-template-columns: 0.5fr 1fr 0.5fr;
1381 + }
1382 + &:disabled {
1383 + cursor: not-allowed;
1384 + opacity: 30%;
1385 + &:before {
1386 + background-color: transparent;
1387 + border: var(--border) solid currentColor;
1388 + }
1389 + }
1390 + }
1391 + }
1392 + .\!input {
1393 + @layer daisyui.l1.l2.l3 {
1394 + cursor: text !important;
1395 + border: var(--border) solid #0000 !important;
1396 + position: relative !important;
1397 + display: inline-flex !important;
1398 + flex-shrink: 1 !important;
1399 + appearance: none !important;
1400 + align-items: center !important;
1401 + gap: calc(0.25rem * 2) !important;
1402 + background-color: var(--color-base-100) !important;
1403 + padding-inline: calc(0.25rem * 3) !important;
1404 + vertical-align: middle !important;
1405 + white-space: nowrap !important;
1406 + width: clamp(3rem, 20rem, 100%) !important;
1407 + height: var(--size) !important;
1408 + font-size: max(var(--font-size, 0.875rem), 0.875rem) !important;
1409 + touch-action: manipulation !important;
1410 + border-start-start-radius: var(--join-ss, var(--radius-field)) !important;
1411 + border-start-end-radius: var(--join-se, var(--radius-field)) !important;
1412 + border-end-start-radius: var(--join-es, var(--radius-field)) !important;
1413 + border-end-end-radius: var(--join-ee, var(--radius-field)) !important;
1414 + border-color: var(--input-color) !important;
1415 + box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset !important;
1416 + @supports (color: color-mix(in lab, red, red)) {
1417 + box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset !important;
1418 + }
1419 + --size: calc(var(--size-field, 0.25rem) * 10) !important;
1420 + --input-color: var(--color-base-content) !important;
1421 + @supports (color: color-mix(in lab, red, red)) {
1422 + --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000) !important;
1423 + }
1424 + &:where(input) {
1425 + display: inline-flex !important;
1426 + }
1427 + :where(input) {
1428 + display: inline-flex !important;
1429 + height: 100% !important;
1430 + width: 100% !important;
1431 + appearance: none !important;
1432 + background-color: transparent !important;
1433 + border: none !important;
1434 + &:focus, &:focus-within {
1435 + --tw-outline-style: none !important;
1436 + outline-style: none !important;
1437 + @media (forced-colors: active) {
1438 + outline: 2px solid transparent !important;
1439 + outline-offset: 2px !important;
1440 + }
1441 + }
1442 + }
1443 + :where(input[type="url"]), :where(input[type="email"]) {
1444 + direction: ltr !important;
1445 + }
1446 + :where(input[type="date"]) {
1447 + display: inline-flex !important;
1448 + }
1449 + &:focus, &:focus-within {
1450 + --input-color: var(--color-base-content) !important;
1451 + box-shadow: 0 1px var(--input-color) !important;
1452 + @supports (color: color-mix(in lab, red, red)) {
1453 + box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) !important;
1454 + }
1455 + outline: 2px solid var(--input-color) !important;
1456 + outline-offset: 2px !important;
1457 + isolation: isolate !important;
1458 + }
1459 + @media (pointer: coarse) {
1460 + @supports (-webkit-touch-callout: none) {
1461 + &:focus, &:focus-within {
1462 + --font-size: 1rem !important;
1463 + }
1464 + }
1465 + }
1466 + &:has(> input[disabled]), &:is(:disabled, [disabled]), fieldset:disabled & {
1467 + cursor: not-allowed !important;
1468 + border-color: var(--color-base-200) !important;
1469 + background-color: var(--color-base-200) !important;
1470 + color: var(--color-base-content) !important;
1471 + @supports (color: color-mix(in lab, red, red)) {
1472 + color: color-mix(in oklab, var(--color-base-content) 40%, transparent) !important;
1473 + }
1474 + &::placeholder {
1475 + color: var(--color-base-content) !important;
1476 + @supports (color: color-mix(in lab, red, red)) {
1477 + color: color-mix(in oklab, var(--color-base-content) 20%, transparent) !important;
1478 + }
1479 + }
1480 + box-shadow: none !important;
1481 + }
1482 + &:has(> input[disabled]) > input[disabled] {
1483 + cursor: not-allowed !important;
1484 + }
1485 + &::-webkit-date-and-time-value {
1486 + text-align: inherit !important;
1487 + }
1488 + &[type="number"] {
1489 + &::-webkit-inner-spin-button {
1490 + margin-block: calc(0.25rem * -3) !important;
1491 + margin-inline-end: calc(0.25rem * -3) !important;
1492 + }
1493 + }
1494 + &::-webkit-calendar-picker-indicator {
1495 + position: absolute !important;
1496 + inset-inline-end: 0.75em !important;
1497 + }
1498 + &:has(> input[type="date"]) {
1499 + :where(input[type="date"]) {
1500 + display: inline-flex !important;
1501 + webkit-appearance: none !important;
1502 + appearance: none !important;
1503 + }
1504 + input[type="date"]::-webkit-calendar-picker-indicator {
1505 + position: absolute !important;
1506 + inset-inline-end: 0.75em !important;
1507 + width: 1em !important;
1508 + height: 1em !important;
1509 + cursor: pointer !important;
1510 + }
1511 + }
1512 + }
1513 + }
1127 1514 .input {
1128 1515 @layer daisyui.l1.l2.l3 {
1129 1516 cursor: text;
@@ -1706,28 +2093,6 @@
1706 2093 }
1707 2094 }
1708 2095 }
1709 - .image-full {
1710 - @layer daisyui.l1.l2 {
1711 - display: grid;
1712 - > * {
1713 - grid-column-start: 1;
1714 - grid-row-start: 1;
1715 - }
1716 - > .card-body {
1717 - position: relative;
1718 - color: var(--color-neutral-content);
1719 - }
1720 - :where(figure) {
1721 - overflow: hidden;
1722 - border-radius: inherit;
1723 - }
1724 - > figure img {
1725 - height: 100%;
1726 - object-fit: cover;
1727 - filter: brightness(28%);
1728 - }
1729 - }
1730 - }
1731 2096 .swap {
1732 2097 @layer daisyui.l1.l2.l3 {
1733 2098 position: relative;
@@ -1764,6 +2129,20 @@
1764 2129 }
1765 2130 }
1766 2131 }
2132 + .collapse-title {
2133 + @layer daisyui.l1.l2.l3 {
2134 + grid-column-start: 1;
2135 + grid-row-start: 1;
2136 + }
2137 + @layer daisyui.l1.l2.l3 {
2138 + position: relative;
2139 + width: 100%;
2140 + padding: 1rem;
2141 + padding-inline-end: 3rem;
2142 + min-height: 1lh;
2143 + transition: background-color 0.2s ease-out;
2144 + }
2145 + }
1767 2146 .menu-vertical {
1768 2147 @layer daisyui.l1.l2 {
1769 2148 display: inline-flex;
@@ -2098,9 +2477,6 @@
2098 2477 }
2099 2478 }
2100 2479 }
2101 - .inset-0 {
2102 - inset: calc(var(--spacing) * 0);
2103 - }
2104 2480 .start {
2105 2481 inset-inline-start: var(--spacing);
2106 2482 }
@@ -2145,18 +2521,12 @@
2145 2521 }
2146 2522 }
2147 2523 }
2148 - .top-0 {
2149 - top: calc(var(--spacing) * 0);
2150 - }
2151 2524 .top-16 {
2152 2525 top: calc(var(--spacing) * 16);
2153 2526 }
2154 2527 .top-full {
2155 2528 top: 100%;
2156 2529 }
2157 - .right-0 {
2158 - right: calc(var(--spacing) * 0);
2159 - }
2160 2530 .right-4 {
2161 2531 right: calc(var(--spacing) * 4);
2162 2532 }
@@ -2236,6 +2606,17 @@
2236 2606 }
2237 2607 }
2238 2608 }
2609 + .hero-content {
2610 + @layer daisyui.l1.l2.l3 {
2611 + isolation: isolate;
2612 + display: flex;
2613 + max-width: 80rem;
2614 + align-items: center;
2615 + justify-content: center;
2616 + gap: calc(0.25rem * 4);
2617 + padding: calc(0.25rem * 4);
2618 + }
2619 + }
2239 2620 .textarea {
2240 2621 @layer daisyui.l1.l2.l3 {
2241 2622 border: var(--border) solid #0000;
@@ -2402,29 +2783,120 @@
2402 2783 }
2403 2784 }
2404 2785 }
2405 - .modal-backdrop {
2786 + .stack\! {
2406 2787 @layer daisyui.l1.l2.l3 {
2407 - grid-column-start: 1;
2408 - grid-row-start: 1;
2409 - display: grid;
2410 - align-self: stretch;
2411 - justify-self: stretch;
2412 - color: transparent;
2788 + display: inline-grid !important;
2789 + grid-template-columns: 3px 4px 1fr 4px 3px !important;
2790 + grid-template-rows: 3px 4px 1fr 4px 3px !important;
2791 + & > * {
2792 + height: 100% !important;
2793 + width: 100% !important;
2794 + &:nth-child(n + 2) {
2795 + width: 100% !important;
2796 + opacity: 70% !important;
2797 + }
2798 + &:nth-child(2) {
2799 + z-index: 2 !important;
2800 + opacity: 90% !important;
2801 + }
2802 + &:nth-child(1) {
2803 + z-index: 3 !important;
2804 + width: 100% !important;
2805 + }
2806 + }
2807 + }
2808 + @layer daisyui.l1.l2 {
2809 + &, &.stack-bottom {
2810 + > * {
2811 + grid-column: 3 / 4 !important;
2812 + grid-row: 3 / 6 !important;
2813 + &:nth-child(2) {
2814 + grid-column: 2 / 5 !important;
2815 + grid-row: 2 / 5 !important;
2816 + }
2817 + &:nth-child(1) {
2818 + grid-column: 1 / 6 !important;
2819 + grid-row: 1 / 4 !important;
2820 + }
2821 + }
2822 + }
2823 + &.stack-top {
2824 + > * {
2825 + grid-column: 3 / 4 !important;
2826 + grid-row: 1 / 4 !important;
2827 + &:nth-child(2) {
2828 + grid-column: 2 / 5 !important;
2829 + grid-row: 2 / 5 !important;
2830 + }
2831 + &:nth-child(1) {
2832 + grid-column: 1 / 6 !important;
2833 + grid-row: 3 / 6 !important;
2834 + }
2835 + }
2836 + }
2837 + &.stack-start {
2838 + > * {
2839 + grid-column: 1 / 4 !important;
2840 + grid-row: 3 / 4 !important;
2841 + &:nth-child(2) {
2842 + grid-column: 2 / 5 !important;
2843 + grid-row: 2 / 5 !important;
2844 + }
2845 + &:nth-child(1) {
2846 + grid-column: 3 / 6 !important;
2847 + grid-row: 1 / 6 !important;
2848 + }
2849 + }
2850 + }
2851 + &.stack-end {
2852 + > * {
2853 + grid-column: 3 / 6 !important;
2854 + grid-row: 3 / 4 !important;
2855 + &:nth-child(2) {
2856 + grid-column: 2 / 5 !important;
2857 + grid-row: 2 / 5 !important;
2858 + }
2859 + &:nth-child(1) {
2860 + grid-column: 1 / 4 !important;
2861 + grid-row: 1 / 6 !important;
2862 + }
2863 + }
2864 + }
2865 + }
2866 + }
2867 + .modal-backdrop {
2868 + @layer daisyui.l1.l2.l3 {
2869 + grid-column-start: 1;
2870 + grid-row-start: 1;
2871 + display: grid;
2872 + align-self: stretch;
2873 + justify-self: stretch;
2874 + color: transparent;
2413 2875 z-index: -1;
2414 2876 button {
2415 2877 cursor: pointer;
2416 2878 }
2417 2879 }
2418 2880 }
2419 - .z-40 {
2420 - z-index: 40;
2421 - }
2422 2881 .z-50 {
2423 2882 z-index: 50;
2424 2883 }
2425 2884 .z-\[1\] {
2426 2885 z-index: 1;
2427 2886 }
2887 + .hero {
2888 + @layer daisyui.l1.l2.l3 {
2889 + display: grid;
2890 + width: 100%;
2891 + place-items: center;
2892 + background-size: cover;
2893 + background-position: center;
2894 + & > * {
2895 + grid-column-start: 1;
2896 + grid-row-start: 1;
2897 + }
2898 + }
2899 + }
2428 2900 .modal-box {
2429 2901 @layer daisyui.l1.l2.l3 {
2430 2902 grid-column-start: 1;
@@ -2483,8 +2955,35 @@
2483 2955 max-width: 96rem;
2484 2956 }
2485 2957 }
2486 - .m-4 {
2487 - margin: calc(var(--spacing) * 4);
2958 + .divider {
2959 + @layer daisyui.l1.l2.l3 {
2960 + display: flex;
2961 + height: calc(0.25rem * 4);
2962 + flex-direction: row;
2963 + align-items: center;
2964 + align-self: stretch;
2965 + white-space: nowrap;
2966 + margin: var(--divider-m, 1rem 0);
2967 + --divider-color: var(--color-base-content);
2968 + @supports (color: color-mix(in lab, red, red)) {
2969 + --divider-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
2970 + }
2971 + &:before, &:after {
2972 + content: "";
2973 + height: calc(0.25rem * 0.5);
2974 + width: 100%;
2975 + flex-grow: 1;
2976 + background-color: var(--divider-color);
2977 + }
2978 + @media print {
2979 + &:before, &:after {
2980 + border: 0.5px solid;
2981 + }
2982 + }
2983 + &:not(:empty) {
2984 + gap: calc(0.25rem * 4);
2985 + }
2986 + }
2488 2987 }
2489 2988 .filter {
2490 2989 @layer daisyui.l1.l2.l3 {
@@ -2531,36 +3030,9 @@
2531 3030 }
2532 3031 }
2533 3032 }
2534 - .mx-4 {
2535 - margin-inline: calc(var(--spacing) * 4);
2536 - }
2537 3033 .mx-auto {
2538 3034 margin-inline: auto;
2539 3035 }
2540 - .input-lg {
2541 - @layer daisyui.l1.l2 {
2542 - --size: calc(var(--size-field, 0.25rem) * 12);
2543 - font-size: max(var(--font-size, 1.125rem), 1.125rem);
2544 - &[type="number"] {
2545 - &::-webkit-inner-spin-button {
2546 - margin-block: calc(0.25rem * -3);
2547 - margin-inline-end: calc(0.25rem * -3);
2548 - }
2549 - }
2550 - }
2551 - }
2552 - .input-md {
2553 - @layer daisyui.l1.l2 {
2554 - --size: calc(var(--size-field, 0.25rem) * 10);
2555 - font-size: max(var(--font-size, 0.875rem), 0.875rem);
2556 - &[type="number"] {
2557 - &::-webkit-inner-spin-button {
2558 - margin-block: calc(0.25rem * -3);
2559 - margin-inline-end: calc(0.25rem * -3);
2560 - }
2561 - }
2562 - }
2563 - }
2564 3036 .input-sm {
2565 3037 @layer daisyui.l1.l2 {
2566 3038 --size: calc(var(--size-field, 0.25rem) * 8);
@@ -2573,24 +3045,9 @@
2573 3045 }
2574 3046 }
2575 3047 }
2576 - .input-xs {
2577 - @layer daisyui.l1.l2 {
2578 - --size: calc(var(--size-field, 0.25rem) * 6);
2579 - font-size: max(var(--font-size, 0.6875rem), 0.6875rem);
2580 - &[type="number"] {
2581 - &::-webkit-inner-spin-button {
2582 - margin-block: calc(0.25rem * -1);
2583 - margin-inline-end: calc(0.25rem * -3);
2584 - }
2585 - }
2586 - }
2587 - }
2588 3048 .my-2 {
2589 3049 margin-block: calc(var(--spacing) * 2);
2590 3050 }
2591 - .my-4 {
2592 - margin-block: calc(var(--spacing) * 4);
2593 - }
2594 3051 .breadcrumbs {
2595 3052 @layer daisyui.l1.l2.l3 {
2596 3053 max-width: 100%;
@@ -2703,6 +3160,9 @@
2703 3160 gap: calc(0.25rem * 2);
2704 3161 }
2705 3162 }
3163 + .mt-0\.5 {
3164 + margin-top: calc(var(--spacing) * 0.5);
3165 + }
2706 3166 .mt-1 {
2707 3167 margin-top: calc(var(--spacing) * 1);
2708 3168 }
@@ -2721,15 +3181,9 @@
2721 3181 .mt-8 {
2722 3182 margin-top: calc(var(--spacing) * 8);
2723 3183 }
2724 - .mt-12 {
2725 - margin-top: calc(var(--spacing) * 12);
2726 - }
2727 3184 .mr-2 {
2728 3185 margin-right: calc(var(--spacing) * 2);
2729 3186 }
2730 - .mr-4 {
2731 - margin-right: calc(var(--spacing) * 4);
2732 - }
2733 3187 .mb-1 {
2734 3188 margin-bottom: calc(var(--spacing) * 1);
2735 3189 }
@@ -2751,8 +3205,11 @@
2751 3205 .mb-12 {
2752 3206 margin-bottom: calc(var(--spacing) * 12);
2753 3207 }
2754 - .ml-4 {
2755 - margin-left: calc(var(--spacing) * 4);
3208 + .ml-1 {
3209 + margin-left: calc(var(--spacing) * 1);
3210 + }
3211 + .ml-2 {
3212 + margin-left: calc(var(--spacing) * 2);
2756 3213 }
2757 3214 .ml-auto {
2758 3215 margin-left: auto;
@@ -2967,6 +3424,62 @@
2967 3424 font-weight: 600;
2968 3425 }
2969 3426 }
3427 + .prose {
3428 + :root & {
3429 + --tw-prose-body: var(--color-base-content);
3430 + @supports (color: color-mix(in lab, red, red)) {
3431 + --tw-prose-body: color-mix(in oklab, var(--color-base-content) 80%, #0000);
3432 + }
3433 + --tw-prose-headings: var(--color-base-content);
3434 + --tw-prose-lead: var(--color-base-content);
3435 + --tw-prose-links: var(--color-base-content);
3436 + --tw-prose-bold: var(--color-base-content);
3437 + --tw-prose-counters: var(--color-base-content);
3438 + --tw-prose-bullets: var(--color-base-content);
3439 + @supports (color: color-mix(in lab, red, red)) {
3440 + --tw-prose-bullets: color-mix(in oklab, var(--color-base-content) 50%, #0000);
3441 + }
3442 + --tw-prose-hr: var(--color-base-content);
3443 + @supports (color: color-mix(in lab, red, red)) {
3444 + --tw-prose-hr: color-mix(in oklab, var(--color-base-content) 20%, #0000);
3445 + }
3446 + --tw-prose-quotes: var(--color-base-content);
3447 + --tw-prose-quote-borders: var(--color-base-content);
3448 + @supports (color: color-mix(in lab, red, red)) {
3449 + --tw-prose-quote-borders: color-mix(in oklab, var(--color-base-content) 20%, #0000);
3450 + }
3451 + --tw-prose-captions: var(--color-base-content);
3452 + @supports (color: color-mix(in lab, red, red)) {
3453 + --tw-prose-captions: color-mix(in oklab, var(--color-base-content) 50%, #0000);
3454 + }
3455 + --tw-prose-code: var(--color-base-content);
3456 + --tw-prose-pre-code: var(--color-neutral-content);
3457 + --tw-prose-pre-bg: var(--color-neutral);
3458 + --tw-prose-th-borders: var(--color-base-content);
3459 + @supports (color: color-mix(in lab, red, red)) {
3460 + --tw-prose-th-borders: color-mix(in oklab, var(--color-base-content) 50%, #0000);
3461 + }
3462 + --tw-prose-td-borders: var(--color-base-content);
3463 + @supports (color: color-mix(in lab, red, red)) {
3464 + --tw-prose-td-borders: color-mix(in oklab, var(--color-base-content) 20%, #0000);
3465 + }
3466 + --tw-prose-kbd: var(--color-base-content);
3467 + @supports (color: color-mix(in lab, red, red)) {
3468 + --tw-prose-kbd: color-mix(in oklab, var(--color-base-content) 80%, #0000);
3469 + }
3470 + :where(code):not(pre > code) {
3471 + background-color: var(--color-base-200);
3472 + border-radius: var(--radius-selector);
3473 + border: var(--border) solid var(--color-base-300);
3474 + padding-inline: 0.5em;
3475 + padding-block: 0.2em;
3476 + font-weight: inherit;
3477 + &:before, &:after {
3478 + display: none;
3479 + }
3480 + }
3481 + }
3482 + }
2970 3483 .mask {
2971 3484 @layer daisyui.l1.l2.l3 {
2972 3485 display: inline-block;
@@ -2979,6 +3492,9 @@
2979 3492 .block {
2980 3493 display: block;
2981 3494 }
3495 + .contents {
3496 + display: contents;
3497 + }
2982 3498 .flex {
2983 3499 display: flex;
2984 3500 }
@@ -2997,34 +3513,6 @@
2997 3513 .table {
2998 3514 display: table;
2999 3515 }
3000 - .card-side {
3001 - @layer daisyui.l1.l2 {
3002 - align-items: stretch;
3003 - flex-direction: row;
3004 - :where(figure:first-child) {
3005 - overflow: hidden;
3006 - border-start-start-radius: inherit;
3007 - border-start-end-radius: unset;
3008 - border-end-start-radius: inherit;
3009 - border-end-end-radius: unset;
3010 - }
3011 - :where(figure:last-child) {
3012 - overflow: hidden;
3013 - border-start-start-radius: unset;
3014 - border-start-end-radius: inherit;
3015 - border-end-start-radius: unset;
3016 - border-end-end-radius: inherit;
3017 - }
3018 - figure > * {
3019 - max-width: unset;
3020 - }
3021 - :where(figure > *) {
3022 - width: 100%;
3023 - height: 100%;
3024 - object-fit: cover;
3025 - }
3026 - }
3027 - }
3028 3516 .btn-circle {
3029 3517 @layer daisyui.l1.l2 {
3030 3518 border-radius: calc(infinity * 1px);
@@ -3033,12 +3521,8 @@
3033 3521 height: var(--size);
3034 3522 }
3035 3523 }
3036 - .btn-square {
3037 - @layer daisyui.l1.l2 {
3038 - padding-inline: calc(0.25rem * 0);
3039 - width: var(--size);
3040 - height: var(--size);
3041 - }
3524 + .h-3 {
3525 + height: calc(var(--spacing) * 3);
3042 3526 }
3043 3527 .h-4 {
3044 3528 height: calc(var(--spacing) * 4);
@@ -3064,24 +3548,23 @@
3064 3548 .h-20 {
3065 3549 height: calc(var(--spacing) * 20);
3066 3550 }
3067 - .h-24 {
3068 - height: calc(var(--spacing) * 24);
3551 + .h-48 {
3552 + height: calc(var(--spacing) * 48);
3069 3553 }
3070 - .h-full {
3071 - height: 100%;
3554 + .min-h-\[60vh\] {
3555 + min-height: 60vh;
3072 3556 }
3073 3557 .min-h-screen {
3074 3558 min-height: 100vh;
3075 3559 }
3076 - .btn-wide {
3560 + .btn-block {
3077 3561 @layer daisyui.l1.l2 {
3078 3562 width: 100%;
3079 - max-width: calc(0.25rem * 64);
3080 3563 }
3081 3564 }
3082 - .btn-block {
3565 + .loading-lg {
3083 3566 @layer daisyui.l1.l2 {
3084 - width: 100%;
3567 + width: calc(var(--size-selector, 0.25rem) * 7);
3085 3568 }
3086 3569 }
3087 3570 .loading-sm {
@@ -3089,6 +3572,9 @@
3089 3572 width: calc(var(--size-selector, 0.25rem) * 5);
3090 3573 }
3091 3574 }
3575 + .w-3 {
3576 + width: calc(var(--spacing) * 3);
3577 + }
3092 3578 .w-4 {
3093 3579 width: calc(var(--spacing) * 4);
3094 3580 }
@@ -3113,8 +3599,8 @@
3113 3599 .w-20 {
3114 3600 width: calc(var(--spacing) * 20);
3115 3601 }
3116 - .w-24 {
3117 - width: calc(var(--spacing) * 24);
3602 + .w-48 {
3603 + width: calc(var(--spacing) * 48);
3118 3604 }
3119 3605 .w-52 {
3120 3606 width: calc(var(--spacing) * 52);
@@ -3128,6 +3614,9 @@
3128 3614 .max-w-2xl {
3129 3615 max-width: var(--container-2xl);
3130 3616 }
3617 + .max-w-3xl {
3618 + max-width: var(--container-3xl);
3619 + }
3131 3620 .max-w-4xl {
3132 3621 max-width: var(--container-4xl);
3133 3622 }
@@ -3140,6 +3629,15 @@
3140 3629 .max-w-md {
3141 3630 max-width: var(--container-md);
3142 3631 }
3632 + .max-w-sm {
3633 + max-width: var(--container-sm);
3634 + }
3635 + .max-w-xl {
3636 + max-width: var(--container-xl);
3637 + }
3638 + .max-w-xs {
3639 + max-width: var(--container-xs);
3640 + }
3143 3641 .min-w-0 {
3144 3642 min-width: calc(var(--spacing) * 0);
3145 3643 }
@@ -3149,14 +3647,6 @@
3149 3647 .shrink-0 {
3150 3648 flex-shrink: 0;
3151 3649 }
3152 - .translate-x-0 {
3153 - --tw-translate-x: calc(var(--spacing) * 0);
3154 - translate: var(--tw-translate-x) var(--tw-translate-y);
3155 - }
3156 - .translate-x-full {
3157 - --tw-translate-x: 100%;
3158 - translate: var(--tw-translate-x) var(--tw-translate-y);
3159 - }
3160 3650 .scale-95 {
3161 3651 --tw-scale-x: 95%;
3162 3652 --tw-scale-y: 95%;
@@ -3175,6 +3665,22 @@
3175 3665 .transform {
3176 3666 transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
3177 3667 }
3668 + .skeleton {
3669 + @layer daisyui.l1.l2.l3 {
3670 + border-radius: var(--radius-box);
3671 + background-color: var(--color-base-300);
3672 + @media (prefers-reduced-motion: reduce) {
3673 + transition-duration: 15s;
3674 + }
3675 + will-change: background-position;
3676 + background-image: linear-gradient( 105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100% );
3677 + background-size: 200% auto;
3678 + background-position-x: -50%;
3679 + @media (prefers-reduced-motion: no-preference) {
3680 + animation: skeleton 1.8s ease-in-out infinite;
3681 + }
3682 + }
3683 + }
3178 3684 .link {
3179 3685 @layer daisyui.l1.l2.l3 {
3180 3686 cursor: pointer;
@@ -3193,37 +3699,27 @@
3193 3699 }
3194 3700 }
3195 3701 }
3196 - .cursor-default {
3197 - cursor: default;
3198 - }
3199 3702 .cursor-pointer {
3200 3703 cursor: pointer;
3201 3704 }
3705 + .resize {
3706 + resize: both;
3707 + }
3202 3708 .list-inside {
3203 3709 list-style-position: inside;
3204 3710 }
3205 3711 .list-disc {
3206 3712 list-style-type: disc;
3207 3713 }
3208 - .stats-vertical {
3209 - @layer daisyui.l1.l2 {
3210 - grid-auto-flow: row;
3211 - overflow-y: auto;
3212 - .stat:not(:last-child) {
3213 - border-inline-end: none;
3214 - border-block-end: var(--border) dashed currentColor;
3215 - @supports (color: color-mix(in lab, red, red)) {
3216 - border-block-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
3217 - }
3218 - }
3219 - }
3220 - }
3221 3714 .grid-cols-1 {
3222 3715 grid-template-columns: repeat(1, minmax(0, 1fr));
3223 3716 }
3224 3717 .grid-cols-2 {
3225 3718 grid-template-columns: repeat(2, minmax(0, 1fr));
3226 3719 }
3720 + .grid-cols-3 {
3721 + grid-template-columns: repeat(3, minmax(0, 1fr));
3722 + }
3227 3723 .flex-col {
3228 3724 flex-direction: column;
3229 3725 }
@@ -3263,11 +3759,14 @@
3263 3759 .gap-4 {
3264 3760 gap: calc(var(--spacing) * 4);
3265 3761 }
3762 + .gap-5 {
3763 + gap: calc(var(--spacing) * 5);
3764 + }
3266 3765 .gap-6 {
3267 3766 gap: calc(var(--spacing) * 6);
3268 3767 }
3269 - .gap-8 {
3270 - gap: calc(var(--spacing) * 8);
3768 + .gap-7 {
3769 + gap: calc(var(--spacing) * 7);
3271 3770 }
3272 3771 .space-y-2 {
3273 3772 :where(& > :not(:last-child)) {
@@ -3304,20 +3803,6 @@
3304 3803 margin-inline-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse)));
3305 3804 }
3306 3805 }
3307 - .space-x-2 {
3308 - :where(& > :not(:last-child)) {
3309 - --tw-space-x-reverse: 0;
3310 - margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));
3311 - margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)));
3312 - }
3313 - }
3314 - .space-x-3 {
3315 - :where(& > :not(:last-child)) {
3316 - --tw-space-x-reverse: 0;
3317 - margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));
3318 - margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
3319 - }
3320 - }
3321 3806 .truncate {
3322 3807 overflow: hidden;
3323 3808 text-overflow: ellipsis;
@@ -3332,6 +3817,9 @@
3332 3817 .overflow-y-auto {
3333 3818 overflow-y: auto;
3334 3819 }
3820 + .rounded {
3821 + border-radius: 0.25rem;
3822 + }
3335 3823 .rounded-box {
3336 3824 border-radius: var(--radius-box);
3337 3825 }
@@ -3356,35 +3844,13 @@
3356 3844 border-top-style: var(--tw-border-style);
3357 3845 border-top-width: 1px;
3358 3846 }
3359 - .border-r {
3360 - border-right-style: var(--tw-border-style);
3361 - border-right-width: 1px;
3362 - }
3363 3847 .border-b {
3364 3848 border-bottom-style: var(--tw-border-style);
3365 3849 border-bottom-width: 1px;
3366 3850 }
3367 - .border-l {
3851 + .border-l-4 {
3368 3852 border-left-style: var(--tw-border-style);
3369 - border-left-width: 1px;
3370 - }
3371 - .badge-dash {
3372 - @layer daisyui.l1.l2 {
3373 - color: var(--badge-color);
3374 - --badge-bg: #0000;
3375 - background-image: none;
3376 - border-color: currentColor;
3377 - border-style: dashed;
3378 - }
3379 - }
3380 - .alert-dash {
3381 - @layer daisyui.l1 {
3382 - background-color: transparent;
3383 - color: var(--alert-color);
3384 - border-style: dashed;
3385 - box-shadow: none;
3386 - background-image: none;
3387 - }
3853 + border-left-width: 4px;
3388 3854 }
3389 3855 .badge-ghost {
3390 3856 @layer daisyui.l1.l2 {
@@ -3394,33 +3860,6 @@
3394 3860 background-image: none;
3395 3861 }
3396 3862 }
3397 - .badge-soft {
3398 - @layer daisyui.l1.l2 {
3399 - color: var(--badge-color, var(--color-base-content));
3400 - background-color: var(--badge-color, var(--color-base-content));
3401 - @supports (color: color-mix(in lab, red, red)) {
3402 - background-color: color-mix( in oklab, var(--badge-color, var(--color-base-content)) 8%, var(--color-base-100) );
3403 - }
3404 - border-color: var(--badge-color, var(--color-base-content));
3405 - @supports (color: color-mix(in lab, red, red)) {
3406 - border-color: color-mix( in oklab, var(--badge-color, var(--color-base-content)) 10%, var(--color-base-100) );
3407 - }
3408 - background-image: none;
3409 - }
3410 - }
3411 - .input-ghost {
3412 - @layer daisyui.l1.l2 {
3413 - background-color: transparent;
3414 - box-shadow: none;
3415 - border-color: #0000;
3416 - &:focus, &:focus-within {
3417 - background-color: var(--color-base-100);
3418 - color: var(--color-base-content);
3419 - border-color: #0000;
3420 - box-shadow: none;
3421 - }
3422 - }
3423 - }
3424 3863 .badge-outline {
3425 3864 @layer daisyui.l1.l2 {
3426 3865 color: var(--badge-color);
@@ -3432,23 +3871,12 @@
3432 3871 .border-base-300 {
3433 3872 border-color: var(--color-base-300);
3434 3873 }
3435 - .border-info\/20 {
3436 - border-color: var(--color-info);
3437 - @supports (color: color-mix(in lab, red, red)) {
3438 - border-color: color-mix(in oklab, var(--color-info) 20%, transparent);
3439 - }
3874 + .border-error {
3875 + border-color: var(--color-error);
3440 3876 }
3441 3877 .border-primary {
3442 3878 border-color: var(--color-primary);
3443 3879 }
3444 - .alert-outline {
3445 - @layer daisyui.l1 {
3446 - background-color: transparent;
3447 - color: var(--alert-color);
3448 - box-shadow: none;
3449 - background-image: none;
3450 - }
3451 - }
3452 3880 .table-zebra {
3453 3881 @layer daisyui.l1.l2 {
3454 3882 tbody {
@@ -3478,9 +3906,6 @@
3478 3906 .bg-base-200 {
3479 3907 background-color: var(--color-base-200);
3480 3908 }
3481 - .bg-black {
3482 - background-color: var(--color-black);
3483 - }
3484 3909 .bg-error\/20 {
3485 3910 background-color: var(--color-error);
3486 3911 @supports (color: color-mix(in lab, red, red)) {
@@ -3490,24 +3915,9 @@
3490 3915 .bg-info {
3491 3916 background-color: var(--color-info);
3492 3917 }
3493 - .bg-info\/10 {
3494 - background-color: var(--color-info);
3495 - @supports (color: color-mix(in lab, red, red)) {
3496 - background-color: color-mix(in oklab, var(--color-info) 10%, transparent);
3497 - }
3498 - }
3499 3918 .bg-primary {
3500 3919 background-color: var(--color-primary);
3501 3920 }
3502 - .bg-primary\/20 {
3503 - background-color: var(--color-primary);
3504 - @supports (color: color-mix(in lab, red, red)) {
3505 - background-color: color-mix(in oklab, var(--color-primary) 20%, transparent);
3506 - }
3507 - }
3508 - .bg-red-500\! {
3509 - background-color: var(--color-red-500) !important;
3510 - }
3511 3921 .bg-success\/20 {
3512 3922 background-color: var(--color-success);
3513 3923 @supports (color: color-mix(in lab, red, red)) {
@@ -3520,29 +3930,13 @@
3520 3930 background-color: color-mix(in oklab, var(--color-warning) 20%, transparent);
3521 3931 }
3522 3932 }
3933 + .bg-white {
3934 + background-color: var(--color-white);
3935 + }
3523 3936 .bg-gradient-to-br {
3524 3937 --tw-gradient-position: to bottom right in oklab;
3525 3938 background-image: linear-gradient(var(--tw-gradient-stops));
3526 3939 }
3527 - .alert-soft {
3528 - @layer daisyui.l1 {
3529 - color: var(--alert-color, var(--color-base-content));
3530 - background: var(--alert-color, var(--color-base-content));
3531 - @supports (color: color-mix(in lab, red, red)) {
3532 - background: color-mix( in oklab, var(--alert-color, var(--color-base-content)) 8%, var(--color-base-100) );
3533 - }
3534 - --alert-border-color: var(--alert-color, var(--color-base-content));
3535 - @supports (color: color-mix(in lab, red, red)) {
3536 - --alert-border-color: color-mix(
3537 - in oklab,
3538 - var(--alert-color, var(--color-base-content)) 10%,
3539 - var(--color-base-100)
3540 - );
3541 - }
3542 - box-shadow: none;
3543 - background-image: none;
3544 - }
3545 - }
3546 3940 .from-base-300 {
3547 3941 --tw-gradient-from: var(--color-base-300);
3548 3942 --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
@@ -3570,6 +3964,9 @@
3570 3964 --size: calc(var(--size-selector, 0.25rem) * 5);
3571 3965 }
3572 3966 }
3967 + .p-0 {
3968 + padding: calc(var(--spacing) * 0);
3969 + }
3573 3970 .p-2 {
3574 3971 padding: calc(var(--spacing) * 2);
3575 3972 }
@@ -3594,6 +3991,17 @@
3594 3991 font-weight: 600;
3595 3992 }
3596 3993 }
3994 + .table-sm {
3995 + @layer daisyui.l1.l2 {
3996 + :not(thead, tfoot) tr {
3997 + font-size: 0.75rem;
3998 + }
3999 + :where(th, td) {
4000 + padding-inline: calc(0.25rem * 3);
4001 + padding-block: calc(0.25rem * 2);
4002 + }
4003 + }
4004 + }
3597 4005 .px-2 {
3598 4006 padding-inline: calc(var(--spacing) * 2);
3599 4007 }
@@ -3615,27 +4023,30 @@
3615 4023 .py-8 {
3616 4024 padding-block: calc(var(--spacing) * 8);
3617 4025 }
4026 + .py-10 {
4027 + padding-block: calc(var(--spacing) * 10);
4028 + }
4029 + .py-12 {
4030 + padding-block: calc(var(--spacing) * 12);
4031 + }
4032 + .py-16 {
4033 + padding-block: calc(var(--spacing) * 16);
4034 + }
3618 4035 .pt-2 {
3619 4036 padding-top: calc(var(--spacing) * 2);
3620 4037 }
3621 4038 .pt-4 {
3622 4039 padding-top: calc(var(--spacing) * 4);
3623 4040 }
3624 - .pr-4 {
3625 - padding-right: calc(var(--spacing) * 4);
3626 - }
3627 - .pb-4 {
3628 - padding-bottom: calc(var(--spacing) * 4);
3629 - }
3630 - .pl-4 {
3631 - padding-left: calc(var(--spacing) * 4);
3632 - }
3633 4041 .text-center {
3634 4042 text-align: center;
3635 4043 }
3636 4044 .text-left {
3637 4045 text-align: left;
3638 4046 }
4047 + .text-right {
4048 + text-align: right;
4049 + }
3639 4050 .font-mono {
3640 4051 font-family: var(--font-mono);
3641 4052 }
@@ -3651,9 +4062,9 @@
3651 4062 font-size: var(--text-4xl);
3652 4063 line-height: var(--tw-leading, var(--text-4xl--line-height));
3653 4064 }
3654 - .text-6xl {
3655 - font-size: var(--text-6xl);
3656 - line-height: var(--tw-leading, var(--text-6xl--line-height));
4065 + .text-5xl {
4066 + font-size: var(--text-5xl);
4067 + line-height: var(--tw-leading, var(--text-5xl--line-height));
3657 4068 }
3658 4069 .text-base {
3659 4070 font-size: var(--text-base);
@@ -3681,30 +4092,12 @@
3681 4092 font-size: 1rem;
3682 4093 }
3683 4094 }
3684 - .badge-md {
3685 - @layer daisyui.l1.l2 {
3686 - --size: calc(var(--size-selector, 0.25rem) * 6);
3687 - font-size: 0.875rem;
3688 - }
3689 - }
3690 4095 .badge-sm {
3691 4096 @layer daisyui.l1.l2 {
3692 4097 --size: calc(var(--size-selector, 0.25rem) * 5);
3693 4098 font-size: 0.75rem;
3694 4099 }
3695 4100 }
3696 - .badge-xl {
3697 - @layer daisyui.l1.l2 {
3698 - --size: calc(var(--size-selector, 0.25rem) * 8);
3699 - font-size: 1.125rem;
3700 - }
3701 - }
3702 - .badge-xs {
3703 - @layer daisyui.l1.l2 {
3704 - --size: calc(var(--size-selector, 0.25rem) * 4);
3705 - font-size: 0.625rem;
3706 - }
3707 - }
3708 4101 .font-bold {
3709 4102 --tw-font-weight: var(--font-weight-bold);
3710 4103 font-weight: var(--font-weight-bold);
@@ -3713,17 +4106,20 @@
3713 4106 --tw-font-weight: var(--font-weight-medium);
3714 4107 font-weight: var(--font-weight-medium);
3715 4108 }
3716 - .font-normal {
3717 - --tw-font-weight: var(--font-weight-normal);
3718 - font-weight: var(--font-weight-normal);
3719 - }
3720 4109 .font-semibold {
3721 4110 --tw-font-weight: var(--font-weight-semibold);
3722 4111 font-weight: var(--font-weight-semibold);
3723 4112 }
3724 - .tracking-wider {
3725 - --tw-tracking: var(--tracking-wider);
3726 - letter-spacing: var(--tracking-wider);
4113 + .tracking-wide {
4114 + --tw-tracking: var(--tracking-wide);
4115 + letter-spacing: var(--tracking-wide);
4116 + }
4117 + .tracking-widest {
4118 + --tw-tracking: var(--tracking-widest);
4119 + letter-spacing: var(--tracking-widest);
4120 + }
4121 + .break-all {
4122 + word-break: break-all;
3727 4123 }
3728 4124 .whitespace-nowrap {
3729 4125 white-space: nowrap;
@@ -3801,12 +4197,6 @@
3801 4197 color: color-mix(in oklab, var(--color-base-content) 30%, transparent);
3802 4198 }
3803 4199 }
3804 - .text-base-content\/40 {
3805 - color: var(--color-base-content);
3806 - @supports (color: color-mix(in lab, red, red)) {
3807 - color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
3808 - }
3809 - }
3810 4200 .text-base-content\/50 {
3811 4201 color: var(--color-base-content);
3812 4202 @supports (color: color-mix(in lab, red, red)) {
@@ -3834,9 +4224,6 @@
3834 4224 .text-error {
3835 4225 color: var(--color-error);
3836 4226 }
3837 - .text-gray-800 {
3838 - color: var(--color-gray-800);
3839 - }
3840 4227 .text-info {
3841 4228 color: var(--color-info);
3842 4229 }
@@ -3858,6 +4245,9 @@
3858 4245 .text-warning {
3859 4246 color: var(--color-warning);
3860 4247 }
4248 + .capitalize {
4249 + text-transform: capitalize;
4250 + }
3861 4251 .lowercase {
3862 4252 text-transform: lowercase;
3863 4253 }
@@ -3867,26 +4257,30 @@
3867 4257 .uppercase {
3868 4258 text-transform: uppercase;
3869 4259 }
3870 - .btn-link {
3871 - @layer daisyui.l1 {
3872 - text-decoration-line: underline;
3873 - outline-color: currentcolor;
3874 - --btn-border: #0000;
3875 - --btn-bg: #0000;
3876 - --btn-noise: none;
3877 - --btn-shadow: "";
3878 - &:not(.btn-disabled, .btn:disabled, .btn[disabled]) {
3879 - --btn-fg: var(--btn-color, var(--color-primary));
3880 - }
3881 - &:is(.btn-active, :hover, :active:focus, :focus-visible) {
3882 - --btn-border: #0000;
3883 - --btn-bg: #0000;
4260 + .link-hover {
4261 + @layer daisyui.l1.l2 {
4262 + text-decoration-line: none;
4263 + &:hover {
4264 + @media (hover: hover) {
4265 + text-decoration-line: underline;
4266 + }
3884 4267 }
3885 4268 }
3886 4269 }
4270 + .prose {
4271 + & :where(a.btn:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
4272 + text-decoration-line: none;
4273 + }
4274 + }
3887 4275 .opacity-0 {
3888 4276 opacity: 0%;
3889 4277 }
4278 + .opacity-40 {
4279 + opacity: 40%;
4280 + }
4281 + .opacity-50 {
4282 + opacity: 50%;
4283 + }
3890 4284 .opacity-100 {
3891 4285 opacity: 100%;
3892 4286 }
@@ -3898,6 +4292,10 @@
3898 4292 --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
3899 4293 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
3900 4294 }
4295 + .shadow-inner {
4296 + --tw-shadow: inset 0 2px 4px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
4297 + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
4298 + }
3901 4299 .shadow-lg {
3902 4300 --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
3903 4301 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -3914,10 +4312,6 @@
3914 4312 --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
3915 4313 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
3916 4314 }
3917 - .outline {
3918 - outline-style: var(--tw-outline-style);
3919 - outline-width: 1px;
3920 - }
3921 4315 .btn-ghost {
3922 4316 @layer daisyui.l1 {
3923 4317 &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
@@ -3981,10 +4375,6 @@
3981 4375 --tw-duration: 200ms;
3982 4376 transition-duration: 200ms;
3983 4377 }
3984 - .duration-300 {
3985 - --tw-duration: 300ms;
3986 - transition-duration: 300ms;
3987 - }
3988 4378 .ease-in {
3989 4379 --tw-ease: var(--ease-in);
3990 4380 transition-timing-function: var(--ease-in);
@@ -4013,20 +4403,6 @@
4013 4403 }
4014 4404 }
4015 4405 }
4016 - .btn-lg {
4017 - @layer daisyui.l1.l2 {
4018 - --fontsize: 1.125rem;
4019 - --btn-p: 1.25rem;
4020 - --size: calc(var(--size-field, 0.25rem) * 12);
4021 - }
4022 - }
4023 - .btn-md {
4024 - @layer daisyui.l1.l2 {
4025 - --fontsize: 0.875rem;
4026 - --btn-p: 1rem;
4027 - --size: calc(var(--size-field, 0.25rem) * 10);
4028 - }
4029 - }
4030 4406 .btn-sm {
4031 4407 @layer daisyui.l1.l2 {
4032 4408 --fontsize: 0.75rem;
@@ -4034,81 +4410,6 @@
4034 4410 --size: calc(var(--size-field, 0.25rem) * 8);
4035 4411 }
4036 4412 }
4037 - .btn-xl {
4038 - @layer daisyui.l1.l2 {
4039 - --fontsize: 1.375rem;
4040 - --btn-p: 1.5rem;
4041 - --size: calc(var(--size-field, 0.25rem) * 14);
4042 - }
4043 - }
4044 - .btn-xs {
4045 - @layer daisyui.l1.l2 {
4046 - --fontsize: 0.6875rem;
4047 - --btn-p: 0.5rem;
4048 - --size: calc(var(--size-field, 0.25rem) * 6);
4049 - }
4050 - }
4051 - .card-lg {
4052 - @layer daisyui.l1.l2 {
4053 - .card-body {
4054 - --card-p: 2rem;
4055 - --card-fs: 1rem;
4056 - }
4057 - .card-title {
4058 - --cardtitle-fs: 1.25rem;
4059 - }
4060 - }
4061 - }
4062 - .card-md {
4063 - @layer daisyui.l1.l2 {
4064 - .card-body {
4065 - --card-p: 1.5rem;
4066 - --card-fs: 0.875rem;
4067 - }
4068 - .card-title {
4069 - --cardtitle-fs: 1.125rem;
4070 - }
4071 - }
4072 - }
4073 - .card-sm {
4074 - @layer daisyui.l1.l2 {
4075 - .card-body {
4076 - --card-p: 1rem;
4077 - --card-fs: 0.75rem;
4078 - }
4079 - .card-title {
4080 - --cardtitle-fs: 1rem;
4081 - }
4082 - }
4083 - }
4084 - .card-xl {
4085 - @layer daisyui.l1.l2 {
4086 - .card-body {
4087 - --card-p: 2.5rem;
4088 - --card-fs: 1.125rem;
4089 - }
4090 - .card-title {
4091 - --cardtitle-fs: 1.375rem;
4092 - }
4093 - }
4094 - }
4095 - .card-xs {
4096 - @layer daisyui.l1.l2 {
4097 - .card-body {
4098 - --card-p: 0.5rem;
4099 - --card-fs: 0.6875rem;
4100 - }
4101 - .card-title {
4102 - --cardtitle-fs: 0.875rem;
4103 - }
4104 - }
4105 - }
4106 - .badge-accent {
4107 - @layer daisyui.l1.l2 {
4108 - --badge-color: var(--color-accent);
4109 - --badge-fg: var(--color-accent-content);
4110 - }
4111 - }
4112 4413 .badge-error {
4113 4414 @layer daisyui.l1.l2 {
4114 4415 --badge-color: var(--color-error);
@@ -4127,12 +4428,6 @@
4127 4428 --badge-fg: var(--color-primary-content);
4128 4429 }
4129 4430 }
4130 - .badge-secondary {
4131 - @layer daisyui.l1.l2 {
4132 - --badge-color: var(--color-secondary);
4133 - --badge-fg: var(--color-secondary-content);
4134 - }
4135 - }
4136 4431 .badge-success {
4137 4432 @layer daisyui.l1.l2 {
4138 4433 --badge-color: var(--color-success);
@@ -4175,61 +4470,42 @@
4175 4470 --btn-fg: var(--color-secondary-content);
4176 4471 }
4177 4472 }
4178 - .btn-success {
4179 - @layer daisyui.l1.l2.l3 {
4180 - --btn-color: var(--color-success);
4181 - --btn-fg: var(--color-success-content);
4182 - }
4183 - }
4184 4473 .btn-warning {
4185 4474 @layer daisyui.l1.l2.l3 {
4186 4475 --btn-color: var(--color-warning);
4187 4476 --btn-fg: var(--color-warning-content);
4188 4477 }
4189 4478 }
4190 - .card-border {
4191 - @layer daisyui.l1.l2 {
4192 - border: var(--border) solid var(--color-base-200);
4193 - }
4194 - }
4195 - .card-dash {
4196 - @layer daisyui.l1.l2 {
4197 - border: var(--border) dashed var(--color-base-200);
4198 - }
4479 + .select-all {
4480 + -webkit-user-select: all;
4481 + user-select: all;
4199 4482 }
4200 - .input-error {
4483 + .radio-primary {
4201 4484 @layer daisyui.l1.l2 {
4202 - &, &:focus, &:focus-within {
4203 - --input-color: var(--color-error);
4204 - }
4485 + --input-color: var(--color-primary);
4205 4486 }
4206 4487 }
4207 - .input-info {
4488 + .toggle-primary {
4208 4489 @layer daisyui.l1.l2 {
4209 - &, &:focus, &:focus-within {
4210 - --input-color: var(--color-info);
4490 + &:checked, &[aria-checked="true"] {
4491 + --input-color: var(--color-primary);
4211 4492 }
4212 4493 }
4213 4494 }
4214 - .input-success {
4495 + .toggle-secondary {
4215 4496 @layer daisyui.l1.l2 {
4216 - &, &:focus, &:focus-within {
4217 - --input-color: var(--color-success);
4497 + &:checked, &[aria-checked="true"] {
4498 + --input-color: var(--color-secondary);
4218 4499 }
4219 4500 }
4220 4501 }
4221 - .input-warning {
4502 + .toggle-sm {
4222 4503 @layer daisyui.l1.l2 {
4223 - &, &:focus, &:focus-within {
4224 - --input-color: var(--color-warning);
4504 + &:is([type="checkbox"]), &:has([type="checkbox"]) {
4505 + --size: calc(var(--size-selector, 0.25rem) * 5);
4225 4506 }
4226 4507 }
4227 4508 }
4228 - .radio-primary {
4229 - @layer daisyui.l1.l2 {
4230 - --input-color: var(--color-primary);
4231 - }
4232 - }
4233 4509 .hover\:bg-base-200 {
4234 4510 &:hover {
4235 4511 @media (hover: hover) {
@@ -4261,13 +4537,6 @@
4261 4537 }
4262 4538 }
4263 4539 }
4264 - .hover\:opacity-80 {
4265 - &:hover {
4266 - @media (hover: hover) {
4267 - opacity: 80%;
4268 - }
4269 - }
4270 - }
4271 4540 .hover\:shadow-2xl {
4272 4541 &:hover {
4273 4542 @media (hover: hover) {
@@ -4292,6 +4561,23 @@
4292 4561 }
4293 4562 }
4294 4563 }
4564 + .focus\:ring-2 {
4565 + &:focus {
4566 + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
4567 + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
4568 + }
4569 + }
4570 + .focus\:ring-primary {
4571 + &:focus {
4572 + --tw-ring-color: var(--color-primary);
4573 + }
4574 + }
4575 + .focus\:outline-none {
4576 + &:focus {
4577 + --tw-outline-style: none;
4578 + outline-style: none;
4579 + }
4580 + }
4295 4581 .md\:col-span-2 {
4296 4582 @media (width >= 48rem) {
4297 4583 grid-column: span 2 / span 2;
@@ -4317,14 +4603,14 @@
4317 4603 grid-template-columns: repeat(2, minmax(0, 1fr));
4318 4604 }
4319 4605 }
4320 - .md\:grid-cols-4 {
4606 + .md\:grid-cols-3 {
4321 4607 @media (width >= 48rem) {
4322 - grid-template-columns: repeat(4, minmax(0, 1fr));
4608 + grid-template-columns: repeat(3, minmax(0, 1fr));
4323 4609 }
4324 4610 }
4325 - .md\:flex-row {
4611 + .md\:grid-cols-4 {
4326 4612 @media (width >= 48rem) {
4327 - flex-direction: row;
4613 + grid-template-columns: repeat(4, minmax(0, 1fr));
4328 4614 }
4329 4615 }
4330 4616 .lg\:block {
@@ -4332,26 +4618,6 @@
4332 4618 display: block;
4333 4619 }
4334 4620 }
4335 - .lg\:hidden {
4336 - @media (width >= 64rem) {
4337 - display: none;
4338 - }
4339 - }
4340 - .lg\:stats-horizontal {
4341 - @media (width >= 64rem) {
4342 - @layer daisyui.l1.l2 {
4343 - grid-auto-flow: column;
4344 - overflow-x: auto;
4345 - .stat:not(:last-child) {
4346 - border-inline-end: var(--border) dashed currentColor;
4347 - @supports (color: color-mix(in lab, red, red)) {
4348 - border-inline-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
4349 - }
4350 - border-block-end: none;
4351 - }
4352 - }
4353 - }
4354 - }
4355 4621 .lg\:grid-cols-2 {
4356 4622 @media (width >= 64rem) {
4357 4623 grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -4369,6 +4635,39 @@
4369 4635 }
4370 4636 }
4371 4637 @layer base {
4638 + :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
4639 + color-scheme: light;
4640 + --color-base-100: oklch(100% 0 0);
4641 + --color-base-200: oklch(98% 0 0);
4642 + --color-base-300: oklch(95% 0 0);
4643 + --color-base-content: oklch(21% 0.006 285.885);
4644 + --color-primary: oklch(45% 0.24 277.023);
4645 + --color-primary-content: oklch(93% 0.034 272.788);
4646 + --color-secondary: oklch(65% 0.241 354.308);
4647 + --color-secondary-content: oklch(94% 0.028 342.258);
4648 + --color-accent: oklch(77% 0.152 181.912);
4649 + --color-accent-content: oklch(38% 0.063 188.416);
4650 + --color-neutral: oklch(14% 0.005 285.823);
4651 + --color-neutral-content: oklch(92% 0.004 286.32);
4652 + --color-info: oklch(74% 0.16 232.661);
4653 + --color-info-content: oklch(29% 0.066 243.157);
4654 + --color-success: oklch(76% 0.177 163.223);
4655 + --color-success-content: oklch(37% 0.077 168.94);
4656 + --color-warning: oklch(82% 0.189 84.429);
4657 + --color-warning-content: oklch(41% 0.112 45.904);
4658 + --color-error: oklch(71% 0.194 13.428);
4659 + --color-error-content: oklch(27% 0.105 12.094);
4660 + --radius-selector: 0.5rem;
4661 + --radius-field: 0.25rem;
4662 + --radius-box: 0.5rem;
4663 + --size-selector: 0.25rem;
4664 + --size-field: 0.25rem;
4665 + --border: 1px;
4666 + --depth: 1;
4667 + --noise: 0;
4668 + }
4669 +}
4670 +@layer base {
4372 4671 :root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
4373 4672 color-scheme: light;
4374 4673 --color-base-100: oklch(100% 0 0);
@@ -4547,28 +4846,28 @@
4547 4846 }
4548 4847 }
4549 4848 @layer base {
4550 - :where(:root),:root:has(input.theme-controller[value=rustwarden]:checked),[data-theme="rustwarden"] {
4849 + :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme="light"] {
4551 4850 color-scheme: light;
4552 - --color-base-100: oklch(100% 0 0);
4553 - --color-base-200: oklch(96% 0.01 240);
4554 - --color-base-300: oklch(92% 0.02 240);
4555 - --color-base-content: oklch(20% 0.02 240);
4556 - --color-primary: oklch(55% 0.15 240);
4557 - --color-primary-content: oklch(98% 0.01 240);
4558 - --color-secondary: oklch(25% 0.02 240);
4559 - --color-secondary-content: oklch(98% 0.01 240);
4560 - --color-accent: oklch(70% 0.18 150);
4561 - --color-accent-content: oklch(98% 0.01 150);
4562 - --color-neutral: oklch(96% 0.01 240);
4563 - --color-neutral-content: oklch(20% 0.02 240);
4564 - --color-info: oklch(55% 0.15 240);
4565 - --color-info-content: oklch(98% 0.01 240);
4566 - --color-success: oklch(70% 0.18 150);
4567 - --color-success-content: oklch(98% 0.01 150);
4568 - --color-warning: oklch(80% 0.15 85);
4569 - --color-warning-content: oklch(20% 0.05 85);
4570 - --color-error: oklch(65% 0.20 25);
4571 - --color-error-content: oklch(98% 0.01 25);
4851 + --color-base-100: #ffffff;
4852 + --color-base-200: #f9fbfa;
4853 + --color-base-300: #e1e5e8;
4854 + --color-base-content: #001e2b;
4855 + --color-primary: #00ed64;
4856 + --color-primary-content: #001e2b;
4857 + --color-secondary: #001e2b;
4858 + --color-secondary-content: #ffffff;
4859 + --color-accent: #00684a;
4860 + --color-accent-content: #ffffff;
4861 + --color-neutral: #1c2d38;
4862 + --color-neutral-content: #ffffff;
4863 + --color-info: #3d4f9f;
4864 + --color-info-content: #ffffff;
4865 + --color-success: #00a35c;
4866 + --color-success-content: #001e2b;
4867 + --color-warning: #fa6e39;
4868 + --color-warning-content: #001e2b;
4869 + --color-error: #e74856;
4870 + --color-error-content: #ffffff;
4572 4871 --radius-selector: 0.5rem;
4573 4872 --radius-field: 0.25rem;
4574 4873 --radius-box: 0.5rem;
@@ -4579,20 +4878,73 @@
4579 4878 --noise: 0;
4580 4879 }
4581 4880 }
4582 -@property --tw-translate-x {
4583 - syntax: "*";
4584 - inherits: false;
4585 - initial-value: 0;
4586 -}
4587 -@property --tw-translate-y {
4588 - syntax: "*";
4589 - inherits: false;
4590 - initial-value: 0;
4881 +@layer base {
4882 + @media (prefers-color-scheme: dark) {
4883 + :root:not([data-theme]) {
4884 + color-scheme: dark;
4885 + --color-base-100: #001e2b;
4886 + --color-base-200: #1c2d38;
4887 + --color-base-300: #003d4f;
4888 + --color-base-content: #ffffff;
4889 + --color-primary: #00ed64;
4890 + --color-primary-content: #001e2b;
4891 + --color-secondary: #003d4f;
4892 + --color-secondary-content: #ffffff;
4893 + --color-accent: #00ed64;
4894 + --color-accent-content: #001e2b;
4895 + --color-neutral: #0d1c26;
4896 + --color-neutral-content: #ffffff;
4897 + --color-info: #8ea0e8;
4898 + --color-info-content: #001e2b;
4899 + --color-success: #00ed64;
4900 + --color-success-content: #001e2b;
4901 + --color-warning: #fa6e39;
4902 + --color-warning-content: #001e2b;
4903 + --color-error: #ff8b96;
4904 + --color-error-content: #001e2b;
4905 + --radius-selector: 0.5rem;
4906 + --radius-field: 0.25rem;
4907 + --radius-box: 0.5rem;
4908 + --size-selector: 0.25rem;
4909 + --size-field: 0.25rem;
4910 + --border: 1px;
4911 + --depth: 1;
4912 + --noise: 0;
4913 + }
4914 + }
4591 4915 }
4592 -@property --tw-translate-z {
4593 - syntax: "*";
4594 - inherits: false;
4595 - initial-value: 0;
4916 +@layer base {
4917 + :root:has(input.theme-controller[value=dark]:checked),[data-theme="dark"] {
4918 + color-scheme: dark;
4919 + --color-base-100: #001e2b;
4920 + --color-base-200: #1c2d38;
4921 + --color-base-300: #003d4f;
4922 + --color-base-content: #ffffff;
4923 + --color-primary: #00ed64;
4924 + --color-primary-content: #001e2b;
4925 + --color-secondary: #003d4f;
4926 + --color-secondary-content: #ffffff;
4927 + --color-accent: #00ed64;
4928 + --color-accent-content: #001e2b;
4929 + --color-neutral: #0d1c26;
4930 + --color-neutral-content: #ffffff;
4931 + --color-info: #8ea0e8;
4932 + --color-info-content: #001e2b;
4933 + --color-success: #00ed64;
4934 + --color-success-content: #001e2b;
4935 + --color-warning: #fa6e39;
4936 + --color-warning-content: #001e2b;
4937 + --color-error: #ff8b96;
4938 + --color-error-content: #001e2b;
4939 + --radius-selector: 0.5rem;
4940 + --radius-field: 0.25rem;
4941 + --radius-box: 0.5rem;
4942 + --size-selector: 0.25rem;
4943 + --size-field: 0.25rem;
4944 + --border: 1px;
4945 + --depth: 1;
4946 + --noise: 0;
4947 + }
4596 4948 }
4597 4949 @property --tw-scale-x {
4598 4950 syntax: "*";
@@ -4759,11 +5111,6 @@
4759 5111 inherits: false;
4760 5112 initial-value: 0 0 #0000;
4761 5113 }
4762 -@property --tw-outline-style {
4763 - syntax: "*";
4764 - inherits: false;
4765 - initial-value: solid;
4766 -}
4767 5114 @property --tw-blur {
4768 5115 syntax: "*";
4769 5116 inherits: false;
@@ -4828,9 +5175,6 @@
4828 5175 @layer properties {
4829 5176 @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
4830 5177 *, ::before, ::after, ::backdrop {
4831 - --tw-translate-x: 0;
4832 - --tw-translate-y: 0;
4833 - --tw-translate-z: 0;
4834 5178 --tw-scale-x: 1;
4835 5179 --tw-scale-y: 1;
4836 5180 --tw-scale-z: 1;
@@ -4867,7 +5211,6 @@
4867 5211 --tw-ring-offset-width: 0px;
4868 5212 --tw-ring-offset-color: #fff;
4869 5213 --tw-ring-offset-shadow: 0 0 #0000;
4870 - --tw-outline-style: solid;
4871 5214 --tw-blur: initial;
4872 5215 --tw-brightness: initial;
4873 5216 --tw-contrast: initial;
M static/css/tailwind.src.css
+78 -40
@@ -1,67 +1,59 @@
1 1 /* Tailwind CSS 4 + DaisyUI 5 Configuration */
2 2 @import "tailwindcss";
3 3
4 -/* Source files for class extraction - Tailwind CSS 4 syntax */
5 -/* Path is relative to this CSS file location (static/css/) */
6 -@source "../../templates/**/*.html";
4 +/* Source files for class extraction. Paths are relative to static/css/. */
5 +@source "../../crates/rustwarden-ui/templates/**/*.html";
6 +@source "../../crates/rustwarden-fediverse-ui/templates/**/*.html";
7 +@source "../../crates/rustwarden-fediverse-guard/templates/**/*.html";
7 8
8 -/* Custom RustCloud colors for Tailwind utilities */
9 +/* Brand tokens from docs/INFORMATIF/design/design.md */
9 10 @theme {
10 - --color-rustwarden-blue: #0082c9;
11 - --color-rustwarden-rust: #ce422b;
11 + --color-brand-green: #00ed64;
12 + --color-brand-teal: #001e2b;
12 13 }
13 14
14 -/* DaisyUI Plugin */
15 +/* DaisyUI Plugin. The server sets data-theme to light or dark. */
15 16 @plugin "daisyui" {
16 - themes: rustwarden --default, light, dark;
17 + themes: light --default, dark;
17 18 root: ":root";
18 - logs: true;
19 + logs: false;
19 20 }
20 21
21 -/* Custom RustCloud Theme */
22 +/* Light theme — canvas, ink, primary green from the design palette. */
22 23 @plugin "daisyui/theme" {
23 - name: "rustwarden";
24 + name: "light";
24 25 default: true;
25 26 prefersdark: false;
26 27 color-scheme: light;
27 28
28 - /* Base colors - converted from hex to oklch approximations */
29 - --color-base-100: oklch(100% 0 0); /* #ffffff - white */
30 - --color-base-200: oklch(96% 0.01 240); /* #f5f5f5 - light gray */
31 - --color-base-300: oklch(92% 0.02 240); /* darker gray for elevations */
32 - --color-base-content: oklch(20% 0.02 240); /* dark text on light background */
29 + --color-base-100: #ffffff;
30 + --color-base-200: #f9fbfa;
31 + --color-base-300: #e1e5e8;
32 + --color-base-content: #001e2b;
33 33
34 - /* Primary - RustCloud blue #0082c9 */
35 - --color-primary: oklch(55% 0.15 240);
36 - --color-primary-content: oklch(98% 0.01 240);
34 + --color-primary: #00ed64;
35 + --color-primary-content: #001e2b;
37 36
38 - /* Secondary - Dark gray #1d2d35 */
39 - --color-secondary: oklch(25% 0.02 240);
40 - --color-secondary-content: oklch(98% 0.01 240);
37 + --color-secondary: #001e2b;
38 + --color-secondary-content: #ffffff;
41 39
42 - /* Accent - Success green #46ba61 */
43 - --color-accent: oklch(70% 0.18 150);
44 - --color-accent-content: oklch(98% 0.01 150);
40 + --color-accent: #00684a;
41 + --color-accent-content: #ffffff;
45 42
46 - /* Neutral - Light gray #f5f5f5 */
47 - --color-neutral: oklch(96% 0.01 240);
48 - --color-neutral-content: oklch(20% 0.02 240);
43 + --color-neutral: #1c2d38;
44 + --color-neutral-content: #ffffff;
49 45
50 - /* Info - RustCloud blue #0082c9 */
51 - --color-info: oklch(55% 0.15 240);
52 - --color-info-content: oklch(98% 0.01 240);
46 + --color-info: #3d4f9f;
47 + --color-info-content: #ffffff;
53 48
54 - /* Success - Green #46ba61 */
55 - --color-success: oklch(70% 0.18 150);
56 - --color-success-content: oklch(98% 0.01 150);
49 + --color-success: #00a35c;
50 + --color-success-content: #001e2b;
57 51
58 - /* Warning - Yellow #f0b400 */
59 - --color-warning: oklch(80% 0.15 85);
60 - --color-warning-content: oklch(20% 0.05 85);
52 + --color-warning: #fa6e39;
53 + --color-warning-content: #001e2b;
61 54
62 - /* Error - Red #e74856 */
63 - --color-error: oklch(65% 0.20 25);
64 - --color-error-content: oklch(98% 0.01 25);
55 + --color-error: #e74856;
56 + --color-error-content: #ffffff;
65 57
66 58 /* Border radius */
67 59 --radius-selector: 0.5rem;
@@ -79,3 +71,49 @@
79 71 --depth: 1;
80 72 --noise: 0;
81 73 }
74 +
75 +/* Dark theme — same primary, teal canvas. */
76 +@plugin "daisyui/theme" {
77 + name: "dark";
78 + default: false;
79 + prefersdark: true;
80 + color-scheme: dark;
81 +
82 + --color-base-100: #001e2b;
83 + --color-base-200: #1c2d38;
84 + --color-base-300: #003d4f;
85 + --color-base-content: #ffffff;
86 +
87 + --color-primary: #00ed64;
88 + --color-primary-content: #001e2b;
89 +
90 + --color-secondary: #003d4f;
91 + --color-secondary-content: #ffffff;
92 +
93 + --color-accent: #00ed64;
94 + --color-accent-content: #001e2b;
95 +
96 + --color-neutral: #0d1c26;
97 + --color-neutral-content: #ffffff;
98 +
99 + --color-info: #8ea0e8;
100 + --color-info-content: #001e2b;
101 +
102 + --color-success: #00ed64;
103 + --color-success-content: #001e2b;
104 +
105 + --color-warning: #fa6e39;
106 + --color-warning-content: #001e2b;
107 +
108 + --color-error: #ff8b96;
109 + --color-error-content: #001e2b;
110 +
111 + --radius-selector: 0.5rem;
112 + --radius-field: 0.25rem;
113 + --radius-box: 0.5rem;
114 + --size-selector: 0.25rem;
115 + --size-field: 0.25rem;
116 + --border: 1px;
117 + --depth: 1;
118 + --noise: 0;
119 +}
A static/js/alpine-components.js
+268 -0
@@ -0,0 +1,268 @@
1 +/* Alpine components for the CSP build (@alpinejs/csp).
2 + Loaded before alpine-csp.min.js so these listeners are registered
3 + before Alpine.start() dispatches alpine:init.
4 + Method bodies here are real JavaScript: fetch, window and document
5 + are allowed. They must not appear in HTML Alpine expressions. */
6 +document.addEventListener("alpine:init", () => {
7 + Alpine.data("twoFactorPage", () => ({
8 + code: "",
9 + error: "",
10 + verifying: false,
11 + useBackupCode: false,
12 + challengeToken: "",
13 + errorInvalid: "",
14 + errorNetwork: "",
15 +
16 + init() {
17 + const params = new URLSearchParams(window.location.search);
18 + this.challengeToken = params.get("challenge") || "";
19 + this.errorInvalid = this.$el.dataset.errorInvalid || "";
20 + this.errorNetwork = this.$el.dataset.errorNetwork || "";
21 + },
22 +
23 + toggleBackup() {
24 + this.useBackupCode = !this.useBackupCode;
25 + this.code = "";
26 + },
27 +
28 + async verify() {
29 + if (!this.code || !this.challengeToken) return;
30 + this.verifying = true;
31 + this.error = "";
32 + try {
33 + const res = await fetch("/api/v1/auth/2fa/verify", {
34 + method: "POST",
35 + headers: { "Content-Type": "application/json" },
36 + body: JSON.stringify({
37 + challenge_token: this.challengeToken,
38 + code: this.code,
39 + }),
40 + });
41 + const data = await res.json();
42 + if (res.ok) {
43 + if (data.refresh_token) {
44 + localStorage.setItem("refresh_token", data.refresh_token);
45 + }
46 + if (data.user && data.user.must_change_password === true) {
47 + window.location.href = "/settings/password?must_change=true";
48 + } else {
49 + window.location.href = "/home";
50 + }
51 + } else {
52 + this.error = data.error || this.errorInvalid;
53 + this.code = "";
54 + }
55 + } catch (e) {
56 + this.error = this.errorNetwork;
57 + }
58 + this.verifying = false;
59 + },
60 + }));
61 +
62 + Alpine.data("securityPage", () => ({
63 + setupStarted: false,
64 + setupComplete: false,
65 + setupError: "",
66 + qrCode: "",
67 + secretBase32: "",
68 + verifyCode: "",
69 + verifying: false,
70 + showBackupCodes: false,
71 + backupCodes: [],
72 + regenerating: false,
73 + showDisableModal: false,
74 + disablePassword: "",
75 + disableError: "",
76 + disabling: false,
77 + csrf: "",
78 + regenerateConfirm: "",
79 +
80 + init() {
81 + this.csrf = this.$el.dataset.csrf || "";
82 + this.regenerateConfirm = this.$el.dataset.regenerateConfirm || "";
83 + },
84 +
85 + async startSetup() {
86 + this.setupStarted = true;
87 + this.setupError = "";
88 + try {
89 + const res = await fetch("/settings/security/totp/enable", {
90 + method: "POST",
91 + headers: {
92 + "Content-Type": "application/json",
93 + "X-CSRF-Token": this.csrf,
94 + },
95 + });
96 + const data = await res.json();
97 + if (res.ok) {
98 + this.qrCode = data.qr_code_data_uri;
99 + this.secretBase32 = data.secret_base32;
100 + } else {
101 + this.setupError = data.error || "Error";
102 + this.setupStarted = false;
103 + }
104 + } catch (e) {
105 + this.setupError = "Network error";
106 + this.setupStarted = false;
107 + }
108 + },
109 +
110 + async verifyAndEnable() {
111 + if (this.verifyCode.length !== 6) return;
112 + this.verifying = true;
113 + this.setupError = "";
114 + try {
115 + const res = await fetch("/settings/security/totp/verify", {
116 + method: "POST",
117 + headers: {
118 + "Content-Type": "application/json",
119 + "X-CSRF-Token": this.csrf,
120 + },
121 + body: JSON.stringify({ code: this.verifyCode }),
122 + });
123 + const data = await res.json();
124 + if (res.ok) {
125 + this.setupComplete = true;
126 + this.backupCodes = data.backup_codes;
127 + this.showBackupCodes = true;
128 + } else {
129 + this.setupError = data.error || "Invalid code";
130 + this.verifyCode = "";
131 + }
132 + } catch (e) {
133 + this.setupError = "Network error";
134 + }
135 + this.verifying = false;
136 + },
137 +
138 + async regenerateBackupCodes() {
139 + if (!confirm(this.regenerateConfirm)) return;
140 + this.regenerating = true;
141 + try {
142 + const res = await fetch("/settings/security/backup-codes", {
143 + method: "POST",
144 + headers: {
145 + "Content-Type": "application/json",
146 + "X-CSRF-Token": this.csrf,
147 + },
148 + });
149 + const data = await res.json();
150 + if (res.ok) {
151 + this.backupCodes = data.backup_codes;
152 + this.showBackupCodes = true;
153 + }
154 + } catch (e) {}
155 + this.regenerating = false;
156 + },
157 +
158 + async disableTotp() {
159 + this.disabling = true;
160 + this.disableError = "";
161 + try {
162 + const res = await fetch("/settings/security/totp/disable", {
163 + method: "POST",
164 + headers: {
165 + "Content-Type": "application/json",
166 + "X-CSRF-Token": this.csrf,
167 + },
168 + body: JSON.stringify({ password: this.disablePassword }),
169 + });
170 + if (res.ok) {
171 + window.location.reload();
172 + } else {
173 + const data = await res.json();
174 + this.disableError = data.error || "Error";
175 + }
176 + } catch (e) {
177 + this.disableError = "Network error";
178 + }
179 + this.disabling = false;
180 + },
181 +
182 + copyBackupCodes() {
183 + const text = this.backupCodes.join("\n");
184 + if (navigator.clipboard && navigator.clipboard.writeText) {
185 + navigator.clipboard.writeText(text);
186 + } else {
187 + const ta = document.createElement("textarea");
188 + ta.value = text;
189 + ta.style.position = "fixed";
190 + ta.style.opacity = "0";
191 + document.body.appendChild(ta);
192 + ta.select();
193 + document.execCommand("copy");
194 + document.body.removeChild(ta);
195 + }
196 + },
197 +
198 + acknowledgeBackupCodes() {
199 + this.showBackupCodes = false;
200 + window.location.reload();
201 + },
202 +
203 + closeDisableModal() {
204 + this.showDisableModal = false;
205 + this.disablePassword = "";
206 + this.disableError = "";
207 + },
208 + }));
209 +
210 + Alpine.data("analyticsOptOut", () => ({
211 + storageKey: "",
212 + optedOut: false,
213 +
214 + init() {
215 + this.storageKey = this.$el.dataset.storageKey || "";
216 + try {
217 + this.optedOut = localStorage.getItem(this.storageKey) !== null;
218 + } catch (e) {}
219 + },
220 +
221 + toggle() {
222 + try {
223 + if (this.optedOut) {
224 + localStorage.removeItem(this.storageKey);
225 + this.optedOut = false;
226 + } else {
227 + localStorage.setItem(this.storageKey, "1");
228 + this.optedOut = true;
229 + }
230 + } catch (e) {}
231 + },
232 + }));
233 +
234 + Alpine.data("fediversePage", () => ({
235 + saved: false,
236 + deleteTarget: null,
237 + revokeTarget: null,
238 + killDomain: null,
239 +
240 + init() {
241 + this.saved = this.$el.dataset.saved === "true";
242 + this.$watch("saved", (value) => {
243 + if (value) {
244 + setTimeout(() => {
245 + this.saved = false;
246 + }, 4000);
247 + }
248 + });
249 + },
250 +
251 + revokeHandle() {
252 + if (!this.revokeTarget) return "";
253 + const parts = this.revokeTarget.split("|");
254 + return parts.length > 1 ? parts[1] : "";
255 + },
256 +
257 + openKillModal() {
258 + if (!this.killDomain) return;
259 + const modal = document.getElementById("kill-domain-modal");
260 + if (modal) modal.showModal();
261 + },
262 +
263 + closeKillModal() {
264 + const modal = document.getElementById("kill-domain-modal");
265 + if (modal) modal.close();
266 + },
267 + }));
268 +});
A static/js/alpine-csp.min.js
+23 -0
@@ -0,0 +1,23 @@
1 +(()=>{var bt=!1,vt=!1,K=[],wt=-1,Fe=!1,At=!1;function wr(e){_n(e)}function Ar(){At=!0}function Or(){At=!1,Sr()}function _n(e){K.includes(e)||(K.push(e),e._x_schedulerPriority!==void 0&&(Fe=!0)),Sr()}function he(e){let t=K.indexOf(e);t!==-1&&t>wt&&K.splice(t,1)}function Sr(){if(!vt&&!bt){if(At)return;bt=!0,queueMicrotask(gn)}}function gn(){bt=!1,vt=!0;for(let e=0;e<K.length;e++)Fe&&yn(e),K[e](),wt=e;K.length=0,wt=-1,Fe=!1,vt=!1}function yn(e){let t=new Map,r=K.slice(e).sort((i,n)=>xn(i,n,t));for(let i=0;i<r.length;i++)K[e+i]=r[i];Fe=!1}function xn(e,t,r){return Et(e)?Et(t)?vr(e._x_schedulerPriority.el,r)-vr(t._x_schedulerPriority.el,r)||e._x_schedulerPriority.order-t._x_schedulerPriority.order:-1:Et(t)?1:0}function Et(e){return e._x_schedulerPriority!==void 0}function vr(e,t){if(t.has(e))return t.get(e);let r=0,i=e;for(;e;)r++,e._x_teleportBack?e=e._x_teleportBack:typeof ShadowRoot=="function"&&e.parentNode instanceof ShadowRoot?e=e.parentNode.host:e=e.parentElement;return t.set(i,r),r}var D,j,q,St,En=0,Ot=!0;function Nr(e){Ot=!1,e(),Ot=!0}function Tr(e){D=e.reactive,q=e.release,j=t=>e.effect(t,{scheduler:r=>{Ot?wr(r):r()}}),St=e.raw}function Nt(e){j=e}function Cr(e){let t=()=>{};return[(i,n)=>{let s=n?.priority==="structural"?En++:void 0,o=j(i);return s!==void 0&&o!==void 0&&(o._x_schedulerPriority={el:e,order:s}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(o),t=()=>{o!==void 0&&(e._x_effects.delete(o),q(o))},o},()=>{t()}]}function $e(e,t){let r=!0,i,n,s=j(()=>{let o=e(),a=JSON.stringify(o);if(!r&&(typeof o=="object"||o!==i)){let c=typeof i=="object"?JSON.parse(n):i;queueMicrotask(()=>{t(o,c)})}i=o,n=a,r=!1});return()=>{he(s),q(s)}}async function Rr(e){Ar();try{await e(),await Promise.resolve()}finally{Or()}}var kr=[],Dr=[],Pr=[];function Ir(e){Pr.push(e)}function me(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Dr.push(t))}function He(e){kr.push(e)}function Be(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Tt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,i])=>{(t===void 0||t.includes(r))&&(i.forEach(n=>n()),delete e._x_attributeCleanups[r])})}function Mr(e){for(e._x_effects?.forEach(he);e._x_cleanups?.length;)e._x_cleanups.pop()()}var Ke=new MutationObserver(qe),Ct=!1;function Oe(){Ke.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Ct=!0}function Rt(){bn(),Ke.disconnect(),Ct=!1}var te=[];function bn(){let e=Ke.takeRecords();te.push(()=>e.length>0&&qe(e));let t=te.length;queueMicrotask(()=>{if(te.length===t)for(;te.length>0;)te.shift()()})}function Lr(){for(;te.length>0;)te.shift()();let e=Ke.takeRecords();e.length>0&&qe(e)}function _(e){if(!Ct)return e();Rt();try{return e()}finally{Oe()}}var kt=!1,Ve=[];function Ur(){kt=!0}function jr(){kt=!1,qe(Ve),Ve=[]}function qe(e){if(kt){Ve=Ve.concat(e);return}let t=[],r=new Set,i=new Map,n=new Map;for(let s=0;s<e.length;s++)if(!e[s].target._x_ignoreMutationObserver&&(e[s].type==="childList"&&(e[s].removedNodes.forEach(o=>{o.nodeType===1&&o._x_marker&&r.add(o)}),e[s].addedNodes.forEach(o=>{if(o.nodeType===1){if(r.has(o)){r.delete(o);return}o._x_marker||t.push(o)}})),e[s].type==="attributes")){let o=e[s].target,a=e[s].attributeName,c=e[s].oldValue,l=()=>{i.has(o)||i.set(o,[]),i.get(o).push({name:a,value:o.getAttribute(a)})},u=()=>{n.has(o)||n.set(o,[]),n.get(o).push(a)};o.hasAttribute(a)&&c===null?l():o.hasAttribute(a)?(u(),l()):u()}n.forEach((s,o)=>{Tt(o,s)}),i.forEach((s,o)=>{kr.forEach(a=>a(o,s))});for(let s of r)t.some(o=>o.contains(s))||Dr.forEach(o=>o(s));for(let s of t)s.isConnected&&Pr.forEach(o=>o(s));t=null,r=null,i=null,n=null}function ze(e){return P(z(e))}function F(e,t,r){return e._x_dataStack=[t,...z(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(i=>i!==t)}}function z(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?z(e.host):e.parentNode?z(e.parentNode):[]}function P(e){return new Proxy({objects:e},vn)}function Fr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:Fr(Object.getPrototypeOf(e),t)}var vn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?wn:Reflect.get(e.find(i=>Reflect.has(i,t))||{},t,r)},set({objects:e},t,r,i){let n;for(let o of e)if(n=Fr(o,t),n)break;n||(n=e[e.length-1]);let s=Object.getOwnPropertyDescriptor(n,t);return s?.set&&s?.get?s.set.call(i,r)||!0:Reflect.set(n,t,r)}};function wn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function _e(e,t=()=>{}){let r=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,i=(n,s="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=s===""?o:`${s}.${o}`;typeof a=="object"&&a!==null&&a._x_interceptor?n[o]=a.initialize(e,l,o,t):r(a)&&a!==n&&!(a instanceof Element)&&i(a,l)})};return i(e)}function We(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(i,n,s,o){return e(this.initialValue,()=>An(i,n),a=>Dt(i,n,a),n,s,o)}};return t(r),i=>{if(typeof i=="object"&&i!==null&&i._x_interceptor){let n=r.initialize.bind(r);r.initialize=(s,o,a,c)=>{let l=i.initialize(s,o,a,c);return r.initialValue=l,n(s,o,a,c)}}else r.initialValue=i;return r}}function An(e,t){return t.split(".").reduce((r,i)=>r[i],e)}function Dt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Dt(e[t[0]],t.slice(1),r)}}var $r={};function v(e,t){$r[e]=t}function X(e,t){let r=On(t);return Object.entries($r).forEach(([i,n])=>{Object.defineProperty(e,`$${i}`,{get(){return n(t,r)},enumerable:!1})}),e}function On(e){let[t,r]=Pt(e),i={interceptor:We,...t};return me(e,r),i}function It(e,t,r,...i){try{return r(...i)}catch(n){re(n,e,t)}}function re(...e){return Vr(...e)}var Vr=Sn;function Hr(e){Vr=e}function Sn(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
2 +
3 +${r?'Expression: "'+r+`"
4 +
5 +`:""}`,t),setTimeout(()=>{throw e},0)}var Z=!0;function Ge(e){let t=Z;Z=!1;let r=e();return Z=t,r}function I(e,t,r={}){let i;return b(e,t)(n=>i=n,r),i}function b(...e){return Br(...e)}var Br=()=>{};function Kr(e){Br=e}var qr;function zr(e){qr=e}function Wr(e,t){return(r=()=>{},{scope:i={},params:n=[],context:s}={})=>{if(!Z){Mt(r,t,P([i,...e]),n);return}let o=t.apply(P([i,...e]),n);Mt(r,o)}}function Mt(e,t,r,i,n){if(Z&&typeof t=="function"){let s=t.apply(r,i);s instanceof Promise?s.then(o=>Mt(e,o,r,i)).catch(o=>re(o,n,t)):e(s)}else typeof t=="object"&&t instanceof Promise?t.then(s=>e(s)):e(t)}function Gr(...e){return qr(...e)}var jt="x-";function R(e=""){return jt+e}function Yr(e){jt=e}var Ye={};function h(e,t){return Ye[e]=t,{before(r){if(!Ye[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let i=ie.indexOf(r);ie.splice(i>=0?i:ie.indexOf("DEFAULT"),0,e)}}}function Jr(e){return Object.keys(Ye).includes(e)}function ne(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let s=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),o=Ft(s);s=s.map(a=>o.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(s)}let i={};return t.map(Qr((s,o)=>i[s]=o)).filter(ti).map(Tn(i,r)).sort(Cn).map(s=>Nn(e,s))}function Ft(e){return Array.from(e).map(Qr()).filter(t=>!ti(t))}var Lt=!1,Se=new Map,Xr=Symbol();function Zr(e){Lt=!0;let t=Symbol();Xr=t,Se.set(t,[]);let r=()=>{for(;Se.get(t).length;)Se.get(t).shift()();Se.delete(t)},i=()=>{Lt=!1,r()};e(r),i()}function Pt(e){let t=[],r=a=>t.push(a),[i,n]=Cr(e);return t.push(n),[{Alpine:W,effect:i,cleanup:r,evaluateLater:b.bind(b,e),evaluate:I.bind(I,e)},()=>t.forEach(a=>a())]}function Nn(e,t){let r=()=>{},i=Ye[t.type]||r,[n,s]=Pt(e);Be(e,t.original,s);let o=()=>{e._x_ignore||e._x_ignoreSelf||(i.inline&&i.inline(e,t,n),i=i.bind(i,e,t,n),Lt?Se.get(Xr).push(i):i())};return o.runCleanups=s,o}var Je=(e,t)=>({name:r,value:i})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:i}),Xe=e=>e;function Qr(e=()=>{}){return({name:t,value:r})=>{let{name:i,value:n}=ei.reduce((s,o)=>o(s),{name:t,value:r});return i!==t&&e(i,t),{name:i,value:n}}}var ei=[];function ge(e){ei.push(e)}function ti({name:e}){return ri().test(e)}var ri=()=>new RegExp(`^${jt}([^:^.]+)\\b`);function Tn(e,t){return({name:r,value:i})=>{r===i&&(i="");let n=r.match(ri()),s=r.match(/:([a-zA-Z0-9\-_:]+)/),o=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:n?n[1]:null,value:s?s[1]:null,modifiers:o.map(c=>c.replace(".","")),expression:i,original:a}}}var Ut="DEFAULT",ie=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",Ut,"teleport"];function Cn(e,t){let r=ie.indexOf(e.type)===-1?Ut:e.type,i=ie.indexOf(t.type)===-1?Ut:t.type;return ie.indexOf(r)-ie.indexOf(i)}function $(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(n=>$(n,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let i=e.firstElementChild;for(;i;)$(i,t,!1),i=i.nextElementSibling}var V=!1;function O(e,t=()=>{}){return(...r)=>V?t(...r):e(...r)}function ii(e){return(...t)=>V&&e(...t)}var ni=[];function H(e){ni.push(e)}function si(e,t){ni.forEach(r=>r(e,t)),V=!0,ai(()=>{S(t,(r,i)=>{i(r,()=>{})})}),V=!1}var Ze=!1;function oi(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),V=!0,Ze=!0,ai(()=>{Rn(t)}),V=!1,Ze=!1}function Rn(e){let t=!1;S(e,(i,n)=>{$(i,(s,o)=>{if(t&&ci(s))return o();t=!0,n(s,o)})})}function ai(e){let t=j;Nt((r,i)=>{let n=t(r);return q(n),()=>{}}),e(),Nt(t)}var Qe=0;function li(e,t){let r=e._x_deferInit;r||(r=e._x_deferInit={pending:0,ownsIgnore:!e._x_ignore,queuedAttributes:new Map},r.ownsIgnore&&(e._x_ignore=!0),Qe++),r.pending++,Promise.resolve(t).catch(i=>{try{re(i,e)}catch(n){setTimeout(()=>{throw n},0)}}).then(()=>kn(e,r))}function kn(e,t){t.pending--,!(t.pending>0)&&(Lr(),!(t.pending>0)&&e._x_deferInit===t&&(delete e._x_deferInit,t.ownsIgnore&&delete e._x_ignore,Qe--,e.isConnected&&(Dn(t),S(e))))}function ui(e,t){if(Qe===0)return!1;let r=N(e,i=>i._x_deferInit);return r?(fi(r._x_deferInit,e,t.map(({name:i})=>i)),!0):!1}function fi(e,t,r){let i=e.queuedAttributes.get(t);(!i||i.marker!==t._x_marker)&&(i={marker:t._x_marker,names:new Set},e.queuedAttributes.set(t,i)),r.forEach(n=>i.names.add(n))}function Dn(e){e.queuedAttributes.forEach((t,r)=>{if(!r.isConnected||!r._x_marker||r._x_marker!==t.marker)return;let i=N(r,s=>s._x_deferInit);if(i){fi(i._x_deferInit,r,Array.from(t.names));return}let n=Array.from(t.names).filter(s=>r.hasAttribute(s)).map(s=>({name:s,value:r.getAttribute(s)}));n.length!==0&&ne(r,n).forEach(s=>s())})}H((e,t)=>{Qe!==0&&(!e||e.nodeType!==1||!t||t.nodeType!==1||N(e,r=>r._x_deferInit)&&(t._x_ignore=!0))});function se(e,t,r={},i={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...i}))}function T(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var pi=!1;function di(){pi&&T("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),pi=!0,document.body||T("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),se(document,"alpine:init"),se(document,"alpine:initializing"),Oe(),Ir(t=>S(t,$)),me(t=>k(t)),He((t,r)=>{ui(t,r)||ne(t,r).forEach(i=>i())});let e=t=>!oe(t.parentElement,!0);Array.from(document.querySelectorAll(_i().join(","))).filter(e).forEach(t=>{S(t)}),se(document,"alpine:initialized"),setTimeout(()=>{In()})}var $t=[],hi=[];function mi(){return $t.map(e=>e())}function _i(){return $t.concat(hi).map(e=>e())}function et(e){$t.push(e)}function tt(e){hi.push(e)}function oe(e,t=!1){return N(e,r=>{if((t?_i():mi()).some(n=>r.matches(n)))return!0})}function N(e,t){if(e){if(t(e))return e;if(e._x_teleportBack)return N(e._x_teleportBack,t);if(e.parentNode instanceof ShadowRoot)return N(e.parentNode.host,t);if(e.parentElement)return N(e.parentElement,t)}}function ci(e){return mi().some(t=>e.matches(t))}var gi=[];function yi(e){gi.push(e)}var Pn=1;function S(e,t=$,r=()=>{}){N(e,i=>i._x_ignore)||Zr(()=>{t(e,(i,n)=>{i._x_marker||(r(i,n),gi.forEach(s=>s(i,n)),ne(i,i.attributes).forEach(s=>s()),i._x_ignore||(i._x_marker=Pn++),i._x_ignore&&n())})})}function k(e,t=$){t(e,r=>{Mr(r),Tt(r),delete r._x_marker})}function In(){[["ui","dialog",["[x-dialog], [x-popover]"]],["anchor","anchor",["[x-anchor]"]],["sort","sort",["[x-sort]"]]].forEach(([t,r,i])=>{Jr(r)||i.some(n=>{if(document.querySelector(n))return T(`found "${n}", but missing ${t} plugin`),!0})})}var Vt=[],Ht=!1;function ye(e=()=>{}){return queueMicrotask(()=>{Ht||setTimeout(()=>{rt()})}),new Promise(t=>{Vt.push(()=>{e(),t()})})}function rt(){for(Ht=!1;Vt.length;)Vt.shift()()}function xi(){Ht=!0}function Ne(e,t){return Array.isArray(t)?Ei(e,t.join(" ")):typeof t=="object"&&t!==null?Mn(e,t):typeof t=="function"?Ne(e,t()):Ei(e,t)}function Bt(e){return e.split(/\s/).filter(Boolean)}function Ei(e,t){let r=n=>Bt(n).filter(s=>!e.classList.contains(s)).filter(Boolean),i=n=>(e.classList.add(...n),()=>{e.classList.remove(...n)});return t=t===!0?t="":t||"",i(r(t))}function Mn(e,t){let r=Object.entries(t).flatMap(([o,a])=>a?Bt(o):!1).filter(Boolean),i=Object.entries(t).flatMap(([o,a])=>a?!1:Bt(o)).filter(Boolean),n=[],s=[];return i.forEach(o=>{e.classList.contains(o)&&(e.classList.remove(o),s.push(o))}),r.forEach(o=>{e.classList.contains(o)||(e.classList.add(o),n.push(o))}),()=>{s.forEach(o=>e.classList.add(o)),n.forEach(o=>e.classList.remove(o))}}function ae(e,t){return typeof t=="object"&&t!==null?Ln(e,t):Un(e,t)}function Ln(e,t){let r={};return Object.entries(t).forEach(([i,n])=>{r[i]=e.style[i],i.startsWith("--")||(i=jn(i)),e.style.setProperty(i,n)}),setTimeout(()=>{e.style.length===0&&e.removeAttribute("style")}),()=>{ae(e,r)}}function Un(e,t){let r=e.getAttribute("style",t);return e.setAttribute("style",t),()=>{e.setAttribute("style",r||"")}}function jn(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function Te(e,t=()=>{}){let r=!1;return function(){r?t.apply(this,arguments):(r=!0,e.apply(this,arguments))}}h("transition",(e,{value:t,modifiers:r,expression:i},{evaluate:n})=>{typeof i=="function"&&(i=n(i)),i!==!1&&(!i||typeof i=="boolean"?$n(e,r,t):Fn(e,i,t))});function Fn(e,t,r){bi(e,Ne,""),{enter:n=>{e._x_transition.enter.during=n},"enter-start":n=>{e._x_transition.enter.start=n},"enter-end":n=>{e._x_transition.enter.end=n},leave:n=>{e._x_transition.leave.during=n},"leave-start":n=>{e._x_transition.leave.start=n},"leave-end":n=>{e._x_transition.leave.end=n}}[r](t)}function $n(e,t,r){bi(e,ae);let i=!t.includes("in")&&!t.includes("out")&&!r,n=i||t.includes("in")||["enter"].includes(r),s=i||t.includes("out")||["leave"].includes(r);t.includes("in")&&!i&&(t=t.filter((p,y)=>y<t.indexOf("out"))),t.includes("out")&&!i&&(t=t.filter((p,y)=>y>t.indexOf("out")));let o=!t.includes("opacity")&&!t.includes("scale"),a=o||t.includes("opacity"),c=o||t.includes("scale"),l=a?0:1,u=c?Ce(t,"scale",95)/100:1,f=Ce(t,"delay",0)/1e3,g=Ce(t,"origin","center"),m="opacity, transform",L=Ce(t,"duration",150)/1e3,d=Ce(t,"duration",75)/1e3,w="cubic-bezier(0.4, 0.0, 0.2, 1)";n&&(e._x_transition.enter.during={transformOrigin:g,transitionDelay:`${f}s`,transitionProperty:m,transitionDuration:`${L}s`,transitionTimingFunction:w},e._x_transition.enter.start={opacity:l,transform:`scale(${u})`},e._x_transition.enter.end={opacity:1,transform:"scale(1)"}),s&&(e._x_transition.leave.during={transformOrigin:g,transitionDelay:`${f}s`,transitionProperty:m,transitionDuration:`${d}s`,transitionTimingFunction:w},e._x_transition.leave.start={opacity:1,transform:"scale(1)"},e._x_transition.leave.end={opacity:l,transform:`scale(${u})`})}function bi(e,t,r={}){e._x_transition||(e._x_transition={enter:{during:r,start:r,end:r},leave:{during:r,start:r,end:r},in(i=()=>{},n=()=>{}){it(e,t,{during:this.enter.during,start:this.enter.start,end:this.enter.end},i,n)},out(i=()=>{},n=()=>{}){it(e,t,{during:this.leave.during,start:this.leave.start,end:this.leave.end},i,n)}})}window.Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,i){let n=document.visibilityState==="visible"?requestAnimationFrame:setTimeout,s=()=>n(r);if(t){e._x_transition&&(e._x_transition.enter||e._x_transition.leave)?e._x_transition.enter&&(Object.entries(e._x_transition.enter.during).length||Object.entries(e._x_transition.enter.start).length||Object.entries(e._x_transition.enter.end).length)?e._x_transition.in(r):s():e._x_transition?e._x_transition.in(r):s();return}e._x_hidePromise=e._x_transition?new Promise((o,a)=>{e._x_transition.out(()=>{},()=>o(i)),e._x_transitioning&&e._x_transitioning.beforeCancel(()=>a({isFromCancelledTransition:!0}))}):Promise.resolve(i),queueMicrotask(()=>{let o=vi(e);o?(o._x_hideChildren||(o._x_hideChildren=[]),o._x_hideChildren.push(e)):n(()=>{let a=c=>{let l=Promise.all([c._x_hidePromise,...(c._x_hideChildren||[]).map(a)]).then(([u])=>u?.());return delete c._x_hidePromise,delete c._x_hideChildren,l};a(e).catch(c=>{if(!c.isFromCancelledTransition)throw c})})})};function vi(e){let t=e.parentNode;if(t)return t._x_hidePromise?t:vi(t)}function it(e,t,{during:r,start:i,end:n}={},s=()=>{},o=()=>{}){if(e._x_transitioning&&e._x_transitioning.cancel(),Object.keys(r).length===0&&Object.keys(i).length===0&&Object.keys(n).length===0){s(),o();return}let a,c,l;Vn(e,{start(){a=t(e,i)},during(){c=t(e,r)},before:s,end(){a(),l=t(e,n)},after:o,cleanup(){c(),l()}})}function Vn(e,t){let r,i,n,s=Te(()=>{_(()=>{r=!0,i||t.before(),n||(t.end(),rt()),t.after(),e.isConnected&&t.cleanup(),delete e._x_transitioning})});e._x_transitioning={beforeCancels:[],beforeCancel(o){this.beforeCancels.push(o)},cancel:Te(function(){for(;this.beforeCancels.length;)this.beforeCancels.shift()();s()}),finish:s},_(()=>{t.start(),t.during()}),xi(),requestAnimationFrame(()=>{if(r)return;let o=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,a=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;o===0&&(o=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),_(()=>{t.before()}),i=!0,requestAnimationFrame(()=>{r||(_(()=>{t.end()}),rt(),setTimeout(e._x_transitioning.finish,o+a),n=!0)})})}function Ce(e,t,r){if(e.indexOf(t)===-1)return r;let i=e[e.indexOf(t)+1];if(!i||t==="scale"&&isNaN(i))return r;if(t==="duration"||t==="delay"){let n=i.match(/([0-9]+)ms/);if(n)return n[1]}return t==="origin"&&["top","right","left","center","bottom"].includes(e[e.indexOf(t)+2])?[i,e[e.indexOf(t)+2]].join(" "):i}function Re(e,t,r,i=[]){switch(e._x_bindings||(e._x_bindings=D({})),e._x_bindings[t]=r,t=i.includes("camel")?Yn(t):t,t){case"value":Hn(e,r);break;case"style":Kn(e,r);break;case"class":Bn(e,r);break;case"selected":case"checked":qn(e,t,r);break;default:Kt(e,t,r);break}}function Hn(e,t){if(nt(e))e.attributes.value===void 0&&(e.value=t);else if(De(e))Number.isInteger(t)?e.value=t:!Array.isArray(t)&&typeof t!="boolean"&&![null,void 0].includes(t)?e.value=String(t):Array.isArray(t)?e.checked=t.some(r=>Jn(r,e.value)):e.checked=!!t;else if(e.tagName==="SELECT")Gn(e,t);else if(e.tagName==="OPTION")Kt(e,"value",t);else{if(e.value===t&&(typeof t!="object"||t===null))return;e.value=t===void 0?"":t}}function Bn(e,t){e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedClasses=Ne(e,t)}function Kn(e,t){e._x_undoAddedStyles&&e._x_undoAddedStyles(),e._x_undoAddedStyles=ae(e,t)}function qn(e,t,r){Kt(e,t,r),Wn(e,t,r)}function Kt(e,t,r){[null,void 0,!1].includes(r)&&Zn(t)?e.removeAttribute(t):(wi(t)&&(r=t),Qn(r)&&(r=JSON.stringify(r)),zn(e,t,r))}function zn(e,t,r){e.getAttribute(t)!=r&&e.setAttribute(t,r)}function Wn(e,t,r){e[t]!==r&&(e[t]=r)}function Gn(e,t){let r=[].concat(t).map(i=>i+"");Array.from(e.options).forEach(i=>{i.selected=r.includes(i.value)})}function Yn(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function Jn(e,t){return e==t}function ke(e){return[1,"1","true","on","yes",!0].includes(e)?!0:[0,"0","false","off","no",!1].includes(e)?!1:e?Boolean(e):null}var Xn=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected","shadowrootclonable","shadowrootdelegatesfocus","shadowrootserializable"]);function wi(e){return Xn.has(e)}function Zn(e){return!["aria-pressed","aria-checked","aria-expanded","aria-selected"].includes(e)}function Qn(e){return typeof e=="object"&&e!==null}function Ai(e,t,r){return e._x_bindings&&e._x_bindings[t]!==void 0?e._x_bindings[t]:Si(e,t,r)}function Oi(e,t,r,i=!0){if(e._x_bindings&&e._x_bindings[t]!==void 0)return e._x_bindings[t];if(e._x_inlineBindings&&e._x_inlineBindings[t]!==void 0){let n=e._x_inlineBindings[t];return n.extract=i,Ge(()=>I(e,n.expression))}return Si(e,t,r)}function Si(e,t,r){let i=e.getAttribute(t);return i===null?typeof r=="function"?r():r:i===""?!0:wi(t)?!![t,"true"].includes(i):i}function De(e){return e.type==="checkbox"||e.localName==="ui-checkbox"||e.localName==="ui-switch"}function nt(e){return e.type==="radio"||e.localName==="ui-radio"}function st(e,t){let r;return function(){let i=this,n=arguments,s=function(){r=null,e.apply(i,n)};clearTimeout(r),r=setTimeout(s,t)}}function ot(e,t){let r;return function(){let i=this,n=arguments;r||(e.apply(i,n),r=!0,setTimeout(()=>r=!1,t))}}function at({get:e,set:t},{get:r,set:i}){let n=!0,s,o,a=j(()=>{let c=e(),l=r();if(n)i(qt(c)),n=!1;else{let u=JSON.stringify(c),f=JSON.stringify(l);u!==s?i(qt(c)):u!==f&&t(qt(l))}s=JSON.stringify(e()),o=JSON.stringify(r())});return()=>{he(a),q(a)}}function qt(e){return typeof e=="object"?JSON.parse(JSON.stringify(e)):e}function Ni(e){(Array.isArray(e)?e:[e]).forEach(r=>r(W))}var G={},Ti=!1;function Ci(e,t){if(Ti||(G=D(G),Ti=!0),t===void 0)return G[e];G[e]=t,typeof t=="object"&&t!==null&&t._x_interceptor?G[e]=t.initialize(G,e,e,()=>{}):_e(G[e]),typeof t=="object"&&t!==null&&t.hasOwnProperty("init")&&typeof t.init=="function"&&G[e].init()}function Ri(){return G}var ki={};function Di(e,t){let r=typeof t!="function"?()=>t:t;return e instanceof Element?zt(e,r()):(ki[e]=r,()=>{})}function Pi(e){return Object.entries(ki).forEach(([t,r])=>{Object.defineProperty(e,t,{get(){return(...i)=>r(...i)}})}),e}function zt(e,t,r){let i=[];for(;i.length;)i.pop()();let n=Object.entries(t).map(([o,a])=>({name:o,value:a})),s=Ft(n);return n=n.map(o=>s.find(a=>a.name===o.name)?{name:`x-bind:${o.name}`,value:`"${o.value}"`}:o),ne(e,n,r).map(o=>{i.push(o.runCleanups),o()}),()=>{for(;i.length;)i.pop()()}}var Ii={};function Mi(e,t){Ii[e]=t}function Li(e,t){return Object.entries(Ii).forEach(([r,i])=>{Object.defineProperty(e,r,{get(){return(...n)=>i.bind(t)(...n)},enumerable:!1})}),e}var es={get reactive(){return D},get release(){return q},get effect(){return j},get raw(){return St},get transaction(){return Rr},version:"3.17.4",flushAndStopDeferringMutations:jr,dontAutoEvaluateFunctions:Ge,disableEffectScheduling:Nr,startObservingMutations:Oe,stopObservingMutations:Rt,setReactivityEngine:Tr,onAttributeRemoved:Be,onAttributesAdded:He,closestDataStack:z,skipDuringClone:O,onlyDuringClone:ii,addRootSelector:et,addInitSelector:tt,setErrorHandler:Hr,interceptClone:H,addScopeToNode:F,deferMutations:Ur,mapAttributes:ge,evaluateLater:b,interceptInit:yi,initInterceptors:_e,injectMagics:X,setEvaluator:Kr,setRawEvaluator:zr,mergeProxies:P,extractProp:Oi,findClosest:N,onElRemoved:me,closestRoot:oe,destroyTree:k,interceptor:We,transition:it,setStyles:ae,mutateDom:_,deferInit:li,directive:h,entangle:at,throttle:ot,debounce:st,evaluate:I,evaluateRaw:Gr,initTree:S,nextTick:ye,prefixed:R,prefix:Yr,plugin:Ni,magic:v,store:Ci,start:di,clone:oi,cloneNode:si,bound:Ai,$data:ze,watch:$e,walk:$,data:Mi,bind:Di},W=es;var Ui=new WeakMap,ji=new Set;Object.getOwnPropertyNames(globalThis).forEach(e=>{if(e==="styleMedia"||e==="sharedStorage")return;let t;try{t=globalThis[e]}catch{return}ji.add(t)});var A=class{constructor(t,r,i,n){this.type=t,this.value=r,this.start=i,this.end=n}},ts=["IDENTIFIER","KEYWORD","BOOLEAN","NULL","UNDEFINED"],Wt=class{constructor(t){this.input=t,this.position=0,this.tokens=[]}tokenize(){for(;this.position<this.input.length&&(this.skipWhitespace(),!(this.position>=this.input.length));){let t=this.input[this.position];this.isDigit(t)?this.readNumber():this.isAlpha(t)||t==="_"||t==="$"?this.readIdentifierOrKeyword():t==='"'||t==="'"?this.readString():t==="/"&&this.peek()==="/"?this.skipLineComment():this.readOperatorOrPunctuation()}return this.tokens.push(new A("EOF",null,this.position,this.position)),this.tokens}skipWhitespace(){for(;this.position<this.input.length&&/\s/.test(this.input[this.position]);)this.position++}skipLineComment(){for(;this.position<this.input.length&&this.input[this.position]!==`
6 +`;)this.position++}isDigit(t){return/[0-9]/.test(t)}isAlpha(t){return/[a-zA-Z]/.test(t)}isAlphaNumeric(t){return/[a-zA-Z0-9_$]/.test(t)}peek(t=1){return this.input[this.position+t]||""}readNumber(){let t=this.position,r=!1;for(;this.position<this.input.length;){let n=this.input[this.position];if(this.isDigit(n))this.position++;else if(n==="."&&!r)r=!0,this.position++;else break}let i=this.input.slice(t,this.position);this.tokens.push(new A("NUMBER",parseFloat(i),t,this.position))}readIdentifierOrKeyword(){let t=this.position;for(;this.position<this.input.length&&this.isAlphaNumeric(this.input[this.position]);)this.position++;let r=this.input.slice(t,this.position);["true","false","null","undefined","new","typeof","void","delete","in","instanceof"].includes(r)?r==="true"||r==="false"?this.tokens.push(new A("BOOLEAN",r==="true",t,this.position)):r==="null"?this.tokens.push(new A("NULL",null,t,this.position)):r==="undefined"?this.tokens.push(new A("UNDEFINED",void 0,t,this.position)):this.tokens.push(new A("KEYWORD",r,t,this.position)):this.tokens.push(new A("IDENTIFIER",r,t,this.position))}readString(){let t=this.position,r=this.input[this.position];this.position++;let i="",n=!1;for(;this.position<this.input.length;){let s=this.input[this.position];if(n){switch(s){case"n":i+=`
7 +`;break;case"t":i+=" ";break;case"r":i+="\r";break;case"\\":i+="\\";break;case r:i+=r;break;case"u":{let o=this.input.slice(this.position+1,this.position+5);/^[0-9a-fA-F]{4}$/.test(o)?(i+=String.fromCharCode(parseInt(o,16)),this.position+=4):i+=s;break}default:i+=s}n=!1}else if(s==="\\")n=!0;else if(s===r){this.position++,this.tokens.push(new A("STRING",i,t,this.position));return}else i+=s;this.position++}throw new Error(`Unterminated string starting at position ${t}`)}readOperatorOrPunctuation(){let t=this.position,r=this.input[this.position],i=this.peek(),n=this.peek(2);if(r==="="&&i==="="&&n==="=")this.position+=3,this.tokens.push(new A("OPERATOR","===",t,this.position));else if(r==="!"&&i==="="&&n==="=")this.position+=3,this.tokens.push(new A("OPERATOR","!==",t,this.position));else if(r==="="&&i==="=")this.position+=2,this.tokens.push(new A("OPERATOR","==",t,this.position));else if(r==="!"&&i==="=")this.position+=2,this.tokens.push(new A("OPERATOR","!=",t,this.position));else if(r==="<"&&i==="=")this.position+=2,this.tokens.push(new A("OPERATOR","<=",t,this.position));else if(r===">"&&i==="=")this.position+=2,this.tokens.push(new A("OPERATOR",">=",t,this.position));else if(r==="&"&&i==="&")this.position+=2,this.tokens.push(new A("OPERATOR","&&",t,this.position));else if(r==="|"&&i==="|")this.position+=2,this.tokens.push(new A("OPERATOR","||",t,this.position));else if(r==="+"&&i==="+")this.position+=2,this.tokens.push(new A("OPERATOR","++",t,this.position));else if(r==="-"&&i==="-")this.position+=2,this.tokens.push(new A("OPERATOR","--",t,this.position));else{this.position++;let s="()[]{},.;:?".includes(r)?"PUNCTUATION":"OPERATOR";this.tokens.push(new A(s,r,t,this.position))}}},Gt=class{constructor(t){this.tokens=t,this.position=0}parse(){if(this.isAtEnd())throw new Error("Empty expression");let t=this.parseExpression();if(this.match("PUNCTUATION",";"),!this.isAtEnd())throw new Error(`Unexpected token: ${this.current().value}`);return t}parseExpression(){return this.parseAssignment()}parseAssignment(){let t=this.parseTernary();if(this.match("OPERATOR","=")){let r=this.parseAssignment();if(t.type==="Identifier"||t.type==="MemberExpression")return{type:"AssignmentExpression",left:t,operator:"=",right:r};throw new Error("Invalid assignment target")}return t}parseTernary(){let t=this.parseLogicalOr();if(this.match("PUNCTUATION","?")){let r=this.parseExpression();this.consume("PUNCTUATION",":");let i=this.parseExpression();return{type:"ConditionalExpression",test:t,consequent:r,alternate:i}}return t}parseLogicalOr(){let t=this.parseLogicalAnd();for(;this.match("OPERATOR","||");){let r=this.previous().value,i=this.parseLogicalAnd();t={type:"BinaryExpression",operator:r,left:t,right:i}}return t}parseLogicalAnd(){let t=this.parseEquality();for(;this.match("OPERATOR","&&");){let r=this.previous().value,i=this.parseEquality();t={type:"BinaryExpression",operator:r,left:t,right:i}}return t}parseEquality(){let t=this.parseRelational();for(;this.match("OPERATOR","==","!=","===","!==");){let r=this.previous().value,i=this.parseRelational();t={type:"BinaryExpression",operator:r,left:t,right:i}}return t}parseRelational(){let t=this.parseAdditive();for(;this.match("OPERATOR","<",">","<=",">=");){let r=this.previous().value,i=this.parseAdditive();t={type:"BinaryExpression",operator:r,left:t,right:i}}return t}parseAdditive(){let t=this.parseMultiplicative();for(;this.match("OPERATOR","+","-");){let r=this.previous().value,i=this.parseMultiplicative();t={type:"BinaryExpression",operator:r,left:t,right:i}}return t}parseMultiplicative(){let t=this.parseUnary();for(;this.match("OPERATOR","*","/","%");){let r=this.previous().value,i=this.parseUnary();t={type:"BinaryExpression",operator:r,left:t,right:i}}return t}parseUnary(){if(this.match("OPERATOR","++","--")){let t=this.previous().value,r=this.parseUnary();return{type:"UpdateExpression",operator:t,argument:r,prefix:!0}}if(this.match("OPERATOR","!","-","+")){let t=this.previous().value,r=this.parseUnary();return{type:"UnaryExpression",operator:t,argument:r,prefix:!0}}return this.parsePostfix()}parsePostfix(){let t=this.parseMember();return this.match("OPERATOR","++","--")?{type:"UpdateExpression",operator:this.previous().value,argument:t,prefix:!1}:t}parseMember(){let t=this.parsePrimary();for(;;)if(this.match("PUNCTUATION",".")){let r=this.consumeIdentifierName();t={type:"MemberExpression",object:t,property:{type:"Identifier",name:r},computed:!1}}else if(this.match("PUNCTUATION","[")){let r=this.parseExpression();this.consume("PUNCTUATION","]"),t={type:"MemberExpression",object:t,property:r,computed:!0}}else if(this.match("PUNCTUATION","(")){let r=this.parseArguments();t={type:"CallExpression",callee:t,arguments:r}}else break;return t}parseArguments(){let t=[];if(!this.check("PUNCTUATION",")"))do t.push(this.parseExpression());while(this.match("PUNCTUATION",","));return this.consume("PUNCTUATION",")"),t}parsePrimary(){if(this.match("NUMBER"))return{type:"Literal",value:this.previous().value};if(this.match("STRING"))return{type:"Literal",value:this.previous().value};if(this.match("BOOLEAN"))return{type:"Literal",value:this.previous().value};if(this.match("NULL"))return{type:"Literal",value:null};if(this.match("UNDEFINED"))return{type:"Literal",value:void 0};if(this.match("IDENTIFIER"))return{type:"Identifier",name:this.previous().value};if(this.match("PUNCTUATION","(")){let t=this.parseExpression();return this.consume("PUNCTUATION",")"),t}if(this.match("PUNCTUATION","["))return this.parseArrayLiteral();if(this.match("PUNCTUATION","{"))return this.parseObjectLiteral();throw new Error(`Unexpected token: ${this.current().type} "${this.current().value}"`)}parseArrayLiteral(){let t=[];for(;!this.check("PUNCTUATION","]")&&!this.isAtEnd()&&(t.push(this.parseExpression()),this.match("PUNCTUATION",","));)if(this.check("PUNCTUATION","]"))break;return this.consume("PUNCTUATION","]"),{type:"ArrayExpression",elements:t}}parseObjectLiteral(){let t=[];for(;!this.check("PUNCTUATION","}")&&!this.isAtEnd();){let r,i=!1;if(this.match("STRING"))r={type:"Literal",value:this.previous().value};else if(this.checkIdentifierName())r={type:"Identifier",name:this.consumeIdentifierName()};else if(this.match("PUNCTUATION","["))r=this.parseExpression(),i=!0,this.consume("PUNCTUATION","]");else throw new Error("Expected property key");this.consume("PUNCTUATION",":");let n=this.parseExpression();if(t.push({type:"Property",key:r,value:n,computed:i,shorthand:!1}),this.match("PUNCTUATION",",")){if(this.check("PUNCTUATION","}"))break}else break}return this.consume("PUNCTUATION","}"),{type:"ObjectExpression",properties:t}}match(...t){for(let r=0;r<t.length;r++){let i=t[r];if(r===0&&t.length>1){let n=i;for(let s=1;s<t.length;s++)if(this.check(n,t[s]))return this.advance(),!0;return!1}else if(t.length===1)return this.checkType(i)?(this.advance(),!0):!1}return!1}check(t,r){return this.isAtEnd()?!1:r!==void 0?this.current().type===t&&this.current().value===r:this.current().type===t}checkType(t){return this.isAtEnd()?!1:this.current().type===t}advance(){return this.isAtEnd()||this.position++,this.previous()}isAtEnd(){return this.current().type==="EOF"}current(){return this.tokens[this.position]}previous(){return this.tokens[this.position-1]}consume(t,r){if(r!==void 0){if(this.check(t,r))return this.advance();throw new Error(`Expected ${t} "${r}" but got ${this.current().type} "${this.current().value}"`)}if(this.check(t))return this.advance();throw new Error(`Expected ${t} but got ${this.current().type} "${this.current().value}"`)}checkIdentifierName(){return this.isAtEnd()?!1:ts.includes(this.current().type)}consumeIdentifierName(){if(!this.checkIdentifierName())throw new Error(`Expected an identifier name but got ${this.current().type} "${this.current().value}"`);let t=this.advance();switch(t.type){case"BOOLEAN":return t.value?"true":"false";case"NULL":return"null";case"UNDEFINED":return"undefined";default:return t.value}}},Yt=class{evaluate({node:t,scope:r={},context:i=null,forceBindingRootScopeToFunctions:n=!0}){switch(t.type){case"Literal":return t.value;case"Identifier":if(t.name in r){let p=r[t.name];return this.checkForDangerousValues(p),typeof p=="function"?p.bind(r):p}throw new Error(`Undefined variable: ${t.name}`);case"MemberExpression":let s=this.evaluate({node:t.object,scope:r,context:i,forceBindingRootScopeToFunctions:n});if(s==null)throw new Error("Cannot read property of null or undefined");let o;t.computed?o=this.evaluate({node:t.property,scope:r,context:i,forceBindingRootScopeToFunctions:n}):o=t.property.name,this.checkForDangerousKeywords(o);let a=s[o];return this.checkForDangerousValues(a),typeof a=="function"?n?a.bind(r):a.bind(s):a;case"CallExpression":let c=t.arguments.map(p=>this.evaluate({node:p,scope:r,context:i,forceBindingRootScopeToFunctions:n})),l;if(t.callee.type==="MemberExpression"){let p=this.evaluate({node:t.callee.object,scope:r,context:i,forceBindingRootScopeToFunctions:n}),y;t.callee.computed?y=this.evaluate({node:t.callee.property,scope:r,context:i,forceBindingRootScopeToFunctions:n}):y=t.callee.property.name,this.checkForDangerousKeywords(y);let U=p[y];if(typeof U!="function")throw new Error("Value is not a function");l=U.apply(p,c)}else if(t.callee.type==="Identifier"){let p=t.callee.name,y;if(p in r)y=r[p];else throw new Error(`Undefined variable: ${p}`);if(typeof y!="function")throw new Error("Value is not a function");let U=i!==null?i:r;l=y.apply(U,c)}else{let p=this.evaluate({node:t.callee,scope:r,context:i,forceBindingRootScopeToFunctions:n});if(typeof p!="function")throw new Error("Value is not a function");l=p.apply(i,c)}return this.checkForDangerousValues(l),l;case"UnaryExpression":let u=this.evaluate({node:t.argument,scope:r,context:i,forceBindingRootScopeToFunctions:n});switch(t.operator){case"!":return!u;case"-":return-u;case"+":return+u;default:throw new Error(`Unknown unary operator: ${t.operator}`)}case"UpdateExpression":if(t.argument.type==="Identifier"){let p=t.argument.name;if(!(p in r))throw new Error(`Undefined variable: ${p}`);let y=r[p];return t.operator==="++"?r[p]=y+1:t.operator==="--"&&(r[p]=y-1),t.prefix?r[p]:y}else if(t.argument.type==="MemberExpression"){let p=this.evaluate({node:t.argument.object,scope:r,context:i,forceBindingRootScopeToFunctions:n}),y=t.argument.computed?this.evaluate({node:t.argument.property,scope:r,context:i,forceBindingRootScopeToFunctions:n}):t.argument.property.name;if(this.isDOMObject(p))throw new Error("Property assignments on DOM objects are prohibited in the CSP build");this.checkForDangerousKeywords(y);let U=p[y];return t.operator==="++"?p[y]=U+1:t.operator==="--"&&(p[y]=U-1),t.prefix?p[y]:U}throw new Error("Invalid update expression target");case"BinaryExpression":let f=this.evaluate({node:t.left,scope:r,context:i,forceBindingRootScopeToFunctions:n}),g=()=>this.evaluate({node:t.right,scope:r,context:i,forceBindingRootScopeToFunctions:n});if(t.operator==="&&")return f&&g();if(t.operator==="||")return f||g();let m=g();switch(t.operator){case"+":return f+m;case"-":return f-m;case"*":return f*m;case"/":return f/m;case"%":return f%m;case"==":return f==m;case"!=":return f!=m;case"===":return f===m;case"!==":return f!==m;case"<":return f<m;case">":return f>m;case"<=":return f<=m;case">=":return f>=m;default:throw new Error(`Unknown binary operator: ${t.operator}`)}case"ConditionalExpression":return this.evaluate({node:t.test,scope:r,context:i,forceBindingRootScopeToFunctions:n})?this.evaluate({node:t.consequent,scope:r,context:i,forceBindingRootScopeToFunctions:n}):this.evaluate({node:t.alternate,scope:r,context:i,forceBindingRootScopeToFunctions:n});case"AssignmentExpression":let d=this.evaluate({node:t.right,scope:r,context:i,forceBindingRootScopeToFunctions:n});if(t.left.type==="Identifier")return r[t.left.name]=d,d;if(t.left.type==="MemberExpression"){let p=this.evaluate({node:t.left.object,scope:r,context:i,forceBindingRootScopeToFunctions:n}),y=t.left.computed?this.evaluate({node:t.left.property,scope:r,context:i,forceBindingRootScopeToFunctions:n}):t.left.property.name;if(this.isDOMObject(p))throw new Error("Property assignments on DOM objects are prohibited in the CSP build");return this.checkForDangerousKeywords(y),p[y]=d,d}throw new Error("Invalid assignment target");case"ArrayExpression":return t.elements.map(p=>this.evaluate({node:p,scope:r,context:i,forceBindingRootScopeToFunctions:n}));case"ObjectExpression":let w={};for(let p of t.properties){let y=p.computed?this.evaluate({node:p.key,scope:r,context:i,forceBindingRootScopeToFunctions:n}):p.key.type==="Identifier"?p.key.name:this.evaluate({node:p.key,scope:r,context:i,forceBindingRootScopeToFunctions:n}),U=this.evaluate({node:p.value,scope:r,context:i,forceBindingRootScopeToFunctions:n});w[y]=U}return w;default:throw new Error(`Unknown node type: ${t.type}`)}}isDOMObject(t){if(t instanceof Node||typeof CSSStyleDeclaration<"u"&&t instanceof CSSStyleDeclaration||typeof DOMStringMap<"u"&&t instanceof DOMStringMap||typeof DOMTokenList<"u"&&t instanceof DOMTokenList||typeof NamedNodeMap<"u"&&t instanceof NamedNodeMap)return!0;let r=t!=null&&t.constructor;return!!(r&&typeof r.name=="string"&&/^(SVG|CSS|DOM)[A-Z]/.test(r.name))}checkForDangerousKeywords(t){if(["constructor","prototype","__proto__","__defineGetter__","__defineSetter__","insertAdjacentHTML","insertAdjacentElement","insertAdjacentText","setAttribute","setAttributeNS","setAttributeNode","setAttributeNodeNS","createElement","createElementNS","createDocumentFragment","createContextualFragment","appendChild","insertBefore","replaceChild"].includes(t))throw new Error(`Accessing "${t}" is prohibited in the CSP build`)}checkForDangerousValues(t){if(t!==null&&!(typeof t!="object"&&typeof t!="function")&&!Ui.has(t)){if(t instanceof HTMLIFrameElement||t instanceof HTMLScriptElement||typeof SVGScriptElement<"u"&&t instanceof SVGScriptElement)throw new Error("Accessing iframes and scripts is prohibited in the CSP build");if(typeof Document<"u"&&t instanceof Document)throw new Error("Accessing document objects is prohibited in the CSP build");if(ji.has(t))throw new Error("Accessing global variables is prohibited in the CSP build");return Ui.set(t,!0),!0}}};function Jt(e){try{let r=new Wt(e).tokenize(),n=new Gt(r).parse(),s=new Yt;return function(o={}){let{scope:a={},context:c=null,forceBindingRootScopeToFunctions:l=!1}=o;return s.evaluate({node:n,scope:a,context:c,forceBindingRootScopeToFunctions:l})}}catch(t){throw new Error(`CSP Parser Error: ${t.message}`)}}function Fi(e,t,r={}){let i=Vi(e),n=P([r.scope??{},...i]),s=r.params??[],a=Jt(t)({scope:n,forceBindingRootScopeToFunctions:!0});return typeof a=="function"&&Z?a.apply(n,s):a}function $i(e,t){let r=Vi(e);if(typeof t=="function")return Wr(r,t);let i=rs(e,t,r);return It.bind(null,e,t,i)}function Vi(e){let t={};return X(t,e),[t,...z(e)]}function rs(e,t,r){if(e instanceof HTMLIFrameElement)throw new Error("Evaluating expressions on an iframe is prohibited in the CSP build");if(e instanceof HTMLScriptElement)throw new Error("Evaluating expressions on a script is prohibited in the CSP build");return(i=()=>{},{scope:n={},params:s=[]}={})=>{let o=P([n,...r]),c=Jt(t)({scope:o,forceBindingRootScopeToFunctions:!0});if(Z&&typeof c=="function"){let l=c.apply(c,s);l instanceof Promise?l.then(u=>i(u)):i(l)}else typeof c=="object"&&c instanceof Promise?c.then(l=>i(l)):i(c)}}function Xt(e){let t=Object.create(null);for(let r of e.split(","))t[r]=1;return r=>r in t}var is=Object.freeze({}),Ka=Object.freeze([]);var xe=Object.assign;var ns=Object.prototype.hasOwnProperty,ct=(e,t)=>ns.call(e,t),Ee=Array.isArray,be=e=>Hi(e)==="[object Map]";var ss=e=>typeof e=="string",ve=e=>typeof e=="symbol",we=e=>e!==null&&typeof e=="object";var os=Object.prototype.toString,Hi=e=>os.call(e),Zt=e=>Hi(e).slice(8,-1);var lt=e=>ss(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var ut=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},as=/-\w/g,qa=ut(e=>e.replace(as,t=>t.slice(1).toUpperCase())),cs=/\B([A-Z])/g,za=ut(e=>e.replace(cs,"-$1").toLowerCase()),Qt=ut(e=>e.charAt(0).toUpperCase()+e.slice(1)),Wa=ut(e=>e?`on${Qt(e)}`:""),Q=(e,t)=>!Object.is(e,t);var ls="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var Ga=Xt(ls+",async,autofocus,autoplay,controls,default,defer,disabled,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");function le(e,...t){console.warn(`[Vue warn] ${e}`,...t)}var er;var x;var tr=new WeakSet,dt=class{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,er&&(er.active?er.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,tr.has(this)&&(tr.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||us(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,Bi(this),Wi(this);let t=x,r=M;x=this,M=!0;try{return this.fn()}finally{x!==this&&le("Active effect was not restored correctly - this is likely a Vue internal bug."),Gi(this),x=t,M=r,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)mr(t);this.deps=this.depsTail=void 0,Bi(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?tr.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){nr(this)&&this.run()}get dirty(){return nr(this)}},zi=0,Ie,Me;function us(e,t=!1){if(e.flags|=8,t){e.next=Me,Me=e;return}e.next=Ie,Ie=e}function dr(){zi++}function hr(){if(--zi>0)return;if(Me){let t=Me;for(Me=void 0;t;){let r=t.next;t.next=void 0,t.flags&=-9,t=r}}let e;for(;Ie;){let t=Ie;for(Ie=void 0;t;){let r=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(i){e||(e=i)}t=r}}if(e)throw e}function Wi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Gi(e){let t,r=e.depsTail,i=r;for(;i;){let n=i.prevDep;i.version===-1?(i===r&&(r=n),mr(i),ps(i)):t=i,i.dep.activeLink=i.prevActiveLink,i.prevActiveLink=void 0,i=n}e.deps=t,e.depsTail=r}function nr(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(fs(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function fs(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===ht)||(e.globalVersion=ht,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!nr(e))))return;e.flags|=2;let t=e.dep,r=x,i=M;x=e,M=!0;try{Wi(e);let n=e.fn(e._value);(t.version===0||Q(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(n){throw t.version++,n}finally{x=r,M=i,Gi(e),e.flags&=-3}}function mr(e,t=!1){let{dep:r,prevSub:i,nextSub:n}=e;if(i&&(i.nextSub=n,e.prevSub=void 0),n&&(n.prevSub=i,e.nextSub=void 0),r.subsHead===e&&(r.subsHead=n),r.subs===e&&(r.subs=i,!i&&r.computed)){r.computed.flags&=-5;for(let s=r.computed.deps;s;s=s.nextDep)mr(s,!0)}!t&&!--r.sc&&r.map&&r.map.delete(r.key)}function ps(e){let{prevDep:t,nextDep:r}=e;t&&(t.nextDep=r,e.prevDep=void 0),r&&(r.prevDep=t,e.nextDep=void 0)}function Yi(e,t){e.effect instanceof dt&&(e=e.effect.fn);let r=new dt(e);t&&xe(r,t);try{r.run()}catch(n){throw r.stop(),n}let i=r.run.bind(r);return i.effect=r,i}function Ji(e){e.effect.stop()}var M=!0,Xi=[];function ds(){Xi.push(M),M=!1}function hs(){let e=Xi.pop();M=e===void 0?!0:e}function Bi(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let r=x;x=void 0;try{t()}finally{x=r}}}var ht=0,sr=class{constructor(t,r){this.sub=t,this.dep=r,this.version=r.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}},or=class{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0,this.subsHead=void 0}track(t){if(!x||!M||x===this.computed)return;let r=this.activeLink;if(r===void 0||r.sub!==x)r=this.activeLink=new sr(x,this),x.deps?(r.prevDep=x.depsTail,x.depsTail.nextDep=r,x.depsTail=r):x.deps=x.depsTail=r,Zi(r);else if(r.version===-1&&(r.version=this.version,r.nextDep)){let i=r.nextDep;i.prevDep=r.prevDep,r.prevDep&&(r.prevDep.nextDep=i),r.prevDep=x.depsTail,r.nextDep=void 0,x.depsTail.nextDep=r,x.depsTail=r,x.deps===r&&(x.deps=i)}return x.onTrack&&x.onTrack(xe({effect:x},t)),r}trigger(t){this.version++,ht++,this.notify(t)}notify(t){dr();try{for(let r=this.subsHead;r;r=r.nextSub)r.sub.onTrigger&&!(r.sub.flags&8)&&r.sub.onTrigger(xe({effect:r.sub},t));for(let r=this.subs;r;r=r.prevSub)r.sub.notify()&&r.sub.dep.notify()}finally{hr()}}};function Zi(e){if(e.dep.sc++,e.sub.flags&4){let t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let i=t.deps;i;i=i.nextDep)Zi(i)}let r=e.dep.subs;r!==e&&(e.prevSub=r,r&&(r.nextSub=e)),e.dep.subsHead===void 0&&(e.dep.subsHead=e),e.dep.subs=e}}var ar=new WeakMap,ce=Symbol("Object iterate"),cr=Symbol("Map keys iterate"),Ue=Symbol("Array iterate");function C(e,t,r){if(M&&x){let i=ar.get(e);i||ar.set(e,i=new Map);let n=i.get(r);n||(i.set(r,n=new or),n.map=i,n.key=r),n.track({target:e,type:t,key:r})}}function ee(e,t,r,i,n,s){let o=ar.get(e);if(!o){ht++;return}let a=c=>{c&&c.trigger({target:e,type:t,key:r,newValue:i,oldValue:n,oldTarget:s})};if(dr(),t==="clear")o.forEach(a);else{let c=Ee(e),l=c&&lt(r);if(c&&r==="length"){let u=Number(i);o.forEach((f,g)=>{(g==="length"||g===Ue||!ve(g)&&g>=u)&&a(f)})}else switch((r!==void 0||o.has(void 0))&&a(o.get(r)),l&&a(o.get(Ue)),t){case"add":c?l&&a(o.get("length")):(a(o.get(ce)),be(e)&&a(o.get(cr)));break;case"delete":c||(a(o.get(ce)),be(e)&&a(o.get(cr)));break;case"set":be(e)&&a(o.get(ce));break}}hr()}function Ae(e){let t=E(e);return t===e?t:(C(t,"iterate",Ue),fe(e)?t:t.map(pe))}function _r(e){return C(e=E(e),"iterate",Ue),e}function B(e,t){return ue(e)?sn(e)?je(pe(t)):je(t):pe(t)}var ms={__proto__:null,[Symbol.iterator](){return rr(this,Symbol.iterator,e=>B(this,e))},concat(...e){return Ae(this).concat(...e.map(t=>Ee(t)?Ae(t):t))},entries(){return rr(this,"entries",e=>(e[1]=B(this,e[1]),e))},every(e,t){return Y(this,"every",e,t,void 0,arguments)},filter(e,t){return Y(this,"filter",e,t,r=>r.map(i=>B(this,i)),arguments)},find(e,t){return Y(this,"find",e,t,r=>B(this,r),arguments)},findIndex(e,t){return Y(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Y(this,"findLast",e,t,r=>B(this,r),arguments)},findLastIndex(e,t){return Y(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Y(this,"forEach",e,t,void 0,arguments)},includes(...e){return ir(this,"includes",e)},indexOf(...e){return ir(this,"indexOf",e)},join(e){return Ae(this).join(e)},lastIndexOf(...e){return ir(this,"lastIndexOf",e)},map(e,t){return Y(this,"map",e,t,void 0,arguments)},pop(){return Pe(this,"pop")},push(...e){return Pe(this,"push",e)},reduce(e,...t){return Ki(this,"reduce",e,t)},reduceRight(e,...t){return Ki(this,"reduceRight",e,t)},shift(){return Pe(this,"shift")},some(e,t){return Y(this,"some",e,t,void 0,arguments)},splice(...e){return Pe(this,"splice",e)},toReversed(){return Ae(this).toReversed()},toSorted(e){return Ae(this).toSorted(e)},toSpliced(...e){return Ae(this).toSpliced(...e)},unshift(...e){return Pe(this,"unshift",e)},values(){return rr(this,"values",e=>B(this,e))}};function rr(e,t,r){let i=_r(e),n=i[t]();return i!==e&&!fe(e)&&(n._next=n.next,n.next=()=>{let s=n._next();return s.done||(s.value=r(s.value)),s}),n}var _s=Array.prototype;function Y(e,t,r,i,n,s){let o=_r(e),a=o!==e&&!fe(e),c=o[t];if(c!==_s[t]){let f=c.apply(e,s);return a?pe(f):f}let l=r;o!==e&&(a?l=function(f,g){return r.call(this,B(e,f),g,e)}:r.length>2&&(l=function(f,g){return r.call(this,f,g,e)}));let u=c.call(o,l,i);return a&&n?n(u):u}function Ki(e,t,r,i){let n=_r(e),s=n!==e&&!fe(e),o=r,a=!1;n!==e&&(s?(a=i.length===0,o=function(l,u,f){return a&&(a=!1,l=B(e,l)),r.call(this,l,B(e,u),f,e)}):r.length>3&&(o=function(l,u,f){return r.call(this,l,u,f,e)}));let c=n[t](o,...i);return a?B(e,c):c}function ir(e,t,r){let i=E(e);C(i,"iterate",Ue);let n=i[t](...r);return(n===-1||n===!1)&&Ts(r[0])?(r[0]=E(r[0]),i[t](...r)):n}function Pe(e,t,r=[]){ds(),dr();let i=E(e)[t].apply(e,r);return hr(),hs(),i}var gs=Xt("__proto__,__v_isRef,__isVue"),Qi=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(ve));function ys(e){ve(e)||(e=String(e));let t=E(this);return C(t,"has",e),t.hasOwnProperty(e)}var mt=class{constructor(t=!1,r=!1){this._isReadonly=t,this._isShallow=r}get(t,r,i){if(r==="__v_skip")return t.__v_skip;let n=this._isReadonly,s=this._isShallow;if(r==="__v_isReactive")return!n;if(r==="__v_isReadonly")return n;if(r==="__v_isShallow")return s;if(r==="__v_raw")return i===(n?s?Ss:rn:s?Os:tn).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(i)?t:void 0;let o=Ee(t);if(!n){let c;if(o&&(c=ms[r]))return c;if(r==="hasOwnProperty")return ys}let a=Reflect.get(t,r,Le(t)?t:i);if((ve(r)?Qi.has(r):gs(r))||(n||C(t,"get",r),s))return a;if(Le(a)){let c=o&&lt(r)?a:a.value;return n&&we(c)?pr(c):c}return we(a)?n?pr(a):_t(a):a}},lr=class extends mt{constructor(t=!1){super(!1,t)}set(t,r,i,n){let s=t[r],o=Ee(t)&&lt(r);if(!this._isShallow){let l=ue(s);if(!fe(i)&&!ue(i)&&(s=E(s),i=E(i)),!o&&Le(s)&&!Le(i))return l?(le(`Set operation on key "${String(r)}" failed: target is readonly.`,t[r]),!0):(s.value=i,!0)}let a=o?Number(r)<t.length:ct(t,r),c=Reflect.set(t,r,i,Le(t)?t:n);return t===E(n)&&c&&(a?Q(i,s)&&ee(t,"set",r,i,s):ee(t,"add",r,i)),c}deleteProperty(t,r){let i=ct(t,r),n=t[r],s=Reflect.deleteProperty(t,r);return s&&i&&ee(t,"delete",r,void 0,n),s}has(t,r){let i=Reflect.has(t,r);return(!ve(r)||!Qi.has(r))&&C(t,"has",r),i}ownKeys(t){return C(t,"iterate",Ee(t)?"length":ce),Reflect.ownKeys(t)}},ur=class extends mt{constructor(t=!1){super(!0,t)}set(t,r){return le(`Set operation on key "${String(r)}" failed: target is readonly.`,t),!0}deleteProperty(t,r){return le(`Delete operation on key "${String(r)}" failed: target is readonly.`,t),!0}},xs=new lr,Es=new ur;var fr=e=>e,ft=e=>Reflect.getPrototypeOf(e);function bs(e,t,r){return function(...i){let n=this.__v_raw,s=E(n),o=be(s),a=e==="entries"||e===Symbol.iterator&&o,c=e==="keys"&&o,l=n[e](...i),u=r?fr:t?je:pe;return!t&&C(s,"iterate",c?cr:ce),xe(Object.create(l),{next(){let{value:f,done:g}=l.next();return g?{value:f,done:g}:{value:a?[u(f[0]),u(f[1])]:u(f),done:g}}})}}function pt(e){return function(...t){{let r=t[0]?`on key "${t[0]}" `:"";le(`${Qt(e)} operation ${r}failed: target is readonly.`,E(this))}return e==="delete"?!1:e==="clear"?void 0:this}}function vs(e,t){let r={get(n){let s=this.__v_raw,o=E(s),a=E(n);e||(Q(n,a)&&C(o,"get",n),C(o,"get",a));let{has:c}=ft(o),l=t?fr:e?je:pe;if(c.call(o,n))return l(s.get(n));if(c.call(o,a))return l(s.get(a));s!==o&&s.get(n)},get size(){let n=this.__v_raw;return!e&&C(E(n),"iterate",ce),n.size},has(n){let s=this.__v_raw,o=E(s),a=E(n);return e||(Q(n,a)&&C(o,"has",n),C(o,"has",a)),n===a?s.has(n):s.has(n)||s.has(a)},forEach(n,s){let o=this,a=o.__v_raw,c=E(a),l=t?fr:e?je:pe;return!e&&C(c,"iterate",ce),a.forEach((u,f)=>n.call(s,l(u),l(f),o))}};return xe(r,e?{add:pt("add"),set:pt("set"),delete:pt("delete"),clear:pt("clear")}:{add(n){let s=E(this),o=ft(s),a=E(n),c=!t&&!fe(n)&&!ue(n)?a:n;return o.has.call(s,c)||Q(n,c)&&o.has.call(s,n)||Q(a,c)&&o.has.call(s,a)||(s.add(c),ee(s,"add",c,c)),this},set(n,s){!t&&!fe(s)&&!ue(s)&&(s=E(s));let o=E(this),{has:a,get:c}=ft(o),l=a.call(o,n);l?qi(o,a,n):(n=E(n),l=a.call(o,n));let u=c.call(o,n);return o.set(n,s),l?Q(s,u)&&ee(o,"set",n,s,u):ee(o,"add",n,s),this},delete(n){let s=E(this),{has:o,get:a}=ft(s),c=o.call(s,n);c?qi(s,o,n):(n=E(n),c=o.call(s,n));let l=a?a.call(s,n):void 0,u=s.delete(n);return c&&ee(s,"delete",n,void 0,l),u},clear(){let n=E(this),s=n.size!==0,o=be(n)?new Map(n):new Set(n),a=n.clear();return s&&ee(n,"clear",void 0,void 0,o),a}}),["keys","values","entries",Symbol.iterator].forEach(n=>{r[n]=bs(n,e,t)}),r}function en(e,t){let r=vs(e,t);return(i,n,s)=>n==="__v_isReactive"?!e:n==="__v_isReadonly"?e:n==="__v_raw"?i:Reflect.get(ct(r,n)&&n in i?r:i,n,s)}var ws={get:en(!1,!1)};var As={get:en(!0,!1)};function qi(e,t,r){let i=E(r);if(i!==r&&t.call(e,i)){let n=Zt(e);le(`Reactive ${n} contains both the raw and reactive versions of the same object${n==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}var tn=new WeakMap,Os=new WeakMap,rn=new WeakMap,Ss=new WeakMap;function Ns(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function _t(e){return ue(e)?e:nn(e,!1,xs,ws,tn)}function pr(e){return nn(e,!0,Es,As,rn)}function nn(e,t,r,i,n){if(!we(e))return le(`value cannot be made ${t?"readonly":"reactive"}: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;let s=n.get(e);if(s)return s;let o=Ns(Zt(e));if(o===0)return e;let a=new Proxy(e,o===2?i:r);return n.set(e,a),a}function sn(e){return ue(e)?sn(e.__v_raw):!!(e&&e.__v_isReactive)}function ue(e){return!!(e&&e.__v_isReadonly)}function fe(e){return!!(e&&e.__v_isShallow)}function Ts(e){return e?!!e.__v_raw:!1}function E(e){let t=e&&e.__v_raw;return t?E(t):e}var pe=e=>we(e)?_t(e):e,je=e=>we(e)?pr(e):e;function Le(e){return e?e.__v_isRef===!0:!1}v("nextTick",()=>ye);v("dispatch",e=>se.bind(se,e));v("watch",(e,{evaluateLater:t,cleanup:r})=>(i,n)=>{let s=t(i),a=$e(()=>{let c;return s(l=>c=l),c},n);r(a)});v("store",Ri);v("data",e=>ze(e));v("root",e=>oe(e));v("refs",e=>(e._x_refs_proxy||(e._x_refs_proxy=P(Cs(e))),e._x_refs_proxy));function Cs(e){let t=[];return N(e,r=>{r._x_refs&&t.push(r._x_refs)}),t}var gr={};function yr(e){return gr[e]||(gr[e]=0),++gr[e]}function on(e,t){return N(e,r=>{if(r._x_ids&&r._x_ids[t])return!0})}function an(e,t){e._x_ids||(e._x_ids={}),e._x_ids[t]||(e._x_ids[t]=yr(t))}v("id",(e,{cleanup:t})=>(r,i=null)=>{let n=`${r}${i?`-${i}`:""}`;return Rs(e,n,t,()=>{let s=on(e,r),o=s?s._x_ids[r]:yr(r);return i?`${r}-${o}-${i}`:`${r}-${o}`})});H((e,t)=>{e._x_id&&(t._x_id=e._x_id)});function Rs(e,t,r,i){if(e._x_id||(e._x_id={}),e._x_id[t])return e._x_id[t];let n=i();return e._x_id[t]=n,r(()=>{delete e._x_id[t]}),n}v("el",e=>e);cn("Focus","focus","focus");cn("Persist","persist","persist");function cn(e,t,r){v(t,i=>T(`You can't use [$${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,i))}h("modelable",(e,{expression:t},{effect:r,evaluateLater:i,cleanup:n})=>{let s=i(t),o=()=>{let u;return s(f=>u=f),u},a=i(`${t} = __placeholder`),c=u=>a(()=>{},{scope:{__placeholder:u}}),l=o();c(l),queueMicrotask(()=>{if(!e._x_model)return;e._x_removeModelListeners.default();let u=e._x_model.get,f=e._x_model.setWithModifiers,g=at({get(){return u()},set(m){f(m)}},{get(){return o()},set(m){c(m)}});n(g)})});h("teleport",(e,{modifiers:t,expression:r},{cleanup:i})=>{e.tagName.toLowerCase()!=="template"&&T("x-teleport can only be used on a <template> tag",e);let n=ln(r),s=e.content.cloneNode(!0).firstElementChild;e._x_teleport=s,s._x_teleportBack=e,e.setAttribute("data-teleport-template",!0),s.setAttribute("data-teleport-target",!0),e._x_forwardEvents&&e._x_forwardEvents.forEach(a=>{s.addEventListener(a,c=>{c.stopPropagation(),e.dispatchEvent(new c.constructor(c.type,c))})}),F(s,{},e);let o=(a,c,l)=>{l.includes("prepend")?c.parentNode.insertBefore(a,c):l.includes("append")?c.parentNode.insertBefore(a,c.nextSibling):c.appendChild(a)};_(()=>{O(()=>{o(s,n,t),S(s)})()}),e._x_teleportPutBack=()=>{let a=ln(r);_(()=>{o(e._x_teleport,a,t)})},i(()=>_(()=>{s.remove(),k(s)}))});var ks=document.createElement("div");function ln(e){let t=O(()=>document.querySelector(e),()=>ks)();return t||T(`Cannot find x-teleport element for selector: "${e}"`),t}var un=()=>{};un.inline=(e,{modifiers:t},{cleanup:r})=>{t.includes("self")?e._x_ignoreSelf=!0:e._x_ignore=!0,r(()=>{t.includes("self")?delete e._x_ignoreSelf:delete e._x_ignore})};h("ignore",un);h("effect",O((e,{expression:t},{effect:r})=>{r(b(e,t))}));function J(e,t,r,i){let n=e,s=c=>i(c),o={},a=(c,l)=>u=>l(c,u);return r.includes("dot")&&(t=Ds(t)),r.includes("camel")&&(t=Ps(t)),r.includes("capture")&&(o.capture=!0),r.includes("window")&&(n=window),r.includes("document")&&(n=document),r.includes("passive")&&(o.passive=r[r.indexOf("passive")+1]!=="false"),s=xr(r,s),r.includes("prevent")&&(s=a(s,(c,l)=>{l.preventDefault(),c(l)})),r.includes("stop")&&(s=a(s,(c,l)=>{l.stopPropagation(),c(l)})),r.includes("once")&&(s=a(s,(c,l)=>{c(l),n.removeEventListener(t,s,o)})),(r.includes("away")||r.includes("outside"))&&(n=document,s=a(s,(c,l)=>{e.contains(l.target)||l.target.isConnected!==!1&&(e.offsetWidth<1&&e.offsetHeight<1||e._x_isShown!==!1&&c(l))})),r.includes("self")&&(s=a(s,(c,l)=>{l.target===e&&c(l)})),t==="submit"&&(s=a(s,(c,l)=>{l.target._x_pendingModelUpdates&&l.target._x_pendingModelUpdates.forEach(u=>u()),c(l)})),(Ms(t)||pn(t))&&(s=a(s,(c,l)=>{Us(l,r)||c(l)})),n.addEventListener(t,s,o),()=>{n.removeEventListener(t,s,o)}}function xr(e,t){if(e.includes("debounce")){let r=e[e.indexOf("debounce")+1]||"invalid-wait",i=gt(r.split("ms")[0])?Number(r.split("ms")[0]):250;t=st(t,i)}if(e.includes("throttle")){let r=e[e.indexOf("throttle")+1]||"invalid-wait",i=gt(r.split("ms")[0])?Number(r.split("ms")[0]):250;t=ot(t,i)}return t}function Ds(e){return e.replace(/-/g,".")}function Ps(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function gt(e){return!Array.isArray(e)&&!isNaN(e)}function Is(e){return[" ","_"].includes(e)?e:e.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/[_\s]/,"-").toLowerCase()}function Ms(e){return["keydown","keyup"].includes(e)}function pn(e){return["contextmenu","click","mouse"].some(t=>e.includes(t))}var Ls=["window","document","prevent","stop","once","capture","self","away","outside","passive","dot","camel","preserve-scroll","blur","change","lazy","number","boolean","trim","fill","unintrusive","parent"];function Us(e,t){let r=t.filter((s,o)=>s==="false"&&t[o-1]==="passive"?!1:!Ls.includes(s));if(r.includes("debounce")){let s=r.indexOf("debounce");r.splice(s,gt((r[s+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.includes("throttle")){let s=r.indexOf("throttle");r.splice(s,gt((r[s+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.length===0||r.length===1&&fn(e.key).includes(r[0]))return!1;let n=["ctrl","shift","alt","meta","cmd","super"].filter(s=>r.includes(s));return r=r.filter(s=>!n.includes(s)),!(n.length>0&&n.filter(o=>((o==="cmd"||o==="super")&&(o="meta"),e[`${o}Key`])).length===n.length&&(pn(e.type)||fn(e.key).includes(r[0])))}function fn(e){if(!e)return[];e=Is(e);let t={ctrl:"control",slash:"/",space:" ",spacebar:" ",cmd:"meta",esc:"escape",up:"arrow-up",down:"arrow-down",left:"arrow-left",right:"arrow-right",period:".",comma:",",equal:"=",minus:"-",underscore:"_"};return t[e]=e,Object.keys(t).map(r=>{if(t[r]===e)return r}).filter(r=>r)}h("model",(e,{modifiers:t,expression:r},{effect:i,cleanup:n})=>{let s=e;t.includes("parent")&&(s=N(e,d=>d!==e));let o=b(s,r),a;typeof r=="string"?a=b(s,`${r} = __placeholder`):typeof r=="function"&&typeof r()=="string"?a=b(s,`${r()} = __placeholder`):a=()=>{};let c=()=>{let d;return o(w=>d=w),dn(d)?d.get():d},l=d=>{let w;o(p=>w=p),dn(w)?w.set(d):a(()=>{},{scope:{__placeholder:d}})};typeof r=="string"&&e.type==="radio"&&_(()=>{e.hasAttribute("name")||e.setAttribute("name",r)});let u=t.includes("change")||t.includes("lazy"),f=t.includes("blur"),g=t.includes("enter"),m=u||f||g,L;if(V)L=()=>{};else if(m){let d=[],w=p=>l(yt(e,t,p,c()));if(u&&d.push(J(e,"change",t,w)),f&&(d.push(J(e,"blur",t,w)),e.form)){let p=e.form,y=()=>w({target:e});p._x_pendingModelUpdates||(p._x_pendingModelUpdates=[]),p._x_pendingModelUpdates.push(y),n(()=>{p._x_pendingModelUpdates&&p._x_pendingModelUpdates.splice(p._x_pendingModelUpdates.indexOf(y),1)})}g&&d.push(J(e,"keydown",t,p=>{p.key==="Enter"&&w(p)})),L=()=>d.forEach(p=>p())}else{let d=e.tagName.toLowerCase()==="select"||["checkbox","radio"].includes(e.type)?"change":"input";L=J(e,d,t,w=>{l(yt(e,t,w,c()))})}if(t.includes("fill")&&([void 0,null,""].includes(c())||De(e)&&Array.isArray(c())||e.tagName.toLowerCase()==="select"&&e.multiple)&&l(yt(e,t,{target:e},c())),e._x_removeModelListeners||(e._x_removeModelListeners={}),e._x_removeModelListeners.default=L,n(()=>e._x_removeModelListeners.default()),e.form){let d=J(e.form,"reset",[],w=>{ye(()=>e._x_model&&e._x_model.set(yt(e,t,{target:e},c())))});n(()=>d())}if(e._x_model={get(){return c()},set(d){l(d)},setWithModifiers:xr(t,l)},e._x_forceModelUpdate=d=>{d===void 0&&typeof r=="string"&&r.match(/\./)&&(d=""),_(()=>{De(e)?Array.isArray(d)?e.checked=d.some(w=>w==e.value):e.checked=!!d:nt(e)?typeof d=="boolean"?e.checked=ke(e.value)===d:e.checked=e.value==d:Re(e,"value",d)})},e.tagName==="SELECT"){let d=new MutationObserver(()=>{e._x_forceModelUpdate(c())});d.observe(e,{childList:!0}),n(()=>d.disconnect())}i(()=>{let d=c();t.includes("unintrusive")&&document.activeElement.isSameNode(e)||e._x_forceModelUpdate(d)})});function yt(e,t,r,i){return _(()=>{if(r instanceof CustomEvent&&r.detail!==void 0)return r.detail!==null&&r.detail!==void 0?r.detail:r.target.value;if(De(e))if(Array.isArray(i)){let n=null;return t.includes("number")?n=Er(r.target.value):t.includes("boolean")?n=ke(r.target.value):n=r.target.value,r.target.checked?i.includes(n)?i:i.concat([n]):i.filter(s=>!js(s,n))}else return r.target.checked;else{if(e.tagName.toLowerCase()==="select"&&e.multiple)return t.includes("number")?Array.from(r.target.selectedOptions).map(n=>{let s=n.value||n.text;return Er(s)}):t.includes("boolean")?Array.from(r.target.selectedOptions).map(n=>{let s=n.value||n.text;return ke(s)}):Array.from(r.target.selectedOptions).map(n=>n.value||n.text);{let n;return nt(e)?r.target.checked?n=r.target.value:n=i:n=r.target.value,t.includes("number")?Er(n):t.includes("boolean")?ke(n):t.includes("trim")?n.trim():n}}})}function Er(e){let t=e?parseFloat(e):null;return Fs(t)?t:e}function js(e,t){return e==t}function Fs(e){return!Array.isArray(e)&&!isNaN(e)}function dn(e){return e!==null&&typeof e=="object"&&typeof e.get=="function"&&typeof e.set=="function"}h("cloak",e=>queueMicrotask(()=>_(()=>e.removeAttribute(R("cloak")))));tt(()=>`[${R("init")}]`);h("init",O((e,{expression:t},{evaluate:r})=>typeof t=="string"?!!t.trim()&&r(t,{},!1):r(t,{},!1)));h("text",(e,{expression:t},{effect:r,evaluateLater:i})=>{let n=i(t);r(()=>{n(s=>{_(()=>{e.textContent=s})})})});h("html",(e,{expression:t},{effect:r,evaluateLater:i})=>{let n=i(t);r(()=>{n(s=>{_(()=>{Array.from(e.children).forEach(o=>k(o)),e.innerHTML=s??"",e._x_ignoreSelf=!0,S(e),delete e._x_ignoreSelf})})},{priority:"structural"})});ge(Je(":",Xe(R("bind:"))));var hn=(e,{value:t,modifiers:r,expression:i,original:n},{effect:s,cleanup:o})=>{if(!t){let c={};Pi(c),b(e,i)(u=>{zt(e,u,n)},{scope:c});return}if(t==="key")return $s(e,i);if(e._x_inlineBindings&&e._x_inlineBindings[t]&&e._x_inlineBindings[t].extract)return;let a=b(e,i);s(()=>a(c=>{c===void 0&&typeof i=="string"&&i.match(/\./)&&(c=""),_(()=>Re(e,t,c,r))})),o(()=>{e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedStyles&&e._x_undoAddedStyles()})};hn.inline=(e,{value:t,modifiers:r,expression:i})=>{t&&(e._x_inlineBindings||(e._x_inlineBindings={}),e._x_inlineBindings[t]={expression:i,extract:!1})};h("bind",hn);function $s(e,t){e._x_keyExpression=t}et(()=>`[${R("data")}]`);var de=Symbol();h("data",(e,{expression:t},{cleanup:r})=>{if(Hs(e))return;let i=e[de];if(i?.expression===t)return;t=t===""?"{}":t;let n={};X(n,e);let s={};Li(s,n);let o=I(e,t,{scope:s});(o===void 0||o===!0)&&(o={}),X(o,e);let a;if(i?.reactiveData){a=i.reactiveData,Vs(a,o);let l={expression:t};e[de]=l,queueMicrotask(()=>{e[de]===l&&delete e[de]})}else a=D(o);_e(a,r);let c=F(e,a);O(()=>{a.init&&I(e,a.init)})(),r(()=>{a.destroy&&I(e,a.destroy),c();let l={reactiveData:a};e[de]=l,queueMicrotask(()=>{e[de]===l&&delete e[de]})})});function Vs(e,t){Object.keys(t).forEach(r=>{let i=Object.getOwnPropertyDescriptor(t,r),n=Object.getOwnPropertyDescriptor(e,r);i.get||i.set||n?.get||n?.set?(n&&delete e[r],n||(e[r]=void 0),i.get||i.set?Object.defineProperty(e,r,i):e[r]=t[r]):e[r]=t[r]}),Object.keys(e).filter(r=>!Object.prototype.hasOwnProperty.call(t,r)).forEach(r=>delete e[r])}H((e,t)=>{e._x_dataStack&&(t._x_dataStack=e._x_dataStack,t.setAttribute("data-has-alpine-state",!0))});function Hs(e){return V?Ze?!0:e.hasAttribute("data-has-alpine-state"):!1}h("show",(e,{modifiers:t,expression:r},{effect:i})=>{let n=b(e,r);e._x_doHide||(e._x_doHide=()=>{_(()=>{e.style.setProperty("display","none",t.includes("important")?"important":void 0)})}),e._x_doShow||(e._x_doShow=()=>{_(()=>{e.style.length===1&&e.style.display==="none"?e.removeAttribute("style"):e.style.removeProperty("display")})});let s=()=>{e._x_doHide(),e._x_isShown=!1},o=()=>{e._x_doShow(),e._x_isShown=!0},a=()=>setTimeout(o),c=Te(f=>f?o():s(),f=>{typeof e._x_toggleAndCascadeWithTransitions=="function"?e._x_toggleAndCascadeWithTransitions(e,f,o,s):f?a():s()}),l,u=!0;i(()=>n(f=>{!u&&f===l||(t.includes("immediate")&&(f?a():s()),c(f),l=f,u=!1)}))});h("for",O((e,{expression:t},{effect:r,cleanup:i})=>{let n=qs(t),s=b(e,n.items),o=b(e,e._x_keyExpression||"index");e._x_lookup=new Map,r(()=>Ks(e,n,s,o),{priority:"structural"}),i(()=>{e._x_lookup.forEach(a=>_(()=>{k(a),a.remove()})),delete e._x_lookup,delete e._x_lastRenderedEl})}));function Bs(e){return t=>{Object.entries(t).forEach(([r,i])=>{e[r]=i})}}function Ks(e,t,r,i){r(n=>{Ws(n)&&(n=Array.from({length:n},(l,u)=>u+1)),n==null&&(n=[]),n instanceof Set&&(n=Array.from(n)),n instanceof Map&&(n=Array.from(n));let s=e._x_lookup,o=new Map;e._x_lookup=o;let a=Gs(n),c=Object.entries(n).map(([l,u])=>{a||(l=parseInt(l));let f=zs(t,u,l,n),g;return i(m=>{typeof m=="object"&&T("x-for key cannot be an object, it must be a string or an integer",e),s.has(m)&&(o.set(m,s.get(m)),s.delete(m)),g=m},{scope:{index:l,...f}}),[g,f]});_(()=>{s.forEach(f=>{k(f),f.remove()});let l=new Set,u=e;c.forEach(([f,g])=>{if(o.has(f)){let d=o.get(f);d._x_refreshXForScope(g),u.nextElementSibling!==d&&(u.nextElementSibling&&d.replaceWith(u.nextElementSibling),u.after(d)),u=d,d._x_currentIfEl&&(d.nextElementSibling!==d._x_currentIfEl&&u.after(d._x_currentIfEl),u=d._x_currentIfEl);return}e.content.children.length>1&&T("x-for templates require a single root element, additional elements will be ignored.",e);let m=document.importNode(e.content,!0).firstElementChild,L=D(g);F(m,L,e),m._x_refreshXForScope=Bs(L),o.set(f,m),l.add(m),u.after(m),u=m}),l.forEach(f=>S(f)),u!==e?e._x_lastRenderedEl=u:delete e._x_lastRenderedEl})})}function qs(e){let t=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,r=/^\s*\(|\)\s*$/g,i=/([\s\S]*?)\b(?:in|of)\b([\s\S]*)/,n=e.match(i);if(!n)return;let s={};s.items=n[2].trim();let o=n[1].replace(r,"").trim(),a=o.match(t);return a?(s.item=o.replace(t,"").trim(),s.index=a[1].trim(),a[2]&&(s.collection=a[2].trim())):s.item=o,s}function zs(e,t,r,i){let n={};return/^\[.*\]$/.test(e.item)&&Array.isArray(t)?e.item.replace("[","").replace("]","").split(",").map(o=>o.trim()).forEach((o,a)=>{n[o]=t[a]}):/^\{.*\}$/.test(e.item)&&!Array.isArray(t)&&typeof t=="object"?e.item.replace("{","").replace("}","").split(",").map(o=>o.trim()).forEach(o=>{n[o]=t[o]}):n[e.item]=t,e.index&&(n[e.index]=r),e.collection&&(n[e.collection]=i),n}function Ws(e){return typeof e!="object"&&!isNaN(e)}function Gs(e){return typeof e=="object"&&!Array.isArray(e)}function mn(){}mn.inline=(e,{expression:t},{cleanup:r})=>{let i=oe(e);i&&(i._x_refs||(i._x_refs={}),i._x_refs[t]=e,r(()=>delete i._x_refs[t]))};h("ref",mn);h("if",O((e,{expression:t},{effect:r,cleanup:i})=>{e.tagName.toLowerCase()!=="template"&&T("x-if can only be used on a <template> tag",e);let n=b(e,t),s=()=>{if(e._x_currentIfEl)return e._x_currentIfEl;let a=e.content.cloneNode(!0).firstElementChild;return F(a,{},e),_(()=>{e.after(a),S(a)}),e._x_currentIfEl=a,e._x_lastRenderedEl=a,e._x_undoIf=()=>{_(()=>{k(a),a.remove()}),delete e._x_currentIfEl,delete e._x_lastRenderedEl},a},o=()=>{e._x_undoIf&&(e._x_undoIf(),delete e._x_undoIf)};r(()=>n(a=>{a?s():o()}),{priority:"structural"}),i(()=>e._x_undoIf&&e._x_undoIf())}));h("id",(e,{expression:t},{evaluate:r})=>{r(t).forEach(n=>an(e,n))});H((e,t)=>{e._x_ids&&(t._x_ids=e._x_ids)});ge(Je("@",Xe(R("on:"))));h("on",O((e,{value:t,modifiers:r,expression:i},{cleanup:n})=>{let s=i?b(e,i):()=>{};e.tagName.toLowerCase()==="template"&&(e._x_forwardEvents||(e._x_forwardEvents=[]),e._x_forwardEvents.includes(t)||e._x_forwardEvents.push(t));let o=J(e,t,r,a=>{s(()=>{},{scope:{$event:a},params:[a]})});n(()=>o())}));xt("Collapse","collapse","collapse");xt("Intersect","intersect","intersect");xt("Focus","trap","focus");xt("Mask","mask","mask");function xt(e,t,r){h(t,i=>T(`You can't use [x-${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,i))}h("html",(e,{expression:t})=>{re(new Error("Using the x-html directive is prohibited in the CSP build"),e)});W.setEvaluator($i);W.setRawEvaluator(Fi);W.setReactivityEngine({reactive:_t,effect:(e,t={})=>{let r;return r=Yi(e,{scheduler:()=>{r&&(t.scheduler?t.scheduler(r):r())}}),r},release:Ji,raw:E});var br=W;window.Alpine=br;queueMicrotask(()=>{br.start()});})();
8 +/*! Bundled license information:
9 +
10 +@vue/shared/dist/shared.esm-bundler.js:
11 + (**
12 + * @vue/shared v3.5.41
13 + * (c) 2018-present Yuxi (Evan) You and Vue contributors
14 + * @license MIT
15 + **)
16 +
17 +@vue/reactivity/dist/reactivity.esm-bundler.js:
18 + (**
19 + * @vue/reactivity v3.5.41
20 + * (c) 2018-present Yuxi (Evan) You and Vue contributors
21 + * @license MIT
22 + **)
23 +*/
D static/js/alpine.min.js
+0 -5
@@ -1,5 +0,0 @@
1 -(()=>{var rt=!1,nt=!1,q=[],it=-1;function Vt(e){Sn(e)}function Sn(e){q.includes(e)||q.push(e),An()}function ve(e){let t=q.indexOf(e);t!==-1&&t>it&&q.splice(t,1)}function An(){!nt&&!rt&&(rt=!0,queueMicrotask(On))}function On(){rt=!1,nt=!0;for(let e=0;e<q.length;e++)q[e](),it=e;q.length=0,it=-1,nt=!1}var T,N,L,st,ot=!0;function qt(e){ot=!1,e(),ot=!0}function Ut(e){T=e.reactive,L=e.release,N=t=>e.effect(t,{scheduler:r=>{ot?Vt(r):r()}}),st=e.raw}function at(e){N=e}function Wt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function Se(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}function U(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function O(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>O(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)O(n,t,!1),n=n.nextElementSibling}function v(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Gt=!1;function Jt(){Gt&&v("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Gt=!0,document.body||v("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),U(document,"alpine:init"),U(document,"alpine:initializing"),le(),rr(t=>S(t,O)),ee(t=>ce(t)),Ce((t,r)=>{ue(t,r).forEach(n=>n())});let e=t=>!W(t.parentElement,!0);Array.from(document.querySelectorAll(Zt().join(","))).filter(e).forEach(t=>{S(t)}),U(document,"alpine:initialized")}var ct=[],Yt=[];function Xt(){return ct.map(e=>e())}function Zt(){return ct.concat(Yt).map(e=>e())}function Ae(e){ct.push(e)}function Oe(e){Yt.push(e)}function W(e,t=!1){return Q(e,r=>{if((t?Zt():Xt()).some(i=>r.matches(i)))return!0})}function Q(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return Q(e.parentElement,t)}}function Qt(e){return Xt().some(t=>e.matches(t))}var er=[];function tr(e){er.push(e)}function S(e,t=O,r=()=>{}){ir(()=>{t(e,(n,i)=>{r(n,i),er.forEach(o=>o(n,i)),ue(n,n.attributes).forEach(o=>o()),n._x_ignore&&i()})})}function ce(e){O(e,t=>{lt(t),nr(t)})}var or=[],sr=[],ar=[];function rr(e){ar.push(e)}function ee(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,sr.push(t))}function Ce(e){or.push(e)}function Re(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function nr(e){if(e._x_cleanups)for(;e._x_cleanups.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function le(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){Cn(),ut.disconnect(),ft=!1}var fe=[];function Cn(){let e=ut.takeRecords();fe.push(()=>e.length>0&&mt(e));let t=fe.length;queueMicrotask(()=>{if(fe.length===t)for(;fe.length>0;)fe.shift()()})}function h(e){if(!ft)return e();dt();let t=e();return le(),t}var pt=!1,Te=[];function cr(){pt=!0}function lr(){pt=!1,mt(Te),Te=[]}function mt(e){if(pt){Te=Te.concat(e);return}let t=new Set,r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].addedNodes.forEach(s=>s.nodeType===1&&t.add(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.add(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{or.forEach(a=>a(s,o))});for(let o of r)t.has(o)||(sr.forEach(s=>s(o)),ce(o));t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.has(o)||o.isConnected&&(delete o._x_ignoreSelf,delete o._x_ignore,ar.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function Me(e){return F(j(e))}function P(e,t,r){return e._x_dataStack=[t,...j(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function j(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?j(e.host):e.parentNode?j(e.parentNode):[]}function F(e){return new Proxy({objects:e},Tn)}var Tn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t))},get({objects:e},t,r){return t=="toJSON"?Rn:Reflect.get(e.find(n=>Object.prototype.hasOwnProperty.call(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?Reflect.set(i,t,r,n):Reflect.set(i,t,r)}};function Rn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Ne(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Pe(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>Mn(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function Mn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ur={};function y(e,t){ur[e]=t}function de(e,t){return Object.entries(ur).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=_t(t);return i={interceptor:Pe,...s},ee(t,a),i}}Object.defineProperty(e,`$${r}`,{get(){return n(t,o())},enumerable:!1})}),e}function fr(e,t,r,...n){try{return r(...n)}catch(i){te(i,e,t)}}function te(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
2 -
3 -${r?'Expression: "'+r+`"
4 -
5 -`:""}`,t),setTimeout(()=>{throw e},0)}var Ie=!0;function ke(e){let t=Ie;Ie=!1;let r=e();return Ie=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return dr(...e)}var dr=xt;function pr(e){dr=e}function xt(e,t){let r={};de(r,e);let n=[r,...j(e)],i=typeof t=="function"?Nn(n,t):In(n,t,e);return fr.bind(null,e,t,i)}function Nn(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);De(r,o)}}var gt={};function Pn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return te(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function In(e,t,r){let n=Pn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>te(l,r,t));n.finished?(De(i,n.result,a,s,r),n.result=void 0):c.then(l=>{De(i,l,a,s,r)}).catch(l=>te(l,r,t)).finally(()=>n.result=void 0)}}}function De(e,t,r,n,i){if(Ie&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>De(e,s,r,n)).catch(s=>te(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var Et="x-";function C(e=""){return Et+e}function mr(e){Et=e}var yt={};function d(e,t){return yt[e]=t,{before(r){if(!yt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function ue(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=vt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(_r((o,s)=>n[o]=s)).filter(xr).map(kn(n,r)).sort(Ln).map(o=>Dn(e,o))}function vt(e){return Array.from(e).map(_r()).filter(t=>!xr(t))}var bt=!1,pe=new Map,hr=Symbol();function ir(e){bt=!0;let t=Symbol();hr=t,pe.set(t,[]);let r=()=>{for(;pe.get(t).length;)pe.get(t).shift()();pe.delete(t)},n=()=>{bt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Wt(e);return t.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Dn(e,t){let r=()=>{},n=yt[t.type]||r,[i,o]=_t(e);Re(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),bt?pe.get(hr).push(n):n())};return s.runCleanups=o,s}var Le=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),$e=e=>e;function _r(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=gr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var gr=[];function re(e){gr.push(e)}function xr({name:e}){return yr().test(e)}var yr=()=>new RegExp(`^${Et}([^:^.]+)\\b`);function kn(e,t){return({name:r,value:n})=>{let i=r.match(yr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var wt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",wt,"teleport"];function Ln(e,t){let r=G.indexOf(e.type)===-1?wt:e.type,n=G.indexOf(t.type)===-1?wt:t.type;return G.indexOf(r)-G.indexOf(n)}var St=[],At=!1;function ne(e=()=>{}){return queueMicrotask(()=>{At||setTimeout(()=>{je()})}),new Promise(t=>{St.push(()=>{e(),t()})})}function je(){for(At=!1;St.length;)St.shift()()}function br(){At=!0}function me(e,t){return Array.isArray(t)?wr(e,t.join(" ")):typeof t=="object"&&t!==null?$n(e,t):typeof t=="function"?me(e,t()):wr(e,t)}function wr(e,t){let r=o=>o.split(" ").filter(Boolean),n=o=>o.split(" ").filter(s=>!e.classList.contains(s)).filter(Boolean),i=o=>(e.classList.add(...o),()=>{e.classList.remove(...o)});return t=t===!0?t="":t||"",i(n(t))}function $n(e,t){let r=a=>a.split(" ").filter(Boolean),n=Object.entries(t).flatMap(([a,c])=>c?r(a):!1).filter(Boolean),i=Object.entries(t).flatMap(([a,c])=>c?!1:r(a)).filter(Boolean),o=[],s=[];return i.forEach(a=>{e.classList.contains(a)&&(e.classList.remove(a),s.push(a))}),n.forEach(a=>{e.classList.contains(a)||(e.classList.add(a),o.push(a))}),()=>{s.forEach(a=>e.classList.add(a)),o.forEach(a=>e.classList.remove(a))}}function J(e,t){return typeof t=="object"&&t!==null?jn(e,t):Fn(e,t)}function jn(e,t){let r={};return Object.entries(t).forEach(([n,i])=>{r[n]=e.style[n],n.startsWith("--")||(n=Bn(n)),e.style.setProperty(n,i)}),setTimeout(()=>{e.style.length===0&&e.removeAttribute("style")}),()=>{J(e,r)}}function Fn(e,t){let r=e.getAttribute("style",t);return e.setAttribute("style",t),()=>{e.setAttribute("style",r||"")}}function Bn(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function he(e,t=()=>{}){let r=!1;return function(){r?t.apply(this,arguments):(r=!0,e.apply(this,arguments))}}d("transition",(e,{value:t,modifiers:r,expression:n},{evaluate:i})=>{typeof n=="function"&&(n=i(n)),n!==!1&&(!n||typeof n=="boolean"?Kn(e,r,t):zn(e,n,t))});function zn(e,t,r){Er(e,me,""),{enter:i=>{e._x_transition.enter.during=i},"enter-start":i=>{e._x_transition.enter.start=i},"enter-end":i=>{e._x_transition.enter.end=i},leave:i=>{e._x_transition.leave.during=i},"leave-start":i=>{e._x_transition.leave.start=i},"leave-end":i=>{e._x_transition.leave.end=i}}[r](t)}function Kn(e,t,r){Er(e,J);let n=!t.includes("in")&&!t.includes("out")&&!r,i=n||t.includes("in")||["enter"].includes(r),o=n||t.includes("out")||["leave"].includes(r);t.includes("in")&&!n&&(t=t.filter((g,b)=>b<t.indexOf("out"))),t.includes("out")&&!n&&(t=t.filter((g,b)=>b>t.indexOf("out")));let s=!t.includes("opacity")&&!t.includes("scale"),a=s||t.includes("opacity"),c=s||t.includes("scale"),l=a?0:1,u=c?_e(t,"scale",95)/100:1,p=_e(t,"delay",0)/1e3,m=_e(t,"origin","center"),w="opacity, transform",$=_e(t,"duration",150)/1e3,Ee=_e(t,"duration",75)/1e3,f="cubic-bezier(0.4, 0.0, 0.2, 1)";i&&(e._x_transition.enter.during={transformOrigin:m,transitionDelay:`${p}s`,transitionProperty:w,transitionDuration:`${$}s`,transitionTimingFunction:f},e._x_transition.enter.start={opacity:l,transform:`scale(${u})`},e._x_transition.enter.end={opacity:1,transform:"scale(1)"}),o&&(e._x_transition.leave.during={transformOrigin:m,transitionDelay:`${p}s`,transitionProperty:w,transitionDuration:`${Ee}s`,transitionTimingFunction:f},e._x_transition.leave.start={opacity:1,transform:"scale(1)"},e._x_transition.leave.end={opacity:l,transform:`scale(${u})`})}function Er(e,t,r={}){e._x_transition||(e._x_transition={enter:{during:r,start:r,end:r},leave:{during:r,start:r,end:r},in(n=()=>{},i=()=>{}){Fe(e,t,{during:this.enter.during,start:this.enter.start,end:this.enter.end},n,i)},out(n=()=>{},i=()=>{}){Fe(e,t,{during:this.leave.during,start:this.leave.start,end:this.leave.end},n,i)}})}window.Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,n){let i=document.visibilityState==="visible"?requestAnimationFrame:setTimeout,o=()=>i(r);if(t){e._x_transition&&(e._x_transition.enter||e._x_transition.leave)?e._x_transition.enter&&(Object.entries(e._x_transition.enter.during).length||Object.entries(e._x_transition.enter.start).length||Object.entries(e._x_transition.enter.end).length)?e._x_transition.in(r):o():e._x_transition?e._x_transition.in(r):o();return}e._x_hidePromise=e._x_transition?new Promise((s,a)=>{e._x_transition.out(()=>{},()=>s(n)),e._x_transitioning&&e._x_transitioning.beforeCancel(()=>a({isFromCancelledTransition:!0}))}):Promise.resolve(n),queueMicrotask(()=>{let s=vr(e);s?(s._x_hideChildren||(s._x_hideChildren=[]),s._x_hideChildren.push(e)):i(()=>{let a=c=>{let l=Promise.all([c._x_hidePromise,...(c._x_hideChildren||[]).map(a)]).then(([u])=>u());return delete c._x_hidePromise,delete c._x_hideChildren,l};a(e).catch(c=>{if(!c.isFromCancelledTransition)throw c})})})};function vr(e){let t=e.parentNode;if(t)return t._x_hidePromise?t:vr(t)}function Fe(e,t,{during:r,start:n,end:i}={},o=()=>{},s=()=>{}){if(e._x_transitioning&&e._x_transitioning.cancel(),Object.keys(r).length===0&&Object.keys(n).length===0&&Object.keys(i).length===0){o(),s();return}let a,c,l;Hn(e,{start(){a=t(e,n)},during(){c=t(e,r)},before:o,end(){a(),l=t(e,i)},after:s,cleanup(){c(),l()}})}function Hn(e,t){let r,n,i,o=he(()=>{h(()=>{r=!0,n||t.before(),i||(t.end(),je()),t.after(),e.isConnected&&t.cleanup(),delete e._x_transitioning})});e._x_transitioning={beforeCancels:[],beforeCancel(s){this.beforeCancels.push(s)},cancel:he(function(){for(;this.beforeCancels.length;)this.beforeCancels.shift()();o()}),finish:o},h(()=>{t.start(),t.during()}),br(),requestAnimationFrame(()=>{if(r)return;let s=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,a=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;s===0&&(s=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),h(()=>{t.before()}),n=!0,requestAnimationFrame(()=>{r||(h(()=>{t.end()}),je(),setTimeout(e._x_transitioning.finish,s+a),i=!0)})})}function _e(e,t,r){if(e.indexOf(t)===-1)return r;let n=e[e.indexOf(t)+1];if(!n||t==="scale"&&isNaN(n))return r;if(t==="duration"||t==="delay"){let i=n.match(/([0-9]+)ms/);if(i)return i[1]}return t==="origin"&&["top","right","left","center","bottom"].includes(e[e.indexOf(t)+2])?[n,e[e.indexOf(t)+2]].join(" "):n}var I=!1;function D(e,t=()=>{}){return(...r)=>I?t(...r):e(...r)}function Sr(e){return(...t)=>I&&e(...t)}var Ar=[];function z(e){Ar.push(e)}function Or(e,t){Ar.forEach(r=>r(e,t)),I=!0,Tr(()=>{S(t,(r,n)=>{n(r,()=>{})})}),I=!1}var Be=!1;function Cr(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),I=!0,Be=!0,Tr(()=>{Vn(t)}),I=!1,Be=!1}function Vn(e){let t=!1;S(e,(n,i)=>{O(n,(o,s)=>{if(t&&Qt(o))return s();t=!0,i(o,s)})})}function Tr(e){let t=N;at((r,n)=>{let i=t(r);return L(i),()=>{}}),e(),at(t)}function ge(e,t,r,n=[]){switch(e._x_bindings||(e._x_bindings=T({})),e._x_bindings[t]=r,t=n.includes("camel")?Zn(t):t,t){case"value":qn(e,r);break;case"style":Wn(e,r);break;case"class":Un(e,r);break;case"selected":case"checked":Gn(e,t,r);break;default:Mr(e,t,r);break}}function qn(e,t){if(e.type==="radio")e.attributes.value===void 0&&(e.value=t),window.fromModel&&(typeof t=="boolean"?e.checked=xe(e.value)===t:e.checked=Rr(e.value,t));else if(e.type==="checkbox")Number.isInteger(t)?e.value=t:!Array.isArray(t)&&typeof t!="boolean"&&![null,void 0].includes(t)?e.value=String(t):Array.isArray(t)?e.checked=t.some(r=>Rr(r,e.value)):e.checked=!!t;else if(e.tagName==="SELECT")Xn(e,t);else{if(e.value===t)return;e.value=t===void 0?"":t}}function Un(e,t){e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedClasses=me(e,t)}function Wn(e,t){e._x_undoAddedStyles&&e._x_undoAddedStyles(),e._x_undoAddedStyles=J(e,t)}function Gn(e,t,r){Mr(e,t,r),Yn(e,t,r)}function Mr(e,t,r){[null,void 0,!1].includes(r)&&Qn(t)?e.removeAttribute(t):(Nr(t)&&(r=t),Jn(e,t,r))}function Jn(e,t,r){e.getAttribute(t)!=r&&e.setAttribute(t,r)}function Yn(e,t,r){e[t]!==r&&(e[t]=r)}function Xn(e,t){let r=[].concat(t).map(n=>n+"");Array.from(e.options).forEach(n=>{n.selected=r.includes(n.value)})}function Zn(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function Rr(e,t){return e==t}function xe(e){return[1,"1","true","on","yes",!0].includes(e)?!0:[0,"0","false","off","no",!1].includes(e)?!1:e?Boolean(e):null}function Nr(e){return["disabled","checked","required","readonly","hidden","open","selected","autofocus","itemscope","multiple","novalidate","allowfullscreen","allowpaymentrequest","formnovalidate","autoplay","controls","loop","muted","playsinline","default","ismap","reversed","async","defer","nomodule"].includes(e)}function Qn(e){return!["aria-pressed","aria-checked","aria-expanded","aria-selected"].includes(e)}function Pr(e,t,r){return e._x_bindings&&e._x_bindings[t]!==void 0?e._x_bindings[t]:Dr(e,t,r)}function Ir(e,t,r,n=!0){if(e._x_bindings&&e._x_bindings[t]!==void 0)return e._x_bindings[t];if(e._x_inlineBindings&&e._x_inlineBindings[t]!==void 0){let i=e._x_inlineBindings[t];return i.extract=n,ke(()=>R(e,i.expression))}return Dr(e,t,r)}function Dr(e,t,r){let n=e.getAttribute(t);return n===null?typeof r=="function"?r():r:n===""?!0:Nr(t)?!![t,"true"].includes(n):n}function ze(e,t){var r;return function(){var n=this,i=arguments,o=function(){r=null,e.apply(n,i)};clearTimeout(r),r=setTimeout(o,t)}}function Ke(e,t){let r;return function(){let n=this,i=arguments;r||(e.apply(n,i),r=!0,setTimeout(()=>r=!1,t))}}function He({get:e,set:t},{get:r,set:n}){let i=!0,o,s,a=N(()=>{let c=e(),l=r();if(i)n(Ot(c)),i=!1;else{let u=JSON.stringify(c),p=JSON.stringify(l);u!==o?n(Ot(c)):u!==p&&t(Ot(l))}o=JSON.stringify(e()),s=JSON.stringify(r())});return()=>{L(a)}}function Ot(e){return typeof e=="object"?JSON.parse(JSON.stringify(e)):e}function kr(e){(Array.isArray(e)?e:[e]).forEach(r=>r(B))}var Y={},Lr=!1;function $r(e,t){if(Lr||(Y=T(Y),Lr=!0),t===void 0)return Y[e];Y[e]=t,typeof t=="object"&&t!==null&&t.hasOwnProperty("init")&&typeof t.init=="function"&&Y[e].init(),Ne(Y[e])}function jr(){return Y}var Fr={};function Br(e,t){let r=typeof t!="function"?()=>t:t;return e instanceof Element?Ct(e,r()):(Fr[e]=r,()=>{})}function zr(e){return Object.entries(Fr).forEach(([t,r])=>{Object.defineProperty(e,t,{get(){return(...n)=>r(...n)}})}),e}function Ct(e,t,r){let n=[];for(;n.length;)n.pop()();let i=Object.entries(t).map(([s,a])=>({name:s,value:a})),o=vt(i);return i=i.map(s=>o.find(a=>a.name===s.name)?{name:`x-bind:${s.name}`,value:`"${s.value}"`}:s),ue(e,i,r).map(s=>{n.push(s.runCleanups),s()}),()=>{for(;n.length;)n.pop()()}}var Kr={};function Hr(e,t){Kr[e]=t}function Vr(e,t){return Object.entries(Kr).forEach(([r,n])=>{Object.defineProperty(e,r,{get(){return(...i)=>n.bind(t)(...i)},enumerable:!1})}),e}var ei={get reactive(){return T},get release(){return L},get effect(){return N},get raw(){return st},version:"3.13.5",flushAndStopDeferringMutations:lr,dontAutoEvaluateFunctions:ke,disableEffectScheduling:qt,startObservingMutations:le,stopObservingMutations:dt,setReactivityEngine:Ut,onAttributeRemoved:Re,onAttributesAdded:Ce,closestDataStack:j,skipDuringClone:D,onlyDuringClone:Sr,addRootSelector:Ae,addInitSelector:Oe,interceptClone:z,addScopeToNode:P,deferMutations:cr,mapAttributes:re,evaluateLater:x,interceptInit:tr,setEvaluator:pr,mergeProxies:F,extractProp:Ir,findClosest:Q,onElRemoved:ee,closestRoot:W,destroyTree:ce,interceptor:Pe,transition:Fe,setStyles:J,mutateDom:h,directive:d,entangle:He,throttle:Ke,debounce:ze,evaluate:R,initTree:S,nextTick:ne,prefixed:C,prefix:mr,plugin:kr,magic:y,store:$r,start:Jt,clone:Cr,cloneNode:Or,bound:Pr,$data:Me,watch:Se,walk:O,data:Hr,bind:Br},B=ei;function Tt(e,t){let r=Object.create(null),n=e.split(",");for(let i=0;i<n.length;i++)r[n[i]]=!0;return t?i=>!!r[i.toLowerCase()]:i=>!!r[i]}var ti="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var Ps=Tt(ti+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");var qr=Object.freeze({}),Is=Object.freeze([]);var ri=Object.prototype.hasOwnProperty,ye=(e,t)=>ri.call(e,t),K=Array.isArray,ie=e=>Ur(e)==="[object Map]";var ni=e=>typeof e=="string",Ve=e=>typeof e=="symbol",be=e=>e!==null&&typeof e=="object";var ii=Object.prototype.toString,Ur=e=>ii.call(e),Rt=e=>Ur(e).slice(8,-1);var qe=e=>ni(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var Ue=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},oi=/-(\w)/g,Ds=Ue(e=>e.replace(oi,(t,r)=>r?r.toUpperCase():"")),si=/\B([A-Z])/g,ks=Ue(e=>e.replace(si,"-$1").toLowerCase()),Mt=Ue(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ls=Ue(e=>e?`on${Mt(e)}`:""),Nt=(e,t)=>e!==t&&(e===e||t===t);var Pt=new WeakMap,we=[],k,X=Symbol("iterate"),It=Symbol("Map key iterate");function ai(e){return e&&e._isEffect===!0}function Zr(e,t=qr){ai(e)&&(e=e.raw);let r=li(e,t);return t.lazy||r(),r}function Qr(e){e.active&&(en(e),e.options.onStop&&e.options.onStop(),e.active=!1)}var ci=0;function li(e,t){let r=function(){if(!r.active)return e();if(!we.includes(r)){en(r);try{return fi(),we.push(r),k=r,e()}finally{we.pop(),tn(),k=we[we.length-1]}}};return r.id=ci++,r.allowRecurse=!!t.allowRecurse,r._isEffect=!0,r.active=!0,r.raw=e,r.deps=[],r.options=t,r}function en(e){let{deps:t}=e;if(t.length){for(let r=0;r<t.length;r++)t[r].delete(e);t.length=0}}var oe=!0,kt=[];function ui(){kt.push(oe),oe=!1}function fi(){kt.push(oe),oe=!0}function tn(){let e=kt.pop();oe=e===void 0?!0:e}function M(e,t,r){if(!oe||k===void 0)return;let n=Pt.get(e);n||Pt.set(e,n=new Map);let i=n.get(r);i||n.set(r,i=new Set),i.has(k)||(i.add(k),k.deps.push(i),k.options.onTrack&&k.options.onTrack({effect:k,target:e,type:t,key:r}))}function V(e,t,r,n,i,o){let s=Pt.get(e);if(!s)return;let a=new Set,c=u=>{u&&u.forEach(p=>{(p!==k||p.allowRecurse)&&a.add(p)})};if(t==="clear")s.forEach(c);else if(r==="length"&&K(e))s.forEach((u,p)=>{(p==="length"||p>=n)&&c(u)});else switch(r!==void 0&&c(s.get(r)),t){case"add":K(e)?qe(r)&&c(s.get("length")):(c(s.get(X)),ie(e)&&c(s.get(It)));break;case"delete":K(e)||(c(s.get(X)),ie(e)&&c(s.get(It)));break;case"set":ie(e)&&c(s.get(X));break}let l=u=>{u.options.onTrigger&&u.options.onTrigger({effect:u,target:e,key:r,type:t,newValue:n,oldValue:i,oldTarget:o}),u.options.scheduler?u.options.scheduler(u):u()};a.forEach(l)}var di=Tt("__proto__,__v_isRef,__isVue"),rn=new Set(Object.getOwnPropertyNames(Symbol).map(e=>Symbol[e]).filter(Ve)),pi=nn();var mi=nn(!0);var Wr=hi();function hi(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...r){let n=_(this);for(let o=0,s=this.length;o<s;o++)M(n,"get",o+"");let i=n[t](...r);return i===-1||i===!1?n[t](...r.map(_)):i}}),["push","pop","shift","unshift","splice"].forEach(t=>{e[t]=function(...r){ui();let n=_(this)[t].apply(this,r);return tn(),n}}),e}function nn(e=!1,t=!1){return function(n,i,o){if(i==="__v_isReactive")return!e;if(i==="__v_isReadonly")return e;if(i==="__v_raw"&&o===(e?t?Ni:cn:t?Mi:an).get(n))return n;let s=K(n);if(!e&&s&&ye(Wr,i))return Reflect.get(Wr,i,o);let a=Reflect.get(n,i,o);return(Ve(i)?rn.has(i):di(i))||(e||M(n,"get",i),t)?a:Dt(a)?!s||!qe(i)?a.value:a:be(a)?e?ln(a):Qe(a):a}}var _i=gi();function gi(e=!1){return function(r,n,i,o){let s=r[n];if(!e&&(i=_(i),s=_(s),!K(r)&&Dt(s)&&!Dt(i)))return s.value=i,!0;let a=K(r)&&qe(n)?Number(n)<r.length:ye(r,n),c=Reflect.set(r,n,i,o);return r===_(o)&&(a?Nt(i,s)&&V(r,"set",n,i,s):V(r,"add",n,i)),c}}function xi(e,t){let r=ye(e,t),n=e[t],i=Reflect.deleteProperty(e,t);return i&&r&&V(e,"delete",t,void 0,n),i}function yi(e,t){let r=Reflect.has(e,t);return(!Ve(t)||!rn.has(t))&&M(e,"has",t),r}function bi(e){return M(e,"iterate",K(e)?"length":X),Reflect.ownKeys(e)}var wi={get:pi,set:_i,deleteProperty:xi,has:yi,ownKeys:bi},Ei={get:mi,set(e,t){return console.warn(`Set operation on key "${String(t)}" failed: target is readonly.`,e),!0},deleteProperty(e,t){return console.warn(`Delete operation on key "${String(t)}" failed: target is readonly.`,e),!0}};var Lt=e=>be(e)?Qe(e):e,$t=e=>be(e)?ln(e):e,jt=e=>e,Ze=e=>Reflect.getPrototypeOf(e);function We(e,t,r=!1,n=!1){e=e.__v_raw;let i=_(e),o=_(t);t!==o&&!r&&M(i,"get",t),!r&&M(i,"get",o);let{has:s}=Ze(i),a=n?jt:r?$t:Lt;if(s.call(i,t))return a(e.get(t));if(s.call(i,o))return a(e.get(o));e!==i&&e.get(t)}function Ge(e,t=!1){let r=this.__v_raw,n=_(r),i=_(e);return e!==i&&!t&&M(n,"has",e),!t&&M(n,"has",i),e===i?r.has(e):r.has(e)||r.has(i)}function Je(e,t=!1){return e=e.__v_raw,!t&&M(_(e),"iterate",X),Reflect.get(e,"size",e)}function Gr(e){e=_(e);let t=_(this);return Ze(t).has.call(t,e)||(t.add(e),V(t,"add",e,e)),this}function Jr(e,t){t=_(t);let r=_(this),{has:n,get:i}=Ze(r),o=n.call(r,e);o?sn(r,n,e):(e=_(e),o=n.call(r,e));let s=i.call(r,e);return r.set(e,t),o?Nt(t,s)&&V(r,"set",e,t,s):V(r,"add",e,t),this}function Yr(e){let t=_(this),{has:r,get:n}=Ze(t),i=r.call(t,e);i?sn(t,r,e):(e=_(e),i=r.call(t,e));let o=n?n.call(t,e):void 0,s=t.delete(e);return i&&V(t,"delete",e,void 0,o),s}function Xr(){let e=_(this),t=e.size!==0,r=ie(e)?new Map(e):new Set(e),n=e.clear();return t&&V(e,"clear",void 0,void 0,r),n}function Ye(e,t){return function(n,i){let o=this,s=o.__v_raw,a=_(s),c=t?jt:e?$t:Lt;return!e&&M(a,"iterate",X),s.forEach((l,u)=>n.call(i,c(l),c(u),o))}}function Xe(e,t,r){return function(...n){let i=this.__v_raw,o=_(i),s=ie(o),a=e==="entries"||e===Symbol.iterator&&s,c=e==="keys"&&s,l=i[e](...n),u=r?jt:t?$t:Lt;return!t&&M(o,"iterate",c?It:X),{next(){let{value:p,done:m}=l.next();return m?{value:p,done:m}:{value:a?[u(p[0]),u(p[1])]:u(p),done:m}},[Symbol.iterator](){return this}}}}function H(e){return function(...t){{let r=t[0]?`on key "${t[0]}" `:"";console.warn(`${Mt(e)} operation ${r}failed: target is readonly.`,_(this))}return e==="delete"?!1:this}}function vi(){let e={get(o){return We(this,o)},get size(){return Je(this)},has:Ge,add:Gr,set:Jr,delete:Yr,clear:Xr,forEach:Ye(!1,!1)},t={get(o){return We(this,o,!1,!0)},get size(){return Je(this)},has:Ge,add:Gr,set:Jr,delete:Yr,clear:Xr,forEach:Ye(!1,!0)},r={get(o){return We(this,o,!0)},get size(){return Je(this,!0)},has(o){return Ge.call(this,o,!0)},add:H("add"),set:H("set"),delete:H("delete"),clear:H("clear"),forEach:Ye(!0,!1)},n={get(o){return We(this,o,!0,!0)},get size(){return Je(this,!0)},has(o){return Ge.call(this,o,!0)},add:H("add"),set:H("set"),delete:H("delete"),clear:H("clear"),forEach:Ye(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(o=>{e[o]=Xe(o,!1,!1),r[o]=Xe(o,!0,!1),t[o]=Xe(o,!1,!0),n[o]=Xe(o,!0,!0)}),[e,r,t,n]}var[Si,Ai,Oi,Ci]=vi();function on(e,t){let r=t?e?Ci:Oi:e?Ai:Si;return(n,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?n:Reflect.get(ye(r,i)&&i in n?r:n,i,o)}var Ti={get:on(!1,!1)};var Ri={get:on(!0,!1)};function sn(e,t,r){let n=_(r);if(n!==r&&t.call(e,n)){let i=Rt(e);console.warn(`Reactive ${i} contains both the raw and reactive versions of the same object${i==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}var an=new WeakMap,Mi=new WeakMap,cn=new WeakMap,Ni=new WeakMap;function Pi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Ii(e){return e.__v_skip||!Object.isExtensible(e)?0:Pi(Rt(e))}function Qe(e){return e&&e.__v_isReadonly?e:un(e,!1,wi,Ti,an)}function ln(e){return un(e,!0,Ei,Ri,cn)}function un(e,t,r,n,i){if(!be(e))return console.warn(`value cannot be made reactive: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive))return e;let o=i.get(e);if(o)return o;let s=Ii(e);if(s===0)return e;let a=new Proxy(e,s===2?n:r);return i.set(e,a),a}function _(e){return e&&_(e.__v_raw)||e}function Dt(e){return Boolean(e&&e.__v_isRef===!0)}y("nextTick",()=>ne);y("dispatch",e=>U.bind(U,e));y("watch",(e,{evaluateLater:t,cleanup:r})=>(n,i)=>{let o=t(n),a=Se(()=>{let c;return o(l=>c=l),c},i);r(a)});y("store",jr);y("data",e=>Me(e));y("root",e=>W(e));y("refs",e=>(e._x_refs_proxy||(e._x_refs_proxy=F(Di(e))),e._x_refs_proxy));function Di(e){let t=[],r=e;for(;r;)r._x_refs&&t.push(r._x_refs),r=r.parentNode;return t}var Ft={};function Bt(e){return Ft[e]||(Ft[e]=0),++Ft[e]}function fn(e,t){return Q(e,r=>{if(r._x_ids&&r._x_ids[t])return!0})}function dn(e,t){e._x_ids||(e._x_ids={}),e._x_ids[t]||(e._x_ids[t]=Bt(t))}y("id",(e,{cleanup:t})=>(r,n=null)=>{let i=`${r}${n?`-${n}`:""}`;return ki(e,i,t,()=>{let o=fn(e,r),s=o?o._x_ids[r]:Bt(r);return n?`${r}-${s}-${n}`:`${r}-${s}`})});z((e,t)=>{e._x_id&&(t._x_id=e._x_id)});function ki(e,t,r,n){if(e._x_id||(e._x_id={}),e._x_id[t])return e._x_id[t];let i=n();return e._x_id[t]=i,r(()=>{delete e._x_id[t]}),i}y("el",e=>e);pn("Focus","focus","focus");pn("Persist","persist","persist");function pn(e,t,r){y(t,n=>v(`You can't use [$${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}d("modelable",(e,{expression:t},{effect:r,evaluateLater:n,cleanup:i})=>{let o=n(t),s=()=>{let u;return o(p=>u=p),u},a=n(`${t} = __placeholder`),c=u=>a(()=>{},{scope:{__placeholder:u}}),l=s();c(l),queueMicrotask(()=>{if(!e._x_model)return;e._x_removeModelListeners.default();let u=e._x_model.get,p=e._x_model.set,m=He({get(){return u()},set(w){p(w)}},{get(){return s()},set(w){c(w)}});i(m)})});d("teleport",(e,{modifiers:t,expression:r},{cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&v("x-teleport can only be used on a <template> tag",e);let i=mn(r),o=e.content.cloneNode(!0).firstElementChild;e._x_teleport=o,o._x_teleportBack=e,e.setAttribute("data-teleport-template",!0),o.setAttribute("data-teleport-target",!0),e._x_forwardEvents&&e._x_forwardEvents.forEach(a=>{o.addEventListener(a,c=>{c.stopPropagation(),e.dispatchEvent(new c.constructor(c.type,c))})}),P(o,{},e);let s=(a,c,l)=>{l.includes("prepend")?c.parentNode.insertBefore(a,c):l.includes("append")?c.parentNode.insertBefore(a,c.nextSibling):c.appendChild(a)};h(()=>{s(o,i,t),S(o),o._x_ignore=!0}),e._x_teleportPutBack=()=>{let a=mn(r);h(()=>{s(e._x_teleport,a,t)})},n(()=>o.remove())});var Li=document.createElement("div");function mn(e){let t=D(()=>document.querySelector(e),()=>Li)();return t||v(`Cannot find x-teleport element for selector: "${e}"`),t}var hn=()=>{};hn.inline=(e,{modifiers:t},{cleanup:r})=>{t.includes("self")?e._x_ignoreSelf=!0:e._x_ignore=!0,r(()=>{t.includes("self")?delete e._x_ignoreSelf:delete e._x_ignore})};d("ignore",hn);d("effect",D((e,{expression:t},{effect:r})=>{r(x(e,t))}));function se(e,t,r,n){let i=e,o=c=>n(c),s={},a=(c,l)=>u=>l(c,u);if(r.includes("dot")&&(t=$i(t)),r.includes("camel")&&(t=ji(t)),r.includes("passive")&&(s.passive=!0),r.includes("capture")&&(s.capture=!0),r.includes("window")&&(i=window),r.includes("document")&&(i=document),r.includes("debounce")){let c=r[r.indexOf("debounce")+1]||"invalid-wait",l=et(c.split("ms")[0])?Number(c.split("ms")[0]):250;o=ze(o,l)}if(r.includes("throttle")){let c=r[r.indexOf("throttle")+1]||"invalid-wait",l=et(c.split("ms")[0])?Number(c.split("ms")[0]):250;o=Ke(o,l)}return r.includes("prevent")&&(o=a(o,(c,l)=>{l.preventDefault(),c(l)})),r.includes("stop")&&(o=a(o,(c,l)=>{l.stopPropagation(),c(l)})),r.includes("self")&&(o=a(o,(c,l)=>{l.target===e&&c(l)})),(r.includes("away")||r.includes("outside"))&&(i=document,o=a(o,(c,l)=>{e.contains(l.target)||l.target.isConnected!==!1&&(e.offsetWidth<1&&e.offsetHeight<1||e._x_isShown!==!1&&c(l))})),r.includes("once")&&(o=a(o,(c,l)=>{c(l),i.removeEventListener(t,o,s)})),o=a(o,(c,l)=>{Bi(t)&&zi(l,r)||c(l)}),i.addEventListener(t,o,s),()=>{i.removeEventListener(t,o,s)}}function $i(e){return e.replace(/-/g,".")}function ji(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function et(e){return!Array.isArray(e)&&!isNaN(e)}function Fi(e){return[" ","_"].includes(e)?e:e.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/[_\s]/,"-").toLowerCase()}function Bi(e){return["keydown","keyup"].includes(e)}function zi(e,t){let r=t.filter(o=>!["window","document","prevent","stop","once","capture"].includes(o));if(r.includes("debounce")){let o=r.indexOf("debounce");r.splice(o,et((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.includes("throttle")){let o=r.indexOf("throttle");r.splice(o,et((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.length===0||r.length===1&&_n(e.key).includes(r[0]))return!1;let i=["ctrl","shift","alt","meta","cmd","super"].filter(o=>r.includes(o));return r=r.filter(o=>!i.includes(o)),!(i.length>0&&i.filter(s=>((s==="cmd"||s==="super")&&(s="meta"),e[`${s}Key`])).length===i.length&&_n(e.key).includes(r[0]))}function _n(e){if(!e)return[];e=Fi(e);let t={ctrl:"control",slash:"/",space:" ",spacebar:" ",cmd:"meta",esc:"escape",up:"arrow-up",down:"arrow-down",left:"arrow-left",right:"arrow-right",period:".",equal:"=",minus:"-",underscore:"_"};return t[e]=e,Object.keys(t).map(r=>{if(t[r]===e)return r}).filter(r=>r)}d("model",(e,{modifiers:t,expression:r},{effect:n,cleanup:i})=>{let o=e;t.includes("parent")&&(o=e.parentNode);let s=x(o,r),a;typeof r=="string"?a=x(o,`${r} = __placeholder`):typeof r=="function"&&typeof r()=="string"?a=x(o,`${r()} = __placeholder`):a=()=>{};let c=()=>{let m;return s(w=>m=w),gn(m)?m.get():m},l=m=>{let w;s($=>w=$),gn(w)?w.set(m):a(()=>{},{scope:{__placeholder:m}})};typeof r=="string"&&e.type==="radio"&&h(()=>{e.hasAttribute("name")||e.setAttribute("name",r)});var u=e.tagName.toLowerCase()==="select"||["checkbox","radio"].includes(e.type)||t.includes("lazy")?"change":"input";let p=I?()=>{}:se(e,u,t,m=>{l(Ki(e,t,m,c()))});if(t.includes("fill")&&([void 0,null,""].includes(c())||e.type==="checkbox"&&Array.isArray(c()))&&e.dispatchEvent(new Event(u,{})),e._x_removeModelListeners||(e._x_removeModelListeners={}),e._x_removeModelListeners.default=p,i(()=>e._x_removeModelListeners.default()),e.form){let m=se(e.form,"reset",[],w=>{ne(()=>e._x_model&&e._x_model.set(e.value))});i(()=>m())}e._x_model={get(){return c()},set(m){l(m)}},e._x_forceModelUpdate=m=>{m===void 0&&typeof r=="string"&&r.match(/\./)&&(m=""),window.fromModel=!0,h(()=>ge(e,"value",m)),delete window.fromModel},n(()=>{let m=c();t.includes("unintrusive")&&document.activeElement.isSameNode(e)||e._x_forceModelUpdate(m)})});function Ki(e,t,r,n){return h(()=>{if(r instanceof CustomEvent&&r.detail!==void 0)return r.detail!==null&&r.detail!==void 0?r.detail:r.target.value;if(e.type==="checkbox")if(Array.isArray(n)){let i=null;return t.includes("number")?i=zt(r.target.value):t.includes("boolean")?i=xe(r.target.value):i=r.target.value,r.target.checked?n.concat([i]):n.filter(o=>!Hi(o,i))}else return r.target.checked;else return e.tagName.toLowerCase()==="select"&&e.multiple?t.includes("number")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return zt(o)}):t.includes("boolean")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return xe(o)}):Array.from(r.target.selectedOptions).map(i=>i.value||i.text):t.includes("number")?zt(r.target.value):t.includes("boolean")?xe(r.target.value):t.includes("trim")?r.target.value.trim():r.target.value})}function zt(e){let t=e?parseFloat(e):null;return Vi(t)?t:e}function Hi(e,t){return e==t}function Vi(e){return!Array.isArray(e)&&!isNaN(e)}function gn(e){return e!==null&&typeof e=="object"&&typeof e.get=="function"&&typeof e.set=="function"}d("cloak",e=>queueMicrotask(()=>h(()=>e.removeAttribute(C("cloak")))));Oe(()=>`[${C("init")}]`);d("init",D((e,{expression:t},{evaluate:r})=>typeof t=="string"?!!t.trim()&&r(t,{},!1):r(t,{},!1)));d("text",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{h(()=>{e.textContent=o})})})});d("html",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{h(()=>{e.innerHTML=o,e._x_ignoreSelf=!0,S(e),delete e._x_ignoreSelf})})})});re(Le(":",$e(C("bind:"))));var xn=(e,{value:t,modifiers:r,expression:n,original:i},{effect:o})=>{if(!t){let a={};zr(a),x(e,n)(l=>{Ct(e,l,i)},{scope:a});return}if(t==="key")return qi(e,n);if(e._x_inlineBindings&&e._x_inlineBindings[t]&&e._x_inlineBindings[t].extract)return;let s=x(e,n);o(()=>s(a=>{a===void 0&&typeof n=="string"&&n.match(/\./)&&(a=""),h(()=>ge(e,t,a,r))}))};xn.inline=(e,{value:t,modifiers:r,expression:n})=>{t&&(e._x_inlineBindings||(e._x_inlineBindings={}),e._x_inlineBindings[t]={expression:n,extract:!1})};d("bind",xn);function qi(e,t){e._x_keyExpression=t}Ae(()=>`[${C("data")}]`);d("data",(e,{expression:t},{cleanup:r})=>{if(Ui(e))return;t=t===""?"{}":t;let n={};de(n,e);let i={};Vr(i,n);let o=R(e,t,{scope:i});(o===void 0||o===!0)&&(o={}),de(o,e);let s=T(o);Ne(s);let a=P(e,s);s.init&&R(e,s.init),r(()=>{s.destroy&&R(e,s.destroy),a()})});z((e,t)=>{e._x_dataStack&&(t._x_dataStack=e._x_dataStack,t.setAttribute("data-has-alpine-state",!0))});function Ui(e){return I?Be?!0:e.hasAttribute("data-has-alpine-state"):!1}d("show",(e,{modifiers:t,expression:r},{effect:n})=>{let i=x(e,r);e._x_doHide||(e._x_doHide=()=>{h(()=>{e.style.setProperty("display","none",t.includes("important")?"important":void 0)})}),e._x_doShow||(e._x_doShow=()=>{h(()=>{e.style.length===1&&e.style.display==="none"?e.removeAttribute("style"):e.style.removeProperty("display")})});let o=()=>{e._x_doHide(),e._x_isShown=!1},s=()=>{e._x_doShow(),e._x_isShown=!0},a=()=>setTimeout(s),c=he(p=>p?s():o(),p=>{typeof e._x_toggleAndCascadeWithTransitions=="function"?e._x_toggleAndCascadeWithTransitions(e,p,s,o):p?a():o()}),l,u=!0;n(()=>i(p=>{!u&&p===l||(t.includes("immediate")&&(p?a():o()),c(p),l=p,u=!1)}))});d("for",(e,{expression:t},{effect:r,cleanup:n})=>{let i=Gi(t),o=x(e,i.items),s=x(e,e._x_keyExpression||"index");e._x_prevKeys=[],e._x_lookup={},r(()=>Wi(e,i,o,s)),n(()=>{Object.values(e._x_lookup).forEach(a=>a.remove()),delete e._x_prevKeys,delete e._x_lookup})});function Wi(e,t,r,n){let i=s=>typeof s=="object"&&!Array.isArray(s),o=e;r(s=>{Ji(s)&&s>=0&&(s=Array.from(Array(s).keys(),f=>f+1)),s===void 0&&(s=[]);let a=e._x_lookup,c=e._x_prevKeys,l=[],u=[];if(i(s))s=Object.entries(s).map(([f,g])=>{let b=yn(t,g,f,s);n(E=>u.push(E),{scope:{index:f,...b}}),l.push(b)});else for(let f=0;f<s.length;f++){let g=yn(t,s[f],f,s);n(b=>u.push(b),{scope:{index:f,...g}}),l.push(g)}let p=[],m=[],w=[],$=[];for(let f=0;f<c.length;f++){let g=c[f];u.indexOf(g)===-1&&w.push(g)}c=c.filter(f=>!w.includes(f));let Ee="template";for(let f=0;f<u.length;f++){let g=u[f],b=c.indexOf(g);if(b===-1)c.splice(f,0,g),p.push([Ee,f]);else if(b!==f){let E=c.splice(f,1)[0],A=c.splice(b-1,1)[0];c.splice(f,0,A),c.splice(b,0,E),m.push([E,A])}else $.push(g);Ee=g}for(let f=0;f<w.length;f++){let g=w[f];a[g]._x_effects&&a[g]._x_effects.forEach(ve),a[g].remove(),a[g]=null,delete a[g]}for(let f=0;f<m.length;f++){let[g,b]=m[f],E=a[g],A=a[b],Z=document.createElement("div");h(()=>{A||v('x-for ":key" is undefined or invalid',o),A.after(Z),E.after(A),A._x_currentIfEl&&A.after(A._x_currentIfEl),Z.before(E),E._x_currentIfEl&&E.after(E._x_currentIfEl),Z.remove()}),A._x_refreshXForScope(l[u.indexOf(b)])}for(let f=0;f<p.length;f++){let[g,b]=p[f],E=g==="template"?o:a[g];E._x_currentIfEl&&(E=E._x_currentIfEl);let A=l[b],Z=u[b],ae=document.importNode(o.content,!0).firstElementChild,Ht=T(A);P(ae,Ht,o),ae._x_refreshXForScope=wn=>{Object.entries(wn).forEach(([En,vn])=>{Ht[En]=vn})},h(()=>{E.after(ae),S(ae)}),typeof Z=="object"&&v("x-for key cannot be an object, it must be a string or an integer",o),a[Z]=ae}for(let f=0;f<$.length;f++)a[$[f]]._x_refreshXForScope(l[u.indexOf($[f])]);o._x_prevKeys=u})}function Gi(e){let t=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,r=/^\s*\(|\)\s*$/g,n=/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/,i=e.match(n);if(!i)return;let o={};o.items=i[2].trim();let s=i[1].replace(r,"").trim(),a=s.match(t);return a?(o.item=s.replace(t,"").trim(),o.index=a[1].trim(),a[2]&&(o.collection=a[2].trim())):o.item=s,o}function yn(e,t,r,n){let i={};return/^\[.*\]$/.test(e.item)&&Array.isArray(t)?e.item.replace("[","").replace("]","").split(",").map(s=>s.trim()).forEach((s,a)=>{i[s]=t[a]}):/^\{.*\}$/.test(e.item)&&!Array.isArray(t)&&typeof t=="object"?e.item.replace("{","").replace("}","").split(",").map(s=>s.trim()).forEach(s=>{i[s]=t[s]}):i[e.item]=t,e.index&&(i[e.index]=r),e.collection&&(i[e.collection]=n),i}function Ji(e){return!Array.isArray(e)&&!isNaN(e)}function bn(){}bn.inline=(e,{expression:t},{cleanup:r})=>{let n=W(e);n._x_refs||(n._x_refs={}),n._x_refs[t]=e,r(()=>delete n._x_refs[t])};d("ref",bn);d("if",(e,{expression:t},{effect:r,cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&v("x-if can only be used on a <template> tag",e);let i=x(e,t),o=()=>{if(e._x_currentIfEl)return e._x_currentIfEl;let a=e.content.cloneNode(!0).firstElementChild;return P(a,{},e),h(()=>{e.after(a),S(a)}),e._x_currentIfEl=a,e._x_undoIf=()=>{O(a,c=>{c._x_effects&&c._x_effects.forEach(ve)}),a.remove(),delete e._x_currentIfEl},a},s=()=>{e._x_undoIf&&(e._x_undoIf(),delete e._x_undoIf)};r(()=>i(a=>{a?o():s()})),n(()=>e._x_undoIf&&e._x_undoIf())});d("id",(e,{expression:t},{evaluate:r})=>{r(t).forEach(i=>dn(e,i))});z((e,t)=>{e._x_ids&&(t._x_ids=e._x_ids)});re(Le("@",$e(C("on:"))));d("on",D((e,{value:t,modifiers:r,expression:n},{cleanup:i})=>{let o=n?x(e,n):()=>{};e.tagName.toLowerCase()==="template"&&(e._x_forwardEvents||(e._x_forwardEvents=[]),e._x_forwardEvents.includes(t)||e._x_forwardEvents.push(t));let s=se(e,t,r,a=>{o(()=>{},{scope:{$event:a},params:[a]})});i(()=>s())}));tt("Collapse","collapse","collapse");tt("Intersect","intersect","intersect");tt("Focus","trap","focus");tt("Mask","mask","mask");function tt(e,t,r){d(t,n=>v(`You can't use [x-${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}B.setEvaluator(xt);B.setReactivityEngine({reactive:Qe,effect:Zr,release:Qr,raw:_});var Kt=B;window.Alpine=Kt;queueMicrotask(()=>{Kt.start()});})();
M tailwind.config.js
+19 -11
@@ -1,12 +1,16 @@
1 1 /** @type {import('tailwindcss').Config} */
2 2 module.exports = {
3 - content: ["./templates/**/*.html"],
3 + content: [
4 + "./crates/rustwarden-ui/templates/**/*.html",
5 + "./crates/rustwarden-fediverse-ui/templates/**/*.html",
6 + "./crates/rustwarden-fediverse-guard/templates/**/*.html",
7 + ],
4 8 theme: {
5 9 extend: {
6 10 colors: {
7 11 'rustwarden': {
8 - 'blue': '#3b82f6',
9 - 'rust': '#f97316',
12 + 'green': '#00ed64',
13 + 'teal': '#001e2b',
10 14 }
11 15 }
12 16 },
@@ -18,15 +22,19 @@ module.exports = {
18 22 themes: [
19 23 {
20 24 rustwarden: {
21 - "primary": "#3b82f6",
22 - "secondary": "#1e293b",
23 - "accent": "#10b981",
24 - "neutral": "#f5f5f5",
25 + "primary": "#00ed64",
26 + "primary-content": "#001e2b",
27 + "secondary": "#001e2b",
28 + "accent": "#00684a",
29 + "neutral": "#1c2d38",
25 30 "base-100": "#ffffff",
26 - "info": "#3b82f6",
27 - "success": "#10b981",
28 - "warning": "#f59e0b",
29 - "error": "#ef4444",
31 + "base-200": "#f9fbfa",
32 + "base-300": "#e1e5e8",
33 + "base-content": "#001e2b",
34 + "info": "#3d4f9f",
35 + "success": "#00a35c",
36 + "warning": "#fa6e39",
37 + "error": "#e74856",
30 38 },
31 39 },
32 40 "light",