Merge branch 'AddTeams' into 'main' Addteams See merge request ebii/rustwarden!4

Eric BOUHANA <eric@ebii.fr> committé le 2026-05-03 18:42

6af318956a1092a64e468778afc0be62dfcc5667

2 parent(s)

164 fichiers modifiés +16223 -3098
M .gitignore
+5 -0
@@ -3,6 +3,7 @@
3 3 **/*.rs.bk
4 4 # Cargo.lock : à commiter pour les binaires/applications (recommandation Rust)
5 5
6 +/node_modules
6 7 # Environment & secrets (constitution: no secrets in repo)
7 8 .env
8 9 .env.*
@@ -16,6 +17,10 @@
16 17 *.swo
17 18 *~
18 19 .vscode/
20 +.claude/
21 +.omc/
22 +CLAUDE.md
23 +
19 24 # Uncomment to share workspace settings: !.vscode/settings.json
20 25
21 26 # OS
D CLAUDE.md
+0 -179
@@ -1,179 +0,0 @@
1 -# CLAUDE.md
2 -
3 -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 -
5 -## Project Overview
6 -
7 -Rustwarden est un **framework réutilisable** qui fournit la couche authentification, gestion utilisateurs, RBAC, OAuth/SSO, email et admin UI à d'autres applications Rust. Il sert de fondation à plusieurs projets (ex: PasterMan). Le CLI wizard (`rustwarden-cli start-project`) génère des projets autonomes qui consomment le framework.
8 -
9 -**Principe fondamental** : toute modification doit préserver la réutilisabilité du framework. Les changements dans `rustwarden-core` et `rustwarden-ui` impactent tous les projets consommateurs.
10 -
11 -## Workspace Structure
12 -
13 -- **`crates/rustwarden-core/`** — Coeur du framework : auth, services, middleware, models (SeaORM), migrations, i18n, background tasks, hooks
14 -- **`crates/rustwarden-ui/`** — UI par défaut : templates Askama, handlers, routes, DTOs, static assets
15 -- **`app/`** — Application de référence (12 lignes, sert de démo/test du builder)
16 -- **`tools/rustwarden-cli/`** — CLI wizard interactif pour scaffolder de nouveaux projets consommateurs
17 -
18 -### Comment les apps consomment le framework
19 -
20 -```rust
21 -// app/src/main.rs — usage minimal
22 -RustwardenApp::builder()
23 - .from_env()
24 - .with_ui(rustwarden_ui::default_routes)
25 - .build().await?
26 - .run().await
27 -
28 -// Usage avancé (projet consommateur type)
29 -let db = rustwarden_core::config::database::create_connection().await?;
30 -my_app::migrations::run_migrations(&db).await?; // migrations métier d'abord
31 -RustwardenApp::builder()
32 - .from_env()
33 - .database(db)
34 - .auto_migrate(false) // DB déjà migrée
35 - .with_ui(rustwarden_ui::default_routes)
36 - .merge_routes(my_app_routes) // routes métier ajoutées
37 - .hooks(MyAppHooks) // hooks lifecycle
38 - .build().await?
39 - .run().await
40 -```
41 -
42 -Les apps consommatrices peuvent **surcharger** les routes framework (les routes custom mergées après prennent priorité) et **surcharger** les templates en créant leurs propres templates + handlers.
43 -
44 -## Build & Development Commands
45 -
46 -```bash
47 -# Build
48 -cargo build # Full workspace
49 -cargo build -p rustwarden-core # Single crate
50 -cargo check # Quick compilation check (no codegen)
51 -
52 -# Run
53 -cargo run -p rustwarden-app # Reference app (port 3000)
54 -cargo run -p rustwarden-cli -- start-project # Project scaffolder
55 -
56 -# Tests
57 -cargo test # All tests
58 -cargo test -p rustwarden-core # Single crate
59 -cargo test test_name # Single test
60 -
61 -# Quality gates (must all pass)
62 -cargo clippy -- -D warnings
63 -cargo fmt --check
64 -cargo doc # Must generate without warnings
65 -
66 -# Database binaries (in rustwarden-core)
67 -cargo run --bin migrate
68 -cargo run --bin seed # Create/update default admin
69 -cargo run --bin hash_password
70 -cargo run --bin change_password -- admin "NewPassword123!"
71 -
72 -# Database (Docker)
73 -cd database && docker compose up -d # PostgreSQL 18
74 -
75 -# CSS (Tailwind + DaisyUI)
76 -npm install
77 -npm run build:css # One-shot build
78 -npm run build:css:watch # Watch mode
79 -npm run build:css:min # Minified
80 -```
81 -
82 -## Tech Stack
83 -
84 -- **Rust 1.91.1+** (Edition 2024), **Axum 0.8**, **Tokio 1.x**, **SeaORM 2.0.0-rc.20**
85 -- **PostgreSQL 18+** (via Docker in `database/`)
86 -- **Templates:** Askama 0.15 — **Frontend:** Alpine.js + HTMX (CDN) — **CSS:** Tailwind 4 + DaisyUI 5
87 -- **Auth:** JWT (jsonwebtoken), bcrypt, tower-sessions (memory/redis)
88 -- **OAuth** (feature-gated): `openidconnect` — Google, GitHub, Discord, Microsoft
89 -- **Email:** lettre (SMTP), imap (bounce detection)
90 -- **i18n:** YAML files in `/locales/` (fr, en, de, es). Default locale: fr
91 -
92 -## Architecture Key Points
93 -
94 -- **Builder pattern:** `RustwardenApp::builder()` — configure DB, routes, hooks, middleware, i18n, background tasks
95 -- **Service layer:** Business logic in `services/`, handlers call services, services call SeaORM
96 -- **Middleware stack:** auth validation → CSRF → rate limiting → admin check
97 -- **Hooks system:** `RustwardenHooks` trait — lifecycle events que les apps consommatrices implémentent (on_user_registered, on_user_login, on_password_changed, on_oauth_login, on_resource_shared...)
98 -- **Feature flags:** `oauth` (OpenID Connect), `redis-session` (Redis session backend)
99 -- **Background tasks:** Email queue, JWT cleanup, password change cleanup, IMAP bounce — démarrés automatiquement au build
100 -- **19 SeaORM entities** in `models/` with 5 migrations
101 -- **Config:** Variables d'environnement (voir `.env.sample`). OAuth secrets chiffrés AES-256-GCM en DB
102 -- **Extensibilité:** Les apps consommatrices étendent via `merge_routes()`, `hooks()`, et surcharge de templates
103 -
104 -## Framework Constraints — Impact sur les consommateurs
105 -
106 -Avant toute modification, considérer :
107 -
108 -1. **API publique** : les types exportés via `prelude` (`RustwardenApp`, `AuthUser`, `AppError`, `Claims`, etc.) et les traits (`RustwardenHooks`, `OAuthProvider`) sont l'interface du framework. Tout changement de signature est un breaking change pour les consommateurs.
109 -
110 -2. **Migrations** : les apps consommatrices appellent `core_migrations()` depuis `src/migrations.rs`, **pas** le `Migrator` de `migrations/src/lib.rs`. Les deux fichiers doivent rester synchronisés (voir "Adding Features").
111 -
112 -3. **Templates** : les apps consommatrices peuvent étendre `base.html` et inclure les composants (`navbar.html`, `sidebar.html`). Les variables de `LayoutContext` (`page_title`, `app_name`, `app_version`, `csrf_token`) sont un contrat.
113 -
114 -4. **CLI wizard** : `tools/rustwarden-cli/src/templates.rs` génère le code des nouveaux projets. Si l'API du builder ou les imports changent, mettre à jour les templates du wizard.
115 -
116 -## Handler Patterns
117 -
118 -Each handler file in `rustwarden-ui/src/handlers/` follows this structure:
119 -
120 -- Template struct with `#[derive(Template)]` and `#[template(path = "...")]`
121 -- Required base variables (contrat `base.html`): `page_title`, `app_name`, `app_version`, `csrf_token`
122 -- Authenticated templates add: `username`, `email`, `initials`, `avatar_color`, `roles_text`, `nav_items`, `app_domain`, `app_description`
123 -- Auth extractors: `Extension(claims): Extension<Claims>` (preferred) or `AuthUser`
124 -- Services take `&DatabaseConnection` as first parameter, return `Result<T, AppError>`
125 -
126 -## Frontend Stack — Tailwind CSS v4 + DaisyUI v5
127 -
128 -Couleurs CSS variables au format DaisyUI v5 :
129 -
130 -- `var(--color-primary)`, `var(--color-base-100)`, `var(--color-base-content)`, etc.
131 -- Ne PAS utiliser l'ancienne syntaxe DaisyUI v3/v4 : ~~`oklch(var(--p))`~~, ~~`oklch(var(--bc))`~~
132 -- Pour les opacités/mélanges : `color-mix(in oklab, var(--color-primary) 35%, transparent)`
133 -
134 -## HTMX — Principes
135 -
136 -Documentation : https://htmx.fr/
137 -
138 -**Philosophie** : privilégier les attributs HTMX au JavaScript pour toute interaction serveur renvoyant du HTML. Réserver le JS (ou Alpine.js) uniquement aux cas où HTMX ne suffit pas : API JSON, Clipboard API, manipulation DOM purement client-side.
139 -
140 -| Cas | Utiliser |
141 -|-----|----------|
142 -| Formulaire → réponse HTML | HTMX (`hx-post` + `hx-target`) |
143 -| Confirmation avant action | HTMX (`hx-confirm`) |
144 -| Suppression avec feedback | HTMX (`hx-delete` + `hx-swap="outerHTML"`) |
145 -| Pagination / chargement | HTMX (`hx-get` + `hx-swap`) |
146 -| API JSON (login, register) | Alpine.js / JS (`fetch`) |
147 -| Clipboard, drag & drop | JS natif |
148 -| Toggle CSS / état UI local | Alpine.js (`x-data`, `@click`) |
149 -
150 -## Adding Features
151 -
152 -- **New service**: service in `rustwarden-core/src/services/` → declare in `mod.rs`
153 -- **New entity**: migration → SeaORM model in `models/` → service → declare in `mod.rs` files
154 -- **New page (UI)**: Askama template in `rustwarden-ui/templates/` → Template struct in handler → route in `routes.rs`
155 -- **New middleware**: implement in `rustwarden-core/src/middleware/` → wire into app builder
156 -- **New migration**: `m{YYYYMMDD}_{NNNNNN}_{description}.rs` — must be declared in **both** `migrations/src/lib.rs` **and** `src/migrations.rs` (`core_migrations()`) to be executed by consumer apps. Missing one breaks either `sea-orm-cli` or production consumer apps.
157 -- **New builder option / public type**: update `prelude` re-exports in `lib.rs` + update CLI wizard templates in `tools/rustwarden-cli/src/templates.rs`
158 -
159 -## Code Conventions (from constitution.md)
160 -
161 -- **All public functions** must have `///` doc comments
162 -- **No `unwrap()`/`expect()`** in production code (only in tests). Use `Result<T, AppError>` with `?`
163 -- **No raw SQL** — all DB access via SeaORM. Consult SeaORM docs for patterns
164 -- **No `unsafe`** without `// SAFETY:` justification
165 -- **No stub handlers** — every route must have a complete, functional implementation
166 -- **Zero clippy warnings:** `cargo clippy -- -D warnings`
167 -- **`cargo fmt`** before every commit
168 -- **Test coverage target:** >80% overall, >90% for auth/security/core services
169 -- **ANSSI compliance:** See `docs/ANSSI_COMPLIANCE_REPORT.md`
170 -
171 -## Documentation
172 -
173 -- **`DEVELOPER.md`** — Guide de référence complet (70KB+) : architecture, ajout de routes/models/services, personnalisation templates, auth, RBAC, OAuth, hooks, i18n, tâches de fond, tests, production
174 -- **`constitution.md`** — Règles du projet, principes de sécurité, contraintes techniques
175 -- **`docs/ANSSI_COMPLIANCE_REPORT.md`** — Rapport de conformité sécurité
176 -
177 -## Language
178 -
179 -La documentation du projet (constitution, guide développeur, commentaires) est en **français**. Les identifiants de code et les chemins API sont en anglais.
M Cargo.lock
+154 -0
@@ -3,6 +3,12 @@
3 3 version = 4
4 4
5 5 [[package]]
6 +name = "adler2"
7 +version = "2.0.1"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 +[[package]]
6 12 name = "aead"
7 13 version = "0.5.2"
8 14 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -542,6 +548,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
542 548 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
543 549
544 550 [[package]]
551 +name = "base32"
552 +version = "0.5.1"
553 +source = "registry+https://github.com/rust-lang/crates.io-index"
554 +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
555 +
556 +[[package]]
545 557 name = "base64"
546 558 version = "0.13.1"
547 559 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -705,12 +717,24 @@ dependencies = [
705 717 ]
706 718
707 719 [[package]]
720 +name = "bytemuck"
721 +version = "1.25.0"
722 +source = "registry+https://github.com/rust-lang/crates.io-index"
723 +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
724 +
725 +[[package]]
708 726 name = "byteorder"
709 727 version = "1.5.0"
710 728 source = "registry+https://github.com/rust-lang/crates.io-index"
711 729 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
712 730
713 731 [[package]]
732 +name = "byteorder-lite"
733 +version = "0.1.0"
734 +source = "registry+https://github.com/rust-lang/crates.io-index"
735 +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
736 +
737 +[[package]]
714 738 name = "bytes"
715 739 version = "1.11.1"
716 740 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -877,6 +901,12 @@ dependencies = [
877 901 ]
878 902
879 903 [[package]]
904 +name = "constant_time_eq"
905 +version = "0.3.1"
906 +source = "registry+https://github.com/rust-lang/crates.io-index"
907 +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
908 +
909 +[[package]]
880 910 name = "cookie"
881 911 version = "0.18.1"
882 912 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -950,6 +980,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
950 980 checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
951 981
952 982 [[package]]
983 +name = "crc32fast"
984 +version = "1.5.0"
985 +source = "registry+https://github.com/rust-lang/crates.io-index"
986 +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
987 +dependencies = [
988 + "cfg-if",
989 +]
990 +
991 +[[package]]
953 992 name = "crossbeam-queue"
954 993 version = "0.3.12"
955 994 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1335,6 +1374,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1335 1374 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
1336 1375
1337 1376 [[package]]
1377 +name = "fdeflate"
1378 +version = "0.3.7"
1379 +source = "registry+https://github.com/rust-lang/crates.io-index"
1380 +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
1381 +dependencies = [
1382 + "simd-adler32",
1383 +]
1384 +
1385 +[[package]]
1338 1386 name = "ff"
1339 1387 version = "0.13.1"
1340 1388 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1357,6 +1405,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1357 1405 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
1358 1406
1359 1407 [[package]]
1408 +name = "flate2"
1409 +version = "1.1.9"
1410 +source = "registry+https://github.com/rust-lang/crates.io-index"
1411 +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
1412 +dependencies = [
1413 + "crc32fast",
1414 + "miniz_oxide",
1415 +]
1416 +
1417 +[[package]]
1360 1418 name = "float-cmp"
1361 1419 version = "0.9.0"
1362 1420 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2064,6 +2122,19 @@ dependencies = [
2064 2122 ]
2065 2123
2066 2124 [[package]]
2125 +name = "image"
2126 +version = "0.25.10"
2127 +source = "registry+https://github.com/rust-lang/crates.io-index"
2128 +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
2129 +dependencies = [
2130 + "bytemuck",
2131 + "byteorder-lite",
2132 + "moxcms",
2133 + "num-traits",
2134 + "png",
2135 +]
2136 +
2137 +[[package]]
2067 2138 name = "imap"
2068 2139 version = "2.4.1"
2069 2140 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2474,6 +2545,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2474 2545 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
2475 2546
2476 2547 [[package]]
2548 +name = "miniz_oxide"
2549 +version = "0.8.9"
2550 +source = "registry+https://github.com/rust-lang/crates.io-index"
2551 +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
2552 +dependencies = [
2553 + "adler2",
2554 + "simd-adler32",
2555 +]
2556 +
2557 +[[package]]
2477 2558 name = "mio"
2478 2559 version = "1.1.1"
2479 2560 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2485,6 +2566,16 @@ dependencies = [
2485 2566 ]
2486 2567
2487 2568 [[package]]
2569 +name = "moxcms"
2570 +version = "0.8.1"
2571 +source = "registry+https://github.com/rust-lang/crates.io-index"
2572 +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
2573 +dependencies = [
2574 + "num-traits",
2575 + "pxfm",
2576 +]
2577 +
2578 +[[package]]
2488 2579 name = "native-tls"
2489 2580 version = "0.2.18"
2490 2581 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2941,6 +3032,19 @@ dependencies = [
2941 3032 ]
2942 3033
2943 3034 [[package]]
3035 +name = "png"
3036 +version = "0.18.1"
3037 +source = "registry+https://github.com/rust-lang/crates.io-index"
3038 +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
3039 +dependencies = [
3040 + "bitflags 2.11.0",
3041 + "crc32fast",
3042 + "fdeflate",
3043 + "flate2",
3044 + "miniz_oxide",
3045 +]
3046 +
3047 +[[package]]
2944 3048 name = "polyval"
2945 3049 version = "0.6.2"
2946 3050 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3079,6 +3183,29 @@ dependencies = [
3079 3183 ]
3080 3184
3081 3185 [[package]]
3186 +name = "pxfm"
3187 +version = "0.1.28"
3188 +source = "registry+https://github.com/rust-lang/crates.io-index"
3189 +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
3190 +
3191 +[[package]]
3192 +name = "qrcodegen"
3193 +version = "1.8.0"
3194 +source = "registry+https://github.com/rust-lang/crates.io-index"
3195 +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
3196 +
3197 +[[package]]
3198 +name = "qrcodegen-image"
3199 +version = "1.5.1"
3200 +source = "registry+https://github.com/rust-lang/crates.io-index"
3201 +checksum = "99530e45ded4640c0eab5420fc60f9a0ec1be51a22e49cc8578b9a0d8be70712"
3202 +dependencies = [
3203 + "base64 0.22.1",
3204 + "image",
3205 + "qrcodegen",
3206 +]
3207 +
3208 +[[package]]
3082 3209 name = "quinn"
3083 3210 version = "0.11.9"
3084 3211 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3580,6 +3707,7 @@ dependencies = [
3580 3707 "bcrypt",
3581 3708 "chrono",
3582 3709 "dotenvy",
3710 + "futures",
3583 3711 "hex",
3584 3712 "hyper",
3585 3713 "hyper-util",
@@ -3600,6 +3728,8 @@ dependencies = [
3600 3728 "thiserror 2.0.18",
3601 3729 "time",
3602 3730 "tokio",
3731 + "tokio-stream",
3732 + "totp-rs",
3603 3733 "tower",
3604 3734 "tower-sessions",
3605 3735 "tower-sessions-redis-store",
@@ -4084,6 +4214,12 @@ dependencies = [
4084 4214 ]
4085 4215
4086 4216 [[package]]
4217 +name = "simd-adler32"
4218 +version = "0.3.9"
4219 +source = "registry+https://github.com/rust-lang/crates.io-index"
4220 +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
4221 +
4222 +[[package]]
4087 4223 name = "simdutf8"
4088 4224 version = "0.1.5"
4089 4225 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4666,6 +4802,7 @@ dependencies = [
4666 4802 "futures-core",
4667 4803 "pin-project-lite",
4668 4804 "tokio",
4805 + "tokio-util",
4669 4806 ]
4670 4807
4671 4808 [[package]]
@@ -4712,6 +4849,23 @@ dependencies = [
4712 4849 ]
4713 4850
4714 4851 [[package]]
4852 +name = "totp-rs"
4853 +version = "5.7.1"
4854 +source = "registry+https://github.com/rust-lang/crates.io-index"
4855 +checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
4856 +dependencies = [
4857 + "base32",
4858 + "constant_time_eq",
4859 + "hmac",
4860 + "qrcodegen-image",
4861 + "rand 0.9.2",
4862 + "sha1",
4863 + "sha2",
4864 + "url",
4865 + "urlencoding",
4866 +]
4867 +
4868 +[[package]]
4715 4869 name = "tower"
4716 4870 version = "0.5.3"
4717 4871 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml
+3 -0
@@ -9,3 +9,6 @@ resolver = "2"
9 9
10 10 [workspace.lints.clippy]
11 11 empty_line_after_doc_comments = "allow"
12 +
13 +[workspace.lints.rust]
14 +missing_docs = "warn"
M DEVELOPER.md
+178 -7
@@ -20,12 +20,13 @@ Ce document est le guide de reference pour comprendre, etendre et contribuer au
20 20 12. [Hooks : intercepter les evenements](#hooks--intercepter-les-evenements)
21 21 13. [Internationalisation (i18n)](#internationalisation-i18n)
22 22 14. [Taches de fond](#taches-de-fond)
23 -15. [Middleware](#middleware)
24 -16. [Configuration](#configuration)
25 -17. [Commandes binaires](#commandes-binaires)
26 -18. [Le CLI wizard](#le-cli-wizard)
27 -19. [Tests](#tests)
28 -20. [Production](#production)
23 +15. [Rate limiting et notifications temps-reel](#rate-limiting-et-notifications-temps-reel)
24 +16. [Middleware](#middleware)
25 +17. [Configuration](#configuration)
26 +18. [Commandes binaires](#commandes-binaires)
27 +19. [Le CLI wizard](#le-cli-wizard)
28 +20. [Tests](#tests)
29 +21. [Production](#production)
29 30
30 31 ---
31 32
@@ -1418,15 +1419,74 @@ RustwardenApp::builder()
1418 1419 | `on_user_registered` | `(db, user_id, username)` | Apres inscription reussie |
1419 1420 | `on_user_login` | `(db, user_id, username)` | Apres login reussi |
1420 1421 | `on_user_logout` | `(db, user_id)` | Apres deconnexion |
1422 +| `on_user_deleted` | `(db, user_id)` | Apres suppression d'un utilisateur |
1421 1423 | `on_password_changed` | `(db, user_id)` | Apres changement de mot de passe |
1422 1424 | `on_email_verified` | `(db, user_id)` | Apres verification email |
1423 1425 | `on_role_changed` | `(db, user_id, roles)` | Apres modification des roles |
1426 +| `on_oauth_login` | `(db, user_id, provider, is_new_user)` | Apres login OAuth reussi |
1427 +| `on_totp_enabled` | `(db, user_id)` | Apres activation 2FA/TOTP |
1428 +| `on_totp_disabled` | `(db, user_id)` | Apres desactivation 2FA/TOTP |
1429 +| `on_resource_registered` | `(db, type, id, owner)` | Apres enregistrement d'une ressource |
1430 +| `on_resource_unregistered` | `(db, type, id)` | Apres desenregistrement |
1424 1431 | `on_resource_shared` | `(db, type, id, user, level, by)` | Apres partage d'une ressource |
1425 1432 | `on_resource_unshared` | `(db, type, id, user)` | Apres revocation d'un partage |
1426 -| `on_oauth_login` | `(db, user_id, provider, is_new_user)` | Apres login OAuth reussi |
1433 +| `on_team_created` | `(db, team_id, creator_id)` | Apres creation d'une team |
1434 +| `on_team_deleted` | `(db, team_id)` | Apres suppression d'une team |
1435 +| `on_team_member_added` | `(db, team_id, user_id, role)` | Apres ajout d'un membre |
1436 +| `on_team_member_removed` | `(db, team_id, user_id)` | Apres retrait d'un membre |
1437 +| `on_audit_event` | `(db, event)` | Tout evenement audit (auth, admin, acces...) |
1427 1438
1428 1439 Tous les hooks ont une implementation par defaut (no-op), implementez uniquement ceux dont vous avez besoin.
1429 1440
1441 +### Exemple : hooks teams pour un audit log
1442 +
1443 +```rust
1444 +use async_trait::async_trait;
1445 +use rustwarden_core::hooks::RustwardenHooks;
1446 +use sea_orm::DatabaseConnection;
1447 +use uuid::Uuid;
1448 +
1449 +struct AuditHooks;
1450 +
1451 +#[async_trait]
1452 +impl RustwardenHooks for AuditHooks {
1453 + async fn on_team_created(
1454 + &self,
1455 + _db: &DatabaseConnection,
1456 + team_id: Uuid,
1457 + creator_id: Uuid,
1458 + ) -> anyhow::Result<()> {
1459 + tracing::info!(%team_id, %creator_id, "team created");
1460 + // Ici : insert dans votre table audit_log métier
1461 + Ok(())
1462 + }
1463 +
1464 + async fn on_team_member_added(
1465 + &self,
1466 + _db: &DatabaseConnection,
1467 + team_id: Uuid,
1468 + user_id: Uuid,
1469 + role: &str,
1470 + ) -> anyhow::Result<()> {
1471 + tracing::info!(%team_id, %user_id, role, "team member added");
1472 + Ok(())
1473 + }
1474 +
1475 + async fn on_team_member_removed(
1476 + &self,
1477 + _db: &DatabaseConnection,
1478 + team_id: Uuid,
1479 + user_id: Uuid,
1480 + ) -> anyhow::Result<()> {
1481 + tracing::info!(%team_id, %user_id, "team member removed");
1482 + Ok(())
1483 + }
1484 +}
1485 +```
1486 +
1487 +Signatures canoniques dans [`crates/rustwarden-core/src/hooks.rs`](crates/rustwarden-core/src/hooks.rs).
1488 +Voir aussi `docs/SERVICES_REFERENCE.md#17-hooks-lifecycle-et-audit` pour la reference complete des parametres.
1489 +
1430 1490 ---
1431 1491
1432 1492 ## Internationalisation (i18n)
@@ -1489,6 +1549,58 @@ Les taches de fond demarrent automatiquement avec le builder (sauf si `.without_
1489 1549 | JWT blacklist cleanup | dans `app.rs` | Supprime les JWT expires de la blacklist |
1490 1550 | IMAP bounce processor | `services/imap_bounce_service.rs` | Verifie les bounces email par IMAP |
1491 1551
1552 +### Nettoyage des Personal Access Tokens
1553 +
1554 +Le framework fournit `PersonalTokenCleanupProcessor<S: TokenScope>` qui purge
1555 +periodiquement les PAT expires **et** revoques. Contrairement aux taches ci-dessus,
1556 +il **n'est pas demarre automatiquement** : il est generique sur le scope `S`
1557 +(enum metier defini par votre app), et le framework ne peut pas connaitre ce
1558 +type a la compilation.
1559 +
1560 +Dans votre `main.rs`, apres avoir defini votre enum `Scope` et son
1561 +implementation de `TokenScope`, demarrez la tache juste avant `run()` :
1562 +
1563 +```rust
1564 +use rustwarden_core::prelude::{PersonalTokenCleanupProcessor, RustwardenApp, TokenScope};
1565 +use sea_orm::DatabaseConnection;
1566 +use std::sync::Arc;
1567 +
1568 +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1569 +enum MyScope { ApiRead, ApiWrite, Admin }
1570 +
1571 +impl TokenScope for MyScope {
1572 + fn as_str(&self) -> &'static str {
1573 + match self {
1574 + Self::ApiRead => "api:read",
1575 + Self::ApiWrite => "api:write",
1576 + Self::Admin => "admin",
1577 + }
1578 + }
1579 + fn parse(raw: &str) -> Option<Self> {
1580 + match raw {
1581 + "api:read" => Some(Self::ApiRead),
1582 + "api:write" => Some(Self::ApiWrite),
1583 + "admin" => Some(Self::Admin),
1584 + _ => None,
1585 + }
1586 + }
1587 +}
1588 +
1589 +#[tokio::main]
1590 +async fn main() -> anyhow::Result<()> {
1591 + let app = RustwardenApp::builder().from_env().build().await?;
1592 + let db_arc: Arc<DatabaseConnection> = Arc::new(app.db().clone());
1593 + PersonalTokenCleanupProcessor::<MyScope>::new(db_arc).start();
1594 + app.run().await
1595 +}
1596 +```
1597 +
1598 +Intervalle configurable via `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` (defaut dans
1599 +[`config::tasks::personal_token_cleanup_interval_secs`](crates/rustwarden-core/src/config/tasks.rs)).
1600 +
1601 +Voir aussi `docs/SERVICES_REFERENCE.md#7-tokens-et-sessions` pour l'API complete
1602 +de `PersonalTokenService<S>`.
1603 +
1492 1604 ### Ajouter une tache de fond
1493 1605
1494 1606 ```rust
@@ -1505,6 +1617,65 @@ tokio::spawn(async move {
1505 1617
1506 1618 ---
1507 1619
1620 +## Rate limiting et notifications temps-reel
1621 +
1622 +### RateLimitService
1623 +
1624 +Sliding-window in-process generique. Utilise par le framework (reset mot de passe,
1625 +validation email, changement mot de passe) et disponible dans votre app.
1626 +
1627 +```rust
1628 +use rustwarden_core::prelude::RateLimitService;
1629 +
1630 +let limiter = RateLimitService::new();
1631 +// 5 tentatives max sur une fenetre glissante de 60s, par cle arbitraire
1632 +match limiter.check_sliding_window("login:jdoe", 5, 60) {
1633 + Ok(()) => { /* requete autorisee */ }
1634 + Err(retry_after) => { /* trop de tentatives, retry apres `retry_after` secondes */ }
1635 +}
1636 +```
1637 +
1638 +Voir `docs/SERVICES_REFERENCE.md#15-rate-limiting` pour les options avancees
1639 +(double map, decisions atomiques, TTL).
1640 +
1641 +### Notifications temps-reel (SSE)
1642 +
1643 +`NotificationBus<T>` est un pub/sub generique avec filtre par subscriber, backé
1644 +par `tokio::sync::broadcast`. `sse_response` emballe la subscription en reponse
1645 +Server-Sent Events avec keep-alive 30s.
1646 +
1647 +```rust
1648 +use axum::{extract::Extension, response::IntoResponse};
1649 +use rustwarden_core::prelude::{InMemoryBus, NotificationBus, sse_response};
1650 +use std::sync::Arc;
1651 +
1652 +#[derive(Clone, serde::Serialize)]
1653 +enum AppEvent {
1654 + ImportProgress { job_id: uuid::Uuid, pct: u8 },
1655 + Ping,
1656 +}
1657 +
1658 +// Dans le builder de l'app, injecter le bus comme Extension :
1659 +// let bus: Arc<dyn NotificationBus<AppEvent>> = Arc::new(InMemoryBus::new(128));
1660 +// .with_extension(bus.clone())
1661 +
1662 +async fn stream(
1663 + Extension(bus): Extension<Arc<dyn NotificationBus<AppEvent>>>,
1664 +) -> impl IntoResponse {
1665 + let filter = Arc::new(|e: &AppEvent| matches!(e, AppEvent::ImportProgress { .. }));
1666 + sse_response(bus.subscribe(filter))
1667 +}
1668 +
1669 +// Coté producteur :
1670 +// bus.publish(AppEvent::ImportProgress { job_id, pct: 42 }).await;
1671 +```
1672 +
1673 +Limitation : `InMemoryBus` est single-process. Pour le multi-instance
1674 +(horizontal scaling), il faudra un backend Redis (trait stable, backend
1675 +remplacable sans changer les handlers). Voir `docs/SERVICES_REFERENCE.md#16-notifications-temps-reel-et-sse`.
1676 +
1677 +---
1678 +
1508 1679 ## Middleware
1509 1680
1510 1681 ### Middleware disponibles
M app/Cargo.toml
+2 -2
@@ -23,5 +23,5 @@ async-trait = "0.1"
23 23 uuid = { version = "1.0", features = ["v4"] }
24 24 serde_json = "1.0"
25 25
26 -[lints.clippy]
27 -empty_line_after_doc_comments = "allow"
26 +[lints]
27 +workspace = true
M app/examples/custom_hooks.rs
+1 -5
@@ -36,11 +36,7 @@ impl RustwardenHooks for MyAppHooks {
36 36 Ok(())
37 37 }
38 38
39 - async fn on_user_logout(
40 - &self,
41 - _db: &DatabaseConnection,
42 - user_id: Uuid,
43 - ) -> anyhow::Result<()> {
39 + async fn on_user_logout(&self, _db: &DatabaseConnection, user_id: Uuid) -> anyhow::Result<()> {
44 40 tracing::info!("[HOOK] User logged out: {}", user_id);
45 41 Ok(())
46 42 }
M app/examples/minimal_headless.rs
+1 -2
@@ -25,8 +25,7 @@ async fn main() -> anyhow::Result<()> {
25 25 }
26 26
27 27 fn api_routes() -> Router<DatabaseConnection> {
28 - Router::new()
29 - .route("/api/status", get(status))
28 + Router::new().route("/api/status", get(status))
30 29 }
31 30
32 31 async fn status() -> Json<serde_json::Value> {
M app/src/main.rs
+2 -0
@@ -1,3 +1,5 @@
1 +//! Reference application for the Rustwarden framework.
2 +
1 3 use rustwarden_core::prelude::*;
2 4
3 5 #[tokio::main]
M constitution.md
+2 -0
@@ -20,6 +20,8 @@ Sync Impact Report:
20 20
21 21 Toutes les fonctions publiques DOIVENT être documentées avec `///` (doc comments Rust). Le code DOIT respecter les bonnes pratiques Rust (clippy warnings = 0). Les structures de données DOIVENT utiliser `#[derive(Debug, Clone)]` où approprié. La gestion d'erreurs DOIT utiliser `Result<T, E>` avec des types d'erreur explicites.
22 22
23 +**Documentation Language** (amendement v0.5.3): Tous les commentaires rustdoc (`///` et `//!`) DOIVENT être rédigés en anglais. La documentation humaine dans `/docs/` reste en français. Cela garantit que la sortie de `cargo doc` est accessible à la communauté Rust internationale. Le lint `missing_docs` est activé au niveau workspace (`warn`) pour prévenir les régressions.
24 +
23 25 **Rationale**: La documentation et la qualité du code facilitent la maintenance, la collaboration et réduisent les bugs. Rust offre des outils puissants (clippy, rustfmt) qui doivent être utilisés.
24 26
25 27 ### II. Architecture Axum et SeaORM
M crates/rustwarden-core/Cargo.toml
+8 -3
@@ -9,6 +9,9 @@ description = "Core library for Rustwarden – authentication, RBAC, user manage
9 9 axum = { version = "0.8", features = ["macros"] }
10 10 tokio = { version = "1", features = ["full"] }
11 11 async-trait = "0.1"
12 +# Streams (SSE / NotificationBus)
13 +futures = "0.3"
14 +tokio-stream = { version = "0.1", features = ["sync"] }
12 15 # Serialization
13 16 serde = { version = "1.0", features = ["derive"] }
14 17 serde_json = "1.0"
@@ -55,8 +58,10 @@ yaml-rust2 = "0.11.0"
55 58 num-format = "0.4.4"
56 59 # Templates (macro + LayoutContext)
57 60 askama = "0.15"
58 -# Encryption (for storing OAuth secrets in DB)
61 +# Encryption (for storing OAuth secrets and TOTP secrets in DB)
59 62 aes-gcm = "0.10"
63 +# TOTP 2FA
64 +totp-rs = { version = "5", features = ["otpauth", "gen_secret", "qr"] }
60 65 # URL encoding for redirect parameters
61 66 urlencoding = "2"
62 67 # OAuth2 / OpenID Connect (optional)
@@ -68,8 +73,8 @@ default = []
68 73 redis-session = ["tower-sessions-redis-store"]
69 74 oauth = ["openidconnect", "reqwest"]
70 75
71 -[lints.clippy]
72 -empty_line_after_doc_comments = "allow"
76 +[lints]
77 +workspace = true
73 78
74 79 [[bin]]
75 80 name = "migrate"
M crates/rustwarden-core/locales/en.yml
+1 -1
@@ -41,7 +41,7 @@ common:
41 41 # i18n messages
42 42 i18n:
43 43 language_updated: "Language preference updated. Please reload the page to apply changes."
44 - invalid_language: "Invalid language code. Supported: fr, en, de, es"
44 + invalid_language: "Invalid language code. Supported: fr, en, de, es, it, pt"
45 45
46 46 # Email verification messages
47 47 email_verify:
M crates/rustwarden-core/locales/fr.yml
+1 -1
@@ -42,7 +42,7 @@ common:
42 42 # Messages i18n
43 43 i18n:
44 44 language_updated: "Préférence de langue mise à jour. Veuillez recharger la page pour appliquer les changements."
45 - invalid_language: "Code de langue invalide. Langues supportées : fr, en, de, es"
45 + invalid_language: "Code de langue invalide. Langues supportées : fr, en, de, es, it, pt"
46 46
47 47 # Vérification d'email
48 48 email_verify:
A crates/rustwarden-core/locales/it.yml
+82 -0
@@ -0,0 +1,82 @@
1 +# Traduzioni italiane — rustwarden-core
2 +# Sezioni: error, common, i18n, email, oauth, rate_limit, session
3 +
4 +# Messaggi di errore
5 +error:
6 + invalid_credentials: "Credenziali non valide"
7 + email_invalid: "Email non valida"
8 + required_field: "Questo campo è obbligatorio"
9 + unauthorized: "Non autorizzato"
10 + not_found: "Risorsa non trovata"
11 + validation: "Errore di validazione: {message}"
12 + database: "Errore del database"
13 + internal: "Errore interno del server"
14 + invalid_token: "Token non valido"
15 + token_expired: "Token scaduto"
16 + token_reused: "Token riutilizzato - si prega di autenticarsi nuovamente"
17 + must_change_password: "Cambio password obbligatorio"
18 + authentication_required: "Autenticazione richiesta"
19 + insufficient_permissions: "Permessi insufficienti"
20 + config: "Errore di configurazione del server"
21 + template: "Errore di rendering del template"
22 + validation_field:
23 + username_min_length: "Il nome utente deve contenere almeno {min} caratteri"
24 + password_min_length: "La password deve contenere almeno {min} caratteri"
25 + email_format: "L'indirizzo email non è valido"
26 + password_require_uppercase: "La password deve contenere almeno una lettera maiuscola"
27 + password_require_lowercase: "La password deve contenere almeno una lettera minuscola"
28 + password_require_digits: "La password deve contenere almeno una cifra"
29 + password_require_special: "La password deve contenere almeno un carattere speciale (!@#$%^&*()_+-=[]{}|;:,.<>?)"
30 +
31 +# Messaggi comuni
32 +common:
33 + welcome: "Benvenuto {username}"
34 + loading: "Caricamento..."
35 + save: "Salva"
36 + cancel: "Annulla"
37 + delete: "Elimina"
38 + edit: "Modifica"
39 + create: "Crea"
40 + load_more: "Carica altro"
41 +
42 +# Messaggi i18n
43 +i18n:
44 + language_updated: "Preferenza di lingua aggiornata. Ricarica la pagina per applicare le modifiche."
45 + invalid_language: "Codice lingua non valido. Lingue supportate: fr, en, de, es, it"
46 +
47 +# Verifica email
48 +email_verify:
49 + config_error: "Errore di configurazione del server. Contattare l'amministratore."
50 + success: "Il tuo indirizzo email è stato verificato con successo. Ora puoi accedere al tuo account."
51 + invalid_link: "Il link di verifica non è valido o è scaduto."
52 +
53 +# Oggetti email
54 +email_subject:
55 + password_reset: "Reimpostazione della password"
56 + email_validation: "Verifica del tuo indirizzo email"
57 + welcome: "Benvenuto su RustWarden!"
58 +
59 +# Email di conferma cambio password
60 +password_change_confirmation:
61 + subject: "Conferma cambio password"
62 + greeting: "Ciao {username}"
63 + message: "Hai richiesto di cambiare la password del tuo account {app_name}. Per confermare questa modifica, clicca sul link qui sotto."
64 + button_text: "Conferma il cambio password"
65 + expires_message: "Questo link è valido per {hours} ore ed è monouso."
66 + footer: "Questa email è stata inviata automaticamente, si prega di non rispondere."
67 +
68 +# Rate limiting
69 +rate_limit:
70 + exceeded: "Troppe richieste. Riprova più tardi."
71 + password_change: "Troppe richieste di cambio password. Attendi prima di riprovare."
72 +
73 +# OAuth
74 +oauth:
75 + divider: "oppure"
76 + login_with: "Accedi con {provider}"
77 + error_denied: "Accesso rifiutato dal provider."
78 + error_generic: "Errore durante l'accesso OAuth."
79 +
80 +# Sessioni
81 +session:
82 + ip_unavailable: "Non disponibile"
A crates/rustwarden-core/locales/pt.yml
+82 -0
@@ -0,0 +1,82 @@
1 +# Traduções portuguesas — rustwarden-core
2 +# Seções: error, common, i18n, email, oauth, rate_limit, session
3 +
4 +# Mensagens de erro
5 +error:
6 + invalid_credentials: "Credenciais inválidas"
7 + email_invalid: "Email inválido"
8 + required_field: "Este campo é obrigatório"
9 + unauthorized: "Não autorizado"
10 + not_found: "Recurso não encontrado"
11 + validation: "Erro de validação: {message}"
12 + database: "Erro no banco de dados"
13 + internal: "Erro interno do servidor"
14 + invalid_token: "Token inválido"
15 + token_expired: "Token expirado"
16 + token_reused: "Token reutilizado - por favor, autentique-se novamente"
17 + must_change_password: "Alteração de senha obrigatória"
18 + authentication_required: "Autenticação necessária"
19 + insufficient_permissions: "Permissões insuficientes"
20 + config: "Erro de configuração do servidor"
21 + template: "Erro de renderização do template"
22 + validation_field:
23 + username_min_length: "O nome de usuário deve ter pelo menos {min} caracteres"
24 + password_min_length: "A senha deve ter pelo menos {min} caracteres"
25 + email_format: "O endereço de email não é válido"
26 + password_require_uppercase: "A senha deve conter pelo menos uma letra maiúscula"
27 + password_require_lowercase: "A senha deve conter pelo menos uma letra minúscula"
28 + password_require_digits: "A senha deve conter pelo menos um dígito"
29 + password_require_special: "A senha deve conter pelo menos um caractere especial (!@#$%^&*()_+-=[]{}|;:,.<>?)"
30 +
31 +# Mensagens comuns
32 +common:
33 + welcome: "Bem-vindo {username}"
34 + loading: "Carregando..."
35 + save: "Salvar"
36 + cancel: "Cancelar"
37 + delete: "Excluir"
38 + edit: "Editar"
39 + create: "Criar"
40 + load_more: "Carregar mais"
41 +
42 +# Mensagens i18n
43 +i18n:
44 + language_updated: "Preferência de idioma atualizada. Recarregue a página para aplicar as alterações."
45 + invalid_language: "Código de idioma inválido. Idiomas suportados: fr, en, de, es, it, pt"
46 +
47 +# Verificação de email
48 +email_verify:
49 + config_error: "Erro de configuração do servidor. Entre em contato com o administrador."
50 + success: "Seu endereço de email foi verificado com sucesso. Agora você pode acessar sua conta."
51 + invalid_link: "O link de verificação é inválido ou expirou."
52 +
53 +# Assuntos de email
54 +email_subject:
55 + password_reset: "Redefinição de senha"
56 + email_validation: "Verificação do seu endereço de email"
57 + welcome: "Bem-vindo ao RustWarden!"
58 +
59 +# Email de confirmação de alteração de senha
60 +password_change_confirmation:
61 + subject: "Confirmação de alteração de senha"
62 + greeting: "Olá {username}"
63 + message: "Você solicitou a alteração da senha da sua conta {app_name}. Para confirmar esta alteração, clique no link abaixo."
64 + button_text: "Confirmar a alteração de senha"
65 + expires_message: "Este link é válido por {hours} horas e pode ser usado apenas uma vez."
66 + footer: "Este email foi enviado automaticamente, por favor não responda."
67 +
68 +# Limitação de taxa
69 +rate_limit:
70 + exceeded: "Muitas requisições. Tente novamente mais tarde."
71 + password_change: "Muitas solicitações de alteração de senha. Aguarde antes de tentar novamente."
72 +
73 +# OAuth
74 +oauth:
75 + divider: "ou"
76 + login_with: "Entrar com {provider}"
77 + error_denied: "Acesso negado pelo provedor."
78 + error_generic: "Erro durante o login OAuth."
79 +
80 +# Sessões
81 +session:
82 + ip_unavailable: "Indisponível"
M crates/rustwarden-core/migrations/src/lib.rs
+6 -0
@@ -5,6 +5,9 @@ pub mod m20260306_000002_create_app_settings_table;
5 5 pub mod m20260309_000003_create_permissions_tables;
6 6 pub mod m20260309_000004_create_resources_tables;
7 7 pub mod m20260310_000005_create_oauth_accounts_table;
8 +pub mod m20260409_000006_create_totp_tables;
9 +pub mod m20260422_000007_create_personal_access_tokens;
10 +pub mod m20260423_000008_create_teams;
8 11
9 12 pub struct Migrator;
10 13
@@ -17,6 +20,9 @@ impl MigratorTrait for Migrator {
17 20 Box::new(m20260309_000003_create_permissions_tables::Migration),
18 21 Box::new(m20260309_000004_create_resources_tables::Migration),
19 22 Box::new(m20260310_000005_create_oauth_accounts_table::Migration),
23 + Box::new(m20260409_000006_create_totp_tables::Migration),
24 + Box::new(m20260422_000007_create_personal_access_tokens::Migration),
25 + Box::new(m20260423_000008_create_teams::Migration),
20 26 ]
21 27 }
22 28 }
M crates/rustwarden-core/migrations/src/m20260305_000001_initial_schema.rs
+25 -32
@@ -1,5 +1,6 @@
1 1 use sea_orm_migration::prelude::*;
2 2
3 +/// Initial schema migration: creates the core users, roles, and sessions tables.
3 4 #[derive(DeriveMigrationName)]
4 5 pub struct Migration;
5 6
@@ -554,10 +555,7 @@ impl MigrationTrait for Migration {
554 555 .foreign_key(
555 556 ForeignKey::create()
556 557 .name("fk_email_validation_tokens_user_id")
557 - .from(
558 - EmailValidationTokens::Table,
559 - EmailValidationTokens::UserId,
560 - )
558 + .from(EmailValidationTokens::Table, EmailValidationTokens::UserId)
561 559 .to(Users::Table, Users::Id)
562 560 .on_delete(ForeignKeyAction::Cascade),
563 561 )
@@ -771,16 +769,8 @@ impl MigrationTrait for Migration {
771 769 .null(),
772 770 )
773 771 .col(ColumnDef::new(EmailQueue::ErrorMessage).text().null())
774 - .col(
775 - ColumnDef::new(EmailQueue::SmtpCode)
776 - .string_len(10)
777 - .null(),
778 - )
779 - .col(
780 - ColumnDef::new(EmailQueue::SmtpResponse)
781 - .text()
782 - .null(),
783 - )
772 + .col(ColumnDef::new(EmailQueue::SmtpCode).string_len(10).null())
773 + .col(ColumnDef::new(EmailQueue::SmtpResponse).text().null())
784 774 .col(
785 775 ColumnDef::new(EmailQueue::CreatedAt)
786 776 .timestamp_with_time_zone()
@@ -986,13 +976,28 @@ impl MigrationTrait for Migration {
986 976 Table::create()
987 977 .table(AuditLogs::Table)
988 978 .if_not_exists()
989 - .col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key())
979 + .col(
980 + ColumnDef::new(AuditLogs::Id)
981 + .uuid()
982 + .not_null()
983 + .primary_key(),
984 + )
990 985 .col(ColumnDef::new(AuditLogs::UserId).uuid())
991 986 .col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null())
992 987 .col(ColumnDef::new(AuditLogs::TargetUserId).uuid())
993 - .col(ColumnDef::new(AuditLogs::Level).string_len(10).not_null().default("INFO"))
988 + .col(
989 + ColumnDef::new(AuditLogs::Level)
990 + .string_len(10)
991 + .not_null()
992 + .default("INFO"),
993 + )
994 994 .col(ColumnDef::new(AuditLogs::Message).text().not_null())
995 - .col(ColumnDef::new(AuditLogs::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
995 + .col(
996 + ColumnDef::new(AuditLogs::CreatedAt)
997 + .timestamp_with_time_zone()
998 + .not_null()
999 + .default(Expr::current_timestamp()),
1000 + )
996 1001 .foreign_key(
997 1002 ForeignKey::create()
998 1003 .name("fk_audit_logs_user")
@@ -1067,11 +1072,7 @@ impl MigrationTrait for Migration {
1067 1072 .await?;
1068 1073
1069 1074 manager
1070 - .drop_table(
1071 - Table::drop()
1072 - .table(EmailDeliveryStatus::Table)
1073 - .to_owned(),
1074 - )
1075 + .drop_table(Table::drop().table(EmailDeliveryStatus::Table).to_owned())
1075 1076 .await?;
1076 1077
1077 1078 manager
@@ -1087,19 +1088,11 @@ impl MigrationTrait for Migration {
1087 1088 .await?;
1088 1089
1089 1090 manager
1090 - .drop_table(
1091 - Table::drop()
1092 - .table(EmailValidationTokens::Table)
1093 - .to_owned(),
1094 - )
1091 + .drop_table(Table::drop().table(EmailValidationTokens::Table).to_owned())
1095 1092 .await?;
1096 1093
1097 1094 manager
1098 - .drop_table(
1099 - Table::drop()
1100 - .table(PasswordResetTokens::Table)
1101 - .to_owned(),
1102 - )
1095 + .drop_table(Table::drop().table(PasswordResetTokens::Table).to_owned())
1103 1096 .await?;
1104 1097
1105 1098 manager
M crates/rustwarden-core/migrations/src/m20260306_000002_create_app_settings_table.rs
+1 -0
@@ -1,5 +1,6 @@
1 1 use sea_orm_migration::prelude::*;
2 2
3 +/// Migration: creates the `app_settings` key-value table.
3 4 #[derive(DeriveMigrationName)]
4 5 pub struct Migration;
5 6
M crates/rustwarden-core/migrations/src/m20260309_000003_create_permissions_tables.rs
+2 -5
@@ -1,5 +1,6 @@
1 1 use sea_orm_migration::prelude::*;
2 2
3 +/// Migration: creates the `permissions`, `roles`, and `role_permissions` tables.
3 4 #[derive(DeriveMigrationName)]
4 5 pub struct Migration;
5 6
@@ -25,11 +26,7 @@ impl MigrationTrait for Migration {
25 26 .not_null()
26 27 .unique_key(),
27 28 )
28 - .col(
29 - ColumnDef::new(Permissions::Description)
30 - .text()
31 - .null(),
32 - )
29 + .col(ColumnDef::new(Permissions::Description).text().null())
33 30 .col(
34 31 ColumnDef::new(Permissions::Category)
35 32 .string()
M crates/rustwarden-core/migrations/src/m20260309_000004_create_resources_tables.rs
+4 -15
@@ -1,5 +1,6 @@
1 1 use sea_orm_migration::prelude::*;
2 2
3 +/// Migration: creates the `resources` and `resource_shares` tables.
3 4 #[derive(DeriveMigrationName)]
4 5 pub struct Migration;
5 6
@@ -24,16 +25,8 @@ impl MigrationTrait for Migration {
24 25 .string_len(100)
25 26 .not_null(),
26 27 )
27 - .col(
28 - ColumnDef::new(Resources::ResourceId)
29 - .uuid()
30 - .not_null(),
31 - )
32 - .col(
33 - ColumnDef::new(Resources::OwnerId)
34 - .uuid()
35 - .not_null(),
36 - )
28 + .col(ColumnDef::new(Resources::ResourceId).uuid().not_null())
29 + .col(ColumnDef::new(Resources::OwnerId).uuid().not_null())
37 30 .col(
38 31 ColumnDef::new(Resources::IsPublic)
39 32 .boolean()
@@ -104,11 +97,7 @@ impl MigrationTrait for Migration {
104 97 .not_null()
105 98 .primary_key(),
106 99 )
107 - .col(
108 - ColumnDef::new(ResourceShares::ResourceId)
109 - .uuid()
110 - .not_null(),
111 - )
100 + .col(ColumnDef::new(ResourceShares::ResourceId).uuid().not_null())
112 101 .col(
113 102 ColumnDef::new(ResourceShares::SharedWithUserId)
114 103 .uuid()
M crates/rustwarden-core/migrations/src/m20260310_000005_create_oauth_accounts_table.rs
+2 -5
@@ -1,5 +1,6 @@
1 1 use sea_orm_migration::prelude::*;
2 2
3 +/// Migration: creates the `oauth_accounts` table for linked OAuth identities.
3 4 #[derive(DeriveMigrationName)]
4 5 pub struct Migration;
5 6
@@ -17,11 +18,7 @@ impl MigrationTrait for Migration {
17 18 .not_null()
18 19 .primary_key(),
19 20 )
20 - .col(
21 - ColumnDef::new(OauthAccounts::UserId)
22 - .uuid()
23 - .not_null(),
24 - )
21 + .col(ColumnDef::new(OauthAccounts::UserId).uuid().not_null())
25 22 .col(
26 23 ColumnDef::new(OauthAccounts::Provider)
27 24 .string()
A crates/rustwarden-core/migrations/src/m20260409_000006_create_totp_tables.rs
+184 -0
@@ -0,0 +1,184 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +/// Migration: creates the TOTP secrets and backup-codes tables for 2FA.
4 +#[derive(DeriveMigrationName)]
5 +pub struct Migration;
6 +
7 +#[async_trait::async_trait]
8 +impl MigrationTrait for Migration {
9 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
10 + // Table: user_totp (TOTP 2FA data per user)
11 + manager
12 + .create_table(
13 + Table::create()
14 + .table(UserTotp::Table)
15 + .if_not_exists()
16 + .col(ColumnDef::new(UserTotp::Id).uuid().not_null().primary_key())
17 + .col(
18 + ColumnDef::new(UserTotp::UserId)
19 + .uuid()
20 + .not_null()
21 + .unique_key(),
22 + )
23 + .col(ColumnDef::new(UserTotp::SecretEncrypted).text().not_null())
24 + .col(
25 + ColumnDef::new(UserTotp::Enabled)
26 + .boolean()
27 + .not_null()
28 + .default(false),
29 + )
30 + .col(ColumnDef::new(UserTotp::BackupCodesEncrypted).text().null())
31 + .col(
32 + ColumnDef::new(UserTotp::BackupCodesRemaining)
33 + .integer()
34 + .not_null()
35 + .default(0),
36 + )
37 + .col(
38 + ColumnDef::new(UserTotp::EnabledAt)
39 + .timestamp_with_time_zone()
40 + .null(),
41 + )
42 + .col(
43 + ColumnDef::new(UserTotp::CreatedAt)
44 + .timestamp_with_time_zone()
45 + .not_null()
46 + .default(Expr::current_timestamp()),
47 + )
48 + .col(
49 + ColumnDef::new(UserTotp::UpdatedAt)
50 + .timestamp_with_time_zone()
51 + .not_null()
52 + .default(Expr::current_timestamp()),
53 + )
54 + .foreign_key(
55 + ForeignKey::create()
56 + .name("fk_user_totp_user_id")
57 + .from(UserTotp::Table, UserTotp::UserId)
58 + .to(Users::Table, Users::Id)
59 + .on_delete(ForeignKeyAction::Cascade),
60 + )
61 + .to_owned(),
62 + )
63 + .await?;
64 +
65 + // Table: totp_challenges (ephemeral login 2FA challenges)
66 + manager
67 + .create_table(
68 + Table::create()
69 + .table(TotpChallenges::Table)
70 + .if_not_exists()
71 + .col(
72 + ColumnDef::new(TotpChallenges::Id)
73 + .uuid()
74 + .not_null()
75 + .primary_key(),
76 + )
77 + .col(ColumnDef::new(TotpChallenges::UserId).uuid().not_null())
78 + .col(
79 + ColumnDef::new(TotpChallenges::ChallengeToken)
80 + .string()
81 + .string_len(128)
82 + .not_null()
83 + .unique_key(),
84 + )
85 + .col(
86 + ColumnDef::new(TotpChallenges::Attempts)
87 + .integer()
88 + .not_null()
89 + .default(0),
90 + )
91 + .col(
92 + ColumnDef::new(TotpChallenges::MaxAttempts)
93 + .integer()
94 + .not_null()
95 + .default(5),
96 + )
97 + .col(
98 + ColumnDef::new(TotpChallenges::ExpiresAt)
99 + .timestamp_with_time_zone()
100 + .not_null(),
101 + )
102 + .col(
103 + ColumnDef::new(TotpChallenges::CreatedAt)
104 + .timestamp_with_time_zone()
105 + .not_null()
106 + .default(Expr::current_timestamp()),
107 + )
108 + .foreign_key(
109 + ForeignKey::create()
110 + .name("fk_totp_challenges_user_id")
111 + .from(TotpChallenges::Table, TotpChallenges::UserId)
112 + .to(Users::Table, Users::Id)
113 + .on_delete(ForeignKeyAction::Cascade),
114 + )
115 + .to_owned(),
116 + )
117 + .await?;
118 +
119 + // Index on challenge_token for fast lookup
120 + manager
121 + .create_index(
122 + Index::create()
123 + .name("idx_totp_challenges_token")
124 + .table(TotpChallenges::Table)
125 + .col(TotpChallenges::ChallengeToken)
126 + .to_owned(),
127 + )
128 + .await?;
129 +
130 + // Index on expires_at for cleanup queries
131 + manager
132 + .create_index(
133 + Index::create()
134 + .name("idx_totp_challenges_expires")
135 + .table(TotpChallenges::Table)
136 + .col(TotpChallenges::ExpiresAt)
137 + .to_owned(),
138 + )
139 + .await?;
140 +
141 + Ok(())
142 + }
143 +
144 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
145 + manager
146 + .drop_table(Table::drop().table(TotpChallenges::Table).to_owned())
147 + .await?;
148 + manager
149 + .drop_table(Table::drop().table(UserTotp::Table).to_owned())
150 + .await
151 + }
152 +}
153 +
154 +#[derive(Iden)]
155 +enum UserTotp {
156 + Table,
157 + Id,
158 + UserId,
159 + SecretEncrypted,
160 + Enabled,
161 + BackupCodesEncrypted,
162 + BackupCodesRemaining,
163 + EnabledAt,
164 + CreatedAt,
165 + UpdatedAt,
166 +}
167 +
168 +#[derive(Iden)]
169 +enum TotpChallenges {
170 + Table,
171 + Id,
172 + UserId,
173 + ChallengeToken,
174 + Attempts,
175 + MaxAttempts,
176 + ExpiresAt,
177 + CreatedAt,
178 +}
179 +
180 +#[derive(Iden)]
181 +enum Users {
182 + Table,
183 + Id,
184 +}
A crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs
+147 -0
@@ -0,0 +1,147 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +/// Migration: creates the `personal_access_tokens` table.
4 +#[derive(DeriveMigrationName)]
5 +pub struct Migration;
6 +
7 +#[async_trait::async_trait]
8 +impl MigrationTrait for Migration {
9 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
10 + // Table: personal_access_tokens (long-lived API tokens with scopes)
11 + //
12 + // Complements refresh_token: whereas refresh_token implements JWT
13 + // rotation (one-use, detects reuse), PATs are static secrets consumed
14 + // unchanged across calls (e.g. git push over HTTPS). Hash-only storage,
15 + // nullable expiration, and per-user unique names.
16 + manager
17 + .create_table(
18 + Table::create()
19 + .table(PersonalAccessTokens::Table)
20 + .if_not_exists()
21 + .col(
22 + ColumnDef::new(PersonalAccessTokens::Id)
23 + .uuid()
24 + .not_null()
25 + .primary_key(),
26 + )
27 + .col(
28 + ColumnDef::new(PersonalAccessTokens::UserId)
29 + .uuid()
30 + .not_null(),
31 + )
32 + .col(
33 + ColumnDef::new(PersonalAccessTokens::Name)
34 + .string()
35 + .string_len(128)
36 + .not_null(),
37 + )
38 + .col(
39 + ColumnDef::new(PersonalAccessTokens::TokenHash)
40 + .string()
41 + .string_len(64) // SHA-256 hex
42 + .not_null()
43 + .unique_key(),
44 + )
45 + .col(
46 + ColumnDef::new(PersonalAccessTokens::Scopes)
47 + .array(sea_query::ColumnType::Text)
48 + .not_null(),
49 + )
50 + .col(
51 + ColumnDef::new(PersonalAccessTokens::ExpiresAt)
52 + .timestamp_with_time_zone()
53 + .null(),
54 + )
55 + .col(
56 + ColumnDef::new(PersonalAccessTokens::LastUsedAt)
57 + .timestamp_with_time_zone()
58 + .null(),
59 + )
60 + .col(
61 + ColumnDef::new(PersonalAccessTokens::RevokedAt)
62 + .timestamp_with_time_zone()
63 + .null(),
64 + )
65 + .col(
66 + ColumnDef::new(PersonalAccessTokens::CreatedAt)
67 + .timestamp_with_time_zone()
68 + .not_null()
69 + .default(Expr::current_timestamp()),
70 + )
71 + .foreign_key(
72 + ForeignKey::create()
73 + .name("fk_personal_access_tokens_user_id")
74 + .from(PersonalAccessTokens::Table, PersonalAccessTokens::UserId)
75 + .to(Users::Table, Users::Id)
76 + .on_delete(ForeignKeyAction::Cascade),
77 + )
78 + .to_owned(),
79 + )
80 + .await?;
81 +
82 + // One token name per user (UI uniqueness guarantee).
83 + manager
84 + .create_index(
85 + Index::create()
86 + .name("idx_pat_name_per_user")
87 + .table(PersonalAccessTokens::Table)
88 + .col(PersonalAccessTokens::UserId)
89 + .col(PersonalAccessTokens::Name)
90 + .unique()
91 + .to_owned(),
92 + )
93 + .await?;
94 +
95 + // Fast lookup for `verify(token)` — token_hash is already unique, but
96 + // an explicit btree index documents intent and lets EXPLAIN stay stable
97 + // across rewrites.
98 + manager
99 + .create_index(
100 + Index::create()
101 + .name("idx_pat_token_hash")
102 + .table(PersonalAccessTokens::Table)
103 + .col(PersonalAccessTokens::TokenHash)
104 + .to_owned(),
105 + )
106 + .await?;
107 +
108 + // Supports the cleanup job `WHERE expires_at < now()`.
109 + manager
110 + .create_index(
111 + Index::create()
112 + .name("idx_pat_expires_at")
113 + .table(PersonalAccessTokens::Table)
114 + .col(PersonalAccessTokens::ExpiresAt)
115 + .to_owned(),
116 + )
117 + .await?;
118 +
119 + Ok(())
120 + }
121 +
122 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
123 + manager
124 + .drop_table(Table::drop().table(PersonalAccessTokens::Table).to_owned())
125 + .await
126 + }
127 +}
128 +
129 +#[derive(Iden)]
130 +enum PersonalAccessTokens {
131 + Table,
132 + Id,
133 + UserId,
134 + Name,
135 + TokenHash,
136 + Scopes,
137 + ExpiresAt,
138 + LastUsedAt,
139 + RevokedAt,
140 + CreatedAt,
141 +}
142 +
143 +#[derive(Iden)]
144 +enum Users {
145 + Table,
146 + Id,
147 +}
A crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs
+277 -0
@@ -0,0 +1,277 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +/// Migration: creates the `teams` and `team_members` tables.
4 +#[derive(DeriveMigrationName)]
5 +pub struct Migration;
6 +
7 +#[async_trait::async_trait]
8 +impl MigrationTrait for Migration {
9 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
10 + // -----------------------------------------------------------------
11 + // 1. teams — named groups of users with an owner
12 + // -----------------------------------------------------------------
13 + manager
14 + .create_table(
15 + Table::create()
16 + .table(Teams::Table)
17 + .if_not_exists()
18 + .col(ColumnDef::new(Teams::Id).uuid().not_null().primary_key())
19 + .col(
20 + ColumnDef::new(Teams::Slug)
21 + .string()
22 + .string_len(64)
23 + .not_null()
24 + .unique_key(),
25 + )
26 + .col(
27 + ColumnDef::new(Teams::Name)
28 + .string()
29 + .string_len(128)
30 + .not_null(),
31 + )
32 + .col(ColumnDef::new(Teams::OwnerId).uuid().not_null())
33 + .col(
34 + ColumnDef::new(Teams::CreatedAt)
35 + .timestamp_with_time_zone()
36 + .not_null()
37 + .default(Expr::current_timestamp()),
38 + )
39 + .foreign_key(
40 + ForeignKey::create()
41 + .name("fk_teams_owner_id")
42 + .from(Teams::Table, Teams::OwnerId)
43 + .to(Users::Table, Users::Id)
44 + .on_delete(ForeignKeyAction::Cascade),
45 + )
46 + .to_owned(),
47 + )
48 + .await?;
49 +
50 + // -----------------------------------------------------------------
51 + // 2. team_members — N:N with role (owner/admin/member)
52 + // -----------------------------------------------------------------
53 + manager
54 + .create_table(
55 + Table::create()
56 + .table(TeamMembers::Table)
57 + .if_not_exists()
58 + .col(ColumnDef::new(TeamMembers::TeamId).uuid().not_null())
59 + .col(ColumnDef::new(TeamMembers::UserId).uuid().not_null())
60 + .col(
61 + ColumnDef::new(TeamMembers::Role)
62 + .string()
63 + .string_len(16)
64 + .not_null()
65 + .default("member"),
66 + )
67 + .col(
68 + ColumnDef::new(TeamMembers::JoinedAt)
69 + .timestamp_with_time_zone()
70 + .not_null()
71 + .default(Expr::current_timestamp()),
72 + )
73 + .primary_key(
74 + Index::create()
75 + .col(TeamMembers::TeamId)
76 + .col(TeamMembers::UserId),
77 + )
78 + .foreign_key(
79 + ForeignKey::create()
80 + .name("fk_team_members_team_id")
81 + .from(TeamMembers::Table, TeamMembers::TeamId)
82 + .to(Teams::Table, Teams::Id)
83 + .on_delete(ForeignKeyAction::Cascade),
84 + )
85 + .foreign_key(
86 + ForeignKey::create()
87 + .name("fk_team_members_user_id")
88 + .from(TeamMembers::Table, TeamMembers::UserId)
89 + .to(Users::Table, Users::Id)
90 + .on_delete(ForeignKeyAction::Cascade),
91 + )
92 + .to_owned(),
93 + )
94 + .await?;
95 +
96 + // Index: list teams of a user in constant time.
97 + manager
98 + .create_index(
99 + Index::create()
100 + .name("idx_team_members_user")
101 + .table(TeamMembers::Table)
102 + .col(TeamMembers::UserId)
103 + .to_owned(),
104 + )
105 + .await?;
106 +
107 + // -----------------------------------------------------------------
108 + // 3. resource_shares — extend to accept team principals
109 + //
110 + // Strategy (option B of the evolution plan): keep the existing
111 + // `shared_with_user_id` column (now nullable) and add a companion
112 + // `shared_with_team_id`. A DB-level CHECK constraint enforces that
113 + // exactly one is set — keeps schema-level referential integrity
114 + // instead of a polymorphic ID + discriminator string.
115 + // -----------------------------------------------------------------
116 + manager
117 + .get_connection()
118 + .execute_unprepared(
119 + "ALTER TABLE resource_shares ALTER COLUMN shared_with_user_id DROP NOT NULL",
120 + )
121 + .await?;
122 +
123 + manager
124 + .alter_table(
125 + Table::alter()
126 + .table(ResourceShares::Table)
127 + .add_column(
128 + ColumnDef::new(ResourceShares::SharedWithTeamId)
129 + .uuid()
130 + .null(),
131 + )
132 + .to_owned(),
133 + )
134 + .await?;
135 +
136 + manager
137 + .get_connection()
138 + .execute_unprepared(
139 + "ALTER TABLE resource_shares \
140 + ADD CONSTRAINT fk_resource_shares_shared_with_team \
141 + FOREIGN KEY (shared_with_team_id) REFERENCES teams(id) ON DELETE CASCADE",
142 + )
143 + .await?;
144 +
145 + // Exactly one of (user, team) must be set.
146 + manager
147 + .get_connection()
148 + .execute_unprepared(
149 + "ALTER TABLE resource_shares \
150 + ADD CONSTRAINT chk_resource_shares_principal \
151 + CHECK ((shared_with_user_id IS NOT NULL)::int + \
152 + (shared_with_team_id IS NOT NULL)::int = 1)",
153 + )
154 + .await?;
155 +
156 + // The existing unique (resource_id, shared_with_user_id) index becomes
157 + // partial — only enforce uniqueness when the user column is set.
158 + // We drop & recreate instead of altering to keep this cross-backend-friendly.
159 + manager
160 + .get_connection()
161 + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_resource_user_unique")
162 + .await?;
163 +
164 + manager
165 + .get_connection()
166 + .execute_unprepared(
167 + "CREATE UNIQUE INDEX idx_resource_shares_resource_user_unique \
168 + ON resource_shares (resource_id, shared_with_user_id) \
169 + WHERE shared_with_user_id IS NOT NULL",
170 + )
171 + .await?;
172 +
173 + manager
174 + .get_connection()
175 + .execute_unprepared(
176 + "CREATE UNIQUE INDEX idx_resource_shares_resource_team_unique \
177 + ON resource_shares (resource_id, shared_with_team_id) \
178 + WHERE shared_with_team_id IS NOT NULL",
179 + )
180 + .await?;
181 +
182 + manager
183 + .get_connection()
184 + .execute_unprepared(
185 + "CREATE INDEX idx_resource_shares_team ON resource_shares (shared_with_team_id) \
186 + WHERE shared_with_team_id IS NOT NULL",
187 + )
188 + .await?;
189 +
190 + Ok(())
191 + }
192 +
193 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
194 + manager
195 + .get_connection()
196 + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_team")
197 + .await?;
198 + manager
199 + .get_connection()
200 + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_resource_team_unique")
201 + .await?;
202 + manager
203 + .get_connection()
204 + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_resource_user_unique")
205 + .await?;
206 + manager
207 + .get_connection()
208 + .execute_unprepared(
209 + "CREATE UNIQUE INDEX idx_resource_shares_resource_user_unique \
210 + ON resource_shares (resource_id, shared_with_user_id)",
211 + )
212 + .await?;
213 + manager
214 + .get_connection()
215 + .execute_unprepared(
216 + "ALTER TABLE resource_shares DROP CONSTRAINT IF EXISTS chk_resource_shares_principal",
217 + )
218 + .await?;
219 + manager
220 + .get_connection()
221 + .execute_unprepared(
222 + "ALTER TABLE resource_shares DROP CONSTRAINT IF EXISTS fk_resource_shares_shared_with_team",
223 + )
224 + .await?;
225 + manager
226 + .alter_table(
227 + Table::alter()
228 + .table(ResourceShares::Table)
229 + .drop_column(ResourceShares::SharedWithTeamId)
230 + .to_owned(),
231 + )
232 + .await?;
233 + manager
234 + .get_connection()
235 + .execute_unprepared(
236 + "ALTER TABLE resource_shares ALTER COLUMN shared_with_user_id SET NOT NULL",
237 + )
238 + .await?;
239 + manager
240 + .drop_table(Table::drop().table(TeamMembers::Table).to_owned())
241 + .await?;
242 + manager
243 + .drop_table(Table::drop().table(Teams::Table).to_owned())
244 + .await
245 + }
246 +}
247 +
248 +#[derive(Iden)]
249 +enum Teams {
250 + Table,
251 + Id,
252 + Slug,
253 + Name,
254 + OwnerId,
255 + CreatedAt,
256 +}
257 +
258 +#[derive(Iden)]
259 +enum TeamMembers {
260 + Table,
261 + TeamId,
262 + UserId,
263 + Role,
264 + JoinedAt,
265 +}
266 +
267 +#[derive(Iden)]
268 +enum ResourceShares {
269 + Table,
270 + SharedWithTeamId,
271 +}
272 +
273 +#[derive(Iden)]
274 +enum Users {
275 + Table,
276 + Id,
277 +}
M crates/rustwarden-core/migrations/src/main.rs
+2 -0
@@ -1,3 +1,5 @@
1 +//! CLI entry point for running SeaORM migrations against the Rustwarden database.
2 +
1 3 use sea_orm_migration::prelude::*;
2 4
3 5 #[path = "lib.rs"]
M crates/rustwarden-core/src/app.rs
+37 -22
@@ -18,7 +18,13 @@
18 18 //! }
19 19 //! ```
20 20
21 -use axum::{extract::Request, http::{header::HeaderName, HeaderValue}, middleware::Next, response::Response, Router};
21 +use axum::{
22 + extract::Request,
23 + http::{header::HeaderName, HeaderValue},
24 + middleware::Next,
25 + response::Response,
26 + Router,
27 +};
22 28 use sea_orm::DatabaseConnection;
23 29 use std::path::PathBuf;
24 30 use std::sync::Arc;
@@ -140,9 +146,7 @@ impl Default for RustwardenBuilder {
140 146 enable_email: true,
141 147 enable_background_tasks: true,
142 148 locale_dirs: Vec::new(),
143 - supported_locales: vec![
144 - "fr".into(), "en".into(), "de".into(), "es".into(),
145 - ],
149 + supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()],
146 150 default_locale: "fr".into(),
147 151 }
148 152 }
@@ -190,7 +194,10 @@ impl RustwardenBuilder {
190 194
191 195 /// Register the default UI routes (login, profile, admin panel).
192 196 /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`).
193 - pub fn with_ui(mut self, routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static) -> Self {
197 + pub fn with_ui(
198 + mut self,
199 + routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static,
200 + ) -> Self {
194 201 self.ui_routes_fn = Some(Box::new(routes_fn));
195 202 self
196 203 }
@@ -274,8 +281,8 @@ impl RustwardenBuilder {
274 281 tracing::info!("Default admin and app settings initialized");
275 282
276 283 // 3. Auth config & session (SEC-M1: load once, share via Extension)
277 - let auth_config = AuthConfig::from_env()
278 - .map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?;
284 + let auth_config =
285 + AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?;
279 286 let session_layer = SessionConfig::create_memory_layer(&auth_config);
280 287 let auth_config = Arc::new(auth_config);
281 288 tracing::info!("Session management configured");
@@ -316,11 +323,8 @@ impl RustwardenBuilder {
316 323 let oauth_registry = {
317 324 use crate::config::oauth::OAuthConfig;
318 325 use crate::services::oauth::providers::{
319 - ProviderRegistry,
320 - google::GoogleProvider,
321 - github::GitHubProvider,
322 - discord::DiscordProvider,
323 - microsoft::MicrosoftProvider,
326 + discord::DiscordProvider, github::GitHubProvider, google::GoogleProvider,
327 + microsoft::MicrosoftProvider, ProviderRegistry,
324 328 };
325 329
326 330 let oauth_config = OAuthConfig::load(&db).await;
@@ -382,8 +386,8 @@ impl RustwardenBuilder {
382 386 // 7b. OAuth routes (conditional on feature + config)
383 387 #[cfg(feature = "oauth")]
384 388 {
385 - use axum::routing::get;
386 389 use crate::auth::oauth_handlers;
390 + use axum::routing::get;
387 391
388 392 let oauth_routes = Router::new()
389 393 .route(
@@ -442,7 +446,8 @@ impl RustwardenBuilder {
442 446 let app = app.layer(axum::Extension(oauth_registry));
443 447
444 448 // 9. Resolve address
445 - let address = self.server_address
449 + let address = self
450 + .server_address
446 451 .unwrap_or_else(|| ServerConfig::from_env().address());
447 452
448 453 tracing::info!("Rustwarden application built successfully");
@@ -501,10 +506,8 @@ impl RustwardenBuilder {
501 506
502 507 {
503 508 use crate::tasks::password_change_email_retry::PasswordChangeEmailRetryProcessor;
504 - let retry_processor = PasswordChangeEmailRetryProcessor::new(
505 - db_arc.clone(),
506 - email_cfg.clone(),
507 - );
509 + let retry_processor =
510 + PasswordChangeEmailRetryProcessor::new(db_arc.clone(), email_cfg.clone());
508 511 retry_processor.start();
509 512 tracing::info!("Password change email retry task started");
510 513 }
@@ -532,11 +535,19 @@ impl RustwardenBuilder {
532 535 for (locale, translations) in all_translations {
533 536 key_count += translations.len();
534 537 locale_count += 1;
535 - if let Err(e) = service.load_translations(locale.clone(), translations).await {
538 + if let Err(e) = service
539 + .load_translations(locale.clone(), translations)
540 + .await
541 + {
536 542 tracing::error!("Failed to load translations for {}: {}", locale, e);
537 543 }
538 544 }
539 - tracing::info!("Loaded {} locales ({} keys) from {}", locale_count, key_count, dir);
545 + tracing::info!(
546 + "Loaded {} locales ({} keys) from {}",
547 + locale_count,
548 + key_count,
549 + dir
550 + );
540 551 }
541 552 Err(e) => {
542 553 tracing::warn!("Failed to load translations from {}: {}", dir, e);
@@ -577,7 +588,7 @@ async fn security_headers_middleware(request: Request, next: Next) -> Response {
577 588 headers.insert(
578 589 HeaderName::from_static("content-security-policy"),
579 590 HeaderValue::from_static(
580 - "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'"
591 + "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'"
581 592 ),
582 593 );
583 594 // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE
@@ -606,7 +617,11 @@ async fn locale_middleware(
606 617 .get("locale")
607 618 .and_then(|c| {
608 619 let v = c.value().to_string();
609 - if supported.contains(&v) { Some(v) } else { None }
620 + if supported.contains(&v) {
621 + Some(v)
622 + } else {
623 + None
624 + }
610 625 })
611 626 .or_else(|| {
612 627 parts
M crates/rustwarden-core/src/auth/handlers.rs
+310 -46
@@ -13,11 +13,11 @@ use axum::{
13 13 response::{Html, IntoResponse, Response},
14 14 Extension, Json,
15 15 };
16 -use std::sync::Arc;
17 16 use axum_extra::extract::CookieJar;
18 17 use chrono::{DateTime, Utc};
19 18 use sea_orm::DatabaseConnection;
20 19 use serde::{Deserialize, Serialize};
20 +use std::sync::Arc;
21 21 use time::Duration;
22 22 use tower_sessions::Session;
23 23 use uuid::Uuid;
@@ -30,124 +30,178 @@ use crate::error::AppError;
30 30 use crate::hooks::RustwardenHooks;
31 31 use crate::services::{
32 32 auth_logging::AuthLoggingService, auth_service::AuthService,
33 - email_validation_service::EmailValidationService,
34 - jwt_blacklist_service::JwtBlacklistService, password_reset_service::PasswordResetService,
35 - password_service::PasswordService, refresh_token_service::RefreshTokenService,
36 - role_service::RoleService, user_preferences_service::UserPreferencesService,
37 - user_service::UserService,
33 + email_validation_service::EmailValidationService, jwt_blacklist_service::JwtBlacklistService,
34 + password_reset_service::PasswordResetService, password_service::PasswordService,
35 + refresh_token_service::RefreshTokenService, role_service::RoleService,
36 + user_preferences_service::UserPreferencesService, user_service::UserService,
38 37 };
39 38
40 39 // --- Auth DTOs (inlined, no separate dto module) ---
41 40
41 +/// Request body for POST /api/v1/auth/login.
42 42 #[derive(Debug, Deserialize, Validate)]
43 43 pub struct LoginRequest {
44 + /// Username or email address of the authenticating user.
44 45 #[validate(length(min = 1))]
45 46 pub username: String,
47 + /// Plaintext password (validated then compared against the stored hash).
46 48 #[validate(length(min = 1))]
47 49 pub password: String,
48 - /// Si true, le refresh token a une durée de vie étendue.
49 - /// Défaut : false (rétrocompatibilité).
50 + /// When true, the refresh token gets an extended lifetime.
51 + /// Default: false (backward compatible).
50 52 #[serde(default)]
51 53 pub remember_me: bool,
52 54 }
53 55
56 +/// Compact user representation returned inside login/refresh responses.
54 57 #[derive(Debug, Serialize)]
55 58 pub struct UserInfo {
59 + /// User's unique identifier.
56 60 pub id: Uuid,
61 + /// User's login name.
57 62 pub username: String,
63 + /// User's email address.
58 64 pub email: String,
65 + /// List of role names assigned to the user.
59 66 pub roles: Vec<String>,
67 + /// When `true`, the user must change their password before proceeding.
60 68 pub must_change_password: Option<bool>,
69 + /// Indicates that the user must set up 2FA before being allowed to navigate.
70 + #[serde(skip_serializing_if = "Option::is_none")]
71 + pub must_setup_2fa: Option<bool>,
61 72 }
62 73
74 +/// Response body for a successful login.
63 75 #[derive(Debug, Serialize)]
64 76 pub struct LoginResponse {
77 + /// JWT access token.
65 78 pub access_token: String,
79 + /// Opaque refresh token (absent when the server has not issued one).
66 80 pub refresh_token: Option<String>,
81 + /// Token type, always `"Bearer"`.
67 82 pub token_type: String,
83 + /// Access token lifetime in seconds.
68 84 pub expires_in: i64,
69 - /// Durée de vie du refresh token en secondes.
85 + /// Refresh token lifetime in seconds.
70 86 pub refresh_expires_in: i64,
87 + /// Compact user info embedded in the login response.
71 88 pub user: UserInfo,
72 89 }
73 90
91 +/// Response body for GET /api/v1/auth/me.
74 92 #[derive(Debug, Serialize)]
75 93 pub struct CurrentUserResponse {
94 + /// User's unique identifier.
76 95 pub id: Uuid,
96 + /// User's login name.
77 97 pub username: String,
98 + /// User's email address.
78 99 pub email: String,
100 + /// List of role names assigned to the user.
79 101 pub roles: Vec<String>,
102 + /// Timestamp when the user account was created.
80 103 pub created_at: DateTime<Utc>,
104 + /// Timestamp of the last update to the user account.
81 105 pub updated_at: DateTime<Utc>,
106 + /// When `true`, the user must change their password before proceeding.
82 107 pub must_change_password: bool,
83 108 }
84 109
110 +/// Request body for POST /api/v1/auth/refresh.
85 111 #[derive(Debug, Deserialize, Validate)]
86 112 pub struct RefreshRequest {
113 + /// The opaque refresh token previously issued by the server.
87 114 #[validate(length(min = 1))]
88 115 pub refresh_token: String,
89 116 }
90 117
118 +/// Response body for a successful token refresh.
91 119 #[derive(Debug, Serialize)]
92 120 pub struct RefreshResponse {
121 + /// New JWT access token.
93 122 pub access_token: String,
123 + /// New refresh token (rotated on each use).
94 124 pub refresh_token: Option<String>,
125 + /// Token type, always `"Bearer"`.
95 126 pub token_type: String,
127 + /// Access token lifetime in seconds.
96 128 pub expires_in: i64,
97 - /// Durée de vie du refresh token en secondes.
129 + /// Refresh token lifetime in seconds.
98 130 pub refresh_expires_in: i64,
99 131 }
100 132
133 +/// Request body for POST /api/v1/auth/forgot-password.
101 134 #[derive(Debug, Deserialize, Validate)]
102 135 pub struct ForgotPasswordRequest {
136 + /// Email address to send the password-reset link to.
103 137 #[validate(email)]
104 138 pub email: String,
105 139 }
106 140
141 +/// Response body for the forgot-password endpoint.
107 142 #[derive(Debug, Serialize)]
108 143 pub struct ForgotPasswordResponse {
144 + /// Generic confirmation message (does not reveal whether the email exists).
109 145 pub message: String,
110 146 }
111 147
148 +/// Request body for POST /api/v1/auth/reset-password.
112 149 #[derive(Debug, Deserialize, Validate)]
113 150 pub struct ResetPasswordRequest {
151 + /// Single-use reset token received via email.
114 152 #[validate(length(min = 1))]
115 153 pub token: String,
154 + /// The new plaintext password to set (minimum 8 characters).
116 155 #[validate(length(min = 8))]
117 156 pub new_password: String,
118 157 }
119 158
159 +/// Response body for the reset-password endpoint.
120 160 #[derive(Debug, Serialize)]
121 161 pub struct ResetPasswordResponse {
162 + /// Confirmation message returned after a successful password reset.
122 163 pub message: String,
123 164 }
124 165
166 +/// Request body for POST /api/v1/auth/resend-verification.
125 167 #[derive(Debug, Deserialize, Validate)]
126 168 pub struct ResendVerificationRequest {
169 + /// Email address to resend the verification link to.
127 170 #[validate(email)]
128 171 pub email: String,
129 172 }
130 173
174 +/// Response body for the resend-verification endpoint.
131 175 #[derive(Debug, Serialize)]
132 176 pub struct ResendVerificationResponse {
177 + /// Generic confirmation message (does not reveal whether the email exists).
133 178 pub message: String,
134 179 }
135 180
181 +/// Request body for POST /api/v1/auth/register.
136 182 #[derive(Debug, Deserialize, Validate)]
137 183 pub struct RegisterRequest {
184 + /// Desired login name (3–64 characters).
138 185 #[validate(length(min = 3, max = 64))]
139 186 pub username: String,
187 + /// Email address for the new account.
140 188 #[validate(email)]
141 189 pub email: String,
190 + /// Plaintext password for the new account (minimum 8 characters).
142 191 #[validate(length(min = 8))]
143 192 pub password: String,
144 193 }
145 194
195 +/// Response body for a successful registration.
146 196 #[derive(Debug, Serialize)]
147 197 pub struct RegisterResponse {
198 + /// Confirmation message describing next steps (e.g. email verification).
148 199 pub message: String,
200 + /// UUID of the newly created user.
149 201 pub user_id: Uuid,
202 + /// Login name of the newly created user.
150 203 pub username: String,
204 + /// Email address of the newly created user.
151 205 pub email: String,
152 206 }
153 207
@@ -277,19 +331,57 @@ pub async fn login(
277 331 user.email
278 332 );
279 333 // Journaliser sans stocker de secrets
280 - AuthLoggingService::log_blocked_login(&db, &user.email, reason).await?;
334 + AuthLoggingService::log_blocked_login(
335 + &db,
336 + &user.email,
337 + reason,
338 + hooks.as_ref().map(|Extension(h)| h),
339 + )
340 + .await?;
281 341 return Err(AppError::Validation(
282 342 "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
283 343 ));
284 344 }
285 345
346 + // Check if 2FA is forced globally
347 + let totp_force_enabled =
348 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
349 + &db,
350 + &["totp_force_enabled"],
351 + false,
352 + )
353 + .await
354 + .unwrap_or(false);
355 +
356 + // Check if user has TOTP 2FA enabled
357 + if crate::services::totp_service::TotpService::is_totp_enabled(&db, user.id).await? {
358 + // Create a 2FA challenge instead of issuing tokens
359 + let challenge_token =
360 + crate::services::totp_service::TotpService::create_challenge(&db, user.id).await?;
361 + tracing::info!("2FA challenge created for user {}", user.username);
362 + let response = serde_json::json!({
363 + "requires_2fa": true,
364 + "challenge_token": challenge_token,
365 + });
366 + return Ok((axum::http::StatusCode::OK, Json(response)).into_response());
367 + }
368 +
286 369 // Fetch user roles
287 370 let roles = UserService::get_user_roles(&db, user.id).await?;
288 371
289 372 // Fetch user permissions
290 - let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?;
373 + let permissions =
374 + crate::services::permission_service::PermissionService::get_permissions_for_user(
375 + &db, user.id,
376 + )
377 + .await?;
291 378
292 - tracing::debug!("User {} has roles: {:?}, permissions: {:?}", user.username, roles, permissions);
379 + tracing::debug!(
380 + "User {} has roles: {:?}, permissions: {:?}",
381 + user.username,
382 + roles,
383 + permissions
384 + );
293 385
294 386 // Load auth configuration
295 387 let auth_config = AuthConfig::from_env().map_err(|e| {
@@ -298,8 +390,13 @@ pub async fn login(
298 390 })?;
299 391
300 392 // Generate JWT token
301 - let access_token =
302 - JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?;
393 + let access_token = JwtService::generate_token(
394 + &auth_config,
395 + user.id,
396 + &user.username,
397 + roles.clone(),
398 + permissions,
399 + )?;
303 400
304 401 // JWT token is already generated above
305 402 // No need to store in session serveur - JWT is stateless
@@ -312,12 +409,8 @@ pub async fn login(
312 409 auth_config.refresh_token_expiration_days
313 410 };
314 411
315 - let created_rt = RefreshTokenService::create_refresh_token(
316 - &db,
317 - user.id,
318 - rt_expiration_days,
319 - )
320 - .await?;
412 + let created_rt =
413 + RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?;
321 414
322 415 let refresh_token = Some(created_rt.plain_token);
323 416
@@ -327,13 +420,14 @@ pub async fn login(
327 420 refresh_token,
328 421 token_type: "Bearer".to_string(),
329 422 expires_in: auth_config.jwt_expiration_minutes * 60, // Convert minutes to seconds
330 - refresh_expires_in: rt_expiration_days * 86400, // Convert days to seconds
423 + refresh_expires_in: rt_expiration_days * 86400, // Convert days to seconds
331 424 user: UserInfo {
332 425 id: user.id,
333 426 username: user.username.clone(),
334 427 email: user.email.clone(),
335 428 roles,
336 429 must_change_password: Some(user.must_change_password),
430 + must_setup_2fa: if totp_force_enabled { Some(true) } else { None },
337 431 },
338 432 };
339 433
@@ -425,6 +519,154 @@ pub async fn login(
425 519 Ok(Response::from_parts(parts, body))
426 520 }
427 521
522 +/// Request payload for 2FA verification
523 +#[derive(Debug, Deserialize, Validate)]
524 +pub struct Verify2faRequest {
525 + /// Challenge token from the login response
526 + #[validate(length(min = 1))]
527 + pub challenge_token: String,
528 + /// TOTP code (6 digits) or backup code (8 alphanumeric chars)
529 + #[validate(length(min = 6, max = 16))]
530 + pub code: String,
531 +}
532 +
533 +/// Handler for POST /api/v1/auth/2fa/verify
534 +///
535 +/// Verifies a TOTP code or backup code for the 2FA login flow.
536 +/// On success, issues JWT + refresh token (same as normal login).
537 +pub async fn verify_2fa(
538 + State(db): State<DatabaseConnection>,
539 + _user_session: Session,
540 + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
541 + Json(payload): Json<Verify2faRequest>,
542 +) -> Result<Response, AppError> {
543 + payload
544 + .validate()
545 + .map_err(|e| AppError::Validation(format!("Invalid 2FA request: {}", e)))?;
546 +
547 + use crate::services::totp_service::TotpService;
548 +
549 + // Validate challenge (checks expiry + max attempts)
550 + let user_id = TotpService::validate_challenge(&db, &payload.challenge_token).await?;
551 +
552 + // Try TOTP code first (6 digits), then backup code
553 + let verified = if payload.code.len() == 6 && payload.code.chars().all(|c| c.is_ascii_digit()) {
554 + TotpService::verify_code(&db, user_id, &payload.code).await?
555 + } else {
556 + TotpService::verify_backup_code(&db, user_id, &payload.code).await?
557 + };
558 +
559 + if !verified {
560 + TotpService::record_challenge_attempt(&db, &payload.challenge_token).await?;
561 + tracing::warn!(user_id = %user_id, "TOTP_FAILED");
562 + return Err(AppError::TotpFailed);
563 + }
564 +
565 + // 2FA verified — consume challenge and issue tokens
566 + TotpService::consume_challenge(&db, &payload.challenge_token).await?;
567 + tracing::info!(user_id = %user_id, "TOTP_VERIFIED");
568 +
569 + // Fetch user data
570 + let user = UserService::get_user_by_id(&db, user_id)
571 + .await?
572 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
573 +
574 + let roles = UserService::get_user_roles(&db, user.id).await?;
575 + let permissions =
576 + crate::services::permission_service::PermissionService::get_permissions_for_user(
577 + &db, user.id,
578 + )
579 + .await?;
580 +
581 + // Load auth config and generate tokens
582 + let auth_config = AuthConfig::from_env()
583 + .map_err(|e| AppError::Config(format!("Auth configuration error: {}", e)))?;
584 +
585 + let access_token = JwtService::generate_token(
586 + &auth_config,
587 + user.id,
588 + &user.username,
589 + roles.clone(),
590 + permissions,
591 + )?;
592 +
593 + let rt_expiration_days = auth_config.refresh_token_expiration_days;
594 + let created_rt =
595 + RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?;
596 +
597 + let response_body = LoginResponse {
598 + access_token: access_token.clone(),
599 + refresh_token: Some(created_rt.plain_token),
600 + token_type: "Bearer".to_string(),
601 + expires_in: auth_config.jwt_expiration_minutes * 60,
602 + refresh_expires_in: rt_expiration_days * 86400,
603 + user: UserInfo {
604 + id: user.id,
605 + username: user.username.clone(),
606 + email: user.email.clone(),
607 + roles,
608 + must_change_password: Some(user.must_change_password),
609 + must_setup_2fa: None,
610 + },
611 + };
612 +
613 + // Create JWT cookie
614 + let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() {
615 + "strict" => axum_extra::extract::cookie::SameSite::Strict,
616 + "lax" => axum_extra::extract::cookie::SameSite::Lax,
617 + "none" => axum_extra::extract::cookie::SameSite::None,
618 + _ => axum_extra::extract::cookie::SameSite::Strict,
619 + };
620 +
621 + let cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token))
622 + .http_only(true)
623 + .same_site(same_site)
624 + .secure(auth_config.cookie_secure)
625 + .path("/")
626 + .max_age(Duration::seconds(auth_config.jwt_expiration_minutes * 60));
627 +
628 + let json_response: Response = Json(response_body).into_response();
629 + let (mut parts, body) = json_response.into_parts();
630 +
631 + if let Ok(hv) = cookie.to_string().parse() {
632 + parts.headers.append(axum::http::header::SET_COOKIE, hv);
633 + }
634 +
635 + // Set preference cookies
636 + if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await {
637 + let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme))
638 + .http_only(false)
639 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
640 + .path("/")
641 + .max_age(Duration::seconds(31536000));
642 + if let Ok(hv) = theme_cookie.to_string().parse() {
643 + parts.headers.append(axum::http::header::SET_COOKIE, hv);
644 + }
645 +
646 + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language))
647 + .http_only(false)
648 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
649 + .path("/")
650 + .max_age(Duration::seconds(31536000));
651 + if let Ok(hv) = locale_cookie.to_string().parse() {
652 + parts.headers.append(axum::http::header::SET_COOKIE, hv);
653 + }
654 + }
655 +
656 + // Regenerate session
657 + if let Err(e) = _user_session.cycle_id().await {
658 + tracing::warn!("Failed to cycle session ID: {}", e);
659 + }
660 +
661 + if let Some(Extension(hooks)) = hooks {
662 + if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await {
663 + tracing::warn!("Login hook error for {}: {}", user.username, e);
664 + }
665 + }
666 +
667 + Ok(Response::from_parts(parts, body))
668 +}
669 +
428 670 /// Handler for GET /api/v1/auth/me
429 671 ///
430 672 /// Returns the current authenticated user's information.
@@ -741,7 +983,9 @@ pub async fn refresh_token(
741 983 ) -> Result<Response, AppError> {
742 984 // SEC-C2: Per-IP rate limiting
743 985 if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
744 - return Err(AppError::Validation("Too many requests. Please try again later.".to_string()));
986 + return Err(AppError::Validation(
987 + "Too many requests. Please try again later.".to_string(),
988 + ));
745 989 }
746 990
747 991 // Validate input
@@ -784,7 +1028,11 @@ pub async fn refresh_token(
784 1028 let roles = UserService::get_user_roles(&db, user.id).await?;
785 1029
786 1030 // Get user permissions
787 - let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?;
1031 + let permissions =
1032 + crate::services::permission_service::PermissionService::get_permissions_for_user(
1033 + &db, user.id,
1034 + )
1035 + .await?;
788 1036
789 1037 // Load auth configuration
790 1038 let auth_config = AuthConfig::from_env().map_err(|e| {
@@ -793,8 +1041,13 @@ pub async fn refresh_token(
793 1041 })?;
794 1042
795 1043 // Generate new JWT access token
796 - let access_token =
797 - JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?;
1044 + let access_token = JwtService::generate_token(
1045 + &auth_config,
1046 + user.id,
1047 + &user.username,
1048 + roles.clone(),
1049 + permissions,
1050 + )?;
798 1051
799 1052 // Preserve original refresh token duration (remember me tokens stay long-lived)
800 1053 let original_duration_days = (old_token.expires_at - old_token.created_at).num_days();
@@ -803,12 +1056,8 @@ pub async fn refresh_token(
803 1056 .max(auth_config.refresh_token_expiration_days);
804 1057
805 1058 // Generate new refresh token (rotation)
806 - let new_created_rt = RefreshTokenService::create_refresh_token(
807 - &db,
808 - user.id,
809 - rt_expiration_days,
810 - )
811 - .await?;
1059 + let new_created_rt =
1060 + RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?;
812 1061
813 1062 // Mark old token as replaced (completes rotation)
814 1063 RefreshTokenService::mark_as_replaced(&db, old_token.id, new_created_rt.model.id).await?;
@@ -909,7 +1158,9 @@ pub async fn forgot_password(
909 1158 ) -> Result<impl IntoResponse, AppError> {
910 1159 // SEC-C2: Per-IP rate limiting
911 1160 if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
912 - return Err(AppError::Validation("Too many requests. Please try again later.".to_string()));
1161 + return Err(AppError::Validation(
1162 + "Too many requests. Please try again later.".to_string(),
1163 + ));
913 1164 }
914 1165
915 1166 // Validate email format
@@ -966,7 +1217,11 @@ pub async fn reset_password(
966 1217 .map_err(|e| AppError::Validation(format!("Invalid request: {}", e)))?;
967 1218
968 1219 // SEC-H1: Validate password complexity from DB rules
969 - crate::middleware::password_complexity::validate_password_complexity(&db, &payload.new_password).await?;
1220 + crate::middleware::password_complexity::validate_password_complexity(
1221 + &db,
1222 + &payload.new_password,
1223 + )
1224 + .await?;
970 1225
971 1226 // Load email config
972 1227 let email_config = EmailConfig::from_env()
@@ -1016,7 +1271,9 @@ pub async fn verify_email(
1016 1271 tracing::error!("Email configuration error: {}", e);
1017 1272 return EmailVerifiedTemplate {
1018 1273 success: false,
1019 - message: i18n_service.translate("email_verify.config_error", loc).await,
1274 + message: i18n_service
1275 + .translate("email_verify.config_error", loc)
1276 + .await,
1020 1277 };
1021 1278 }
1022 1279 };
@@ -1042,7 +1299,11 @@ pub async fn verify_email(
1042 1299 message: match e {
1043 1300 AppError::Validation(msg) => msg,
1044 1301 AppError::NotFound(msg) => msg,
1045 - _ => i18n_service.translate("email_verify.invalid_link", loc).await,
1302 + _ => {
1303 + i18n_service
1304 + .translate("email_verify.invalid_link", loc)
1305 + .await
1306 + }
1046 1307 },
1047 1308 }
1048 1309 }
@@ -1099,6 +1360,7 @@ pub async fn resend_verification(
1099 1360 /// Request body for resend verification by username
1100 1361 #[derive(Debug, Deserialize, Validate)]
1101 1362 pub struct ResendVerificationByUsernameRequest {
1363 + /// Login name of the account whose verification email should be resent.
1102 1364 #[validate(length(min = 1))]
1103 1365 pub username: String,
1104 1366 }
@@ -1184,7 +1446,9 @@ pub async fn register(
1184 1446 ) -> Result<Json<RegisterResponse>, AppError> {
1185 1447 // SEC-C2: Per-IP rate limiting
1186 1448 if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() {
1187 - return Err(AppError::Validation("Too many requests. Please try again later.".to_string()));
1449 + return Err(AppError::Validation(
1450 + "Too many requests. Please try again later.".to_string(),
1451 + ));
1188 1452 }
1189 1453
1190 1454 tracing::info!(
@@ -1199,16 +1463,16 @@ pub async fn register(
1199 1463 })?;
1200 1464
1201 1465 // SEC-H1: Validate password complexity from DB rules
1202 - crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password).await?;
1466 + crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password)
1467 + .await?;
1203 1468
1204 - let allow_registration =
1205 - crate::services::app_settings_service::AppSettingsService::get_bool(
1206 - &db,
1207 - "allow_registration",
1208 - false,
1209 - )
1210 - .await
1211 - .unwrap_or(false);
1469 + let allow_registration = crate::services::app_settings_service::AppSettingsService::get_bool(
1470 + &db,
1471 + "allow_registration",
1472 + false,
1473 + )
1474 + .await
1475 + .unwrap_or(false);
1212 1476
1213 1477 if !allow_registration {
1214 1478 tracing::warn!("Registration attempt blocked: registration is disabled");
M crates/rustwarden-core/src/auth/mod.rs
+7 -1
@@ -5,10 +5,16 @@
5 5 /// - Session management
6 6 /// - Refresh token system
7 7 /// - Authentication middleware
8 +/// Request handlers for login, logout, refresh, and current-user endpoints.
8 9 pub mod extractors;
10 +/// Authentication HTTP handlers.
9 11 pub mod handlers;
12 +/// JWT creation and validation service.
10 13 pub mod jwt;
14 +/// Axum extractor that validates a Bearer JWT and provides the authenticated user.
11 15 pub mod jwt_extractor;
12 -pub mod session;
16 +/// OAuth 2.0 / OIDC handlers (redirect initiation and callback processing).
13 17 #[cfg(feature = "oauth")]
14 18 pub mod oauth_handlers;
19 +/// Session helpers (CSRF token generation and retrieval).
20 +pub mod session;
M crates/rustwarden-core/src/auth/oauth_handlers.rs
+70 -26
@@ -17,19 +17,24 @@ use crate::hooks::RustwardenHooks;
17 17 use crate::services::oauth::providers::ProviderRegistry;
18 18 use crate::services::oauth::OAuthService;
19 19 use crate::services::refresh_token_service::RefreshTokenService;
20 -use crate::services::user_service::UserService;
21 20 use crate::services::user_preferences_service::UserPreferencesService;
21 +use crate::services::user_service::UserService;
22 22
23 23 const OAUTH_STATE_KEY: &str = "oauth_state";
24 24 const OAUTH_NONCE_KEY: &str = "oauth_nonce";
25 25 const OAUTH_PKCE_KEY: &str = "oauth_pkce_verifier";
26 26 const OAUTH_PROVIDER_KEY: &str = "oauth_provider";
27 27
28 +/// Query parameters received on the OAuth callback URL.
28 29 #[derive(Deserialize)]
29 30 pub struct OAuthCallbackQuery {
31 + /// Authorization code returned by the provider on success.
30 32 pub code: Option<String>,
33 + /// CSRF state value to verify against the session.
31 34 pub state: Option<String>,
35 + /// OAuth error code returned by the provider on failure.
32 36 pub error: Option<String>,
37 + /// Human-readable description of the OAuth error.
33 38 pub error_description: Option<String>,
34 39 }
35 40
@@ -48,29 +53,41 @@ pub async fn oauth_redirect(
48 53 return Err(AppError::Validation("OAuth is not enabled".to_string()));
49 54 }
50 55
51 - let provider = registry
52 - .get(&provider_name)
53 - .ok_or_else(|| AppError::NotFound(format!("OAuth provider '{}' not found", provider_name)))?;
56 + let provider = registry.get(&provider_name).ok_or_else(|| {
57 + AppError::NotFound(format!("OAuth provider '{}' not found", provider_name))
58 + })?;
54 59
55 60 let redirect_uri = oauth_config.callback_url(&provider_name);
56 61 let auth_url = provider.authorization_url(&redirect_uri)?;
57 62
58 63 // Store security params in session
59 - session.insert(OAUTH_STATE_KEY, &auth_url.csrf_state).await
64 + session
65 + .insert(OAUTH_STATE_KEY, &auth_url.csrf_state)
66 + .await
60 67 .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
61 - session.insert(OAUTH_PROVIDER_KEY, &provider_name).await
68 + session
69 + .insert(OAUTH_PROVIDER_KEY, &provider_name)
70 + .await
62 71 .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
63 72
64 73 if let Some(ref nonce) = auth_url.nonce {
65 - session.insert(OAUTH_NONCE_KEY, nonce).await
74 + session
75 + .insert(OAUTH_NONCE_KEY, nonce)
76 + .await
66 77 .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
67 78 }
68 79 if let Some(ref verifier) = auth_url.pkce_verifier {
69 - session.insert(OAUTH_PKCE_KEY, verifier).await
80 + session
81 + .insert(OAUTH_PKCE_KEY, verifier)
82 + .await
70 83 .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
71 84 }
72 85
73 - tracing::info!("OAuth redirect to {} for provider {}", &auth_url.url[..50.min(auth_url.url.len())], provider_name);
86 + tracing::info!(
87 + "OAuth redirect to {} for provider {}",
88 + &auth_url.url[..50.min(auth_url.url.len())],
89 + provider_name
90 + );
74 91 Ok(Redirect::temporary(&auth_url.url).into_response())
75 92 }
76 93
@@ -88,9 +105,10 @@ pub async fn oauth_callback(
88 105 Path(provider_name): Path<String>,
89 106 Query(params): Query<OAuthCallbackQuery>,
90 107 ) -> Result<Response, AppError> {
91 - // SEC-C2: Rate limiting
92 - if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() {
93 - return Err(AppError::Validation("Too many requests".to_string()));
108 + // SEC-C2: Rate limiting — surface the proper 429 + Retry-After response
109 + // instead of masking it as a generic 400.
110 + if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
111 + return Ok(resp);
94 112 }
95 113
96 114 // Check for error from provider
@@ -104,27 +122,39 @@ pub async fn oauth_callback(
104 122 return Ok(Redirect::to("/login?error=oauth_denied").into_response());
105 123 }
106 124
107 - let code = params.code.as_deref().ok_or_else(|| {
108 - AppError::Validation("Missing authorization code".to_string())
109 - })?;
125 + let code = params
126 + .code
127 + .as_deref()
128 + .ok_or_else(|| AppError::Validation("Missing authorization code".to_string()))?;
110 129
111 130 // Validate CSRF state
112 - let session_state: Option<String> = session.get(OAUTH_STATE_KEY).await
131 + let session_state: Option<String> = session
132 + .get(OAUTH_STATE_KEY)
133 + .await
113 134 .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
114 - let session_provider: Option<String> = session.get(OAUTH_PROVIDER_KEY).await
135 + let session_provider: Option<String> = session
136 + .get(OAUTH_PROVIDER_KEY)
137 + .await
115 138 .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
116 139
117 140 let expected_state = session_state
118 141 .ok_or_else(|| AppError::Validation("Missing OAuth state in session".to_string()))?;
119 - let received_state = params.state.as_deref()
142 + let received_state = params
143 + .state
144 + .as_deref()
120 145 .ok_or_else(|| AppError::Validation("Missing state parameter".to_string()))?;
121 146
122 147 // SEC: Constant-time comparison for state
123 148 use subtle::ConstantTimeEq;
124 - let state_match: bool = expected_state.as_bytes().ct_eq(received_state.as_bytes()).into();
149 + let state_match: bool = expected_state
150 + .as_bytes()
151 + .ct_eq(received_state.as_bytes())
152 + .into();
125 153 if !state_match {
126 154 tracing::warn!("OAuth state mismatch for provider {}", provider_name);
127 - return Err(AppError::Validation("OAuth state mismatch (possible CSRF attack)".to_string()));
155 + return Err(AppError::Validation(
156 + "OAuth state mismatch (possible CSRF attack)".to_string(),
157 + ));
128 158 }
129 159
130 160 if session_provider.as_deref() != Some(&provider_name) {
@@ -159,11 +189,16 @@ pub async fn oauth_callback(
159 189 .await?;
160 190
161 191 // Find or create user
162 - let (user, is_new_user) = OAuthService::find_or_create_user(&db, &provider_name, &user_info).await?;
192 + let (user, is_new_user) =
193 + OAuthService::find_or_create_user(&db, &provider_name, &user_info).await?;
163 194
164 195 // Generate JWT
165 196 let roles = UserService::get_user_roles(&db, user.id).await?;
166 - let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?;
197 + let permissions =
198 + crate::services::permission_service::PermissionService::get_permissions_for_user(
199 + &db, user.id,
200 + )
201 + .await?;
167 202
168 203 let auth_config = AuthConfig::from_env()
169 204 .map_err(|e| AppError::Config(format!("Auth config error: {}", e)))?;
@@ -207,7 +242,9 @@ pub async fn oauth_callback(
207 242
208 243 let mut response = Redirect::to("/home").into_response();
209 244 if let Ok(hv) = jwt_cookie.to_string().parse() {
210 - response.headers_mut().insert(axum::http::header::SET_COOKIE, hv);
245 + response
246 + .headers_mut()
247 + .insert(axum::http::header::SET_COOKIE, hv);
211 248 }
212 249
213 250 // Set theme/locale cookies from preferences
@@ -218,7 +255,9 @@ pub async fn oauth_callback(
218 255 .path("/")
219 256 .max_age(time::Duration::seconds(31536000));
220 257 if let Ok(hv) = theme_cookie.to_string().parse() {
221 - response.headers_mut().append(axum::http::header::SET_COOKIE, hv);
258 + response
259 + .headers_mut()
260 + .append(axum::http::header::SET_COOKIE, hv);
222 261 }
223 262
224 263 let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language))
@@ -227,7 +266,9 @@ pub async fn oauth_callback(
227 266 .path("/")
228 267 .max_age(time::Duration::seconds(31536000));
229 268 if let Ok(hv) = locale_cookie.to_string().parse() {
230 - response.headers_mut().append(axum::http::header::SET_COOKIE, hv);
269 + response
270 + .headers_mut()
271 + .append(axum::http::header::SET_COOKIE, hv);
231 272 }
232 273 }
233 274
@@ -243,7 +284,10 @@ pub async fn oauth_callback(
243 284 if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await {
244 285 tracing::warn!("on_user_login hook error: {}", e);
245 286 }
246 - if let Err(e) = hooks.on_oauth_login(&db, user.id, &provider_name, is_new_user).await {
287 + if let Err(e) = hooks
288 + .on_oauth_login(&db, user.id, &provider_name, is_new_user)
289 + .await
290 + {
247 291 tracing::warn!("on_oauth_login hook error: {}", e);
248 292 }
249 293 }
M crates/rustwarden-core/src/bin/seed.rs
+1 -4
@@ -170,10 +170,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
170 170 UserService::assign_role_to_user(&db, user.id, role.id).await?;
171 171 info!(" Assigned role: {}", role_name);
172 172 } else {
173 - warn!(
174 - "Role '{}' not found, user created without role",
175 - role_name
176 - );
173 + warn!("Role '{}' not found, user created without role", role_name);
177 174 }
178 175
179 176 created += 1;
M crates/rustwarden-core/src/config/admin.rs
+10 -1
@@ -10,12 +10,16 @@ use validator::ValidationError;
10 10 /// Configuration for the default administrator account
11 11 #[derive(Debug, Clone)]
12 12 pub struct AdminConfig {
13 + /// Login name for the default admin (env: `ADMIN_USERNAME`, default: `"admin"`).
13 14 pub username: String,
15 + /// Email address for the default admin (env: `ADMIN_EMAIL`, default: `"admin@localhost"`).
14 16 pub email: String,
17 + /// Optional password override (env: `ADMIN_PASSWORD`); generated if absent.
15 18 pub password: Option<String>,
16 19 }
17 20
18 21 impl AdminConfig {
22 + /// Load admin configuration from environment variables.
19 23 pub fn from_env() -> Self {
20 24 let username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
21 25 let email = env::var("ADMIN_EMAIL").unwrap_or_else(|_| "admin@localhost".to_string());
@@ -28,11 +32,16 @@ impl AdminConfig {
28 32 }
29 33 }
30 34
35 + /// Validate the admin configuration, returning an error if any field is invalid.
31 36 pub fn validate(&self) -> Result<(), ValidationError> {
32 37 if self.username.len() < 3 || self.username.len() > 64 {
33 38 return Err(ValidationError::new("username_length"));
34 39 }
35 - if !self.username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
40 + if !self
41 + .username
42 + .chars()
43 + .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
44 + {
36 45 return Err(ValidationError::new("username_format"));
37 46 }
38 47 if !self.email.contains('@') {
M crates/rustwarden-core/src/config/app.rs
+10 -0
@@ -12,30 +12,40 @@ pub fn default_app_name() -> String {
12 12 /// Application configuration with feature flags
13 13 #[derive(Debug, Clone, Serialize, Deserialize)]
14 14 pub struct AppConfig {
15 + /// Human-readable application name (env: `APP_NAME`).
15 16 pub app_name: String,
17 + /// Application version string taken from `Cargo.toml`.
16 18 pub app_version: String,
19 + /// Map of feature flag names to enabled/disabled state.
17 20 pub features: HashMap<String, bool>,
21 + /// UI theme identifier (env: `APP_THEME`).
18 22 pub theme: String,
23 + /// Whether the application is running in debug/development mode.
19 24 pub debug_mode: bool,
20 25 }
21 26
22 27 impl AppConfig {
28 + /// Create a new `AppConfig` using default values.
23 29 pub fn new() -> Self {
24 30 Self::default()
25 31 }
26 32
33 + /// Return `true` if the named feature flag is enabled.
27 34 pub fn is_feature_enabled(&self, feature: &str) -> bool {
28 35 self.features.get(feature).copied().unwrap_or(false)
29 36 }
30 37
38 + /// Enable the named feature flag.
31 39 pub fn enable_feature(&mut self, feature: &str) {
32 40 self.features.insert(feature.to_string(), true);
33 41 }
34 42
43 + /// Disable the named feature flag.
35 44 pub fn disable_feature(&mut self, feature: &str) {
36 45 self.features.insert(feature.to_string(), false);
37 46 }
38 47
48 + /// Load application configuration from environment variables.
39 49 pub fn from_env() -> Self {
40 50 let mut config = Self::default();
41 51 if let Ok(name) = std::env::var("APP_NAME") {
M crates/rustwarden-core/src/config/auth.rs
+24 -5
@@ -5,12 +5,14 @@
5 5 use std::env;
6 6
7 7 /// Session backend storage type
8 -#[derive(Debug, Clone, PartialEq)]
9 -#[derive(Default)]
8 +#[derive(Debug, Clone, PartialEq, Default)]
10 9 pub enum SessionBackend {
10 + /// In-process memory store (default; not suitable for multi-instance deployments).
11 11 #[default]
12 12 Memory,
13 + /// Persist sessions in the database via SeaORM.
13 14 SeaOrm,
15 + /// Persist sessions in a Redis instance.
14 16 Redis,
15 17 }
16 18
@@ -33,24 +35,37 @@ impl std::str::FromStr for SessionBackend {
33 35 /// Authentication configuration loaded from environment variables
34 36 #[derive(Debug, Clone)]
35 37 pub struct AuthConfig {
38 + /// Secret key used to sign and verify JWTs (env: `JWT_SECRET`, min 32 bytes).
36 39 pub jwt_secret: String,
40 + /// JWT access token lifetime in minutes (env: `JWT_EXPIRATION_MINUTES`, default: 15).
37 41 pub jwt_expiration_minutes: i64,
42 + /// Issuer claim embedded in JWTs (env: `JWT_ISSUER`, default: `"rustwarden"`).
38 43 pub jwt_issuer: String,
44 + /// Refresh token lifetime in days (env: `REFRESH_TOKEN_EXPIRATION_DAYS`, default: 7).
39 45 pub refresh_token_expiration_days: i64,
40 - /// Durée du refresh token quand "remember me" est activé.
41 - /// Variable : REMEMBER_ME_EXPIRATION_DAYS (défaut : 30)
46 + /// Refresh token lifetime when "remember me" is enabled.
47 + /// Env variable: REMEMBER_ME_EXPIRATION_DAYS (default: 30)
42 48 pub remember_me_expiration_days: i64,
49 + /// Idle session timeout in minutes (env: `SESSION_TIMEOUT_MINUTES`, default: 30).
43 50 pub session_timeout_minutes: i64,
51 + /// Storage backend used for server-side sessions.
44 52 pub session_backend: SessionBackend,
53 + /// Redis connection URL, required when `session_backend` is `Redis`.
45 54 pub redis_url: Option<String>,
55 + /// Maximum login attempts per minute per IP (env: `RATE_LIMIT_LOGIN_PER_MINUTE`, default: 5).
46 56 pub rate_limit_login_per_minute: u32,
57 + /// Maximum token-refresh attempts per minute per IP (env: `RATE_LIMIT_REFRESH_PER_MINUTE`, default: 10).
47 58 pub rate_limit_refresh_per_minute: u32,
59 + /// Maximum general API requests per minute per IP (env: `RATE_LIMIT_GENERAL_PER_MINUTE`, default: 100).
48 60 pub rate_limit_general_per_minute: u32,
61 + /// Whether the `Secure` flag is set on auth cookies.
49 62 pub cookie_secure: bool,
63 + /// Value of the `SameSite` cookie attribute (env: `COOKIE_SAME_SITE`, default: `"Strict"`).
50 64 pub cookie_same_site: String,
51 65 }
52 66
53 67 impl AuthConfig {
68 + /// Load and validate authentication configuration from environment variables.
54 69 pub fn from_env() -> Result<Self, String> {
55 70 let jwt_secret = env::var("JWT_SECRET")
56 71 .map_err(|_| "JWT_SECRET environment variable is required".to_string())?;
@@ -173,7 +188,8 @@ impl AuthConfig {
173 188 };
174 189
175 190 // SEC-L8: Default SameSite to Strict (matching security comments)
176 - let cookie_same_site = env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string());
191 + let cookie_same_site =
192 + env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string());
177 193
178 194 tracing::info!(
179 195 "Auth configuration loaded: JWT expiration={}min, Session backend={:?}, Session timeout={}min",
@@ -205,16 +221,19 @@ impl AuthConfig {
205 221 })
206 222 }
207 223
224 + /// Return the JWT expiration as a `chrono::Duration`.
208 225 #[allow(dead_code)]
209 226 pub fn jwt_expiration_duration(&self) -> chrono::Duration {
210 227 chrono::Duration::minutes(self.jwt_expiration_minutes)
211 228 }
212 229
230 + /// Return the refresh token expiration as a `chrono::Duration`.
213 231 #[allow(dead_code)]
214 232 pub fn refresh_token_expiration_duration(&self) -> chrono::Duration {
215 233 chrono::Duration::days(self.refresh_token_expiration_days)
216 234 }
217 235
236 + /// Return the session timeout as a `chrono::Duration`.
218 237 #[allow(dead_code)]
219 238 pub fn session_timeout_duration(&self) -> chrono::Duration {
220 239 chrono::Duration::minutes(self.session_timeout_minutes)
M crates/rustwarden-core/src/config/mod.rs
+10 -2
@@ -1,12 +1,20 @@
1 1 //! Configuration module for Rustwarden
2 2 //!
3 3 //! Server, database, authentication, email, and admin configuration.
4 +/// Admin account bootstrapping configuration.
4 5 pub mod admin;
6 +/// Application-level configuration (name, base URL, etc.).
5 7 pub mod app;
8 +/// Authentication configuration (JWT, sessions, rate limits).
6 9 pub mod auth;
10 +/// Database connection configuration.
7 11 pub mod database;
12 +/// Email (SMTP) configuration.
8 13 pub mod email;
9 -pub mod server;
10 -pub mod tasks;
14 +/// OAuth provider configuration.
11 15 #[cfg(feature = "oauth")]
12 16 pub mod oauth;
17 +/// HTTP server configuration (host, port, TLS).
18 +pub mod server;
19 +/// Background task scheduler configuration.
20 +pub mod tasks;
M crates/rustwarden-core/src/config/oauth.rs
+19 -2
@@ -8,25 +8,37 @@ use crate::services::crypto_service::CryptoService;
8 8 /// Configuration for a single OAuth provider.
9 9 #[derive(Debug, Clone)]
10 10 pub struct OAuthProviderConfig {
11 + /// Provider identifier (e.g. `"google"`, `"github"`).
11 12 pub name: String,
13 + /// OAuth client ID registered with the provider.
12 14 pub client_id: String,
15 + /// OAuth client secret (may be AES-GCM encrypted with the `enc:` prefix).
13 16 pub client_secret: String,
17 + /// Azure AD / Microsoft tenant ID (only used by the Microsoft provider).
14 18 pub tenant: Option<String>,
19 + /// Whether this provider is enabled and available for login.
15 20 pub enabled: bool,
16 21 }
17 22
18 23 /// Global OAuth configuration.
19 24 #[derive(Debug, Clone)]
20 25 pub struct OAuthConfig {
26 + /// Whether OAuth login is enabled globally.
21 27 pub enabled: bool,
28 + /// Base URL used to build provider callback URLs (env: `OAUTH_REDIRECT_BASE_URL`).
22 29 pub redirect_base_url: String,
30 + /// Automatically register a new user when an unknown OAuth identity logs in.
23 31 pub auto_register: bool,
32 + /// Link an OAuth identity to an existing account with the same email.
24 33 pub link_existing_account: bool,
34 + /// Configured OAuth providers (only providers with both `client_id` and `client_secret` are included).
25 35 pub providers: Vec<OAuthProviderConfig>,
26 36 }
27 37
28 38 impl OAuthConfig {
29 - pub const KNOWN_PROVIDERS: &'static [&'static str] = &["google", "github", "discord", "microsoft"];
39 + /// Names of all recognised OAuth providers.
40 + pub const KNOWN_PROVIDERS: &'static [&'static str] =
41 + &["google", "github", "discord", "microsoft"];
30 42
31 43 /// Load OAuth configuration from the database, falling back to environment
32 44 /// variables for any value not set in the DB.
@@ -126,7 +138,9 @@ impl OAuthConfig {
126 138 }
127 139
128 140 if enabled && providers.is_empty() {
129 - tracing::warn!("OAuth is enabled but no providers are configured (neither DB nor env vars)");
141 + tracing::warn!(
142 + "OAuth is enabled but no providers are configured (neither DB nor env vars)"
143 + );
130 144 }
131 145
132 146 Self {
@@ -187,14 +201,17 @@ impl OAuthConfig {
187 201 }
188 202 }
189 203
204 + /// Return all providers that are currently enabled.
190 205 pub fn enabled_providers(&self) -> Vec<&OAuthProviderConfig> {
191 206 self.providers.iter().filter(|p| p.enabled).collect()
192 207 }
193 208
209 + /// Look up an enabled provider by name.
194 210 pub fn get_provider(&self, name: &str) -> Option<&OAuthProviderConfig> {
195 211 self.providers.iter().find(|p| p.name == name && p.enabled)
196 212 }
197 213
214 + /// Build the OAuth callback URL for the given provider name.
198 215 pub fn callback_url(&self, provider_name: &str) -> String {
199 216 format!(
200 217 "{}/api/v1/auth/oauth/{}/callback",
M crates/rustwarden-core/src/config/tasks.rs
+8 -0
@@ -71,6 +71,14 @@ pub fn jwt_cleanup_interval_secs() -> u64 {
71 71 env_u64("JWT_CLEANUP_INTERVAL_SECS", 3600)
72 72 }
73 73
74 +// ── Personal access token cleanup ──────────────────────────────────
75 +
76 +/// Interval between PAT cleanup runs (seconds).
77 +/// Env: `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` — default 3600 (1 h)
78 +pub fn personal_token_cleanup_interval_secs() -> u64 {
79 + env_u64("PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS", 3600)
80 +}
81 +
74 82 // ── Token expiration durations ─────────────────────────────────────
75 83
76 84 /// Password-change request expiration (hours).
M crates/rustwarden-core/src/error.rs
+146 -0
@@ -13,51 +13,117 @@ use thiserror::Error;
13 13
14 14 #[derive(Error, Debug)]
15 15 pub enum AppError {
16 + /// A SeaORM database operation failed.
16 17 #[error("Database error: {0}")]
17 18 Database(#[from] sea_orm::DbErr),
18 19
20 + /// User-supplied input failed validation. The message describes what was invalid.
19 21 #[error("Validation error: {0}")]
20 22 Validation(String),
21 23
24 + /// The requested resource could not be found.
22 25 #[error("Resource not found: {0}")]
23 26 NotFound(String),
24 27
28 + /// An unexpected internal error occurred. The message contains diagnostic detail.
25 29 #[error("Internal server error: {0}")]
26 30 Internal(String),
27 31
32 + /// The supplied username/password combination is incorrect.
28 33 #[error("Invalid credentials")]
29 34 InvalidCredentials,
30 35
36 + /// The supplied token is malformed or does not match any known record.
31 37 #[error("Invalid token")]
32 38 InvalidToken,
33 39
40 + /// The token was valid but its expiration time has passed.
34 41 #[error("Token expired")]
35 42 TokenExpired,
36 43
44 + /// A refresh token was presented that had already been used, indicating possible token theft.
37 45 #[error("Token reused - possible security breach")]
38 46 TokenReused,
39 47
48 + /// The user must set a new password before accessing the application.
40 49 #[allow(dead_code)]
41 50 #[error("Password change required")]
42 51 MustChangePassword,
43 52
53 + /// The request requires authentication but no valid credentials were provided.
44 54 #[allow(dead_code)]
45 55 #[error("Authentication required")]
46 56 Unauthorized,
47 57
58 + /// The authenticated user does not have permission to perform this action.
48 59 #[allow(dead_code)]
49 60 #[error("Insufficient permissions")]
50 61 Forbidden,
51 62
63 + /// A required configuration value is missing or invalid.
52 64 #[error("Configuration error: {0}")]
53 65 Config(String),
54 66
67 + /// An Askama template failed to render.
55 68 #[error("Template rendering error: {0}")]
56 69 Template(String),
70 +
71 + /// The submitted TOTP code is incorrect.
72 + #[error("TOTP verification failed")]
73 + TotpFailed,
74 +
75 + /// The TOTP challenge token has expired or does not exist.
76 + #[error("TOTP challenge expired or invalid")]
77 + TotpChallengeInvalid,
78 +
79 + /// The TOTP challenge was failed too many times and is now locked.
80 + #[error("TOTP challenge max attempts exceeded")]
81 + TotpMaxAttemptsExceeded,
82 +
83 + /// The account has TOTP enabled; a second-factor code must be provided to continue.
84 + #[error("Two-factor authentication required")]
85 + TotpRequired,
86 +
87 + /// TOTP cannot be enabled because it is already active for this user.
88 + #[allow(dead_code)]
89 + #[error("TOTP already enabled")]
90 + TotpAlreadyEnabled,
91 +
92 + /// The requested operation requires TOTP to be enabled, but it is not.
93 + #[allow(dead_code)]
94 + #[error("TOTP not enabled")]
95 + TotpNotEnabled,
96 +
97 + /// Client exceeded a rate limit. Carries the `Retry-After` header value (seconds).
98 + #[error("Too many requests, retry after {retry_after_secs}s")]
99 + TooManyRequests {
100 + /// Number of seconds the client should wait before retrying.
101 + retry_after_secs: u64,
102 + },
57 103 }
58 104
59 105 impl IntoResponse for AppError {
60 106 fn into_response(self) -> Response {
107 + // 429 has a required Retry-After header, so handle it before the generic
108 + // (status, body) path used by other variants.
109 + if let AppError::TooManyRequests { retry_after_secs } = self {
110 + tracing::warn!(
111 + retry_after_secs,
112 + "Rate limit exceeded, returning 429 Too Many Requests"
113 + );
114 + let body = Json(json!({
115 + "error": "Too many requests, please retry later",
116 + "status": StatusCode::TOO_MANY_REQUESTS.as_u16(),
117 + "retry_after_secs": retry_after_secs,
118 + }));
119 + return (
120 + StatusCode::TOO_MANY_REQUESTS,
121 + [("Retry-After", retry_after_secs.to_string())],
122 + body,
123 + )
124 + .into_response();
125 + }
126 +
61 127 let (status, error_message) = match self {
62 128 AppError::Database(err) => {
63 129 tracing::error!("Database error: {}", err);
@@ -141,6 +207,48 @@ impl IntoResponse for AppError {
141 207 "Template error".to_string(),
142 208 )
143 209 }
210 + AppError::TotpFailed => {
211 + tracing::warn!("TOTP verification failed");
212 + (
213 + StatusCode::UNAUTHORIZED,
214 + "TOTP verification failed".to_string(),
215 + )
216 + }
217 + AppError::TotpChallengeInvalid => {
218 + tracing::warn!("TOTP challenge expired or invalid");
219 + (
220 + StatusCode::UNAUTHORIZED,
221 + "TOTP challenge expired or invalid".to_string(),
222 + )
223 + }
224 + AppError::TotpMaxAttemptsExceeded => {
225 + tracing::warn!("TOTP challenge max attempts exceeded");
226 + (
227 + StatusCode::TOO_MANY_REQUESTS,
228 + "Too many TOTP attempts".to_string(),
229 + )
230 + }
231 + AppError::TotpRequired => {
232 + tracing::info!("Two-factor authentication setup required");
233 + (
234 + StatusCode::FORBIDDEN,
235 + "Two-factor authentication required".to_string(),
236 + )
237 + }
238 + AppError::TotpAlreadyEnabled => {
239 + tracing::info!("TOTP already enabled for user");
240 + (
241 + StatusCode::BAD_REQUEST,
242 + "TOTP is already enabled".to_string(),
243 + )
244 + }
245 + AppError::TotpNotEnabled => {
246 + tracing::info!("TOTP not enabled for user");
247 + (StatusCode::BAD_REQUEST, "TOTP is not enabled".to_string())
248 + }
249 + AppError::TooManyRequests { .. } => {
250 + unreachable!("TooManyRequests is handled above to include the Retry-After header")
251 + }
144 252 };
145 253
146 254 let body = Json(json!({
@@ -151,3 +259,41 @@ impl IntoResponse for AppError {
151 259 (status, body).into_response()
152 260 }
153 261 }
262 +
263 +#[cfg(test)]
264 +mod tests {
265 + use super::*;
266 + use axum::body::to_bytes;
267 +
268 + #[tokio::test]
269 + async fn too_many_requests_returns_429_with_retry_after_header() {
270 + let err = AppError::TooManyRequests {
271 + retry_after_secs: 42,
272 + };
273 + let response = err.into_response();
274 +
275 + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
276 + let retry_after = response
277 + .headers()
278 + .get("Retry-After")
279 + .expect("Retry-After header must be set for 429 responses");
280 + assert_eq!(retry_after.to_str().unwrap(), "42");
281 + }
282 +
283 + #[tokio::test]
284 + async fn too_many_requests_body_contains_error_message() {
285 + let err = AppError::TooManyRequests {
286 + retry_after_secs: 1,
287 + };
288 + let response = err.into_response();
289 + let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
290 + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
291 +
292 + assert_eq!(body["status"], 429);
293 + assert!(body["error"]
294 + .as_str()
295 + .unwrap()
296 + .to_lowercase()
297 + .contains("too many"));
298 + }
299 +}
M crates/rustwarden-core/src/hooks.rs
+361 -5
@@ -24,6 +24,29 @@ use async_trait::async_trait;
24 24 use sea_orm::DatabaseConnection;
25 25 use uuid::Uuid;
26 26
27 +/// Payload handed to [`RustwardenHooks::on_audit_event`].
28 +///
29 +/// Generic escape hatch for framework-level audit wiring: business events that
30 +/// don't warrant a dedicated trait method (issue created, PR merged, wall
31 +/// shared, etc.) are dispatched here by the core, so consumer apps can plug
32 +/// monitoring / SIEM indexing / compliance logging in one place without
33 +/// forking the trait.
34 +///
35 +/// Borrowed fields are cheap — hook implementations should copy what they
36 +/// need before returning (e.g. clone the metadata JSON into storage).
37 +#[derive(Debug, Clone, Copy)]
38 +pub struct AuditEvent<'a> {
39 + /// Short namespaced identifier (`"user.login_success"`, `"repo.created"`).
40 + pub kind: &'a str,
41 + /// User who triggered the event, if known (system events may leave it `None`).
42 + pub actor_id: Option<Uuid>,
43 + /// `(resource_type, resource_id)` of the event's target, if any.
44 + pub target: Option<(&'a str, Uuid)>,
45 + /// Free-form structured metadata — serialize-friendly; consumers decide
46 + /// the shape per `kind`.
47 + pub metadata: &'a serde_json::Value,
48 +}
49 +
27 50 /// Lifecycle hooks for extending Rustwarden behavior.
28 51 ///
29 52 /// All methods have default no-op implementations, so you only need to
@@ -51,11 +74,7 @@ pub trait RustwardenHooks: Send + Sync {
51 74 }
52 75
53 76 /// Called after a user logs out.
54 - async fn on_user_logout(
55 - &self,
56 - _db: &DatabaseConnection,
57 - _user_id: Uuid,
58 - ) -> anyhow::Result<()> {
77 + async fn on_user_logout(&self, _db: &DatabaseConnection, _user_id: Uuid) -> anyhow::Result<()> {
59 78 Ok(())
60 79 }
61 80
@@ -125,6 +144,24 @@ pub trait RustwardenHooks: Send + Sync {
125 144 Ok(())
126 145 }
127 146
147 + /// Called after a user enables TOTP two-factor authentication.
148 + async fn on_totp_enabled(
149 + &self,
150 + _db: &DatabaseConnection,
151 + _user_id: Uuid,
152 + ) -> anyhow::Result<()> {
153 + Ok(())
154 + }
155 +
156 + /// Called after a user disables TOTP two-factor authentication.
157 + async fn on_totp_disabled(
158 + &self,
159 + _db: &DatabaseConnection,
160 + _user_id: Uuid,
161 + ) -> anyhow::Result<()> {
162 + Ok(())
163 + }
164 +
128 165 /// Called after a successful OAuth login (new or existing user).
129 166 async fn on_oauth_login(
130 167 &self,
@@ -135,6 +172,89 @@ pub trait RustwardenHooks: Send + Sync {
135 172 ) -> anyhow::Result<()> {
136 173 Ok(())
137 174 }
175 +
176 + /// Called after a resource (repo, wall, workspace…) has been registered
177 + /// with [`ResourceService`](crate::services::resource_service::ResourceService).
178 + ///
179 + /// Replaces ad-hoc filtering by `resource_type == "…"` in custom hook
180 + /// implementations (see gitrust-hooks in the consumer ecosystem).
181 + async fn on_resource_registered(
182 + &self,
183 + _db: &DatabaseConnection,
184 + _resource_type: &str,
185 + _resource_id: Uuid,
186 + _owner_id: Uuid,
187 + ) -> anyhow::Result<()> {
188 + Ok(())
189 + }
190 +
191 + /// Called after a resource has been unregistered. Use this to release
192 + /// filesystem / external resources tied to the entity.
193 + async fn on_resource_unregistered(
194 + &self,
195 + _db: &DatabaseConnection,
196 + _resource_type: &str,
197 + _resource_id: Uuid,
198 + ) -> anyhow::Result<()> {
199 + Ok(())
200 + }
201 +
202 + /// Called after a team has been created. Prerequisite for Phase 5
203 + /// (`TeamService`) of the upstream evolution plan — consumers can pre-wire
204 + /// auditing today.
205 + async fn on_team_created(
206 + &self,
207 + _db: &DatabaseConnection,
208 + _team_id: Uuid,
209 + _owner_id: Uuid,
210 + ) -> anyhow::Result<()> {
211 + Ok(())
212 + }
213 +
214 + /// Called after a team has been deleted.
215 + async fn on_team_deleted(
216 + &self,
217 + _db: &DatabaseConnection,
218 + _team_id: Uuid,
219 + ) -> anyhow::Result<()> {
220 + Ok(())
221 + }
222 +
223 + /// Called after a user has been added to a team.
224 + async fn on_team_member_added(
225 + &self,
226 + _db: &DatabaseConnection,
227 + _team_id: Uuid,
228 + _user_id: Uuid,
229 + ) -> anyhow::Result<()> {
230 + Ok(())
231 + }
232 +
233 + /// Called after a user has been removed from a team.
234 + async fn on_team_member_removed(
235 + &self,
236 + _db: &DatabaseConnection,
237 + _team_id: Uuid,
238 + _user_id: Uuid,
239 + ) -> anyhow::Result<()> {
240 + Ok(())
241 + }
242 +
243 + /// Generic audit hook for events that do not warrant a dedicated trait
244 + /// method. Invoked by the framework's logging services (see
245 + /// [`AuthLoggingService`](crate::services::auth_logging::AuthLoggingService))
246 + /// and available for apps to emit their own events (issue created, PR
247 + /// merged, resource-specific operations…).
248 + ///
249 + /// Errors returned here are propagated to the caller — use with care; a
250 + /// panicking or slow hook affects the request path.
251 + async fn on_audit_event(
252 + &self,
253 + _db: &DatabaseConnection,
254 + _event: AuditEvent<'_>,
255 + ) -> anyhow::Result<()> {
256 + Ok(())
257 + }
138 258 }
139 259
140 260 /// Default no-op implementation of hooks.
@@ -142,3 +262,239 @@ pub struct NoopHooks;
142 262
143 263 #[async_trait]
144 264 impl RustwardenHooks for NoopHooks {}
265 +
266 +#[cfg(test)]
267 +mod tests {
268 + use super::*;
269 + use std::sync::{Arc, Mutex};
270 +
271 + /// Collect every hook invocation so tests can assert dispatch.
272 + #[derive(Default)]
273 + struct RecordingHooks {
274 + events: Arc<Mutex<Vec<String>>>,
275 + }
276 +
277 + #[async_trait]
278 + impl RustwardenHooks for RecordingHooks {
279 + async fn on_resource_registered(
280 + &self,
281 + _db: &DatabaseConnection,
282 + resource_type: &str,
283 + resource_id: Uuid,
284 + owner_id: Uuid,
285 + ) -> anyhow::Result<()> {
286 + self.events.lock().unwrap().push(format!(
287 + "registered:{resource_type}:{resource_id}:{owner_id}"
288 + ));
289 + Ok(())
290 + }
291 +
292 + async fn on_resource_unregistered(
293 + &self,
294 + _db: &DatabaseConnection,
295 + resource_type: &str,
296 + resource_id: Uuid,
297 + ) -> anyhow::Result<()> {
298 + self.events
299 + .lock()
300 + .unwrap()
301 + .push(format!("unregistered:{resource_type}:{resource_id}"));
302 + Ok(())
303 + }
304 +
305 + async fn on_team_created(
306 + &self,
307 + _db: &DatabaseConnection,
308 + team_id: Uuid,
309 + owner_id: Uuid,
310 + ) -> anyhow::Result<()> {
311 + self.events
312 + .lock()
313 + .unwrap()
314 + .push(format!("team_created:{team_id}:{owner_id}"));
315 + Ok(())
316 + }
317 +
318 + async fn on_team_deleted(
319 + &self,
320 + _db: &DatabaseConnection,
321 + team_id: Uuid,
322 + ) -> anyhow::Result<()> {
323 + self.events
324 + .lock()
325 + .unwrap()
326 + .push(format!("team_deleted:{team_id}"));
327 + Ok(())
328 + }
329 +
330 + async fn on_team_member_added(
331 + &self,
332 + _db: &DatabaseConnection,
333 + team_id: Uuid,
334 + user_id: Uuid,
335 + ) -> anyhow::Result<()> {
336 + self.events
337 + .lock()
338 + .unwrap()
339 + .push(format!("member_added:{team_id}:{user_id}"));
340 + Ok(())
341 + }
342 +
343 + async fn on_team_member_removed(
344 + &self,
345 + _db: &DatabaseConnection,
346 + team_id: Uuid,
347 + user_id: Uuid,
348 + ) -> anyhow::Result<()> {
349 + self.events
350 + .lock()
351 + .unwrap()
352 + .push(format!("member_removed:{team_id}:{user_id}"));
353 + Ok(())
354 + }
355 +
356 + async fn on_audit_event(
357 + &self,
358 + _db: &DatabaseConnection,
359 + event: AuditEvent<'_>,
360 + ) -> anyhow::Result<()> {
361 + let target = event
362 + .target
363 + .map(|(rt, rid)| format!("{rt}:{rid}"))
364 + .unwrap_or_else(|| "-".into());
365 + let actor = event
366 + .actor_id
367 + .map(|a| a.to_string())
368 + .unwrap_or_else(|| "-".into());
369 + self.events
370 + .lock()
371 + .unwrap()
372 + .push(format!("audit:{}:{}:{}", event.kind, actor, target));
373 + Ok(())
374 + }
375 + }
376 +
377 + /// Per-test connection — see personal_token_service::tests for rationale
378 + /// (a static OnceCell across `#[tokio::test]` runtimes causes
379 + /// "Tokio 1.x context being shutdown" errors).
380 + async fn db() -> DatabaseConnection {
381 + dotenvy::dotenv().ok();
382 + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for hook tests");
383 + sea_orm::Database::connect(&url)
384 + .await
385 + .expect("hook tests require a reachable DATABASE_URL")
386 + }
387 +
388 + #[tokio::test]
389 + async fn noop_hooks_accept_every_new_event_without_panicking() {
390 + let hooks = NoopHooks;
391 + let id = Uuid::new_v4();
392 +
393 + hooks
394 + .on_resource_registered(&db().await, "repo", id, id)
395 + .await
396 + .unwrap();
397 + hooks
398 + .on_resource_unregistered(&db().await, "repo", id)
399 + .await
400 + .unwrap();
401 + hooks.on_team_created(&db().await, id, id).await.unwrap();
402 + hooks.on_team_deleted(&db().await, id).await.unwrap();
403 + hooks
404 + .on_team_member_added(&db().await, id, id)
405 + .await
406 + .unwrap();
407 + hooks
408 + .on_team_member_removed(&db().await, id, id)
409 + .await
410 + .unwrap();
411 + hooks
412 + .on_audit_event(
413 + &db().await,
414 + AuditEvent {
415 + kind: "noop.smoke",
416 + actor_id: None,
417 + target: None,
418 + metadata: &serde_json::Value::Null,
419 + },
420 + )
421 + .await
422 + .unwrap();
423 + }
424 +
425 + #[tokio::test]
426 + async fn custom_hooks_receive_each_event_with_correct_payload() {
427 + let hooks = RecordingHooks::default();
428 + let team = Uuid::new_v4();
429 + let owner = Uuid::new_v4();
430 + let member = Uuid::new_v4();
431 + let resource = Uuid::new_v4();
432 +
433 + hooks
434 + .on_resource_registered(&db().await, "repo", resource, owner)
435 + .await
436 + .unwrap();
437 + hooks
438 + .on_resource_unregistered(&db().await, "repo", resource)
439 + .await
440 + .unwrap();
441 + hooks
442 + .on_team_created(&db().await, team, owner)
443 + .await
444 + .unwrap();
445 + hooks
446 + .on_team_member_added(&db().await, team, member)
447 + .await
448 + .unwrap();
449 + hooks
450 + .on_team_member_removed(&db().await, team, member)
451 + .await
452 + .unwrap();
453 + hooks.on_team_deleted(&db().await, team).await.unwrap();
454 +
455 + let meta = serde_json::json!({"subject": "s"});
456 + hooks
457 + .on_audit_event(
458 + &db().await,
459 + AuditEvent {
460 + kind: "pr.merged",
461 + actor_id: Some(owner),
462 + target: Some(("pr", resource)),
463 + metadata: &meta,
464 + },
465 + )
466 + .await
467 + .unwrap();
468 +
469 + let events = hooks.events.lock().unwrap().clone();
470 + assert_eq!(
471 + events,
472 + vec![
473 + format!("registered:repo:{resource}:{owner}"),
474 + format!("unregistered:repo:{resource}"),
475 + format!("team_created:{team}:{owner}"),
476 + format!("member_added:{team}:{member}"),
477 + format!("member_removed:{team}:{member}"),
478 + format!("team_deleted:{team}"),
479 + format!("audit:pr.merged:{owner}:pr:{resource}"),
480 + ]
481 + );
482 + }
483 +
484 + #[test]
485 + fn audit_event_carries_structured_metadata() {
486 + let actor = Uuid::new_v4();
487 + let target_id = Uuid::new_v4();
488 + let meta = serde_json::json!({"ip": "127.0.0.1", "reason": "manual"});
489 + let event = AuditEvent {
490 + kind: "user.ban",
491 + actor_id: Some(actor),
492 + target: Some(("user", target_id)),
493 + metadata: &meta,
494 + };
495 + assert_eq!(event.kind, "user.ban");
496 + assert_eq!(event.actor_id, Some(actor));
497 + assert_eq!(event.target, Some(("user", target_id)));
498 + assert_eq!(event.metadata["ip"], "127.0.0.1");
499 + }
500 +}
M crates/rustwarden-core/src/i18n/extractor.rs
+9 -1
@@ -116,7 +116,7 @@ fn is_valid_language_code(code: &str) -> bool {
116 116 };
117 117
118 118 // Check if base language is supported
119 - matches!(base_lang, "fr" | "en" | "de" | "es")
119 + matches!(base_lang, "fr" | "en" | "de" | "es" | "it" | "pt")
120 120 }
121 121
122 122 /// Normalize locale code to base language
@@ -216,6 +216,8 @@ mod tests {
216 216 assert!(is_valid_language_code("en"));
217 217 assert!(is_valid_language_code("de"));
218 218 assert!(is_valid_language_code("es"));
219 + assert!(is_valid_language_code("it"));
220 + assert!(is_valid_language_code("pt"));
219 221 assert!(!is_valid_language_code("ja"));
220 222 assert!(!is_valid_language_code(""));
221 223 }
@@ -240,6 +242,12 @@ mod tests {
240 242 Some("es".to_string())
241 243 );
242 244
245 + // Italian
246 + assert_eq!(
247 + parse_accept_language("it-IT,it;q=0.9"),
248 + Some("it".to_string())
249 + );
250 +
243 251 // Unsupported languages
244 252 assert_eq!(parse_accept_language("ja-JP,ja;q=0.9"), None); // Not supported
245 253 assert_eq!(parse_accept_language("zh-CN,zh;q=0.9"), None); // Not supported
M crates/rustwarden-core/src/i18n/helpers.rs
+30 -3
@@ -57,8 +57,8 @@ pub fn format_date(date: &chrono::DateTime<chrono::Utc>, locale: &str) -> String
57 57 // German format: DD.MM.YYYY HH:MM
58 58 date.format("%d.%m.%Y %H:%M").to_string()
59 59 }
60 - "es" => {
61 - // Spanish format: DD/MM/YYYY HH:MM (same as French)
60 + "es" | "it" | "pt" => {
61 + // Spanish/Italian/Portuguese format: DD/MM/YYYY HH:MM (same as French)
62 62 date.format("%d/%m/%Y %H:%M").to_string()
63 63 }
64 64 "en" => {
@@ -111,6 +111,8 @@ pub fn format_number(number: f64, locale: &str) -> String {
111 111 "fr" => Locale::fr,
112 112 "de" => Locale::de,
113 113 "es" => Locale::es,
114 + "it" => Locale::it,
115 + "pt" => Locale::pt,
114 116 "en" => Locale::en,
115 117 _ => Locale::en, // Default to English for unknown locales
116 118 };
@@ -126,7 +128,7 @@ pub fn format_number(number: f64, locale: &str) -> String {
126 128
127 129 // Determine decimal separator based on locale
128 130 let decimal_separator = match locale_normalized {
129 - "fr" | "de" | "es" => ",",
131 + "fr" | "de" | "es" | "it" | "pt" => ",",
130 132 _ => ".", // English and default
131 133 };
132 134
@@ -278,6 +280,17 @@ mod tests {
278 280 "English format should contain AM/PM"
279 281 );
280 282
283 + // Test Italian format
284 + let it_formatted = format_date(&date, "it");
285 + assert!(
286 + it_formatted.contains("31/12/2024"),
287 + "Italian format should contain DD/MM/YYYY"
288 + );
289 + assert!(
290 + it_formatted.contains("14:30"),
291 + "Italian format should contain 24-hour time"
292 + );
293 +
281 294 // Test invalid locale (should default to English)
282 295 let invalid_formatted = format_date(&date, "xx");
283 296 assert!(
@@ -352,6 +365,20 @@ mod tests {
352 365 en_formatted
353 366 );
354 367
368 + // Test Italian format: 1.234,56 (same as German/Spanish)
369 + let it_formatted = format_number(number, "it");
370 + println!("Italian formatted: '{}'", it_formatted);
371 + assert!(
372 + it_formatted.contains(","),
373 + "Italian format should use comma as decimal separator, got: {}",
374 + it_formatted
375 + );
376 + assert!(
377 + it_formatted.contains("."),
378 + "Italian format should use dot as thousand separator, got: {}",
379 + it_formatted
380 + );
381 +
355 382 // Test invalid locale (should default to English)
356 383 let invalid_formatted = format_number(number, "xx");
357 384 println!("Invalid locale formatted: '{}'", invalid_formatted);
M crates/rustwarden-core/src/i18n/middleware.rs
+13 -8
@@ -32,6 +32,7 @@ pub struct LocaleMiddleware<S> {
32 32 }
33 33
34 34 impl<S> LocaleMiddleware<S> {
35 + /// Create a new `LocaleMiddleware` wrapping the given inner service.
35 36 pub fn new(inner: S, db: Arc<DatabaseConnection>, i18n_service: Arc<I18nService>) -> Self {
36 37 Self {
37 38 inner,
@@ -72,7 +73,10 @@ where
72 73 // User is authenticated, try to get preference from database
73 74 match UserPreferencesService::get_preferences(&db, claims.sub).await {
74 75 Ok(Some(prefs))
75 - if matches!(prefs.language.as_str(), "fr" | "en" | "de" | "es") =>
76 + if matches!(
77 + prefs.language.as_str(),
78 + "fr" | "en" | "de" | "es" | "it" | "pt"
79 + ) =>
76 80 {
77 81 (Locale(prefs.language.clone()), false) // Don't set cookie for authenticated users
78 82 }
@@ -104,11 +108,12 @@ where
104 108
105 109 // Create locale cookie (1 year expiration)
106 110 use time::Duration as TimeDuration;
107 - let cookie = axum_extra::extract::cookie::Cookie::build(("locale", locale.as_str()))
108 - .http_only(false) // Allow JavaScript access for client-side language switching
109 - .same_site(axum_extra::extract::cookie::SameSite::Lax)
110 - .path("/")
111 - .max_age(TimeDuration::seconds(31536000)); // 1 year
111 + let cookie =
112 + axum_extra::extract::cookie::Cookie::build(("locale", locale.as_str()))
113 + .http_only(false) // Allow JavaScript access for client-side language switching
114 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
115 + .path("/")
116 + .max_age(TimeDuration::seconds(31536000)); // 1 year
112 117
113 118 // Add cookie to response headers
114 119 if let Ok(header_value) = cookie.to_string().parse() {
@@ -140,7 +145,7 @@ fn extract_locale_from_request(
140 145 if let Some(cookie) = axum_extra::extract::CookieJar::from_headers(&parts.headers).get("locale")
141 146 {
142 147 let locale_str = cookie.value().to_string();
143 - if matches!(locale_str.as_str(), "fr" | "en" | "de" | "es") {
148 + if matches!(locale_str.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt") {
144 149 return (Locale(locale_str), false); // Cookie exists, don't set it again
145 150 }
146 151 }
@@ -164,7 +169,7 @@ fn extract_locale_from_request(
164 169 "fr".to_string() // Default to French
165 170 }
166 171 })
167 - .filter(|s| matches!(s.as_str(), "fr" | "en" | "de" | "es"))
172 + .filter(|s| matches!(s.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt"))
168 173 {
169 174 // Only use Accept-Language if it's not the default "fr"
170 175 // This ensures that users without explicit preference get French by default
M crates/rustwarden-core/src/lib.rs
+27 -4
@@ -3,18 +3,34 @@
3 3 //! Reusable web framework for user management, authentication, and RBAC.
4 4 //! Use this crate as a dependency to add secure authentication to any Axum application.
5 5
6 +/// Application setup and router builder.
6 7 pub mod app;
8 +/// Authentication extractors, JWT service, and session management.
7 9 pub mod auth;
8 -pub mod hooks;
10 +/// Server, database, authentication, and email configuration.
9 11 pub mod config;
12 +/// Application error types and HTTP response conversion.
10 13 pub mod error;
14 +/// Extension hooks for consumer applications to react to framework events.
15 +pub mod hooks;
16 +/// Internationalization utilities.
11 17 pub mod i18n;
18 +/// Axum middleware: authentication, CSRF, rate-limiting, error handling.
12 19 pub mod middleware;
20 +/// SeaORM database migrations bundled with the core crate.
13 21 pub mod migrations;
22 +/// SeaORM entity models.
14 23 pub mod models;
24 +/// Server-sent events and in-memory notification bus.
25 +pub mod notifications;
26 +/// Business logic services (auth, users, roles, resources, teams, etc.).
15 27 pub mod services;
28 +/// Background task processors.
16 29 pub mod tasks;
30 +/// Askama HTML templates.
17 31 pub mod templates;
32 +/// Domain-specific newtype wrappers.
33 +pub mod types;
18 34
19 35 /// Prelude module re-exporting the most commonly used types.
20 36 pub mod prelude {
@@ -23,8 +39,15 @@ pub mod prelude {
23 39 pub use crate::auth::jwt::{Claims, JwtService};
24 40 pub use crate::error::AppError;
25 41 pub use crate::hooks::RustwardenHooks;
26 - pub use crate::services::user_service::UserService;
27 - pub use crate::services::role_service::RoleService;
42 + pub use crate::notifications::sse::sse_response;
43 + pub use crate::notifications::{EventFilter, InMemoryBus, NotificationBus};
28 44 pub use crate::services::permission_service::PermissionService;
29 - pub use crate::services::resource_service::ResourceService;
45 + pub use crate::services::personal_token_service::{PersonalTokenService, TokenScope};
46 + pub use crate::services::rate_limit_service::RateLimitService;
47 + pub use crate::services::resource_service::{Principal, ResourceService};
48 + pub use crate::services::role_service::RoleService;
49 + pub use crate::services::team_service::{TeamRole, TeamService};
50 + pub use crate::services::user_service::UserService;
51 + pub use crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor;
52 + pub use crate::types::TeamSlug;
30 53 }
M crates/rustwarden-core/src/middleware/admin_helper.rs
+1 -1
@@ -7,12 +7,12 @@ use axum::extract::Request;
7 7 use axum::http::request::Parts;
8 8 use sea_orm::DatabaseConnection;
9 9
10 -use crate::middleware::auth_required::extract_user_from_jwt;
11 10 use crate::auth::jwt::Claims;
12 11 use crate::auth::jwt::JwtService;
13 12 use crate::auth::jwt_extractor;
14 13 use crate::config::auth::AuthConfig;
15 14 use crate::error::AppError;
15 +use crate::middleware::auth_required::extract_user_from_jwt;
16 16 use crate::services::jwt_blacklist_service::JwtBlacklistService;
17 17
18 18 /// Extract admin claims from request parts and headers
M crates/rustwarden-core/src/middleware/admin_required.rs
+1 -12
@@ -11,8 +11,8 @@ use axum::{
11 11 };
12 12 use sea_orm::DatabaseConnection;
13 13
14 -use crate::middleware::auth_required::extract_user_from_jwt;
15 14 use crate::error::AppError;
15 +use crate::middleware::auth_required::extract_user_from_jwt;
16 16
17 17 /// Middleware that requires admin role
18 18 ///
@@ -136,14 +136,3 @@ pub async fn admin_required_impl(
136 136 // User is admin, proceed to handler
137 137 Ok(next.run(request).await)
138 138 }
139 -
140 -#[cfg(test)]
141 -mod tests {
142 - use super::*;
143 -
144 - #[test]
145 - fn test_admin_required_middleware_compiles() {
146 - // This test verifies the middleware function compiles
147 - // Integration tests in tests/ directory will test actual behavior
148 - }
149 -}
M crates/rustwarden-core/src/middleware/admin_service.rs
+1 -0
@@ -24,6 +24,7 @@ pub struct AdminService<S> {
24 24 }
25 25
26 26 impl<S> AdminService<S> {
27 + /// Create a new `AdminService` middleware wrapping the given inner service.
27 28 pub fn new(inner: S, db: DatabaseConnection) -> Self {
28 29 Self { inner, db }
29 30 }
M crates/rustwarden-core/src/middleware/auth_required.rs
+0 -17
@@ -223,20 +223,3 @@ pub async fn optional_auth(
223 223 // Always proceed to handler
224 224 next.run(request).await
225 225 }
226 -
227 -#[cfg(test)]
228 -mod tests {
229 - use super::*;
230 -
231 - #[test]
232 - fn test_extract_user_helper_compiles() {
233 - // This test verifies the helper function compiles
234 - // Runtime tests would require a test session setup
235 - }
236 -
237 - #[test]
238 - fn test_middleware_functions_compile() {
239 - // This test verifies middleware functions compile
240 - // Integration tests in tests/ directory will test actual behavior
241 - }
242 -}
M crates/rustwarden-core/src/middleware/auth_service.rs
+42 -11
@@ -26,6 +26,7 @@ pub(crate) fn sanitize_redirect(uri: &str) -> String {
26 26 }
27 27 }
28 28
29 +/// Tower middleware that injects the authenticated user into request extensions.
29 30 #[derive(Clone)]
30 31 pub struct AuthService<S> {
31 32 inner: S,
@@ -33,6 +34,7 @@ pub struct AuthService<S> {
33 34 }
34 35
35 36 impl<S> AuthService<S> {
37 + /// Create a new `AuthService` middleware wrapping the given inner service.
36 38 pub fn new(inner: S, db: DatabaseConnection) -> Self {
37 39 Self { inner, db }
38 40 }
@@ -56,11 +58,7 @@ where
56 58 let uri = req.uri().clone();
57 59 let path = uri.path().to_owned();
58 60
59 - let protected_routes = [
60 - "/home",
61 - "/profile",
62 - "/test-base",
63 - ];
61 + let protected_routes = ["/home", "/profile", "/test-base", "/settings", "/admin"];
64 62 let is_protected = protected_routes.iter().any(|route| path.starts_with(route));
65 63
66 64 if is_protected {
@@ -155,10 +153,44 @@ where
155 153 user_id,
156 154 path
157 155 );
158 - return Ok(
159 - axum::response::Redirect::to("/settings/password?must_change=true")
160 - .into_response(),
161 - );
156 + return Ok(axum::response::Redirect::to(
157 + "/settings/password?must_change=true",
158 + )
159 + .into_response());
160 + }
161 +
162 + // Check if 2FA is forced globally and user hasn't set it up
163 + if !path.starts_with("/settings/security")
164 + && !path.starts_with("/api/v1/auth")
165 + {
166 + let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
167 + &db,
168 + &["totp_force_enabled"],
169 + false,
170 + )
171 + .await
172 + .unwrap_or(false);
173 +
174 + if totp_force_enabled {
175 + let totp_enabled =
176 + crate::services::totp_service::TotpService::is_totp_enabled(
177 + &db, user_id,
178 + )
179 + .await
180 + .unwrap_or(false);
181 +
182 + if !totp_enabled {
183 + tracing::info!(
184 + "User {} must setup 2FA, redirecting from {}",
185 + user_id,
186 + path
187 + );
188 + return Ok(axum::response::Redirect::to(
189 + "/settings/security?force_2fa=true",
190 + )
191 + .into_response());
192 + }
193 + }
162 194 }
163 195 }
164 196 }
@@ -200,8 +232,7 @@ where
200 232
201 233 if !is_blacklisted {
202 234 if let Ok(auth_config) = AuthConfig::from_env() {
203 - if let Ok(claims) =
204 - JwtService::validate_token(&auth_config, &jwt_token)
235 + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token)
205 236 {
206 237 parts.extensions.insert(claims.clone());
207 238 if let Ok(json) = serde_json::to_string(&claims) {
M crates/rustwarden-core/src/middleware/csrf.rs
+452 -81
@@ -1,22 +1,68 @@
1 -/// CSRF Protection Middleware
2 -///
3 -/// Middleware to protect against Cross-Site Request Forgery (CSRF) attacks.
4 -/// Uses session-based CSRF tokens for state-changing operations (POST/PUT/DELETE).
5 -/// SEC-H4: Uses constant-time comparison to prevent timing attacks.
6 -/// SEC-C3: Blocks requests without CSRF token (except API Bearer routes).
1 +//! CSRF Protection Middleware
2 +//!
3 +//! Protects against Cross-Site Request Forgery (CSRF) attacks via
4 +//! session-bound tokens for state-changing operations.
5 +//!
6 +//! Two entry points:
7 +//! - [`csrf_protection`] — default behavior. Accepts the token only in the
8 +//! `X-CSRF-Token` header. Suited to JS clients / HTMX.
9 +//! - [`csrf_protection_with_config`] — explicit [`CsrfProtectionConfig`].
10 +//! When `accept_form_body = true`, also looks for `csrf_token=...` in a
11 +//! `application/x-www-form-urlencoded` body and buffers it up to
12 +//! `max_form_body_bytes`. Suited to SSR apps without JS (e.g. Gitrust).
13 +//!
14 +//! Security properties (inherited):
15 +//! - SEC-H4: constant-time token comparison.
16 +//! - SEC-C3: blocks state-changing requests without a token, except those
17 +//! carrying an `Authorization: Bearer ...` header (API clients, not
18 +//! browser-driven, so not CSRF-vulnerable).
19 +
7 20 use axum::{
21 + body::Body,
8 22 extract::{Request, State},
9 - http::{Method, StatusCode, Uri},
23 + http::{header::CONTENT_TYPE, Method, StatusCode, Uri},
10 24 middleware::Next,
11 25 response::{IntoResponse, Response},
12 26 };
13 27 use rand::RngCore;
14 28 use sea_orm::DatabaseConnection;
29 +use std::sync::Arc;
15 30 use subtle::ConstantTimeEq;
16 31 use tower_sessions::Session;
17 32
18 33 use crate::error::AppError;
19 34
35 +/// Default buffer cap for form bodies that may carry a CSRF token: 1 MiB.
36 +pub const DEFAULT_MAX_FORM_BODY_BYTES: usize = 1024 * 1024;
37 +
38 +/// Runtime configuration for [`csrf_protection_with_config`].
39 +///
40 +/// The defaults match the behavior of [`csrf_protection`] (header-only, no
41 +/// body parsing), so an app that opts in to the configurable wrapper without
42 +/// changing any field sees no behavior change.
43 +#[derive(Debug, Clone)]
44 +pub struct CsrfProtectionConfig {
45 + /// When true, if the `X-CSRF-Token` header is absent and the request
46 + /// declares `Content-Type: application/x-www-form-urlencoded`, the body
47 + /// is buffered and scanned for a `csrf_token` field.
48 + ///
49 + /// Multipart bodies are never buffered (too risky on large uploads).
50 + pub accept_form_body: bool,
51 +
52 + /// Maximum number of bytes buffered when `accept_form_body` is true.
53 + /// Requests that exceed this cap are rejected with HTTP 413.
54 + pub max_form_body_bytes: usize,
55 +}
56 +
57 +impl Default for CsrfProtectionConfig {
58 + fn default() -> Self {
59 + Self {
60 + accept_form_body: false,
61 + max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES,
62 + }
63 + }
64 +}
65 +
20 66 /// Session key for CSRF token
21 67 const CSRF_TOKEN_KEY: &str = "csrf_token";
22 68
@@ -59,28 +105,48 @@ pub async fn get_or_create_csrf_token(session: &Session) -> Result<String, AppEr
59 105 Ok(token)
60 106 }
61 107
62 -/// Extract CSRF token from request
63 -///
64 -/// Checks both header (X-CSRF-Token) and form field (csrf_token).
108 +/// Extract the CSRF token from the `X-CSRF-Token` header only.
65 109 ///
66 -/// # Arguments
67 -///
68 -/// * `request` - HTTP request
110 +/// Body-based extraction is handled separately by [`extract_csrf_from_form`]
111 +/// when the middleware is configured with `accept_form_body = true`.
112 +fn extract_header_token(request: &Request) -> Option<String> {
113 + request
114 + .headers()
115 + .get("X-CSRF-Token")
116 + .and_then(|v| v.to_str().ok())
117 + .map(|s| s.to_string())
118 +}
119 +
120 +/// True iff the request declares `Content-Type: application/x-www-form-urlencoded`.
121 +fn is_form_urlencoded(request: &Request) -> bool {
122 + request
123 + .headers()
124 + .get(CONTENT_TYPE)
125 + .and_then(|v| v.to_str().ok())
126 + .map(|v| {
127 + v.to_ascii_lowercase()
128 + .starts_with("application/x-www-form-urlencoded")
129 + })
130 + .unwrap_or(false)
131 +}
132 +
133 +/// Extract the `csrf_token` field from a URL-encoded form body.
69 134 ///
70 -/// # Returns
135 +/// Returns `None` when:
136 +/// - the body is not valid UTF-8,
137 +/// - no `csrf_token` field is present,
138 +/// - the URL-decoded value is invalid.
71 139 ///
72 -/// CSRF token if found, None otherwise
73 -fn extract_csrf_token_from_request(request: &Request) -> Option<String> {
74 - // Check header first
75 - if let Some(header_value) = request.headers().get("X-CSRF-Token") {
76 - if let Ok(token) = header_value.to_str() {
77 - return Some(token.to_string());
78 - }
79 - }
80 -
81 - // For form data, we would need to parse the body
82 - // This is handled in handlers that process form data
83 - None
140 +/// Exposed as `pub` so apps that run their own buffering can still reuse the
141 +/// framework's parser (matches proposition B in
142 +/// [`docs/UPSTREAM_RUSTWARDEN.md`](../../../../docs/UPSTREAM_RUSTWARDEN.md)).
143 +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> {
144 + let text = std::str::from_utf8(body).ok()?;
145 + text.split('&')
146 + .filter_map(|pair| pair.split_once('='))
147 + .find(|(k, _)| *k == "csrf_token")
148 + .and_then(|(_, v)| urlencoding::decode(v).ok())
149 + .map(|cow| cow.into_owned())
84 150 }
85 151
86 152 /// Middleware that validates CSRF tokens for state-changing operations
@@ -120,92 +186,178 @@ fn csrf_tokens_match(a: &str, b: &str) -> bool {
120 186 a.as_bytes().ct_eq(b.as_bytes()).into()
121 187 }
122 188
123 -pub async fn csrf_protection(
124 - State(_db): State<DatabaseConnection>,
125 - session: Session,
126 - uri: Uri,
189 +/// Outcome of running the CSRF check against an incoming request.
190 +///
191 +/// `Pass` carries the (possibly reconstructed) request forward so body-buffered
192 +/// flows can replay the consumed bytes to downstream extractors.
193 +#[derive(Debug)]
194 +enum CsrfOutcome {
195 + /// Request is safe, Bearer-authed, or token validated — forward to `next`.
196 + Pass(Request),
197 + /// Token missing, mismatched, or body cap exceeded — short-circuit.
198 + Reject(Response),
199 +}
200 +
201 +/// Core CSRF check with configurable body buffering.
202 +///
203 +/// Flow:
204 +/// 1. Safe method (`GET`/`HEAD`/`OPTIONS`) → pass.
205 +/// 2. `Authorization: Bearer ...` → pass (API client, not CSRF-vulnerable).
206 +/// 3. `X-CSRF-Token` header present → validate and decide.
207 +/// 4. Else if `cfg.accept_form_body` and `Content-Type: application/x-www-form-urlencoded` →
208 +/// buffer (capped at `cfg.max_form_body_bytes`), scan for `csrf_token`, validate.
209 +/// The buffered body is reattached to the request so the downstream handler
210 +/// can still decode its `Form<T>`.
211 +/// 5. Else → reject (403 CSRF token required).
212 +async fn csrf_check(
213 + session: &Session,
214 + uri: &Uri,
127 215 request: Request,
128 - next: Next,
129 -) -> Result<Response, AppError> {
216 + cfg: &CsrfProtectionConfig,
217 +) -> Result<CsrfOutcome, AppError> {
130 218 let method = request.method().clone();
131 219
132 - // SEC-H4: TRACE removed — only safe idempotent methods are exempt
220 + // SEC-H4: TRACE removed — only safe idempotent methods are exempt.
133 221 if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
134 - return Ok(next.run(request).await);
222 + return Ok(CsrfOutcome::Pass(request));
135 223 }
136 224
137 - // SEC-C3: Skip CSRF only for requests carrying a valid Bearer JWT.
138 - // The mere presence of the header is not enough — we must validate the token.
225 + // SEC-C3: Skip CSRF for Bearer API clients.
139 226 if is_bearer_api_request(&request) {
140 227 tracing::debug!("CSRF skip: Bearer API request for {} {}", method, uri);
141 - return Ok(next.run(request).await);
228 + return Ok(CsrfOutcome::Pass(request));
142 229 }
143 230
144 231 tracing::debug!("CSRF protection: validating token for {} {}", method, uri);
232 + let session_token = get_or_create_csrf_token(session).await?;
145 233
146 - let session_token = get_or_create_csrf_token(&session).await?;
147 - let request_token = extract_csrf_token_from_request(&request);
234 + // Prefer the header: cheap and non-destructive.
235 + if let Some(token) = extract_header_token(&request) {
236 + return Ok(validate_or_reject(
237 + &session_token,
238 + &token,
239 + &method,
240 + uri,
241 + request,
242 + ));
243 + }
148 244
149 - match request_token {
150 - Some(token) => {
151 - if !csrf_tokens_match(&token, &session_token) {
245 + // Fall back to the form body when explicitly enabled.
246 + if cfg.accept_form_body && is_form_urlencoded(&request) {
247 + let (parts, body) = request.into_parts();
248 + let bytes = match axum::body::to_bytes(body, cfg.max_form_body_bytes).await {
249 + Ok(b) => b,
250 + Err(err) => {
152 251 tracing::warn!(
153 - "CSRF token mismatch for {} {} (expected: {}..., got: {}...)",
252 + cap = cfg.max_form_body_bytes,
253 + error = %err,
254 + "CSRF body buffering failed (cap exceeded or stream error) for {} {}",
154 255 method,
155 256 uri,
156 - &session_token[..8.min(session_token.len())],
157 - &token[..8.min(token.len())]
158 257 );
159 - return Ok((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response());
258 + return Ok(CsrfOutcome::Reject(
259 + (
260 + StatusCode::PAYLOAD_TOO_LARGE,
261 + "Form body too large for CSRF check",
262 + )
263 + .into_response(),
264 + ));
160 265 }
266 + };
267 + let body_token = extract_csrf_from_form(&bytes);
268 + // Reconstruct the request *before* deciding, so that a valid token
269 + // still has a body available for the downstream handler.
270 + let reconstructed = Request::from_parts(parts, Body::from(bytes));
271 + return Ok(match body_token {
272 + Some(t) => validate_or_reject(&session_token, &t, &method, uri, reconstructed),
273 + None => {
274 + tracing::warn!("CSRF token missing from form body for {} {}", method, uri);
275 + CsrfOutcome::Reject((StatusCode::FORBIDDEN, "CSRF token required").into_response())
276 + }
277 + });
278 + }
161 279
162 - tracing::debug!("CSRF token validated for {} {}", method, uri);
163 - Ok(next.run(request).await)
164 - }
165 - None => {
166 - // SEC-C3: No CSRF token at all → block unconditionally.
167 - // The previous /api/ exception has been removed.
168 - tracing::warn!("CSRF token missing for {} {}", method, uri);
169 - Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response())
170 - }
280 + // SEC-C3: No token at all → block.
281 + tracing::warn!("CSRF token missing for {} {}", method, uri);
282 + Ok(CsrfOutcome::Reject(
283 + (StatusCode::FORBIDDEN, "CSRF token required").into_response(),
284 + ))
285 +}
286 +
287 +fn validate_or_reject(
288 + session_token: &str,
289 + incoming: &str,
290 + method: &Method,
291 + uri: &Uri,
292 + request: Request,
293 +) -> CsrfOutcome {
294 + if csrf_tokens_match(incoming, session_token) {
295 + tracing::debug!("CSRF token validated for {} {}", method, uri);
296 + CsrfOutcome::Pass(request)
297 + } else {
298 + tracing::warn!(
299 + "CSRF token mismatch for {} {} (expected: {}..., got: {}...)",
300 + method,
301 + uri,
302 + &session_token[..8.min(session_token.len())],
303 + &incoming[..8.min(incoming.len())]
304 + );
305 + CsrfOutcome::Reject((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response())
171 306 }
172 307 }
173 308
174 -/// Internal implementation that takes db and session as parameters
175 -pub async fn csrf_protection_impl(
176 - _db: &DatabaseConnection,
309 +/// Default header-only CSRF middleware.
310 +///
311 +/// Wire as `axum::middleware::from_fn_with_state(db, csrf_protection)` on a
312 +/// router whose routes already reject without a token. For SSR apps that only
313 +/// have the token in a form body, use [`csrf_protection_with_config`] instead.
314 +pub async fn csrf_protection(
315 + State(_db): State<DatabaseConnection>,
177 316 session: Session,
178 317 uri: Uri,
179 318 request: Request,
180 319 next: Next,
181 320 ) -> Result<Response, AppError> {
182 - let method = request.method().clone();
183 -
184 - if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
185 - return Ok(next.run(request).await);
321 + let cfg = CsrfProtectionConfig::default();
322 + match csrf_check(&session, &uri, request, &cfg).await? {
323 + CsrfOutcome::Pass(req) => Ok(next.run(req).await),
324 + CsrfOutcome::Reject(resp) => Ok(resp),
186 325 }
326 +}
187 327
188 - if is_bearer_api_request(&request) {
189 - return Ok(next.run(request).await);
328 +/// CSRF middleware with explicit [`CsrfProtectionConfig`].
329 +///
330 +/// Wire with `axum::middleware::from_fn_with_state((db, Arc::new(cfg)),
331 +/// csrf_protection_with_config)` when you need `accept_form_body = true` for
332 +/// SSR HTML forms (no JS, token in a hidden input).
333 +pub async fn csrf_protection_with_config(
334 + State((_db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
335 + session: Session,
336 + uri: Uri,
337 + request: Request,
338 + next: Next,
339 +) -> Result<Response, AppError> {
340 + match csrf_check(&session, &uri, request, cfg.as_ref()).await? {
341 + CsrfOutcome::Pass(req) => Ok(next.run(req).await),
342 + CsrfOutcome::Reject(resp) => Ok(resp),
190 343 }
344 +}
191 345
192 - let session_token = get_or_create_csrf_token(&session).await?;
193 - let request_token = extract_csrf_token_from_request(&request);
194 -
195 - match request_token {
196 - Some(token) => {
197 - if !csrf_tokens_match(&token, &session_token) {
198 - tracing::warn!("CSRF token mismatch for {} {}", method, uri);
199 - return Ok((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response());
200 - }
201 -
202 - tracing::debug!("CSRF token validated for {} {}", method, uri);
203 - Ok(next.run(request).await)
204 - }
205 - None => {
206 - tracing::warn!("CSRF token missing for {} {}", method, uri);
207 - Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response())
208 - }
346 +/// Internal implementation that takes db by reference instead of via `State`.
347 +///
348 +/// Behaviorally identical to [`csrf_protection`]; kept as a separate entry
349 +/// point for call sites that already have a `&DatabaseConnection` in scope.
350 +pub async fn csrf_protection_impl(
351 + _db: &DatabaseConnection,
352 + session: Session,
353 + uri: Uri,
354 + request: Request,
355 + next: Next,
356 +) -> Result<Response, AppError> {
357 + let cfg = CsrfProtectionConfig::default();
358 + match csrf_check(&session, &uri, request, &cfg).await? {
359 + CsrfOutcome::Pass(req) => Ok(next.run(req).await),
360 + CsrfOutcome::Reject(resp) => Ok(resp),
209 361 }
210 362 }
211 363
@@ -238,6 +390,43 @@ pub async fn validate_csrf_token(session: &Session, form_token: &str) -> Result<
238 390 #[cfg(test)]
239 391 mod tests {
240 392 use super::*;
393 + use axum::body::Body;
394 + use axum::http::Request as HttpRequest;
395 + use std::sync::Arc;
396 + use tower_sessions::MemoryStore;
397 +
398 + // ---------- Form body extraction (pure function) ----------
399 +
400 + #[test]
401 + fn extract_csrf_from_form_returns_token_value() {
402 + let body = b"csrf_token=deadbeef";
403 + assert_eq!(extract_csrf_from_form(body), Some("deadbeef".to_string()));
404 + }
405 +
406 + #[test]
407 + fn extract_csrf_from_form_handles_multiple_fields() {
408 + let body = b"foo=bar&csrf_token=abc123&baz=qux";
409 + assert_eq!(extract_csrf_from_form(body), Some("abc123".to_string()));
410 + }
411 +
412 + #[test]
413 + fn extract_csrf_from_form_url_decodes_token() {
414 + // 61 = '=' so %3D is a decoded '='
415 + let body = b"csrf_token=abc%3D%3D";
416 + assert_eq!(extract_csrf_from_form(body), Some("abc==".to_string()));
417 + }
418 +
419 + #[test]
420 + fn extract_csrf_from_form_returns_none_when_absent() {
421 + let body = b"foo=bar&baz=qux";
422 + assert_eq!(extract_csrf_from_form(body), None);
423 + }
424 +
425 + #[test]
426 + fn extract_csrf_from_form_returns_none_on_invalid_utf8() {
427 + let body = &[0xff, 0xfe, 0xfd];
428 + assert_eq!(extract_csrf_from_form(body), None);
429 + }
241 430
242 431 #[test]
243 432 fn test_generate_csrf_token() {
@@ -247,4 +436,186 @@ mod tests {
247 436 assert_eq!(token1.len(), 64); // 32 bytes = 64 hex chars
248 437 assert_ne!(token1, token2); // Tokens should be different
249 438 }
439 +
440 + fn test_session() -> Session {
441 + Session::new(None, Arc::new(MemoryStore::default()), None)
442 + }
443 +
444 + fn build_request(method: Method, token_header: Option<&str>, bearer: bool) -> Request {
445 + let mut builder = HttpRequest::builder().method(method).uri("/resource");
446 + if let Some(t) = token_header {
447 + builder = builder.header("X-CSRF-Token", t);
448 + }
449 + if bearer {
450 + builder = builder.header(axum::http::header::AUTHORIZATION, "Bearer some.jwt.token");
451 + }
452 + builder.body(Body::empty()).unwrap()
453 + }
454 +
455 + fn build_form_request(body: &str, content_type: Option<&str>) -> Request {
456 + let ct = content_type.unwrap_or("application/x-www-form-urlencoded");
457 + HttpRequest::builder()
458 + .method(Method::POST)
459 + .uri("/resource")
460 + .header(CONTENT_TYPE, ct)
461 + .body(Body::from(body.to_owned()))
462 + .unwrap()
463 + }
464 +
465 + fn uri() -> Uri {
466 + "/resource".parse().unwrap()
467 + }
468 +
469 + fn assert_rejected(outcome: CsrfOutcome, expected: StatusCode) {
470 + match outcome {
471 + CsrfOutcome::Reject(resp) => assert_eq!(resp.status(), expected),
472 + CsrfOutcome::Pass(_) => panic!("expected Reject({expected}), got Pass"),
473 + }
474 + }
475 +
476 + fn default_cfg() -> CsrfProtectionConfig {
477 + CsrfProtectionConfig::default()
478 + }
479 +
480 + fn form_body_cfg() -> CsrfProtectionConfig {
481 + CsrfProtectionConfig {
482 + accept_form_body: true,
483 + max_form_body_bytes: 4096,
484 + }
485 + }
486 +
487 + #[tokio::test]
488 + async fn check_passes_safe_method() {
489 + let session = test_session();
490 + let req = build_request(Method::GET, None, false);
491 + let outcome = csrf_check(&session, &uri(), req, &default_cfg())
492 + .await
493 + .unwrap();
494 + assert!(matches!(outcome, CsrfOutcome::Pass(_)));
495 + }
496 +
497 + #[tokio::test]
498 + async fn check_passes_bearer_auth() {
499 + let session = test_session();
500 + let req = build_request(Method::POST, None, true);
501 + let outcome = csrf_check(&session, &uri(), req, &default_cfg())
502 + .await
503 + .unwrap();
504 + assert!(matches!(outcome, CsrfOutcome::Pass(_)));
505 + }
506 +
507 + #[tokio::test]
508 + async fn check_accepts_matching_header_token() {
509 + let session = test_session();
510 + let token = get_or_create_csrf_token(&session).await.unwrap();
511 + let req = build_request(Method::POST, Some(&token), false);
512 + let outcome = csrf_check(&session, &uri(), req, &default_cfg())
513 + .await
514 + .unwrap();
515 + assert!(matches!(outcome, CsrfOutcome::Pass(_)));
516 + }
517 +
518 + #[tokio::test]
519 + async fn check_rejects_mismatched_header_token() {
520 + let session = test_session();
521 + let _ = get_or_create_csrf_token(&session).await.unwrap();
522 + let req = build_request(Method::POST, Some("bogus"), false);
523 + let outcome = csrf_check(&session, &uri(), req, &default_cfg())
524 + .await
525 + .unwrap();
526 + assert_rejected(outcome, StatusCode::FORBIDDEN);
527 + }
528 +
529 + #[tokio::test]
530 + async fn check_rejects_missing_token() {
531 + let session = test_session();
532 + let req = build_request(Method::POST, None, false);
533 + let outcome = csrf_check(&session, &uri(), req, &default_cfg())
534 + .await
535 + .unwrap();
536 + assert_rejected(outcome, StatusCode::FORBIDDEN);
537 + }
538 +
539 + // ---------- Form-body (accept_form_body = true) ----------
540 +
541 + #[tokio::test]
542 + async fn check_accepts_matching_form_body_token_and_preserves_body() {
543 + let session = test_session();
544 + let token = get_or_create_csrf_token(&session).await.unwrap();
545 + let form = format!("csrf_token={token}&other=value");
546 + let req = build_form_request(&form, None);
547 +
548 + let outcome = csrf_check(&session, &uri(), req, &form_body_cfg())
549 + .await
550 + .unwrap();
551 +
552 + let forwarded = match outcome {
553 + CsrfOutcome::Pass(r) => r,
554 + CsrfOutcome::Reject(_) => panic!("expected Pass for valid form token"),
555 + };
556 + // Body must survive the buffering so downstream handlers can Form-decode it.
557 + let (_, body) = forwarded.into_parts();
558 + let bytes = axum::body::to_bytes(body, 4096).await.unwrap();
559 + assert_eq!(std::str::from_utf8(&bytes).unwrap(), form);
560 + }
561 +
562 + #[tokio::test]
563 + async fn check_rejects_form_body_when_accept_form_body_is_false() {
564 + let session = test_session();
565 + let token = get_or_create_csrf_token(&session).await.unwrap();
566 + let form = format!("csrf_token={token}");
567 + let req = build_form_request(&form, None);
568 +
569 + // Default config does NOT buffer the body → token unreachable → 403.
570 + let outcome = csrf_check(&session, &uri(), req, &default_cfg())
571 + .await
572 + .unwrap();
573 + assert_rejected(outcome, StatusCode::FORBIDDEN);
574 + }
575 +
576 + #[tokio::test]
577 + async fn check_rejects_form_body_with_mismatched_token() {
578 + let session = test_session();
579 + let _ = get_or_create_csrf_token(&session).await.unwrap();
580 + let req = build_form_request("csrf_token=wrong-token", None);
581 +
582 + let outcome = csrf_check(&session, &uri(), req, &form_body_cfg())
583 + .await
584 + .unwrap();
585 + assert_rejected(outcome, StatusCode::FORBIDDEN);
586 + }
587 +
588 + #[tokio::test]
589 + async fn check_rejects_form_body_exceeding_cap() {
590 + let session = test_session();
591 + let huge_body = "a=".to_string() + &"x".repeat(1024);
592 + let cfg = CsrfProtectionConfig {
593 + accept_form_body: true,
594 + max_form_body_bytes: 16, // intentionally tiny
595 + };
596 + let req = build_form_request(&huge_body, None);
597 +
598 + let outcome = csrf_check(&session, &uri(), req, &cfg).await.unwrap();
599 + assert_rejected(outcome, StatusCode::PAYLOAD_TOO_LARGE);
600 + }
601 +
602 + #[tokio::test]
603 + async fn check_rejects_multipart_body_without_header() {
604 + let session = test_session();
605 + let _ = get_or_create_csrf_token(&session).await.unwrap();
606 + // Multipart is never buffered — security choice, avoids large uploads.
607 + let req = build_form_request("ignored", Some("multipart/form-data; boundary=----abc"));
608 +
609 + let outcome = csrf_check(&session, &uri(), req, &form_body_cfg())
610 + .await
611 + .unwrap();
612 + assert_rejected(outcome, StatusCode::FORBIDDEN);
613 + }
614 +
615 + #[tokio::test]
616 + async fn default_config_matches_header_only_behavior() {
617 + let cfg = CsrfProtectionConfig::default();
618 + assert!(!cfg.accept_form_body);
619 + assert_eq!(cfg.max_form_body_bytes, DEFAULT_MAX_FORM_BODY_BYTES);
620 + }
250 621 }
M crates/rustwarden-core/src/middleware/error_handler.rs
+45 -19
@@ -21,37 +21,56 @@ use crate::error::AppError;
21 21 #[derive(Debug, Clone)]
22 22 pub enum ErrorCode {
23 23 // Authentication errors
24 - Auth001, // Invalid credentials
25 - Auth002, // Invalid token
26 - Auth003, // Token expired
27 - Auth004, // Token reused
28 - Auth005, // Authentication required
29 - Auth006, // Password change required
24 + /// Invalid credentials supplied (wrong username or password).
25 + Auth001,
26 + /// Token is malformed or unrecognised.
27 + Auth002,
28 + /// Token has expired and must be refreshed.
29 + Auth003,
30 + /// Token was already used; possible token-theft detected.
31 + Auth004,
32 + /// Request requires authentication but no credentials were provided.
33 + Auth005,
34 + /// User must change their password before proceeding.
35 + Auth006,
30 36
31 37 // CSRF errors
32 - Csrf001, // Missing CSRF token
33 - Csrf002, // Invalid CSRF token
34 - Csrf003, // Expired CSRF token
38 + /// CSRF token is absent from the request.
39 + Csrf001,
40 + /// CSRF token does not match the expected value.
41 + Csrf002,
42 + /// CSRF token has expired.
43 + Csrf003,
35 44
36 45 // Rate limiting errors
37 - Rate001, // Rate limit exceeded
46 + /// The client has exceeded the allowed request rate.
47 + Rate001,
38 48
39 49 // Validation errors
40 - Valid001, // Invalid input data
41 - Valid002, // Password complexity not met
42 - Valid003, // Invalid email format
43 - Valid004, // Invalid username format
50 + /// Input data failed schema or business-rule validation.
51 + Valid001,
52 + /// Submitted password does not meet complexity requirements.
53 + Valid002,
54 + /// Supplied email address is not in a valid format.
55 + Valid003,
56 + /// Supplied username is not in a valid format.
57 + Valid004,
44 58
45 59 // Authorization errors
46 - Rbac001, // Insufficient permissions
47 - Rbac002, // Admin role required
60 + /// The authenticated user lacks the required permission.
61 + Rbac001,
62 + /// The operation requires administrator role.
63 + Rbac002,
48 64
49 65 // Database errors
50 - Db001, // Database operation failed
51 - Db002, // Resource not found
66 + /// A database operation failed unexpectedly.
67 + Db001,
68 + /// The requested database record could not be found.
69 + Db002,
52 70
53 71 // Configuration errors
54 - Config001, // Configuration error
72 + /// A configuration value is missing or invalid.
73 + Config001,
55 74 }
56 75
57 76 impl ErrorCode {
@@ -96,6 +115,13 @@ impl ErrorCode {
96 115 AppError::Config(_) => ErrorCode::Config001,
97 116 AppError::Internal(_) => ErrorCode::Db001,
98 117 AppError::Template(_) => ErrorCode::Db001, // Template errors treated as internal
118 + AppError::TotpFailed => ErrorCode::Auth001,
119 + AppError::TotpChallengeInvalid => ErrorCode::Auth002,
120 + AppError::TotpMaxAttemptsExceeded => ErrorCode::Rate001,
121 + AppError::TotpRequired => ErrorCode::Auth006,
122 + AppError::TotpAlreadyEnabled => ErrorCode::Valid001,
123 + AppError::TotpNotEnabled => ErrorCode::Valid001,
124 + AppError::TooManyRequests { .. } => ErrorCode::Rate001,
99 125 }
100 126 }
101 127 }
M crates/rustwarden-core/src/middleware/mod.rs
+11 -0
@@ -1,14 +1,25 @@
1 1 /// API Middleware Module
2 2 ///
3 3 /// Middleware for the Rustwarden API.
4 +/// Extractor for admin identity from request extensions.
4 5 pub mod admin_extractor;
6 +/// Helper utilities for admin middleware.
5 7 pub mod admin_helper;
8 +/// Middleware that rejects non-admin requests.
6 9 pub mod admin_required;
10 +/// Service layer for admin-specific operations used by middleware.
7 11 pub mod admin_service;
12 +/// Middleware that rejects unauthenticated requests.
8 13 pub mod auth_required;
14 +/// Service layer for authentication checks used by middleware.
9 15 pub mod auth_service;
16 +/// CSRF token generation and validation middleware.
10 17 pub mod csrf;
18 +/// Centralized error handling middleware that maps errors to structured responses.
11 19 pub mod error_handler;
20 +/// Middleware that validates password complexity requirements.
12 21 pub mod password_complexity;
22 +/// Token-bucket rate limiting middleware.
13 23 pub mod rate_limit;
24 +/// Middleware that updates the user's last-login timestamp on successful requests.
14 25 pub mod update_last_login;
M crates/rustwarden-core/src/middleware/password_complexity.rs
+9 -6
@@ -16,12 +16,17 @@ use crate::services::app_settings_service::AppSettingsService;
16 16 /// Password validation rules (loaded from DB or defaults)
17 17 #[derive(Debug, Clone, serde::Serialize)]
18 18 pub struct PasswordRules {
19 + /// Minimum number of characters required in a password.
19 20 pub min_length: usize,
20 21 /// SEC-M3: bcrypt silently truncates at 72 bytes; cap passwords to prevent DoS
21 22 pub max_length: usize,
23 + /// Whether at least one uppercase letter is required.
22 24 pub require_uppercase: bool,
25 + /// Whether at least one lowercase letter is required.
23 26 pub require_lowercase: bool,
27 + /// Whether at least one digit is required.
24 28 pub require_digits: bool,
29 + /// Whether at least one special character is required.
25 30 pub require_special: bool,
26 31 }
27 32
@@ -43,12 +48,10 @@ impl PasswordRules {
43 48 /// Load password rules from the `app_settings` table.
44 49 /// Falls back to defaults for any missing key.
45 50 pub async fn from_db(db: &DatabaseConnection) -> Self {
46 - let get_bool = |key: &'static str, default: bool| {
47 - async move {
48 - AppSettingsService::get_bool(db, key, default)
49 - .await
50 - .unwrap_or(default)
51 - }
51 + let get_bool = |key: &'static str, default: bool| async move {
52 + AppSettingsService::get_bool(db, key, default)
53 + .await
54 + .unwrap_or(default)
52 55 };
53 56
54 57 let min_length = match AppSettingsService::get_setting(db, "password_min_length").await {
M crates/rustwarden-core/src/middleware/rate_limit.rs
+96 -281
@@ -1,27 +1,37 @@
1 -/// Rate Limiting Middleware for Admin Routes and Public Share Endpoints
2 -/// Feature 009: Admin & Settings
3 -/// Feature 041: Files Sync & Sharing (T074)
4 -///
5 -/// Middleware to apply rate limiting to admin routes and public share endpoints with different limits:
6 -/// - Admin endpoints: 100 req/min
7 -/// - User management: 20 req/min
8 -/// - Reset password: 10 req/min
9 -/// - Public share endpoints: 30 req/min per IP
1 +//! Rate Limiting Middleware and Helpers
2 +//!
3 +//! Feature 009: Admin & Settings — per-route tower layers for blunt-instrument
4 +//! rate limiting on admin endpoints.
5 +//!
6 +//! Feature 041: Files Sync & Sharing (T074) — fine-grained per-IP limits for
7 +//! public share endpoints and authentication endpoints, routed through the
8 +//! generic [`RateLimitService`](crate::services::rate_limit_service::RateLimitService).
9 +//!
10 +//! # Thresholds
11 +//!
12 +//! | Scope | Default |
13 +//! |-------|---------|
14 +//! | Admin (tower `RateLimitLayer`) | 100 req/min |
15 +//! | User management (tower) | 20 req/min |
16 +//! | Reset password (tower) | 10 req/min |
17 +//! | Public share per IP | 30 req/min |
18 +//! | Auth per IP | env `RATE_LIMIT_AUTH_PER_MINUTE`, default 10 req/min |
19 +//!
20 +//! Public share and auth limits are backed by the in-process
21 +//! [`RateLimitService`] so they share bounding, cleanup and the canonical
22 +//! `TooManyRequests` error (HTTP 429 + `Retry-After`).
23 +
10 24 use axum::{
11 25 extract::{Request, State},
12 - http::StatusCode,
13 26 middleware::Next,
14 27 response::{IntoResponse, Response},
15 28 };
16 29 use sea_orm::DatabaseConnection;
17 -use std::{
18 - collections::HashMap,
19 - sync::{Arc, Mutex},
20 - time::{Duration, Instant},
21 -};
30 +use std::{sync::OnceLock, time::Duration};
22 31 use tower::limit::RateLimitLayer;
23 32
24 33 use crate::error::AppError;
34 +use crate::services::rate_limit_service::RateLimitService;
25 35
26 36 /// Rate limiting configuration for admin routes
27 37 pub struct AdminRateLimitConfig;
@@ -49,7 +59,7 @@ pub struct PublicShareRateLimitConfig;
49 59
50 60 impl PublicShareRateLimitConfig {
51 61 /// Create rate limiter for public share endpoints (30 requests per minute per IP)
52 - ///
62 + ///
53 63 /// This limit is more restrictive than authenticated endpoints to prevent abuse
54 64 /// of public links (brute force password attempts, token enumeration, etc.)
55 65 pub fn public_share_limit() -> RateLimitLayer {
@@ -57,106 +67,46 @@ impl PublicShareRateLimitConfig {
57 67 }
58 68 }
59 69
60 -/// Middleware that applies rate limiting to admin routes
61 -///
62 -/// This middleware uses tower's RateLimitLayer which applies per-IP rate limiting.
63 -/// Different limits can be applied to different route groups.
64 -///
65 -/// # Arguments
70 +/// Placeholder middleware — real admin rate limiting is applied via
71 +/// [`AdminRateLimitConfig::admin_limit`] in the router layer.
66 72 ///
67 -/// * `State(_db)` - Database connection (reserved for future per-user limits)
68 -/// * `request` - HTTP request
69 -/// * `next` - Next middleware or handler
70 -///
71 -/// # Returns
72 -///
73 -/// * `200 OK` if within limits (passes to handler)
74 -/// * `429 Too Many Requests` if limit exceeded
75 -///
76 -/// # Note
77 -///
78 -/// This middleware is a placeholder. The actual rate limiting is applied
79 -/// via `RateLimitLayer` in the router configuration (see routes.rs).
80 -/// This middleware can be extended to implement per-user rate limiting
81 -/// or more sophisticated rate limiting strategies.
73 +/// Kept for composability if a future version routes admin traffic through
74 +/// [`RateLimitService`] (for per-user quotas).
82 75 pub async fn admin_rate_limit(
83 76 State(_db): State<DatabaseConnection>,
84 77 request: Request,
85 78 next: Next,
86 79 ) -> Result<Response, AppError> {
87 - // Rate limiting is handled by RateLimitLayer in router configuration
88 - // This middleware can be extended for per-user limits or custom logic
89 80 Ok(next.run(request).await)
90 81 }
91 82
92 -/// SEC-M2: Maximum distinct IPs tracked before forcing a purge.
93 -const MAX_RATE_LIMIT_ENTRIES: usize = 100_000;
83 +const PUBLIC_SHARE_MAX_PER_MINUTE: u32 = 30;
84 +const DEFAULT_AUTH_MAX_PER_MINUTE: u32 = 10;
94 85
95 -/// Rate limit tracker for public share endpoints (per-IP)
96 -#[derive(Clone)]
97 -pub struct PublicShareRateLimiter {
98 - requests: Arc<Mutex<HashMap<String, (u32, Instant)>>>,
99 - max_requests: u32,
100 - window_seconds: u64,
101 -}
86 +static PUBLIC_SHARE_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
87 +static AUTH_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
88 +static AUTH_MAX_PER_MINUTE: OnceLock<u32> = OnceLock::new();
102 89
103 -impl Default for PublicShareRateLimiter {
104 - fn default() -> Self {
105 - Self::new(30, 60)
106 - }
90 +fn public_share_limiter() -> &'static RateLimitService {
91 + PUBLIC_SHARE_LIMITER.get_or_init(RateLimitService::new)
107 92 }
108 93
109 -impl PublicShareRateLimiter {
110 - pub fn new(max_requests: u32, window_seconds: u64) -> Self {
111 - Self {
112 - requests: Arc::new(Mutex::new(HashMap::new())),
113 - max_requests,
114 - window_seconds,
115 - }
116 - }
117 -
118 - pub fn check_rate_limit(&self, ip: &str) -> Result<(), StatusCode> {
119 - let mut requests = self
120 - .requests
121 - .lock()
122 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
123 -
124 - let now = Instant::now();
125 - let window_duration = Duration::from_secs(self.window_seconds);
126 -
127 - requests.retain(|_, (_, start)| now.duration_since(*start) < window_duration);
128 -
129 - // SEC-M2: Reject if map is still too large after purge (memory DoS protection)
130 - if requests.len() >= MAX_RATE_LIMIT_ENTRIES && !requests.contains_key(ip) {
131 - tracing::warn!("Rate limiter map full ({} entries), rejecting new IP", requests.len());
132 - return Err(StatusCode::TOO_MANY_REQUESTS);
133 - }
134 -
135 - let (count, start) = requests.entry(ip.to_string()).or_insert_with(|| (0, now));
136 -
137 - if now.duration_since(*start) >= window_duration {
138 - *count = 0;
139 - *start = now;
140 - }
141 -
142 - *count += 1;
143 - if *count > self.max_requests {
144 - tracing::warn!(
145 - ip = %ip,
146 - count = %count,
147 - max = %self.max_requests,
148 - "Rate limit exceeded for public share endpoint"
149 - );
150 - return Err(StatusCode::TOO_MANY_REQUESTS);
151 - }
94 +fn auth_limiter() -> &'static RateLimitService {
95 + AUTH_LIMITER.get_or_init(RateLimitService::new)
96 +}
152 97
153 - Ok(())
154 - }
98 +fn auth_max_per_minute() -> u32 {
99 + *AUTH_MAX_PER_MINUTE.get_or_init(|| {
100 + std::env::var("RATE_LIMIT_AUTH_PER_MINUTE")
101 + .ok()
102 + .and_then(|v| v.parse().ok())
103 + .unwrap_or(DEFAULT_AUTH_MAX_PER_MINUTE)
104 + })
155 105 }
156 106
157 107 /// SEC-H3: Number of trusted reverse proxy hops (0 = direct connection, ignore XFF).
158 108 /// Read once from TRUSTED_PROXY_HOPS environment variable.
159 -static TRUSTED_PROXY_HOPS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
109 +static TRUSTED_PROXY_HOPS: OnceLock<usize> = OnceLock::new();
160 110
161 111 fn trusted_proxy_hops() -> usize {
162 112 *TRUSTED_PROXY_HOPS.get_or_init(|| {
@@ -170,10 +120,7 @@ fn trusted_proxy_hops() -> usize {
170 120 /// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS.
171 121 /// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For.
172 122 /// Otherwise falls back to the direct connection IP.
173 -fn extract_ip_from_xff_or_connect(
174 - xff: Option<&str>,
175 - connect_ip: Option<String>,
176 -) -> String {
123 +fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String {
177 124 let hops = trusted_proxy_hops();
178 125 if hops > 0 {
179 126 if let Some(xff_value) = xff {
@@ -201,199 +148,55 @@ pub fn extract_client_ip(request: &Request) -> String {
201 148
202 149 /// Extract IP address from HeaderMap (used in handlers where Request is consumed)
203 150 pub fn extract_client_ip_from_headers(headers: &axum::http::HeaderMap) -> String {
204 - let xff = headers
205 - .get("x-forwarded-for")
206 - .and_then(|h| h.to_str().ok());
151 + let xff = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok());
207 152 extract_ip_from_xff_or_connect(xff, None)
208 153 }
209 154
210 -/// Global rate limiter instance for public share endpoints
211 -/// Feature 041: Files Sync & Sharing (T074)
155 +/// Check rate limit for a public share endpoint request.
212 156 ///
213 -/// This is a static instance that can be used across handlers.
214 -/// In a production environment, this could be replaced with a distributed
215 -/// rate limiter (Redis, etc.) for multi-instance deployments.
216 -static PUBLIC_SHARE_RATE_LIMITER: std::sync::OnceLock<PublicShareRateLimiter> =
217 - std::sync::OnceLock::new();
218 -
219 -/// Get or initialize the global public share rate limiter
220 -/// Feature 041: Files Sync & Sharing (T074)
221 -pub fn get_public_share_rate_limiter() -> &'static PublicShareRateLimiter {
222 - PUBLIC_SHARE_RATE_LIMITER.get_or_init(PublicShareRateLimiter::default)
223 -}
224 -
225 -/// Check rate limit for a public share endpoint request
226 -/// Feature 041: Files Sync & Sharing (T074)
227 -///
228 -/// This function can be called directly from handlers to check rate limits.
229 -/// Returns an error response if rate limit is exceeded.
230 -///
231 -/// # Arguments
232 -///
233 -/// * `request` - HTTP request
234 -///
235 -/// # Returns
236 -///
237 -/// * `Ok(())` if within limits
238 -/// * `Err(Response)` with 429 status if limit exceeded
157 +/// Feature 041: Files Sync & Sharing (T074). Returns an [`AppError`]
158 +/// response (HTTP 429 with `Retry-After`) if the limit is exceeded.
239 159 #[allow(clippy::result_large_err)]
240 -pub fn check_public_share_rate_limit(
241 - request: &Request,
242 -) -> Result<(), axum::response::Response> {
160 +pub fn check_public_share_rate_limit(request: &Request) -> Result<(), Response> {
243 161 let ip = extract_client_ip(request);
244 - let rate_limiter = get_public_share_rate_limiter();
245 -
246 - match rate_limiter.check_rate_limit(&ip) {
247 - Ok(()) => {
248 - tracing::debug!(ip = %ip, "Public share request allowed");
249 - Ok(())
250 - }
251 - Err(status) => {
252 - tracing::warn!(
253 - ip = %ip,
254 - "Public share rate limit exceeded"
255 - );
256 - Err((
257 - status,
258 - axum::Json(serde_json::json!({
259 - "error": "Rate limit exceeded. Please try again later."
260 - })),
261 - )
262 - .into_response())
162 + match public_share_limiter().check(&format!("share:{ip}"), PUBLIC_SHARE_MAX_PER_MINUTE) {
163 + Ok(()) => Ok(()),
164 + Err(err) => {
165 + tracing::warn!(ip = %ip, "Public share rate limit exceeded");
166 + Err(err.into_response())
263 167 }
264 168 }
265 169 }
266 170
267 -/// Check rate limit for a public share endpoint using HeaderMap
268 -/// Feature 041: Files Sync & Sharing (T074)
269 -///
270 -/// This function can be called directly from handlers to check rate limits.
271 -/// Returns an error response if rate limit is exceeded.
272 -///
273 -/// # Arguments
274 -///
275 -/// * `headers` - HTTP headers
276 -///
277 -/// # Returns
171 +/// Check rate limit for a public share endpoint using `HeaderMap`.
278 172 ///
279 -/// * `Ok(())` if within limits
280 -/// * `Err(Response)` with 429 status if limit exceeded
173 +/// Feature 041: Files Sync & Sharing (T074). Prefer this variant when the
174 +/// `Request` has already been destructured by an extractor.
281 175 #[allow(clippy::result_large_err)]
282 176 pub fn check_public_share_rate_limit_from_headers(
283 177 headers: &axum::http::HeaderMap,
284 -) -> Result<(), axum::response::Response> {
178 +) -> Result<(), Response> {
285 179 let ip = extract_client_ip_from_headers(headers);
286 - let rate_limiter = get_public_share_rate_limiter();
287 -
288 - match rate_limiter.check_rate_limit(&ip) {
289 - Ok(()) => {
290 - tracing::debug!(ip = %ip, "Public share request allowed");
291 - Ok(())
292 - }
293 - Err(status) => {
294 - tracing::warn!(
295 - ip = %ip,
296 - "Public share rate limit exceeded"
297 - );
298 - Err((
299 - status,
300 - axum::Json(serde_json::json!({
301 - "error": "Rate limit exceeded. Please try again later."
302 - })),
303 - )
304 - .into_response())
305 - }
306 - }
307 -}
308 -
309 -/// SEC-C2: Per-IP rate limiter for authentication endpoints
310 -/// Protects login, register, refresh, and forgot-password from brute-force attacks.
311 -#[derive(Clone)]
312 -pub struct AuthRateLimiter {
313 - requests: Arc<Mutex<HashMap<String, (u32, Instant)>>>,
314 - max_requests: u32,
315 - window_seconds: u64,
316 -}
317 -
318 -impl AuthRateLimiter {
319 - pub fn new(max_requests: u32, window_seconds: u64) -> Self {
320 - Self {
321 - requests: Arc::new(Mutex::new(HashMap::new())),
322 - max_requests,
323 - window_seconds,
324 - }
325 - }
326 -
327 - pub fn check_rate_limit(&self, ip: &str) -> Result<(), StatusCode> {
328 - let mut requests = self
329 - .requests
330 - .lock()
331 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
332 -
333 - let now = Instant::now();
334 - let window_duration = Duration::from_secs(self.window_seconds);
335 -
336 - requests.retain(|_, (_, start)| now.duration_since(*start) < window_duration);
337 -
338 - // SEC-M2: Reject if map is still too large after purge
339 - if requests.len() >= MAX_RATE_LIMIT_ENTRIES && !requests.contains_key(ip) {
340 - tracing::warn!("Auth rate limiter map full ({} entries), rejecting new IP", requests.len());
341 - return Err(StatusCode::TOO_MANY_REQUESTS);
342 - }
343 -
344 - let (count, start) = requests.entry(ip.to_string()).or_insert_with(|| (0, now));
345 -
346 - if now.duration_since(*start) >= window_duration {
347 - *count = 0;
348 - *start = now;
349 - }
350 -
351 - *count += 1;
352 - if *count > self.max_requests {
353 - tracing::warn!(
354 - ip = %ip,
355 - count = %count,
356 - max = %self.max_requests,
357 - "Auth rate limit exceeded"
358 - );
359 - return Err(StatusCode::TOO_MANY_REQUESTS);
180 + match public_share_limiter().check(&format!("share:{ip}"), PUBLIC_SHARE_MAX_PER_MINUTE) {
181 + Ok(()) => Ok(()),
182 + Err(err) => {
183 + tracing::warn!(ip = %ip, "Public share rate limit exceeded");
184 + Err(err.into_response())
360 185 }
361 -
362 - Ok(())
363 186 }
364 187 }
365 188
366 -static AUTH_RATE_LIMITER: std::sync::OnceLock<AuthRateLimiter> = std::sync::OnceLock::new();
367 -
368 -/// Get or initialize the global auth rate limiter.
369 -/// Default: 10 requests per minute per IP.
370 -pub fn get_auth_rate_limiter() -> &'static AuthRateLimiter {
371 - AUTH_RATE_LIMITER.get_or_init(|| {
372 - let max = std::env::var("RATE_LIMIT_AUTH_PER_MINUTE")
373 - .ok()
374 - .and_then(|v| v.parse().ok())
375 - .unwrap_or(10);
376 - AuthRateLimiter::new(max, 60)
377 - })
378 -}
379 -
380 -/// Check auth rate limit for a request and return an error response if exceeded.
189 +/// SEC-C2: Check auth rate limit for an incoming request.
190 +///
191 +/// Protects login, register, refresh, and forgot-password from brute-force
192 +/// attacks. Returns 429 `Too Many Requests` with a `Retry-After` header when
193 +/// the per-IP budget is exceeded.
381 194 #[allow(clippy::result_large_err)]
382 -pub fn check_auth_rate_limit(
383 - headers: &axum::http::HeaderMap,
384 -) -> Result<(), axum::response::Response> {
195 +pub fn check_auth_rate_limit(headers: &axum::http::HeaderMap) -> Result<(), Response> {
385 196 let ip = extract_client_ip_from_headers(headers);
386 - let limiter = get_auth_rate_limiter();
387 -
388 - match limiter.check_rate_limit(&ip) {
197 + match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) {
389 198 Ok(()) => Ok(()),
390 - Err(status) => Err((
391 - status,
392 - axum::Json(serde_json::json!({
393 - "error": "Too many requests. Please try again later."
394 - })),
395 - )
396 - .into_response()),
199 + Err(err) => Err(err.into_response()),
397 200 }
398 201 }
399 202
@@ -409,13 +212,25 @@ mod tests {
409 212 }
410 213
411 214 #[test]
412 - fn test_auth_rate_limiter() {
413 - let limiter = AuthRateLimiter::new(3, 60);
414 - assert!(limiter.check_rate_limit("127.0.0.1").is_ok());
415 - assert!(limiter.check_rate_limit("127.0.0.1").is_ok());
416 - assert!(limiter.check_rate_limit("127.0.0.1").is_ok());
417 - assert!(limiter.check_rate_limit("127.0.0.1").is_err());
418 - // Different IP should still be allowed
419 - assert!(limiter.check_rate_limit("192.168.1.1").is_ok());
215 + fn auth_limiter_isolates_budget_per_ip() {
216 + let rl = RateLimitService::new();
217 + for _ in 0..3 {
218 + rl.check("auth:1.2.3.4", 3).unwrap();
219 + }
220 + assert!(rl.check("auth:1.2.3.4", 3).is_err());
221 + // Different IP has its own budget.
222 + rl.check("auth:5.6.7.8", 3)
223 + .expect("distinct IP must not share the other IP's budget");
224 + }
225 +
226 + #[test]
227 + fn share_limiter_isolates_budget_per_ip() {
228 + let rl = RateLimitService::new();
229 + for _ in 0..3 {
230 + rl.check("share:1.2.3.4", 3).unwrap();
231 + }
232 + assert!(rl.check("share:1.2.3.4", 3).is_err());
233 + rl.check("share:9.9.9.9", 3)
234 + .expect("distinct IP must not share the other IP's budget");
420 235 }
421 236 }
M crates/rustwarden-core/src/migrations.rs
+23 -1
@@ -21,21 +21,38 @@
21 21 //! }
22 22 //! ```
23 23
24 +/// Migration 1: initial schema (users, roles, refresh tokens, audit logs).
24 25 #[path = "../migrations/src/m20260305_000001_initial_schema.rs"]
25 26 pub mod m20260305_000001_initial_schema;
26 27
28 +/// Migration 2: application settings table.
27 29 #[path = "../migrations/src/m20260306_000002_create_app_settings_table.rs"]
28 30 pub mod m20260306_000002_create_app_settings_table;
29 31
32 +/// Migration 3: RBAC permissions tables.
30 33 #[path = "../migrations/src/m20260309_000003_create_permissions_tables.rs"]
31 34 pub mod m20260309_000003_create_permissions_tables;
32 35
36 +/// Migration 4: generic resources and resource-shares tables.
33 37 #[path = "../migrations/src/m20260309_000004_create_resources_tables.rs"]
34 38 pub mod m20260309_000004_create_resources_tables;
35 39
40 +/// Migration 5: OAuth accounts table.
36 41 #[path = "../migrations/src/m20260310_000005_create_oauth_accounts_table.rs"]
37 42 pub mod m20260310_000005_create_oauth_accounts_table;
38 43
44 +/// Migration 6: TOTP challenges and user-TOTP tables.
45 +#[path = "../migrations/src/m20260409_000006_create_totp_tables.rs"]
46 +pub mod m20260409_000006_create_totp_tables;
47 +
48 +/// Migration 7: personal access tokens table.
49 +#[path = "../migrations/src/m20260422_000007_create_personal_access_tokens.rs"]
50 +pub mod m20260422_000007_create_personal_access_tokens;
51 +
52 +/// Migration 8: teams and team-members tables.
53 +#[path = "../migrations/src/m20260423_000008_create_teams.rs"]
54 +pub mod m20260423_000008_create_teams;
55 +
39 56 use sea_orm_migration::prelude::*;
40 57
41 58 /// Returns all core Rustwarden migrations in order.
@@ -46,6 +63,9 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
46 63 Box::new(m20260309_000003_create_permissions_tables::Migration),
47 64 Box::new(m20260309_000004_create_resources_tables::Migration),
48 65 Box::new(m20260310_000005_create_oauth_accounts_table::Migration),
66 + Box::new(m20260409_000006_create_totp_tables::Migration),
67 + Box::new(m20260422_000007_create_personal_access_tokens::Migration),
68 + Box::new(m20260423_000008_create_teams::Migration),
49 69 ]
50 70 }
51 71
@@ -60,7 +80,9 @@ impl MigratorTrait for CoreMigrator {
60 80 }
61 81
62 82 /// Run all pending core migrations on the database.
63 -pub async fn run_migrations(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm_migration::DbErr> {
83 +pub async fn run_migrations(
84 + db: &sea_orm::DatabaseConnection,
85 +) -> Result<(), sea_orm_migration::DbErr> {
64 86 use sea_orm_migration::MigratorTrait;
65 87 CoreMigrator::up(db, None).await
66 88 }
M crates/rustwarden-core/src/models/app_settings.rs
+2 -0
@@ -26,8 +26,10 @@ pub struct Model {
26 26 pub updated_at: DateTime<Utc>,
27 27 }
28 28
29 +/// SeaORM relations for the AppSettings entity.
29 30 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
30 31 pub enum Relation {
32 + /// The user who last updated this setting.
31 33 #[sea_orm(
32 34 belongs_to = "super::user::Entity",
33 35 from = "Column::UpdatedBy",
M crates/rustwarden-core/src/models/audit_log.rs
+3 -0
@@ -27,14 +27,17 @@ pub struct Model {
27 27 pub created_at: DateTime<Utc>,
28 28 }
29 29
30 +/// SeaORM relations for the AuditLog entity.
30 31 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
31 32 pub enum Relation {
33 + /// The user who performed the logged action.
32 34 #[sea_orm(
33 35 belongs_to = "super::user::Entity",
34 36 from = "Column::UserId",
35 37 to = "super::user::Column::Id"
36 38 )]
37 39 User,
40 + /// The user targeted by the logged action (if any).
38 41 #[sea_orm(
39 42 belongs_to = "super::user::Entity",
40 43 from = "Column::TargetUserId",
M crates/rustwarden-core/src/models/email_delivery_status.rs
+17 -0
@@ -3,28 +3,45 @@ use sea_orm::entity::prelude::*;
3 3 use serde::{Deserialize, Serialize};
4 4 use uuid::Uuid;
5 5
6 +/// SeaORM model for the `email_delivery_status` table.
7 +///
8 +/// Tracks the delivery lifecycle of outbound emails (queued, sent, failed, etc.).
6 9 #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
7 10 #[sea_orm(table_name = "email_delivery_status")]
8 11 pub struct Model {
12 + /// Primary key.
9 13 #[sea_orm(primary_key)]
10 14 pub id: Uuid,
15 + /// Category of email (e.g. `"verification"`, `"password_reset"`).
11 16 pub email_type: String,
17 + /// Destination email address.
12 18 pub recipient: String,
19 + /// Name of the template used to render the email body.
13 20 pub template: String,
21 + /// Current delivery state (e.g. `"pending"`, `"sent"`, `"failed"`).
14 22 #[sea_orm(indexed)]
15 23 pub state: String,
24 + /// SMTP response code from the last delivery attempt.
16 25 pub smtp_code: Option<String>,
26 + /// Full SMTP response message from the last delivery attempt.
17 27 pub smtp_response: Option<String>,
28 + /// Number of delivery attempts made so far.
18 29 pub attempt_count: i32,
30 + /// Timestamp of the most recent delivery attempt.
19 31 pub last_attempt_at: Option<DateTime<Utc>>,
32 + /// Timestamp of the last state transition.
20 33 #[sea_orm(indexed)]
21 34 pub last_state_at: DateTime<Utc>,
35 + /// Optional correlation ID linking this record to a business transaction.
22 36 pub correlation_id: Option<Uuid>,
37 + /// Timestamp when this record was created.
23 38 #[sea_orm(indexed)]
24 39 pub created_at: DateTime<Utc>,
40 + /// Timestamp of the last update to this record.
25 41 pub updated_at: DateTime<Utc>,
26 42 }
27 43
44 +/// SeaORM relations for the EmailDeliveryStatus entity (none defined).
28 45 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
29 46 pub enum Relation {}
30 47
M crates/rustwarden-core/src/models/email_queue.rs
+1 -0
@@ -83,6 +83,7 @@ pub struct Model {
83 83 pub updated_at: DateTime<Utc>,
84 84 }
85 85
86 +/// SeaORM relations for the EmailQueue entity (none defined).
86 87 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
87 88 pub enum Relation {}
88 89
M crates/rustwarden-core/src/models/email_validation_token.rs
+1 -0
@@ -50,6 +50,7 @@ pub struct Model {
50 50 pub created_at: DateTime<Utc>,
51 51 }
52 52
53 +/// SeaORM relations for the EmailValidationToken entity.
53 54 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
54 55 pub enum Relation {
55 56 /// Relationship to User entity
M crates/rustwarden-core/src/models/jwt_blacklist.rs
+2 -1
@@ -49,9 +49,10 @@ pub struct Model {
49 49 pub created_at: DateTime<Utc>,
50 50 }
51 51
52 +/// SeaORM relations for the JwtBlacklist entity.
52 53 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
53 54 pub enum Relation {
54 - /// Relationship to User entity
55 + /// The user whose token was blacklisted.
55 56 #[sea_orm(
56 57 belongs_to = "super::user::Entity",
57 58 from = "Column::UserId",
M crates/rustwarden-core/src/models/mod.rs
+37 -4
@@ -2,25 +2,53 @@
2 2 //!
3 3 //! SeaORM entity models for the Rustwarden framework.
4 4
5 +/// Application settings entity (key-value configuration store).
5 6 pub mod app_settings;
7 +/// Audit log entity for admin operations.
6 8 pub mod audit_log;
7 -#[cfg(feature = "oauth")]
8 -pub mod oauth_account;
9 +/// Email delivery status tracking entity.
9 10 pub mod email_delivery_status;
11 +/// Email queue entity for asynchronous email sending.
10 12 pub mod email_queue;
13 +/// Email validation token entity.
11 14 pub mod email_validation_token;
15 +/// JWT blacklist entity for token revocation.
12 16 pub mod jwt_blacklist;
17 +/// OAuth account entity linking external provider identities to users.
18 +#[cfg(feature = "oauth")]
19 +pub mod oauth_account;
20 +/// Password change request entity requiring email confirmation.
13 21 pub mod password_change_request;
22 +/// Password reset token entity.
14 23 pub mod password_reset_token;
24 +/// Permission entity for RBAC.
15 25 pub mod permission;
26 +/// Personal access token entity (long-lived API credentials).
27 +pub mod personal_access_token;
28 +/// Refresh token entity for JWT rotation.
16 29 pub mod refresh_token;
30 +/// Generic resource registry entity.
17 31 pub mod resource;
32 +/// Resource share entity granting access to a principal.
18 33 pub mod resource_share;
34 +/// Role entity for RBAC.
19 35 pub mod role;
36 +/// Role-permission junction entity.
20 37 pub mod role_permission;
38 +/// Team entity for group-based access control.
39 +pub mod team;
40 +/// Team membership entity linking users to teams.
41 +pub mod team_member;
42 +/// Ephemeral TOTP challenge entity for the 2FA login flow.
43 +pub mod totp_challenge;
44 +/// User entity.
21 45 pub mod user;
46 +/// User preferences entity (theme, language).
22 47 pub mod user_preferences;
48 +/// User-role junction entity.
23 49 pub mod user_role;
50 +/// User TOTP secrets entity for two-factor authentication.
51 +pub mod user_totp;
24 52
25 53 pub use app_settings::Entity as AppSettings;
26 54 pub use audit_log::Entity as AuditLog;
@@ -28,16 +56,21 @@ pub use email_delivery_status::Entity as EmailDeliveryStatus;
28 56 pub use email_queue::Entity as EmailQueue;
29 57 pub use email_validation_token::Entity as EmailValidationToken;
30 58 pub use jwt_blacklist::Entity as JwtBlacklist;
59 +#[cfg(feature = "oauth")]
60 +pub use oauth_account::Entity as OauthAccount;
31 61 pub use password_change_request::Entity as PasswordChangeRequest;
32 62 pub use password_reset_token::Entity as PasswordResetToken;
33 63 pub use permission::Entity as Permission;
64 +pub use personal_access_token::Entity as PersonalAccessToken;
34 65 pub use refresh_token::Entity as RefreshToken;
35 66 pub use resource::Entity as Resource;
36 67 pub use resource_share::Entity as ResourceShare;
37 68 pub use role::Entity as Role;
38 69 pub use role_permission::Entity as RolePermission;
70 +pub use team::Entity as Team;
71 +pub use team_member::Entity as TeamMember;
72 +pub use totp_challenge::Entity as TotpChallenge;
39 73 pub use user::Entity as User;
40 74 pub use user_preferences::Entity as UserPreferences;
41 75 pub use user_role::Entity as UserRole;
42 -#[cfg(feature = "oauth")]
43 -pub use oauth_account::Entity as OauthAccount;
76 +pub use user_totp::Entity as UserTotp;
M crates/rustwarden-core/src/models/oauth_account.rs
+14 -0
@@ -3,23 +3,37 @@ use sea_orm::entity::prelude::*;
3 3 use serde::{Deserialize, Serialize};
4 4 use uuid::Uuid;
5 5
6 +/// SeaORM model for the `oauth_accounts` table.
7 +///
8 +/// Links a Rustwarden user account to an external OAuth/OIDC identity.
6 9 #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
7 10 #[sea_orm(table_name = "oauth_accounts")]
8 11 pub struct Model {
12 + /// Primary key.
9 13 #[sea_orm(primary_key)]
10 14 pub id: Uuid,
15 + /// FK → users.id — the local user this OAuth identity belongs to.
11 16 pub user_id: Uuid,
17 + /// Provider name (e.g. `"google"`, `"github"`).
12 18 pub provider: String,
19 + /// Unique user ID as returned by the OAuth provider.
13 20 pub provider_user_id: String,
21 + /// Email address reported by the provider (may be absent for some providers).
14 22 pub email: Option<String>,
23 + /// Display name reported by the provider.
15 24 pub display_name: Option<String>,
25 + /// URL of the user's avatar image reported by the provider.
16 26 pub avatar_url: Option<String>,
27 + /// Timestamp when this OAuth link was created.
17 28 pub created_at: DateTime<Utc>,
29 + /// Timestamp of the last update to this OAuth link.
18 30 pub updated_at: DateTime<Utc>,
19 31 }
20 32
33 +/// SeaORM relations for the OAuthAccount entity.
21 34 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
22 35 pub enum Relation {
36 + /// The local user account this OAuth identity is linked to.
23 37 #[sea_orm(
24 38 belongs_to = "super::user::Entity",
25 39 from = "Column::UserId",
M crates/rustwarden-core/src/models/password_change_request.rs
+1 -0
@@ -60,6 +60,7 @@ pub struct Model {
60 60 pub retry_count: i32,
61 61 }
62 62
63 +/// SeaORM relations for the PasswordChangeRequest entity.
63 64 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
64 65 pub enum Relation {
65 66 /// Relationship to User entity
M crates/rustwarden-core/src/models/password_reset_token.rs
+2 -1
@@ -49,9 +49,10 @@ pub struct Model {
49 49 pub created_at: DateTime<Utc>,
50 50 }
51 51
52 +/// SeaORM relations for the PasswordResetToken entity.
52 53 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
53 54 pub enum Relation {
54 - /// Relationship to User entity
55 + /// The user this password reset token belongs to.
55 56 #[sea_orm(
56 57 belongs_to = "super::user::Entity",
57 58 from = "Column::UserId",
M crates/rustwarden-core/src/models/permission.rs
+9 -0
@@ -3,19 +3,28 @@ use sea_orm::entity::prelude::*;
3 3 use serde::{Deserialize, Serialize};
4 4 use uuid::Uuid;
5 5
6 +/// SeaORM model for the `permissions` table.
7 +///
8 +/// Represents a fine-grained permission that can be assigned to roles.
6 9 #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
7 10 #[sea_orm(table_name = "permissions")]
8 11 pub struct Model {
12 + /// Primary key.
9 13 #[sea_orm(primary_key)]
10 14 pub id: Uuid,
15 + /// Unique machine-readable permission name (e.g. `"users:write"`).
11 16 #[sea_orm(unique)]
12 17 pub name: String,
18 + /// Optional human-readable description of what this permission grants.
13 19 #[sea_orm(nullable)]
14 20 pub description: Option<String>,
21 + /// Grouping category for UI display (e.g. `"users"`, `"admin"`).
15 22 pub category: String,
23 + /// Timestamp when this permission was created.
16 24 pub created_at: DateTime<Utc>,
17 25 }
18 26
27 +/// SeaORM relations for the Permission entity (none defined).
19 28 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
20 29 pub enum Relation {}
21 30
A crates/rustwarden-core/src/models/personal_access_token.rs
+158 -0
@@ -0,0 +1,158 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +/// Personal access token (long-lived API credential).
7 +///
8 +/// Companion of [`refresh_token`](super::refresh_token) — whereas refresh
9 +/// tokens rotate on every use, PATs are static secrets consumed unchanged by
10 +/// protocols that cannot handle rotation (git push over HTTPS, webhook ingest,
11 +/// third-party integrations).
12 +///
13 +/// # Storage
14 +///
15 +/// Only the SHA-256 hash of the token is persisted. The plaintext is returned
16 +/// once from [`crate::services::personal_token_service::PersonalTokenService::create`]
17 +/// and never stored anywhere in the framework.
18 +///
19 +/// # Lifecycle
20 +///
21 +/// 1. **Creation**: a random 32-byte token is generated, shown once to the user,
22 +/// its SHA-256 hash is stored with requested scopes.
23 +/// 2. **Usage**: every `verify()` call updates `last_used_at` for UX (list
24 +/// tokens, show "last used N hours ago").
25 +/// 3. **Revocation**: `revoked_at` is set; the row is kept for audit.
26 +/// 4. **Expiration**: optional `expires_at` — `None` means permanent.
27 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
28 +#[sea_orm(table_name = "personal_access_tokens")]
29 +pub struct Model {
30 + /// Unique identifier for the token record.
31 + #[sea_orm(primary_key)]
32 + pub id: Uuid,
33 +
34 + /// Owner of the token. Cascade-deleted with the user.
35 + pub user_id: Uuid,
36 +
37 + /// Human-friendly name chosen by the user (unique per user).
38 + pub name: String,
39 +
40 + /// SHA-256 hex digest of the plaintext token (64 chars).
41 + #[sea_orm(unique)]
42 + pub token_hash: String,
43 +
44 + /// Requested scopes (app-specific strings, e.g. `"read:repo"`).
45 + pub scopes: Vec<String>,
46 +
47 + /// Optional expiration. `None` = permanent token.
48 + #[sea_orm(nullable)]
49 + pub expires_at: Option<DateTime<Utc>>,
50 +
51 + /// Last time this token was used for authentication (for UX).
52 + #[sea_orm(nullable)]
53 + pub last_used_at: Option<DateTime<Utc>>,
54 +
55 + /// When the token was revoked (if applicable). A revoked token is kept
56 + /// for audit but refuses authentication.
57 + #[sea_orm(nullable)]
58 + pub revoked_at: Option<DateTime<Utc>>,
59 +
60 + /// Creation timestamp.
61 + pub created_at: DateTime<Utc>,
62 +}
63 +
64 +/// SeaORM relations for the PersonalAccessToken entity.
65 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
66 +pub enum Relation {
67 + /// The user who owns this personal access token.
68 + #[sea_orm(
69 + belongs_to = "super::user::Entity",
70 + from = "Column::UserId",
71 + to = "super::user::Column::Id",
72 + on_delete = "Cascade"
73 + )]
74 + User,
75 +}
76 +
77 +impl Related<super::user::Entity> for Entity {
78 + fn to() -> RelationDef {
79 + Relation::User.def()
80 + }
81 +}
82 +
83 +impl ActiveModelBehavior for ActiveModel {}
84 +
85 +impl Model {
86 + /// Returns true if the token has a set expiration date in the past.
87 + pub fn is_expired(&self) -> bool {
88 + self.expires_at.is_some_and(|t| Utc::now() > t)
89 + }
90 +
91 + /// Returns true if the token has been explicitly revoked.
92 + pub fn is_revoked(&self) -> bool {
93 + self.revoked_at.is_some()
94 + }
95 +
96 + /// Returns true if the token is neither expired nor revoked.
97 + pub fn is_active(&self) -> bool {
98 + !self.is_expired() && !self.is_revoked()
99 + }
100 +}
101 +
102 +#[cfg(test)]
103 +mod tests {
104 + use super::*;
105 + use chrono::Duration;
106 +
107 + fn base_model() -> Model {
108 + Model {
109 + id: Uuid::new_v4(),
110 + user_id: Uuid::new_v4(),
111 + name: "test".into(),
112 + token_hash: "a".repeat(64),
113 + scopes: vec![],
114 + expires_at: None,
115 + last_used_at: None,
116 + revoked_at: None,
117 + created_at: Utc::now(),
118 + }
119 + }
120 +
121 + #[test]
122 + fn permanent_token_never_expires() {
123 + assert!(!base_model().is_expired());
124 + }
125 +
126 + #[test]
127 + fn expires_at_in_past_is_expired() {
128 + let m = Model {
129 + expires_at: Some(Utc::now() - Duration::hours(1)),
130 + ..base_model()
131 + };
132 + assert!(m.is_expired());
133 + }
134 +
135 + #[test]
136 + fn expires_at_in_future_is_not_expired() {
137 + let m = Model {
138 + expires_at: Some(Utc::now() + Duration::hours(1)),
139 + ..base_model()
140 + };
141 + assert!(!m.is_expired());
142 + }
143 +
144 + #[test]
145 + fn revoked_token_is_inactive() {
146 + let m = Model {
147 + revoked_at: Some(Utc::now()),
148 + ..base_model()
149 + };
150 + assert!(m.is_revoked());
151 + assert!(!m.is_active());
152 + }
153 +
154 + #[test]
155 + fn fresh_permanent_token_is_active() {
156 + assert!(base_model().is_active());
157 + }
158 +}
M crates/rustwarden-core/src/models/refresh_token.rs
+2 -1
@@ -55,9 +55,10 @@ pub struct Model {
55 55 pub replaced_by: Option<Uuid>,
56 56 }
57 57
58 +/// SeaORM relations for the RefreshToken entity.
58 59 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
59 60 pub enum Relation {
60 - /// Relationship to User entity
61 + /// The user this refresh token belongs to.
61 62 #[sea_orm(
62 63 belongs_to = "super::user::Entity",
63 64 from = "Column::UserId",
M crates/rustwarden-core/src/models/resource.rs
+4 -0
@@ -11,6 +11,7 @@ use uuid::Uuid;
11 11 #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
12 12 #[sea_orm(table_name = "resources")]
13 13 pub struct Model {
14 + /// Primary key.
14 15 #[sea_orm(primary_key)]
15 16 pub id: Uuid,
16 17 /// Application-defined type key (e.g. "document", "project")
@@ -21,11 +22,14 @@ pub struct Model {
21 22 pub owner_id: Uuid,
22 23 /// Whether the resource is publicly visible to all authenticated users
23 24 pub is_public: bool,
25 + /// Timestamp when this resource was registered.
24 26 pub created_at: DateTime<Utc>,
25 27 }
26 28
29 +/// SeaORM relations for the Resource entity.
27 30 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
28 31 pub enum Relation {
32 + /// The user who owns this resource.
29 33 #[sea_orm(
30 34 belongs_to = "super::user::Entity",
31 35 from = "Column::OwnerId",
M crates/rustwarden-core/src/models/resource_share.rs
+25 -3
@@ -3,25 +3,38 @@ use sea_orm::entity::prelude::*;
3 3 use serde::{Deserialize, Serialize};
4 4 use uuid::Uuid;
5 5
6 -/// A share entry granting a user access to a resource with a specific permission level.
6 +/// A share entry granting access to a resource for a given principal
7 +/// (a user OR a team) with a specific permission level.
8 +///
9 +/// Exactly one of `shared_with_user_id` and `shared_with_team_id` is set —
10 +/// enforced by the `chk_resource_shares_principal` CHECK constraint at the
11 +/// DB level.
7 12 #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
8 13 #[sea_orm(table_name = "resource_shares")]
9 14 pub struct Model {
15 + /// Primary key.
10 16 #[sea_orm(primary_key)]
11 17 pub id: Uuid,
12 18 /// FK → resources.id
13 19 pub resource_id: Uuid,
14 - /// The user the resource is shared with
15 - pub shared_with_user_id: Uuid,
20 + /// The user the resource is shared with (`Some` for user-principal shares).
21 + #[sea_orm(nullable)]
22 + pub shared_with_user_id: Option<Uuid>,
23 + /// The team the resource is shared with (`Some` for team-principal shares).
24 + #[sea_orm(nullable)]
25 + pub shared_with_team_id: Option<Uuid>,
16 26 /// Permission level: "read", "write", or "admin"
17 27 pub permission_level: String,
18 28 /// The user who created this share
19 29 pub shared_by_user_id: Uuid,
30 + /// Timestamp when this share was created.
20 31 pub created_at: DateTime<Utc>,
21 32 }
22 33
34 +/// SeaORM relations for the ResourceShare entity.
23 35 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
24 36 pub enum Relation {
37 + /// The resource being shared.
25 38 #[sea_orm(
26 39 belongs_to = "super::resource::Entity",
27 40 from = "Column::ResourceId",
@@ -29,12 +42,21 @@ pub enum Relation {
29 42 on_delete = "Cascade"
30 43 )]
31 44 Resource,
45 + /// The user the resource is shared with (user-principal shares).
32 46 #[sea_orm(
33 47 belongs_to = "super::user::Entity",
34 48 from = "Column::SharedWithUserId",
35 49 to = "super::user::Column::Id"
36 50 )]
37 51 SharedWithUser,
52 + /// The team the resource is shared with (team-principal shares).
53 + #[sea_orm(
54 + belongs_to = "super::team::Entity",
55 + from = "Column::SharedWithTeamId",
56 + to = "super::team::Column::Id"
57 + )]
58 + SharedWithTeam,
59 + /// The user who created the share.
38 60 #[sea_orm(
39 61 belongs_to = "super::user::Entity",
40 62 from = "Column::SharedByUserId",
M crates/rustwarden-core/src/models/role.rs
+1 -0
@@ -26,6 +26,7 @@ pub struct Model {
26 26 pub updated_at: DateTime<Utc>,
27 27 }
28 28
29 +/// SeaORM relations for the Role entity (none defined).
29 30 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
30 31 pub enum Relation {}
31 32
M crates/rustwarden-core/src/models/role_permission.rs
+8 -0
@@ -1,17 +1,24 @@
1 1 use sea_orm::entity::prelude::*;
2 2 use uuid::Uuid;
3 3
4 +/// SeaORM model for the `role_permissions` junction table.
5 +///
6 +/// Associates roles with permissions using a composite primary key.
4 7 #[derive(Clone, Debug, DeriveEntityModel)]
5 8 #[sea_orm(table_name = "role_permissions")]
6 9 pub struct Model {
10 + /// FK → roles.id (part of composite primary key).
7 11 #[sea_orm(primary_key)]
8 12 pub role_id: Uuid,
13 + /// FK → permissions.id (part of composite primary key).
9 14 #[sea_orm(primary_key)]
10 15 pub permission_id: Uuid,
11 16 }
12 17
18 +/// SeaORM relations for the RolePermission junction entity.
13 19 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
14 20 pub enum Relation {
21 + /// The role side of the junction.
15 22 #[sea_orm(
16 23 belongs_to = "super::role::Entity",
17 24 from = "Column::RoleId",
@@ -19,6 +26,7 @@ pub enum Relation {
19 26 on_delete = "Cascade"
20 27 )]
21 28 Role,
29 + /// The permission side of the junction.
22 30 #[sea_orm(
23 31 belongs_to = "super::permission::Entity",
24 32 from = "Column::PermissionId",
A crates/rustwarden-core/src/models/team.rs
+45 -0
@@ -0,0 +1,45 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +/// A named group of users. Backs [`TeamService`](crate::services::team_service::TeamService)
7 +/// and is an accepted principal for [`ResourceService`](crate::services::resource_service::ResourceService)
8 +/// sharing.
9 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
10 +#[sea_orm(table_name = "teams")]
11 +pub struct Model {
12 + /// Primary key.
13 + #[sea_orm(primary_key)]
14 + pub id: Uuid,
15 + /// URL-safe handle (`@backend`, `design-ops`, …). Unique across the tenant.
16 + #[sea_orm(unique)]
17 + pub slug: String,
18 + /// Human-readable label.
19 + pub name: String,
20 + /// User who created the team and retains admin rights by default.
21 + pub owner_id: Uuid,
22 + /// Timestamp when the team was created.
23 + pub created_at: DateTime<Utc>,
24 +}
25 +
26 +/// SeaORM relations for the Team entity.
27 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
28 +pub enum Relation {
29 + /// The user who owns (created) this team.
30 + #[sea_orm(
31 + belongs_to = "super::user::Entity",
32 + from = "Column::OwnerId",
33 + to = "super::user::Column::Id",
34 + on_delete = "Cascade"
35 + )]
36 + Owner,
37 +}
38 +
39 +impl Related<super::user::Entity> for Entity {
40 + fn to() -> RelationDef {
41 + Relation::Owner.def()
42 + }
43 +}
44 +
45 +impl ActiveModelBehavior for ActiveModel {}
A crates/rustwarden-core/src/models/team_member.rs
+56 -0
@@ -0,0 +1,56 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +/// Membership edge between a user and a team. Composite primary key
7 +/// `(team_id, user_id)` prevents duplicates at the schema level.
8 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
9 +#[sea_orm(table_name = "team_members")]
10 +pub struct Model {
11 + /// FK → teams.id (part of composite primary key).
12 + #[sea_orm(primary_key, auto_increment = false)]
13 + pub team_id: Uuid,
14 + /// FK → users.id (part of composite primary key).
15 + #[sea_orm(primary_key, auto_increment = false)]
16 + pub user_id: Uuid,
17 + /// `"owner" | "admin" | "member"` (validated at the service layer).
18 + pub role: String,
19 + /// Timestamp when the user joined (or was added to) the team.
20 + pub joined_at: DateTime<Utc>,
21 +}
22 +
23 +/// SeaORM relations for the TeamMember entity.
24 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
25 +pub enum Relation {
26 + /// The team this membership belongs to.
27 + #[sea_orm(
28 + belongs_to = "super::team::Entity",
29 + from = "Column::TeamId",
30 + to = "super::team::Column::Id",
31 + on_delete = "Cascade"
32 + )]
33 + Team,
34 + /// The user who is a member of the team.
35 + #[sea_orm(
36 + belongs_to = "super::user::Entity",
37 + from = "Column::UserId",
38 + to = "super::user::Column::Id",
39 + on_delete = "Cascade"
40 + )]
41 + User,
42 +}
43 +
44 +impl Related<super::team::Entity> for Entity {
45 + fn to() -> RelationDef {
46 + Relation::Team.def()
47 + }
48 +}
49 +
50 +impl Related<super::user::Entity> for Entity {
51 + fn to() -> RelationDef {
52 + Relation::User.def()
53 + }
54 +}
55 +
56 +impl ActiveModelBehavior for ActiveModel {}
A crates/rustwarden-core/src/models/totp_challenge.rs
+77 -0
@@ -0,0 +1,77 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +/// TotpChallenge entity model
7 +///
8 +/// Ephemeral challenge for the 2FA login flow.
9 +/// Created after successful password verification when user has TOTP enabled.
10 +/// Expires after 5 minutes. Max 5 attempts per challenge.
11 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
12 +#[sea_orm(table_name = "totp_challenges")]
13 +pub struct Model {
14 + /// Unique identifier
15 + #[sea_orm(primary_key)]
16 + pub id: Uuid,
17 + /// User ID
18 + pub user_id: Uuid,
19 + /// Random challenge token (sent to client, used to identify the challenge)
20 + #[sea_orm(unique)]
21 + pub challenge_token: String,
22 + /// Number of verification attempts
23 + pub attempts: i32,
24 + /// Maximum allowed attempts
25 + pub max_attempts: i32,
26 + /// Challenge expiration time
27 + pub expires_at: DateTime<Utc>,
28 + /// Creation timestamp
29 + pub created_at: DateTime<Utc>,
30 +}
31 +
32 +/// SeaORM relations for the TotpChallenge entity.
33 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
34 +pub enum Relation {
35 + /// The user this challenge was issued for.
36 + #[sea_orm(
37 + belongs_to = "super::user::Entity",
38 + from = "Column::UserId",
39 + to = "super::user::Column::Id"
40 + )]
41 + User,
42 +}
43 +
44 +impl Related<super::user::Entity> for Entity {
45 + fn to() -> RelationDef {
46 + Relation::User.def()
47 + }
48 +}
49 +
50 +impl ActiveModelBehavior for ActiveModel {}
51 +
52 +#[cfg(test)]
53 +mod tests {
54 + use super::*;
55 +
56 + #[test]
57 + fn test_totp_challenge_model() {
58 + let id = Uuid::new_v4();
59 + let user_id = Uuid::new_v4();
60 + let now = Utc::now();
61 +
62 + let challenge = Model {
63 + id,
64 + user_id,
65 + challenge_token: "test-token".to_string(),
66 + attempts: 0,
67 + max_attempts: 5,
68 + expires_at: now + chrono::Duration::minutes(5),
69 + created_at: now,
70 + };
71 +
72 + assert_eq!(challenge.id, id);
73 + assert_eq!(challenge.attempts, 0);
74 + assert_eq!(challenge.max_attempts, 5);
75 + assert!(challenge.expires_at > now);
76 + }
77 +}
M crates/rustwarden-core/src/models/user.rs
+1 -0
@@ -39,6 +39,7 @@ pub struct Model {
39 39 pub updated_at: DateTime<Utc>,
40 40 }
41 41
42 +/// SeaORM relations for the User entity (none defined here; relations are declared on child entities).
42 43 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
43 44 pub enum Relation {}
44 45
M crates/rustwarden-core/src/models/user_preferences.rs
+2 -0
@@ -26,8 +26,10 @@ pub struct Model {
26 26 pub updated_at: DateTime<Utc>,
27 27 }
28 28
29 +/// SeaORM relations for the UserPreferences entity.
29 30 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
30 31 pub enum Relation {
32 + /// The user these preferences belong to.
31 33 #[sea_orm(
32 34 belongs_to = "super::user::Entity",
33 35 from = "Column::UserId",
M crates/rustwarden-core/src/models/user_role.rs
+3 -0
@@ -19,8 +19,10 @@ pub struct Model {
19 19 pub created_at: DateTime<Utc>,
20 20 }
21 21
22 +/// SeaORM relations for the UserRole junction entity.
22 23 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
23 24 pub enum Relation {
25 + /// The user side of the user-role junction.
24 26 #[sea_orm(
25 27 belongs_to = "super::user::Entity",
26 28 from = "Column::UserId",
@@ -28,6 +30,7 @@ pub enum Relation {
28 30 on_delete = "Cascade"
29 31 )]
30 32 User,
33 + /// The role side of the user-role junction.
31 34 #[sea_orm(
32 35 belongs_to = "super::role::Entity",
33 36 from = "Column::RoleId",
A crates/rustwarden-core/src/models/user_totp.rs
+83 -0
@@ -0,0 +1,83 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +/// UserTotp entity model
7 +///
8 +/// Stores TOTP two-factor authentication data for a user.
9 +/// One-to-one relationship with User (enforced by unique constraint on user_id).
10 +/// The secret and backup codes are encrypted at rest via CryptoService (AES-256-GCM).
11 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
12 +#[sea_orm(table_name = "user_totp")]
13 +pub struct Model {
14 + /// Unique identifier
15 + #[sea_orm(primary_key)]
16 + pub id: Uuid,
17 + /// User ID (unique, one-to-one with users)
18 + #[sea_orm(unique)]
19 + pub user_id: Uuid,
20 + /// TOTP secret encrypted with AES-256-GCM (enc: prefixed)
21 + pub secret_encrypted: String,
22 + /// Whether 2FA is fully enabled (true after first code verification)
23 + pub enabled: bool,
24 + /// Backup codes: JSON array of bcrypt-hashed codes, AES-encrypted
25 + pub backup_codes_encrypted: Option<String>,
26 + /// Number of remaining unused backup codes
27 + pub backup_codes_remaining: i32,
28 + /// Timestamp when 2FA was enabled
29 + pub enabled_at: Option<DateTime<Utc>>,
30 + /// Creation timestamp
31 + pub created_at: DateTime<Utc>,
32 + /// Last update timestamp
33 + pub updated_at: DateTime<Utc>,
34 +}
35 +
36 +/// SeaORM relations for the UserTotp entity.
37 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
38 +pub enum Relation {
39 + /// The user these TOTP credentials belong to.
40 + #[sea_orm(
41 + belongs_to = "super::user::Entity",
42 + from = "Column::UserId",
43 + to = "super::user::Column::Id"
44 + )]
45 + User,
46 +}
47 +
48 +impl Related<super::user::Entity> for Entity {
49 + fn to() -> RelationDef {
50 + Relation::User.def()
51 + }
52 +}
53 +
54 +impl ActiveModelBehavior for ActiveModel {}
55 +
56 +#[cfg(test)]
57 +mod tests {
58 + use super::*;
59 +
60 + #[test]
61 + fn test_user_totp_model() {
62 + let id = Uuid::new_v4();
63 + let user_id = Uuid::new_v4();
64 + let now = Utc::now();
65 +
66 + let totp = Model {
67 + id,
68 + user_id,
69 + secret_encrypted: "enc:test".to_string(),
70 + enabled: false,
71 + backup_codes_encrypted: None,
72 + backup_codes_remaining: 0,
73 + enabled_at: None,
74 + created_at: now,
75 + updated_at: now,
76 + };
77 +
78 + assert_eq!(totp.id, id);
79 + assert_eq!(totp.user_id, user_id);
80 + assert!(!totp.enabled);
81 + assert_eq!(totp.backup_codes_remaining, 0);
82 + }
83 +}
A crates/rustwarden-core/src/notifications/mod.rs
+242 -0
@@ -0,0 +1,242 @@
1 +//! Generic pub/sub bus for real-time notifications.
2 +//!
3 +//! Central plumbing for features that need to push events to subscribed
4 +//! consumers — Server-Sent Events streams, live progress bars, CI logs,
5 +//! wall-activity feeds, etc. Each consumer app picks an event type `T` and
6 +//! gets `publish` + filtered `subscribe` on top of a single in-memory
7 +//! [`tokio::sync::broadcast`] channel.
8 +//!
9 +//! # Multi-instance deployments
10 +//!
11 +//! The built-in [`InMemoryBus`] is single-process only. For multi-instance
12 +//! (horizontal scaling) the trait is stable enough to add a `redis-bus`
13 +//! feature later with the same signatures — out of scope for v1.
14 +//!
15 +//! # Example
16 +//!
17 +//! ```no_run
18 +//! use std::sync::Arc;
19 +//! use rustwarden_core::notifications::{InMemoryBus, NotificationBus};
20 +//!
21 +//! #[derive(Clone, serde::Serialize)]
22 +//! enum AppEvent {
23 +//! ImportProgress { job_id: uuid::Uuid, pct: u8 },
24 +//! Ping,
25 +//! }
26 +//!
27 +//! # async fn usage() {
28 +//! let bus: Arc<dyn NotificationBus<AppEvent>> = Arc::new(InMemoryBus::<AppEvent>::new(128));
29 +//!
30 +//! // Consumer: only "ImportProgress" events.
31 +//! let filter = Arc::new(|e: &AppEvent| matches!(e, AppEvent::ImportProgress { .. }));
32 +//! let _stream = bus.subscribe(filter);
33 +//!
34 +//! bus.publish(AppEvent::Ping).await;
35 +//! # }
36 +//! ```
37 +
38 +pub mod sse;
39 +
40 +use async_trait::async_trait;
41 +use futures::stream::{Stream, StreamExt};
42 +use std::pin::Pin;
43 +use std::sync::Arc;
44 +use tokio::sync::broadcast;
45 +use tokio_stream::wrappers::BroadcastStream;
46 +
47 +/// Filter predicate applied to each published event before delivery to a
48 +/// subscriber. `Arc<dyn Fn>` so subscribers can share the filter without
49 +/// boxing it again and so it is trivially clonable.
50 +pub type EventFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;
51 +
52 +/// Abstract publish/subscribe bus for cloneable event types.
53 +///
54 +/// Consumers hold an `Arc<dyn NotificationBus<T>>` so the concrete backend
55 +/// (in-memory today, Redis tomorrow) is swappable without touching call sites.
56 +#[async_trait]
57 +pub trait NotificationBus<T>: Send + Sync
58 +where
59 + T: Clone + Send + Sync + 'static,
60 +{
61 + /// Broadcast an event to every live subscriber. Drops the event silently
62 + /// when no subscriber is registered — the bus does not persist.
63 + async fn publish(&self, event: T);
64 +
65 + /// Subscribe with a filter predicate. Only events for which `filter`
66 + /// returns `true` reach the returned stream.
67 + ///
68 + /// A subscriber that falls behind the channel capacity drops the oldest
69 + /// missed events (standard `tokio::sync::broadcast` semantics); the
70 + /// stream keeps running.
71 + fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>>;
72 +}
73 +
74 +/// In-memory broadcast-backed implementation. Cheap, lock-free on the hot
75 +/// path, suitable for single-process deployments.
76 +pub struct InMemoryBus<T: Clone + Send + Sync + 'static> {
77 + tx: broadcast::Sender<T>,
78 +}
79 +
80 +impl<T: Clone + Send + Sync + 'static> InMemoryBus<T> {
81 + /// Build a bus with the given channel capacity.
82 + ///
83 + /// When more than `capacity` events pile up for a slow subscriber, the
84 + /// oldest are dropped — the stream yields nothing for them but does not
85 + /// terminate. Pick capacity proportional to peak burst rate × worst-case
86 + /// subscriber latency.
87 + pub fn new(capacity: usize) -> Self {
88 + let (tx, _) = broadcast::channel(capacity);
89 + Self { tx }
90 + }
91 +
92 + /// Current number of live subscribers. Useful for metrics / tests.
93 + pub fn receiver_count(&self) -> usize {
94 + self.tx.receiver_count()
95 + }
96 +}
97 +
98 +impl<T: Clone + Send + Sync + 'static> Default for InMemoryBus<T> {
99 + fn default() -> Self {
100 + Self::new(256)
101 + }
102 +}
103 +
104 +#[async_trait]
105 +impl<T> NotificationBus<T> for InMemoryBus<T>
106 +where
107 + T: Clone + Send + Sync + 'static,
108 +{
109 + async fn publish(&self, event: T) {
110 + // `send` returns Err only when there are zero receivers — that's fine
111 + // for a transient bus (no persistence guarantee).
112 + let _ = self.tx.send(event);
113 + }
114 +
115 + fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>> {
116 + let rx = self.tx.subscribe();
117 + let stream = BroadcastStream::new(rx)
118 + // Drop Lagged events (slow consumer) instead of ending the stream.
119 + .filter_map(|res| async move { res.ok() })
120 + .filter(move |event| {
121 + let keep = filter(event);
122 + async move { keep }
123 + });
124 + Box::pin(stream)
125 + }
126 +}
127 +
128 +#[cfg(test)]
129 +mod tests {
130 + use super::*;
131 + use std::time::Duration;
132 + use tokio::time::timeout;
133 +
134 + #[derive(Clone, Debug, PartialEq, Eq)]
135 + enum TestEvent {
136 + Ping,
137 + Import { job: u32, pct: u8 },
138 + }
139 +
140 + fn pass_all<T>() -> EventFilter<T> {
141 + Arc::new(|_| true)
142 + }
143 +
144 + #[tokio::test]
145 + async fn publish_with_no_subscribers_is_noop() {
146 + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
147 + bus.publish(TestEvent::Ping).await;
148 + assert_eq!(bus.receiver_count(), 0);
149 + }
150 +
151 + #[tokio::test]
152 + async fn subscriber_receives_published_events() {
153 + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
154 + let mut stream = bus.subscribe(pass_all());
155 +
156 + bus.publish(TestEvent::Ping).await;
157 + bus.publish(TestEvent::Import { job: 1, pct: 50 }).await;
158 +
159 + let first = timeout(Duration::from_millis(200), stream.next())
160 + .await
161 + .unwrap()
162 + .unwrap();
163 + let second = timeout(Duration::from_millis(200), stream.next())
164 + .await
165 + .unwrap()
166 + .unwrap();
167 +
168 + assert_eq!(first, TestEvent::Ping);
169 + assert_eq!(second, TestEvent::Import { job: 1, pct: 50 });
170 + }
171 +
172 + #[tokio::test]
173 + async fn multiple_subscribers_each_receive_the_event() {
174 + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
175 + let mut a = bus.subscribe(pass_all());
176 + let mut b = bus.subscribe(pass_all());
177 +
178 + bus.publish(TestEvent::Ping).await;
179 +
180 + let a_got = timeout(Duration::from_millis(200), a.next())
181 + .await
182 + .unwrap()
183 + .unwrap();
184 + let b_got = timeout(Duration::from_millis(200), b.next())
185 + .await
186 + .unwrap()
187 + .unwrap();
188 + assert_eq!(a_got, TestEvent::Ping);
189 + assert_eq!(b_got, TestEvent::Ping);
190 + }
191 +
192 + #[tokio::test]
193 + async fn filter_drops_non_matching_events() {
194 + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
195 + let only_imports: EventFilter<TestEvent> =
196 + Arc::new(|e| matches!(e, TestEvent::Import { .. }));
197 + let mut stream = bus.subscribe(only_imports);
198 +
199 + bus.publish(TestEvent::Ping).await;
200 + bus.publish(TestEvent::Import { job: 7, pct: 10 }).await;
201 +
202 + let got = timeout(Duration::from_millis(200), stream.next())
203 + .await
204 + .unwrap()
205 + .unwrap();
206 + assert_eq!(got, TestEvent::Import { job: 7, pct: 10 });
207 + }
208 +
209 + #[tokio::test]
210 + async fn subscriber_drop_does_not_block_publisher() {
211 + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
212 + {
213 + let _stream = bus.subscribe(pass_all());
214 + // _stream drops at end of scope.
215 + }
216 + // Publisher must not hang or panic after the receiver is gone.
217 + bus.publish(TestEvent::Ping).await;
218 + assert_eq!(bus.receiver_count(), 0);
219 + }
220 +
221 + #[tokio::test]
222 + async fn slow_subscriber_loses_old_events_but_stream_keeps_going() {
223 + // Capacity 2 → third event evicts the first for a stalled consumer.
224 + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(2);
225 + let mut stream = bus.subscribe(pass_all());
226 +
227 + for pct in [10, 20, 30, 40] {
228 + bus.publish(TestEvent::Import { job: 1, pct }).await;
229 + }
230 +
231 + // We should still eventually get a tail slice of events (exact number
232 + // depends on runtime scheduling, but it must be ≥ 1).
233 + let first = timeout(Duration::from_millis(200), stream.next())
234 + .await
235 + .unwrap()
236 + .unwrap();
237 + match first {
238 + TestEvent::Import { .. } => {}
239 + other => panic!("expected Import event, got {other:?}"),
240 + }
241 + }
242 +}
A crates/rustwarden-core/src/notifications/sse.rs
+114 -0
@@ -0,0 +1,114 @@
1 +//! Helper to turn a [`NotificationBus`](super::NotificationBus) subscription
2 +//! into an HTTP Server-Sent Events response.
3 +//!
4 +//! Wraps the usual boilerplate (JSON-serialising each event, attaching a
5 +//! keep-alive, converting errors into client-friendly ends of stream) in a
6 +//! single call so handlers stay trivial:
7 +//!
8 +//! ```no_run
9 +//! use axum::response::sse::Sse;
10 +//! use axum::response::IntoResponse;
11 +//! use std::sync::Arc;
12 +//! use rustwarden_core::notifications::{InMemoryBus, NotificationBus, sse::sse_response};
13 +//!
14 +//! #[derive(Clone, serde::Serialize)]
15 +//! struct MyEvent { msg: String }
16 +//!
17 +//! async fn stream_handler(
18 +//! bus: axum::extract::Extension<Arc<dyn NotificationBus<MyEvent>>>,
19 +//! ) -> impl IntoResponse {
20 +//! let filter = Arc::new(|_: &MyEvent| true);
21 +//! sse_response(bus.subscribe(filter))
22 +//! }
23 +//! ```
24 +
25 +use axum::response::sse::{Event, KeepAlive, Sse};
26 +use futures::stream::{Stream, StreamExt};
27 +use serde::Serialize;
28 +use std::convert::Infallible;
29 +use std::time::Duration;
30 +
31 +/// Default keep-alive interval (30 s) — matches most reverse-proxy idle timeouts.
32 +pub const DEFAULT_SSE_KEEPALIVE: Duration = Duration::from_secs(30);
33 +
34 +/// Wrap a stream of serializable events into an axum [`Sse`] response with
35 +/// JSON payloads and a 30-second keep-alive.
36 +///
37 +/// Serialization errors for individual events are logged and the event is
38 +/// dropped from the output stream; the connection stays open for subsequent
39 +/// events.
40 +pub fn sse_response<T, S>(stream: S) -> Sse<impl Stream<Item = Result<Event, Infallible>>>
41 +where
42 + T: Serialize + Send + 'static,
43 + S: Stream<Item = T> + Send + 'static,
44 +{
45 + let events = stream.filter_map(|event| async move {
46 + match Event::default().json_data(&event) {
47 + Ok(e) => Some(Ok::<_, Infallible>(e)),
48 + Err(err) => {
49 + tracing::warn!("Dropping SSE event that failed to serialize: {}", err);
50 + None
51 + }
52 + }
53 + });
54 + Sse::new(events).keep_alive(KeepAlive::new().interval(DEFAULT_SSE_KEEPALIVE))
55 +}
56 +
57 +#[cfg(test)]
58 +mod tests {
59 + use super::*;
60 + use crate::notifications::{EventFilter, InMemoryBus, NotificationBus};
61 + use axum::body::to_bytes;
62 + use axum::response::IntoResponse;
63 + use serde::{Deserialize, Serialize};
64 + use std::sync::Arc;
65 +
66 + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
67 + struct Event {
68 + tag: String,
69 + count: u32,
70 + }
71 +
72 + fn pass_all<T>() -> EventFilter<T> {
73 + Arc::new(|_| true)
74 + }
75 +
76 + #[tokio::test]
77 + async fn sse_response_emits_serialized_events_then_keepalive_stops_stream() {
78 + let bus: InMemoryBus<Event> = InMemoryBus::new(4);
79 + let stream = bus.subscribe(pass_all());
80 +
81 + bus.publish(Event {
82 + tag: "a".into(),
83 + count: 1,
84 + })
85 + .await;
86 + bus.publish(Event {
87 + tag: "b".into(),
88 + count: 2,
89 + })
90 + .await;
91 +
92 + // Close the bus so the subscription stream terminates naturally.
93 + drop(bus);
94 +
95 + let response = sse_response(stream).into_response();
96 + let status = response.status();
97 + assert!(status.is_success(), "expected 2xx, got {status}");
98 +
99 + // Read the whole body — bounded because the stream terminates once
100 + // the bus is dropped.
101 + let body = response.into_body();
102 + let bytes = to_bytes(body, 64 * 1024).await.unwrap();
103 + let text = std::str::from_utf8(&bytes).unwrap();
104 +
105 + assert!(
106 + text.contains(r#""tag":"a""#),
107 + "body must contain first event, got: {text}"
108 + );
109 + assert!(
110 + text.contains(r#""tag":"b""#),
111 + "body must contain second event, got: {text}"
112 + );
113 + }
114 +}
M crates/rustwarden-core/src/services/app_settings_service.rs
+0 -11
@@ -401,14 +401,3 @@ impl AppSettingsService {
401 401 Ok(())
402 402 }
403 403 }
404 -
405 -#[cfg(test)]
406 -mod tests {
407 - use super::*;
408 -
409 - #[tokio::test]
410 - async fn test_get_setting_not_found() {
411 - // This test would require a test database setup
412 - // For now, it's a placeholder
413 - }
414 -}
M crates/rustwarden-core/src/services/auth_logging.rs
+104 -6
@@ -1,23 +1,32 @@
1 1 use crate::error::AppError;
2 +use crate::hooks::{AuditEvent, RustwardenHooks};
2 3 use crate::models::audit_log;
3 4 use chrono::Utc;
4 5 use sea_orm::{ActiveModelTrait, DatabaseConnection, Set};
6 +use std::sync::Arc;
5 7 use uuid::Uuid;
6 8
7 -/// Service de journalisation des tentatives bloquées (auth)
9 +/// Logging service for blocked authentication attempts.
8 10 ///
9 -/// Objectifs :
10 -/// - Tracer les blocages (raison, email, horodatage)
11 -/// - Ne jamais stocker de secrets ou de mots de passe
12 -/// - Ne pas faire échouer le flux principal si l’écriture échoue
11 +/// Goals:
12 +/// - Record blocked attempts (reason, email, timestamp)
13 +/// - Never store secrets or passwords
14 +/// - Never fail the main authentication flow if logging fails
15 +/// - Notify consumers via `RustwardenHooks::on_audit_event`
16 +/// when they have plugged in a sink (SIEM, Langfuse, etc.)
13 17 pub struct AuthLoggingService;
14 18
15 19 impl AuthLoggingService {
16 - /// Journalise une tentative de connexion bloquée (email non vérifié, etc.)
20 + /// Log a blocked login attempt (unverified email, etc.).
21 + ///
22 + /// When `hooks` is provided, also emits an `AuditEvent { kind: "auth.login_blocked" }`
23 + /// so that consumer applications can aggregate all audit data in a
24 + /// single pipeline without re-reading the `audit_logs` table.
17 25 pub async fn log_blocked_login(
18 26 db: &DatabaseConnection,
19 27 email: &str,
20 28 reason: &str,
29 + hooks: Option<&Arc<dyn RustwardenHooks>>,
21 30 ) -> Result<(), AppError> {
22 31 tracing::warn!("[LOGIN_BLOCKED] email={} reason={}", email, reason);
23 32
@@ -36,6 +45,95 @@ impl AuthLoggingService {
36 45 tracing::warn!("Impossible d’écrire le log d’auth: {}", e);
37 46 }
38 47
48 + // Fan-out au hook generique si le consommateur en a branché un.
49 + // Un échec du hook ne doit pas casser le flux d'auth (best-effort).
50 + if let Some(hooks) = hooks {
51 + let metadata = serde_json::json!({ "email": email, "reason": reason });
52 + let event = AuditEvent {
53 + kind: "auth.login_blocked",
54 + actor_id: None,
55 + target: None,
56 + metadata: &metadata,
57 + };
58 + if let Err(e) = hooks.on_audit_event(db, event).await {
59 + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
60 + }
61 + }
62 +
39 63 Ok(())
40 64 }
41 65 }
66 +
67 +#[cfg(test)]
68 +mod tests {
69 + use super::*;
70 + use crate::hooks::RustwardenHooks;
71 + use async_trait::async_trait;
72 + use std::sync::Mutex;
73 +
74 + /// Hooks impl that forwards every recorded event to an externally-owned
75 + /// `Arc<Mutex<Vec<_>>>`. Keeping the sink outside the trait object avoids
76 + /// any downcast when the `Arc<dyn RustwardenHooks>` is passed downstream.
77 + struct CapturingHooks {
78 + sink: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
79 + }
80 +
81 + #[async_trait]
82 + impl RustwardenHooks for CapturingHooks {
83 + async fn on_audit_event(
84 + &self,
85 + _db: &DatabaseConnection,
86 + event: AuditEvent<'_>,
87 + ) -> anyhow::Result<()> {
88 + self.sink
89 + .lock()
90 + .unwrap()
91 + .push((event.kind.to_string(), event.metadata.clone()));
92 + Ok(())
93 + }
94 + }
95 +
96 + /// Per-test connection — see personal_token_service::tests for rationale.
97 + async fn test_db() -> DatabaseConnection {
98 + dotenvy::dotenv().ok();
99 + let url =
100 + std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_logging tests");
101 + sea_orm::Database::connect(&url).await.unwrap()
102 + }
103 +
104 + #[tokio::test]
105 + async fn log_blocked_login_dispatches_audit_event_when_hooks_provided() {
106 + let db = test_db().await;
107 + let sink: Arc<Mutex<Vec<(String, serde_json::Value)>>> = Arc::new(Mutex::new(Vec::new()));
108 + let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks { sink: sink.clone() });
109 +
110 + AuthLoggingService::log_blocked_login(
111 + &db,
112 + "test-audit@example.com",
113 + "email_not_verified",
114 + Some(&hooks),
115 + )
116 + .await
117 + .unwrap();
118 +
119 + let events = sink.lock().unwrap().clone();
120 + assert_eq!(events.len(), 1);
121 + assert_eq!(events[0].0, "auth.login_blocked");
122 + assert_eq!(events[0].1["email"], "test-audit@example.com");
123 + assert_eq!(events[0].1["reason"], "email_not_verified");
124 + }
125 +
126 + #[tokio::test]
127 + async fn log_blocked_login_works_without_hooks() {
128 + let db = test_db().await;
129 + // None → no hook dispatch, no panic.
130 + AuthLoggingService::log_blocked_login(
131 + &db,
132 + "no-hook@example.com",
133 + "email_not_verified",
134 + None,
135 + )
136 + .await
137 + .unwrap();
138 + }
139 +}
M crates/rustwarden-core/src/services/auth_service.rs
+34 -15
@@ -39,47 +39,66 @@ pub struct AuthService;
39 39 impl AuthService {
40 40 /// Verify user credentials and return the user if valid
41 41 ///
42 + /// The `identifier` parameter accepts either a username or an email address.
43 + /// If it contains `@`, lookup is done by email; otherwise by username.
44 + ///
42 45 /// # Security
43 46 ///
44 47 /// - SEC-C1: Always runs bcrypt verify (even for non-existent users) to prevent timing attacks
45 - /// - SEC-L5: Usernames are partially masked in log output
46 - /// - Returns generic error message (never reveals if username or password is wrong)
48 + /// - SEC-L5: Identifiers are partially masked in log output
49 + /// - Returns generic error message (never reveals if identifier or password is wrong)
47 50 pub async fn verify_credentials(
48 51 db: &DatabaseConnection,
49 - username: &str,
52 + identifier: &str,
50 53 password: &str,
51 54 ) -> Result<user::Model, AppError> {
52 - let user = user::Entity::find()
53 - .filter(user::Column::Username.eq(username))
54 - .one(db)
55 - .await?;
55 + let user = if identifier.contains('@') {
56 + user::Entity::find()
57 + .filter(user::Column::Email.eq(identifier))
58 + .one(db)
59 + .await?
60 + } else {
61 + user::Entity::find()
62 + .filter(user::Column::Username.eq(identifier))
63 + .one(db)
64 + .await?
65 + };
56 66
57 67 let user = match user {
58 68 Some(u) => u,
59 69 None => {
60 70 // SEC-C1: always run bcrypt to prevent timing-based user enumeration
61 71 let _ = PasswordService::verify_password("invalid", &DUMMY_HASH).await;
62 - tracing::warn!("Login attempt for unknown user: {}", mask_username(username));
72 + tracing::warn!(
73 + "Login attempt for unknown identifier: {}",
74 + mask_username(identifier)
75 + );
63 76 return Err(AppError::InvalidCredentials);
64 77 }
65 78 };
66 79
67 - let password_valid = PasswordService::verify_password(password, &user.password_hash).await?;
80 + let password_valid =
81 + PasswordService::verify_password(password, &user.password_hash).await?;
68 82
69 83 if !password_valid {
70 84 tracing::warn!(
71 - "Login attempt with invalid password for user: {}",
72 - mask_username(username)
85 + "Login attempt with invalid password for identifier: {}",
86 + mask_username(identifier)
73 87 );
74 88 return Err(AppError::InvalidCredentials);
75 89 }
76 90
77 - tracing::info!("User {} authenticated successfully", mask_username(username));
91 + tracing::info!(
92 + "User {} authenticated successfully",
93 + mask_username(identifier)
94 + );
78 95 Ok(user)
79 96 }
80 97
81 98 /// Authenticate user and return the model with must_change_password flag
82 99 ///
100 + /// The `identifier` parameter accepts either a username or an email address.
101 + ///
83 102 /// This is a higher-level authentication method that:
84 103 /// 1. Verifies credentials
85 104 /// 2. Logs if user must change password (but does NOT block login)
@@ -89,13 +108,13 @@ impl AuthService {
89 108 /// redirect the user to a password-change page.
90 109 pub async fn authenticate_user(
91 110 db: &DatabaseConnection,
92 - username: &str,
111 + identifier: &str,
93 112 password: &str,
94 113 ) -> Result<user::Model, AppError> {
95 - let user = Self::verify_credentials(db, username, password).await?;
114 + let user = Self::verify_credentials(db, identifier, password).await?;
96 115
97 116 if user.must_change_password {
98 - tracing::info!("User {} should change password (flag set)", username);
117 + tracing::info!("User {} should change password (flag set)", user.username);
99 118 }
100 119
101 120 Ok(user)
M crates/rustwarden-core/src/services/crypto_service.rs
+17 -18
@@ -1,33 +1,32 @@
1 1 use crate::error::AppError;
2 2 use aes_gcm::{
3 3 aead::{Aead, KeyInit, OsRng},
4 - Aes256Gcm, AeadCore, Nonce,
4 + AeadCore, Aes256Gcm, Nonce,
5 5 };
6 6 use base64::{engine::general_purpose::STANDARD as B64, Engine};
7 7 use std::env;
8 8
9 9 const ENCRYPTED_PREFIX: &str = "enc:";
10 10
11 +/// Service providing AES-256-GCM encryption and decryption for sensitive settings.
11 12 pub struct CryptoService;
12 13
13 14 impl CryptoService {
14 15 fn get_key() -> Result<[u8; 32], AppError> {
15 - let key_hex = env::var("OAUTH_ENCRYPTION_KEY").map_err(|_| {
16 + let key_hex = env::var("ENCRYPTION_KEY")
17 + .or_else(|_| env::var("OAUTH_ENCRYPTION_KEY"))
18 + .map_err(|_| {
16 19 AppError::Internal(
17 - "OAUTH_ENCRYPTION_KEY is not set. A 64-character hex key is required to store OAuth secrets.".to_string(),
20 + "ENCRYPTION_KEY (or OAUTH_ENCRYPTION_KEY) is not set. A 64-character hex key is required for encryption.".to_string(),
18 21 )
19 22 })?;
20 23
21 - let bytes = hex::decode(key_hex.trim()).map_err(|e| {
22 - AppError::Internal(format!(
23 - "OAUTH_ENCRYPTION_KEY is not valid hex: {}",
24 - e
25 - ))
26 - })?;
24 + let bytes = hex::decode(key_hex.trim())
25 + .map_err(|e| AppError::Internal(format!("ENCRYPTION_KEY is not valid hex: {}", e)))?;
27 26
28 27 if bytes.len() != 32 {
29 28 return Err(AppError::Internal(format!(
30 - "OAUTH_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got {} bytes",
29 + "ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got {} bytes",
31 30 bytes.len()
32 31 )));
33 32 }
@@ -66,9 +65,9 @@ impl CryptoService {
66 65 let cipher = Aes256Gcm::new_from_slice(&key_bytes)
67 66 .map_err(|e| AppError::Internal(format!("Failed to create cipher: {}", e)))?;
68 67
69 - let combined = B64.decode(encoded).map_err(|e| {
70 - AppError::Internal(format!("Failed to decode encrypted value: {}", e))
71 - })?;
68 + let combined = B64
69 + .decode(encoded)
70 + .map_err(|e| AppError::Internal(format!("Failed to decode encrypted value: {}", e)))?;
72 71
73 72 if combined.len() < 12 {
74 73 return Err(AppError::Internal("Encrypted value too short".to_string()));
@@ -77,17 +76,17 @@ impl CryptoService {
77 76 let (nonce_bytes, ciphertext) = combined.split_at(12);
78 77 let nonce = Nonce::from_slice(nonce_bytes);
79 78
80 - let plaintext = cipher
81 - .decrypt(nonce, ciphertext)
82 - .map_err(|_| AppError::Internal("Decryption failed – wrong key or corrupted data".to_string()))?;
79 + let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
80 + AppError::Internal("Decryption failed – wrong key or corrupted data".to_string())
81 + })?;
83 82
84 83 String::from_utf8(plaintext)
85 84 .map_err(|e| AppError::Internal(format!("Decrypted value is not valid UTF-8: {}", e)))
86 85 }
87 86
88 - /// Returns true if `OAUTH_ENCRYPTION_KEY` is configured.
87 + /// Returns true if an encryption key is configured.
89 88 pub fn is_available() -> bool {
90 - env::var("OAUTH_ENCRYPTION_KEY").is_ok()
89 + env::var("ENCRYPTION_KEY").is_ok() || env::var("OAUTH_ENCRYPTION_KEY").is_ok()
91 90 }
92 91
93 92 /// Returns true if the value is already encrypted (has the `enc:` prefix).
M crates/rustwarden-core/src/services/email_validation_service.rs
+17 -52
@@ -10,24 +10,25 @@ use crate::config::email::EmailConfig;
10 10 use crate::error::AppError;
11 11 use crate::models::{email_validation_token, user};
12 12 use crate::services::email_queue_service::EmailQueueService;
13 +use crate::services::rate_limit_service::RateLimitService;
13 14 use crate::services::user_service::UserService;
14 15 use chrono::{Duration, Utc};
15 16 use sea_orm::{
16 17 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
17 18 };
18 19 use sha2::{Digest, Sha256};
19 -use std::collections::HashMap;
20 -use std::sync::Arc;
21 -use std::time::{SystemTime, UNIX_EPOCH};
22 -use tokio::sync::Mutex;
23 20 use uuid::Uuid;
24 21
22 +/// Default: 3 verification email requests per address per hour.
23 +const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3;
24 +const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: std::time::Duration =
25 + std::time::Duration::from_secs(3600);
26 +
25 27 /// Email validation service
26 28 pub struct EmailValidationService {
27 29 email_config: EmailConfig,
28 - /// Rate limiting: track requests per email (email -> timestamp of last request)
29 - /// Cleaned up periodically to prevent memory leak
30 - rate_limit_store: Arc<Mutex<HashMap<String, Vec<SystemTime>>>>,
30 + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
31 + rate_limiter: RateLimitService,
31 32 }
32 33
33 34 impl EmailValidationService {
@@ -35,7 +36,7 @@ impl EmailValidationService {
35 36 pub fn new(email_config: EmailConfig) -> Self {
36 37 Self {
37 38 email_config,
38 - rate_limit_store: Arc::new(Mutex::new(HashMap::new())),
39 + rate_limiter: RateLimitService::new(),
39 40 }
40 41 }
41 42
@@ -75,7 +76,8 @@ impl EmailValidationService {
75 76 // Hash token with SHA-256
76 77 let token_hash = Self::hash_token(&token);
77 78
78 - let expires_at = Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours());
79 + let expires_at =
80 + Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours());
79 81
80 82 let validation_token = email_validation_token::ActiveModel {
81 83 id: Set(Uuid::new_v4()),
@@ -295,8 +297,12 @@ impl EmailValidationService {
295 297 db: &DatabaseConnection,
296 298 email: &str,
297 299 ) -> Result<bool, AppError> {
298 - // Check rate limiting (3 requests per hour per email)
299 - self.check_rate_limit(email).await?;
300 + // Check rate limiting (3 requests per hour per email). Atomic check+record.
301 + self.rate_limiter.check_sliding_window(
302 + &format!("email_verify:{email}"),
303 + EMAIL_VALIDATION_RATE_LIMIT_MAX,
304 + EMAIL_VALIDATION_RATE_LIMIT_WINDOW,
305 + )?;
300 306
301 307 // Find user by email
302 308 let user = match UserService::get_user_by_email(db, email).await? {
@@ -315,9 +321,6 @@ impl EmailValidationService {
315 321 // Send validation email
316 322 self.send_validation_email(db, user.id).await?;
317 323
318 - // Update rate limit
319 - self.update_rate_limit(email).await;
320 -
321 324 Ok(true)
322 325 }
323 326
@@ -350,42 +353,4 @@ impl EmailValidationService {
350 353
351 354 Ok(())
352 355 }
353 -
354 - /// Check rate limiting (3 requests per hour per email)
355 - async fn check_rate_limit(&self, email: &str) -> Result<(), AppError> {
356 - let mut store = self.rate_limit_store.lock().await;
357 -
358 - // Clean up old entries (older than 1 hour)
359 - let one_hour_ago = SystemTime::now()
360 - .duration_since(UNIX_EPOCH)
361 - .unwrap_or_default()
362 - .as_secs()
363 - .saturating_sub(3600);
364 -
365 - if let Some(requests) = store.get_mut(email) {
366 - requests.retain(|&time| {
367 - time.duration_since(UNIX_EPOCH)
368 - .unwrap_or_default()
369 - .as_secs()
370 - > one_hour_ago
371 - });
372 - }
373 -
374 - // Check current requests
375 - let requests = store.entry(email.to_string()).or_insert_with(Vec::new);
376 - if requests.len() >= 3 {
377 - return Err(AppError::Validation(
378 - "Too many verification email requests. Please try again later.".to_string(),
379 - ));
380 - }
381 -
382 - Ok(())
383 - }
384 -
385 - /// Update rate limit after successful request
386 - async fn update_rate_limit(&self, email: &str) {
387 - let mut store = self.rate_limit_store.lock().await;
388 - let requests = store.entry(email.to_string()).or_insert_with(Vec::new);
389 - requests.push(SystemTime::now());
390 - }
391 356 }
M crates/rustwarden-core/src/services/mod.rs
+32 -3
@@ -2,25 +2,54 @@
2 2 //!
3 3 //! Business logic services for the Rustwarden framework.
4 4
5 +/// Service for reading and updating application-wide settings.
5 6 pub mod app_settings_service;
7 +/// Logging helpers for authentication events.
6 8 pub mod auth_logging;
9 +/// Core authentication service (login, logout, token refresh).
10 +pub mod auth_service;
11 +/// AES-256-GCM encryption and hashing utilities.
7 12 pub mod crypto_service;
13 +/// Ensures a default admin account exists on first boot.
8 14 pub mod default_admin_service;
9 -pub mod auth_service;
15 +/// Background service that drains the email queue.
10 16 pub mod email_queue_service;
17 +/// Email address sanitization helpers.
11 18 pub mod email_sanitization;
19 +/// SMTP email sending service.
12 20 pub mod email_service;
21 +/// Email address validation workflow service.
13 22 pub mod email_validation_service;
23 +/// IMAP bounce processing service.
14 24 pub mod imap_bounce_service;
25 +/// JWT blacklist service for token revocation.
15 26 pub mod jwt_blacklist_service;
27 +/// OAuth provider integration services.
28 +#[cfg(feature = "oauth")]
29 +pub mod oauth;
30 +/// Password change with email confirmation service.
16 31 pub mod password_change_service;
32 +/// Password reset via email token service.
17 33 pub mod password_reset_service;
34 +/// Password hashing and verification utilities.
18 35 pub mod password_service;
36 +/// RBAC permission checking service.
19 37 pub mod permission_service;
38 +/// Personal access token (PAT) management service.
39 +pub mod personal_token_service;
40 +/// Token-bucket rate limiting service.
41 +pub mod rate_limit_service;
42 +/// Refresh token rotation and revocation service.
20 43 pub mod refresh_token_service;
44 +/// Generic resource registration and sharing service.
21 45 pub mod resource_service;
46 +/// Role assignment and lookup service.
22 47 pub mod role_service;
48 +/// Team CRUD and membership management service.
49 +pub mod team_service;
50 +/// TOTP two-factor authentication service.
51 +pub mod totp_service;
52 +/// User preferences (theme, language) service.
23 53 pub mod user_preferences_service;
54 +/// User account management service.
24 55 pub mod user_service;
25 -#[cfg(feature = "oauth")]
26 -pub mod oauth;
M crates/rustwarden-core/src/services/oauth/mod.rs
+3 -0
@@ -1,5 +1,8 @@
1 +/// Trait definition for OAuth/OIDC providers and associated data types.
1 2 pub mod provider_trait;
3 +/// Registry and concrete implementations of OAuth providers.
2 4 pub mod providers;
5 +/// High-level OAuth service: account linking and user provisioning.
3 6 pub mod service;
4 7
5 8 pub use provider_trait::{OAuthProvider, ProviderUserInfo};
M crates/rustwarden-core/src/services/oauth/provider_trait.rs
+10 -1
@@ -1,21 +1,30 @@
1 -use async_trait::async_trait;
2 1 use crate::error::AppError;
2 +use async_trait::async_trait;
3 3
4 4 /// Information about a user obtained from an OAuth/OIDC provider.
5 5 #[derive(Debug, Clone)]
6 6 pub struct ProviderUserInfo {
7 + /// Unique identifier for the user as assigned by the OAuth provider.
7 8 pub provider_user_id: String,
9 + /// Email address reported by the provider (may be absent).
8 10 pub email: Option<String>,
11 + /// Whether the provider has verified the email address.
9 12 pub email_verified: bool,
13 + /// Display name reported by the provider.
10 14 pub display_name: Option<String>,
15 + /// URL of the user's avatar image reported by the provider.
11 16 pub avatar_url: Option<String>,
12 17 }
13 18
14 19 /// Authorization URL and associated security state.
15 20 pub struct AuthorizationUrl {
21 + /// The full authorization URL to redirect the user to.
16 22 pub url: String,
23 + /// CSRF state token to validate on callback.
17 24 pub csrf_state: String,
25 + /// OIDC nonce value stored in the session for replay protection.
18 26 pub nonce: Option<String>,
27 + /// PKCE code verifier stored in the session (S256 challenge sent to provider).
19 28 pub pkce_verifier: Option<String>,
20 29 }
21 30
M crates/rustwarden-core/src/services/oauth/providers/discord.rs
+16 -5
@@ -1,18 +1,23 @@
1 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
2 +use crate::error::AppError;
1 3 use async_trait::async_trait;
2 4 use openidconnect::{CsrfToken, PkceCodeChallenge};
3 5 use reqwest;
4 6 use serde::Deserialize;
5 -use crate::error::AppError;
6 -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
7 7
8 +/// OAuth 2.0 provider implementation for Discord.
8 9 pub struct DiscordProvider {
9 10 client_id: String,
10 11 client_secret: String,
11 12 }
12 13
13 14 impl DiscordProvider {
15 + /// Create a new `DiscordProvider` with the given client credentials.
14 16 pub fn new(client_id: String, client_secret: String) -> Self {
15 - Self { client_id, client_secret }
17 + Self {
18 + client_id,
19 + client_secret,
20 + }
16 21 }
17 22 }
18 23
@@ -96,7 +101,10 @@ impl OAuthProvider for DiscordProvider {
96 101
97 102 let user: DiscordUser = http
98 103 .get("https://discord.com/api/v10/users/@me")
99 - .header("Authorization", format!("Bearer {}", token_resp.access_token))
104 + .header(
105 + "Authorization",
106 + format!("Bearer {}", token_resp.access_token),
107 + )
100 108 .send()
101 109 .await
102 110 .map_err(|e| AppError::Internal(format!("Discord user fetch failed: {}", e)))?
@@ -105,7 +113,10 @@ impl OAuthProvider for DiscordProvider {
105 113 .map_err(|e| AppError::Internal(format!("Discord user parse failed: {}", e)))?;
106 114
107 115 let avatar_url = user.avatar.as_ref().map(|hash| {
108 - format!("https://cdn.discordapp.com/avatars/{}/{}.png", user.id, hash)
116 + format!(
117 + "https://cdn.discordapp.com/avatars/{}/{}.png",
118 + user.id, hash
119 + )
109 120 });
110 121
111 122 Ok(ProviderUserInfo {
M crates/rustwarden-core/src/services/oauth/providers/github.rs
+17 -8
@@ -1,17 +1,22 @@
1 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
2 +use crate::error::AppError;
1 3 use async_trait::async_trait;
2 4 use openidconnect::{CsrfToken, PkceCodeChallenge};
3 5 use serde::Deserialize;
4 -use crate::error::AppError;
5 -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
6 6
7 +/// OAuth 2.0 provider implementation for GitHub.
7 8 pub struct GitHubProvider {
8 9 client_id: String,
9 10 client_secret: String,
10 11 }
11 12
12 13 impl GitHubProvider {
14 + /// Create a new `GitHubProvider` with the given client credentials.
13 15 pub fn new(client_id: String, client_secret: String) -> Self {
14 - Self { client_id, client_secret }
16 + Self {
17 + client_id,
18 + client_secret,
19 + }
15 20 }
16 21 }
17 22
@@ -99,7 +104,10 @@ impl OAuthProvider for GitHubProvider {
99 104
100 105 let user: GitHubUser = http
101 106 .get("https://api.github.com/user")
102 - .header("Authorization", format!("Bearer {}", token_resp.access_token))
107 + .header(
108 + "Authorization",
109 + format!("Bearer {}", token_resp.access_token),
110 + )
103 111 .header("User-Agent", "Rustwarden")
104 112 .send()
105 113 .await
@@ -113,7 +121,10 @@ impl OAuthProvider for GitHubProvider {
113 121 } else {
114 122 let emails: Vec<GitHubEmail> = http
115 123 .get("https://api.github.com/user/emails")
116 - .header("Authorization", format!("Bearer {}", token_resp.access_token))
124 + .header(
125 + "Authorization",
126 + format!("Bearer {}", token_resp.access_token),
127 + )
117 128 .header("User-Agent", "Rustwarden")
118 129 .send()
119 130 .await
@@ -122,9 +133,7 @@ impl OAuthProvider for GitHubProvider {
122 133 .await
123 134 .unwrap_or_default();
124 135
125 - let primary = emails
126 - .into_iter()
127 - .find(|e| e.primary && e.verified);
136 + let primary = emails.into_iter().find(|e| e.primary && e.verified);
128 137 match primary {
129 138 Some(e) => (Some(e.email), true),
130 139 None => (None, false),
M crates/rustwarden-core/src/services/oauth/providers/google.rs
+20 -12
@@ -1,20 +1,24 @@
1 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
2 +use crate::error::AppError;
1 3 use async_trait::async_trait;
2 4 use openidconnect::{
3 - core::CoreProviderMetadata,
4 - AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
5 - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
5 + core::CoreProviderMetadata, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl,
6 + Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
6 7 };
7 -use crate::error::AppError;
8 -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
9 8
9 +/// OIDC provider implementation for Google.
10 10 pub struct GoogleProvider {
11 11 client_id: String,
12 12 client_secret: String,
13 13 }
14 14
15 15 impl GoogleProvider {
16 + /// Create a new `GoogleProvider` with the given client credentials.
16 17 pub fn new(client_id: String, client_secret: String) -> Self {
17 - Self { client_id, client_secret }
18 + Self {
19 + client_id,
20 + client_secret,
21 + }
18 22 }
19 23 }
20 24
@@ -73,10 +77,9 @@ impl OAuthProvider for GoogleProvider {
73 77 let issuer_url = IssuerUrl::new("https://accounts.google.com".to_string())
74 78 .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?;
75 79
76 - let provider_metadata =
77 - CoreProviderMetadata::discover_async(issuer_url, &http_client)
78 - .await
79 - .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;
80 + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client)
81 + .await
82 + .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;
80 83
81 84 let client = openidconnect::core::CoreClient::from_provider_metadata(
82 85 provider_metadata,
@@ -109,7 +112,9 @@ impl OAuthProvider for GoogleProvider {
109 112
110 113 // SEC-H8: Reject missing nonce — empty string would bypass replay protection
111 114 let nonce_value = nonce_str.ok_or_else(|| {
112 - AppError::Internal("OIDC nonce missing from session — possible replay attack".to_string())
115 + AppError::Internal(
116 + "OIDC nonce missing from session — possible replay attack".to_string(),
117 + )
113 118 })?;
114 119 let nonce = Nonce::new(nonce_value.to_string());
115 120 let claims = id_token
@@ -123,7 +128,10 @@ impl OAuthProvider for GoogleProvider {
123 128 .name()
124 129 .and_then(|n| n.get(None))
125 130 .map(|n| n.to_string());
126 - let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.to_string());
131 + let picture = claims
132 + .picture()
133 + .and_then(|p| p.get(None))
134 + .map(|p| p.to_string());
127 135
128 136 Ok(ProviderUserInfo {
129 137 provider_user_id: subject,
M crates/rustwarden-core/src/services/oauth/providers/microsoft.rs
+20 -14
@@ -1,12 +1,12 @@
1 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
2 +use crate::error::AppError;
1 3 use async_trait::async_trait;
2 4 use openidconnect::{
3 - core::CoreProviderMetadata,
4 - AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
5 - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
5 + core::CoreProviderMetadata, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl,
6 + Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
6 7 };
7 -use crate::error::AppError;
8 -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
9 8
9 +/// OIDC provider implementation for Microsoft (Azure AD / Entra ID).
10 10 pub struct MicrosoftProvider {
11 11 client_id: String,
12 12 client_secret: String,
@@ -14,6 +14,7 @@ pub struct MicrosoftProvider {
14 14 }
15 15
16 16 impl MicrosoftProvider {
17 + /// Create a new `MicrosoftProvider`. Defaults to the `"common"` tenant when none is specified.
17 18 pub fn new(client_id: String, client_secret: String, tenant: Option<String>) -> Self {
18 19 Self {
19 20 client_id,
@@ -76,15 +77,15 @@ impl OAuthProvider for MicrosoftProvider {
76 77 .build()
77 78 .map_err(|e| AppError::Internal(format!("HTTP client error: {}", e)))?;
78 79
79 - let issuer_url = IssuerUrl::new(
80 - format!("https://login.microsoftonline.com/{}/v2.0", self.tenant),
81 - )
80 + let issuer_url = IssuerUrl::new(format!(
81 + "https://login.microsoftonline.com/{}/v2.0",
82 + self.tenant
83 + ))
82 84 .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?;
83 85
84 - let provider_metadata =
85 - CoreProviderMetadata::discover_async(issuer_url, &http_client)
86 - .await
87 - .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;
86 + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client)
87 + .await
88 + .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;
88 89
89 90 let client = openidconnect::core::CoreClient::from_provider_metadata(
90 91 provider_metadata,
@@ -117,7 +118,9 @@ impl OAuthProvider for MicrosoftProvider {
117 118
118 119 // SEC-H8: Reject missing nonce — empty string would bypass replay protection
119 120 let nonce_value = nonce_str.ok_or_else(|| {
120 - AppError::Internal("OIDC nonce missing from session — possible replay attack".to_string())
121 + AppError::Internal(
122 + "OIDC nonce missing from session — possible replay attack".to_string(),
123 + )
121 124 })?;
122 125 let nonce = Nonce::new(nonce_value.to_string());
123 126 let claims = id_token
@@ -131,7 +134,10 @@ impl OAuthProvider for MicrosoftProvider {
131 134 .name()
132 135 .and_then(|n| n.get(None))
133 136 .map(|n| n.to_string());
134 - let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.to_string());
137 + let picture = claims
138 + .picture()
139 + .and_then(|p| p.get(None))
140 + .map(|p| p.to_string());
135 141
136 142 Ok(ProviderUserInfo {
137 143 provider_user_id: subject,
M crates/rustwarden-core/src/services/oauth/providers/mod.rs
+12 -3
@@ -1,11 +1,15 @@
1 -pub mod google;
2 -pub mod github;
1 +/// Discord OAuth provider implementation.
3 2 pub mod discord;
3 +/// GitHub OAuth provider implementation.
4 +pub mod github;
5 +/// Google OIDC provider implementation.
6 +pub mod google;
7 +/// Microsoft OIDC provider implementation.
4 8 pub mod microsoft;
5 9
10 +use super::provider_trait::OAuthProvider;
6 11 use std::collections::HashMap;
7 12 use std::sync::Arc;
8 -use super::provider_trait::OAuthProvider;
9 13
10 14 /// Registry of available OAuth providers, indexed by name.
11 15 pub struct ProviderRegistry {
@@ -13,20 +17,24 @@ pub struct ProviderRegistry {
13 17 }
14 18
15 19 impl ProviderRegistry {
20 + /// Create an empty provider registry.
16 21 pub fn new() -> Self {
17 22 Self {
18 23 providers: HashMap::new(),
19 24 }
20 25 }
21 26
27 + /// Register an OAuth provider, replacing any existing registration with the same name.
22 28 pub fn register(&mut self, provider: Arc<dyn OAuthProvider>) {
23 29 self.providers.insert(provider.name().to_string(), provider);
24 30 }
25 31
32 + /// Look up a provider by its name identifier.
26 33 pub fn get(&self, name: &str) -> Option<&Arc<dyn OAuthProvider>> {
27 34 self.providers.get(name)
28 35 }
29 36
37 + /// Return a list of `(name, display_name)` pairs for all registered providers.
30 38 pub fn list(&self) -> Vec<(&str, &str)> {
31 39 self.providers
32 40 .values()
@@ -34,6 +42,7 @@ impl ProviderRegistry {
34 42 .collect()
35 43 }
36 44
45 + /// Return `true` if no providers are registered.
37 46 pub fn is_empty(&self) -> bool {
38 47 self.providers.is_empty()
39 48 }
M crates/rustwarden-core/src/services/oauth/service.rs
+22 -11
@@ -2,12 +2,13 @@ use chrono::Utc;
2 2 use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
3 3 use uuid::Uuid;
4 4
5 +use super::provider_trait::ProviderUserInfo;
5 6 use crate::error::AppError;
6 7 use crate::models::oauth_account;
7 -use crate::services::user_service::UserService;
8 8 use crate::services::role_service::RoleService;
9 -use super::provider_trait::ProviderUserInfo;
9 +use crate::services::user_service::UserService;
10 10
11 +/// High-level service for OAuth account linking and user provisioning.
11 12 pub struct OAuthService;
12 13
13 14 impl OAuthService {
@@ -123,7 +124,9 @@ impl OAuthService {
123 124 info: &ProviderUserInfo,
124 125 ) -> Result<(crate::models::user::Model, bool), AppError> {
125 126 // 1. Check existing link
126 - if let Some(account) = Self::find_oauth_account(db, provider, &info.provider_user_id).await? {
127 + if let Some(account) =
128 + Self::find_oauth_account(db, provider, &info.provider_user_id).await?
129 + {
127 130 let user = UserService::get_user_by_id(db, account.user_id)
128 131 .await?
129 132 .ok_or_else(|| AppError::Internal("Linked user not found".to_string()))?;
@@ -149,11 +152,9 @@ impl OAuthService {
149 152 } else {
150 153 // 3. Create new user
151 154 is_new_user = true;
152 - let username = Self::generate_unique_username(
153 - db,
154 - info.display_name.as_deref().unwrap_or("user"),
155 - )
156 - .await?;
155 + let username =
156 + Self::generate_unique_username(db, info.display_name.as_deref().unwrap_or("user"))
157 + .await?;
157 158
158 159 let user = UserService::create_user(
159 160 db,
@@ -203,17 +204,27 @@ impl OAuthService {
203 204 sanitized
204 205 };
205 206
206 - if UserService::get_user_by_username(db, &sanitized).await?.is_none() {
207 + if UserService::get_user_by_username(db, &sanitized)
208 + .await?
209 + .is_none()
210 + {
207 211 return Ok(sanitized);
208 212 }
209 213
210 214 for i in 1..100 {
211 215 let candidate = format!("{}_{}", sanitized, i);
212 - if UserService::get_user_by_username(db, &candidate).await?.is_none() {
216 + if UserService::get_user_by_username(db, &candidate)
217 + .await?
218 + .is_none()
219 + {
213 220 return Ok(candidate);
214 221 }
215 222 }
216 223
217 - Ok(format!("{}_{}", sanitized, &Uuid::new_v4().to_string()[..8]))
224 + Ok(format!(
225 + "{}_{}",
226 + sanitized,
227 + &Uuid::new_v4().to_string()[..8]
228 + ))
218 229 }
219 230 }
M crates/rustwarden-core/src/services/password_change_service.rs
+16 -72
@@ -12,6 +12,7 @@
12 12 use crate::error::AppError;
13 13 use crate::models::password_change_request;
14 14 use crate::services::password_service::PasswordService;
15 +use crate::services::rate_limit_service::RateLimitService;
15 16 use crate::services::user_service::UserService;
16 17 use chrono::{Duration, Utc};
17 18 use sea_orm::{
@@ -19,17 +20,12 @@ use sea_orm::{
19 20 Set,
20 21 };
21 22 use sha2::{Digest, Sha256};
22 -use std::collections::HashMap;
23 -use std::sync::Arc;
24 -use std::time::{SystemTime, UNIX_EPOCH};
25 -use tokio::sync::Mutex;
26 23 use uuid::Uuid;
27 24
28 25 /// Password change service
29 26 pub struct PasswordChangeService {
30 - /// Rate limiting: track requests per user_id (user_id -> timestamps of requests)
31 - /// Cleaned up periodically to prevent memory leak
32 - rate_limit_store: Arc<Mutex<HashMap<Uuid, Vec<SystemTime>>>>,
27 + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
28 + rate_limiter: RateLimitService,
33 29 }
34 30
35 31 impl Default for PasswordChangeService {
@@ -42,7 +38,7 @@ impl PasswordChangeService {
42 38 /// Create a new PasswordChangeService
43 39 pub fn new() -> Self {
44 40 Self {
45 - rate_limit_store: Arc::new(Mutex::new(HashMap::new())),
41 + rate_limiter: RateLimitService::new(),
46 42 }
47 43 }
48 44
@@ -78,8 +74,16 @@ impl PasswordChangeService {
78 74 user_id: Uuid,
79 75 new_password: &str,
80 76 ) -> Result<(Uuid, String), AppError> {
81 - // Check rate limiting (3 requests per hour per user)
82 - self.check_rate_limit(user_id).await?;
77 + // Check rate limiting (configurable, default 3 requests per hour per user).
78 + // Atomic check+record via the shared RateLimitService — a failed downstream
79 + // op still consumes a slot, which hardens the flow against spam of invalid
80 + // requests.
81 + let max = crate::config::tasks::password_change_rate_limit_max() as u32;
82 + let window = std::time::Duration::from_secs(
83 + crate::config::tasks::password_change_rate_limit_window_secs(),
84 + );
85 + self.rate_limiter
86 + .check_sliding_window(&format!("pwd_change:{user_id}"), max, window)?;
83 87
84 88 // Invalidate any existing pending request for this user
85 89 self.cancel_pending_request(db, user_id).await?;
@@ -93,7 +97,8 @@ impl PasswordChangeService {
93 97 // Hash token with SHA-256
94 98 let token_hash = Self::hash_token(&token);
95 99
96 - let expires_at = Utc::now() + Duration::hours(crate::config::tasks::password_change_expiration_hours());
100 + let expires_at =
101 + Utc::now() + Duration::hours(crate::config::tasks::password_change_expiration_hours());
97 102
98 103 let request = password_change_request::ActiveModel {
99 104 id: Set(Uuid::new_v4()),
@@ -109,9 +114,6 @@ impl PasswordChangeService {
109 114
110 115 let inserted = request.insert(db).await?;
111 116
112 - // Update rate limit
113 - self.update_rate_limit(user_id).await;
114 -
115 117 // Feature 026 - T066, T071: Audit logging for password change request creation
116 118 tracing::info!(
117 119 action = "PASSWORD_CHANGE_REQUESTED",
@@ -467,64 +469,6 @@ impl PasswordChangeService {
467 469 hasher.update(token.as_bytes());
468 470 format!("{:x}", hasher.finalize())
469 471 }
470 -
471 - /// Check rate limiting for password change requests
472 - ///
473 - /// Limits: 3 requests per hour per user
474 - ///
475 - /// # Arguments
476 - ///
477 - /// * `user_id` - User ID to check
478 - ///
479 - /// # Returns
480 - ///
481 - /// Ok(()) if within rate limit
482 - ///
483 - /// # Errors
484 - ///
485 - /// Returns `AppError::TooManyRequests` if rate limit exceeded
486 - async fn check_rate_limit(&self, user_id: Uuid) -> Result<(), AppError> {
487 - let mut store = self.rate_limit_store.lock().await;
488 -
489 - // Get current time
490 - let now = SystemTime::now()
491 - .duration_since(UNIX_EPOCH)
492 - .unwrap_or_default()
493 - .as_secs();
494 -
495 - // Get user's request history
496 - let requests = store.entry(user_id).or_insert_with(Vec::new);
497 -
498 - let window_secs = crate::config::tasks::password_change_rate_limit_window_secs();
499 - let cutoff = now.saturating_sub(window_secs);
500 - requests.retain(|&timestamp| {
501 - timestamp
502 - .duration_since(UNIX_EPOCH)
503 - .unwrap_or_default()
504 - .as_secs()
505 - > cutoff
506 - });
507 -
508 - let max_requests = crate::config::tasks::password_change_rate_limit_max();
509 - if requests.len() >= max_requests {
510 - return Err(AppError::Validation(
511 - "Too many password change requests. Please wait before trying again.".to_string(),
512 - ));
513 - }
514 -
515 - Ok(())
516 - }
517 -
518 - /// Update rate limit tracking after a request
519 - ///
520 - /// # Arguments
521 - ///
522 - /// * `user_id` - User ID who made the request
523 - async fn update_rate_limit(&self, user_id: Uuid) {
524 - let mut store = self.rate_limit_store.lock().await;
525 - let requests = store.entry(user_id).or_insert_with(Vec::new);
526 - requests.push(SystemTime::now());
527 - }
528 472 }
529 473
530 474 #[cfg(test)]
M crates/rustwarden-core/src/services/password_reset_service.rs
+18 -52
@@ -11,6 +11,7 @@ use crate::error::AppError;
11 11 use crate::models::{password_reset_token, user};
12 12 use crate::services::email_queue_service::EmailQueueService;
13 13 use crate::services::password_service::PasswordService;
14 +use crate::services::rate_limit_service::RateLimitService;
14 15 use crate::services::user_service::UserService;
15 16 use crate::templates::email_templates::PasswordResetEmailTemplate;
16 17 use chrono::{Duration, Utc};
@@ -18,18 +19,17 @@ use sea_orm::{
18 19 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
19 20 };
20 21 use sha2::{Digest, Sha256};
21 -use std::collections::HashMap;
22 -use std::sync::Arc;
23 -use std::time::{SystemTime, UNIX_EPOCH};
24 -use tokio::sync::Mutex;
25 22 use uuid::Uuid;
26 23
24 +/// Default: 3 reset requests per email per hour.
25 +const RESET_RATE_LIMIT_MAX: u32 = 3;
26 +const RESET_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
27 +
27 28 /// Password reset service
28 29 pub struct PasswordResetService {
29 30 email_config: EmailConfig,
30 - /// Rate limiting: track requests per email (email -> timestamp of last request)
31 - /// Cleaned up periodically to prevent memory leak
32 - rate_limit_store: Arc<Mutex<HashMap<String, Vec<SystemTime>>>>,
31 + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
32 + rate_limiter: RateLimitService,
33 33 }
34 34
35 35 impl PasswordResetService {
@@ -37,7 +37,7 @@ impl PasswordResetService {
37 37 pub fn new(email_config: EmailConfig) -> Self {
38 38 Self {
39 39 email_config,
40 - rate_limit_store: Arc::new(Mutex::new(HashMap::new())),
40 + rate_limiter: RateLimitService::new(),
41 41 }
42 42 }
43 43
@@ -63,8 +63,14 @@ impl PasswordResetService {
63 63 db: &DatabaseConnection,
64 64 email: &str,
65 65 ) -> Result<bool, AppError> {
66 - // Check rate limiting (3 requests per hour per email)
67 - self.check_rate_limit(email).await?;
66 + // Check rate limiting (3 requests per hour per email). Atomic check+record:
67 + // a failed downstream op still consumes the slot to block abuse of the
68 + // reset flow against non-existent accounts.
69 + self.rate_limiter.check_sliding_window(
70 + &format!("pwd_reset:{email}"),
71 + RESET_RATE_LIMIT_MAX,
72 + RESET_RATE_LIMIT_WINDOW,
73 + )?;
68 74
69 75 // Find user by email
70 76 let user = match UserService::get_user_by_email(db, email).await? {
@@ -84,7 +90,8 @@ impl PasswordResetService {
84 90 // Hash token with SHA-256
85 91 let token_hash = Self::hash_token(&token);
86 92
87 - let expires_at = Utc::now() + Duration::hours(crate::config::tasks::password_reset_expiration_hours());
93 + let expires_at =
94 + Utc::now() + Duration::hours(crate::config::tasks::password_reset_expiration_hours());
88 95
89 96 let reset_token = password_reset_token::ActiveModel {
90 97 id: Set(Uuid::new_v4()),
@@ -146,9 +153,6 @@ impl PasswordResetService {
146 153 )
147 154 .await?;
148 155
149 - // Update rate limit
150 - self.update_rate_limit(email).await;
151 -
152 156 Ok(true)
153 157 }
154 158
@@ -298,42 +302,4 @@ impl PasswordResetService {
298 302
299 303 Ok(())
300 304 }
301 -
302 - /// Check rate limiting (3 requests per hour per email)
303 - async fn check_rate_limit(&self, email: &str) -> Result<(), AppError> {
304 - let mut store = self.rate_limit_store.lock().await;
305 -
306 - // Clean up old entries (older than 1 hour)
307 - let one_hour_ago = SystemTime::now()
308 - .duration_since(UNIX_EPOCH)
309 - .unwrap_or_default()
310 - .as_secs()
311 - .saturating_sub(3600);
312 -
313 - if let Some(requests) = store.get_mut(email) {
314 - requests.retain(|&time| {
315 - time.duration_since(UNIX_EPOCH)
316 - .unwrap_or_default()
317 - .as_secs()
318 - > one_hour_ago
319 - });
320 - }
321 -
322 - // Check current requests
323 - let requests = store.entry(email.to_string()).or_insert_with(Vec::new);
324 - if requests.len() >= 3 {
325 - return Err(AppError::Validation(
326 - "Too many password reset requests. Please try again later.".to_string(),
327 - ));
328 - }
329 -
330 - Ok(())
331 - }
332 -
333 - /// Update rate limit after successful request
334 - async fn update_rate_limit(&self, email: &str) {
335 - let mut store = self.rate_limit_store.lock().await;
336 - let requests = store.entry(email.to_string()).or_insert_with(Vec::new);
337 - requests.push(SystemTime::now());
338 - }
339 305 }
M crates/rustwarden-core/src/services/password_service.rs
+7 -3
@@ -1,5 +1,5 @@
1 1 use crate::error::AppError;
2 -use bcrypt::{DEFAULT_COST};
2 +use bcrypt::DEFAULT_COST;
3 3 use rand::seq::SliceRandom;
4 4 use rand::Rng;
5 5 use std::sync::LazyLock;
@@ -210,10 +210,14 @@ mod tests {
210 210 let hash = PasswordService::hash_password(password).await.unwrap();
211 211
212 212 // Verify correct password
213 - assert!(PasswordService::verify_password(password, &hash).await.unwrap());
213 + assert!(PasswordService::verify_password(password, &hash)
214 + .await
215 + .unwrap());
214 216
215 217 // Verify incorrect password
216 - assert!(!PasswordService::verify_password("wrong_password", &hash).await.unwrap());
218 + assert!(!PasswordService::verify_password("wrong_password", &hash)
219 + .await
220 + .unwrap());
217 221 }
218 222
219 223 #[test]
M crates/rustwarden-core/src/services/permission_service.rs
+10 -13
@@ -1,21 +1,20 @@
1 1 use crate::error::AppError;
2 2 use crate::models::{permission, role_permission, user_role};
3 3 use chrono::Utc;
4 -use sea_orm::{
5 - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
6 -};
4 +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
7 5 use uuid::Uuid;
8 6
7 +/// Service for managing permissions and role-permission assignments.
9 8 pub struct PermissionService;
10 9
11 10 impl PermissionService {
12 - pub async fn list_all(
13 - db: &DatabaseConnection,
14 - ) -> Result<Vec<permission::Model>, AppError> {
11 + /// Return all permissions stored in the database.
12 + pub async fn list_all(db: &DatabaseConnection) -> Result<Vec<permission::Model>, AppError> {
15 13 let perms = permission::Entity::find().all(db).await?;
16 14 Ok(perms)
17 15 }
18 16
17 + /// Return a single permission by its UUID, or `None` if not found.
19 18 pub async fn get_by_id(
20 19 db: &DatabaseConnection,
21 20 id: Uuid,
@@ -24,6 +23,7 @@ impl PermissionService {
24 23 Ok(perm)
25 24 }
26 25
26 + /// Return a single permission by its unique name, or `None` if not found.
27 27 pub async fn get_by_name(
28 28 db: &DatabaseConnection,
29 29 name: &str,
@@ -35,6 +35,7 @@ impl PermissionService {
35 35 Ok(perm)
36 36 }
37 37
38 + /// Create and persist a new permission.
38 39 pub async fn create(
39 40 db: &DatabaseConnection,
40 41 name: String,
@@ -52,10 +53,8 @@ impl PermissionService {
52 53 Ok(result)
53 54 }
54 55
55 - pub async fn delete(
56 - db: &DatabaseConnection,
57 - id: Uuid,
58 - ) -> Result<(), AppError> {
56 + /// Delete a permission by its UUID.
57 + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result<(), AppError> {
59 58 permission::Entity::delete_by_id(id).exec(db).await?;
60 59 Ok(())
61 60 }
@@ -142,9 +141,7 @@ impl PermissionService {
142 141 }
143 142
144 143 /// Seed default permissions (idempotent).
145 - pub async fn initialize_default_permissions(
146 - db: &DatabaseConnection,
147 - ) -> Result<(), AppError> {
144 + pub async fn initialize_default_permissions(db: &DatabaseConnection) -> Result<(), AppError> {
148 145 let defaults = vec![
149 146 ("users.list", "View the list of users", "users"),
150 147 ("users.create", "Create new users", "users"),
A crates/rustwarden-core/src/services/personal_token_service.rs
+582 -0
@@ -0,0 +1,582 @@
1 +//! Personal access token service.
2 +//!
3 +//! Generic over a user-defined [`TokenScope`] so each consumer app keeps a
4 +//! type-safe vocabulary (e.g. gitrust → `RepoScope`, PasterMan → `WallScope`)
5 +//! while sharing the hash/verify/revoke/cleanup plumbing.
6 +//!
7 +//! # Security
8 +//!
9 +//! - Token plaintext is returned once from [`PersonalTokenService::create`]
10 +//! and never persisted. Only the SHA-256 digest is stored.
11 +//! - [`PersonalTokenService::verify`] uses a constant-time comparison on the
12 +//! hash (via [`subtle`]) to reject timing oracles.
13 +//! - Expired and revoked tokens refuse authentication without leaking the
14 +//! reason (uniform `AppError::InvalidToken`).
15 +//!
16 +//! # Example
17 +//!
18 +//! ```rust,ignore
19 +//! use rustwarden_core::services::personal_token_service::{
20 +//! PersonalTokenService, TokenScope,
21 +//! };
22 +//!
23 +//! #[derive(Clone, Debug)]
24 +//! enum RepoScope {
25 +//! Read,
26 +//! Write,
27 +//! }
28 +//!
29 +//! impl TokenScope for RepoScope {
30 +//! fn as_str(&self) -> &str {
31 +//! match self {
32 +//! RepoScope::Read => "read:repo",
33 +//! RepoScope::Write => "write:repo",
34 +//! }
35 +//! }
36 +//! fn parse(s: &str) -> Option<Self> {
37 +//! match s {
38 +//! "read:repo" => Some(Self::Read),
39 +//! "write:repo" => Some(Self::Write),
40 +//! _ => None,
41 +//! }
42 +//! }
43 +//! }
44 +//! ```
45 +
46 +use crate::error::AppError;
47 +use crate::models::personal_access_token;
48 +use chrono::{DateTime, Utc};
49 +use rand::RngCore;
50 +use sea_orm::{
51 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
52 +};
53 +use sha2::{Digest, Sha256};
54 +use std::marker::PhantomData;
55 +use subtle::ConstantTimeEq;
56 +use uuid::Uuid;
57 +
58 +/// Scope vocabulary implemented by each consumer app.
59 +///
60 +/// Scopes are stored as their `as_str()` representation in the DB; a token
61 +/// created with an unknown-to-the-app scope string is silently dropped from
62 +/// the parsed list returned by [`PersonalTokenService::verify`], so rolling
63 +/// back a scope definition does not invalidate prior tokens outright — they
64 +/// just lose the removed capability.
65 +pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
66 + /// Canonical string representation (e.g. `"read:repo"`).
67 + fn as_str(&self) -> &str;
68 + /// Parse back from the canonical string. Returns `None` for unknown values.
69 + fn parse(s: &str) -> Option<Self>;
70 +}
71 +
72 +/// Personal access token service parameterised by a scope vocabulary.
73 +///
74 +/// All methods are associated fns (no state) — the `PhantomData<S>` only
75 +/// binds the scope type for the trait impl. Construct as
76 +/// `PersonalTokenService::<MyScope>::create(...)` or keep a type alias.
77 +pub struct PersonalTokenService<S: TokenScope> {
78 + _phantom: PhantomData<S>,
79 +}
80 +
81 +impl<S: TokenScope> PersonalTokenService<S> {
82 + /// Create a new PAT and return the (model, plaintext) pair.
83 + ///
84 + /// The plaintext is shown once to the user and MUST NOT be persisted
85 + /// elsewhere by the caller — only its SHA-256 digest lives in the DB.
86 + ///
87 + /// # Errors
88 + ///
89 + /// - `AppError::Validation` if `name` is empty.
90 + /// - `AppError::Database` on insertion failure (FK violation, unique
91 + /// constraint on `(user_id, name)` or `token_hash`).
92 + pub async fn create(
93 + db: &DatabaseConnection,
94 + user_id: Uuid,
95 + name: &str,
96 + scopes: &[S],
97 + expires_at: Option<DateTime<Utc>>,
98 + ) -> Result<(personal_access_token::Model, String), AppError> {
99 + if name.trim().is_empty() {
100 + return Err(AppError::Validation(
101 + "Token name cannot be empty".to_string(),
102 + ));
103 + }
104 +
105 + let plain = generate_token_plain();
106 + let token_hash = hash_token(&plain);
107 + let scope_strings: Vec<String> = scopes.iter().map(|s| s.as_str().to_string()).collect();
108 +
109 + let active = personal_access_token::ActiveModel {
110 + id: Set(Uuid::new_v4()),
111 + user_id: Set(user_id),
112 + name: Set(name.to_string()),
113 + token_hash: Set(token_hash),
114 + scopes: Set(scope_strings),
115 + expires_at: Set(expires_at),
116 + last_used_at: NotSet,
117 + revoked_at: NotSet,
118 + created_at: Set(Utc::now()),
119 + };
120 +
121 + let model = active.insert(db).await?;
122 + Ok((model, plain))
123 + }
124 +
125 + /// Verify a plaintext token and return the record plus parsed scopes.
126 + ///
127 + /// Touches `last_used_at` on success (best-effort; update failure only
128 + /// logs a warning). Rejects tokens that are revoked, expired, or whose
129 + /// hash does not match anything in the DB — always with
130 + /// [`AppError::InvalidToken`] to avoid leaking which condition failed.
131 + pub async fn verify(
132 + db: &DatabaseConnection,
133 + token_clear: &str,
134 + ) -> Result<(personal_access_token::Model, Vec<S>), AppError> {
135 + if token_clear.is_empty() {
136 + return Err(AppError::InvalidToken);
137 + }
138 +
139 + let candidate_hash = hash_token(token_clear);
140 + let row = personal_access_token::Entity::find()
141 + .filter(personal_access_token::Column::TokenHash.eq(&candidate_hash))
142 + .one(db)
143 + .await?
144 + .ok_or(AppError::InvalidToken)?;
145 +
146 + // Constant-time re-check — the SQL lookup already matched on the hash,
147 + // but this guards against DB-side comparison being variable-time.
148 + if !bool::from(row.token_hash.as_bytes().ct_eq(candidate_hash.as_bytes())) {
149 + return Err(AppError::InvalidToken);
150 + }
151 +
152 + if !row.is_active() {
153 + return Err(AppError::InvalidToken);
154 + }
155 +
156 + // Best-effort last_used_at update (don't block auth on slow write).
157 + let id = row.id;
158 + let mut touched: personal_access_token::ActiveModel = row.clone().into();
159 + touched.last_used_at = Set(Some(Utc::now()));
160 + if let Err(e) = touched.update(db).await {
161 + tracing::warn!(token_id = %id, "PAT last_used_at update failed: {}", e);
162 + }
163 +
164 + let scopes: Vec<S> = row.scopes.iter().filter_map(|s| S::parse(s)).collect();
165 + Ok((row, scopes))
166 + }
167 +
168 + /// List all non-revoked PATs for a user (most recent first).
169 + pub async fn list_for_user(
170 + db: &DatabaseConnection,
171 + user_id: Uuid,
172 + ) -> Result<Vec<personal_access_token::Model>, AppError> {
173 + use sea_orm::QueryOrder;
174 + let rows = personal_access_token::Entity::find()
175 + .filter(personal_access_token::Column::UserId.eq(user_id))
176 + .filter(personal_access_token::Column::RevokedAt.is_null())
177 + .order_by_desc(personal_access_token::Column::CreatedAt)
178 + .all(db)
179 + .await?;
180 + Ok(rows)
181 + }
182 +
183 + /// Revoke a PAT by id. Idempotent: revoking an already-revoked token is
184 + /// a no-op. Refuses if the token does not belong to `user_id` (ownership
185 + /// enforcement — otherwise an attacker knowing the UUID could revoke
186 + /// another user's token).
187 + pub async fn revoke(
188 + db: &DatabaseConnection,
189 + token_id: Uuid,
190 + user_id: Uuid,
191 + ) -> Result<(), AppError> {
192 + let row = personal_access_token::Entity::find_by_id(token_id)
193 + .one(db)
194 + .await?
195 + .ok_or_else(|| AppError::NotFound(format!("PAT {token_id}")))?;
196 +
197 + if row.user_id != user_id {
198 + return Err(AppError::Forbidden);
199 + }
200 +
201 + if row.revoked_at.is_some() {
202 + return Ok(()); // idempotent
203 + }
204 +
205 + let mut active: personal_access_token::ActiveModel = row.into();
206 + active.revoked_at = Set(Some(Utc::now()));
207 + active.update(db).await?;
208 + Ok(())
209 + }
210 +
211 + /// Background cleanup: hard-delete tokens that are both expired AND
212 + /// revoked (either condition alone is kept for audit window). Returns
213 + /// the number of rows removed.
214 + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> {
215 + let now = Utc::now();
216 + let result = personal_access_token::Entity::delete_many()
217 + .filter(
218 + sea_orm::Condition::all()
219 + .add(personal_access_token::Column::ExpiresAt.is_not_null())
220 + .add(personal_access_token::Column::ExpiresAt.lt(now))
221 + .add(personal_access_token::Column::RevokedAt.is_not_null()),
222 + )
223 + .exec(db)
224 + .await?;
225 + Ok(result.rows_affected)
226 + }
227 +}
228 +
229 +/// Generate a fresh 32-byte token encoded as 64 hex chars (URL-safe).
230 +fn generate_token_plain() -> String {
231 + let mut bytes = [0u8; 32];
232 + rand::rng().fill_bytes(&mut bytes);
233 + hex::encode(bytes)
234 +}
235 +
236 +/// SHA-256 hex of the plaintext token — deterministic indexing key.
237 +fn hash_token(plain: &str) -> String {
238 + let mut hasher = Sha256::new();
239 + hasher.update(plain.as_bytes());
240 + hex::encode(hasher.finalize())
241 +}
242 +
243 +#[cfg(test)]
244 +mod tests {
245 + use super::*;
246 + use crate::services::password_service::PasswordService;
247 + use crate::services::user_service::UserService;
248 + use chrono::Duration;
249 +
250 + /// Minimal scope type exercising the generic bounds.
251 + #[derive(Clone, Debug, PartialEq, Eq)]
252 + enum TestScope {
253 + Read,
254 + Write,
255 + }
256 +
257 + impl TokenScope for TestScope {
258 + fn as_str(&self) -> &str {
259 + match self {
260 + Self::Read => "test:read",
261 + Self::Write => "test:write",
262 + }
263 + }
264 + fn parse(s: &str) -> Option<Self> {
265 + match s {
266 + "test:read" => Some(Self::Read),
267 + "test:write" => Some(Self::Write),
268 + _ => None,
269 + }
270 + }
271 + }
272 +
273 + /// Per-test connection. A shared `OnceCell<DatabaseConnection>` cannot be
274 + /// reused across `#[tokio::test]` calls because each creates its own
275 + /// runtime; the pool captured by the first test outlives it and later
276 + /// tests see "Tokio 1.x context being shutdown". Re-connecting per test
277 + /// costs ~10ms on localhost Postgres — worth the isolation.
278 + async fn test_db() -> DatabaseConnection {
279 + dotenvy::dotenv().ok();
280 + let url = std::env::var("DATABASE_URL")
281 + .expect("DATABASE_URL required for personal_token_service tests");
282 + sea_orm::Database::connect(&url).await.unwrap()
283 + }
284 +
285 + /// Isolates each test with a unique user. Avoids polluting the dev DB.
286 + async fn fresh_user(db: &DatabaseConnection) -> Uuid {
287 + let unique = Uuid::new_v4().simple().to_string();
288 + let hash = PasswordService::hash_password("NotReallyUsed123!")
289 + .await
290 + .unwrap();
291 + let user = UserService::create_user(
292 + db,
293 + format!("pat_test_{}", &unique[..12]),
294 + format!("pat_test_{}@example.com", &unique[..12]),
295 + hash,
296 + false,
297 + )
298 + .await
299 + .unwrap();
300 + user.id
301 + }
302 +
303 + #[tokio::test]
304 + async fn create_returns_plaintext_and_persists_hash_only() {
305 + let db = test_db().await;
306 + let user_id = fresh_user(&db).await;
307 +
308 + let (record, plain) = PersonalTokenService::<TestScope>::create(
309 + &db,
310 + user_id,
311 + "ci-bot",
312 + &[TestScope::Read],
313 + None,
314 + )
315 + .await
316 + .unwrap();
317 +
318 + assert_eq!(plain.len(), 64, "token should be 32 bytes hex");
319 + assert_ne!(
320 + record.token_hash, plain,
321 + "DB must store hash, not plaintext"
322 + );
323 + assert_eq!(record.token_hash.len(), 64, "SHA-256 hex is 64 chars");
324 + assert_eq!(record.scopes, vec!["test:read".to_string()]);
325 + assert!(record.expires_at.is_none());
326 + assert!(record.last_used_at.is_none());
327 + assert!(record.revoked_at.is_none());
328 + }
329 +
330 + #[tokio::test]
331 + async fn create_rejects_empty_name() {
332 + let db = test_db().await;
333 + let user_id = fresh_user(&db).await;
334 + let err = PersonalTokenService::<TestScope>::create(&db, user_id, " ", &[], None)
335 + .await
336 + .unwrap_err();
337 + matches!(err, AppError::Validation(_));
338 + }
339 +
340 + #[tokio::test]
341 + async fn verify_accepts_valid_token_and_updates_last_used_at() {
342 + let db = test_db().await;
343 + let user_id = fresh_user(&db).await;
344 + let (record, plain) = PersonalTokenService::<TestScope>::create(
345 + &db,
346 + user_id,
347 + "ci-verify",
348 + &[TestScope::Read, TestScope::Write],
349 + None,
350 + )
351 + .await
352 + .unwrap();
353 +
354 + let (verified, scopes) = PersonalTokenService::<TestScope>::verify(&db, &plain)
355 + .await
356 + .unwrap();
357 +
358 + assert_eq!(verified.id, record.id);
359 + assert_eq!(
360 + scopes,
361 + vec![TestScope::Read, TestScope::Write],
362 + "scopes must round-trip through parse()"
363 + );
364 +
365 + // Reload to assert last_used_at was actually persisted.
366 + let fresh = personal_access_token::Entity::find_by_id(record.id)
367 + .one(&db)
368 + .await
369 + .unwrap()
370 + .unwrap();
371 + assert!(
372 + fresh.last_used_at.is_some(),
373 + "last_used_at must be set after verify()"
374 + );
375 + }
376 +
377 + #[tokio::test]
378 + async fn verify_rejects_unknown_token() {
379 + let db = test_db().await;
380 + let err =
381 + PersonalTokenService::<TestScope>::verify(&db, "deadbeefdeadbeefdeadbeefdeadbeef")
382 + .await
383 + .unwrap_err();
384 + assert!(matches!(err, AppError::InvalidToken));
385 + }
386 +
387 + #[tokio::test]
388 + async fn verify_rejects_revoked_token() {
389 + let db = test_db().await;
390 + let user_id = fresh_user(&db).await;
391 + let (record, plain) = PersonalTokenService::<TestScope>::create(
392 + &db,
393 + user_id,
394 + "ci-revoke",
395 + &[TestScope::Read],
396 + None,
397 + )
398 + .await
399 + .unwrap();
400 + PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id)
401 + .await
402 + .unwrap();
403 +
404 + let err = PersonalTokenService::<TestScope>::verify(&db, &plain)
405 + .await
406 + .unwrap_err();
407 + assert!(matches!(err, AppError::InvalidToken));
408 + }
409 +
410 + #[tokio::test]
411 + async fn verify_rejects_expired_token() {
412 + let db = test_db().await;
413 + let user_id = fresh_user(&db).await;
414 + let (_record, plain) = PersonalTokenService::<TestScope>::create(
415 + &db,
416 + user_id,
417 + "ci-expired",
418 + &[TestScope::Read],
419 + Some(Utc::now() - Duration::seconds(1)),
420 + )
421 + .await
422 + .unwrap();
423 +
424 + let err = PersonalTokenService::<TestScope>::verify(&db, &plain)
425 + .await
426 + .unwrap_err();
427 + assert!(matches!(err, AppError::InvalidToken));
428 + }
429 +
430 + #[tokio::test]
431 + async fn list_for_user_returns_only_active_tokens() {
432 + let db = test_db().await;
433 + let user_id = fresh_user(&db).await;
434 +
435 + let (t1, _) =
436 + PersonalTokenService::<TestScope>::create(&db, user_id, "t1", &[TestScope::Read], None)
437 + .await
438 + .unwrap();
439 + let (t2, _) = PersonalTokenService::<TestScope>::create(
440 + &db,
441 + user_id,
442 + "t2",
443 + &[TestScope::Write],
444 + None,
445 + )
446 + .await
447 + .unwrap();
448 +
449 + PersonalTokenService::<TestScope>::revoke(&db, t2.id, user_id)
450 + .await
451 + .unwrap();
452 +
453 + let listed = PersonalTokenService::<TestScope>::list_for_user(&db, user_id)
454 + .await
455 + .unwrap();
456 + assert_eq!(listed.len(), 1, "revoked token must not appear in list");
457 + assert_eq!(listed[0].id, t1.id);
458 + }
459 +
460 + #[tokio::test]
461 + async fn revoke_refuses_other_users_tokens() {
462 + let db = test_db().await;
463 + let owner = fresh_user(&db).await;
464 + let attacker = fresh_user(&db).await;
465 + let (record, _) = PersonalTokenService::<TestScope>::create(
466 + &db,
467 + owner,
468 + "owned",
469 + &[TestScope::Read],
470 + None,
471 + )
472 + .await
473 + .unwrap();
474 +
475 + let err = PersonalTokenService::<TestScope>::revoke(&db, record.id, attacker)
476 + .await
477 + .unwrap_err();
478 + assert!(matches!(err, AppError::Forbidden));
479 +
480 + // Token must still be usable — cross-user revoke must not have fired.
481 + let reloaded = personal_access_token::Entity::find_by_id(record.id)
482 + .one(&db)
483 + .await
484 + .unwrap()
485 + .unwrap();
486 + assert!(reloaded.revoked_at.is_none());
487 + }
488 +
489 + #[tokio::test]
490 + async fn revoke_is_idempotent() {
491 + let db = test_db().await;
492 + let user_id = fresh_user(&db).await;
493 + let (record, _) = PersonalTokenService::<TestScope>::create(
494 + &db,
495 + user_id,
496 + "idempotent",
497 + &[TestScope::Read],
498 + None,
499 + )
500 + .await
501 + .unwrap();
502 +
503 + PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id)
504 + .await
505 + .unwrap();
506 + PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id)
507 + .await
508 + .expect("second revoke must be a no-op");
509 + }
510 +
511 + #[tokio::test]
512 + async fn cleanup_expired_removes_only_expired_and_revoked_rows() {
513 + let db = test_db().await;
514 + let user_id = fresh_user(&db).await;
515 +
516 + // 1. Expired + revoked → should be deleted.
517 + let (expired_revoked, _) = PersonalTokenService::<TestScope>::create(
518 + &db,
519 + user_id,
520 + "expired-revoked",
521 + &[TestScope::Read],
522 + Some(Utc::now() - Duration::hours(1)),
523 + )
524 + .await
525 + .unwrap();
526 + PersonalTokenService::<TestScope>::revoke(&db, expired_revoked.id, user_id)
527 + .await
528 + .unwrap();
529 +
530 + // 2. Expired only (no revoke) → keep for audit.
531 + let (expired_only, _) = PersonalTokenService::<TestScope>::create(
532 + &db,
533 + user_id,
534 + "expired-only",
535 + &[TestScope::Read],
536 + Some(Utc::now() - Duration::hours(1)),
537 + )
538 + .await
539 + .unwrap();
540 +
541 + // 3. Active → must stay.
542 + let (active, _) = PersonalTokenService::<TestScope>::create(
543 + &db,
544 + user_id,
545 + "active",
546 + &[TestScope::Read],
547 + None,
548 + )
549 + .await
550 + .unwrap();
551 +
552 + let removed = PersonalTokenService::<TestScope>::cleanup_expired(&db)
553 + .await
554 + .unwrap();
555 + assert!(removed >= 1);
556 +
557 + assert!(
558 + personal_access_token::Entity::find_by_id(expired_revoked.id)
559 + .one(&db)
560 + .await
561 + .unwrap()
562 + .is_none(),
563 + "expired+revoked must be deleted"
564 + );
565 + assert!(
566 + personal_access_token::Entity::find_by_id(expired_only.id)
567 + .one(&db)
568 + .await
569 + .unwrap()
570 + .is_some(),
571 + "expired-only must be kept for audit"
572 + );
573 + assert!(
574 + personal_access_token::Entity::find_by_id(active.id)
575 + .one(&db)
576 + .await
577 + .unwrap()
578 + .is_some(),
579 + "active token must not be touched"
580 + );
581 + }
582 +}
A crates/rustwarden-core/src/services/rate_limit_service.rs
+317 -0
@@ -0,0 +1,317 @@
1 +//! Generic in-process rate limiter.
2 +//!
3 +//! Tracks request counts per arbitrary key over a sliding window. Used
4 +//! internally by authentication, public share, and password flow rate
5 +//! limiters, and exposed publicly for apps that need to rate-limit their
6 +//! own endpoints (imports, webhooks, etc.).
7 +//!
8 +//! # Example
9 +//!
10 +//! ```no_run
11 +//! use std::sync::Arc;
12 +//! use rustwarden_core::services::rate_limit_service::RateLimitService;
13 +//!
14 +//! let limiter = Arc::new(RateLimitService::new());
15 +//! // Reject after 10 calls / minute per IP:
16 +//! limiter.check(&format!("import:{}", "1.2.3.4"), 10).unwrap();
17 +//! ```
18 +//!
19 +//! # Limitations
20 +//!
21 +//! - In-process only. Multi-instance deployments need a shared backend
22 +//! (Redis) — out of scope for v1.
23 +//! - Memory-bounded via `MAX_ENTRIES` (dropped if the map grows past the
24 +//! cap and the incoming key is unknown).
25 +
26 +use std::{
27 + collections::HashMap,
28 + sync::{Arc, Mutex},
29 + time::{Duration, Instant},
30 +};
31 +
32 +use crate::error::AppError;
33 +
34 +/// Maximum distinct keys tracked before refusing new ones (SEC-M2: memory DoS).
35 +const MAX_ENTRIES: usize = 100_000;
36 +
37 +/// Per-key counter plus the instant at which the current window opened.
38 +#[derive(Debug, Clone, Copy)]
39 +struct WindowCounter {
40 + count: u32,
41 + window_start: Instant,
42 +}
43 +
44 +/// Thread-safe rate limiter keyed by arbitrary strings.
45 +///
46 +/// Supports two semantics:
47 +///
48 +/// - [`check`]/[`check_window`]: fixed-window counter (resets at each window
49 +/// boundary). Cheap, single integer per key.
50 +/// - [`check_sliding_window`]: true sliding window (stores each attempt
51 +/// timestamp). Stricter across boundaries; preferred for security-sensitive
52 +/// flows like password reset throttling.
53 +///
54 +/// Uses a single `Mutex<HashMap>`; contention is acceptable because each
55 +/// check is a handful of microseconds. A sharded `DashMap` backend can be
56 +/// swapped in later without API change.
57 +///
58 +/// [`check`]: Self::check
59 +/// [`check_window`]: Self::check_window
60 +/// [`check_sliding_window`]: Self::check_sliding_window
61 +#[derive(Clone, Default)]
62 +pub struct RateLimitService {
63 + buckets: Arc<Mutex<HashMap<String, WindowCounter>>>,
64 + sliding: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
65 +}
66 +
67 +impl RateLimitService {
68 + /// Create a new `RateLimitService` with empty tracking maps.
69 + pub fn new() -> Self {
70 + Self::default()
71 + }
72 +
73 + /// Check and increment the counter for `key`. Returns `Err(TooManyRequests)`
74 + /// with a `Retry-After` hint when `max_per_minute` is exceeded inside the
75 + /// rolling 60-second window.
76 + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError> {
77 + self.check_window(key, max_per_minute, Duration::from_secs(60))
78 + }
79 +
80 + /// Check and increment the counter for `key` with a custom window size.
81 + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError> {
82 + let now = Instant::now();
83 + let mut buckets = self
84 + .buckets
85 + .lock()
86 + .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?;
87 +
88 + // Drop expired entries so the map stays bounded on low-traffic keys.
89 + buckets.retain(|_, w| now.duration_since(w.window_start) < window);
90 +
91 + if buckets.len() >= MAX_ENTRIES && !buckets.contains_key(key) {
92 + tracing::warn!(
93 + entries = buckets.len(),
94 + cap = MAX_ENTRIES,
95 + "rate limiter map full, rejecting new key"
96 + );
97 + return Err(AppError::TooManyRequests {
98 + retry_after_secs: window.as_secs(),
99 + });
100 + }
101 +
102 + let w = buckets.entry(key.to_string()).or_insert(WindowCounter {
103 + count: 0,
104 + window_start: now,
105 + });
106 +
107 + if now.duration_since(w.window_start) >= window {
108 + w.count = 0;
109 + w.window_start = now;
110 + }
111 +
112 + w.count += 1;
113 + if w.count > max {
114 + let elapsed = now.duration_since(w.window_start);
115 + let remaining = window.saturating_sub(elapsed).as_secs().max(1);
116 + tracing::warn!(
117 + key = %key,
118 + count = w.count,
119 + max,
120 + retry_after_secs = remaining,
121 + "rate limit exceeded"
122 + );
123 + return Err(AppError::TooManyRequests {
124 + retry_after_secs: remaining,
125 + });
126 + }
127 +
128 + Ok(())
129 + }
130 +
131 + /// True sliding-window check: rejects if more than `max` attempts occurred
132 + /// in the last `window` duration, counted attempt-by-attempt.
133 + ///
134 + /// Stricter than [`check_window`] because it never allows a burst across a
135 + /// window boundary. Use for security-sensitive throttling (password reset,
136 + /// email verification resend, etc.).
137 + ///
138 + /// [`check_window`]: Self::check_window
139 + pub fn check_sliding_window(
140 + &self,
141 + key: &str,
142 + max: u32,
143 + window: Duration,
144 + ) -> Result<(), AppError> {
145 + let now = Instant::now();
146 + let mut sliding = self
147 + .sliding
148 + .lock()
149 + .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?;
150 +
151 + // Size guard before insertion of a new key.
152 + if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) {
153 + tracing::warn!(
154 + entries = sliding.len(),
155 + cap = MAX_ENTRIES,
156 + "sliding rate limiter map full, rejecting new key"
157 + );
158 + return Err(AppError::TooManyRequests {
159 + retry_after_secs: window.as_secs(),
160 + });
161 + }
162 +
163 + let attempts = sliding.entry(key.to_string()).or_default();
164 + attempts.retain(|t| now.duration_since(*t) < window);
165 +
166 + if attempts.len() as u32 >= max {
167 + let oldest = attempts.first().copied().unwrap_or(now);
168 + let elapsed = now.duration_since(oldest);
169 + let retry = window.saturating_sub(elapsed).as_secs().max(1);
170 + tracing::warn!(
171 + key = %key,
172 + attempts = attempts.len(),
173 + max,
174 + retry_after_secs = retry,
175 + "sliding rate limit exceeded"
176 + );
177 + return Err(AppError::TooManyRequests {
178 + retry_after_secs: retry,
179 + });
180 + }
181 +
182 + attempts.push(now);
183 + Ok(())
184 + }
185 +
186 + /// Background task that periodically drops expired buckets.
187 + ///
188 + /// Not strictly required (every `check` already evicts stale entries for
189 + /// its own window), but useful when most keys stop being accessed yet the
190 + /// map should not keep growing through bursty traffic.
191 + pub async fn cleanup_loop(self, interval: Duration, window: Duration) {
192 + let mut ticker = tokio::time::interval(interval);
193 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
194 + loop {
195 + ticker.tick().await;
196 + let now = Instant::now();
197 + if let Ok(mut buckets) = self.buckets.lock() {
198 + buckets.retain(|_, w| now.duration_since(w.window_start) < window);
199 + }
200 + if let Ok(mut sliding) = self.sliding.lock() {
201 + sliding.retain(|_, attempts| {
202 + attempts.retain(|t| now.duration_since(*t) < window);
203 + !attempts.is_empty()
204 + });
205 + }
206 + }
207 + }
208 +
209 + /// Number of distinct keys currently tracked in the fixed-window map.
210 + pub fn len(&self) -> usize {
211 + self.buckets.lock().map(|b| b.len()).unwrap_or(0)
212 + }
213 +
214 + /// Return `true` if no keys are currently tracked in the fixed-window map.
215 + pub fn is_empty(&self) -> bool {
216 + self.len() == 0
217 + }
218 +
219 + /// Number of distinct keys currently tracked in the sliding-window map.
220 + pub fn sliding_len(&self) -> usize {
221 + self.sliding.lock().map(|s| s.len()).unwrap_or(0)
222 + }
223 +}
224 +
225 +#[cfg(test)]
226 +mod tests {
227 + use super::*;
228 +
229 + #[test]
230 + fn under_limit_always_allowed() {
231 + let rl = RateLimitService::new();
232 + for _ in 0..5 {
233 + rl.check("k", 10).unwrap();
234 + }
235 + }
236 +
237 + #[test]
238 + fn exceeding_limit_returns_too_many_requests() {
239 + let rl = RateLimitService::new();
240 + for _ in 0..3 {
241 + rl.check("k", 3).unwrap();
242 + }
243 + let err = rl.check("k", 3).unwrap_err();
244 + match err {
245 + AppError::TooManyRequests { retry_after_secs } => {
246 + assert!((1..=60).contains(&retry_after_secs));
247 + }
248 + other => panic!("expected TooManyRequests, got {other:?}"),
249 + }
250 + }
251 +
252 + #[test]
253 + fn distinct_keys_have_independent_budgets() {
254 + let rl = RateLimitService::new();
255 + for _ in 0..3 {
256 + rl.check("a", 3).unwrap();
257 + rl.check("b", 3).unwrap();
258 + }
259 + assert!(rl.check("a", 3).is_err());
260 + assert!(rl.check("b", 3).is_err());
261 + }
262 +
263 + #[test]
264 + fn window_expiry_releases_budget() {
265 + let rl = RateLimitService::new();
266 + let window = Duration::from_millis(50);
267 + rl.check_window("k", 1, window).unwrap();
268 + assert!(rl.check_window("k", 1, window).is_err());
269 +
270 + std::thread::sleep(Duration::from_millis(80));
271 + rl.check_window("k", 1, window)
272 + .expect("window should reset after expiry");
273 + }
274 +
275 + #[test]
276 + fn sliding_window_rejects_burst_across_boundary() {
277 + let rl = RateLimitService::new();
278 + let window = Duration::from_millis(100);
279 +
280 + // 3 attempts inside the window — OK.
281 + rl.check_sliding_window("k", 3, window).unwrap();
282 + rl.check_sliding_window("k", 3, window).unwrap();
283 + rl.check_sliding_window("k", 3, window).unwrap();
284 +
285 + // 4th attempt inside the same window — rejected.
286 + assert!(rl.check_sliding_window("k", 3, window).is_err());
287 +
288 + // After the oldest attempt expires, one slot is released.
289 + std::thread::sleep(Duration::from_millis(120));
290 + rl.check_sliding_window("k", 3, window)
291 + .expect("slot should free after oldest attempt ages out");
292 + }
293 +
294 + #[test]
295 + fn sliding_and_fixed_window_are_independent() {
296 + let rl = RateLimitService::new();
297 + rl.check("k", 1).unwrap();
298 + // Fixed window exhausted.
299 + assert!(rl.check("k", 1).is_err());
300 + // Sliding window is a separate keyspace.
301 + rl.check_sliding_window("k", 1, Duration::from_secs(60))
302 + .expect("sliding map is independent of fixed map");
303 + }
304 +
305 + #[test]
306 + fn check_window_respects_max_per_minute_equivalence() {
307 + // check(k, N) must behave identically to check_window(k, N, 60s).
308 + let a = RateLimitService::new();
309 + let b = RateLimitService::new();
310 + for _ in 0..4 {
311 + a.check("k", 4).unwrap();
312 + b.check_window("k", 4, Duration::from_secs(60)).unwrap();
313 + }
314 + assert!(a.check("k", 4).is_err());
315 + assert!(b.check_window("k", 4, Duration::from_secs(60)).is_err());
316 + }
317 +}
M crates/rustwarden-core/src/services/refresh_token_service.rs
+8 -0
@@ -21,18 +21,26 @@ use uuid::Uuid;
21 21 /// Active session information for display
22 22 #[derive(Debug, Clone, serde::Serialize)]
23 23 pub struct ActiveSession {
24 + /// Unique identifier of the refresh token / session.
24 25 pub id: Uuid,
26 + /// Optional device or user-agent description.
25 27 pub device_info: Option<String>,
28 + /// IP address from which the session was created.
26 29 pub ip_address: Option<String>,
30 + /// Timestamp of the last activity associated with this session.
27 31 pub last_activity: DateTime<Utc>,
32 + /// Timestamp when this session was originally created.
28 33 pub created_at: DateTime<Utc>,
34 + /// Whether this session corresponds to the currently authenticated request.
29 35 #[serde(default)]
30 36 pub is_current: bool,
31 37 }
32 38
33 39 /// Result of creating a refresh token: the DB model (with hash) + plain token for the client.
34 40 pub struct CreatedRefreshToken {
41 + /// The persisted refresh token model (stores the SHA-256 hash, not the plain token).
35 42 pub model: refresh_token::Model,
43 + /// The plaintext token to be sent to the client (never stored directly).
36 44 pub plain_token: String,
37 45 }
38 46
M crates/rustwarden-core/src/services/resource_service.rs
+421 -7
@@ -1,14 +1,28 @@
1 1 use crate::error::AppError;
2 2 use crate::models::{resource, resource_share};
3 +use crate::services::team_service::TeamService;
3 4 use chrono::Utc;
4 5 use sea_orm::{
5 - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
6 + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
7 + QueryOrder, Set,
6 8 };
7 9 use uuid::Uuid;
8 10
9 11 /// Permission levels for resource sharing (ordered by privilege).
10 12 const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"];
11 13
14 +/// A share target. Either an individual user or a team — permission
15 +/// resolution walks user-direct shares + every team the user belongs to and
16 +/// keeps the maximum level.
17 +#[derive(Debug, Clone, Copy, Eq, PartialEq)]
18 +pub enum Principal {
19 + /// An individual user identified by their UUID.
20 + User(Uuid),
21 + /// A team identified by its UUID.
22 + Team(Uuid),
23 +}
24 +
25 +/// Service for registering, querying, and sharing application resources.
12 26 pub struct ResourceService;
13 27
14 28 impl ResourceService {
@@ -151,7 +165,8 @@ impl ResourceService {
151 165 let share = resource_share::ActiveModel {
152 166 id: Set(Uuid::new_v4()),
153 167 resource_id: Set(entry.id),
154 - shared_with_user_id: Set(shared_with_user_id),
168 + shared_with_user_id: Set(Some(shared_with_user_id)),
169 + shared_with_team_id: Set(None),
155 170 permission_level: Set(permission_level.to_string()),
156 171 shared_by_user_id: Set(shared_by_user_id),
157 172 created_at: Set(Utc::now()),
@@ -160,11 +175,70 @@ impl ResourceService {
160 175 Ok(result)
161 176 }
162 177
163 - /// Revoke a share by its ID.
164 - pub async fn revoke_share(
178 + /// Share a resource with an arbitrary [`Principal`] (user or team).
179 + ///
180 + /// Generalises [`Self::share`] — the legacy helper stays as a thin wrapper
181 + /// so existing callers keep working. Uniqueness is enforced per
182 + /// `(resource_id, principal)` via the DB partial unique indexes:
183 + /// a second call updates the existing row's permission level.
184 + pub async fn share_with(
165 185 db: &DatabaseConnection,
166 - share_id: Uuid,
167 - ) -> Result<(), AppError> {
186 + resource_type: &str,
187 + resource_id: Uuid,
188 + principal: Principal,
189 + permission_level: &str,
190 + shared_by_user_id: Uuid,
191 + ) -> Result<resource_share::Model, AppError> {
192 + let entry = Self::find_by_type_and_id(db, resource_type, resource_id)
193 + .await?
194 + .ok_or_else(|| AppError::NotFound("Resource not found".into()))?;
195 +
196 + if let Principal::User(uid) = principal {
197 + if entry.owner_id == uid {
198 + return Err(AppError::Validation(
199 + "Cannot share a resource with its owner".into(),
200 + ));
201 + }
202 + }
203 +
204 + Self::validate_permission_level(permission_level)?;
205 +
206 + let (user_filter, team_filter): (Option<Uuid>, Option<Uuid>) = match principal {
207 + Principal::User(u) => (Some(u), None),
208 + Principal::Team(t) => (None, Some(t)),
209 + };
210 +
211 + let existing = resource_share::Entity::find()
212 + .filter(resource_share::Column::ResourceId.eq(entry.id))
213 + .filter(
214 + Condition::all()
215 + .add(resource_share::Column::SharedWithUserId.eq(user_filter))
216 + .add(resource_share::Column::SharedWithTeamId.eq(team_filter)),
217 + )
218 + .one(db)
219 + .await?;
220 +
221 + if let Some(existing) = existing {
222 + let mut active: resource_share::ActiveModel = existing.into();
223 + active.permission_level = Set(permission_level.to_string());
224 + active.shared_by_user_id = Set(shared_by_user_id);
225 + return Ok(active.update(db).await?);
226 + }
227 +
228 + let share = resource_share::ActiveModel {
229 + id: Set(Uuid::new_v4()),
230 + resource_id: Set(entry.id),
231 + shared_with_user_id: Set(user_filter),
232 + shared_with_team_id: Set(team_filter),
233 + permission_level: Set(permission_level.to_string()),
234 + shared_by_user_id: Set(shared_by_user_id),
235 + created_at: Set(Utc::now()),
236 + };
237 + Ok(share.insert(db).await?)
238 + }
239 +
240 + /// Revoke a share by its ID.
241 + pub async fn revoke_share(db: &DatabaseConnection, share_id: Uuid) -> Result<(), AppError> {
168 242 resource_share::Entity::delete_by_id(share_id)
169 243 .exec(db)
170 244 .await?;
@@ -285,6 +359,63 @@ impl ResourceService {
285 359 Ok(None)
286 360 }
287 361
362 + /// Compute the effective permission a user has on a resource, walking:
363 + /// 1. Ownership → `"owner"`.
364 + /// 2. Direct user share → that share's level.
365 + /// 3. Every team the user belongs to whose team-share targets the
366 + /// resource → take the maximum level across those.
367 + /// 4. Public flag → `"read"`.
368 + ///
369 + /// Returns `None` when the user has no access at all.
370 + ///
371 + /// Supersedes [`Self::effective_permission`]; the legacy fn remains for
372 + /// API compatibility but ignores team memberships.
373 + pub async fn effective_permission_for_user(
374 + db: &DatabaseConnection,
375 + user_id: Uuid,
376 + resource_type: &str,
377 + resource_id: Uuid,
378 + ) -> Result<Option<String>, AppError> {
379 + let entry = match Self::find_by_type_and_id(db, resource_type, resource_id).await? {
380 + Some(e) => e,
381 + None => return Ok(None),
382 + };
383 +
384 + if entry.owner_id == user_id {
385 + return Ok(Some("owner".to_string()));
386 + }
387 +
388 + // Single query pulls both "direct user" and "via team" shares in one
389 + // round-trip. The DB CHECK guarantees each row is exactly one flavour.
390 + let team_ids = TeamService::team_ids_of_user(db, user_id).await?;
391 + let mut condition =
392 + Condition::any().add(resource_share::Column::SharedWithUserId.eq(Some(user_id)));
393 + if !team_ids.is_empty() {
394 + condition = condition.add(resource_share::Column::SharedWithTeamId.is_in(team_ids));
395 + }
396 +
397 + let shares = resource_share::Entity::find()
398 + .filter(resource_share::Column::ResourceId.eq(entry.id))
399 + .filter(condition)
400 + .all(db)
401 + .await?;
402 +
403 + let best = shares
404 + .iter()
405 + .map(|s| Self::level_rank(&s.permission_level))
406 + .max();
407 +
408 + if let Some(rank) = best {
409 + return Ok(Some(PERMISSION_HIERARCHY[rank].to_string()));
410 + }
411 +
412 + if entry.is_public {
413 + return Ok(Some("read".to_string()));
414 + }
415 +
416 + Ok(None)
417 + }
418 +
288 419 /// List all resources of a given type that are shared with a user.
289 420 pub async fn shared_with_user(
290 421 db: &DatabaseConnection,
@@ -292,7 +423,7 @@ impl ResourceService {
292 423 resource_type: &str,
293 424 ) -> Result<Vec<(resource::Model, resource_share::Model)>, AppError> {
294 425 let shares = resource_share::Entity::find()
295 - .filter(resource_share::Column::SharedWithUserId.eq(user_id))
426 + .filter(resource_share::Column::SharedWithUserId.eq(Some(user_id)))
296 427 .all(db)
297 428 .await?;
298 429
@@ -375,11 +506,294 @@ impl ResourceService {
375 506 .unwrap_or(usize::MAX);
376 507 granted_idx >= required_idx
377 508 }
509 +
510 + /// Numeric rank of a permission level (higher = more privileged). Unknown
511 + /// levels fall back to 0 so they are never preferred over a known one.
512 + fn level_rank(level: &str) -> usize {
513 + PERMISSION_HIERARCHY
514 + .iter()
515 + .position(|&l| l == level)
516 + .unwrap_or(0)
517 + }
378 518 }
379 519
380 520 #[cfg(test)]
381 521 mod tests {
382 522 use super::*;
523 + use crate::services::password_service::PasswordService;
524 + use crate::services::team_service::{TeamRole, TeamService};
525 + use crate::services::user_service::UserService;
526 + use crate::types::TeamSlug;
527 +
528 + async fn test_db() -> DatabaseConnection {
529 + dotenvy::dotenv().ok();
530 + let url = std::env::var("DATABASE_URL")
531 + .expect("DATABASE_URL required for resource_service tests");
532 + sea_orm::Database::connect(&url).await.unwrap()
533 + }
534 +
535 + async fn fresh_user(db: &DatabaseConnection) -> Uuid {
536 + let unique = Uuid::new_v4().simple().to_string();
537 + let hash = PasswordService::hash_password("IrrelevantForTests1!")
538 + .await
539 + .unwrap();
540 + UserService::create_user(
541 + db,
542 + format!("res_test_{}", &unique[..12]),
543 + format!("res_test_{}@example.com", &unique[..12]),
544 + hash,
545 + false,
546 + )
547 + .await
548 + .unwrap()
549 + .id
550 + }
551 +
552 + fn fresh_slug() -> TeamSlug {
553 + let raw = format!("rs-{}", &Uuid::new_v4().simple().to_string()[..10]);
554 + TeamSlug::parse(&raw).unwrap()
555 + }
556 +
557 + async fn register_resource(db: &DatabaseConnection, owner: Uuid) -> Uuid {
558 + let rid = Uuid::new_v4();
559 + ResourceService::register(db, "test_resource", rid, owner)
560 + .await
561 + .unwrap();
562 + rid
563 + }
564 +
565 + #[tokio::test]
566 + async fn share_with_user_principal_is_backwards_compatible() {
567 + let db = test_db().await;
568 + let owner = fresh_user(&db).await;
569 + let alice = fresh_user(&db).await;
570 + let rid = register_resource(&db, owner).await;
571 +
572 + ResourceService::share_with(
573 + &db,
574 + "test_resource",
575 + rid,
576 + Principal::User(alice),
577 + "write",
578 + owner,
579 + )
580 + .await
581 + .unwrap();
582 +
583 + let perm = ResourceService::effective_permission_for_user(&db, alice, "test_resource", rid)
584 + .await
585 + .unwrap();
586 + assert_eq!(perm.as_deref(), Some("write"));
587 + }
588 +
589 + #[tokio::test]
590 + async fn share_with_user_refuses_sharing_back_to_owner() {
591 + let db = test_db().await;
592 + let owner = fresh_user(&db).await;
593 + let rid = register_resource(&db, owner).await;
594 + let err = ResourceService::share_with(
595 + &db,
596 + "test_resource",
597 + rid,
598 + Principal::User(owner),
599 + "read",
600 + owner,
601 + )
602 + .await
603 + .unwrap_err();
604 + assert!(matches!(err, AppError::Validation(_)));
605 + }
606 +
607 + #[tokio::test]
608 + async fn share_with_team_grants_access_to_every_member() {
609 + let db = test_db().await;
610 + let owner = fresh_user(&db).await;
611 + let alice = fresh_user(&db).await;
612 + let bob = fresh_user(&db).await;
613 + let team = TeamService::create(&db, owner, fresh_slug(), "Devs", None)
614 + .await
615 + .unwrap();
616 + TeamService::add_member(&db, team.id, alice, TeamRole::Member, None)
617 + .await
618 + .unwrap();
619 + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None)
620 + .await
621 + .unwrap();
622 +
623 + let rid = register_resource(&db, owner).await;
624 + ResourceService::share_with(
625 + &db,
626 + "test_resource",
627 + rid,
628 + Principal::Team(team.id),
629 + "read",
630 + owner,
631 + )
632 + .await
633 + .unwrap();
634 +
635 + let alice_perm =
636 + ResourceService::effective_permission_for_user(&db, alice, "test_resource", rid)
637 + .await
638 + .unwrap();
639 + let bob_perm =
640 + ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid)
641 + .await
642 + .unwrap();
643 + assert_eq!(alice_perm.as_deref(), Some("read"));
644 + assert_eq!(bob_perm.as_deref(), Some("read"));
645 + }
646 +
647 + #[tokio::test]
648 + async fn effective_permission_takes_max_across_user_and_teams() {
649 + let db = test_db().await;
650 + let owner = fresh_user(&db).await;
651 + let bob = fresh_user(&db).await;
652 + let team_read = TeamService::create(&db, owner, fresh_slug(), "Read", None)
653 + .await
654 + .unwrap();
655 + let team_admin = TeamService::create(&db, owner, fresh_slug(), "Admin", None)
656 + .await
657 + .unwrap();
658 + TeamService::add_member(&db, team_read.id, bob, TeamRole::Member, None)
659 + .await
660 + .unwrap();
661 + TeamService::add_member(&db, team_admin.id, bob, TeamRole::Member, None)
662 + .await
663 + .unwrap();
664 +
665 + let rid = register_resource(&db, owner).await;
666 + // Bob gets "write" directly.
667 + ResourceService::share_with(
668 + &db,
669 + "test_resource",
670 + rid,
671 + Principal::User(bob),
672 + "write",
673 + owner,
674 + )
675 + .await
676 + .unwrap();
677 + // "Read" team only has read.
678 + ResourceService::share_with(
679 + &db,
680 + "test_resource",
681 + rid,
682 + Principal::Team(team_read.id),
683 + "read",
684 + owner,
685 + )
686 + .await
687 + .unwrap();
688 + // "Admin" team has admin → should win.
689 + ResourceService::share_with(
690 + &db,
691 + "test_resource",
692 + rid,
693 + Principal::Team(team_admin.id),
694 + "admin",
695 + owner,
696 + )
697 + .await
698 + .unwrap();
699 +
700 + let perm = ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid)
701 + .await
702 + .unwrap();
703 + assert_eq!(perm.as_deref(), Some("admin"));
704 + }
705 +
706 + #[tokio::test]
707 + async fn removing_user_from_team_drops_team_based_access() {
708 + let db = test_db().await;
709 + let owner = fresh_user(&db).await;
710 + let bob = fresh_user(&db).await;
711 + let team = TeamService::create(&db, owner, fresh_slug(), "Backend", None)
712 + .await
713 + .unwrap();
714 + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None)
715 + .await
716 + .unwrap();
717 +
718 + let rid = register_resource(&db, owner).await;
719 + ResourceService::share_with(
720 + &db,
721 + "test_resource",
722 + rid,
723 + Principal::Team(team.id),
724 + "write",
725 + owner,
726 + )
727 + .await
728 + .unwrap();
729 +
730 + // Sanity: bob has write via team.
731 + assert_eq!(
732 + ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid)
733 + .await
734 + .unwrap()
735 + .as_deref(),
736 + Some("write")
737 + );
738 +
739 + TeamService::remove_member(&db, team.id, bob, None)
740 + .await
741 + .unwrap();
742 +
743 + // After removal bob has no access.
744 + assert_eq!(
745 + ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid)
746 + .await
747 + .unwrap(),
748 + None
749 + );
750 + }
751 +
752 + #[tokio::test]
753 + async fn share_with_team_is_idempotent_and_updates_level() {
754 + let db = test_db().await;
755 + let owner = fresh_user(&db).await;
756 + let bob = fresh_user(&db).await;
757 + let team = TeamService::create(&db, owner, fresh_slug(), "T", None)
758 + .await
759 + .unwrap();
760 + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None)
761 + .await
762 + .unwrap();
763 + let rid = register_resource(&db, owner).await;
764 +
765 + ResourceService::share_with(
766 + &db,
767 + "test_resource",
768 + rid,
769 + Principal::Team(team.id),
770 + "read",
771 + owner,
772 + )
773 + .await
774 + .unwrap();
775 + ResourceService::share_with(
776 + &db,
777 + "test_resource",
778 + rid,
779 + Principal::Team(team.id),
780 + "write",
781 + owner,
782 + )
783 + .await
784 + .unwrap();
785 +
786 + // Exactly one share row — second call upserted.
787 + let shares = ResourceService::list_shares(&db, "test_resource", rid)
788 + .await
789 + .unwrap();
790 + let team_shares: Vec<_> = shares
791 + .iter()
792 + .filter(|s| s.shared_with_team_id == Some(team.id))
793 + .collect();
794 + assert_eq!(team_shares.len(), 1);
795 + assert_eq!(team_shares[0].permission_level, "write");
796 + }
383 797
384 798 #[test]
385 799 fn test_level_ge() {
M crates/rustwarden-core/src/services/role_service.rs
+4 -0
@@ -139,11 +139,13 @@ impl RoleService {
139 139 Ok((admin_role, user_role, app_manager_role))
140 140 }
141 141
142 + /// Return all roles stored in the database.
142 143 pub async fn list_all_roles(db: &DatabaseConnection) -> Result<Vec<role::Model>, AppError> {
143 144 let roles = role::Entity::find().all(db).await?;
144 145 Ok(roles)
145 146 }
146 147
148 + /// Return a single role by its UUID, or `None` if not found.
147 149 pub async fn get_role_by_id(
148 150 db: &DatabaseConnection,
149 151 id: Uuid,
@@ -152,6 +154,7 @@ impl RoleService {
152 154 Ok(role)
153 155 }
154 156
157 + /// Update the name and/or description of an existing role.
155 158 pub async fn update_role(
156 159 db: &DatabaseConnection,
157 160 id: Uuid,
@@ -174,6 +177,7 @@ impl RoleService {
174 177 Ok(updated)
175 178 }
176 179
180 + /// Delete a role by its UUID. Returns an error if the role is protected (`admin` or `user`).
177 181 pub async fn delete_role(db: &DatabaseConnection, id: Uuid) -> Result<(), AppError> {
178 182 let existing = Self::get_role_by_id(db, id)
179 183 .await?
A crates/rustwarden-core/src/services/team_service.rs
+522 -0
@@ -0,0 +1,522 @@
1 +//! Team service.
2 +//!
3 +//! CRUD + membership management for [`teams`](crate::models::team) and
4 +//! [`team_members`](crate::models::team_member). Integrates with
5 +//! [`RustwardenHooks`](crate::hooks::RustwardenHooks) (on_team_* events) so
6 +//! consumer apps can mirror team state into their own indices.
7 +//!
8 +//! Teams are the non-user principal accepted by
9 +//! [`ResourceService::share_with`](crate::services::resource_service::ResourceService::share_with).
10 +//! Resolving effective permissions across users + teams is handled by
11 +//! [`ResourceService::effective_permission_for_user`](crate::services::resource_service::ResourceService::effective_permission_for_user).
12 +
13 +use crate::error::AppError;
14 +use crate::hooks::RustwardenHooks;
15 +use crate::models::{team, team_member};
16 +use crate::types::TeamSlug;
17 +use chrono::Utc;
18 +use sea_orm::{
19 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
20 +};
21 +use std::sync::Arc;
22 +use uuid::Uuid;
23 +
24 +/// Allowed membership roles inside a team.
25 +///
26 +/// `Owner` duplicates the `team.owner_id` column for convenience: the creator
27 +/// is inserted as an owner member at creation time so `teams_of_user` works
28 +/// without a `UNION` join.
29 +#[derive(Debug, Clone, Copy, Eq, PartialEq)]
30 +pub enum TeamRole {
31 + /// The user who created the team; has full control.
32 + Owner,
33 + /// A member with elevated privileges who can manage membership.
34 + Admin,
35 + /// A regular team member with read-only access to team resources.
36 + Member,
37 +}
38 +
39 +impl TeamRole {
40 + /// Return the lowercase string representation of this role.
41 + pub fn as_str(&self) -> &'static str {
42 + match self {
43 + TeamRole::Owner => "owner",
44 + TeamRole::Admin => "admin",
45 + TeamRole::Member => "member",
46 + }
47 + }
48 +
49 + /// Parse a raw string into a `TeamRole`, returning `None` for unrecognised values.
50 + pub fn parse(raw: &str) -> Option<Self> {
51 + match raw {
52 + "owner" => Some(Self::Owner),
53 + "admin" => Some(Self::Admin),
54 + "member" => Some(Self::Member),
55 + _ => None,
56 + }
57 + }
58 +}
59 +
60 +/// Service for creating and managing teams and their memberships.
61 +pub struct TeamService;
62 +
63 +impl TeamService {
64 + /// Create a team and insert the owner as the first (owner-role) member.
65 + ///
66 + /// Errors:
67 + /// - `AppError::Validation` if `name` is empty.
68 + /// - `AppError::Database` on unique constraint violation (slug collision).
69 + pub async fn create(
70 + db: &DatabaseConnection,
71 + owner_id: Uuid,
72 + slug: TeamSlug,
73 + name: &str,
74 + hooks: Option<&Arc<dyn RustwardenHooks>>,
75 + ) -> Result<team::Model, AppError> {
76 + let name = name.trim();
77 + if name.is_empty() || name.len() > 128 {
78 + return Err(AppError::Validation(
79 + "Team name must be 1-128 characters".into(),
80 + ));
81 + }
82 +
83 + let team_id = Uuid::new_v4();
84 + let record = team::ActiveModel {
85 + id: Set(team_id),
86 + slug: Set(slug.into_inner()),
87 + name: Set(name.to_string()),
88 + owner_id: Set(owner_id),
89 + created_at: Set(Utc::now()),
90 + }
91 + .insert(db)
92 + .await?;
93 +
94 + team_member::ActiveModel {
95 + team_id: Set(team_id),
96 + user_id: Set(owner_id),
97 + role: Set(TeamRole::Owner.as_str().to_string()),
98 + joined_at: Set(Utc::now()),
99 + }
100 + .insert(db)
101 + .await?;
102 +
103 + if let Some(hooks) = hooks {
104 + if let Err(e) = hooks.on_team_created(db, team_id, owner_id).await {
105 + tracing::warn!(team_id = %team_id, "on_team_created hook failed: {}", e);
106 + }
107 + }
108 +
109 + Ok(record)
110 + }
111 +
112 + /// Look up a team by its id.
113 + pub async fn find_by_id(
114 + db: &DatabaseConnection,
115 + team_id: Uuid,
116 + ) -> Result<Option<team::Model>, AppError> {
117 + Ok(team::Entity::find_by_id(team_id).one(db).await?)
118 + }
119 +
120 + /// Look up a team by its unique slug.
121 + pub async fn find_by_slug(
122 + db: &DatabaseConnection,
123 + slug: &TeamSlug,
124 + ) -> Result<Option<team::Model>, AppError> {
125 + Ok(team::Entity::find()
126 + .filter(team::Column::Slug.eq(slug.as_str()))
127 + .one(db)
128 + .await?)
129 + }
130 +
131 + /// Add a member to a team. Idempotent on `(team_id, user_id)` — updates
132 + /// the role if the edge already exists.
133 + pub async fn add_member(
134 + db: &DatabaseConnection,
135 + team_id: Uuid,
136 + user_id: Uuid,
137 + role: TeamRole,
138 + hooks: Option<&Arc<dyn RustwardenHooks>>,
139 + ) -> Result<team_member::Model, AppError> {
140 + // Upsert via find-then-update/insert (composite PK makes
141 + // ActiveModel::insert conflict-safe to catch here).
142 + let existing = team_member::Entity::find_by_id((team_id, user_id))
143 + .one(db)
144 + .await?;
145 +
146 + let record = if let Some(existing) = existing {
147 + let mut active: team_member::ActiveModel = existing.into();
148 + active.role = Set(role.as_str().to_string());
149 + active.update(db).await?
150 + } else {
151 + team_member::ActiveModel {
152 + team_id: Set(team_id),
153 + user_id: Set(user_id),
154 + role: Set(role.as_str().to_string()),
155 + joined_at: Set(Utc::now()),
156 + }
157 + .insert(db)
158 + .await?
159 + };
160 +
161 + if let Some(hooks) = hooks {
162 + if let Err(e) = hooks.on_team_member_added(db, team_id, user_id).await {
163 + tracing::warn!(team_id = %team_id, user_id = %user_id, "on_team_member_added hook failed: {}", e);
164 + }
165 + }
166 +
167 + Ok(record)
168 + }
169 +
170 + /// Remove a member from a team. Refuses to remove the team owner — use
171 + /// `delete` to drop the whole team instead.
172 + pub async fn remove_member(
173 + db: &DatabaseConnection,
174 + team_id: Uuid,
175 + user_id: Uuid,
176 + hooks: Option<&Arc<dyn RustwardenHooks>>,
177 + ) -> Result<(), AppError> {
178 + let team = Self::find_by_id(db, team_id)
179 + .await?
180 + .ok_or_else(|| AppError::NotFound(format!("Team {team_id}")))?;
181 + if team.owner_id == user_id {
182 + return Err(AppError::Validation(
183 + "Team owner cannot be removed; delete the team instead".into(),
184 + ));
185 + }
186 +
187 + team_member::Entity::delete_by_id((team_id, user_id))
188 + .exec(db)
189 + .await?;
190 +
191 + if let Some(hooks) = hooks {
192 + if let Err(e) = hooks.on_team_member_removed(db, team_id, user_id).await {
193 + tracing::warn!(team_id = %team_id, user_id = %user_id, "on_team_member_removed hook failed: {}", e);
194 + }
195 + }
196 +
197 + Ok(())
198 + }
199 +
200 + /// List every (user, role) pair for the given team, most-recent-join first.
201 + pub async fn list_members(
202 + db: &DatabaseConnection,
203 + team_id: Uuid,
204 + ) -> Result<Vec<team_member::Model>, AppError> {
205 + Ok(team_member::Entity::find()
206 + .filter(team_member::Column::TeamId.eq(team_id))
207 + .order_by_desc(team_member::Column::JoinedAt)
208 + .all(db)
209 + .await?)
210 + }
211 +
212 + /// List every team the user belongs to.
213 + pub async fn teams_of_user(
214 + db: &DatabaseConnection,
215 + user_id: Uuid,
216 + ) -> Result<Vec<team::Model>, AppError> {
217 + use sea_orm::{JoinType, QuerySelect, RelationTrait};
218 + let teams = team::Entity::find()
219 + .join_rev(JoinType::InnerJoin, team_member::Relation::Team.def())
220 + .filter(team_member::Column::UserId.eq(user_id))
221 + .order_by_desc(team::Column::CreatedAt)
222 + .all(db)
223 + .await?;
224 + Ok(teams)
225 + }
226 +
227 + /// Return every `team_id` the user belongs to. Cheap lookup used by
228 + /// permission resolution.
229 + pub async fn team_ids_of_user(
230 + db: &DatabaseConnection,
231 + user_id: Uuid,
232 + ) -> Result<Vec<Uuid>, AppError> {
233 + let rows = team_member::Entity::find()
234 + .filter(team_member::Column::UserId.eq(user_id))
235 + .all(db)
236 + .await?;
237 + Ok(rows.into_iter().map(|r| r.team_id).collect())
238 + }
239 +
240 + /// Hard-delete a team. Cascade drops `team_members` (composite FK) and
241 + /// `resource_shares` where the team was the principal (FK on
242 + /// `shared_with_team_id`).
243 + pub async fn delete(
244 + db: &DatabaseConnection,
245 + team_id: Uuid,
246 + hooks: Option<&Arc<dyn RustwardenHooks>>,
247 + ) -> Result<(), AppError> {
248 + let existed = team::Entity::find_by_id(team_id).one(db).await?.is_some();
249 + team::Entity::delete_by_id(team_id).exec(db).await?;
250 +
251 + if existed {
252 + if let Some(hooks) = hooks {
253 + if let Err(e) = hooks.on_team_deleted(db, team_id).await {
254 + tracing::warn!(team_id = %team_id, "on_team_deleted hook failed: {}", e);
255 + }
256 + }
257 + }
258 +
259 + Ok(())
260 + }
261 +}
262 +
263 +#[cfg(test)]
264 +mod tests {
265 + use super::*;
266 + use crate::hooks::{AuditEvent, RustwardenHooks};
267 + use crate::services::password_service::PasswordService;
268 + use crate::services::user_service::UserService;
269 + use async_trait::async_trait;
270 + use std::sync::Mutex;
271 +
272 + async fn test_db() -> DatabaseConnection {
273 + dotenvy::dotenv().ok();
274 + let url =
275 + std::env::var("DATABASE_URL").expect("DATABASE_URL required for team_service tests");
276 + sea_orm::Database::connect(&url).await.unwrap()
277 + }
278 +
279 + async fn fresh_user(db: &DatabaseConnection) -> Uuid {
280 + let unique = Uuid::new_v4().simple().to_string();
281 + let hash = PasswordService::hash_password("IrrelevantForTests1!")
282 + .await
283 + .unwrap();
284 + UserService::create_user(
285 + db,
286 + format!("team_test_{}", &unique[..12]),
287 + format!("team_test_{}@example.com", &unique[..12]),
288 + hash,
289 + false,
290 + )
291 + .await
292 + .unwrap()
293 + .id
294 + }
295 +
296 + fn fresh_slug() -> TeamSlug {
297 + let raw = format!("t-{}", &Uuid::new_v4().simple().to_string()[..10]);
298 + TeamSlug::parse(&raw).unwrap()
299 + }
300 +
301 + #[derive(Default, Clone)]
302 + struct RecordingHooks(Arc<Mutex<Vec<String>>>);
303 +
304 + #[async_trait]
305 + impl RustwardenHooks for RecordingHooks {
306 + async fn on_team_created(
307 + &self,
308 + _db: &DatabaseConnection,
309 + team_id: Uuid,
310 + owner_id: Uuid,
311 + ) -> anyhow::Result<()> {
312 + self.0
313 + .lock()
314 + .unwrap()
315 + .push(format!("created:{team_id}:{owner_id}"));
316 + Ok(())
317 + }
318 + async fn on_team_deleted(
319 + &self,
320 + _db: &DatabaseConnection,
321 + team_id: Uuid,
322 + ) -> anyhow::Result<()> {
323 + self.0.lock().unwrap().push(format!("deleted:{team_id}"));
324 + Ok(())
325 + }
326 + async fn on_team_member_added(
327 + &self,
328 + _db: &DatabaseConnection,
329 + team_id: Uuid,
330 + user_id: Uuid,
331 + ) -> anyhow::Result<()> {
332 + self.0
333 + .lock()
334 + .unwrap()
335 + .push(format!("added:{team_id}:{user_id}"));
336 + Ok(())
337 + }
338 + async fn on_team_member_removed(
339 + &self,
340 + _db: &DatabaseConnection,
341 + team_id: Uuid,
342 + user_id: Uuid,
343 + ) -> anyhow::Result<()> {
344 + self.0
345 + .lock()
346 + .unwrap()
347 + .push(format!("removed:{team_id}:{user_id}"));
348 + Ok(())
349 + }
350 + async fn on_audit_event(
351 + &self,
352 + _db: &DatabaseConnection,
353 + _event: AuditEvent<'_>,
354 + ) -> anyhow::Result<()> {
355 + Ok(())
356 + }
357 + }
358 +
359 + #[tokio::test]
360 + async fn create_team_inserts_owner_as_first_member() {
361 + let db = test_db().await;
362 + let owner = fresh_user(&db).await;
363 + let team = TeamService::create(&db, owner, fresh_slug(), "Backend", None)
364 + .await
365 + .unwrap();
366 +
367 + let members = TeamService::list_members(&db, team.id).await.unwrap();
368 + assert_eq!(members.len(), 1);
369 + assert_eq!(members[0].user_id, owner);
370 + assert_eq!(members[0].role, "owner");
371 + }
372 +
373 + #[tokio::test]
374 + async fn create_rejects_empty_name() {
375 + let db = test_db().await;
376 + let owner = fresh_user(&db).await;
377 + let err = TeamService::create(&db, owner, fresh_slug(), " ", None)
378 + .await
379 + .unwrap_err();
380 + assert!(matches!(err, AppError::Validation(_)));
381 + }
382 +
383 + #[tokio::test]
384 + async fn add_member_is_idempotent_and_updates_role() {
385 + let db = test_db().await;
386 + let owner = fresh_user(&db).await;
387 + let alice = fresh_user(&db).await;
388 + let team = TeamService::create(&db, owner, fresh_slug(), "T", None)
389 + .await
390 + .unwrap();
391 +
392 + TeamService::add_member(&db, team.id, alice, TeamRole::Member, None)
393 + .await
394 + .unwrap();
395 + TeamService::add_member(&db, team.id, alice, TeamRole::Admin, None)
396 + .await
397 + .unwrap();
398 +
399 + let members = TeamService::list_members(&db, team.id).await.unwrap();
400 + let alice_row = members.iter().find(|m| m.user_id == alice).unwrap();
401 + assert_eq!(alice_row.role, "admin");
402 + // No duplicate.
403 + assert_eq!(
404 + members.iter().filter(|m| m.user_id == alice).count(),
405 + 1,
406 + "add_member must not create duplicates"
407 + );
408 + }
409 +
410 + #[tokio::test]
411 + async fn remove_member_refuses_to_remove_owner() {
412 + let db = test_db().await;
413 + let owner = fresh_user(&db).await;
414 + let team = TeamService::create(&db, owner, fresh_slug(), "T", None)
415 + .await
416 + .unwrap();
417 + let err = TeamService::remove_member(&db, team.id, owner, None)
418 + .await
419 + .unwrap_err();
420 + assert!(matches!(err, AppError::Validation(_)));
421 + }
422 +
423 + #[tokio::test]
424 + async fn remove_member_drops_the_edge() {
425 + let db = test_db().await;
426 + let owner = fresh_user(&db).await;
427 + let bob = fresh_user(&db).await;
428 + let team = TeamService::create(&db, owner, fresh_slug(), "T", None)
429 + .await
430 + .unwrap();
431 +
432 + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None)
433 + .await
434 + .unwrap();
435 + TeamService::remove_member(&db, team.id, bob, None)
436 + .await
437 + .unwrap();
438 +
439 + let members = TeamService::list_members(&db, team.id).await.unwrap();
440 + assert!(members.iter().all(|m| m.user_id != bob));
441 + }
442 +
443 + #[tokio::test]
444 + async fn teams_of_user_returns_membership_teams() {
445 + let db = test_db().await;
446 + let owner = fresh_user(&db).await;
447 + let bob = fresh_user(&db).await;
448 + let t1 = TeamService::create(&db, owner, fresh_slug(), "A", None)
449 + .await
450 + .unwrap();
451 + let t2 = TeamService::create(&db, owner, fresh_slug(), "B", None)
452 + .await
453 + .unwrap();
454 + TeamService::add_member(&db, t1.id, bob, TeamRole::Member, None)
455 + .await
456 + .unwrap();
457 +
458 + let bobs_teams = TeamService::teams_of_user(&db, bob).await.unwrap();
459 + assert_eq!(bobs_teams.len(), 1);
460 + assert_eq!(bobs_teams[0].id, t1.id);
461 +
462 + let owners_teams = TeamService::teams_of_user(&db, owner).await.unwrap();
463 + let ids: Vec<Uuid> = owners_teams.iter().map(|t| t.id).collect();
464 + assert!(ids.contains(&t1.id));
465 + assert!(ids.contains(&t2.id));
466 + }
467 +
468 + #[tokio::test]
469 + async fn delete_cascades_members() {
470 + let db = test_db().await;
471 + let owner = fresh_user(&db).await;
472 + let bob = fresh_user(&db).await;
473 + let team = TeamService::create(&db, owner, fresh_slug(), "T", None)
474 + .await
475 + .unwrap();
476 + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None)
477 + .await
478 + .unwrap();
479 +
480 + TeamService::delete(&db, team.id, None).await.unwrap();
481 +
482 + assert!(TeamService::find_by_id(&db, team.id)
483 + .await
484 + .unwrap()
485 + .is_none());
486 + let remaining = team_member::Entity::find()
487 + .filter(team_member::Column::TeamId.eq(team.id))
488 + .all(&db)
489 + .await
490 + .unwrap();
491 + assert!(remaining.is_empty(), "cascade must clear team_members");
492 + }
493 +
494 + #[tokio::test]
495 + async fn hooks_fire_for_create_add_remove_delete() {
496 + let db = test_db().await;
497 + let owner = fresh_user(&db).await;
498 + let bob = fresh_user(&db).await;
499 + let recording = RecordingHooks::default();
500 + let hooks: Arc<dyn RustwardenHooks> = Arc::new(recording.clone());
501 +
502 + let team = TeamService::create(&db, owner, fresh_slug(), "Observed", Some(&hooks))
503 + .await
504 + .unwrap();
505 + TeamService::add_member(&db, team.id, bob, TeamRole::Member, Some(&hooks))
506 + .await
507 + .unwrap();
508 + TeamService::remove_member(&db, team.id, bob, Some(&hooks))
509 + .await
510 + .unwrap();
511 + TeamService::delete(&db, team.id, Some(&hooks))
512 + .await
513 + .unwrap();
514 +
515 + let events = recording.0.lock().unwrap().clone();
516 + assert_eq!(events.len(), 4, "4 hook events expected, got: {events:?}");
517 + assert!(events[0].starts_with("created:"));
518 + assert!(events[1].starts_with("added:"));
519 + assert!(events[2].starts_with("removed:"));
520 + assert!(events[3].starts_with("deleted:"));
521 + }
522 +}
A crates/rustwarden-core/src/services/totp_service.rs
+518 -0
@@ -0,0 +1,518 @@
1 +//! TOTP two-factor authentication service
2 +//!
3 +//! Provides methods for generating, verifying, and managing TOTP 2FA
4 +//! including backup codes and login challenge flow.
5 +
6 +use chrono::{Duration, Utc};
7 +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
8 +use totp_rs::{Algorithm, Secret, TOTP};
9 +use uuid::Uuid;
10 +
11 +use crate::error::AppError;
12 +use crate::models::{totp_challenge, user_totp};
13 +use crate::services::crypto_service::CryptoService;
14 +
15 +const BACKUP_CODE_COUNT: usize = 10;
16 +const BACKUP_CODE_LENGTH: usize = 8;
17 +const CHALLENGE_EXPIRY_MINUTES: i64 = 5;
18 +const MAX_CHALLENGE_ATTEMPTS: i32 = 5;
19 +
20 +/// Response from TOTP setup containing data needed to display QR code
21 +pub struct TotpSetupResponse {
22 + /// Base32-encoded secret for manual entry
23 + pub secret_base32: String,
24 + /// otpauth:// URI for QR code generation
25 + pub otpauth_uri: String,
26 + /// QR code as base64-encoded PNG data URI
27 + pub qr_code_data_uri: String,
28 +}
29 +
30 +/// TOTP two-factor authentication service
31 +pub struct TotpService;
32 +
33 +impl TotpService {
34 + /// Generate a new TOTP secret and create a setup row (enabled=false).
35 + ///
36 + /// Returns setup data including QR code for the authenticator app.
37 + /// The user must verify with a valid code before 2FA is enabled.
38 + pub async fn setup_totp(
39 + db: &DatabaseConnection,
40 + user_id: Uuid,
41 + username: &str,
42 + issuer: &str,
43 + ) -> Result<TotpSetupResponse, AppError> {
44 + // Check if TOTP is already enabled
45 + if let Some(existing) = user_totp::Entity::find()
46 + .filter(user_totp::Column::UserId.eq(user_id))
47 + .one(db)
48 + .await?
49 + {
50 + if existing.enabled {
51 + return Err(AppError::TotpAlreadyEnabled);
52 + }
53 + // Delete incomplete setup to start fresh
54 + let model: user_totp::ActiveModel = existing.into();
55 + model.delete(db).await?;
56 + }
57 +
58 + // Generate TOTP secret
59 + let secret = Secret::generate_secret();
60 + let secret_base32 = secret.to_encoded().to_string();
61 +
62 + let totp = TOTP::new(
63 + Algorithm::SHA1,
64 + 6,
65 + 1,
66 + 30,
67 + secret
68 + .to_bytes()
69 + .map_err(|e| AppError::Internal(format!("Failed to convert TOTP secret: {}", e)))?,
70 + Some(issuer.to_string()),
71 + username.to_string(),
72 + )
73 + .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?;
74 +
75 + let otpauth_uri = totp.get_url();
76 +
77 + // Generate QR code as base64 PNG data URI
78 + let qr_code_data_uri = totp
79 + .get_qr_base64()
80 + .map_err(|e| AppError::Internal(format!("Failed to generate QR code: {}", e)))?;
81 + let qr_code_data_uri = format!("data:image/png;base64,{}", qr_code_data_uri);
82 +
83 + // Encrypt secret before storage
84 + let secret_encrypted = CryptoService::encrypt(&secret_base32)?;
85 +
86 + // Create user_totp row with enabled=false
87 + let now = Utc::now();
88 + let model = user_totp::ActiveModel {
89 + id: Set(Uuid::new_v4()),
90 + user_id: Set(user_id),
91 + secret_encrypted: Set(secret_encrypted),
92 + enabled: Set(false),
93 + backup_codes_encrypted: Set(None),
94 + backup_codes_remaining: Set(0),
95 + enabled_at: Set(None),
96 + created_at: Set(now),
97 + updated_at: Set(now),
98 + };
99 + model.insert(db).await?;
100 +
101 + Ok(TotpSetupResponse {
102 + secret_base32,
103 + otpauth_uri,
104 + qr_code_data_uri,
105 + })
106 + }
107 +
108 + /// Verify a TOTP code during setup and enable 2FA.
109 + ///
110 + /// On success, generates backup codes and returns them as plaintext (one-time display).
111 + pub async fn verify_and_enable(
112 + db: &DatabaseConnection,
113 + user_id: Uuid,
114 + code: &str,
115 + ) -> Result<Vec<String>, AppError> {
116 + let record = user_totp::Entity::find()
117 + .filter(user_totp::Column::UserId.eq(user_id))
118 + .one(db)
119 + .await?
120 + .ok_or_else(|| AppError::NotFound("TOTP setup not found".to_string()))?;
121 +
122 + if record.enabled {
123 + return Err(AppError::TotpAlreadyEnabled);
124 + }
125 +
126 + // Decrypt secret and verify code
127 + let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?;
128 + if !Self::verify_totp_code(&secret_base32, code)? {
129 + return Err(AppError::TotpFailed);
130 + }
131 +
132 + // Generate backup codes
133 + let (plaintext_codes, encrypted_codes) = Self::generate_backup_codes_internal()?;
134 +
135 + // Enable TOTP
136 + let now = Utc::now();
137 + let mut model: user_totp::ActiveModel = record.into();
138 + model.enabled = Set(true);
139 + model.enabled_at = Set(Some(now));
140 + model.backup_codes_encrypted = Set(Some(encrypted_codes));
141 + model.backup_codes_remaining = Set(BACKUP_CODE_COUNT as i32);
142 + model.updated_at = Set(now);
143 + model.update(db).await?;
144 +
145 + Ok(plaintext_codes)
146 + }
147 +
148 + /// Verify a TOTP code for an enabled user (login flow).
149 + pub async fn verify_code(
150 + db: &DatabaseConnection,
151 + user_id: Uuid,
152 + code: &str,
153 + ) -> Result<bool, AppError> {
154 + let record = user_totp::Entity::find()
155 + .filter(user_totp::Column::UserId.eq(user_id))
156 + .filter(user_totp::Column::Enabled.eq(true))
157 + .one(db)
158 + .await?
159 + .ok_or_else(|| AppError::TotpNotEnabled)?;
160 +
161 + let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?;
162 + Self::verify_totp_code(&secret_base32, code)
163 + }
164 +
165 + /// Verify and consume a backup code.
166 + ///
167 + /// Returns true if the code was valid and consumed.
168 + pub async fn verify_backup_code(
169 + db: &DatabaseConnection,
170 + user_id: Uuid,
171 + code: &str,
172 + ) -> Result<bool, AppError> {
173 + let record = user_totp::Entity::find()
174 + .filter(user_totp::Column::UserId.eq(user_id))
175 + .filter(user_totp::Column::Enabled.eq(true))
176 + .one(db)
177 + .await?
178 + .ok_or_else(|| AppError::TotpNotEnabled)?;
179 +
180 + let encrypted_codes = match &record.backup_codes_encrypted {
181 + Some(c) => c.clone(),
182 + None => return Ok(false),
183 + };
184 +
185 + // Decrypt the JSON array of hashed codes
186 + let codes_json = CryptoService::decrypt(&encrypted_codes)?;
187 + let mut hashed_codes: Vec<String> = serde_json::from_str(&codes_json)
188 + .map_err(|e| AppError::Internal(format!("Failed to parse backup codes: {}", e)))?;
189 +
190 + // Find matching code
191 + let mut found_index = None;
192 + for (i, hashed) in hashed_codes.iter().enumerate() {
193 + if bcrypt::verify(code, hashed).unwrap_or(false) {
194 + found_index = Some(i);
195 + break;
196 + }
197 + }
198 +
199 + let Some(index) = found_index else {
200 + return Ok(false);
201 + };
202 +
203 + // Remove used code
204 + hashed_codes.remove(index);
205 +
206 + // Re-encrypt and save
207 + let updated_json = serde_json::to_string(&hashed_codes)
208 + .map_err(|e| AppError::Internal(format!("Failed to serialize backup codes: {}", e)))?;
209 + let updated_encrypted = CryptoService::encrypt(&updated_json)?;
210 +
211 + let mut model: user_totp::ActiveModel = record.into();
212 + model.backup_codes_encrypted = Set(Some(updated_encrypted));
213 + model.backup_codes_remaining = Set(hashed_codes.len() as i32);
214 + model.updated_at = Set(Utc::now());
215 + model.update(db).await?;
216 +
217 + Ok(true)
218 + }
219 +
220 + /// Disable TOTP for a user (deletes the user_totp row).
221 + pub async fn disable_totp(db: &DatabaseConnection, user_id: Uuid) -> Result<(), AppError> {
222 + let record = user_totp::Entity::find()
223 + .filter(user_totp::Column::UserId.eq(user_id))
224 + .one(db)
225 + .await?
226 + .ok_or_else(|| AppError::TotpNotEnabled)?;
227 +
228 + let model: user_totp::ActiveModel = record.into();
229 + model.delete(db).await?;
230 + Ok(())
231 + }
232 +
233 + /// Regenerate backup codes. Returns new plaintext codes.
234 + ///
235 + /// Replaces all existing backup codes.
236 + pub async fn regenerate_backup_codes(
237 + db: &DatabaseConnection,
238 + user_id: Uuid,
239 + ) -> Result<Vec<String>, AppError> {
240 + let record = user_totp::Entity::find()
241 + .filter(user_totp::Column::UserId.eq(user_id))
242 + .filter(user_totp::Column::Enabled.eq(true))
243 + .one(db)
244 + .await?
245 + .ok_or_else(|| AppError::TotpNotEnabled)?;
246 +
247 + let (plaintext_codes, encrypted_codes) = Self::generate_backup_codes_internal()?;
248 +
249 + let mut model: user_totp::ActiveModel = record.into();
250 + model.backup_codes_encrypted = Set(Some(encrypted_codes));
251 + model.backup_codes_remaining = Set(BACKUP_CODE_COUNT as i32);
252 + model.updated_at = Set(Utc::now());
253 + model.update(db).await?;
254 +
255 + Ok(plaintext_codes)
256 + }
257 +
258 + /// Check if a user has TOTP enabled.
259 + pub async fn is_totp_enabled(db: &DatabaseConnection, user_id: Uuid) -> Result<bool, AppError> {
260 + let record = user_totp::Entity::find()
261 + .filter(user_totp::Column::UserId.eq(user_id))
262 + .filter(user_totp::Column::Enabled.eq(true))
263 + .one(db)
264 + .await?;
265 + Ok(record.is_some())
266 + }
267 +
268 + /// Get TOTP info for a user (for display in settings/admin).
269 + pub async fn get_totp_info(
270 + db: &DatabaseConnection,
271 + user_id: Uuid,
272 + ) -> Result<Option<user_totp::Model>, AppError> {
273 + let record = user_totp::Entity::find()
274 + .filter(user_totp::Column::UserId.eq(user_id))
275 + .one(db)
276 + .await?;
277 + Ok(record)
278 + }
279 +
280 + // --- Challenge management (for 2FA login flow) ---
281 +
282 + /// Create a 2FA challenge for the login flow.
283 + ///
284 + /// Returns the challenge token to send to the client.
285 + pub async fn create_challenge(
286 + db: &DatabaseConnection,
287 + user_id: Uuid,
288 + ) -> Result<String, AppError> {
289 + // Check for too many recent failed challenges (anti-brute-force)
290 + let recent_cutoff = Utc::now() - Duration::minutes(15);
291 + let recent_failed = totp_challenge::Entity::find()
292 + .filter(totp_challenge::Column::UserId.eq(user_id))
293 + .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff))
294 + .filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS))
295 + .all(db)
296 + .await?;
297 +
298 + if recent_failed.len() >= 3 {
299 + return Err(AppError::TotpMaxAttemptsExceeded);
300 + }
301 +
302 + let challenge_token = Uuid::new_v4().to_string();
303 + let now = Utc::now();
304 +
305 + let model = totp_challenge::ActiveModel {
306 + id: Set(Uuid::new_v4()),
307 + user_id: Set(user_id),
308 + challenge_token: Set(challenge_token.clone()),
309 + attempts: Set(0),
310 + max_attempts: Set(MAX_CHALLENGE_ATTEMPTS),
311 + expires_at: Set(now + Duration::minutes(CHALLENGE_EXPIRY_MINUTES)),
312 + created_at: Set(now),
313 + };
314 + model.insert(db).await?;
315 +
316 + Ok(challenge_token)
317 + }
318 +
319 + /// Validate a challenge token. Returns the user_id if valid.
320 + ///
321 + /// Checks expiration and max attempts.
322 + pub async fn validate_challenge(
323 + db: &DatabaseConnection,
324 + challenge_token: &str,
325 + ) -> Result<Uuid, AppError> {
326 + let challenge = totp_challenge::Entity::find()
327 + .filter(totp_challenge::Column::ChallengeToken.eq(challenge_token))
328 + .one(db)
329 + .await?
330 + .ok_or(AppError::TotpChallengeInvalid)?;
331 +
332 + // Check expiration
333 + if Utc::now() > challenge.expires_at {
334 + // Delete expired challenge
335 + let model: totp_challenge::ActiveModel = challenge.into();
336 + model.delete(db).await?;
337 + return Err(AppError::TotpChallengeInvalid);
338 + }
339 +
340 + // Check max attempts
341 + if challenge.attempts >= challenge.max_attempts {
342 + return Err(AppError::TotpMaxAttemptsExceeded);
343 + }
344 +
345 + Ok(challenge.user_id)
346 + }
347 +
348 + /// Increment attempt counter for a challenge.
349 + pub async fn record_challenge_attempt(
350 + db: &DatabaseConnection,
351 + challenge_token: &str,
352 + ) -> Result<(), AppError> {
353 + let challenge = totp_challenge::Entity::find()
354 + .filter(totp_challenge::Column::ChallengeToken.eq(challenge_token))
355 + .one(db)
356 + .await?
357 + .ok_or(AppError::TotpChallengeInvalid)?;
358 +
359 + let mut model: totp_challenge::ActiveModel = challenge.into();
360 + model.attempts = Set(model.attempts.unwrap() + 1);
361 + model.update(db).await?;
362 + Ok(())
363 + }
364 +
365 + /// Delete a challenge after successful verification.
366 + pub async fn consume_challenge(
367 + db: &DatabaseConnection,
368 + challenge_token: &str,
369 + ) -> Result<(), AppError> {
370 + let challenge = totp_challenge::Entity::find()
371 + .filter(totp_challenge::Column::ChallengeToken.eq(challenge_token))
372 + .one(db)
373 + .await?;
374 +
375 + if let Some(c) = challenge {
376 + let model: totp_challenge::ActiveModel = c.into();
377 + model.delete(db).await?;
378 + }
379 + Ok(())
380 + }
381 +
382 + /// Cleanup expired challenges (background task).
383 + pub async fn cleanup_expired_challenges(db: &DatabaseConnection) -> Result<u64, AppError> {
384 + let now = Utc::now();
385 + let result = totp_challenge::Entity::delete_many()
386 + .filter(totp_challenge::Column::ExpiresAt.lt(now))
387 + .exec(db)
388 + .await?;
389 + Ok(result.rows_affected)
390 + }
391 +
392 + // --- Internal helpers ---
393 +
394 + /// Verify a TOTP code against a base32-encoded secret.
395 + fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool, AppError> {
396 + let secret = Secret::Encoded(secret_base32.to_string());
397 + let totp = TOTP::new(
398 + Algorithm::SHA1,
399 + 6,
400 + 1,
401 + 30,
402 + secret
403 + .to_bytes()
404 + .map_err(|e| AppError::Internal(format!("Failed to decode TOTP secret: {}", e)))?,
405 + None,
406 + String::new(),
407 + )
408 + .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?;
409 +
410 + Ok(totp.check_current(code).unwrap_or(false))
411 + }
412 +
413 + /// Generate backup codes: returns (plaintext_codes, encrypted_json_of_hashed_codes).
414 + fn generate_backup_codes_internal() -> Result<(Vec<String>, String), AppError> {
415 + use rand::Rng;
416 +
417 + let mut rng = rand::rng();
418 + let charset: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No I/O/0/1 for readability
419 +
420 + let mut plaintext_codes = Vec::with_capacity(BACKUP_CODE_COUNT);
421 + let mut hashed_codes = Vec::with_capacity(BACKUP_CODE_COUNT);
422 +
423 + for _ in 0..BACKUP_CODE_COUNT {
424 + let code: String = (0..BACKUP_CODE_LENGTH)
425 + .map(|_| {
426 + let idx = rng.random_range(0..charset.len());
427 + charset[idx] as char
428 + })
429 + .collect();
430 +
431 + let hashed = bcrypt::hash(&code, 10)
432 + .map_err(|e| AppError::Internal(format!("Failed to hash backup code: {}", e)))?;
433 +
434 + plaintext_codes.push(code);
435 + hashed_codes.push(hashed);
436 + }
437 +
438 + let codes_json = serde_json::to_string(&hashed_codes)
439 + .map_err(|e| AppError::Internal(format!("Failed to serialize backup codes: {}", e)))?;
440 + let encrypted = CryptoService::encrypt(&codes_json)?;
441 +
442 + Ok((plaintext_codes, encrypted))
443 + }
444 +}
445 +
446 +#[cfg(test)]
447 +mod tests {
448 + use super::*;
449 +
450 + #[test]
451 + fn test_verify_totp_code_with_known_secret() {
452 + // Generate a secret and create a valid code, then verify
453 + let secret = Secret::generate_secret();
454 + let secret_base32 = secret.to_encoded().to_string();
455 +
456 + let totp = TOTP::new(
457 + Algorithm::SHA1,
458 + 6,
459 + 1,
460 + 30,
461 + secret.to_bytes().unwrap(),
462 + None,
463 + String::new(),
464 + )
465 + .unwrap();
466 +
467 + let valid_code = totp.generate_current().unwrap();
468 + let result = TotpService::verify_totp_code(&secret_base32, &valid_code).unwrap();
469 + assert!(result, "Valid TOTP code should verify");
470 +
471 + let result = TotpService::verify_totp_code(&secret_base32, "000000").unwrap();
472 + // May or may not be valid depending on timing, but "000000" is rarely valid
473 + // So we just check it doesn't error
474 + let _ = result;
475 + }
476 +
477 + #[test]
478 + fn test_generate_backup_codes() {
479 + std::env::set_var(
480 + "ENCRYPTION_KEY",
481 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
482 + );
483 +
484 + let (plaintext, encrypted) = TotpService::generate_backup_codes_internal().unwrap();
485 +
486 + assert_eq!(plaintext.len(), BACKUP_CODE_COUNT);
487 + assert!(encrypted.starts_with("enc:"));
488 +
489 + // Each code should be 8 characters
490 + for code in &plaintext {
491 + assert_eq!(code.len(), BACKUP_CODE_LENGTH);
492 + }
493 +
494 + // Decrypt and verify hashes match
495 + let decrypted_json = CryptoService::decrypt(&encrypted).unwrap();
496 + let hashed_codes: Vec<String> = serde_json::from_str(&decrypted_json).unwrap();
497 + assert_eq!(hashed_codes.len(), BACKUP_CODE_COUNT);
498 +
499 + // Verify each plaintext code matches its hash
500 + for (plain, hashed) in plaintext.iter().zip(hashed_codes.iter()) {
501 + assert!(bcrypt::verify(plain, hashed).unwrap());
502 + }
503 + }
504 +
505 + #[test]
506 + fn test_backup_codes_are_unique() {
507 + std::env::set_var(
508 + "ENCRYPTION_KEY",
509 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
510 + );
511 +
512 + let (codes, _) = TotpService::generate_backup_codes_internal().unwrap();
513 + let mut unique = std::collections::HashSet::new();
514 + for code in &codes {
515 + assert!(unique.insert(code.clone()), "Backup codes should be unique");
516 + }
517 + }
518 +}
M crates/rustwarden-core/src/services/user_preferences_service.rs
+0 -11
@@ -116,14 +116,3 @@ impl UserPreferencesService {
116 116 Self::create_preferences(db, user_id, "light".to_string(), "fr".to_string()).await
117 117 }
118 118 }
119 -
120 -#[cfg(test)]
121 -mod tests {
122 - use super::*;
123 -
124 - #[tokio::test]
125 - async fn test_get_preferences_not_found() {
126 - // This test would require a test database setup
127 - // For now, it's a placeholder
128 - }
129 -}
M crates/rustwarden-core/src/tasks/mod.rs
+1 -0
@@ -6,3 +6,4 @@
6 6 pub mod email_queue_processor;
7 7 pub mod password_change_cleanup;
8 8 pub mod password_change_email_retry;
9 +pub mod personal_token_cleanup;
M crates/rustwarden-core/src/tasks/password_change_email_retry.rs
+6 -2
@@ -112,7 +112,10 @@ impl PasswordChangeEmailRetryProcessor {
112 112 let pending_requests = password_change_request::Entity::find()
113 113 .filter(password_change_request::Column::ConfirmedAt.is_null())
114 114 .filter(password_change_request::Column::ExpiresAt.gt(now))
115 - .filter(password_change_request::Column::RetryCount.lt(crate::config::tasks::password_change_max_retries()))
115 + .filter(
116 + password_change_request::Column::RetryCount
117 + .lt(crate::config::tasks::password_change_max_retries()),
118 + )
116 119 .all(&*self.db)
117 120 .await?;
118 121
@@ -121,7 +124,8 @@ impl PasswordChangeEmailRetryProcessor {
121 124 }
122 125
123 126 let mut processed = 0;
124 - let retry_interval = Duration::minutes(crate::config::tasks::password_change_retry_gap_minutes());
127 + let retry_interval =
128 + Duration::minutes(crate::config::tasks::password_change_retry_gap_minutes());
125 129
126 130 for request in pending_requests {
127 131 // Check if enough time has passed since last attempt
A crates/rustwarden-core/src/tasks/personal_token_cleanup.rs
+50 -0
@@ -0,0 +1,50 @@
1 +//! Personal access token cleanup task.
2 +//!
3 +//! Periodically hard-deletes PATs that are both expired AND revoked. See
4 +//! [`PersonalTokenService::cleanup_expired`](crate::services::personal_token_service::PersonalTokenService::cleanup_expired)
5 +//! for the exact predicate.
6 +
7 +use crate::services::personal_token_service::{PersonalTokenService, TokenScope};
8 +use sea_orm::DatabaseConnection;
9 +use std::marker::PhantomData;
10 +use std::sync::Arc;
11 +use tokio::time::Duration;
12 +
13 +/// Background processor that periodically drops expired+revoked PAT rows.
14 +///
15 +/// Generic over the app's scope vocabulary only so that
16 +/// [`PersonalTokenService::<S>::cleanup_expired`] can be reached — the
17 +/// predicate itself is scope-agnostic.
18 +pub struct PersonalTokenCleanupProcessor<S: TokenScope> {
19 + db: Arc<DatabaseConnection>,
20 + _phantom: PhantomData<S>,
21 +}
22 +
23 +impl<S: TokenScope> PersonalTokenCleanupProcessor<S> {
24 + /// Create a new cleanup processor bound to the given database connection.
25 + pub fn new(db: Arc<DatabaseConnection>) -> Self {
26 + Self {
27 + db,
28 + _phantom: PhantomData,
29 + }
30 + }
31 +
32 + /// Spawn the cleanup loop. First tick fires immediately (to purge stale
33 + /// rows on process start), then every
34 + /// [`config::tasks::personal_token_cleanup_interval_secs`](crate::config::tasks::personal_token_cleanup_interval_secs).
35 + pub fn start(self) {
36 + tokio::spawn(async move {
37 + let secs = crate::config::tasks::personal_token_cleanup_interval_secs();
38 + let mut ticker = tokio::time::interval(Duration::from_secs(secs));
39 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
40 + loop {
41 + ticker.tick().await;
42 + match PersonalTokenService::<S>::cleanup_expired(&self.db).await {
43 + Ok(0) => {}
44 + Ok(n) => tracing::info!("PAT cleanup: deleted {} expired+revoked token(s)", n),
45 + Err(e) => tracing::error!("PAT cleanup failed: {}", e),
46 + }
47 + }
48 + });
49 + }
50 +}
M crates/rustwarden-core/src/templates/mod.rs
+76 -0
@@ -33,13 +33,18 @@ macro_rules! impl_template_response {
33 33 /// Base layout context shared by all pages
34 34 #[derive(Debug, Clone, Serialize, Deserialize)]
35 35 pub struct LayoutContext {
36 + /// HTML `<title>` and heading text for the page.
36 37 pub page_title: String,
38 + /// CSRF token embedded in forms to prevent cross-site request forgery.
37 39 pub csrf_token: String,
40 + /// Application display name (from `APP_NAME` env var).
38 41 pub app_name: String,
42 + /// Application version string from `Cargo.toml`.
39 43 pub app_version: String,
40 44 }
41 45
42 46 impl LayoutContext {
47 + /// Create a new `LayoutContext` with the given page title and CSRF token.
43 48 pub fn new(page_title: &str, csrf_token: &str) -> Self {
44 49 Self {
45 50 page_title: page_title.to_string(),
@@ -48,6 +53,30 @@ impl LayoutContext {
48 53 app_version: env!("CARGO_PKG_VERSION").to_string(),
49 54 }
50 55 }
56 +
57 + /// Async constructor that populates `csrf_token` from the tower-sessions session.
58 + ///
59 + /// Replaces the manual pattern:
60 + /// ```rust,ignore
61 + /// let csrf_token = get_or_create_csrf_token(&session).await?;
62 + /// let ctx = LayoutContext::new("My Page", &csrf_token);
63 + /// ```
64 + ///
65 + /// Falls back to an empty token (with a warning log) if session access fails,
66 + /// so template rendering never panics on a transient session backend error.
67 + pub async fn from_session(session: &tower_sessions::Session, page_title: &str) -> Self {
68 + let token = match crate::middleware::csrf::get_or_create_csrf_token(session).await {
69 + Ok(t) => t,
70 + Err(e) => {
71 + tracing::warn!(
72 + error = %e,
73 + "LayoutContext::from_session failed to fetch CSRF token, falling back to empty"
74 + );
75 + String::new()
76 + }
77 + };
78 + Self::new(page_title, &token)
79 + }
51 80 }
52 81
53 82 impl Default for LayoutContext {
@@ -55,3 +84,50 @@ impl Default for LayoutContext {
55 84 Self::new("", "")
56 85 }
57 86 }
87 +
88 +#[cfg(test)]
89 +mod tests {
90 + use super::*;
91 + use std::sync::Arc;
92 + use tower_sessions::{MemoryStore, Session};
93 +
94 + fn make_test_session() -> Session {
95 + let store = Arc::new(MemoryStore::default());
96 + Session::new(None, store, None)
97 + }
98 +
99 + #[tokio::test]
100 + async fn from_session_generates_csrf_token_when_absent() {
101 + let session = make_test_session();
102 + let ctx = LayoutContext::from_session(&session, "Home").await;
103 +
104 + assert_eq!(ctx.page_title, "Home");
105 + assert_eq!(
106 + ctx.csrf_token.len(),
107 + 64,
108 + "csrf_token should be a 32-byte hex string (64 chars)"
109 + );
110 + }
111 +
112 + #[tokio::test]
113 + async fn from_session_returns_existing_csrf_token() {
114 + let session = make_test_session();
115 + let first = LayoutContext::from_session(&session, "First").await;
116 + let second = LayoutContext::from_session(&session, "Second").await;
117 +
118 + assert_eq!(
119 + first.csrf_token, second.csrf_token,
120 + "from_session must reuse the session's existing token"
121 + );
122 + assert_eq!(second.page_title, "Second");
123 + }
124 +
125 + #[tokio::test]
126 + async fn from_session_fills_app_metadata() {
127 + let session = make_test_session();
128 + let ctx = LayoutContext::from_session(&session, "Any").await;
129 +
130 + assert!(!ctx.app_name.is_empty());
131 + assert!(!ctx.app_version.is_empty());
132 + }
133 +}
A crates/rustwarden-core/src/types/mod.rs
+9 -0
@@ -0,0 +1,9 @@
1 +//! Framework-level value objects.
2 +//!
3 +//! Small, focused wrappers around strings/ids that carry a validation
4 +//! invariant in their type. Keeps "unchecked" strings out of handlers and
5 +//! services.
6 +
7 +pub mod team_slug;
8 +
9 +pub use team_slug::TeamSlug;
A crates/rustwarden-core/src/types/team_slug.rs
+127 -0
@@ -0,0 +1,127 @@
1 +//! Validated team slug.
2 +//!
3 +//! A [`TeamSlug`] is a lowercased, URL-safe handle for a team. Enforced
4 +//! invariants:
5 +//!
6 +//! - 1 to 64 characters
7 +//! - `[a-z0-9-]+` (lowercase ASCII letters, digits, hyphen)
8 +//! - Neither starts nor ends with `-`
9 +//! - Not one of the [`RESERVED`] names (routes we never want to shadow)
10 +//!
11 +//! Wrapping raw strings in `TeamSlug` keeps `TeamService::create` and route
12 +//! handlers from having to re-validate.
13 +
14 +use crate::error::AppError;
15 +use serde::{Deserialize, Serialize};
16 +
17 +/// Reserved slugs that would collide with framework routes or reserved UI
18 +/// segments. Match CI-checked list, not an exhaustive blocklist.
19 +pub const RESERVED: &[&str] = &[
20 + "admin", "api", "auth", "health", "login", "logout", "new", "oauth", "register", "settings",
21 + "signin", "signout", "signup", "static", "teams",
22 +];
23 +
24 +/// Validated team slug. Use [`TeamSlug::parse`] to construct.
25 +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
26 +#[serde(transparent)]
27 +pub struct TeamSlug(String);
28 +
29 +impl TeamSlug {
30 + /// Parse a raw string into a `TeamSlug`. Returns
31 + /// `AppError::Validation` on any rule violation so handlers can bubble
32 + /// a 400 up without extra mapping.
33 + pub fn parse(raw: &str) -> Result<Self, AppError> {
34 + let s = raw.trim();
35 + if s.is_empty() || s.len() > 64 {
36 + return Err(AppError::Validation(
37 + "Team slug must be 1-64 characters".into(),
38 + ));
39 + }
40 + if s.starts_with('-') || s.ends_with('-') {
41 + return Err(AppError::Validation(
42 + "Team slug cannot start or end with '-'".into(),
43 + ));
44 + }
45 + if !s
46 + .chars()
47 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
48 + {
49 + return Err(AppError::Validation(
50 + "Team slug must only contain lowercase letters, digits, and '-'".into(),
51 + ));
52 + }
53 + if RESERVED.contains(&s) {
54 + return Err(AppError::Validation(format!("Team slug '{s}' is reserved")));
55 + }
56 + Ok(Self(s.to_string()))
57 + }
58 +
59 + /// Return the slug as a string slice.
60 + pub fn as_str(&self) -> &str {
61 + &self.0
62 + }
63 +
64 + /// Consume the `TeamSlug` and return the inner `String`.
65 + pub fn into_inner(self) -> String {
66 + self.0
67 + }
68 +}
69 +
70 +impl std::fmt::Display for TeamSlug {
71 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 + f.write_str(&self.0)
73 + }
74 +}
75 +
76 +#[cfg(test)]
77 +mod tests {
78 + use super::*;
79 +
80 + #[test]
81 + fn valid_slug() {
82 + assert_eq!(TeamSlug::parse("backend").unwrap().as_str(), "backend");
83 + assert_eq!(TeamSlug::parse("squad-42").unwrap().as_str(), "squad-42");
84 + }
85 +
86 + #[test]
87 + fn trims_whitespace() {
88 + assert_eq!(TeamSlug::parse(" devops ").unwrap().as_str(), "devops");
89 + }
90 +
91 + #[test]
92 + fn rejects_empty() {
93 + assert!(TeamSlug::parse("").is_err());
94 + assert!(TeamSlug::parse(" ").is_err());
95 + }
96 +
97 + #[test]
98 + fn rejects_too_long() {
99 + let too_long = "a".repeat(65);
100 + assert!(TeamSlug::parse(&too_long).is_err());
101 + }
102 +
103 + #[test]
104 + fn rejects_uppercase() {
105 + assert!(TeamSlug::parse("Backend").is_err());
106 + }
107 +
108 + #[test]
109 + fn rejects_special_chars() {
110 + assert!(TeamSlug::parse("back end").is_err());
111 + assert!(TeamSlug::parse("back_end").is_err());
112 + assert!(TeamSlug::parse("back.end").is_err());
113 + }
114 +
115 + #[test]
116 + fn rejects_leading_or_trailing_hyphen() {
117 + assert!(TeamSlug::parse("-backend").is_err());
118 + assert!(TeamSlug::parse("backend-").is_err());
119 + }
120 +
121 + #[test]
122 + fn rejects_reserved() {
123 + for r in RESERVED {
124 + assert!(TeamSlug::parse(r).is_err(), "{r} should be reserved");
125 + }
126 + }
127 +}
M crates/rustwarden-ui/Cargo.toml
+2 -2
@@ -35,5 +35,5 @@ num-format = "0.4.4"
35 35 # Time
36 36 time = { version = "0.3", features = ["macros", "serde"] }
37 37
38 -[lints.clippy]
39 -empty_line_after_doc_comments = "allow"
38 +[lints]
39 +workspace = true
M crates/rustwarden-ui/locales/de.yml
+49 -2
@@ -7,7 +7,7 @@ login:
7 7 page_title: "Anmeldung - RustCloud"
8 8 page_description: "Anmeldung bei RustCloud"
9 9 subtitle: "Melden Sie sich in Ihrem Cloud-Bereich an"
10 - username: "Benutzername"
10 + username: "Benutzername oder E-Mail"
11 11 password: "Passwort"
12 12 submit: "Anmelden"
13 13 remember_me: "Angemeldet bleiben"
@@ -15,7 +15,7 @@ login:
15 15 tab_register: "Registrierung"
16 16 register_title: "Konto erstellen"
17 17 register_submit: "Mein Konto erstellen"
18 - username_placeholder: "Ihr Benutzername"
18 + username_placeholder: "Benutzername oder E-Mail"
19 19 password_placeholder: "Ihr Passwort"
20 20 username_placeholder_register: "Ihr Benutzername (min. 3 Zeichen)"
21 21 password_placeholder_register: "Ihr Passwort (min. 8 Zeichen)"
@@ -69,6 +69,49 @@ password_reset:
69 69 error_link_invalid: "Das Passwort konnte nicht zurückgesetzt werden. Der Link ist möglicherweise abgelaufen oder ungültig."
70 70 error_generic: "Das Passwort kann derzeit nicht zurückgesetzt werden."
71 71
72 +# 2FA-Anmeldeseite
73 +login_2fa:
74 + page_title: "2FA-Überprüfung - RustCloud"
75 + title: "Zwei-Schritt-Überprüfung"
76 + subtitle: "Geben Sie den Code aus Ihrer Authenticator-App ein"
77 + code_label: "Authentifizierungscode"
78 + backup_code_label: "Backup-Code"
79 + verify_button: "Überprüfen"
80 + use_backup_code: "Einen Backup-Code verwenden"
81 + use_totp_code: "Authenticator-App-Code verwenden"
82 + back_to_login: "Zurück zur Anmeldung"
83 + error_invalid_code: "Ungültiger Code. Bitte versuchen Sie es erneut."
84 + error_network: "Netzwerkfehler. Bitte versuchen Sie es erneut."
85 +
86 +# 2FA-Sicherheit
87 +settings_security:
88 + page_title: "Sicherheit - RustWarden"
89 + title: "Zwei-Faktor-Authentifizierung"
90 + subtitle: "Schützen Sie Ihr Konto mit einer zusätzlichen Sicherheitsebene"
91 + totp_title: "TOTP-Authentifizierung"
92 + status_enabled: "Aktiviert"
93 + status_disabled: "Deaktiviert"
94 + enabled_message: "Ihr Konto ist durch Zwei-Faktor-Authentifizierung geschützt."
95 + disabled_message: "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu, indem Sie die Zwei-Faktor-Authentifizierung mit einer App wie Google Authenticator oder Authy aktivieren."
96 + enable_button: "2FA aktivieren"
97 + disable_button: "2FA deaktivieren"
98 + scan_qr: "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App"
99 + manual_entry: "Oder geben Sie diesen Code manuell ein"
100 + enter_code: "Geben Sie den 6-stelligen Code aus Ihrer App ein"
101 + verify_button: "Überprüfen und aktivieren"
102 + backup_codes_title: "Backup-Codes"
103 + backup_codes_warning: "Bewahren Sie diese Codes an einem sicheren Ort auf. Jeder Code kann nur einmal verwendet werden."
104 + backup_codes_remaining: "Verbleibende Backup-Codes"
105 + regenerate_button: "Codes neu generieren"
106 + regenerate_confirm: "Sind Sie sicher? Alte Codes werden ersetzt."
107 + confirm_disable: "Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren"
108 + password_label: "Aktuelles Passwort"
109 + copy_button: "Kopieren"
110 + saved_codes_button: "Ich habe meine Codes gespeichert"
111 + breadcrumb: "Sicherheit"
112 + force_2fa_message: "Ihr Administrator verlangt die Zwei-Faktor-Authentifizierung. Sie müssen 2FA einrichten, bevor Sie auf den Rest der Anwendung zugreifen können."
113 + force_2fa_logout: "Abmelden"
114 +
72 115 # Einstellungen
73 116 settings:
74 117 title: "Einstellungen"
@@ -122,6 +165,10 @@ preferences:
122 165 language_de_desc: "Deutsche Benutzeroberfläche"
123 166 language_es: "Español"
124 167 language_es_desc: "Spanische Benutzeroberfläche"
168 + language_it: "Italiano"
169 + language_it_desc: "Italienische Benutzeroberfläche"
170 + language_pt: "Português"
171 + language_pt_desc: "Portugiesische Benutzeroberfläche"
125 172 translation_warning: "Die vollständige Übersetzung der Benutzeroberfläche befindet sich in der Entwicklung. Einige Elemente können auf Französisch bleiben."
126 173 modified: "Einstellungen geändert"
127 174 modified_desc: "Speichern Sie Ihre Änderungen, um sie anzuwenden"
M crates/rustwarden-ui/locales/en.yml
+49 -2
@@ -7,7 +7,7 @@ login:
7 7 page_title: "Login - RustCloud"
8 8 page_description: "Login to RustCloud"
9 9 subtitle: "Sign in to your cloud space"
10 - username: "Username"
10 + username: "Username or email"
11 11 password: "Password"
12 12 submit: "Sign in"
13 13 remember_me: "Remember me"
@@ -15,7 +15,7 @@ login:
15 15 tab_register: "Register"
16 16 register_title: "Create an account"
17 17 register_submit: "Create my account"
18 - username_placeholder: "Your username"
18 + username_placeholder: "Username or email"
19 19 password_placeholder: "Your password"
20 20 username_placeholder_register: "Your username (min. 3 characters)"
21 21 password_placeholder_register: "Your password (min. 8 characters)"
@@ -69,6 +69,49 @@ password_reset:
69 69 error_link_invalid: "Unable to reset password. The link may be expired or invalid."
70 70 error_generic: "Unable to reset password at this time."
71 71
72 +# 2FA Login page
73 +login_2fa:
74 + page_title: "2FA Verification - RustCloud"
75 + title: "Two-Step Verification"
76 + subtitle: "Enter the code from your authenticator app"
77 + code_label: "Authentication code"
78 + backup_code_label: "Backup code"
79 + verify_button: "Verify"
80 + use_backup_code: "Use a backup code"
81 + use_totp_code: "Use authenticator app code"
82 + back_to_login: "Back to login"
83 + error_invalid_code: "Invalid code. Please try again."
84 + error_network: "Network error. Please try again."
85 +
86 +# 2FA Security
87 +settings_security:
88 + page_title: "Security - RustWarden"
89 + title: "Two-Factor Authentication"
90 + subtitle: "Protect your account with an extra layer of security"
91 + totp_title: "TOTP Authentication"
92 + status_enabled: "Enabled"
93 + status_disabled: "Disabled"
94 + enabled_message: "Your account is protected by two-factor authentication."
95 + disabled_message: "Add an extra layer of security to your account by enabling two-factor authentication using an app like Google Authenticator or Authy."
96 + enable_button: "Enable 2FA"
97 + disable_button: "Disable 2FA"
98 + scan_qr: "Scan this QR code with your authenticator app"
99 + manual_entry: "Or enter this code manually"
100 + enter_code: "Enter the 6-digit code from your app"
101 + verify_button: "Verify and Enable"
102 + backup_codes_title: "Backup Codes"
103 + backup_codes_warning: "Save these codes in a safe place. Each code can only be used once."
104 + backup_codes_remaining: "Backup codes remaining"
105 + regenerate_button: "Regenerate codes"
106 + regenerate_confirm: "Are you sure? Old codes will be replaced."
107 + confirm_disable: "Enter your password to disable 2FA"
108 + password_label: "Current password"
109 + copy_button: "Copy"
110 + saved_codes_button: "I've saved my codes"
111 + breadcrumb: "Security"
112 + force_2fa_message: "Your administrator requires two-factor authentication. You must set up 2FA before you can access the rest of the application."
113 + force_2fa_logout: "Sign out"
114 +
72 115 # Settings
73 116 settings:
74 117 title: "Settings"
@@ -122,6 +165,10 @@ preferences:
122 165 language_de_desc: "German interface"
123 166 language_es: "Español"
124 167 language_es_desc: "Spanish interface"
168 + language_it: "Italiano"
169 + language_it_desc: "Italian interface"
170 + language_pt: "Português"
171 + language_pt_desc: "Portuguese interface"
125 172 translation_warning: "Complete interface translation is under development. Some elements may remain in French."
126 173 modified: "Preferences modified"
127 174 modified_desc: "Save your changes to apply them"
M crates/rustwarden-ui/locales/es.yml
+49 -2
@@ -7,7 +7,7 @@ login:
7 7 page_title: "Iniciar sesión - RustCloud"
8 8 page_description: "Iniciar sesión en RustCloud"
9 9 subtitle: "Inicie sesión en su espacio en la nube"
10 - username: "Nombre de usuario"
10 + username: "Nombre de usuario o email"
11 11 password: "Contraseña"
12 12 submit: "Iniciar sesión"
13 13 remember_me: "Recordarme"
@@ -15,7 +15,7 @@ login:
15 15 tab_register: "Registrarse"
16 16 register_title: "Crear una cuenta"
17 17 register_submit: "Crear mi cuenta"
18 - username_placeholder: "Su nombre de usuario"
18 + username_placeholder: "Nombre de usuario o email"
19 19 password_placeholder: "Su contraseña"
20 20 username_placeholder_register: "Su nombre de usuario (mín. 3 caracteres)"
21 21 password_placeholder_register: "Su contraseña (mín. 8 caracteres)"
@@ -69,6 +69,49 @@ password_reset:
69 69 error_link_invalid: "No se pudo restablecer la contraseña. El enlace puede haber expirado o ser inválido."
70 70 error_generic: "No se puede restablecer la contraseña en este momento."
71 71
72 +# Página de inicio de sesión 2FA
73 +login_2fa:
74 + page_title: "Verificación 2FA - RustCloud"
75 + title: "Verificación en dos pasos"
76 + subtitle: "Ingrese el código de su aplicación de autenticación"
77 + code_label: "Código de autenticación"
78 + backup_code_label: "Código de respaldo"
79 + verify_button: "Verificar"
80 + use_backup_code: "Usar un código de respaldo"
81 + use_totp_code: "Usar código de la aplicación"
82 + back_to_login: "Volver al inicio de sesión"
83 + error_invalid_code: "Código inválido. Inténtelo de nuevo."
84 + error_network: "Error de red. Inténtelo de nuevo."
85 +
86 +# Seguridad 2FA
87 +settings_security:
88 + page_title: "Seguridad - RustWarden"
89 + title: "Autenticación de dos factores"
90 + subtitle: "Proteja su cuenta con una capa adicional de seguridad"
91 + totp_title: "Autenticación TOTP"
92 + status_enabled: "Activado"
93 + status_disabled: "Desactivado"
94 + enabled_message: "Su cuenta está protegida por autenticación de dos factores."
95 + disabled_message: "Agregue una capa adicional de seguridad a su cuenta habilitando la autenticación de dos factores con una aplicación como Google Authenticator o Authy."
96 + enable_button: "Activar 2FA"
97 + disable_button: "Desactivar 2FA"
98 + scan_qr: "Escanee este código QR con su aplicación de autenticación"
99 + manual_entry: "O ingrese este código manualmente"
100 + enter_code: "Ingrese el código de 6 dígitos de su aplicación"
101 + verify_button: "Verificar y activar"
102 + backup_codes_title: "Códigos de respaldo"
103 + backup_codes_warning: "Guarde estos códigos en un lugar seguro. Cada código solo se puede usar una vez."
104 + backup_codes_remaining: "Códigos de respaldo restantes"
105 + regenerate_button: "Regenerar códigos"
106 + regenerate_confirm: "¿Está seguro? Los códigos antiguos serán reemplazados."
107 + confirm_disable: "Ingrese su contraseña para desactivar 2FA"
108 + password_label: "Contraseña actual"
109 + copy_button: "Copiar"
110 + saved_codes_button: "He guardado mis códigos"
111 + breadcrumb: "Seguridad"
112 + force_2fa_message: "Su administrador requiere la autenticación de dos factores. Debe configurar 2FA antes de poder acceder al resto de la aplicación."
113 + force_2fa_logout: "Cerrar sesión"
114 +
72 115 # Configuración
73 116 settings:
74 117 title: "Configuración"
@@ -122,6 +165,10 @@ preferences:
122 165 language_de_desc: "Interfaz en alemán"
123 166 language_es: "Español"
124 167 language_es_desc: "Interfaz en español"
168 + language_it: "Italiano"
169 + language_it_desc: "Interfaz en italiano"
170 + language_pt: "Português"
171 + language_pt_desc: "Interfaz en portugués"
125 172 translation_warning: "La traducción completa de la interfaz está en desarrollo. Algunos elementos pueden permanecer en francés."
126 173 modified: "Preferencias modificadas"
127 174 modified_desc: "Guarde sus cambios para aplicarlos"
M crates/rustwarden-ui/locales/fr.yml
+49 -2
@@ -7,7 +7,7 @@ login:
7 7 page_title: "Connexion - RustCloud"
8 8 page_description: "Connexion à RustCloud"
9 9 subtitle: "Connectez-vous à votre espace cloud"
10 - username: "Nom d'utilisateur"
10 + username: "Nom d'utilisateur ou email"
11 11 password: "Mot de passe"
12 12 submit: "Se connecter"
13 13 remember_me: "Se souvenir de moi"
@@ -15,7 +15,7 @@ login:
15 15 tab_register: "Inscription"
16 16 register_title: "Créer un compte"
17 17 register_submit: "Créer mon compte"
18 - username_placeholder: "Votre nom d'utilisateur"
18 + username_placeholder: "Nom d'utilisateur ou email"
19 19 password_placeholder: "Votre mot de passe"
20 20 username_placeholder_register: "Votre nom d'utilisateur (min. 3 caractères)"
21 21 password_placeholder_register: "Votre mot de passe (min. 8 caractères)"
@@ -69,6 +69,49 @@ password_reset:
69 69 error_link_invalid: "Impossible de réinitialiser le mot de passe. Le lien peut être expiré ou invalide."
70 70 error_generic: "Impossible de réinitialiser le mot de passe pour l'instant."
71 71
72 +# Page 2FA login
73 +login_2fa:
74 + page_title: "Vérification 2FA - RustCloud"
75 + title: "Vérification en deux étapes"
76 + subtitle: "Entrez le code de votre application d'authentification"
77 + code_label: "Code d'authentification"
78 + backup_code_label: "Code de secours"
79 + verify_button: "Vérifier"
80 + use_backup_code: "Utiliser un code de secours"
81 + use_totp_code: "Utiliser le code de l'application"
82 + back_to_login: "Retour à la connexion"
83 + error_invalid_code: "Code invalide. Veuillez réessayer."
84 + error_network: "Erreur réseau. Veuillez réessayer."
85 +
86 +# Sécurité 2FA
87 +settings_security:
88 + page_title: "Sécurité - RustWarden"
89 + title: "Authentification à deux facteurs"
90 + subtitle: "Protégez votre compte avec une couche de sécurité supplémentaire"
91 + totp_title: "Authentification TOTP"
92 + status_enabled: "Activé"
93 + status_disabled: "Désactivé"
94 + enabled_message: "Votre compte est protégé par l'authentification à deux facteurs."
95 + disabled_message: "Ajoutez une couche de sécurité supplémentaire à votre compte en activant l'authentification à deux facteurs via une application comme Google Authenticator ou Authy."
96 + enable_button: "Activer le 2FA"
97 + disable_button: "Désactiver le 2FA"
98 + scan_qr: "Scannez ce QR code avec votre application d'authentification"
99 + manual_entry: "Ou entrez ce code manuellement"
100 + enter_code: "Entrez le code à 6 chiffres de votre application"
101 + verify_button: "Vérifier et activer"
102 + backup_codes_title: "Codes de secours"
103 + backup_codes_warning: "Conservez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois."
104 + backup_codes_remaining: "Codes de secours restants"
105 + regenerate_button: "Régénérer les codes"
106 + regenerate_confirm: "Êtes-vous sûr ? Les anciens codes seront remplacés."
107 + confirm_disable: "Entrez votre mot de passe pour désactiver le 2FA"
108 + password_label: "Mot de passe actuel"
109 + copy_button: "Copier"
110 + saved_codes_button: "J'ai sauvegardé mes codes"
111 + breadcrumb: "Sécurité"
112 + force_2fa_message: "L'administrateur exige l'activation de l'authentification à deux facteurs. Vous devez configurer le 2FA avant de pouvoir accéder au reste de l'application."
113 + force_2fa_logout: "Se déconnecter"
114 +
72 115 # Paramètres
73 116 settings:
74 117 title: "Paramètres"
@@ -122,6 +165,10 @@ preferences:
122 165 language_de_desc: "Deutsche Benutzeroberfläche"
123 166 language_es: "Español"
124 167 language_es_desc: "Interfaz en español"
168 + language_it: "Italiano"
169 + language_it_desc: "Interface en italien"
170 + language_pt: "Português"
171 + language_pt_desc: "Interface en portugais"
125 172 translation_warning: "La traduction complète de l'interface est en cours de développement. Certains éléments peuvent rester en français."
126 173 modified: "Préférences modifiées"
127 174 modified_desc: "Enregistrez vos changements pour les appliquer"
A crates/rustwarden-ui/locales/it.yml
+702 -0
@@ -0,0 +1,702 @@
1 +# Traduzioni italiane — rustwarden-ui
2 +# Sezioni: login, pagine utente, admin, navigazione
3 +
4 +# Accesso
5 +login:
6 + title: "Accesso"
7 + page_title: "Accesso - RustCloud"
8 + page_description: "Accesso a RustCloud"
9 + subtitle: "Accedi al tuo spazio cloud"
10 + username: "Nome utente o email"
11 + password: "Password"
12 + submit: "Accedi"
13 + remember_me: "Ricordami"
14 + tab_login: "Accesso"
15 + tab_register: "Registrazione"
16 + register_title: "Crea un account"
17 + register_submit: "Crea il mio account"
18 + username_placeholder: "Nome utente o email"
19 + password_placeholder: "La tua password"
20 + username_placeholder_register: "Il tuo nome utente (min. 3 caratteri)"
21 + password_placeholder_register: "La tua password (min. 8 caratteri)"
22 + email_placeholder: "tua@email.com"
23 + forgot_password: "Password dimenticata?"
24 + email_not_verified: "Email non verificata?"
25 + resend_verification: "Reinvia l'email di verifica"
26 + already_have_account: "Hai già un account?"
27 + sign_in_link: "Accedi"
28 + success_message: "Accesso riuscito! Reindirizzamento..."
29 + error_message: "Errore di accesso. Verifica le tue credenziali."
30 + email_unverified_message: "La tua email non è verificata. Clicca qui sotto per reinviare il link."
31 + resend_button: "Reinvia l'email di verifica"
32 + resend_success: "Email di verifica reinviata se l'account esiste."
33 + resend_error: "Impossibile inviare la richiesta al momento."
34 + resend_prompt: "Inserisci la tua email prima di reinviare."
35 + register_success: "Account creato con successo! Controlla la tua email."
36 + register_error: "Errore durante la registrazione"
37 + email_label: "Email"
38 + footer_text: "Fork di Nextcloud in Rust"
39 +
40 +# Reinvio email di verifica
41 +resend_verification:
42 + page_title: "Reinvia l'email di verifica - RustWarden"
43 + title: "Reinvia l'email di verifica"
44 + subtitle: "Inserisci il tuo indirizzo email per ricevere un nuovo link di verifica."
45 + email_label: "Indirizzo email"
46 + email_placeholder: "tu@example.com"
47 + submit: "Invia il link di verifica"
48 + back_to_login: "Torna all'accesso"
49 + error_default: "Impossibile inviare la richiesta."
50 + success_default: "Se l'account esiste e non è verificato, un'email è stata inviata."
51 + error_missing_email: "Inserisci la tua email."
52 + error_cannot_send: "Impossibile inviare la richiesta al momento."
53 +
54 +# Reimpostazione password (tramite link email)
55 +password_reset:
56 + page_title: "Reimposta la password"
57 + title: "Reimposta la password"
58 + subtitle: "Inserisci la tua nuova password qui sotto."
59 + new_password_label: "Nuova password"
60 + new_password_placeholder: "Nuova password sicura"
61 + requirements: "⚠️ Deve contenere: 8+ caratteri, maiuscola, minuscola, cifra, carattere speciale"
62 + confirm_password_label: "Conferma password"
63 + confirm_password_placeholder: "Conferma la tua nuova password"
64 + missing_token_url: "Token di reimpostazione mancante nell'URL."
65 + missing_token: "Token di reimpostazione mancante."
66 + missing_new_password: "Inserisci una nuova password."
67 + passwords_not_match: "Le password non corrispondono."
68 + success_message: "La tua password è stata reimpostata con successo. Ora puoi accedere."
69 + error_link_invalid: "Impossibile reimpostare la password. Il link potrebbe essere scaduto o non valido."
70 + error_generic: "Impossibile reimpostare la password al momento."
71 +
72 +# Pagina di accesso 2FA
73 +login_2fa:
74 + page_title: "Verifica 2FA - RustCloud"
75 + title: "Verifica in due passaggi"
76 + subtitle: "Inserisci il codice dalla tua app di autenticazione"
77 + code_label: "Codice di autenticazione"
78 + backup_code_label: "Codice di backup"
79 + verify_button: "Verifica"
80 + use_backup_code: "Usa un codice di backup"
81 + use_totp_code: "Usa il codice dell'app"
82 + back_to_login: "Torna all'accesso"
83 + error_invalid_code: "Codice non valido. Riprova."
84 + error_network: "Errore di rete. Riprova."
85 +
86 +# Sicurezza 2FA
87 +settings_security:
88 + page_title: "Sicurezza - RustWarden"
89 + title: "Autenticazione a due fattori"
90 + subtitle: "Proteggi il tuo account con un livello di sicurezza aggiuntivo"
91 + totp_title: "Autenticazione TOTP"
92 + status_enabled: "Attivata"
93 + status_disabled: "Disattivata"
94 + enabled_message: "Il tuo account è protetto dall'autenticazione a due fattori."
95 + disabled_message: "Aggiungi un livello di sicurezza aggiuntivo al tuo account attivando l'autenticazione a due fattori tramite un'app come Google Authenticator o Authy."
96 + enable_button: "Attiva 2FA"
97 + disable_button: "Disattiva 2FA"
98 + scan_qr: "Scansiona questo codice QR con la tua app di autenticazione"
99 + manual_entry: "Oppure inserisci questo codice manualmente"
100 + enter_code: "Inserisci il codice a 6 cifre dalla tua app"
101 + verify_button: "Verifica e attiva"
102 + backup_codes_title: "Codici di backup"
103 + backup_codes_warning: "Conserva questi codici in un luogo sicuro. Ogni codice può essere utilizzato una sola volta."
104 + backup_codes_remaining: "Codici di backup rimanenti"
105 + regenerate_button: "Rigenera codici"
106 + regenerate_confirm: "Sei sicuro? I vecchi codici verranno sostituiti."
107 + confirm_disable: "Inserisci la tua password per disattivare il 2FA"
108 + password_label: "Password attuale"
109 + copy_button: "Copia"
110 + saved_codes_button: "Ho salvato i miei codici"
111 + breadcrumb: "Sicurezza"
112 + force_2fa_message: "L'amministratore richiede l'autenticazione a due fattori. Devi configurare il 2FA prima di poter accedere al resto dell'applicazione."
113 + force_2fa_logout: "Disconnettersi"
114 +
115 +# Impostazioni
116 +settings:
117 + title: "Impostazioni"
118 + page_title: "Impostazioni - RustWarden"
119 + subtitle: "Gestisci le tue preferenze e impostazioni dell'account"
120 + profile_title: "Il mio Profilo"
121 + profile_desc: "Modifica le tue informazioni personali (nome utente, email)"
122 + password_title: "Password"
123 + password_desc: "Cambia la tua password per proteggere il tuo account"
124 + sessions_title: "Sessioni attive"
125 + sessions_desc: "Gestisci le tue sessioni attive e revoca l'accesso da altri dispositivi"
126 + preferences_title: "Preferenze"
127 + preferences_desc: "Personalizza la tua esperienza (tema, lingua)"
128 + access: "Accedi"
129 + info_title: "Informazioni"
130 + info_desc: "Utilizza le sezioni sopra per gestire i diversi aspetti del tuo account. Tutte le modifiche sono sicure e richiedono la tua autenticazione."
131 + language: "Lingua"
132 + language_description: "Scegli la tua lingua preferita"
133 + save: "Salva"
134 + saved: "Impostazioni salvate"
135 +
136 +# Pagina principale
137 +home:
138 + welcome: "Benvenuto, {username}!"
139 + subtitle: "Benvenuto nel tuo spazio personale"
140 + my_account: "Il mio Account"
141 + stat_username: "Nome utente"
142 + stat_email: "Email"
143 + stat_member_since: "Membro dal"
144 + stat_roles: "Ruoli"
145 +
146 +# Preferenze
147 +preferences:
148 + title: "Preferenze di visualizzazione"
149 + page_title: "Preferenze di visualizzazione - RustCloud"
150 + subtitle: "Personalizza l'aspetto dell'interfaccia"
151 + theme: "Tema"
152 + theme_light: "Chiaro"
153 + theme_light_desc: "Interfaccia luminosa e pulita"
154 + theme_dark: "Scuro"
155 + theme_dark_desc: "Riduce l'affaticamento degli occhi"
156 + theme_auto: "Automatico"
157 + theme_auto_desc: "Si adatta all'ora del giorno"
158 + theme_preview: "Il tema verrà applicato immediatamente dopo il salvataggio"
159 + language: "Lingua"
160 + language_fr: "Français"
161 + language_fr_desc: "Interfaccia in francese"
162 + language_en: "English"
163 + language_en_desc: "Interfaccia in inglese"
164 + language_de: "Deutsch"
165 + language_de_desc: "Interfaccia in tedesco"
166 + language_es: "Español"
167 + language_es_desc: "Interfaccia in spagnolo"
168 + language_it: "Italiano"
169 + language_it_desc: "Interfaccia in italiano"
170 + language_pt: "Português"
171 + language_pt_desc: "Interfaccia in portoghese"
172 + translation_warning: "La traduzione completa dell'interfaccia è in fase di sviluppo. Alcuni elementi potrebbero rimanere in francese."
173 + modified: "Preferenze modificate"
174 + modified_desc: "Salva le modifiche per applicarle"
175 + save: "Salva le preferenze"
176 + saving: "Salvataggio..."
177 + cancel: "Annulla"
178 + saved_success: "Preferenze salvate con successo!"
179 + saved_reload: "Preferenze salvate con successo!\n\nLa lingua è stata modificata. Vuoi ricaricare la pagina ora per applicare le modifiche?"
180 + error: "Errore: {message}"
181 + network_error: "Errore di rete. Riprova."
182 + sync_title: "Le tue preferenze sono sincronizzate"
183 + sync_desc: "Le tue preferenze di visualizzazione sono salvate e si applicano automaticamente su tutti i dispositivi connessi al tuo account RustCloud."
184 + preview_dark: "Anteprima: Tema scuro verrà applicato"
185 + preview_light: "Anteprima: Tema chiaro verrà applicato"
186 + preview_auto: "Anteprima: Tema automatico verrà applicato"
187 +
188 +# Profilo
189 +profile:
190 + title: "Il mio Profilo"
191 + page_title: "Il mio Profilo - RustCloud"
192 + subtitle: "Modifica le tue informazioni personali"
193 + account_info: "Informazioni dell'account"
194 + username: "Nome utente"
195 + username_public: "Visibile pubblicamente"
196 + username_placeholder: "Il tuo nome utente"
197 + username_pattern: "Solo lettere, numeri, trattini e underscore"
198 + email: "Email"
199 + email_placeholder: "tua@email.com"
200 + save: "Salva le modifiche"
201 + saving: "Salvataggio..."
202 + saved: "Profilo aggiornato con successo"
203 + error: "Errore durante l'aggiornamento del profilo"
204 + email_label: "Indirizzo email"
205 + email_used_for: "Utilizzato per l'accesso"
206 + email_warning: "⚠️ Verrai disconnesso dopo la modifica"
207 + email_change_info: "Le modifiche all'email richiedono una nuova connessione per motivi di sicurezza."
208 + username_help: "3-50 caratteri (lettere, numeri, -, _)"
209 + additional_info: "Informazioni aggiuntive"
210 + account_created: "Account creato il"
211 + roles: "Ruoli"
212 + linked_accounts_title: "Account collegati"
213 + no_linked_accounts: "Nessun account esterno collegato."
214 + linked_since: "Collegato dal"
215 + unlink_button: "Scollega"
216 + unlink_confirm: "Sei sicuro di voler scollegare questo account?"
217 + unlink_error: "Impossibile scollegare questo account."
218 + cannot_unlink_last: "Impossibile rimuovere l'ultimo metodo di autenticazione."
219 +
220 +# Breadcrumb
221 +breadcrumb:
222 + home: "Home"
223 + settings: "Impostazioni"
224 + admin: "Amministrazione"
225 + preferences: "Preferenze"
226 + profile: "Profilo"
227 + password: "Password"
228 + users: "Utenti"
229 + sessions: "Sessioni"
230 +
231 +# Admin
232 +admin:
233 + dashboard:
234 + title: "Amministrazione"
235 + heading: "Pannello di controllo Amministratore"
236 + users_card_title: "Utenti"
237 + users_card_subtitle: "Totale utenti"
238 + access_button: "Accedi"
239 + sessions_card_title: "Sessioni attive"
240 + sessions_card_subtitle: "Sessioni in corso"
241 + admins_card_title: "Amministratori"
242 + admins_card_subtitle: "Account admin"
243 + last_activity_card_title: "Ultima attività"
244 + last_activity_card_subtitle: "Ultimo accesso"
245 + audit_section_title: "Statistiche log di audit"
246 + audit_total_logs: "Totale log"
247 + audit_last_24h: "Ultime 24h"
248 + audit_by_level: "Per livello"
249 + chart_label: "Log per livello"
250 + roles_card_title: "Gestione Ruoli"
251 + roles_card_desc: "Gestisci i ruoli e i permessi granulari"
252 + roles_stat_title: "Ruoli"
253 + settings_card_title: "Impostazioni Applicazione"
254 + settings_card_desc: "Configura le impostazioni globali dell'applicazione"
255 + apps_card_title: "Gestione Applicazioni"
256 + apps_card_desc: "Attiva o disattiva le applicazioni modulari"
257 + orgs_card_title: "Gestione Organizzazioni"
258 + orgs_card_desc: "Crea e gestisci le organizzazioni e i loro membri"
259 + orgs_stat_title: "Organizzazioni"
260 + orgs_members_stat_title: "Membri totali"
261 + roles_list:
262 + page_title: "Gestione dei ruoli"
263 + breadcrumb_roles: "Ruoli"
264 + title: "Ruoli"
265 + create_role: "Crea un ruolo"
266 + table_name: "Nome"
267 + table_description: "Descrizione"
268 + table_users: "Utenti"
269 + table_permissions: "Permessi"
270 + table_actions: "Azioni"
271 + edit: "Modifica"
272 + delete: "Elimina"
273 + no_roles: "Nessun ruolo trovato."
274 + protected_hint: "Protetto"
275 + delete_confirm_title: "Conferma eliminazione"
276 + delete_confirm_message: "Sei sicuro di voler eliminare il ruolo"
277 + delete_button: "Elimina"
278 + role_form:
279 + title_create: "Crea un ruolo"
280 + title_edit: "Modifica ruolo"
281 + breadcrumb_create: "Crea"
282 + breadcrumb_edit: "Modifica"
283 + back_to_list: "Torna alla lista"
284 + name_label: "Nome del ruolo"
285 + name_placeholder: "es: moderator"
286 + description_label: "Descrizione"
287 + description_placeholder: "Descrizione del ruolo..."
288 + permissions_label: "Permessi"
289 + protected_name_hint: "Il nome di questo ruolo non può essere modificato."
290 + cancel: "Annulla"
291 + submit_create: "Crea ruolo"
292 + submit_edit: "Salva modifiche"
293 + errors:
294 + title: "Codici di Errore"
295 + description: "Documentazione dei codici di errore dell'applicazione. Ogni codice di errore è associato a un middleware e fornisce informazioni di contesto per il debug."
296 + breadcrumb_errors: "Codici di Errore"
297 + table_code: "Codice"
298 + table_description: "Descrizione"
299 + table_category: "Categoria"
300 + table_middleware: "Middleware"
301 + table_context: "Contesto"
302 + table_example: "Esempio"
303 + legend_title: "Legenda"
304 + legend_code: "Identificatore unico dell'errore (formato: CATEGORY_NUMBER)"
305 + legend_description: "Descrizione dell'errore"
306 + legend_category: "Tipo di errore (Autenticazione, CSRF, Rate Limiting, ecc.)"
307 + legend_middleware: "Middleware responsabile della generazione di questo errore"
308 + legend_context: "Spiegazione dettagliata di quando si verifica questo errore"
309 + legend_example: "Esempio concreto di situazione che genera questo errore"
310 + error_auth001_desc: "Credenziali non valide"
311 + error_auth001_category: "Autenticazione"
312 + error_auth001_context: "L'utente ha fornito credenziali errate durante l'accesso"
313 + error_auth001_example: "Tentativo di accesso con password errata"
314 + error_auth002_desc: "Token non valido"
315 + error_auth002_category: "Autenticazione"
316 + error_auth002_context: "Il token JWT fornito non è valido o è malformato"
317 + error_auth002_example: "Token JWT corrotto o firmato con una chiave diversa"
318 + error_auth003_desc: "Token scaduto"
319 + error_auth003_category: "Autenticazione"
320 + error_auth003_context: "Il token JWT è scaduto e deve essere rinnovato"
321 + error_auth003_example: "Tentativo di accesso con un token scaduto dopo il periodo di validità"
322 + error_csrf001_desc: "Token CSRF mancante"
323 + error_csrf001_category: "Protezione CSRF"
324 + error_csrf001_context: "La richiesta POST/PUT/DELETE non contiene un token CSRF"
325 + error_csrf001_example: "Modulo inviato senza token CSRF nell'header o nel body"
326 + error_csrf002_desc: "Token CSRF non valido"
327 + error_csrf002_category: "Protezione CSRF"
328 + error_csrf002_context: "Il token CSRF fornito non corrisponde al token della sessione"
329 + error_csrf002_example: "Token CSRF modificato o rubato"
330 + error_rate001_desc: "Limite di frequenza superato"
331 + error_rate001_category: "Rate Limiting"
332 + error_rate001_context: "Il numero di richieste al minuto è stato superato"
333 + error_rate001_example: "Più di 100 richieste/min sugli endpoint admin"
334 + error_valid001_desc: "Dati di input non validi"
335 + error_valid001_category: "Validazione"
336 + error_valid001_context: "I dati forniti non rispettano le regole di validazione"
337 + error_valid001_example: "Email in formato non valido o campo obbligatorio mancante"
338 + error_valid002_desc: "Complessità della password non rispettata"
339 + error_valid002_category: "Validazione"
340 + error_valid002_context: "La password non rispetta le regole di complessità configurate"
341 + error_valid002_example: "Password troppo corta o mancante dei caratteri richiesti"
342 + error_rbac001_desc: "Permessi insufficienti"
343 + error_rbac001_category: "Autorizzazione"
344 + error_rbac001_context: "L'utente non ha i permessi necessari per questa azione"
345 + error_rbac001_example: "Utente non admin che tenta di accedere a /admin/users"
346 + error_rbac002_desc: "Ruolo amministratore richiesto"
347 + error_rbac002_category: "Autorizzazione"
348 + error_rbac002_context: "L'azione richiede il ruolo di amministratore"
349 + error_rbac002_example: "Tentativo di eliminazione utente senza ruolo admin"
350 + error_db001_desc: "Errore operazione database"
351 + error_db001_category: "Database"
352 + error_db001_context: "Un'operazione del database è fallita"
353 + error_db001_example: "Vincolo di chiave esterna violato o connessione DB persa"
354 + error_db002_desc: "Risorsa non trovata"
355 + error_db002_category: "Database"
356 + error_db002_context: "La risorsa richiesta non esiste nel database"
357 + error_db002_example: "Utente con ID inesistente"
358 + user_form:
359 + breadcrumb_create: "Crea"
360 + breadcrumb_edit: "Modifica"
361 + title_create: "Crea un utente"
362 + title_edit: "Modifica un utente"
363 + back_to_list: "Torna alla lista"
364 + username_label: "Nome utente"
365 + username_placeholder: "nome_utente"
366 + username_readonly_hint: "Il nome utente non può essere modificato dopo la creazione"
367 + email_label: "Email"
368 + email_placeholder: "utente@example.com"
369 + password_label: "Password"
370 + password_optional: "Opzionale"
371 + password_placeholder: "Lascia vuoto per generare una password casuale"
372 + password_hint: "Se lasciato vuoto, verrà generata automaticamente una password sicura"
373 + roles_label: "Ruoli"
374 + roles_hint: "Seleziona almeno un ruolo"
375 + must_change_password_label: "Forza cambio password"
376 + must_change_password_desc: "L'utente dovrà cambiare la password al prossimo accesso"
377 + cancel: "Annulla"
378 + submit_create: "Crea utente"
379 + submit_edit: "Salva modifiche"
380 + settings:
381 + title: "Impostazioni dell'Applicazione"
382 + page_title: "Impostazioni dell'Applicazione - Admin - RustCloud"
383 + subtitle: "Configurazione globale di RustCloud"
384 + general: "Generale"
385 + app_name: "Nome dell'applicazione"
386 + app_domain: "Dominio"
387 + app_domain_placeholder: "example.com"
388 + app_description: "Descrizione"
389 + allow_registration: "Consenti la registrazione"
390 + allow_registration_desc: "Gli utenti possono creare account dalla pagina principale"
391 + email_validation_required: "Validazione email richiesta"
392 + email_validation_required_desc: "Gli utenti devono validare la loro email prima di poter accedere"
393 + save: "Salva le impostazioni"
394 + cancel: "Annulla"
395 + saved: "Impostazioni salvate con successo"
396 + error: "Errore durante il salvataggio"
397 + network_error: "Errore di rete. Riprova."
398 + password_security: "Sicurezza delle Password"
399 + password_min_length: "Lunghezza minima"
400 + password_min_length_hint: "Tra 6 e 128 caratteri"
401 + password_complexity: "Requisiti di complessità"
402 + require_uppercase: "Maiuscola richiesta"
403 + require_lowercase: "Minuscola richiesta"
404 + require_digits: "Cifra richiesta"
405 + require_special: "Carattere speciale richiesto"
406 + password_change_require_email: "Conferma via email del cambio password"
407 + password_change_require_email_desc: "Quando attivato, viene inviata un'email di conferma per validare qualsiasi cambio di password. Altrimenti, il cambio viene applicato immediatamente."
408 + password_expiration: "Scadenza delle password"
409 + password_expiration_desc: "Forza il cambio periodico"
410 + expiration_days: "Scadenza dopo (giorni)"
411 + expiration_alert: "Avviso email prima della scadenza"
412 + expiration_alert_days: "Avviso (giorni prima)"
413 + audit_logging: "Registrazione Audit"
414 + log_level: "Livello di log"
415 + log_level_debug: "DEBUG - Tutti i dettagli"
416 + log_level_info: "INFO - Informazioni normali"
417 + log_level_warn: "WARN - Solo avvertimenti"
418 + log_level_error: "ERROR - Solo errori"
419 + log_level_hint: "Controlla la verbosità dei log di audit"
420 + log_retention: "Conservazione dei log (giorni)"
421 + log_retention_hint: "Periodo di conservazione dei log (1-365 giorni)"
422 + audit_info_message: "I log di audit registrano tutte le azioni amministrative (creazione, modifica, eliminazione di utenti)."
423 + save_changes: "Salva le modifiche"
424 + save_changes_desc: "Le impostazioni verranno applicate immediatamente"
425 + saving: "Salvataggio..."
426 + save_success: "Impostazioni salvate con successo!"
427 + save_error_prefix: "Errore"
428 + oauth_sso_title: "OAuth / SSO"
429 + oauth_enabled_label: "Attiva OAuth"
430 + oauth_enabled_desc: "Consenti l'accesso tramite provider esterni (Google, GitHub, ecc.)"
431 + oauth_redirect_url_label: "URL di reindirizzamento base"
432 + oauth_redirect_url_desc: "URL base per i callback OAuth (es: https://miaapp.example.com)"
433 + oauth_auto_register_label: "Auto-registrazione"
434 + oauth_auto_register_desc: "Crea automaticamente un account se l'email non esiste"
435 + oauth_link_existing_label: "Collega account esistenti"
436 + oauth_link_existing_desc: "Collega automaticamente un account OAuth se l'email verificata corrisponde"
437 + oauth_client_id: "Client ID"
438 + oauth_client_secret: "Client Secret"
439 + oauth_tenant: "Tenant ID"
440 + oauth_redirect_uri: "URI di reindirizzamento"
441 + oauth_copy_uri: "Copia"
442 + oauth_secret_configured: "Configurato"
443 + oauth_secret_not_configured: "Non configurato"
444 + oauth_secret_change: "Modifica"
445 + oauth_restart_warning: "Le modifiche alle credenziali richiedono un riavvio dell'applicazione."
446 + oauth_env_info: "Le credenziali possono anche essere configurate tramite variabili d'ambiente (OAUTH_*_CLIENT_ID, ecc.)."
447 + oauth_google_label: "Google"
448 + oauth_google_desc: "OpenID Connect"
449 + oauth_github_label: "GitHub"
450 + oauth_github_desc: "OAuth2"
451 + oauth_discord_label: "Discord"
452 + oauth_discord_desc: "OAuth2"
453 + oauth_microsoft_label: "Microsoft"
454 + oauth_microsoft_desc: "OpenID Connect"
455 + users_list:
456 + page_title: "Gestione Utenti - Admin - RustCloud"
457 + title: "Gestione Utenti"
458 + breadcrumb_users: "Utenti"
459 + create_user: "Crea un utente"
460 + search_label: "Cerca"
461 + search_placeholder: "Nome utente o email..."
462 + role_filter_label: "Ruolo"
463 + all_roles: "Tutti i ruoli"
464 + per_page_label: "Per pagina"
465 + search_button: "Cerca"
466 + results_summary: "Visualizzazione di {count} utente/i su {total} totali"
467 + page_info: "Pagina {page} di {total_pages}"
468 + table_username: "Nome utente"
469 + table_email: "Email"
470 + table_roles: "Ruoli"
471 + table_created_at: "Creato il"
472 + table_last_login: "Ultimo accesso"
473 + table_actions: "Azioni"
474 + never: "Mai"
475 + edit: "Modifica"
476 + delete: "Elimina"
477 + reset_password: "Reimposta la password"
478 + no_users: "Nessun utente trovato"
479 + reset_password_title: "Reimposta la password"
480 + reset_generate: "Genera una password casuale"
481 + reset_custom: "Imposta una password personalizzata"
482 + reset_email: "Reimposta via email"
483 + reset_new_password_label: "Nuova password"
484 + reset_new_password_placeholder: "Minimo 8 caratteri, maiuscola, minuscola, cifra, carattere speciale"
485 + reset_password_requirements: "⚠️ Deve contenere: 8+ caratteri, maiuscola, minuscola, cifra, carattere speciale"
486 + reset_invalidate_sessions: "Invalida tutte le sessioni attive di questo utente"
487 + reset_invalidate_sessions_desc: "L'utente dovrà riconnettersi dopo la modifica"
488 + reset_button: "Reimposta"
489 + delete_confirm_title: "⚠️ Conferma eliminazione"
490 + delete_confirm_message: "Sei sicuro di voler eliminare questo utente?"
491 + delete_confirm_username: "Nome utente:"
492 + delete_confirm_email: "Email:"
493 + delete_irreversible: "Questa azione è irreversibile!"
494 + delete_button: "Elimina definitivamente"
495 + edit_user_title: "Modifica utente"
496 + delete_error: "Errore durante l'eliminazione dell'utente"
497 + network_error: "Errore di rete"
498 + resetting: "Reimpostazione..."
499 + reset_by_email_title: "Reimpostazione via email"
500 + close: "Chiudi"
501 + password_reset_success_title: "Password reimpostata"
502 + password_warning_once: "Questa password verrà mostrata una sola volta. Copiala ora!"
503 + new_password_label: "Nuova password"
504 + copy: "Copia"
505 + password_copied: "Password copiata negli appunti"
506 + password_set_success: "Password reimpostata con successo"
507 + reset_error: "Errore durante la reimpostazione della password"
508 +
509 +# Cambio password
510 +password:
511 + title: "Cambia la mia password"
512 + page_title: "Cambia la mia password - RustCloud"
513 + subtitle: "Modifica la tua password per proteggere il tuo account. Un'email di conferma ti verrà inviata per validare la modifica."
514 + subtitle_no_email: "Modifica la tua password per proteggere il tuo account."
515 + must_change_message: "⚠️ Devi cambiare la tua password prima di continuare."
516 + new_password: "Nuova password"
517 + current_password: "Password attuale"
518 + current_password_placeholder: "La tua password attuale"
519 + new_password_placeholder: "Nuova password sicura"
520 + required: "Obbligatorio"
521 + requirements: "📌 Rispetta i requisiti di complessità indicati di seguito."
522 + strength: "Sicurezza della password:"
523 + strength_weak: "Debole"
524 + strength_medium: "Media"
525 + strength_strong: "Forte ✓"
526 + strength_too_short: "troppo corta"
527 + strength_missing_lowercase: "minuscola mancante"
528 + strength_missing_uppercase: "maiuscola mancante"
529 + strength_missing_digit: "cifra mancante"
530 + strength_missing_special: "carattere speciale mancante"
531 + confirm_password: "Conferma la nuova password"
532 + confirm_password_placeholder: "Ripeti la nuova password"
533 + passwords_match: "✓ Le password corrispondono"
534 + passwords_not_match: "✗ Le password non corrispondono"
535 + change_button: "Cambia password"
536 + changing: "Modifica in corso..."
537 + logout_warning_title: "Disconnessione dalle altre sessioni"
538 + logout_warning_desc: "Tutte le tue altre sessioni attive verranno automaticamente disconnesse dopo il cambio di password. Solo la tua sessione attuale rimarrà attiva."
539 + save: "Salva la nuova password"
540 + saving: "Salvataggio..."
541 + saved: "Un'email di conferma è stata inviata al tuo indirizzo. Clicca sul link nell'email per validare il cambio di password."
542 + saved_direct: "Password cambiata con successo! Le tue altre sessioni sono state disconnesse."
543 + error: "Errore durante la modifica della password"
544 + error_all_fields_required: "Tutti i campi sono obbligatori."
545 + error_empty_data: "I dati da inviare sono vuoti."
546 + error_invalid_data: "Dati non validi. Verifica che tutti i campi siano compilati."
547 + error_incorrect_current: "Password attuale errata."
548 + error_reading_response: "Errore durante la lettura della risposta del server"
549 + error_415: "Errore 415: Il server non ha ricevuto i dati nel formato JSON atteso. Riprova."
550 + network_error: "Errore di rete. Riprova."
551 + security_tips_title: "Consigli di sicurezza"
552 + security_tip_unique: "Usa una password unica che non usi altrove"
553 + security_tip_share: "Non condividere mai la tua password con nessuno"
554 + security_tip_regular: "Cambia la tua password regolarmente (ogni 3-6 mesi)"
555 + security_tip_2fa: "Attiva l'autenticazione a due fattori se disponibile"
556 + security_tip_manager: "Usa un gestore di password per maggiore sicurezza"
557 + pending_request_message: "⚠️ Una richiesta di cambio password è in attesa di conferma via email."
558 + pending_request_info: "Un'email di conferma è stata inviata al tuo indirizzo. Clicca sul link nell'email per confermare la modifica."
559 + pending_request_cancel: "Annulla la richiesta in attesa"
560 + pending_request_canceled: "Richiesta annullata con successo"
561 + pending_request_cancel_error: "Errore durante l'annullamento della richiesta"
562 + confirmation_sent: "Email di conferma inviata! Controlla la tua casella di posta."
563 + confirmation_error: "Errore durante l'invio dell'email di conferma"
564 +
565 +# Sessioni
566 +sessions:
567 + title: "Sessioni attive"
568 + page_title: "Sessioni attive - RustCloud"
569 + subtitle: "Gestisci i dispositivi connessi al tuo account"
570 + current_session: "Sessione attuale"
571 + current_badge: "Attuale"
572 + unknown_device: "Dispositivo sconosciuto"
573 + ip_address: "Indirizzo IP:"
574 + created_at: "Creata il:"
575 + last_activity: "Ultima attività:"
576 + session_id: "ID Sessione:"
577 + revoke: "Revoca"
578 + revoke_confirm: "Sei sicuro di voler revocare la sessione per \"{device}\"?\n\nQuesto dispositivo verrà disconnesso immediatamente."
579 + revoke_success: "Sessione revocata con successo!"
580 + revoke_error: "Errore durante la revoca della sessione"
581 + no_sessions: "Nessuna sessione attiva"
582 + no_sessions_desc: "Non hai attualmente sessioni attive"
583 + danger_zone: "Zona pericolosa"
584 + danger_zone_desc: "Disconnetti tutti gli altri dispositivi in un'unica azione (la tua sessione attuale rimarrà attiva)."
585 + revoke_all: "Revoca tutte le altre sessioni"
586 + revoke_all_confirm: "⚠️ ATTENZIONE\n\nSei sicuro di voler revocare TUTTE le tue altre sessioni?\n\nTutti i tuoi altri dispositivi verranno disconnessi. Solo la tua sessione attuale rimarrà attiva."
587 + revoke_all_success: "Tutte le altre sessioni sono state revocate!"
588 + revoke_all_error: "Errore durante la revoca. Alcune sessioni potrebbero non essere state revocate."
589 + about_title: "Informazioni sulle sessioni"
590 + about_desc: "Una sessione viene creata ogni volta che accedi da un dispositivo o browser diverso. Revocare una sessione disconnette immediatamente quel dispositivo. La tua sessione attuale non può essere revocata da questa pagina (disconnettiti per farlo)."
591 + back_to_settings: "Torna alle impostazioni"
592 +
593 +# Menu di navigazione (navbar)
594 +nav:
595 + home: "Home"
596 + settings: "Impostazioni"
597 + admin: "Amministrazione"
598 +
599 +# Menu utente
600 +user_menu:
601 + profile: "Profilo"
602 + logout: "Disconnetti"
603 + settings: "Impostazioni"
604 +
605 +# Organizzazioni
606 +organisations:
607 + list:
608 + title: "Gestione delle Organizzazioni"
609 + create: "Crea un'organizzazione"
610 + search: "Cerca"
611 + search_placeholder: "Nome o descrizione..."
612 + no_organisations: "Nessuna organizzazione trovata"
613 + results_summary: "Risultati"
614 + manage_members: "Gestisci i membri"
615 + table:
616 + name: "Nome"
617 + description: "Descrizione"
618 + members: "Membri"
619 + created_at: "Creata il"
620 + actions: "Azioni"
621 + create:
622 + title: "Crea un'organizzazione"
623 + submit: "Crea"
624 + success: "Organizzazione creata con successo"
625 + error:
626 + already_exists: "L'organizzazione '{gid}' esiste già"
627 + invalid_gid: "Formato gid non valido: {gid}"
628 + generic: "Si è verificato un errore durante la creazione dell'organizzazione"
629 + edit:
630 + title: "Modifica l'organizzazione"
631 + submit: "Salva"
632 + update:
633 + success: "Organizzazione aggiornata con successo"
634 + delete:
635 + success: "Organizzazione eliminata con successo"
636 + confirm: "Sei sicuro di voler eliminare questa organizzazione? Questa azione è irreversibile."
637 + form:
638 + name_label: "Nome"
639 + name_placeholder: "Nome dell'organizzazione"
640 + description_label: "Descrizione"
641 + description_placeholder: "Descrizione dell'organizzazione (opzionale)"
642 + gid_label: "Identificatore (GID)"
643 + gid_placeholder: "identificatore-unico"
644 + gid_help: "Identificatore unico (lettere, numeri, underscore, max 64 caratteri)"
645 + members:
646 + title: "Membri dell'organizzazione"
647 + add_member: "Aggiungi un membro"
648 + search_user_label: "Cerca un utente"
649 + search_user_placeholder: "Nome utente o email..."
650 + no_members: "Nessun membro in questa organizzazione"
651 + back_to_list: "Torna alla lista"
652 + table:
653 + username: "Nome utente"
654 + role: "Ruolo"
655 + joined_at: "Iscritto il"
656 + actions: "Azioni"
657 + add_success: "Membro aggiunto con successo"
658 + remove_success: "Membro rimosso con successo"
659 + add_error: "Errore durante l'aggiunta del membro"
660 + remove_error: "Errore durante la rimozione del membro"
661 + user_not_found: "Utente '{search}' non trovato"
662 + error:
663 + organisation_not_found: "Organizzazione '{gid}' non trovata"
664 + already_member: "Questo utente è già membro dell'organizzazione"
665 +
666 +# Condivisione
667 +sharing:
668 + modal:
669 + title: "Condividi una risorsa"
670 + resource_label: "Risorsa"
671 + resource_type_label: "Tipo"
672 + expiration_label: "Data di scadenza (opzionale)"
673 + expiration_help: "La condivisione scadrà automaticamente a questa data"
674 + cancel_button: "Annulla"
675 + submit_button: "Condividi"
676 + close_button: "Chiudi"
677 + success_message: "Condivisione creata con successo"
678 + error_message: "Errore durante la creazione della condivisione"
679 + error:
680 + already_exists: "Questa condivisione esiste già per questa risorsa e questa organizzazione"
681 + components:
682 + share_button_label: "Condividi"
683 + permissions_label: "Permessi"
684 + permissions_help: "Scegli i permessi concessi per questa condivisione"
685 + share_with_label: "Condividi con"
686 + user_tab_label: "Utente"
687 + organisation_tab_label: "Organizzazione"
688 + select_user_label: "Seleziona un utente"
689 + search_user_placeholder: "Cerca un utente..."
690 + select_organisation_label: "Seleziona un'organizzazione"
691 + select_organisation_placeholder: "Scegli un'organizzazione..."
692 + no_organisations_message: "Nessuna organizzazione disponibile"
693 + permissions:
694 + read_only: "Sola lettura"
695 + read_only_value: "1"
696 + read_only_description: "Può solo consultare la risorsa"
697 + read_write: "Lettura + Scrittura"
698 + read_write_value: "3"
699 + read_write_description: "Può consultare e modificare la risorsa"
700 + all: "Tutti i permessi"
701 + all_value: "15"
702 + all_description: "Può consultare, modificare, eliminare e condividere"
A crates/rustwarden-ui/locales/pt.yml
+702 -0
@@ -0,0 +1,702 @@
1 +# Traduções portuguesas — rustwarden-ui
2 +# Seções: login, páginas de usuário, admin, navegação
3 +
4 +# Acesso
5 +login:
6 + title: "Entrar"
7 + page_title: "Entrar - RustCloud"
8 + page_description: "Acesso ao RustCloud"
9 + subtitle: "Acesse seu espaço na nuvem"
10 + username: "Nome de usuário ou email"
11 + password: "Senha"
12 + submit: "Entrar"
13 + remember_me: "Lembrar-me"
14 + tab_login: "Entrar"
15 + tab_register: "Registrar"
16 + register_title: "Criar uma conta"
17 + register_submit: "Criar minha conta"
18 + username_placeholder: "Nome de usuário ou email"
19 + password_placeholder: "Sua senha"
20 + username_placeholder_register: "Seu nome de usuário (mín. 3 caracteres)"
21 + password_placeholder_register: "Sua senha (mín. 8 caracteres)"
22 + email_placeholder: "seu@email.com"
23 + forgot_password: "Esqueceu a senha?"
24 + email_not_verified: "Email não verificado?"
25 + resend_verification: "Reenviar o email de verificação"
26 + already_have_account: "Já tem uma conta?"
27 + sign_in_link: "Entrar"
28 + success_message: "Login realizado com sucesso! Redirecionando..."
29 + error_message: "Erro de login. Verifique suas credenciais."
30 + email_unverified_message: "Seu email não está verificado. Clique abaixo para reenviar o link."
31 + resend_button: "Reenviar o email de verificação"
32 + resend_success: "Email de verificação reenviado se a conta existir."
33 + resend_error: "Não foi possível enviar a solicitação no momento."
34 + resend_prompt: "Insira seu email antes de reenviar."
35 + register_success: "Conta criada com sucesso! Verifique seu email."
36 + register_error: "Erro durante o registro"
37 + email_label: "Email"
38 + footer_text: "Fork do Nextcloud em Rust"
39 +
40 +# Reenvio de email de verificação
41 +resend_verification:
42 + page_title: "Reenviar o email de verificação - RustWarden"
43 + title: "Reenviar o email de verificação"
44 + subtitle: "Insira seu endereço de email para receber um novo link de verificação."
45 + email_label: "Endereço de email"
46 + email_placeholder: "voce@exemplo.com"
47 + submit: "Enviar o link de verificação"
48 + back_to_login: "Voltar ao login"
49 + error_default: "Não foi possível enviar a solicitação."
50 + success_default: "Se a conta existir e não estiver verificada, um email foi enviado."
51 + error_missing_email: "Insira seu email."
52 + error_cannot_send: "Não foi possível enviar a solicitação no momento."
53 +
54 +# Redefinição de senha (via link de email)
55 +password_reset:
56 + page_title: "Redefinir a senha"
57 + title: "Redefinir a senha"
58 + subtitle: "Insira sua nova senha abaixo."
59 + new_password_label: "Nova senha"
60 + new_password_placeholder: "Nova senha segura"
61 + requirements: "⚠️ Deve conter: 8+ caracteres, maiúscula, minúscula, dígito, caractere especial"
62 + confirm_password_label: "Confirmar senha"
63 + confirm_password_placeholder: "Confirme sua nova senha"
64 + missing_token_url: "Token de redefinição ausente na URL."
65 + missing_token: "Token de redefinição ausente."
66 + missing_new_password: "Insira uma nova senha."
67 + passwords_not_match: "As senhas não correspondem."
68 + success_message: "Sua senha foi redefinida com sucesso. Agora você pode entrar."
69 + error_link_invalid: "Não foi possível redefinir a senha. O link pode ter expirado ou ser inválido."
70 + error_generic: "Não foi possível redefinir a senha no momento."
71 +
72 +# Página de login 2FA
73 +login_2fa:
74 + page_title: "Verificação 2FA - RustCloud"
75 + title: "Verificação em duas etapas"
76 + subtitle: "Insira o código do seu aplicativo de autenticação"
77 + code_label: "Código de autenticação"
78 + backup_code_label: "Código de backup"
79 + verify_button: "Verificar"
80 + use_backup_code: "Usar um código de backup"
81 + use_totp_code: "Usar o código do aplicativo"
82 + back_to_login: "Voltar ao login"
83 + error_invalid_code: "Código inválido. Tente novamente."
84 + error_network: "Erro de rede. Tente novamente."
85 +
86 +# Segurança 2FA
87 +settings_security:
88 + page_title: "Segurança - RustWarden"
89 + title: "Autenticação de dois fatores"
90 + subtitle: "Proteja sua conta com uma camada adicional de segurança"
91 + totp_title: "Autenticação TOTP"
92 + status_enabled: "Ativada"
93 + status_disabled: "Desativada"
94 + enabled_message: "Sua conta está protegida pela autenticação de dois fatores."
95 + disabled_message: "Adicione uma camada extra de segurança à sua conta ativando a autenticação de dois fatores por meio de um aplicativo como Google Authenticator ou Authy."
96 + enable_button: "Ativar 2FA"
97 + disable_button: "Desativar 2FA"
98 + scan_qr: "Escaneie este código QR com seu aplicativo de autenticação"
99 + manual_entry: "Ou insira este código manualmente"
100 + enter_code: "Insira o código de 6 dígitos do seu aplicativo"
101 + verify_button: "Verificar e ativar"
102 + backup_codes_title: "Códigos de backup"
103 + backup_codes_warning: "Guarde estes códigos em um lugar seguro. Cada código pode ser usado apenas uma vez."
104 + backup_codes_remaining: "Códigos de backup restantes"
105 + regenerate_button: "Regenerar códigos"
106 + regenerate_confirm: "Tem certeza? Os códigos antigos serão substituídos."
107 + confirm_disable: "Insira sua senha para desativar o 2FA"
108 + password_label: "Senha atual"
109 + copy_button: "Copiar"
110 + saved_codes_button: "Salvei meus códigos"
111 + breadcrumb: "Segurança"
112 + force_2fa_message: "O administrador exige a autenticação de dois fatores. Você deve configurar o 2FA antes de poder acessar o restante da aplicação."
113 + force_2fa_logout: "Sair"
114 +
115 +# Configurações
116 +settings:
117 + title: "Configurações"
118 + page_title: "Configurações - RustWarden"
119 + subtitle: "Gerencie suas preferências e configurações de conta"
120 + profile_title: "Meu Perfil"
121 + profile_desc: "Edite suas informações pessoais (nome de usuário, email)"
122 + password_title: "Senha"
123 + password_desc: "Altere sua senha para proteger sua conta"
124 + sessions_title: "Sessões ativas"
125 + sessions_desc: "Gerencie suas sessões ativas e revogue o acesso de outros dispositivos"
126 + preferences_title: "Preferências"
127 + preferences_desc: "Personalize sua experiência (tema, idioma)"
128 + access: "Acessar"
129 + info_title: "Informações"
130 + info_desc: "Use as seções acima para gerenciar os diferentes aspectos da sua conta. Todas as alterações são seguras e requerem sua autenticação."
131 + language: "Idioma"
132 + language_description: "Escolha seu idioma preferido"
133 + save: "Salvar"
134 + saved: "Configurações salvas"
135 +
136 +# Página principal
137 +home:
138 + welcome: "Bem-vindo, {username}!"
139 + subtitle: "Bem-vindo ao seu espaço pessoal"
140 + my_account: "Minha Conta"
141 + stat_username: "Nome de usuário"
142 + stat_email: "Email"
143 + stat_member_since: "Membro desde"
144 + stat_roles: "Funções"
145 +
146 +# Preferências
147 +preferences:
148 + title: "Preferências de exibição"
149 + page_title: "Preferências de exibição - RustCloud"
150 + subtitle: "Personalize a aparência da interface"
151 + theme: "Tema"
152 + theme_light: "Claro"
153 + theme_light_desc: "Interface clara e limpa"
154 + theme_dark: "Escuro"
155 + theme_dark_desc: "Reduz o cansaço visual"
156 + theme_auto: "Automático"
157 + theme_auto_desc: "Adapta-se à hora do dia"
158 + theme_preview: "O tema será aplicado imediatamente após salvar"
159 + language: "Idioma"
160 + language_fr: "Français"
161 + language_fr_desc: "Interface em francês"
162 + language_en: "English"
163 + language_en_desc: "Interface em inglês"
164 + language_de: "Deutsch"
165 + language_de_desc: "Interface em alemão"
166 + language_es: "Español"
167 + language_es_desc: "Interface em espanhol"
168 + language_it: "Italiano"
169 + language_it_desc: "Interface em italiano"
170 + language_pt: "Português"
171 + language_pt_desc: "Interface em português"
172 + translation_warning: "A tradução completa da interface está em desenvolvimento. Alguns elementos podem permanecer em francês."
173 + modified: "Preferências modificadas"
174 + modified_desc: "Salve as alterações para aplicá-las"
175 + save: "Salvar preferências"
176 + saving: "Salvando..."
177 + cancel: "Cancelar"
178 + saved_success: "Preferências salvas com sucesso!"
179 + saved_reload: "Preferências salvas com sucesso!\n\nO idioma foi alterado. Deseja recarregar a página agora para aplicar as alterações?"
180 + error: "Erro: {message}"
181 + network_error: "Erro de rede. Tente novamente."
182 + sync_title: "Suas preferências estão sincronizadas"
183 + sync_desc: "Suas preferências de exibição estão salvas e se aplicam automaticamente em todos os dispositivos conectados à sua conta RustCloud."
184 + preview_dark: "Prévia: Tema escuro será aplicado"
185 + preview_light: "Prévia: Tema claro será aplicado"
186 + preview_auto: "Prévia: Tema automático será aplicado"
187 +
188 +# Perfil
189 +profile:
190 + title: "Meu Perfil"
191 + page_title: "Meu Perfil - RustCloud"
192 + subtitle: "Edite suas informações pessoais"
193 + account_info: "Informações da conta"
194 + username: "Nome de usuário"
195 + username_public: "Visível publicamente"
196 + username_placeholder: "Seu nome de usuário"
197 + username_pattern: "Apenas letras, números, hífens e sublinhados"
198 + email: "Email"
199 + email_placeholder: "seu@email.com"
200 + save: "Salvar alterações"
201 + saving: "Salvando..."
202 + saved: "Perfil atualizado com sucesso"
203 + error: "Erro ao atualizar o perfil"
204 + email_label: "Endereço de email"
205 + email_used_for: "Usado para login"
206 + email_warning: "⚠️ Você será desconectado após a alteração"
207 + email_change_info: "Alterações no email requerem uma nova conexão por motivos de segurança."
208 + username_help: "3-50 caracteres (letras, números, -, _)"
209 + additional_info: "Informações adicionais"
210 + account_created: "Conta criada em"
211 + roles: "Funções"
212 + linked_accounts_title: "Contas vinculadas"
213 + no_linked_accounts: "Nenhuma conta externa vinculada."
214 + linked_since: "Vinculado desde"
215 + unlink_button: "Desvincular"
216 + unlink_confirm: "Tem certeza de que deseja desvincular esta conta?"
217 + unlink_error: "Não foi possível desvincular esta conta."
218 + cannot_unlink_last: "Não é possível remover o último método de autenticação."
219 +
220 +# Breadcrumb
221 +breadcrumb:
222 + home: "Home"
223 + settings: "Configurações"
224 + admin: "Administração"
225 + preferences: "Preferências"
226 + profile: "Perfil"
227 + password: "Senha"
228 + users: "Usuários"
229 + sessions: "Sessões"
230 +
231 +# Admin
232 +admin:
233 + dashboard:
234 + title: "Administração"
235 + heading: "Painel de controle do Administrador"
236 + users_card_title: "Usuários"
237 + users_card_subtitle: "Total de usuários"
238 + access_button: "Acessar"
239 + sessions_card_title: "Sessões ativas"
240 + sessions_card_subtitle: "Sessões em andamento"
241 + admins_card_title: "Administradores"
242 + admins_card_subtitle: "Contas admin"
243 + last_activity_card_title: "Última atividade"
244 + last_activity_card_subtitle: "Último acesso"
245 + audit_section_title: "Estatísticas de log de auditoria"
246 + audit_total_logs: "Total de logs"
247 + audit_last_24h: "Últimas 24h"
248 + audit_by_level: "Por nível"
249 + chart_label: "Logs por nível"
250 + roles_card_title: "Gerenciamento de Funções"
251 + roles_card_desc: "Gerencie as funções e permissões granulares"
252 + roles_stat_title: "Funções"
253 + settings_card_title: "Configurações da Aplicação"
254 + settings_card_desc: "Configure as configurações globais da aplicação"
255 + apps_card_title: "Gerenciamento de Aplicações"
256 + apps_card_desc: "Ative ou desative as aplicações modulares"
257 + orgs_card_title: "Gerenciamento de Organizações"
258 + orgs_card_desc: "Crie e gerencie organizações e seus membros"
259 + orgs_stat_title: "Organizações"
260 + orgs_members_stat_title: "Membros totais"
261 + roles_list:
262 + page_title: "Gerenciamento de funções"
263 + breadcrumb_roles: "Funções"
264 + title: "Funções"
265 + create_role: "Criar uma função"
266 + table_name: "Nome"
267 + table_description: "Descrição"
268 + table_users: "Usuários"
269 + table_permissions: "Permissões"
270 + table_actions: "Ações"
271 + edit: "Editar"
272 + delete: "Excluir"
273 + no_roles: "Nenhuma função encontrada."
274 + protected_hint: "Protegida"
275 + delete_confirm_title: "Confirmar exclusão"
276 + delete_confirm_message: "Tem certeza de que deseja excluir a função"
277 + delete_button: "Excluir"
278 + role_form:
279 + title_create: "Criar uma função"
280 + title_edit: "Editar função"
281 + breadcrumb_create: "Criar"
282 + breadcrumb_edit: "Editar"
283 + back_to_list: "Voltar à lista"
284 + name_label: "Nome da função"
285 + name_placeholder: "ex: moderador"
286 + description_label: "Descrição"
287 + description_placeholder: "Descrição da função..."
288 + permissions_label: "Permissões"
289 + protected_name_hint: "O nome desta função não pode ser alterado."
290 + cancel: "Cancelar"
291 + submit_create: "Criar função"
292 + submit_edit: "Salvar alterações"
293 + errors:
294 + title: "Códigos de Erro"
295 + description: "Documentação dos códigos de erro da aplicação. Cada código de erro está associado a um middleware e fornece informações de contexto para depuração."
296 + breadcrumb_errors: "Códigos de Erro"
297 + table_code: "Código"
298 + table_description: "Descrição"
299 + table_category: "Categoria"
300 + table_middleware: "Middleware"
301 + table_context: "Contexto"
302 + table_example: "Exemplo"
303 + legend_title: "Legenda"
304 + legend_code: "Identificador único do erro (formato: CATEGORY_NUMBER)"
305 + legend_description: "Descrição do erro"
306 + legend_category: "Tipo de erro (Autenticação, CSRF, Rate Limiting, etc.)"
307 + legend_middleware: "Middleware responsável pela geração deste erro"
308 + legend_context: "Explicação detalhada de quando este erro ocorre"
309 + legend_example: "Exemplo concreto de situação que gera este erro"
310 + error_auth001_desc: "Credenciais inválidas"
311 + error_auth001_category: "Autenticação"
312 + error_auth001_context: "O usuário forneceu credenciais incorretas durante o login"
313 + error_auth001_example: "Tentativa de login com senha incorreta"
314 + error_auth002_desc: "Token inválido"
315 + error_auth002_category: "Autenticação"
316 + error_auth002_context: "O token JWT fornecido é inválido ou malformado"
317 + error_auth002_example: "Token JWT corrompido ou assinado com uma chave diferente"
318 + error_auth003_desc: "Token expirado"
319 + error_auth003_category: "Autenticação"
320 + error_auth003_context: "O token JWT expirou e deve ser renovado"
321 + error_auth003_example: "Tentativa de acesso com token expirado após o período de validade"
322 + error_csrf001_desc: "Token CSRF ausente"
323 + error_csrf001_category: "Proteção CSRF"
324 + error_csrf001_context: "A requisição POST/PUT/DELETE não contém um token CSRF"
325 + error_csrf001_example: "Formulário enviado sem token CSRF no header ou no body"
326 + error_csrf002_desc: "Token CSRF inválido"
327 + error_csrf002_category: "Proteção CSRF"
328 + error_csrf002_context: "O token CSRF fornecido não corresponde ao token da sessão"
329 + error_csrf002_example: "Token CSRF modificado ou roubado"
330 + error_rate001_desc: "Limite de taxa excedido"
331 + error_rate001_category: "Rate Limiting"
332 + error_rate001_context: "O número de requisições por minuto foi excedido"
333 + error_rate001_example: "Mais de 100 requisições/min nos endpoints admin"
334 + error_valid001_desc: "Dados de entrada inválidos"
335 + error_valid001_category: "Validação"
336 + error_valid001_context: "Os dados fornecidos não respeitam as regras de validação"
337 + error_valid001_example: "Email em formato inválido ou campo obrigatório ausente"
338 + error_valid002_desc: "Complexidade de senha não respeitada"
339 + error_valid002_category: "Validação"
340 + error_valid002_context: "A senha não respeita as regras de complexidade configuradas"
341 + error_valid002_example: "Senha muito curta ou sem os caracteres exigidos"
342 + error_rbac001_desc: "Permissões insuficientes"
343 + error_rbac001_category: "Autorização"
344 + error_rbac001_context: "O usuário não tem as permissões necessárias para esta ação"
345 + error_rbac001_example: "Usuário não admin tentando acessar /admin/users"
346 + error_rbac002_desc: "Função de administrador necessária"
347 + error_rbac002_category: "Autorização"
348 + error_rbac002_context: "A ação requer a função de administrador"
349 + error_rbac002_example: "Tentativa de exclusão de usuário sem função admin"
350 + error_db001_desc: "Erro na operação do banco de dados"
351 + error_db001_category: "Banco de dados"
352 + error_db001_context: "Uma operação do banco de dados falhou"
353 + error_db001_example: "Restrição de chave estrangeira violada ou conexão com DB perdida"
354 + error_db002_desc: "Recurso não encontrado"
355 + error_db002_category: "Banco de dados"
356 + error_db002_context: "O recurso solicitado não existe no banco de dados"
357 + error_db002_example: "Usuário com ID inexistente"
358 + user_form:
359 + breadcrumb_create: "Criar"
360 + breadcrumb_edit: "Editar"
361 + title_create: "Criar um usuário"
362 + title_edit: "Editar um usuário"
363 + back_to_list: "Voltar à lista"
364 + username_label: "Nome de usuário"
365 + username_placeholder: "nome_usuario"
366 + username_readonly_hint: "O nome de usuário não pode ser alterado após a criação"
367 + email_label: "Email"
368 + email_placeholder: "usuario@exemplo.com"
369 + password_label: "Senha"
370 + password_optional: "Opcional"
371 + password_placeholder: "Deixe em branco para gerar uma senha aleatória"
372 + password_hint: "Se deixado em branco, uma senha segura será gerada automaticamente"
373 + roles_label: "Funções"
374 + roles_hint: "Selecione pelo menos uma função"
375 + must_change_password_label: "Forçar alteração de senha"
376 + must_change_password_desc: "O usuário deverá alterar a senha no próximo acesso"
377 + cancel: "Cancelar"
378 + submit_create: "Criar usuário"
379 + submit_edit: "Salvar alterações"
380 + settings:
381 + title: "Configurações da Aplicação"
382 + page_title: "Configurações da Aplicação - Admin - RustCloud"
383 + subtitle: "Configuração global do RustCloud"
384 + general: "Geral"
385 + app_name: "Nome da aplicação"
386 + app_domain: "Domínio"
387 + app_domain_placeholder: "exemplo.com"
388 + app_description: "Descrição"
389 + allow_registration: "Permitir registro"
390 + allow_registration_desc: "Os usuários podem criar contas na página principal"
391 + email_validation_required: "Validação de email obrigatória"
392 + email_validation_required_desc: "Os usuários devem validar seu email antes de acessar"
393 + save: "Salvar configurações"
394 + cancel: "Cancelar"
395 + saved: "Configurações salvas com sucesso"
396 + error: "Erro ao salvar"
397 + network_error: "Erro de rede. Tente novamente."
398 + password_security: "Segurança das Senhas"
399 + password_min_length: "Comprimento mínimo"
400 + password_min_length_hint: "Entre 6 e 128 caracteres"
401 + password_complexity: "Requisitos de complexidade"
402 + require_uppercase: "Maiúscula obrigatória"
403 + require_lowercase: "Minúscula obrigatória"
404 + require_digits: "Dígito obrigatório"
405 + require_special: "Caractere especial obrigatório"
406 + password_change_require_email: "Confirmação por email da alteração de senha"
407 + password_change_require_email_desc: "Quando ativado, um email de confirmação é enviado para validar qualquer alteração de senha. Caso contrário, a alteração é aplicada imediatamente."
408 + password_expiration: "Expiração de senhas"
409 + password_expiration_desc: "Força a troca periódica"
410 + expiration_days: "Expirar após (dias)"
411 + expiration_alert: "Aviso por email antes da expiração"
412 + expiration_alert_days: "Aviso (dias antes)"
413 + audit_logging: "Registro de Auditoria"
414 + log_level: "Nível de log"
415 + log_level_debug: "DEBUG - Todos os detalhes"
416 + log_level_info: "INFO - Informações normais"
417 + log_level_warn: "WARN - Apenas avisos"
418 + log_level_error: "ERROR - Apenas erros"
419 + log_level_hint: "Controla a verbosidade dos logs de auditoria"
420 + log_retention: "Retenção de logs (dias)"
421 + log_retention_hint: "Período de retenção dos logs (1-365 dias)"
422 + audit_info_message: "Os logs de auditoria registram todas as ações administrativas (criação, edição, exclusão de usuários)."
423 + save_changes: "Salvar alterações"
424 + save_changes_desc: "As configurações serão aplicadas imediatamente"
425 + saving: "Salvando..."
426 + save_success: "Configurações salvas com sucesso!"
427 + save_error_prefix: "Erro"
428 + oauth_sso_title: "OAuth / SSO"
429 + oauth_enabled_label: "Ativar OAuth"
430 + oauth_enabled_desc: "Permitir login via provedores externos (Google, GitHub, etc.)"
431 + oauth_redirect_url_label: "URL de redirecionamento base"
432 + oauth_redirect_url_desc: "URL base para callbacks OAuth (ex: https://minhaapp.exemplo.com)"
433 + oauth_auto_register_label: "Auto-registro"
434 + oauth_auto_register_desc: "Criar automaticamente uma conta se o email não existir"
435 + oauth_link_existing_label: "Vincular contas existentes"
436 + oauth_link_existing_desc: "Vincular automaticamente uma conta OAuth se o email verificado corresponder"
437 + oauth_client_id: "Client ID"
438 + oauth_client_secret: "Client Secret"
439 + oauth_tenant: "Tenant ID"
440 + oauth_redirect_uri: "URI de redirecionamento"
441 + oauth_copy_uri: "Copiar"
442 + oauth_secret_configured: "Configurado"
443 + oauth_secret_not_configured: "Não configurado"
444 + oauth_secret_change: "Alterar"
445 + oauth_restart_warning: "Alterações nas credenciais requerem reinicialização da aplicação."
446 + oauth_env_info: "As credenciais também podem ser configuradas via variáveis de ambiente (OAUTH_*_CLIENT_ID, etc.)."
447 + oauth_google_label: "Google"
448 + oauth_google_desc: "OpenID Connect"
449 + oauth_github_label: "GitHub"
450 + oauth_github_desc: "OAuth2"
451 + oauth_discord_label: "Discord"
452 + oauth_discord_desc: "OAuth2"
453 + oauth_microsoft_label: "Microsoft"
454 + oauth_microsoft_desc: "OpenID Connect"
455 + users_list:
456 + page_title: "Gerenciamento de Usuários - Admin - RustCloud"
457 + title: "Gerenciamento de Usuários"
458 + breadcrumb_users: "Usuários"
459 + create_user: "Criar um usuário"
460 + search_label: "Buscar"
461 + search_placeholder: "Nome de usuário ou email..."
462 + role_filter_label: "Função"
463 + all_roles: "Todas as funções"
464 + per_page_label: "Por página"
465 + search_button: "Buscar"
466 + results_summary: "Exibindo {count} usuário(s) de {total} no total"
467 + page_info: "Página {page} de {total_pages}"
468 + table_username: "Nome de usuário"
469 + table_email: "Email"
470 + table_roles: "Funções"
471 + table_created_at: "Criado em"
472 + table_last_login: "Último acesso"
473 + table_actions: "Ações"
474 + never: "Nunca"
475 + edit: "Editar"
476 + delete: "Excluir"
477 + reset_password: "Redefinir senha"
478 + no_users: "Nenhum usuário encontrado"
479 + reset_password_title: "Redefinir senha"
480 + reset_generate: "Gerar uma senha aleatória"
481 + reset_custom: "Definir uma senha personalizada"
482 + reset_email: "Redefinir via email"
483 + reset_new_password_label: "Nova senha"
484 + reset_new_password_placeholder: "Mínimo 8 caracteres, maiúscula, minúscula, dígito, caractere especial"
485 + reset_password_requirements: "⚠️ Deve conter: 8+ caracteres, maiúscula, minúscula, dígito, caractere especial"
486 + reset_invalidate_sessions: "Invalidar todas as sessões ativas deste usuário"
487 + reset_invalidate_sessions_desc: "O usuário precisará se reconectar após a alteração"
488 + reset_button: "Redefinir"
489 + delete_confirm_title: "⚠️ Confirmar exclusão"
490 + delete_confirm_message: "Tem certeza de que deseja excluir este usuário?"
491 + delete_confirm_username: "Nome de usuário:"
492 + delete_confirm_email: "Email:"
493 + delete_irreversible: "Esta ação é irreversível!"
494 + delete_button: "Excluir permanentemente"
495 + edit_user_title: "Editar usuário"
496 + delete_error: "Erro ao excluir o usuário"
497 + network_error: "Erro de rede"
498 + resetting: "Redefinindo..."
499 + reset_by_email_title: "Redefinição via email"
500 + close: "Fechar"
501 + password_reset_success_title: "Senha redefinida"
502 + password_warning_once: "Esta senha será exibida apenas uma vez. Copie agora!"
503 + new_password_label: "Nova senha"
504 + copy: "Copiar"
505 + password_copied: "Senha copiada para a área de transferência"
506 + password_set_success: "Senha redefinida com sucesso"
507 + reset_error: "Erro ao redefinir a senha"
508 +
509 +# Alteração de senha
510 +password:
511 + title: "Alterar minha senha"
512 + page_title: "Alterar minha senha - RustCloud"
513 + subtitle: "Altere sua senha para proteger sua conta. Um email de confirmação será enviado para validar a alteração."
514 + subtitle_no_email: "Altere sua senha para proteger sua conta."
515 + must_change_message: "⚠️ Você deve alterar sua senha antes de continuar."
516 + new_password: "Nova senha"
517 + current_password: "Senha atual"
518 + current_password_placeholder: "Sua senha atual"
519 + new_password_placeholder: "Nova senha segura"
520 + required: "Obrigatório"
521 + requirements: "📌 Respeite os requisitos de complexidade indicados abaixo."
522 + strength: "Força da senha:"
523 + strength_weak: "Fraca"
524 + strength_medium: "Média"
525 + strength_strong: "Forte ✓"
526 + strength_too_short: "muito curta"
527 + strength_missing_lowercase: "minúscula ausente"
528 + strength_missing_uppercase: "maiúscula ausente"
529 + strength_missing_digit: "dígito ausente"
530 + strength_missing_special: "caractere especial ausente"
531 + confirm_password: "Confirmar a nova senha"
532 + confirm_password_placeholder: "Repita a nova senha"
533 + passwords_match: "✓ As senhas correspondem"
534 + passwords_not_match: "✗ As senhas não correspondem"
535 + change_button: "Alterar senha"
536 + changing: "Alterando..."
537 + logout_warning_title: "Desconexão das outras sessões"
538 + logout_warning_desc: "Todas as suas outras sessões ativas serão automaticamente desconectadas após a alteração de senha. Apenas sua sessão atual permanecerá ativa."
539 + save: "Salvar a nova senha"
540 + saving: "Salvando..."
541 + saved: "Um email de confirmação foi enviado para seu endereço. Clique no link no email para validar a alteração de senha."
542 + saved_direct: "Senha alterada com sucesso! Suas outras sessões foram desconectadas."
543 + error: "Erro ao alterar a senha"
544 + error_all_fields_required: "Todos os campos são obrigatórios."
545 + error_empty_data: "Os dados a enviar estão vazios."
546 + error_invalid_data: "Dados inválidos. Verifique se todos os campos estão preenchidos."
547 + error_incorrect_current: "Senha atual incorreta."
548 + error_reading_response: "Erro ao ler a resposta do servidor"
549 + error_415: "Erro 415: O servidor não recebeu os dados no formato JSON esperado. Tente novamente."
550 + network_error: "Erro de rede. Tente novamente."
551 + security_tips_title: "Dicas de segurança"
552 + security_tip_unique: "Use uma senha única que não use em outro lugar"
553 + security_tip_share: "Nunca compartilhe sua senha com ninguém"
554 + security_tip_regular: "Altere sua senha regularmente (a cada 3-6 meses)"
555 + security_tip_2fa: "Ative a autenticação de dois fatores se disponível"
556 + security_tip_manager: "Use um gerenciador de senhas para maior segurança"
557 + pending_request_message: "⚠️ Uma solicitação de alteração de senha aguarda confirmação por email."
558 + pending_request_info: "Um email de confirmação foi enviado para seu endereço. Clique no link no email para confirmar a alteração."
559 + pending_request_cancel: "Cancelar a solicitação pendente"
560 + pending_request_canceled: "Solicitação cancelada com sucesso"
561 + pending_request_cancel_error: "Erro ao cancelar a solicitação"
562 + confirmation_sent: "Email de confirmação enviado! Verifique sua caixa de entrada."
563 + confirmation_error: "Erro ao enviar o email de confirmação"
564 +
565 +# Sessões
566 +sessions:
567 + title: "Sessões ativas"
568 + page_title: "Sessões ativas - RustCloud"
569 + subtitle: "Gerencie os dispositivos conectados à sua conta"
570 + current_session: "Sessão atual"
571 + current_badge: "Atual"
572 + unknown_device: "Dispositivo desconhecido"
573 + ip_address: "Endereço IP:"
574 + created_at: "Criada em:"
575 + last_activity: "Última atividade:"
576 + session_id: "ID da Sessão:"
577 + revoke: "Revogar"
578 + revoke_confirm: "Tem certeza de que deseja revogar a sessão para \"{device}\"?\n\nEste dispositivo será desconectado imediatamente."
579 + revoke_success: "Sessão revogada com sucesso!"
580 + revoke_error: "Erro ao revogar a sessão"
581 + no_sessions: "Nenhuma sessão ativa"
582 + no_sessions_desc: "Você não tem sessões ativas no momento"
583 + danger_zone: "Zona de perigo"
584 + danger_zone_desc: "Desconecte todos os outros dispositivos em uma única ação (sua sessão atual permanecerá ativa)."
585 + revoke_all: "Revogar todas as outras sessões"
586 + revoke_all_confirm: "⚠️ ATENÇÃO\n\nTem certeza de que deseja revogar TODAS as suas outras sessões?\n\nTodos os seus outros dispositivos serão desconectados. Apenas sua sessão atual permanecerá ativa."
587 + revoke_all_success: "Todas as outras sessões foram revogadas!"
588 + revoke_all_error: "Erro ao revogar. Algumas sessões podem não ter sido revogadas."
589 + about_title: "Informações sobre sessões"
590 + about_desc: "Uma sessão é criada toda vez que você acessa de um dispositivo ou navegador diferente. Revogar uma sessão desconecta imediatamente esse dispositivo. Sua sessão atual não pode ser revogada nesta página (saia para fazer isso)."
591 + back_to_settings: "Voltar às configurações"
592 +
593 +# Menu de navegação (navbar)
594 +nav:
595 + home: "Home"
596 + settings: "Configurações"
597 + admin: "Administração"
598 +
599 +# Menu do usuário
600 +user_menu:
601 + profile: "Perfil"
602 + logout: "Sair"
603 + settings: "Configurações"
604 +
605 +# Organizações
606 +organisations:
607 + list:
608 + title: "Gerenciamento de Organizações"
609 + create: "Criar uma organização"
610 + search: "Buscar"
611 + search_placeholder: "Nome ou descrição..."
612 + no_organisations: "Nenhuma organização encontrada"
613 + results_summary: "Resultados"
614 + manage_members: "Gerenciar membros"
615 + table:
616 + name: "Nome"
617 + description: "Descrição"
618 + members: "Membros"
619 + created_at: "Criada em"
620 + actions: "Ações"
621 + create:
622 + title: "Criar uma organização"
623 + submit: "Criar"
624 + success: "Organização criada com sucesso"
625 + error:
626 + already_exists: "A organização '{gid}' já existe"
627 + invalid_gid: "Formato de gid inválido: {gid}"
628 + generic: "Ocorreu um erro ao criar a organização"
629 + edit:
630 + title: "Editar a organização"
631 + submit: "Salvar"
632 + update:
633 + success: "Organização atualizada com sucesso"
634 + delete:
635 + success: "Organização excluída com sucesso"
636 + confirm: "Tem certeza de que deseja excluir esta organização? Esta ação é irreversível."
637 + form:
638 + name_label: "Nome"
639 + name_placeholder: "Nome da organização"
640 + description_label: "Descrição"
641 + description_placeholder: "Descrição da organização (opcional)"
642 + gid_label: "Identificador (GID)"
643 + gid_placeholder: "identificador-unico"
644 + gid_help: "Identificador único (letras, números, sublinhados, máx. 64 caracteres)"
645 + members:
646 + title: "Membros da organização"
647 + add_member: "Adicionar um membro"
648 + search_user_label: "Buscar um usuário"
649 + search_user_placeholder: "Nome de usuário ou email..."
650 + no_members: "Nenhum membro nesta organização"
651 + back_to_list: "Voltar à lista"
652 + table:
653 + username: "Nome de usuário"
654 + role: "Função"
655 + joined_at: "Ingressou em"
656 + actions: "Ações"
657 + add_success: "Membro adicionado com sucesso"
658 + remove_success: "Membro removido com sucesso"
659 + add_error: "Erro ao adicionar o membro"
660 + remove_error: "Erro ao remover o membro"
661 + user_not_found: "Usuário '{search}' não encontrado"
662 + error:
663 + organisation_not_found: "Organização '{gid}' não encontrada"
664 + already_member: "Este usuário já é membro da organização"
665 +
666 +# Compartilhamento
667 +sharing:
668 + modal:
669 + title: "Compartilhar um recurso"
670 + resource_label: "Recurso"
671 + resource_type_label: "Tipo"
672 + expiration_label: "Data de expiração (opcional)"
673 + expiration_help: "O compartilhamento expirará automaticamente nesta data"
674 + cancel_button: "Cancelar"
675 + submit_button: "Compartilhar"
676 + close_button: "Fechar"
677 + success_message: "Compartilhamento criado com sucesso"
678 + error_message: "Erro ao criar o compartilhamento"
679 + error:
680 + already_exists: "Este compartilhamento já existe para este recurso e esta organização"
681 + components:
682 + share_button_label: "Compartilhar"
683 + permissions_label: "Permissões"
684 + permissions_help: "Escolha as permissões concedidas para este compartilhamento"
685 + share_with_label: "Compartilhar com"
686 + user_tab_label: "Usuário"
687 + organisation_tab_label: "Organização"
688 + select_user_label: "Selecionar um usuário"
689 + search_user_placeholder: "Buscar um usuário..."
690 + select_organisation_label: "Selecionar uma organização"
691 + select_organisation_placeholder: "Escolha uma organização..."
692 + no_organisations_message: "Nenhuma organização disponível"
693 + permissions:
694 + read_only: "Somente leitura"
695 + read_only_value: "1"
696 + read_only_description: "Pode apenas consultar o recurso"
697 + read_write: "Leitura + Escrita"
698 + read_write_value: "3"
699 + read_write_description: "Pode consultar e modificar o recurso"
700 + all: "Todas as permissões"
701 + all_value: "15"
702 + all_description: "Pode consultar, modificar, excluir e compartilhar"
M crates/rustwarden-ui/src/dto/admin_dto.rs
+21 -0
@@ -37,9 +37,13 @@ pub struct AuditLogStats {
37 37 /// Logs count by level
38 38 #[derive(Debug, Clone, Serialize, Deserialize)]
39 39 pub struct LogsByLevel {
40 + /// Number of DEBUG-level log entries.
40 41 pub debug: u64,
42 + /// Number of INFO-level log entries.
41 43 pub info: u64,
44 + /// Number of WARN-level log entries.
42 45 pub warn: u64,
46 + /// Number of ERROR-level log entries.
43 47 pub error: u64,
44 48 }
45 49
@@ -215,26 +219,43 @@ pub struct CreateUserResponse {
215 219 #[derive(Debug, Deserialize, Serialize)]
216 220 pub struct UpdateAppSettingsRequest {
217 221 // General settings
222 + /// Application display name.
218 223 pub app_name: Option<String>,
224 + /// Short description of the application.
219 225 pub app_description: Option<String>,
226 + /// Public domain of the application.
220 227 pub app_domain: Option<String>,
228 + /// Whether public self-registration is allowed.
221 229 pub allow_registration: Option<bool>,
230 + /// Whether email validation is required on registration.
222 231 pub validation_email_required: Option<bool>,
232 + /// Alias for `validation_email_required` (legacy French field name).
223 233 pub validation_email_requise: Option<bool>,
224 234
225 235 // Security - Password complexity
236 + /// Minimum password length.
226 237 pub password_min_length: Option<u32>,
238 + /// Whether passwords must contain at least one uppercase letter.
227 239 pub password_require_uppercase: Option<bool>,
240 + /// Whether passwords must contain at least one lowercase letter.
228 241 pub password_require_lowercase: Option<bool>,
242 + /// Whether passwords must contain at least one digit.
229 243 pub password_require_digits: Option<bool>,
244 + /// Whether passwords must contain at least one special character.
230 245 pub password_require_special: Option<bool>,
246 + /// Whether periodic password expiration is enabled.
231 247 pub password_expiration_enabled: Option<bool>,
248 + /// Number of days before a password expires.
232 249 pub password_expiration_days: Option<u32>,
250 + /// Whether an alert email is sent before password expiry.
233 251 pub password_expiration_alert_enabled: Option<bool>,
252 + /// Number of days before expiry at which the alert is sent.
234 253 pub password_expiration_alert_days: Option<u32>,
235 254
236 255 // Audit logging
256 + /// Minimum severity level for audit log entries.
237 257 pub audit_log_level: Option<String>,
258 + /// Number of days to retain audit log entries.
238 259 pub audit_log_retention_days: Option<u32>,
239 260 }
240 261
M crates/rustwarden-ui/src/dto/auth_dto.rs
+12 -0
@@ -17,10 +17,15 @@ pub struct LoginRequest {
17 17 /// User info in login response
18 18 #[derive(Debug, Serialize, Deserialize, Clone)]
19 19 pub struct UserInfo {
20 + /// User unique identifier.
20 21 pub id: uuid::Uuid,
22 + /// User login name.
21 23 pub username: String,
24 + /// User email address.
22 25 pub email: String,
26 + /// Roles assigned to the user.
23 27 pub roles: Vec<String>,
28 + /// Whether the user must change their password on next login.
24 29 pub must_change_password: Option<bool>,
25 30 }
26 31
@@ -83,12 +88,19 @@ pub struct UserInfoResponse {
83 88 /// Current user response (for GET /auth/me)
84 89 #[derive(Debug, Serialize, Deserialize, Clone)]
85 90 pub struct CurrentUserResponse {
91 + /// User unique identifier.
86 92 pub id: uuid::Uuid,
93 + /// User login name.
87 94 pub username: String,
95 + /// User email address.
88 96 pub email: String,
97 + /// List of roles assigned to the user.
89 98 pub roles: Vec<String>,
99 + /// Timestamp when the account was created.
90 100 pub created_at: chrono::DateTime<chrono::Utc>,
101 + /// Timestamp of the last account update.
91 102 pub updated_at: chrono::DateTime<chrono::Utc>,
103 + /// Whether the user must change their password on next login.
92 104 pub must_change_password: bool,
93 105 }
94 106
M crates/rustwarden-ui/src/dto/email_dto.rs
+24 -1
@@ -9,14 +9,19 @@ use serde::{Deserialize, Serialize};
9 9 use uuid::Uuid;
10 10 use validator::Validate;
11 11
12 -/// États de suivi pour les emails critiques (traçabilité SMTP)
12 +/// Tracking states for critical emails (SMTP traceability)
13 13 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
14 14 #[serde(rename_all = "snake_case")]
15 15 pub enum EmailDeliveryState {
16 + /// Email has been queued but not yet sent.
16 17 Queued,
18 + /// Email is currently being transmitted to the SMTP server.
17 19 Sending,
20 + /// SMTP server accepted the message for delivery.
18 21 Accepted,
22 + /// SMTP server rejected the message.
19 23 Rejected,
24 + /// Email was not delivered before its expiry deadline.
20 25 Expired,
21 26 /// Final delivery confirmed (via DSN)
22 27 Delivered,
@@ -25,6 +30,7 @@ pub enum EmailDeliveryState {
25 30 }
26 31
27 32 impl EmailDeliveryState {
33 + /// Returns a static string representation of this delivery state.
28 34 pub fn as_str(self) -> &'static str {
29 35 match self {
30 36 EmailDeliveryState::Queued => "queued",
@@ -37,6 +43,7 @@ impl EmailDeliveryState {
37 43 }
38 44 }
39 45
46 + /// Parses a string slice into an `EmailDeliveryState`, defaulting to `Queued` for unknown values.
40 47 pub fn parse(value: &str) -> Self {
41 48 match value {
42 49 "queued" => EmailDeliveryState::Queued,
@@ -203,23 +210,39 @@ pub struct RetryEmailResponse {
203 210 /// Delivery status response for critical emails (admin)
204 211 #[derive(Debug, Serialize)]
205 212 pub struct EmailDeliveryStatusResponse {
213 + /// Unique identifier of the email queue entry.
206 214 pub id: Uuid,
215 + /// Type of email (e.g. `password_reset`, `email_validation`).
207 216 pub email_type: String,
217 + /// Recipient email address.
208 218 pub recipient: String,
219 + /// Template name used to render the email.
209 220 pub template: String,
221 + /// Current delivery state.
210 222 pub state: EmailDeliveryState,
223 + /// SMTP response code from the last delivery attempt.
211 224 pub smtp_code: Option<String>,
225 + /// Full SMTP response message from the last delivery attempt.
212 226 pub smtp_response: Option<String>,
227 + /// Number of delivery attempts made so far.
213 228 pub attempt_count: i32,
229 + /// Timestamp of the most recent delivery attempt.
214 230 pub last_attempt_at: Option<chrono::DateTime<chrono::Utc>>,
231 + /// Timestamp of the last state change.
215 232 pub last_state_at: chrono::DateTime<chrono::Utc>,
233 + /// Optional correlation ID linking to the originating request.
216 234 pub correlation_id: Option<Uuid>,
235 + /// Timestamp when the queue entry was created.
217 236 pub created_at: chrono::DateTime<chrono::Utc>,
237 + /// Timestamp of the last update to this entry.
218 238 pub updated_at: chrono::DateTime<chrono::Utc>,
219 239 }
220 240
241 +/// Paginated list of email delivery status entries.
221 242 #[derive(Debug, Serialize)]
222 243 pub struct EmailDeliveryStatusListResponse {
244 + /// Page of delivery status entries.
223 245 pub items: Vec<EmailDeliveryStatusResponse>,
246 + /// Cursor for retrieving the next page, if any.
224 247 pub next_cursor: Option<String>,
225 248 }
M crates/rustwarden-ui/src/dto/mod.rs
+11 -1
@@ -1,15 +1,25 @@
1 -// Data Transfer Objects (DTOs)
1 +//! Data Transfer Objects (DTOs)
2 2
3 +/// DTOs for admin endpoints (users, roles, settings).
3 4 pub mod admin_dto;
5 +/// DTOs for authentication endpoints (login, refresh, me).
4 6 pub mod auth_dto;
7 +/// DTOs for breadcrumb navigation rendering.
5 8 pub mod breadcrumb_dto;
6 9
10 +/// DTOs for email delivery status and password-reset flows.
7 11 pub mod email_dto;
12 +/// DTOs for the home/dashboard page.
8 13 pub mod home_dto;
14 +/// DTOs for navigation menu rendering.
9 15 pub mod navigation_dto;
16 +/// DTOs for user settings and preferences.
10 17 pub mod settings_dto;
18 +/// DTOs for sidebar rendering.
11 19 pub mod sidebar_dto;
20 +/// DTOs for user management endpoints.
12 21 pub mod user_dto;
22 +/// DTO for the user profile page.
13 23 pub mod user_profile_dto;
14 24
15 25 pub use admin_dto::{
M crates/rustwarden-ui/src/dto/navigation_dto.rs
+16 -18
@@ -192,9 +192,9 @@ impl NavigationDTO {
192 192 impl Default for NavigationDTO {
193 193 fn default() -> Self {
194 194 Self {
195 - items: vec![
196 - NavigationItemDTO::regular("home", "Accueil", "home", "/home"),
197 - ],
195 + items: vec![NavigationItemDTO::regular(
196 + "home", "Accueil", "home", "/home",
197 + )],
198 198 secondary_items: vec![],
199 199 has_pending_password_change: false,
200 200 }
@@ -248,21 +248,19 @@ impl NavigationDTO {
248 248 let home_label = i18n_service.translate("nav.home", locale_str).await;
249 249 let admin_label = i18n_service.translate("nav.admin", locale_str).await;
250 250
251 - let mut items = vec![
252 - NavigationItemDTO {
253 - id: "home".to_string(),
254 - label: home_label,
255 - icon: "home".to_string(),
256 - url: "/home".to_string(),
257 - active: false,
258 - enabled: true,
259 - badge: String::new(),
260 - has_badge: false,
261 - disabled_message: String::new(),
262 - has_disabled_message: false,
263 - order: 0,
264 - },
265 - ];
251 + let mut items = vec![NavigationItemDTO {
252 + id: "home".to_string(),
253 + label: home_label,
254 + icon: "home".to_string(),
255 + url: "/home".to_string(),
256 + active: false,
257 + enabled: true,
258 + badge: String::new(),
259 + has_badge: false,
260 + disabled_message: String::new(),
261 + has_disabled_message: false,
262 + order: 0,
263 + }];
266 264
267 265 if roles.contains(&"admin".to_string()) {
268 266 items.push(NavigationItemDTO {
M crates/rustwarden-ui/src/extractors.rs
+21 -17
@@ -53,8 +53,8 @@ where
53 53 .await
54 54 .map_err(|_| FormRejection::BodyReadError)?;
55 55
56 - let body_str = std::str::from_utf8(&body_bytes)
57 - .map_err(|_| FormRejection::InvalidEncoding)?;
56 + let body_str =
57 + std::str::from_utf8(&body_bytes).map_err(|_| FormRejection::InvalidEncoding)?;
58 58
59 59 // Parse URL-encoded data and handle duplicates
60 60 let parsed_map = parse_urlencoded_with_duplicates(body_str)?;
@@ -72,7 +72,9 @@ where
72 72 }
73 73
74 74 /// Parse URL-encoded string and handle duplicate keys by collecting them into vectors
75 -fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde_json::Value>, FormRejection> {
75 +fn parse_urlencoded_with_duplicates(
76 + input: &str,
77 +) -> Result<HashMap<String, serde_json::Value>, FormRejection> {
76 78 let mut map: HashMap<String, Vec<String>> = HashMap::new();
77 79
78 80 for pair in input.split('&') {
@@ -82,14 +84,13 @@ fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde
82 84
83 85 let (key, value) = if let Some(eq_pos) = pair.find('=') {
84 86 // Simple URL decoding - replace %XX with actual characters
85 - let key = percent_decode(&pair[..eq_pos])
86 - .map_err(|_| FormRejection::InvalidEncoding)?;
87 - let value = percent_decode(&pair[eq_pos + 1..])
88 - .map_err(|_| FormRejection::InvalidEncoding)?;
87 + let key =
88 + percent_decode(&pair[..eq_pos]).map_err(|_| FormRejection::InvalidEncoding)?;
89 + let value =
90 + percent_decode(&pair[eq_pos + 1..]).map_err(|_| FormRejection::InvalidEncoding)?;
89 91 (key, value)
90 92 } else {
91 - let key = percent_decode(pair)
92 - .map_err(|_| FormRejection::InvalidEncoding)?;
93 + let key = percent_decode(pair).map_err(|_| FormRejection::InvalidEncoding)?;
93 94 (key, String::new())
94 95 };
95 96
@@ -102,7 +103,8 @@ fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde
102 103 if values.len() == 1 {
103 104 result.insert(key, serde_json::Value::String(values[0].clone()));
104 105 } else {
105 - let array: Vec<serde_json::Value> = values.into_iter().map(serde_json::Value::String).collect();
106 + let array: Vec<serde_json::Value> =
107 + values.into_iter().map(serde_json::Value::String).collect();
106 108 result.insert(key, serde_json::Value::Array(array));
107 109 }
108 110 }
@@ -116,7 +118,7 @@ fn percent_decode(input: &str) -> Result<String, ()> {
116 118 let mut result = Vec::new();
117 119 let bytes = input.as_bytes();
118 120 let mut i = 0;
119 -
121 +
120 122 while i < bytes.len() {
121 123 if bytes[i] == b'%' {
122 124 if i + 2 >= bytes.len() {
@@ -137,16 +139,20 @@ fn percent_decode(input: &str) -> Result<String, ()> {
137 139 i += 1;
138 140 }
139 141 }
140 -
142 +
141 143 String::from_utf8(result).map_err(|_| ())
142 144 }
143 145
144 146 /// Rejection type for form extraction errors
145 147 #[derive(Debug)]
146 148 pub enum FormRejection {
149 + /// Content-Type header is not `application/x-www-form-urlencoded`.
147 150 InvalidContentType,
151 + /// Failed to read the request body bytes.
148 152 BodyReadError,
153 + /// Body bytes are not valid UTF-8 or percent-encoding is malformed.
149 154 InvalidEncoding,
155 + /// Deserialization of the form data into the target type failed.
150 156 DeserializeError(String),
151 157 }
152 158
@@ -161,10 +167,9 @@ impl IntoResponse for FormRejection {
161 167 StatusCode::BAD_REQUEST,
162 168 "Failed to read request body".to_string(),
163 169 ),
164 - FormRejection::InvalidEncoding => (
165 - StatusCode::BAD_REQUEST,
166 - "Invalid URL encoding".to_string(),
167 - ),
170 + FormRejection::InvalidEncoding => {
171 + (StatusCode::BAD_REQUEST, "Invalid URL encoding".to_string())
172 + }
168 173 FormRejection::DeserializeError(msg) => (
169 174 StatusCode::BAD_REQUEST,
170 175 format!("Failed to deserialize form: {}", msg),
@@ -174,4 +179,3 @@ impl IntoResponse for FormRejection {
174 179 (status, message).into_response()
175 180 }
176 181 }
177 -
M crates/rustwarden-ui/src/handlers/admin_handler.rs
+1140 -315
@@ -12,17 +12,17 @@ use serde::Deserialize;
12 12 use std::sync::Arc;
13 13 use tower_sessions::Session;
14 14
15 -use rustwarden_core::middleware::csrf;
15 +use crate::dto::NavigationDTO;
16 +use crate::template_helpers::build_base_context_with_claims;
17 +use axum::extract::Extension;
16 18 use rustwarden_core::auth::jwt::Claims;
17 19 use rustwarden_core::error::AppError;
18 20 use rustwarden_core::i18n::extractor::Locale;
19 21 use rustwarden_core::i18n::service::I18nService;
20 22 use rustwarden_core::impl_template_response;
23 +use rustwarden_core::middleware::csrf;
21 24 use rustwarden_core::models::{role, user_role};
22 25 use rustwarden_core::services::app_settings_service::AppSettingsService;
23 -use crate::template_helpers::build_base_context_with_claims;
24 -use crate::dto::NavigationDTO;
25 -use axum::extract::Extension;
26 26 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
27 27
28 28 #[derive(Debug, Clone)]
@@ -82,9 +82,13 @@ async fn load_app_domain_and_description(
82 82 /// Inline DTO: Create user request from admin form
83 83 #[derive(Debug, Clone, serde::Deserialize)]
84 84 pub struct CreateUserRequest {
85 + /// Login name for the new user.
85 86 pub username: String,
87 + /// Email address for the new user.
86 88 pub email: String,
89 + /// Initial password for the new user.
87 90 pub password: String,
91 + /// Roles to assign to the new user.
88 92 #[serde(default)]
89 93 #[serde(deserialize_with = "deserialize_string_or_vec")]
90 94 pub roles: Vec<String>,
@@ -93,13 +97,16 @@ pub struct CreateUserRequest {
93 97 /// Inline DTO: Reset password request
94 98 #[derive(Debug, Clone, serde::Deserialize)]
95 99 pub struct ResetPasswordRequest {
100 + /// New password to set, or `None` to generate one automatically.
96 101 pub new_password: Option<String>,
97 102 }
98 103
99 104 /// Inline DTO: Reset password response
100 105 #[derive(Debug, Clone, serde::Serialize)]
101 106 pub struct ResetPasswordResponse {
107 + /// The new password (shown once when auto-generated).
102 108 pub password: Option<String>,
109 + /// Human-readable confirmation message.
103 110 pub message: String,
104 111 }
105 112
@@ -254,7 +261,9 @@ pub async fn admin_dashboard(
254 261 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
255 262
256 263 let template = AdminDashboardTemplate {
257 - page_title: i18n_service.translate("admin.dashboard.title", locale_str).await,
264 + page_title: i18n_service
265 + .translate("admin.dashboard.title", locale_str)
266 + .await,
258 267 csrf_token,
259 268 app_name: base_context.app_name,
260 269 app_version: base_context.app_version,
@@ -279,33 +288,79 @@ pub async fn admin_dashboard(
279 288 audit_warn_count: 0,
280 289 audit_error_count: 0,
281 290 total_roles,
282 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
291 + user_menu_profile: i18n_service
292 + .translate("user_menu.profile", locale_str)
293 + .await,
283 294 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
284 295
285 296 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
286 297 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
287 - heading: i18n_service.translate("admin.dashboard.heading", locale_str).await,
288 - users_card_title: i18n_service.translate("admin.dashboard.users_card_title", locale_str).await,
289 - users_card_subtitle: i18n_service.translate("admin.dashboard.users_card_subtitle", locale_str).await,
290 - access_button: i18n_service.translate("admin.dashboard.access_button", locale_str).await,
291 - sessions_card_title: i18n_service.translate("admin.dashboard.sessions_card_title", locale_str).await,
292 - sessions_card_subtitle: i18n_service.translate("admin.dashboard.sessions_card_subtitle", locale_str).await,
293 - admins_card_title: i18n_service.translate("admin.dashboard.admins_card_title", locale_str).await,
294 - admins_card_subtitle: i18n_service.translate("admin.dashboard.admins_card_subtitle", locale_str).await,
295 - last_activity_card_title: i18n_service.translate("admin.dashboard.last_activity_card_title", locale_str).await,
296 - last_activity_card_subtitle: i18n_service.translate("admin.dashboard.last_activity_card_subtitle", locale_str).await,
297 - audit_section_title: i18n_service.translate("admin.dashboard.audit_section_title", locale_str).await,
298 - audit_total_logs_label: i18n_service.translate("admin.dashboard.audit_total_logs", locale_str).await,
299 - audit_last_24h: i18n_service.translate("admin.dashboard.audit_last_24h", locale_str).await,
300 - audit_by_level: i18n_service.translate("admin.dashboard.audit_by_level", locale_str).await,
301 - chart_label: i18n_service.translate("admin.dashboard.chart_label", locale_str).await,
302 - roles_card_title: i18n_service.translate("admin.dashboard.roles_card_title", locale_str).await,
303 - roles_card_desc: i18n_service.translate("admin.dashboard.roles_card_desc", locale_str).await,
304 - roles_stat_title: i18n_service.translate("admin.dashboard.roles_stat_title", locale_str).await,
305 - settings_card_title: i18n_service.translate("admin.dashboard.settings_card_title", locale_str).await,
306 - settings_card_desc: i18n_service.translate("admin.dashboard.settings_card_desc", locale_str).await,
307 - apps_card_title: i18n_service.translate("admin.dashboard.apps_card_title", locale_str).await,
308 - apps_card_desc: i18n_service.translate("admin.dashboard.apps_card_desc", locale_str).await,
298 + heading: i18n_service
299 + .translate("admin.dashboard.heading", locale_str)
300 + .await,
301 + users_card_title: i18n_service
302 + .translate("admin.dashboard.users_card_title", locale_str)
303 + .await,
304 + users_card_subtitle: i18n_service
305 + .translate("admin.dashboard.users_card_subtitle", locale_str)
306 + .await,
307 + access_button: i18n_service
308 + .translate("admin.dashboard.access_button", locale_str)
309 + .await,
310 + sessions_card_title: i18n_service
311 + .translate("admin.dashboard.sessions_card_title", locale_str)
312 + .await,
313 + sessions_card_subtitle: i18n_service
314 + .translate("admin.dashboard.sessions_card_subtitle", locale_str)
315 + .await,
316 + admins_card_title: i18n_service
317 + .translate("admin.dashboard.admins_card_title", locale_str)
318 + .await,
319 + admins_card_subtitle: i18n_service
320 + .translate("admin.dashboard.admins_card_subtitle", locale_str)
321 + .await,
322 + last_activity_card_title: i18n_service
323 + .translate("admin.dashboard.last_activity_card_title", locale_str)
324 + .await,
325 + last_activity_card_subtitle: i18n_service
326 + .translate("admin.dashboard.last_activity_card_subtitle", locale_str)
327 + .await,
328 + audit_section_title: i18n_service
329 + .translate("admin.dashboard.audit_section_title", locale_str)
330 + .await,
331 + audit_total_logs_label: i18n_service
332 + .translate("admin.dashboard.audit_total_logs", locale_str)
333 + .await,
334 + audit_last_24h: i18n_service
335 + .translate("admin.dashboard.audit_last_24h", locale_str)
336 + .await,
337 + audit_by_level: i18n_service
338 + .translate("admin.dashboard.audit_by_level", locale_str)
339 + .await,
340 + chart_label: i18n_service
341 + .translate("admin.dashboard.chart_label", locale_str)
342 + .await,
343 + roles_card_title: i18n_service
344 + .translate("admin.dashboard.roles_card_title", locale_str)
345 + .await,
346 + roles_card_desc: i18n_service
347 + .translate("admin.dashboard.roles_card_desc", locale_str)
348 + .await,
349 + roles_stat_title: i18n_service
350 + .translate("admin.dashboard.roles_stat_title", locale_str)
351 + .await,
352 + settings_card_title: i18n_service
353 + .translate("admin.dashboard.settings_card_title", locale_str)
354 + .await,
355 + settings_card_desc: i18n_service
356 + .translate("admin.dashboard.settings_card_desc", locale_str)
357 + .await,
358 + apps_card_title: i18n_service
359 + .translate("admin.dashboard.apps_card_title", locale_str)
360 + .await,
361 + apps_card_desc: i18n_service
362 + .translate("admin.dashboard.apps_card_desc", locale_str)
363 + .await,
309 364 };
310 365
311 366 Ok(template)
@@ -314,9 +369,13 @@ pub async fn admin_dashboard(
314 369 /// Query parameters for user list endpoint
315 370 #[derive(Debug, Deserialize)]
316 371 pub struct UserListQuery {
372 + /// Page number (1-based).
317 373 pub page: Option<u64>,
374 + /// Number of results per page.
318 375 pub per_page: Option<u64>,
376 + /// Optional full-text search filter.
319 377 pub search: Option<String>,
378 + /// Filter by role name.
320 379 pub role_filter: Option<String>,
321 380 }
322 381
@@ -347,6 +406,7 @@ struct UserInfoDisplay {
347 406 last_login_at: String,
348 407 has_last_login_ip: bool,
349 408 last_login_ip: String,
409 + totp_enabled: bool,
350 410 }
351 411
352 412 /// User list template
@@ -541,16 +601,25 @@ pub async fn list_users(
541 601 .filter(user_role::Column::RoleId.eq(role_model.id))
542 602 .all(&db)
543 603 .await?;
544 - let user_ids: Vec<uuid::Uuid> = user_roles_for_filter.iter().map(|ur| ur.user_id).collect();
604 + let user_ids: Vec<uuid::Uuid> =
605 + user_roles_for_filter.iter().map(|ur| ur.user_id).collect();
545 606 all_users.retain(|u| user_ids.contains(&u.id));
546 607 }
547 608 }
548 609 }
549 610
550 611 let total = all_users.len() as u64;
551 - let total_pages = if total == 0 { 1 } else { total.div_ceil(per_page) };
612 + let total_pages = if total == 0 {
613 + 1
614 + } else {
615 + total.div_ceil(per_page)
616 + };
552 617 let start = ((page - 1) * per_page) as usize;
553 - let page_users: Vec<_> = all_users.into_iter().skip(start).take(per_page as usize).collect();
618 + let page_users: Vec<_> = all_users
619 + .into_iter()
620 + .skip(start)
621 + .take(per_page as usize)
622 + .collect();
554 623
555 624 let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
556 625 let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
@@ -583,6 +652,12 @@ pub async fn list_users(
583 652 None => (false, String::new()),
584 653 };
585 654
655 + // Check TOTP status
656 + let totp_enabled =
657 + rustwarden_core::services::totp_service::TotpService::is_totp_enabled(&db, u.id)
658 + .await
659 + .unwrap_or(false);
660 +
586 661 users_display.push(UserInfoDisplay {
587 662 id: u.id,
588 663 username: u.username,
@@ -593,6 +668,7 @@ pub async fn list_users(
593 668 last_login_at: last_login_str,
594 669 has_last_login_ip: u.last_login_ip.is_some(),
595 670 last_login_ip: u.last_login_ip.unwrap_or_default(),
671 + totp_enabled,
596 672 });
597 673 }
598 674
@@ -617,7 +693,9 @@ pub async fn list_users(
617 693 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
618 694
619 695 let template = UserListTemplate {
620 - page_title: i18n_service.translate("admin.users_list.page_title", locale_str).await,
696 + page_title: i18n_service
697 + .translate("admin.users_list.page_title", locale_str)
698 + .await,
621 699 csrf_token,
622 700 app_name: base_context.app_name,
623 701 locale: locale_str.to_string(),
@@ -642,65 +720,169 @@ pub async fn list_users(
642 720 has_role_filter: role_filter.as_ref().is_some_and(|r| !r.is_empty()),
643 721 is_admin_filter: role_filter.as_ref().map(|r| r == "admin").unwrap_or(false),
644 722 is_user_filter: role_filter.as_ref().map(|r| r == "user").unwrap_or(false),
645 - title: i18n_service.translate("admin.users_list.title", locale_str).await,
723 + title: i18n_service
724 + .translate("admin.users_list.title", locale_str)
725 + .await,
646 726 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
647 727 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
648 728 breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
649 - create_user: i18n_service.translate("admin.users_list.create_user", locale_str).await,
650 - search_label: i18n_service.translate("admin.users_list.search_label", locale_str).await,
651 - search_placeholder: i18n_service.translate("admin.users_list.search_placeholder", locale_str).await,
652 - role_filter_label: i18n_service.translate("admin.users_list.role_filter_label", locale_str).await,
653 - all_roles: i18n_service.translate("admin.users_list.all_roles", locale_str).await,
654 - per_page_label: i18n_service.translate("admin.users_list.per_page_label", locale_str).await,
655 - search_button: i18n_service.translate("admin.users_list.search_button", locale_str).await,
729 + create_user: i18n_service
730 + .translate("admin.users_list.create_user", locale_str)
731 + .await,
732 + search_label: i18n_service
733 + .translate("admin.users_list.search_label", locale_str)
734 + .await,
735 + search_placeholder: i18n_service
736 + .translate("admin.users_list.search_placeholder", locale_str)
737 + .await,
738 + role_filter_label: i18n_service
739 + .translate("admin.users_list.role_filter_label", locale_str)
740 + .await,
741 + all_roles: i18n_service
742 + .translate("admin.users_list.all_roles", locale_str)
743 + .await,
744 + per_page_label: i18n_service
745 + .translate("admin.users_list.per_page_label", locale_str)
746 + .await,
747 + search_button: i18n_service
748 + .translate("admin.users_list.search_button", locale_str)
749 + .await,
656 750 results_summary,
657 751 page_info,
658 - table_username: i18n_service.translate("admin.users_list.table_username", locale_str).await,
659 - table_email: i18n_service.translate("admin.users_list.table_email", locale_str).await,
660 - table_roles: i18n_service.translate("admin.users_list.table_roles", locale_str).await,
661 - table_created_at: i18n_service.translate("admin.users_list.table_created_at", locale_str).await,
662 - table_last_login: i18n_service.translate("admin.users_list.table_last_login", locale_str).await,
663 - table_actions: i18n_service.translate("admin.users_list.table_actions", locale_str).await,
664 - never: i18n_service.translate("admin.users_list.never", locale_str).await,
665 - edit: i18n_service.translate("admin.users_list.edit", locale_str).await,
666 - delete: i18n_service.translate("admin.users_list.delete", locale_str).await,
667 - reset_password: i18n_service.translate("admin.users_list.reset_password", locale_str).await,
668 - no_users: i18n_service.translate("admin.users_list.no_users", locale_str).await,
669 - reset_password_title: i18n_service.translate("admin.users_list.reset_password_title", locale_str).await,
670 - reset_generate: i18n_service.translate("admin.users_list.reset_generate", locale_str).await,
671 - reset_custom: i18n_service.translate("admin.users_list.reset_custom", locale_str).await,
672 - reset_email: i18n_service.translate("admin.users_list.reset_email", locale_str).await,
673 - reset_new_password_label: i18n_service.translate("admin.users_list.reset_new_password_label", locale_str).await,
674 - reset_new_password_placeholder: i18n_service.translate("admin.users_list.reset_new_password_placeholder", locale_str).await,
675 - reset_password_requirements: i18n_service.translate("admin.users_list.reset_password_requirements", locale_str).await,
676 - reset_invalidate_sessions: i18n_service.translate("admin.users_list.reset_invalidate_sessions", locale_str).await,
677 - reset_invalidate_sessions_desc: i18n_service.translate("admin.users_list.reset_invalidate_sessions_desc", locale_str).await,
678 - reset_button: i18n_service.translate("admin.users_list.reset_button", locale_str).await,
679 - delete_confirm_title: i18n_service.translate("admin.users_list.delete_confirm_title", locale_str).await,
680 - delete_confirm_message: i18n_service.translate("admin.users_list.delete_confirm_message", locale_str).await,
681 - delete_confirm_username: i18n_service.translate("admin.users_list.delete_confirm_username", locale_str).await,
682 - delete_confirm_email: i18n_service.translate("admin.users_list.delete_confirm_email", locale_str).await,
683 - delete_irreversible: i18n_service.translate("admin.users_list.delete_irreversible", locale_str).await,
684 - delete_button: i18n_service.translate("admin.users_list.delete_button", locale_str).await,
685 - delete_error: i18n_service.translate("admin.users_list.delete_error", locale_str).await,
686 - network_error: i18n_service.translate("admin.users_list.network_error", locale_str).await,
752 + table_username: i18n_service
753 + .translate("admin.users_list.table_username", locale_str)
754 + .await,
755 + table_email: i18n_service
756 + .translate("admin.users_list.table_email", locale_str)
757 + .await,
758 + table_roles: i18n_service
759 + .translate("admin.users_list.table_roles", locale_str)
760 + .await,
761 + table_created_at: i18n_service
762 + .translate("admin.users_list.table_created_at", locale_str)
763 + .await,
764 + table_last_login: i18n_service
765 + .translate("admin.users_list.table_last_login", locale_str)
766 + .await,
767 + table_actions: i18n_service
768 + .translate("admin.users_list.table_actions", locale_str)
769 + .await,
770 + never: i18n_service
771 + .translate("admin.users_list.never", locale_str)
772 + .await,
773 + edit: i18n_service
774 + .translate("admin.users_list.edit", locale_str)
775 + .await,
776 + delete: i18n_service
777 + .translate("admin.users_list.delete", locale_str)
778 + .await,
779 + reset_password: i18n_service
780 + .translate("admin.users_list.reset_password", locale_str)
781 + .await,
782 + no_users: i18n_service
783 + .translate("admin.users_list.no_users", locale_str)
784 + .await,
785 + reset_password_title: i18n_service
786 + .translate("admin.users_list.reset_password_title", locale_str)
787 + .await,
788 + reset_generate: i18n_service
789 + .translate("admin.users_list.reset_generate", locale_str)
790 + .await,
791 + reset_custom: i18n_service
792 + .translate("admin.users_list.reset_custom", locale_str)
793 + .await,
794 + reset_email: i18n_service
795 + .translate("admin.users_list.reset_email", locale_str)
796 + .await,
797 + reset_new_password_label: i18n_service
798 + .translate("admin.users_list.reset_new_password_label", locale_str)
799 + .await,
800 + reset_new_password_placeholder: i18n_service
801 + .translate(
802 + "admin.users_list.reset_new_password_placeholder",
803 + locale_str,
804 + )
805 + .await,
806 + reset_password_requirements: i18n_service
807 + .translate("admin.users_list.reset_password_requirements", locale_str)
808 + .await,
809 + reset_invalidate_sessions: i18n_service
810 + .translate("admin.users_list.reset_invalidate_sessions", locale_str)
811 + .await,
812 + reset_invalidate_sessions_desc: i18n_service
813 + .translate(
814 + "admin.users_list.reset_invalidate_sessions_desc",
815 + locale_str,
816 + )
817 + .await,
818 + reset_button: i18n_service
819 + .translate("admin.users_list.reset_button", locale_str)
820 + .await,
821 + delete_confirm_title: i18n_service
822 + .translate("admin.users_list.delete_confirm_title", locale_str)
823 + .await,
824 + delete_confirm_message: i18n_service
825 + .translate("admin.users_list.delete_confirm_message", locale_str)
826 + .await,
827 + delete_confirm_username: i18n_service
828 + .translate("admin.users_list.delete_confirm_username", locale_str)
829 + .await,
830 + delete_confirm_email: i18n_service
831 + .translate("admin.users_list.delete_confirm_email", locale_str)
832 + .await,
833 + delete_irreversible: i18n_service
834 + .translate("admin.users_list.delete_irreversible", locale_str)
835 + .await,
836 + delete_button: i18n_service
837 + .translate("admin.users_list.delete_button", locale_str)
838 + .await,
839 + delete_error: i18n_service
840 + .translate("admin.users_list.delete_error", locale_str)
841 + .await,
842 + network_error: i18n_service
843 + .translate("admin.users_list.network_error", locale_str)
844 + .await,
687 845 cancel: i18n_service.translate("common.cancel", locale_str).await,
688 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
846 + user_menu_profile: i18n_service
847 + .translate("user_menu.profile", locale_str)
848 + .await,
689 849 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
690 850
691 - resetting: i18n_service.translate("admin.users_list.resetting", locale_str).await,
692 - reset_by_email_title: i18n_service.translate("admin.users_list.reset_by_email_title", locale_str).await,
693 - close: i18n_service.translate("admin.users_list.close", locale_str).await,
694 - password_reset_success_title: i18n_service.translate("admin.users_list.password_reset_success_title", locale_str).await,
695 - password_warning_once: i18n_service.translate("admin.users_list.password_warning_once", locale_str).await,
696 - new_password_label: i18n_service.translate("admin.users_list.new_password_label", locale_str).await,
697 - copy: i18n_service.translate("admin.users_list.copy", locale_str).await,
698 - password_copied: i18n_service.translate("admin.users_list.password_copied", locale_str).await,
699 - password_set_success: i18n_service.translate("admin.users_list.password_set_success", locale_str).await,
700 - reset_error: i18n_service.translate("admin.users_list.reset_error", locale_str).await,
701 - pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db)
702 - .await
703 - .min_length,
851 + resetting: i18n_service
852 + .translate("admin.users_list.resetting", locale_str)
853 + .await,
854 + reset_by_email_title: i18n_service
855 + .translate("admin.users_list.reset_by_email_title", locale_str)
856 + .await,
857 + close: i18n_service
858 + .translate("admin.users_list.close", locale_str)
859 + .await,
860 + password_reset_success_title: i18n_service
861 + .translate("admin.users_list.password_reset_success_title", locale_str)
862 + .await,
863 + password_warning_once: i18n_service
864 + .translate("admin.users_list.password_warning_once", locale_str)
865 + .await,
866 + new_password_label: i18n_service
867 + .translate("admin.users_list.new_password_label", locale_str)
868 + .await,
869 + copy: i18n_service
870 + .translate("admin.users_list.copy", locale_str)
871 + .await,
872 + password_copied: i18n_service
873 + .translate("admin.users_list.password_copied", locale_str)
874 + .await,
875 + password_set_success: i18n_service
876 + .translate("admin.users_list.password_set_success", locale_str)
877 + .await,
878 + reset_error: i18n_service
879 + .translate("admin.users_list.reset_error", locale_str)
880 + .await,
881 + pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(
882 + &db,
883 + )
884 + .await
885 + .min_length,
704 886 };
705 887
706 888 Ok(template)
@@ -734,7 +916,9 @@ pub async fn create_user_form(
734 916 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
735 917
736 918 let template = UserFormTemplate {
737 - page_title: i18n_service.translate("admin.users_list.create_user", locale_str).await,
919 + page_title: i18n_service
920 + .translate("admin.users_list.create_user", locale_str)
921 + .await,
738 922 csrf_token,
739 923 app_name: base_context.app_name,
740 924 app_version: base_context.app_version,
@@ -754,30 +938,68 @@ pub async fn create_user_form(
754 938 form_roles: Vec::new(),
755 939 available_roles: available_roles_display,
756 940 must_change_password: false,
757 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
941 + user_menu_profile: i18n_service
942 + .translate("user_menu.profile", locale_str)
943 + .await,
758 944 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
759 945
760 946 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
761 947 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
762 948 breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
763 - breadcrumb_mode: i18n_service.translate("admin.user_form.breadcrumb_create", locale_str).await,
764 - form_title: i18n_service.translate("admin.user_form.title_create", locale_str).await,
765 - back_to_list: i18n_service.translate("admin.user_form.back_to_list", locale_str).await,
766 - username_label: i18n_service.translate("admin.user_form.username_label", locale_str).await,
767 - username_placeholder: i18n_service.translate("admin.user_form.username_placeholder", locale_str).await,
768 - username_readonly_hint: i18n_service.translate("admin.user_form.username_readonly_hint", locale_str).await,
769 - email_label: i18n_service.translate("admin.user_form.email_label", locale_str).await,
770 - email_placeholder: i18n_service.translate("admin.user_form.email_placeholder", locale_str).await,
771 - password_label: i18n_service.translate("admin.user_form.password_label", locale_str).await,
772 - password_optional: i18n_service.translate("admin.user_form.password_optional", locale_str).await,
773 - password_placeholder: i18n_service.translate("admin.user_form.password_placeholder", locale_str).await,
774 - password_hint: i18n_service.translate("admin.user_form.password_hint", locale_str).await,
775 - roles_label: i18n_service.translate("admin.user_form.roles_label", locale_str).await,
776 - roles_hint: i18n_service.translate("admin.user_form.roles_hint", locale_str).await,
777 - must_change_password_label: i18n_service.translate("admin.user_form.must_change_password_label", locale_str).await,
778 - must_change_password_desc: i18n_service.translate("admin.user_form.must_change_password_desc", locale_str).await,
779 - cancel: i18n_service.translate("admin.user_form.cancel", locale_str).await,
780 - submit_label: i18n_service.translate("admin.user_form.submit_create", locale_str).await,
949 + breadcrumb_mode: i18n_service
950 + .translate("admin.user_form.breadcrumb_create", locale_str)
951 + .await,
952 + form_title: i18n_service
953 + .translate("admin.user_form.title_create", locale_str)
954 + .await,
955 + back_to_list: i18n_service
956 + .translate("admin.user_form.back_to_list", locale_str)
957 + .await,
958 + username_label: i18n_service
959 + .translate("admin.user_form.username_label", locale_str)
960 + .await,
961 + username_placeholder: i18n_service
962 + .translate("admin.user_form.username_placeholder", locale_str)
963 + .await,
964 + username_readonly_hint: i18n_service
965 + .translate("admin.user_form.username_readonly_hint", locale_str)
966 + .await,
967 + email_label: i18n_service
968 + .translate("admin.user_form.email_label", locale_str)
969 + .await,
970 + email_placeholder: i18n_service
971 + .translate("admin.user_form.email_placeholder", locale_str)
972 + .await,
973 + password_label: i18n_service
974 + .translate("admin.user_form.password_label", locale_str)
975 + .await,
976 + password_optional: i18n_service
977 + .translate("admin.user_form.password_optional", locale_str)
978 + .await,
979 + password_placeholder: i18n_service
980 + .translate("admin.user_form.password_placeholder", locale_str)
981 + .await,
982 + password_hint: i18n_service
983 + .translate("admin.user_form.password_hint", locale_str)
984 + .await,
985 + roles_label: i18n_service
986 + .translate("admin.user_form.roles_label", locale_str)
987 + .await,
988 + roles_hint: i18n_service
989 + .translate("admin.user_form.roles_hint", locale_str)
990 + .await,
991 + must_change_password_label: i18n_service
992 + .translate("admin.user_form.must_change_password_label", locale_str)
993 + .await,
994 + must_change_password_desc: i18n_service
995 + .translate("admin.user_form.must_change_password_desc", locale_str)
996 + .await,
997 + cancel: i18n_service
998 + .translate("admin.user_form.cancel", locale_str)
999 + .await,
1000 + submit_label: i18n_service
1001 + .translate("admin.user_form.submit_create", locale_str)
1002 + .await,
781 1003 };
782 1004
783 1005 Ok(template)
@@ -803,7 +1025,10 @@ pub async fn create_user(
803 1025 use rustwarden_core::services::user_service::UserService;
804 1026
805 1027 // Check if username or email already exists
806 - if UserService::get_user_by_username(&db, &form_data.username).await?.is_some() {
1028 + if UserService::get_user_by_username(&db, &form_data.username)
1029 + .await?
1030 + .is_some()
1031 + {
807 1032 let error_template = FormMessageTemplate {
808 1033 message_type: "error".to_string(),
809 1034 message: format!("Le nom d'utilisateur '{}' existe déjà", form_data.username),
@@ -811,7 +1036,10 @@ pub async fn create_user(
811 1036 return Ok(error_template.into_response());
812 1037 }
813 1038
814 - if UserService::get_user_by_email(&db, &form_data.email).await?.is_some() {
1039 + if UserService::get_user_by_email(&db, &form_data.email)
1040 + .await?
1041 + .is_some()
1042 + {
815 1043 let error_template = FormMessageTemplate {
816 1044 message_type: "error".to_string(),
817 1045 message: format!("L'email '{}' est déjà utilisé", form_data.email),
@@ -820,7 +1048,14 @@ pub async fn create_user(
820 1048 }
821 1049
822 1050 let password_hash = PasswordService::hash_password(&form_data.password).await?;
823 - let user = UserService::create_user(&db, form_data.username, form_data.email, password_hash, false).await?;
1051 + let user = UserService::create_user(
1052 + &db,
1053 + form_data.username,
1054 + form_data.email,
1055 + password_hash,
1056 + false,
1057 + )
1058 + .await?;
824 1059
825 1060 for role_name in &form_data.roles {
826 1061 if let Some(r) = RoleService::get_role_by_name(&db, role_name).await? {
@@ -913,30 +1148,68 @@ pub async fn edit_user_form(
913 1148 form_roles: current_role_names,
914 1149 available_roles: available_roles_display,
915 1150 must_change_password: user.must_change_password,
916 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1151 + user_menu_profile: i18n_service
1152 + .translate("user_menu.profile", locale_str)
1153 + .await,
917 1154 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
918 1155
919 1156 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
920 1157 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
921 1158 breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
922 - breadcrumb_mode: i18n_service.translate("admin.user_form.breadcrumb_edit", locale_str).await,
923 - form_title: i18n_service.translate("admin.user_form.title_edit", locale_str).await,
924 - back_to_list: i18n_service.translate("admin.user_form.back_to_list", locale_str).await,
925 - username_label: i18n_service.translate("admin.user_form.username_label", locale_str).await,
926 - username_placeholder: i18n_service.translate("admin.user_form.username_placeholder", locale_str).await,
927 - username_readonly_hint: i18n_service.translate("admin.user_form.username_readonly_hint", locale_str).await,
928 - email_label: i18n_service.translate("admin.user_form.email_label", locale_str).await,
929 - email_placeholder: i18n_service.translate("admin.user_form.email_placeholder", locale_str).await,
930 - password_label: i18n_service.translate("admin.user_form.password_label", locale_str).await,
931 - password_optional: i18n_service.translate("admin.user_form.password_optional", locale_str).await,
932 - password_placeholder: i18n_service.translate("admin.user_form.password_placeholder", locale_str).await,
933 - password_hint: i18n_service.translate("admin.user_form.password_hint", locale_str).await,
934 - roles_label: i18n_service.translate("admin.user_form.roles_label", locale_str).await,
935 - roles_hint: i18n_service.translate("admin.user_form.roles_hint", locale_str).await,
936 - must_change_password_label: i18n_service.translate("admin.user_form.must_change_password_label", locale_str).await,
937 - must_change_password_desc: i18n_service.translate("admin.user_form.must_change_password_desc", locale_str).await,
938 - cancel: i18n_service.translate("admin.user_form.cancel", locale_str).await,
939 - submit_label: i18n_service.translate("admin.user_form.submit_edit", locale_str).await,
1159 + breadcrumb_mode: i18n_service
1160 + .translate("admin.user_form.breadcrumb_edit", locale_str)
1161 + .await,
1162 + form_title: i18n_service
1163 + .translate("admin.user_form.title_edit", locale_str)
1164 + .await,
1165 + back_to_list: i18n_service
1166 + .translate("admin.user_form.back_to_list", locale_str)
1167 + .await,
1168 + username_label: i18n_service
1169 + .translate("admin.user_form.username_label", locale_str)
1170 + .await,
1171 + username_placeholder: i18n_service
1172 + .translate("admin.user_form.username_placeholder", locale_str)
1173 + .await,
1174 + username_readonly_hint: i18n_service
1175 + .translate("admin.user_form.username_readonly_hint", locale_str)
1176 + .await,
1177 + email_label: i18n_service
1178 + .translate("admin.user_form.email_label", locale_str)
1179 + .await,
1180 + email_placeholder: i18n_service
1181 + .translate("admin.user_form.email_placeholder", locale_str)
1182 + .await,
1183 + password_label: i18n_service
1184 + .translate("admin.user_form.password_label", locale_str)
1185 + .await,
1186 + password_optional: i18n_service
1187 + .translate("admin.user_form.password_optional", locale_str)
1188 + .await,
1189 + password_placeholder: i18n_service
1190 + .translate("admin.user_form.password_placeholder", locale_str)
1191 + .await,
1192 + password_hint: i18n_service
1193 + .translate("admin.user_form.password_hint", locale_str)
1194 + .await,
1195 + roles_label: i18n_service
1196 + .translate("admin.user_form.roles_label", locale_str)
1197 + .await,
1198 + roles_hint: i18n_service
1199 + .translate("admin.user_form.roles_hint", locale_str)
1200 + .await,
1201 + must_change_password_label: i18n_service
1202 + .translate("admin.user_form.must_change_password_label", locale_str)
1203 + .await,
1204 + must_change_password_desc: i18n_service
1205 + .translate("admin.user_form.must_change_password_desc", locale_str)
1206 + .await,
1207 + cancel: i18n_service
1208 + .translate("admin.user_form.cancel", locale_str)
1209 + .await,
1210 + submit_label: i18n_service
1211 + .translate("admin.user_form.submit_edit", locale_str)
1212 + .await,
940 1213 };
941 1214
942 1215 Ok(template)
@@ -945,10 +1218,13 @@ pub async fn edit_user_form(
945 1218 /// Form data for user update
946 1219 #[derive(serde::Deserialize)]
947 1220 pub struct UpdateUserFormData {
1221 + /// Updated email address.
948 1222 pub email: String,
1223 + /// Updated list of roles.
949 1224 #[serde(default)]
950 1225 #[serde(deserialize_with = "deserialize_string_or_vec")]
951 1226 pub roles: Vec<String>,
1227 + /// Whether to force a password change on next login.
952 1228 #[serde(default)]
953 1229 pub must_change_password: Option<bool>,
954 1230 }
@@ -961,10 +1237,14 @@ pub async fn update_user(
961 1237 axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
962 1238 axum::Form(form_data): axum::Form<UpdateUserFormData>,
963 1239 ) -> Result<impl IntoResponse, AppError> {
964 - use rustwarden_core::services::user_service::UserService;
965 1240 use rustwarden_core::services::role_service::RoleService;
1241 + use rustwarden_core::services::user_service::UserService;
966 1242
967 - let email = if form_data.email.is_empty() { None } else { Some(form_data.email) };
1243 + let email = if form_data.email.is_empty() {
1244 + None
1245 + } else {
1246 + Some(form_data.email)
1247 + };
968 1248
969 1249 // Update email if provided
970 1250 if let Some(ref new_email) = email {
@@ -973,9 +1253,11 @@ pub async fn update_user(
973 1253
974 1254 // Update must_change_password if provided
975 1255 if let Some(must_change) = form_data.must_change_password {
976 - use sea_orm::{ActiveModelTrait, Set};
977 1256 use rustwarden_core::models::user;
978 - let user_model = user::Entity::find_by_id(user_id).one(&db).await?
1257 + use sea_orm::{ActiveModelTrait, Set};
1258 + let user_model = user::Entity::find_by_id(user_id)
1259 + .one(&db)
1260 + .await?
979 1261 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
980 1262 let mut active: user::ActiveModel = user_model.into();
981 1263 active.must_change_password = Set(must_change);
@@ -1020,7 +1302,9 @@ pub async fn delete_user(
1020 1302 .await?;
1021 1303 let is_admin = admin_user_roles.iter().any(|ur| ur.user_id == user_id);
1022 1304 if is_admin && admin_user_roles.len() <= 1 {
1023 - return Err(AppError::Validation("Cannot delete the last admin user".to_string()));
1305 + return Err(AppError::Validation(
1306 + "Cannot delete the last admin user".to_string(),
1307 + ));
1024 1308 }
1025 1309 }
1026 1310
@@ -1036,7 +1320,9 @@ pub async fn delete_user(
1036 1320
1037 1321 // Delete user
1038 1322 use rustwarden_core::models::user;
1039 - let user_model = user::Entity::find_by_id(user_id).one(&db).await?
1323 + let user_model = user::Entity::find_by_id(user_id)
1324 + .one(&db)
1325 + .await?
1040 1326 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
1041 1327 use sea_orm::ModelTrait;
1042 1328 user_model.delete(&db).await?;
@@ -1116,7 +1402,9 @@ pub async fn reset_user_password(
1116 1402
1117 1403 use rustwarden_core::models::user;
1118 1404 use sea_orm::{ActiveModelTrait, Set};
1119 - let user_model = user::Entity::find_by_id(user_id).one(&db).await?
1405 + let user_model = user::Entity::find_by_id(user_id)
1406 + .one(&db)
1407 + .await?
1120 1408 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
1121 1409 let mut active: user::ActiveModel = user_model.into();
1122 1410 active.password_hash = Set(password_hash);
@@ -1127,7 +1415,8 @@ pub async fn reset_user_password(
1127 1415
1128 1416 let response = ResetPasswordResponse {
1129 1417 password: Some(new_password),
1130 - message: "Password reset successfully. This password will only be displayed once.".to_string(),
1418 + message: "Password reset successfully. This password will only be displayed once."
1419 + .to_string(),
1131 1420 };
1132 1421
1133 1422 Ok(axum::Json(response))
@@ -1282,6 +1571,8 @@ struct AppSettingsTemplateContext {
1282 1571 oauth_microsoft_client_id: String,
1283 1572 oauth_microsoft_has_secret: bool,
1284 1573 oauth_microsoft_tenant: String,
1574 + // TOTP 2FA
1575 + totp_force_enabled: String,
1285 1576 }
1286 1577
1287 1578 /// GET /admin/settings - Application settings page
@@ -1324,7 +1615,10 @@ pub async fn get_app_settings(
1324 1615 password_expiration_enabled: get_val("password_expiration_enabled", "false"),
1325 1616 password_expiration_days: get_val("password_expiration_days", "90"),
1326 1617 password_expiration_alert_enabled: get_val("password_expiration_alert_enabled", "false"),
1327 - password_expiration_alert_days_before: get_val("password_expiration_alert_days_before", "7"),
1618 + password_expiration_alert_days_before: get_val(
1619 + "password_expiration_alert_days_before",
1620 + "7",
1621 + ),
1328 1622 audit_log_level: get_val("audit_log_level", "INFO"),
1329 1623 audit_log_retention_days: get_val("audit_log_retention_days", "365"),
1330 1624 // OAuth
@@ -1345,12 +1639,16 @@ pub async fn get_app_settings(
1345 1639 oauth_microsoft_client_id: get_val("oauth_microsoft_client_id", ""),
1346 1640 oauth_microsoft_has_secret: !get_val("oauth_microsoft_client_secret", "").is_empty(),
1347 1641 oauth_microsoft_tenant: get_val("oauth_microsoft_tenant", ""),
1642 + // TOTP 2FA
1643 + totp_force_enabled: get_val("totp_force_enabled", "false"),
1348 1644 };
1349 1645
1350 1646 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1351 1647
1352 1648 let template = AdminSettingsTemplate {
1353 - page_title: i18n_service.translate("admin.settings.title", locale_str).await,
1649 + page_title: i18n_service
1650 + .translate("admin.settings.title", locale_str)
1651 + .await,
1354 1652 csrf_token,
1355 1653 app_name: base_context.app_name,
1356 1654 app_version: base_context.app_version,
@@ -1364,86 +1662,239 @@ pub async fn get_app_settings(
1364 1662 nav_items,
1365 1663 has_pending_password_change: false,
1366 1664 settings: settings_ctx,
1367 - title: i18n_service.translate("admin.settings.title", locale_str).await,
1368 - subtitle: i18n_service.translate("admin.settings.subtitle", locale_str).await,
1369 - general: i18n_service.translate("admin.settings.general", locale_str).await,
1370 - app_name_label: i18n_service.translate("admin.settings.app_name", locale_str).await,
1371 - app_domain_label: i18n_service.translate("admin.settings.app_domain", locale_str).await,
1372 - app_domain_placeholder: i18n_service.translate("admin.settings.app_domain_placeholder", locale_str).await,
1373 - app_description_label: i18n_service.translate("admin.settings.app_description", locale_str).await,
1374 - allow_registration_label: i18n_service.translate("admin.settings.allow_registration", locale_str).await,
1375 - allow_registration_desc: i18n_service.translate("admin.settings.allow_registration_desc", locale_str).await,
1376 - email_validation_required_label: i18n_service.translate("admin.settings.email_validation_required", locale_str).await,
1377 - email_validation_required_desc: i18n_service.translate("admin.settings.email_validation_required_desc", locale_str).await,
1378 - save_button: i18n_service.translate("admin.settings.save", locale_str).await,
1379 - cancel: i18n_service.translate("admin.settings.cancel", locale_str).await,
1380 - saved: i18n_service.translate("admin.settings.saved", locale_str).await,
1381 - error: i18n_service.translate("admin.settings.error", locale_str).await,
1382 - network_error: i18n_service.translate("admin.settings.network_error", locale_str).await,
1665 + title: i18n_service
1666 + .translate("admin.settings.title", locale_str)
1667 + .await,
1668 + subtitle: i18n_service
1669 + .translate("admin.settings.subtitle", locale_str)
1670 + .await,
1671 + general: i18n_service
1672 + .translate("admin.settings.general", locale_str)
1673 + .await,
1674 + app_name_label: i18n_service
1675 + .translate("admin.settings.app_name", locale_str)
1676 + .await,
1677 + app_domain_label: i18n_service
1678 + .translate("admin.settings.app_domain", locale_str)
1679 + .await,
1680 + app_domain_placeholder: i18n_service
1681 + .translate("admin.settings.app_domain_placeholder", locale_str)
1682 + .await,
1683 + app_description_label: i18n_service
1684 + .translate("admin.settings.app_description", locale_str)
1685 + .await,
1686 + allow_registration_label: i18n_service
1687 + .translate("admin.settings.allow_registration", locale_str)
1688 + .await,
1689 + allow_registration_desc: i18n_service
1690 + .translate("admin.settings.allow_registration_desc", locale_str)
1691 + .await,
1692 + email_validation_required_label: i18n_service
1693 + .translate("admin.settings.email_validation_required", locale_str)
1694 + .await,
1695 + email_validation_required_desc: i18n_service
1696 + .translate("admin.settings.email_validation_required_desc", locale_str)
1697 + .await,
1698 + save_button: i18n_service
1699 + .translate("admin.settings.save", locale_str)
1700 + .await,
1701 + cancel: i18n_service
1702 + .translate("admin.settings.cancel", locale_str)
1703 + .await,
1704 + saved: i18n_service
1705 + .translate("admin.settings.saved", locale_str)
1706 + .await,
1707 + error: i18n_service
1708 + .translate("admin.settings.error", locale_str)
1709 + .await,
1710 + network_error: i18n_service
1711 + .translate("admin.settings.network_error", locale_str)
1712 + .await,
1383 1713 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1384 1714 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1385 - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
1386 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1715 + breadcrumb_settings: i18n_service
1716 + .translate("breadcrumb.settings", locale_str)
1717 + .await,
1718 + user_menu_profile: i18n_service
1719 + .translate("user_menu.profile", locale_str)
1720 + .await,
1387 1721 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1388 1722
1389 - password_security: i18n_service.translate("admin.settings.password_security", locale_str).await,
1390 - password_min_length: i18n_service.translate("admin.settings.password_min_length", locale_str).await,
1391 - password_min_length_hint: i18n_service.translate("admin.settings.password_min_length_hint", locale_str).await,
1392 - password_complexity: i18n_service.translate("admin.settings.password_complexity", locale_str).await,
1393 - require_uppercase: i18n_service.translate("admin.settings.require_uppercase", locale_str).await,
1394 - require_lowercase: i18n_service.translate("admin.settings.require_lowercase", locale_str).await,
1395 - require_digits: i18n_service.translate("admin.settings.require_digits", locale_str).await,
1396 - require_special: i18n_service.translate("admin.settings.require_special", locale_str).await,
1397 - password_change_require_email: i18n_service.translate("admin.settings.password_change_require_email", locale_str).await,
1398 - password_change_require_email_desc: i18n_service.translate("admin.settings.password_change_require_email_desc", locale_str).await,
1399 - password_expiration: i18n_service.translate("admin.settings.password_expiration", locale_str).await,
1400 - password_expiration_desc: i18n_service.translate("admin.settings.password_expiration_desc", locale_str).await,
1401 - expiration_days: i18n_service.translate("admin.settings.expiration_days", locale_str).await,
1402 - expiration_alert: i18n_service.translate("admin.settings.expiration_alert", locale_str).await,
1403 - expiration_alert_days: i18n_service.translate("admin.settings.expiration_alert_days", locale_str).await,
1404 - audit_logging: i18n_service.translate("admin.settings.audit_logging", locale_str).await,
1405 - log_level: i18n_service.translate("admin.settings.log_level", locale_str).await,
1406 - log_level_debug: i18n_service.translate("admin.settings.log_level_debug", locale_str).await,
1407 - log_level_info: i18n_service.translate("admin.settings.log_level_info", locale_str).await,
1408 - log_level_warn: i18n_service.translate("admin.settings.log_level_warn", locale_str).await,
1409 - log_level_error: i18n_service.translate("admin.settings.log_level_error", locale_str).await,
1410 - log_level_hint: i18n_service.translate("admin.settings.log_level_hint", locale_str).await,
1411 - log_retention: i18n_service.translate("admin.settings.log_retention", locale_str).await,
1412 - log_retention_hint: i18n_service.translate("admin.settings.log_retention_hint", locale_str).await,
1413 - audit_info_message: i18n_service.translate("admin.settings.audit_info_message", locale_str).await,
1414 - save_changes: i18n_service.translate("admin.settings.save_changes", locale_str).await,
1415 - save_changes_desc: i18n_service.translate("admin.settings.save_changes_desc", locale_str).await,
1416 - saving: i18n_service.translate("admin.settings.saving", locale_str).await,
1417 - save_success: i18n_service.translate("admin.settings.save_success", locale_str).await,
1418 - save_error_prefix: i18n_service.translate("admin.settings.save_error_prefix", locale_str).await,
1723 + password_security: i18n_service
1724 + .translate("admin.settings.password_security", locale_str)
1725 + .await,
1726 + password_min_length: i18n_service
1727 + .translate("admin.settings.password_min_length", locale_str)
1728 + .await,
1729 + password_min_length_hint: i18n_service
1730 + .translate("admin.settings.password_min_length_hint", locale_str)
1731 + .await,
1732 + password_complexity: i18n_service
1733 + .translate("admin.settings.password_complexity", locale_str)
1734 + .await,
1735 + require_uppercase: i18n_service
1736 + .translate("admin.settings.require_uppercase", locale_str)
1737 + .await,
1738 + require_lowercase: i18n_service
1739 + .translate("admin.settings.require_lowercase", locale_str)
1740 + .await,
1741 + require_digits: i18n_service
1742 + .translate("admin.settings.require_digits", locale_str)
1743 + .await,
1744 + require_special: i18n_service
1745 + .translate("admin.settings.require_special", locale_str)
1746 + .await,
1747 + password_change_require_email: i18n_service
1748 + .translate("admin.settings.password_change_require_email", locale_str)
1749 + .await,
1750 + password_change_require_email_desc: i18n_service
1751 + .translate(
1752 + "admin.settings.password_change_require_email_desc",
1753 + locale_str,
1754 + )
1755 + .await,
1756 + password_expiration: i18n_service
1757 + .translate("admin.settings.password_expiration", locale_str)
1758 + .await,
1759 + password_expiration_desc: i18n_service
1760 + .translate("admin.settings.password_expiration_desc", locale_str)
1761 + .await,
1762 + expiration_days: i18n_service
1763 + .translate("admin.settings.expiration_days", locale_str)
1764 + .await,
1765 + expiration_alert: i18n_service
1766 + .translate("admin.settings.expiration_alert", locale_str)
1767 + .await,
1768 + expiration_alert_days: i18n_service
1769 + .translate("admin.settings.expiration_alert_days", locale_str)
1770 + .await,
1771 + audit_logging: i18n_service
1772 + .translate("admin.settings.audit_logging", locale_str)
1773 + .await,
1774 + log_level: i18n_service
1775 + .translate("admin.settings.log_level", locale_str)
1776 + .await,
1777 + log_level_debug: i18n_service
1778 + .translate("admin.settings.log_level_debug", locale_str)
1779 + .await,
1780 + log_level_info: i18n_service
1781 + .translate("admin.settings.log_level_info", locale_str)
1782 + .await,
1783 + log_level_warn: i18n_service
1784 + .translate("admin.settings.log_level_warn", locale_str)
1785 + .await,
1786 + log_level_error: i18n_service
1787 + .translate("admin.settings.log_level_error", locale_str)
1788 + .await,
1789 + log_level_hint: i18n_service
1790 + .translate("admin.settings.log_level_hint", locale_str)
1791 + .await,
1792 + log_retention: i18n_service
1793 + .translate("admin.settings.log_retention", locale_str)
1794 + .await,
1795 + log_retention_hint: i18n_service
1796 + .translate("admin.settings.log_retention_hint", locale_str)
1797 + .await,
1798 + audit_info_message: i18n_service
1799 + .translate("admin.settings.audit_info_message", locale_str)
1800 + .await,
1801 + save_changes: i18n_service
1802 + .translate("admin.settings.save_changes", locale_str)
1803 + .await,
1804 + save_changes_desc: i18n_service
1805 + .translate("admin.settings.save_changes_desc", locale_str)
1806 + .await,
1807 + saving: i18n_service
1808 + .translate("admin.settings.saving", locale_str)
1809 + .await,
1810 + save_success: i18n_service
1811 + .translate("admin.settings.save_success", locale_str)
1812 + .await,
1813 + save_error_prefix: i18n_service
1814 + .translate("admin.settings.save_error_prefix", locale_str)
1815 + .await,
1419 1816 // OAuth / SSO
1420 - oauth_sso_title: i18n_service.translate("admin.settings.oauth_sso_title", locale_str).await,
1421 - oauth_enabled_label: i18n_service.translate("admin.settings.oauth_enabled_label", locale_str).await,
1422 - oauth_enabled_desc: i18n_service.translate("admin.settings.oauth_enabled_desc", locale_str).await,
1423 - oauth_redirect_url_label: i18n_service.translate("admin.settings.oauth_redirect_url_label", locale_str).await,
1424 - oauth_redirect_url_desc: i18n_service.translate("admin.settings.oauth_redirect_url_desc", locale_str).await,
1425 - oauth_auto_register_label: i18n_service.translate("admin.settings.oauth_auto_register_label", locale_str).await,
1426 - oauth_auto_register_desc: i18n_service.translate("admin.settings.oauth_auto_register_desc", locale_str).await,
1427 - oauth_link_existing_label: i18n_service.translate("admin.settings.oauth_link_existing_label", locale_str).await,
1428 - oauth_link_existing_desc: i18n_service.translate("admin.settings.oauth_link_existing_desc", locale_str).await,
1429 - oauth_client_id: i18n_service.translate("admin.settings.oauth_client_id", locale_str).await,
1430 - oauth_client_secret: i18n_service.translate("admin.settings.oauth_client_secret", locale_str).await,
1431 - oauth_tenant: i18n_service.translate("admin.settings.oauth_tenant", locale_str).await,
1432 - oauth_redirect_uri: i18n_service.translate("admin.settings.oauth_redirect_uri", locale_str).await,
1433 - oauth_copy_uri: i18n_service.translate("admin.settings.oauth_copy_uri", locale_str).await,
1434 - oauth_secret_configured: i18n_service.translate("admin.settings.oauth_secret_configured", locale_str).await,
1435 - oauth_secret_not_configured: i18n_service.translate("admin.settings.oauth_secret_not_configured", locale_str).await,
1436 - oauth_secret_change: i18n_service.translate("admin.settings.oauth_secret_change", locale_str).await,
1437 - oauth_restart_warning: i18n_service.translate("admin.settings.oauth_restart_warning", locale_str).await,
1438 - oauth_env_info: i18n_service.translate("admin.settings.oauth_env_info", locale_str).await,
1439 - oauth_google_label: i18n_service.translate("admin.settings.oauth_google_label", locale_str).await,
1440 - oauth_google_desc: i18n_service.translate("admin.settings.oauth_google_desc", locale_str).await,
1441 - oauth_github_label: i18n_service.translate("admin.settings.oauth_github_label", locale_str).await,
1442 - oauth_github_desc: i18n_service.translate("admin.settings.oauth_github_desc", locale_str).await,
1443 - oauth_discord_label: i18n_service.translate("admin.settings.oauth_discord_label", locale_str).await,
1444 - oauth_discord_desc: i18n_service.translate("admin.settings.oauth_discord_desc", locale_str).await,
1445 - oauth_microsoft_label: i18n_service.translate("admin.settings.oauth_microsoft_label", locale_str).await,
1446 - oauth_microsoft_desc: i18n_service.translate("admin.settings.oauth_microsoft_desc", locale_str).await,
1817 + oauth_sso_title: i18n_service
1818 + .translate("admin.settings.oauth_sso_title", locale_str)
1819 + .await,
1820 + oauth_enabled_label: i18n_service
1821 + .translate("admin.settings.oauth_enabled_label", locale_str)
1822 + .await,
1823 + oauth_enabled_desc: i18n_service
1824 + .translate("admin.settings.oauth_enabled_desc", locale_str)
1825 + .await,
1826 + oauth_redirect_url_label: i18n_service
1827 + .translate("admin.settings.oauth_redirect_url_label", locale_str)
1828 + .await,
1829 + oauth_redirect_url_desc: i18n_service
1830 + .translate("admin.settings.oauth_redirect_url_desc", locale_str)
1831 + .await,
1832 + oauth_auto_register_label: i18n_service
1833 + .translate("admin.settings.oauth_auto_register_label", locale_str)
1834 + .await,
1835 + oauth_auto_register_desc: i18n_service
1836 + .translate("admin.settings.oauth_auto_register_desc", locale_str)
1837 + .await,
1838 + oauth_link_existing_label: i18n_service
1839 + .translate("admin.settings.oauth_link_existing_label", locale_str)
1840 + .await,
1841 + oauth_link_existing_desc: i18n_service
1842 + .translate("admin.settings.oauth_link_existing_desc", locale_str)
1843 + .await,
1844 + oauth_client_id: i18n_service
1845 + .translate("admin.settings.oauth_client_id", locale_str)
1846 + .await,
1847 + oauth_client_secret: i18n_service
1848 + .translate("admin.settings.oauth_client_secret", locale_str)
1849 + .await,
1850 + oauth_tenant: i18n_service
1851 + .translate("admin.settings.oauth_tenant", locale_str)
1852 + .await,
1853 + oauth_redirect_uri: i18n_service
1854 + .translate("admin.settings.oauth_redirect_uri", locale_str)
1855 + .await,
1856 + oauth_copy_uri: i18n_service
1857 + .translate("admin.settings.oauth_copy_uri", locale_str)
1858 + .await,
1859 + oauth_secret_configured: i18n_service
1860 + .translate("admin.settings.oauth_secret_configured", locale_str)
1861 + .await,
1862 + oauth_secret_not_configured: i18n_service
1863 + .translate("admin.settings.oauth_secret_not_configured", locale_str)
1864 + .await,
1865 + oauth_secret_change: i18n_service
1866 + .translate("admin.settings.oauth_secret_change", locale_str)
1867 + .await,
1868 + oauth_restart_warning: i18n_service
1869 + .translate("admin.settings.oauth_restart_warning", locale_str)
1870 + .await,
1871 + oauth_env_info: i18n_service
1872 + .translate("admin.settings.oauth_env_info", locale_str)
1873 + .await,
1874 + oauth_google_label: i18n_service
1875 + .translate("admin.settings.oauth_google_label", locale_str)
1876 + .await,
1877 + oauth_google_desc: i18n_service
1878 + .translate("admin.settings.oauth_google_desc", locale_str)
1879 + .await,
1880 + oauth_github_label: i18n_service
1881 + .translate("admin.settings.oauth_github_label", locale_str)
1882 + .await,
1883 + oauth_github_desc: i18n_service
1884 + .translate("admin.settings.oauth_github_desc", locale_str)
1885 + .await,
1886 + oauth_discord_label: i18n_service
1887 + .translate("admin.settings.oauth_discord_label", locale_str)
1888 + .await,
1889 + oauth_discord_desc: i18n_service
1890 + .translate("admin.settings.oauth_discord_desc", locale_str)
1891 + .await,
1892 + oauth_microsoft_label: i18n_service
1893 + .translate("admin.settings.oauth_microsoft_label", locale_str)
1894 + .await,
1895 + oauth_microsoft_desc: i18n_service
1896 + .translate("admin.settings.oauth_microsoft_desc", locale_str)
1897 + .await,
1447 1898 };
1448 1899
1449 1900 Ok(template)
@@ -1452,42 +1903,80 @@ pub async fn get_app_settings(
1452 1903 /// Form data received from admin settings page (url-encoded)
1453 1904 #[derive(Debug, serde::Deserialize)]
1454 1905 pub struct AdminSettingsFormData {
1906 + /// CSRF protection token.
1455 1907 pub csrf_token: Option<String>,
1908 + /// Application display name.
1456 1909 pub app_name: Option<String>,
1910 + /// Public domain of the application.
1457 1911 pub app_domain: Option<String>,
1912 + /// Short description of the application.
1458 1913 pub app_description: Option<String>,
1914 + /// Whether public self-registration is allowed (`"on"` / absent).
1459 1915 pub allow_registration: Option<String>,
1916 + /// Whether email validation is required on registration.
1460 1917 pub validation_email_requise: Option<String>,
1918 + /// Minimum password length.
1461 1919 pub password_min_length: Option<String>,
1920 + /// Whether passwords must contain at least one uppercase letter.
1462 1921 pub password_require_uppercase: Option<String>,
1922 + /// Whether passwords must contain at least one lowercase letter.
1463 1923 pub password_require_lowercase: Option<String>,
1924 + /// Whether passwords must contain at least one digit.
1464 1925 pub password_require_digits: Option<String>,
1926 + /// Whether passwords must contain at least one special character.
1465 1927 pub password_require_special: Option<String>,
1928 + /// Whether a password-change confirmation email is required.
1466 1929 pub password_change_require_email: Option<String>,
1930 + /// Whether periodic password expiration is enabled.
1467 1931 pub password_expiration_enabled: Option<String>,
1932 + /// Number of days before a password expires.
1468 1933 pub password_expiration_days: Option<String>,
1934 + /// Whether an alert email is sent before password expiry.
1469 1935 pub password_expiration_alert_enabled: Option<String>,
1936 + /// Number of days before expiry at which the alert is sent.
1470 1937 pub password_expiration_alert_days: Option<String>,
1938 + /// Minimum severity level for audit log entries.
1471 1939 pub audit_log_level: Option<String>,
1940 + /// Number of days to retain audit log entries.
1472 1941 pub audit_log_retention_days: Option<String>,
1473 1942 // OAuth
1943 + /// Whether OAuth social login is globally enabled.
1474 1944 pub oauth_enabled: Option<String>,
1945 + /// Base URL used to build OAuth redirect URIs.
1475 1946 pub oauth_redirect_base_url: Option<String>,
1947 + /// Whether new users are registered automatically on OAuth login.
1476 1948 pub oauth_auto_register: Option<String>,
1949 + /// Whether OAuth login can be linked to an existing local account.
1477 1950 pub oauth_link_existing_account: Option<String>,
1951 + /// Whether Google OAuth provider is enabled.
1478 1952 pub oauth_google_enabled: Option<String>,
1953 + /// Google OAuth client ID.
1479 1954 pub oauth_google_client_id: Option<String>,
1955 + /// Google OAuth client secret.
1480 1956 pub oauth_google_client_secret: Option<String>,
1957 + /// Whether GitHub OAuth provider is enabled.
1481 1958 pub oauth_github_enabled: Option<String>,
1959 + /// GitHub OAuth client ID.
1482 1960 pub oauth_github_client_id: Option<String>,
1961 + /// GitHub OAuth client secret.
1483 1962 pub oauth_github_client_secret: Option<String>,
1963 + /// Whether Discord OAuth provider is enabled.
1484 1964 pub oauth_discord_enabled: Option<String>,
1965 + /// Discord OAuth client ID.
1485 1966 pub oauth_discord_client_id: Option<String>,
1967 + /// Discord OAuth client secret.
1486 1968 pub oauth_discord_client_secret: Option<String>,
1969 + /// Whether Microsoft OAuth provider is enabled.
1487 1970 pub oauth_microsoft_enabled: Option<String>,
1971 + /// Microsoft OAuth client ID.
1488 1972 pub oauth_microsoft_client_id: Option<String>,
1973 + /// Microsoft OAuth client secret.
1489 1974 pub oauth_microsoft_client_secret: Option<String>,
1975 + /// Microsoft Azure AD tenant ID or `"common"`.
1490 1976 pub oauth_microsoft_tenant: Option<String>,
1977 + // TOTP 2FA
1978 + /// Whether TOTP two-factor authentication is enforced for all users.
1979 + pub totp_force_enabled: Option<String>,
1491 1980 }
1492 1981
1493 1982 /// PUT /admin/settings - Update app settings
@@ -1510,33 +1999,113 @@ pub async fn update_app_settings(
1510 1999 ("app_domain", form.app_domain.unwrap_or_default()),
1511 2000 ("app_description", form.app_description.unwrap_or_default()),
1512 2001 ("allow_registration", checkbox_val(&form.allow_registration)),
1513 - ("validation_email_required", checkbox_val(&form.validation_email_requise)),
1514 - ("password_min_length", form.password_min_length.unwrap_or_else(|| "8".to_string())),
1515 - ("password_require_uppercase", checkbox_val(&form.password_require_uppercase)),
1516 - ("password_require_lowercase", checkbox_val(&form.password_require_lowercase)),
1517 - ("password_require_digits", checkbox_val(&form.password_require_digits)),
1518 - ("password_require_special", checkbox_val(&form.password_require_special)),
1519 - ("password_change_require_email", checkbox_val(&form.password_change_require_email)),
1520 - ("password_expiration_enabled", checkbox_val(&form.password_expiration_enabled)),
1521 - ("password_expiration_days", form.password_expiration_days.unwrap_or_else(|| "90".to_string())),
1522 - ("password_expiration_alert_enabled", checkbox_val(&form.password_expiration_alert_enabled)),
1523 - ("password_expiration_alert_days_before", form.password_expiration_alert_days.unwrap_or_else(|| "7".to_string())),
1524 - ("audit_log_level", form.audit_log_level.unwrap_or_else(|| "INFO".to_string())),
1525 - ("audit_log_retention_days", form.audit_log_retention_days.unwrap_or_else(|| "365".to_string())),
2002 + (
2003 + "validation_email_required",
2004 + checkbox_val(&form.validation_email_requise),
2005 + ),
2006 + (
2007 + "password_min_length",
2008 + form.password_min_length.unwrap_or_else(|| "8".to_string()),
2009 + ),
2010 + (
2011 + "password_require_uppercase",
2012 + checkbox_val(&form.password_require_uppercase),
2013 + ),
2014 + (
2015 + "password_require_lowercase",
2016 + checkbox_val(&form.password_require_lowercase),
2017 + ),
2018 + (
2019 + "password_require_digits",
2020 + checkbox_val(&form.password_require_digits),
2021 + ),
2022 + (
2023 + "password_require_special",
2024 + checkbox_val(&form.password_require_special),
2025 + ),
2026 + (
2027 + "password_change_require_email",
2028 + checkbox_val(&form.password_change_require_email),
2029 + ),
2030 + (
2031 + "password_expiration_enabled",
2032 + checkbox_val(&form.password_expiration_enabled),
2033 + ),
2034 + (
2035 + "password_expiration_days",
2036 + form.password_expiration_days
2037 + .unwrap_or_else(|| "90".to_string()),
2038 + ),
2039 + (
2040 + "password_expiration_alert_enabled",
2041 + checkbox_val(&form.password_expiration_alert_enabled),
2042 + ),
2043 + (
2044 + "password_expiration_alert_days_before",
2045 + form.password_expiration_alert_days
2046 + .unwrap_or_else(|| "7".to_string()),
2047 + ),
2048 + (
2049 + "audit_log_level",
2050 + form.audit_log_level.unwrap_or_else(|| "INFO".to_string()),
2051 + ),
2052 + (
2053 + "audit_log_retention_days",
2054 + form.audit_log_retention_days
2055 + .unwrap_or_else(|| "365".to_string()),
2056 + ),
1526 2057 // OAuth toggles & non-secret fields
1527 2058 ("oauth_enabled", checkbox_val(&form.oauth_enabled)),
1528 - ("oauth_redirect_base_url", form.oauth_redirect_base_url.unwrap_or_default()),
1529 - ("oauth_auto_register", checkbox_val(&form.oauth_auto_register)),
1530 - ("oauth_link_existing_account", checkbox_val(&form.oauth_link_existing_account)),
1531 - ("oauth_google_enabled", checkbox_val(&form.oauth_google_enabled)),
1532 - ("oauth_google_client_id", form.oauth_google_client_id.unwrap_or_default()),
1533 - ("oauth_github_enabled", checkbox_val(&form.oauth_github_enabled)),
1534 - ("oauth_github_client_id", form.oauth_github_client_id.unwrap_or_default()),
1535 - ("oauth_discord_enabled", checkbox_val(&form.oauth_discord_enabled)),
1536 - ("oauth_discord_client_id", form.oauth_discord_client_id.unwrap_or_default()),
1537 - ("oauth_microsoft_enabled", checkbox_val(&form.oauth_microsoft_enabled)),
1538 - ("oauth_microsoft_client_id", form.oauth_microsoft_client_id.unwrap_or_default()),
1539 - ("oauth_microsoft_tenant", form.oauth_microsoft_tenant.unwrap_or_default()),
2059 + (
2060 + "oauth_redirect_base_url",
2061 + form.oauth_redirect_base_url.unwrap_or_default(),
2062 + ),
2063 + (
2064 + "oauth_auto_register",
2065 + checkbox_val(&form.oauth_auto_register),
2066 + ),
2067 + (
2068 + "oauth_link_existing_account",
2069 + checkbox_val(&form.oauth_link_existing_account),
2070 + ),
2071 + (
2072 + "oauth_google_enabled",
2073 + checkbox_val(&form.oauth_google_enabled),
2074 + ),
2075 + (
2076 + "oauth_google_client_id",
2077 + form.oauth_google_client_id.unwrap_or_default(),
2078 + ),
2079 + (
2080 + "oauth_github_enabled",
2081 + checkbox_val(&form.oauth_github_enabled),
2082 + ),
2083 + (
2084 + "oauth_github_client_id",
2085 + form.oauth_github_client_id.unwrap_or_default(),
2086 + ),
2087 + (
2088 + "oauth_discord_enabled",
2089 + checkbox_val(&form.oauth_discord_enabled),
2090 + ),
2091 + (
2092 + "oauth_discord_client_id",
2093 + form.oauth_discord_client_id.unwrap_or_default(),
2094 + ),
2095 + (
2096 + "oauth_microsoft_enabled",
2097 + checkbox_val(&form.oauth_microsoft_enabled),
2098 + ),
2099 + (
2100 + "oauth_microsoft_client_id",
2101 + form.oauth_microsoft_client_id.unwrap_or_default(),
2102 + ),
2103 + (
2104 + "oauth_microsoft_tenant",
2105 + form.oauth_microsoft_tenant.unwrap_or_default(),
2106 + ),
2107 + // TOTP 2FA
2108 + ("totp_force_enabled", checkbox_val(&form.totp_force_enabled)),
1540 2109 ];
1541 2110
1542 2111 let mut updated_count = 0u32;
@@ -1548,10 +2117,22 @@ pub async fn update_app_settings(
1548 2117 // OAuth secrets: only update if a non-empty value was submitted (otherwise keep existing)
1549 2118 use rustwarden_core::services::crypto_service::CryptoService;
1550 2119 let secret_fields = [
1551 - ("oauth_google_client_secret", &form.oauth_google_client_secret),
1552 - ("oauth_github_client_secret", &form.oauth_github_client_secret),
1553 - ("oauth_discord_client_secret", &form.oauth_discord_client_secret),
1554 - ("oauth_microsoft_client_secret", &form.oauth_microsoft_client_secret),
2120 + (
2121 + "oauth_google_client_secret",
2122 + &form.oauth_google_client_secret,
2123 + ),
2124 + (
2125 + "oauth_github_client_secret",
2126 + &form.oauth_github_client_secret,
2127 + ),
2128 + (
2129 + "oauth_discord_client_secret",
2130 + &form.oauth_discord_client_secret,
2131 + ),
2132 + (
2133 + "oauth_microsoft_client_secret",
2134 + &form.oauth_microsoft_client_secret,
2135 + ),
1555 2136 ];
1556 2137 for (key, value) in &secret_fields {
1557 2138 if let Some(v) = value {
@@ -1732,7 +2313,9 @@ pub async fn list_roles(
1732 2313 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1733 2314
1734 2315 let template = RolesListTemplate {
1735 - page_title: i18n_service.translate("admin.roles_list.page_title", locale_str).await,
2316 + page_title: i18n_service
2317 + .translate("admin.roles_list.page_title", locale_str)
2318 + .await,
1736 2319 csrf_token,
1737 2320 app_name: base_context.app_name,
1738 2321 app_version: base_context.app_version,
@@ -1746,26 +2329,58 @@ pub async fn list_roles(
1746 2329 nav_items,
1747 2330 has_pending_password_change: false,
1748 2331 roles: roles_display,
1749 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
2332 + user_menu_profile: i18n_service
2333 + .translate("user_menu.profile", locale_str)
2334 + .await,
1750 2335 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1751 2336
1752 2337 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1753 2338 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1754 - breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await,
1755 - title: i18n_service.translate("admin.roles_list.title", locale_str).await,
1756 - create_role: i18n_service.translate("admin.roles_list.create_role", locale_str).await,
1757 - table_name: i18n_service.translate("admin.roles_list.table_name", locale_str).await,
1758 - table_description: i18n_service.translate("admin.roles_list.table_description", locale_str).await,
1759 - table_users: i18n_service.translate("admin.roles_list.table_users", locale_str).await,
1760 - table_permissions: i18n_service.translate("admin.roles_list.table_permissions", locale_str).await,
1761 - table_actions: i18n_service.translate("admin.roles_list.table_actions", locale_str).await,
1762 - edit: i18n_service.translate("admin.roles_list.edit", locale_str).await,
1763 - delete_label: i18n_service.translate("admin.roles_list.delete", locale_str).await,
1764 - no_roles: i18n_service.translate("admin.roles_list.no_roles", locale_str).await,
1765 - protected_hint: i18n_service.translate("admin.roles_list.protected_hint", locale_str).await,
1766 - delete_confirm_title: i18n_service.translate("admin.roles_list.delete_confirm_title", locale_str).await,
1767 - delete_confirm_message: i18n_service.translate("admin.roles_list.delete_confirm_message", locale_str).await,
1768 - delete_button: i18n_service.translate("admin.roles_list.delete_button", locale_str).await,
2339 + breadcrumb_roles: i18n_service
2340 + .translate("admin.roles_list.breadcrumb_roles", locale_str)
2341 + .await,
2342 + title: i18n_service
2343 + .translate("admin.roles_list.title", locale_str)
2344 + .await,
2345 + create_role: i18n_service
2346 + .translate("admin.roles_list.create_role", locale_str)
2347 + .await,
2348 + table_name: i18n_service
2349 + .translate("admin.roles_list.table_name", locale_str)
2350 + .await,
2351 + table_description: i18n_service
2352 + .translate("admin.roles_list.table_description", locale_str)
2353 + .await,
2354 + table_users: i18n_service
2355 + .translate("admin.roles_list.table_users", locale_str)
2356 + .await,
2357 + table_permissions: i18n_service
2358 + .translate("admin.roles_list.table_permissions", locale_str)
2359 + .await,
2360 + table_actions: i18n_service
2361 + .translate("admin.roles_list.table_actions", locale_str)
2362 + .await,
2363 + edit: i18n_service
2364 + .translate("admin.roles_list.edit", locale_str)
2365 + .await,
2366 + delete_label: i18n_service
2367 + .translate("admin.roles_list.delete", locale_str)
2368 + .await,
2369 + no_roles: i18n_service
2370 + .translate("admin.roles_list.no_roles", locale_str)
2371 + .await,
2372 + protected_hint: i18n_service
2373 + .translate("admin.roles_list.protected_hint", locale_str)
2374 + .await,
2375 + delete_confirm_title: i18n_service
2376 + .translate("admin.roles_list.delete_confirm_title", locale_str)
2377 + .await,
2378 + delete_confirm_message: i18n_service
2379 + .translate("admin.roles_list.delete_confirm_message", locale_str)
2380 + .await,
2381 + delete_button: i18n_service
2382 + .translate("admin.roles_list.delete_button", locale_str)
2383 + .await,
1769 2384 cancel: i18n_service.translate("common.cancel", locale_str).await,
1770 2385 };
1771 2386
@@ -1819,7 +2434,9 @@ pub async fn create_role_form(
1819 2434 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1820 2435
1821 2436 let template = RoleFormTemplate {
1822 - page_title: i18n_service.translate("admin.role_form.title_create", locale_str).await,
2437 + page_title: i18n_service
2438 + .translate("admin.role_form.title_create", locale_str)
2439 + .await,
1823 2440 csrf_token,
1824 2441 app_name: base_context.app_name,
1825 2442 app_version: base_context.app_version,
@@ -1838,23 +2455,49 @@ pub async fn create_role_form(
1838 2455 form_description: String::new(),
1839 2456 is_protected: false,
1840 2457 permission_groups,
1841 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
2458 + user_menu_profile: i18n_service
2459 + .translate("user_menu.profile", locale_str)
2460 + .await,
1842 2461 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1843 2462
1844 2463 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1845 2464 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1846 - breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await,
1847 - breadcrumb_mode: i18n_service.translate("admin.role_form.breadcrumb_create", locale_str).await,
1848 - form_title: i18n_service.translate("admin.role_form.title_create", locale_str).await,
1849 - back_to_list: i18n_service.translate("admin.role_form.back_to_list", locale_str).await,
1850 - name_label: i18n_service.translate("admin.role_form.name_label", locale_str).await,
1851 - name_placeholder: i18n_service.translate("admin.role_form.name_placeholder", locale_str).await,
1852 - description_label: i18n_service.translate("admin.role_form.description_label", locale_str).await,
1853 - description_placeholder: i18n_service.translate("admin.role_form.description_placeholder", locale_str).await,
1854 - permissions_label: i18n_service.translate("admin.role_form.permissions_label", locale_str).await,
1855 - protected_name_hint: i18n_service.translate("admin.role_form.protected_name_hint", locale_str).await,
1856 - cancel: i18n_service.translate("admin.role_form.cancel", locale_str).await,
1857 - submit_label: i18n_service.translate("admin.role_form.submit_create", locale_str).await,
2465 + breadcrumb_roles: i18n_service
2466 + .translate("admin.roles_list.breadcrumb_roles", locale_str)
2467 + .await,
2468 + breadcrumb_mode: i18n_service
2469 + .translate("admin.role_form.breadcrumb_create", locale_str)
2470 + .await,
2471 + form_title: i18n_service
2472 + .translate("admin.role_form.title_create", locale_str)
2473 + .await,
2474 + back_to_list: i18n_service
2475 + .translate("admin.role_form.back_to_list", locale_str)
2476 + .await,
2477 + name_label: i18n_service
2478 + .translate("admin.role_form.name_label", locale_str)
2479 + .await,
2480 + name_placeholder: i18n_service
2481 + .translate("admin.role_form.name_placeholder", locale_str)
2482 + .await,
2483 + description_label: i18n_service
2484 + .translate("admin.role_form.description_label", locale_str)
2485 + .await,
2486 + description_placeholder: i18n_service
2487 + .translate("admin.role_form.description_placeholder", locale_str)
2488 + .await,
2489 + permissions_label: i18n_service
2490 + .translate("admin.role_form.permissions_label", locale_str)
2491 + .await,
2492 + protected_name_hint: i18n_service
2493 + .translate("admin.role_form.protected_name_hint", locale_str)
2494 + .await,
2495 + cancel: i18n_service
2496 + .translate("admin.role_form.cancel", locale_str)
2497 + .await,
2498 + submit_label: i18n_service
2499 + .translate("admin.role_form.submit_create", locale_str)
2500 + .await,
1858 2501 };
1859 2502
1860 2503 Ok(template)
@@ -1863,8 +2506,11 @@ pub async fn create_role_form(
1863 2506 /// Form data for role create/update
1864 2507 #[derive(Debug, serde::Deserialize)]
1865 2508 pub struct RoleFormData {
2509 + /// Unique role name (e.g. `"admin"`, `"user"`).
1866 2510 pub name: String,
2511 + /// Optional human-readable description of the role.
1867 2512 pub description: Option<String>,
2513 + /// List of permission keys granted by this role.
1868 2514 #[serde(default)]
1869 2515 pub permissions: Vec<String>,
1870 2516 }
@@ -1887,7 +2533,10 @@ pub async fn create_role(
1887 2533 .into_response());
1888 2534 }
1889 2535
1890 - if RoleService::get_role_by_name(&db, &form_data.name).await?.is_some() {
2536 + if RoleService::get_role_by_name(&db, &form_data.name)
2537 + .await?
2538 + .is_some()
2539 + {
1891 2540 return Ok(FormMessageTemplate {
1892 2541 message_type: "error".to_string(),
1893 2542 message: format!("Role '{}' already exists", form_data.name),
@@ -1948,7 +2597,9 @@ pub async fn edit_role_form(
1948 2597 let template = RoleFormTemplate {
1949 2598 page_title: format!(
1950 2599 "{} {}",
1951 - i18n_service.translate("admin.role_form.title_edit", locale_str).await,
2600 + i18n_service
2601 + .translate("admin.role_form.title_edit", locale_str)
2602 + .await,
1952 2603 role.name
1953 2604 ),
1954 2605 csrf_token,
@@ -1969,23 +2620,49 @@ pub async fn edit_role_form(
1969 2620 form_description: role.description.unwrap_or_default(),
1970 2621 is_protected,
1971 2622 permission_groups,
1972 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
2623 + user_menu_profile: i18n_service
2624 + .translate("user_menu.profile", locale_str)
2625 + .await,
1973 2626 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1974 2627
1975 2628 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1976 2629 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1977 - breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await,
1978 - breadcrumb_mode: i18n_service.translate("admin.role_form.breadcrumb_edit", locale_str).await,
1979 - form_title: i18n_service.translate("admin.role_form.title_edit", locale_str).await,
1980 - back_to_list: i18n_service.translate("admin.role_form.back_to_list", locale_str).await,
1981 - name_label: i18n_service.translate("admin.role_form.name_label", locale_str).await,
1982 - name_placeholder: i18n_service.translate("admin.role_form.name_placeholder", locale_str).await,
1983 - description_label: i18n_service.translate("admin.role_form.description_label", locale_str).await,
1984 - description_placeholder: i18n_service.translate("admin.role_form.description_placeholder", locale_str).await,
1985 - permissions_label: i18n_service.translate("admin.role_form.permissions_label", locale_str).await,
1986 - protected_name_hint: i18n_service.translate("admin.role_form.protected_name_hint", locale_str).await,
1987 - cancel: i18n_service.translate("admin.role_form.cancel", locale_str).await,
1988 - submit_label: i18n_service.translate("admin.role_form.submit_edit", locale_str).await,
2630 + breadcrumb_roles: i18n_service
2631 + .translate("admin.roles_list.breadcrumb_roles", locale_str)
2632 + .await,
2633 + breadcrumb_mode: i18n_service
2634 + .translate("admin.role_form.breadcrumb_edit", locale_str)
2635 + .await,
2636 + form_title: i18n_service
2637 + .translate("admin.role_form.title_edit", locale_str)
2638 + .await,
2639 + back_to_list: i18n_service
2640 + .translate("admin.role_form.back_to_list", locale_str)
2641 + .await,
2642 + name_label: i18n_service
2643 + .translate("admin.role_form.name_label", locale_str)
2644 + .await,
2645 + name_placeholder: i18n_service
2646 + .translate("admin.role_form.name_placeholder", locale_str)
2647 + .await,
2648 + description_label: i18n_service
2649 + .translate("admin.role_form.description_label", locale_str)
2650 + .await,
2651 + description_placeholder: i18n_service
2652 + .translate("admin.role_form.description_placeholder", locale_str)
2653 + .await,
2654 + permissions_label: i18n_service
2655 + .translate("admin.role_form.permissions_label", locale_str)
2656 + .await,
2657 + protected_name_hint: i18n_service
2658 + .translate("admin.role_form.protected_name_hint", locale_str)
2659 + .await,
2660 + cancel: i18n_service
2661 + .translate("admin.role_form.cancel", locale_str)
2662 + .await,
2663 + submit_label: i18n_service
2664 + .translate("admin.role_form.submit_edit", locale_str)
2665 + .await,
1989 2666 };
1990 2667
1991 2668 Ok(template)
@@ -2128,18 +2805,102 @@ pub async fn admin_errors(
2128 2805 }
2129 2806
2130 2807 let error_defs = vec![
2131 - ErrorDef { code_enum: ErrorCode::Auth001, desc_key: "admin.errors.error_auth001_desc", cat_key: "admin.errors.error_auth001_category", middleware: "auth_required", ctx_key: "admin.errors.error_auth001_context", ex_key: "admin.errors.error_auth001_example" },
2132 - ErrorDef { code_enum: ErrorCode::Auth002, desc_key: "admin.errors.error_auth002_desc", cat_key: "admin.errors.error_auth002_category", middleware: "auth_required", ctx_key: "admin.errors.error_auth002_context", ex_key: "admin.errors.error_auth002_example" },
2133 - ErrorDef { code_enum: ErrorCode::Auth003, desc_key: "admin.errors.error_auth003_desc", cat_key: "admin.errors.error_auth003_category", middleware: "auth_required", ctx_key: "admin.errors.error_auth003_context", ex_key: "admin.errors.error_auth003_example" },
2134 - ErrorDef { code_enum: ErrorCode::Csrf001, desc_key: "admin.errors.error_csrf001_desc", cat_key: "admin.errors.error_csrf001_category", middleware: "csrf", ctx_key: "admin.errors.error_csrf001_context", ex_key: "admin.errors.error_csrf001_example" },
2135 - ErrorDef { code_enum: ErrorCode::Csrf002, desc_key: "admin.errors.error_csrf002_desc", cat_key: "admin.errors.error_csrf002_category", middleware: "csrf", ctx_key: "admin.errors.error_csrf002_context", ex_key: "admin.errors.error_csrf002_example" },
2136 - ErrorDef { code_enum: ErrorCode::Rate001, desc_key: "admin.errors.error_rate001_desc", cat_key: "admin.errors.error_rate001_category", middleware: "rate_limit", ctx_key: "admin.errors.error_rate001_context", ex_key: "admin.errors.error_rate001_example" },
2137 - ErrorDef { code_enum: ErrorCode::Valid001, desc_key: "admin.errors.error_valid001_desc", cat_key: "admin.errors.error_valid001_category", middleware: "validation", ctx_key: "admin.errors.error_valid001_context", ex_key: "admin.errors.error_valid001_example" },
2138 - ErrorDef { code_enum: ErrorCode::Valid002, desc_key: "admin.errors.error_valid002_desc", cat_key: "admin.errors.error_valid002_category", middleware: "password_complexity", ctx_key: "admin.errors.error_valid002_context", ex_key: "admin.errors.error_valid002_example" },
2139 - ErrorDef { code_enum: ErrorCode::Rbac001, desc_key: "admin.errors.error_rbac001_desc", cat_key: "admin.errors.error_rbac001_category", middleware: "admin_required", ctx_key: "admin.errors.error_rbac001_context", ex_key: "admin.errors.error_rbac001_example" },
2140 - ErrorDef { code_enum: ErrorCode::Rbac002, desc_key: "admin.errors.error_rbac002_desc", cat_key: "admin.errors.error_rbac002_category", middleware: "admin_required", ctx_key: "admin.errors.error_rbac002_context", ex_key: "admin.errors.error_rbac002_example" },
2141 - ErrorDef { code_enum: ErrorCode::Db001, desc_key: "admin.errors.error_db001_desc", cat_key: "admin.errors.error_db001_category", middleware: "database", ctx_key: "admin.errors.error_db001_context", ex_key: "admin.errors.error_db001_example" },
2142 - ErrorDef { code_enum: ErrorCode::Db002, desc_key: "admin.errors.error_db002_desc", cat_key: "admin.errors.error_db002_category", middleware: "database", ctx_key: "admin.errors.error_db002_context", ex_key: "admin.errors.error_db002_example" },
2808 + ErrorDef {
2809 + code_enum: ErrorCode::Auth001,
2810 + desc_key: "admin.errors.error_auth001_desc",
2811 + cat_key: "admin.errors.error_auth001_category",
2812 + middleware: "auth_required",
2813 + ctx_key: "admin.errors.error_auth001_context",
2814 + ex_key: "admin.errors.error_auth001_example",
2815 + },
2816 + ErrorDef {
2817 + code_enum: ErrorCode::Auth002,
2818 + desc_key: "admin.errors.error_auth002_desc",
2819 + cat_key: "admin.errors.error_auth002_category",
2820 + middleware: "auth_required",
2821 + ctx_key: "admin.errors.error_auth002_context",
2822 + ex_key: "admin.errors.error_auth002_example",
2823 + },
2824 + ErrorDef {
2825 + code_enum: ErrorCode::Auth003,
2826 + desc_key: "admin.errors.error_auth003_desc",
2827 + cat_key: "admin.errors.error_auth003_category",
2828 + middleware: "auth_required",
2829 + ctx_key: "admin.errors.error_auth003_context",
2830 + ex_key: "admin.errors.error_auth003_example",
2831 + },
2832 + ErrorDef {
2833 + code_enum: ErrorCode::Csrf001,
2834 + desc_key: "admin.errors.error_csrf001_desc",
2835 + cat_key: "admin.errors.error_csrf001_category",
2836 + middleware: "csrf",
2837 + ctx_key: "admin.errors.error_csrf001_context",
2838 + ex_key: "admin.errors.error_csrf001_example",
2839 + },
2840 + ErrorDef {
2841 + code_enum: ErrorCode::Csrf002,
2842 + desc_key: "admin.errors.error_csrf002_desc",
2843 + cat_key: "admin.errors.error_csrf002_category",
2844 + middleware: "csrf",
2845 + ctx_key: "admin.errors.error_csrf002_context",
2846 + ex_key: "admin.errors.error_csrf002_example",
2847 + },
2848 + ErrorDef {
2849 + code_enum: ErrorCode::Rate001,
2850 + desc_key: "admin.errors.error_rate001_desc",
2851 + cat_key: "admin.errors.error_rate001_category",
2852 + middleware: "rate_limit",
2853 + ctx_key: "admin.errors.error_rate001_context",
2854 + ex_key: "admin.errors.error_rate001_example",
2855 + },
2856 + ErrorDef {
2857 + code_enum: ErrorCode::Valid001,
2858 + desc_key: "admin.errors.error_valid001_desc",
2859 + cat_key: "admin.errors.error_valid001_category",
2860 + middleware: "validation",
2861 + ctx_key: "admin.errors.error_valid001_context",
2862 + ex_key: "admin.errors.error_valid001_example",
2863 + },
2864 + ErrorDef {
2865 + code_enum: ErrorCode::Valid002,
2866 + desc_key: "admin.errors.error_valid002_desc",
2867 + cat_key: "admin.errors.error_valid002_category",
2868 + middleware: "password_complexity",
2869 + ctx_key: "admin.errors.error_valid002_context",
2870 + ex_key: "admin.errors.error_valid002_example",
2871 + },
2872 + ErrorDef {
2873 + code_enum: ErrorCode::Rbac001,
2874 + desc_key: "admin.errors.error_rbac001_desc",
2875 + cat_key: "admin.errors.error_rbac001_category",
2876 + middleware: "admin_required",
2877 + ctx_key: "admin.errors.error_rbac001_context",
2878 + ex_key: "admin.errors.error_rbac001_example",
2879 + },
2880 + ErrorDef {
2881 + code_enum: ErrorCode::Rbac002,
2882 + desc_key: "admin.errors.error_rbac002_desc",
2883 + cat_key: "admin.errors.error_rbac002_category",
2884 + middleware: "admin_required",
2885 + ctx_key: "admin.errors.error_rbac002_context",
2886 + ex_key: "admin.errors.error_rbac002_example",
2887 + },
2888 + ErrorDef {
2889 + code_enum: ErrorCode::Db001,
2890 + desc_key: "admin.errors.error_db001_desc",
2891 + cat_key: "admin.errors.error_db001_category",
2892 + middleware: "database",
2893 + ctx_key: "admin.errors.error_db001_context",
2894 + ex_key: "admin.errors.error_db001_example",
2895 + },
2896 + ErrorDef {
2897 + code_enum: ErrorCode::Db002,
2898 + desc_key: "admin.errors.error_db002_desc",
2899 + cat_key: "admin.errors.error_db002_category",
2900 + middleware: "database",
2901 + ctx_key: "admin.errors.error_db002_context",
2902 + ex_key: "admin.errors.error_db002_example",
2903 + },
2143 2904 ];
2144 2905
2145 2906 let mut error_codes = Vec::new();
@@ -2157,7 +2918,9 @@ pub async fn admin_errors(
2157 2918 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
2158 2919
2159 2920 let template = AdminErrorsTemplate {
2160 - page_title: i18n_service.translate("admin.errors.title", locale_str).await,
2921 + page_title: i18n_service
2922 + .translate("admin.errors.title", locale_str)
2923 + .await,
2161 2924 csrf_token,
2162 2925 app_name: base_context.app_name,
2163 2926 app_version: base_context.app_version,
@@ -2171,28 +2934,90 @@ pub async fn admin_errors(
2171 2934 nav_items,
2172 2935 has_pending_password_change: false,
2173 2936 error_codes,
2174 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
2937 + user_menu_profile: i18n_service
2938 + .translate("user_menu.profile", locale_str)
2939 + .await,
2175 2940 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
2176 2941
2177 2942 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
2178 2943 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
2179 - breadcrumb_errors: i18n_service.translate("admin.errors.breadcrumb_errors", locale_str).await,
2180 - title: i18n_service.translate("admin.errors.title", locale_str).await,
2181 - description: i18n_service.translate("admin.errors.description", locale_str).await,
2182 - table_code: i18n_service.translate("admin.errors.table_code", locale_str).await,
2183 - table_description: i18n_service.translate("admin.errors.table_description", locale_str).await,
2184 - table_category: i18n_service.translate("admin.errors.table_category", locale_str).await,
2185 - table_middleware: i18n_service.translate("admin.errors.table_middleware", locale_str).await,
2186 - table_context: i18n_service.translate("admin.errors.table_context", locale_str).await,
2187 - table_example: i18n_service.translate("admin.errors.table_example", locale_str).await,
2188 - legend_title: i18n_service.translate("admin.errors.legend_title", locale_str).await,
2189 - legend_code: i18n_service.translate("admin.errors.legend_code", locale_str).await,
2190 - legend_description: i18n_service.translate("admin.errors.legend_description", locale_str).await,
2191 - legend_category: i18n_service.translate("admin.errors.legend_category", locale_str).await,
2192 - legend_middleware: i18n_service.translate("admin.errors.legend_middleware", locale_str).await,
2193 - legend_context: i18n_service.translate("admin.errors.legend_context", locale_str).await,
2194 - legend_example: i18n_service.translate("admin.errors.legend_example", locale_str).await,
2944 + breadcrumb_errors: i18n_service
2945 + .translate("admin.errors.breadcrumb_errors", locale_str)
2946 + .await,
2947 + title: i18n_service
2948 + .translate("admin.errors.title", locale_str)
2949 + .await,
2950 + description: i18n_service
2951 + .translate("admin.errors.description", locale_str)
2952 + .await,
2953 + table_code: i18n_service
2954 + .translate("admin.errors.table_code", locale_str)
2955 + .await,
2956 + table_description: i18n_service
2957 + .translate("admin.errors.table_description", locale_str)
2958 + .await,
2959 + table_category: i18n_service
2960 + .translate("admin.errors.table_category", locale_str)
2961 + .await,
2962 + table_middleware: i18n_service
2963 + .translate("admin.errors.table_middleware", locale_str)
2964 + .await,
2965 + table_context: i18n_service
2966 + .translate("admin.errors.table_context", locale_str)
2967 + .await,
2968 + table_example: i18n_service
2969 + .translate("admin.errors.table_example", locale_str)
2970 + .await,
2971 + legend_title: i18n_service
2972 + .translate("admin.errors.legend_title", locale_str)
2973 + .await,
2974 + legend_code: i18n_service
2975 + .translate("admin.errors.legend_code", locale_str)
2976 + .await,
2977 + legend_description: i18n_service
2978 + .translate("admin.errors.legend_description", locale_str)
2979 + .await,
2980 + legend_category: i18n_service
2981 + .translate("admin.errors.legend_category", locale_str)
2982 + .await,
2983 + legend_middleware: i18n_service
2984 + .translate("admin.errors.legend_middleware", locale_str)
2985 + .await,
2986 + legend_context: i18n_service
2987 + .translate("admin.errors.legend_context", locale_str)
2988 + .await,
2989 + legend_example: i18n_service
2990 + .translate("admin.errors.legend_example", locale_str)
2991 + .await,
2195 2992 };
2196 2993
2197 2994 Ok(template)
2198 2995 }
2996 +
2997 +/// POST /admin/users/{id}/reset-totp — Admin resets a user's 2FA
2998 +pub async fn reset_user_totp(
2999 + Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
3000 + State(db): State<sea_orm::DatabaseConnection>,
3001 + axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
3002 +) -> Result<axum::response::Response, rustwarden_core::error::AppError> {
3003 + use axum::response::IntoResponse;
3004 + use rustwarden_core::services::totp_service::TotpService;
3005 +
3006 + // Verify the target user exists
3007 + let _user = rustwarden_core::services::user_service::UserService::get_user_by_id(&db, user_id)
3008 + .await?
3009 + .ok_or_else(|| rustwarden_core::error::AppError::NotFound("User not found".to_string()))?;
3010 +
3011 + // Disable TOTP (delete the row)
3012 + match TotpService::disable_totp(&db, user_id).await {
3013 + Ok(()) => {}
3014 + Err(rustwarden_core::error::AppError::TotpNotEnabled) => {
3015 + // Already disabled, not an error
3016 + }
3017 + Err(e) => return Err(e),
3018 + }
3019 +
3020 + tracing::info!(admin = %claims.username, target_user = %user_id, "TOTP_DISABLED_BY_ADMIN");
3021 +
3022 + Ok(axum::Json(serde_json::json!({ "success": true })).into_response())
3023 +}
M crates/rustwarden-ui/src/handlers/dashboard_handler.rs
+6 -0
@@ -9,11 +9,17 @@ use serde::Serialize;
9 9 /// DTO for a dashboard widget (API response)
10 10 #[derive(Debug, Clone, Serialize)]
11 11 pub struct DashboardWidgetDto {
12 + /// Unique widget identifier.
12 13 pub id: String,
14 + /// Identifier of the application that owns this widget.
13 15 pub app_id: String,
16 + /// Display title shown in the widget header.
14 17 pub title: String,
18 + /// Front-end component name used to render the widget.
15 19 pub component: String,
20 + /// Display order relative to other widgets.
16 21 pub order: i32,
22 + /// Size class of the widget (e.g. `"small"`, `"large"`).
17 23 pub size: String,
18 24 }
19 25
M crates/rustwarden-ui/src/handlers/email_handler.rs
+2 -2
@@ -9,8 +9,6 @@ use crate::dto::{
9 9 EmailDeliveryStatusListResponse, EmailDeliveryStatusResponse, EmailQueueDetailResponse,
10 10 EmailQueueEntry, EmailQueueListResponse, RetryEmailResponse,
11 11 };
12 -use rustwarden_core::error::AppError;
13 -use rustwarden_core::services::email_queue_service::EmailQueueService;
14 12 use axum::{
15 13 extract::{Path, Query, State},
16 14 http::StatusCode,
@@ -18,6 +16,8 @@ use axum::{
18 16 Json,
19 17 };
20 18 use chrono::{DateTime, Utc};
19 +use rustwarden_core::error::AppError;
20 +use rustwarden_core::services::email_queue_service::EmailQueueService;
21 21 use sea_orm::DatabaseConnection;
22 22 use serde::Deserialize;
23 23 use uuid::Uuid;
M crates/rustwarden-ui/src/handlers/home_handler.rs
+1 -13
@@ -44,7 +44,6 @@ struct HomeTemplate {
44 44 account_stats: Vec<AccountStatDTO>,
45 45 user_menu_profile: String,
46 46 user_menu_logout: String,
47 -
48 47 }
49 48
50 49 /// Simplified navigation item for templates
@@ -120,10 +119,7 @@ pub async fn home(
120 119 let user_menu_profile = i18n_service
121 120 .translate("user_menu.profile", locale_str)
122 121 .await;
123 - let user_menu_logout = i18n_service
124 - .translate("user_menu.logout", locale_str)
125 - .await;
126 -
122 + let user_menu_logout = i18n_service.translate("user_menu.logout", locale_str).await;
127 123
128 124 let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
129 125 let nav_items: Vec<NavItem> = navigation
@@ -180,11 +176,3 @@ pub async fn home(
180 176
181 177 Ok(template)
182 178 }
183 -
184 -#[cfg(test)]
185 -mod tests {
186 - use super::*;
187 -
188 - #[test]
189 - fn test_home_template_compiles() {}
190 -}
M crates/rustwarden-ui/src/handlers/login_handler.rs
+133 -23
@@ -130,7 +130,7 @@ pub async fn login_page(
130 130 .get("locale")
131 131 .and_then(|c| {
132 132 let value = c.value();
133 - if matches!(value, "fr" | "en" | "de" | "es") {
133 + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
134 134 Some(value.to_string())
135 135 } else {
136 136 None
@@ -159,6 +159,16 @@ pub async fn login_page(
159 159 "Español".to_string(),
160 160 current_locale == "es",
161 161 ),
162 + (
163 + "it".to_string(),
164 + "Italiano".to_string(),
165 + current_locale == "it",
166 + ),
167 + (
168 + "pt".to_string(),
169 + "Português".to_string(),
170 + current_locale == "pt",
171 + ),
162 172 ];
163 173
164 174 let locale_str = &current_locale;
@@ -177,7 +187,9 @@ pub async fn login_page(
177 187 .await;
178 188 let username_label = i18n_service.translate("login.username", locale_str).await;
179 189 let password_label = i18n_service.translate("login.password", locale_str).await;
180 - let email_label = i18n_service.translate("login.email_label", locale_str).await;
190 + let email_label = i18n_service
191 + .translate("login.email_label", locale_str)
192 + .await;
181 193 let username_placeholder = i18n_service
182 194 .translate("login.username_placeholder", locale_str)
183 195 .await;
@@ -286,9 +298,11 @@ pub async fn login_page(
286 298 resend_prompt,
287 299 register_success,
288 300 register_error,
289 - pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db)
290 - .await
291 - .min_length,
301 + pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(
302 + &db,
303 + )
304 + .await
305 + .min_length,
292 306 };
293 307
294 308 Ok(template)
@@ -307,7 +321,7 @@ pub async fn resend_verification_page(
307 321 .get("locale")
308 322 .and_then(|c| {
309 323 let value = c.value();
310 - if matches!(value, "fr" | "en" | "de" | "es") {
324 + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
311 325 Some(value.to_string())
312 326 } else {
313 327 None
@@ -316,17 +330,39 @@ pub async fn resend_verification_page(
316 330 .unwrap_or_else(|| "fr".to_string());
317 331
318 332 let locale_str = &current_locale;
319 - let page_title = i18n_service.translate("resend_verification.page_title", locale_str).await;
320 - let title = i18n_service.translate("resend_verification.title", locale_str).await;
321 - let subtitle = i18n_service.translate("resend_verification.subtitle", locale_str).await;
322 - let email_label = i18n_service.translate("resend_verification.email_label", locale_str).await;
323 - let email_placeholder = i18n_service.translate("resend_verification.email_placeholder", locale_str).await;
324 - let submit = i18n_service.translate("resend_verification.submit", locale_str).await;
325 - let back_to_login = i18n_service.translate("resend_verification.back_to_login", locale_str).await;
326 - let error_default = i18n_service.translate("resend_verification.error_default", locale_str).await;
327 - let success_default = i18n_service.translate("resend_verification.success_default", locale_str).await;
328 - let error_missing_email = i18n_service.translate("resend_verification.error_missing_email", locale_str).await;
329 - let error_cannot_send = i18n_service.translate("resend_verification.error_cannot_send", locale_str).await;
333 + let page_title = i18n_service
334 + .translate("resend_verification.page_title", locale_str)
335 + .await;
336 + let title = i18n_service
337 + .translate("resend_verification.title", locale_str)
338 + .await;
339 + let subtitle = i18n_service
340 + .translate("resend_verification.subtitle", locale_str)
341 + .await;
342 + let email_label = i18n_service
343 + .translate("resend_verification.email_label", locale_str)
344 + .await;
345 + let email_placeholder = i18n_service
346 + .translate("resend_verification.email_placeholder", locale_str)
347 + .await;
348 + let submit = i18n_service
349 + .translate("resend_verification.submit", locale_str)
350 + .await;
351 + let back_to_login = i18n_service
352 + .translate("resend_verification.back_to_login", locale_str)
353 + .await;
354 + let error_default = i18n_service
355 + .translate("resend_verification.error_default", locale_str)
356 + .await;
357 + let success_default = i18n_service
358 + .translate("resend_verification.success_default", locale_str)
359 + .await;
360 + let error_missing_email = i18n_service
361 + .translate("resend_verification.error_missing_email", locale_str)
362 + .await;
363 + let error_cannot_send = i18n_service
364 + .translate("resend_verification.error_cannot_send", locale_str)
365 + .await;
330 366
331 367 let layout = LayoutContext::new("", "");
332 368
@@ -383,7 +419,7 @@ pub async fn reset_password_page(
383 419 .get("locale")
384 420 .and_then(|c| {
385 421 let value = c.value();
386 - if matches!(value, "fr" | "en" | "de" | "es") {
422 + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
387 423 Some(value.to_string())
388 424 } else {
389 425 None
@@ -490,10 +526,84 @@ pub async fn reset_password_page(
490 526 Ok(template)
491 527 }
492 528
493 -#[cfg(test)]
494 -mod tests {
495 - use super::*;
529 +/// Login 2FA page template
530 +#[derive(Template)]
531 +#[template(path = "login_2fa.html")]
532 +#[allow(dead_code)]
533 +struct Login2faTemplate {
534 + app_name: String,
535 + app_version: String,
536 + page_title: String,
537 + title: String,
538 + subtitle: String,
539 + code_label: String,
540 + backup_code_label: String,
541 + verify_button: String,
542 + use_backup_code: String,
543 + use_totp_code: String,
544 + back_to_login: String,
545 + error_invalid_code: String,
546 + error_network: String,
547 +}
548 +
549 +rustwarden_core::impl_template_response!(Login2faTemplate);
550 +
551 +/// GET /login/2fa — 2FA verification page
552 +pub async fn login_2fa_page(
553 + headers: HeaderMap,
554 + Extension(i18n_service): Extension<Arc<I18nService>>,
555 +) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
556 + let jar = CookieJar::from_headers(&headers);
557 + let current_locale = jar
558 + .get("locale")
559 + .and_then(|c| {
560 + let value = c.value();
561 + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
562 + Some(value.to_string())
563 + } else {
564 + None
565 + }
566 + })
567 + .unwrap_or_else(|| "fr".to_string());
568 +
569 + let locale_str = &current_locale;
570 + let layout = LayoutContext::new("", "");
571 +
572 + let template = Login2faTemplate {
573 + app_name: layout.app_name.clone(),
574 + app_version: layout.app_version.clone(),
575 + page_title: i18n_service
576 + .translate("login_2fa.page_title", locale_str)
577 + .await,
578 + title: i18n_service.translate("login_2fa.title", locale_str).await,
579 + subtitle: i18n_service
580 + .translate("login_2fa.subtitle", locale_str)
581 + .await,
582 + code_label: i18n_service
583 + .translate("login_2fa.code_label", locale_str)
584 + .await,
585 + backup_code_label: i18n_service
586 + .translate("login_2fa.backup_code_label", locale_str)
587 + .await,
588 + verify_button: i18n_service
589 + .translate("login_2fa.verify_button", locale_str)
590 + .await,
591 + use_backup_code: i18n_service
592 + .translate("login_2fa.use_backup_code", locale_str)
593 + .await,
594 + use_totp_code: i18n_service
595 + .translate("login_2fa.use_totp_code", locale_str)
596 + .await,
597 + back_to_login: i18n_service
598 + .translate("login_2fa.back_to_login", locale_str)
599 + .await,
600 + error_invalid_code: i18n_service
601 + .translate("login_2fa.error_invalid_code", locale_str)
602 + .await,
603 + error_network: i18n_service
604 + .translate("login_2fa.error_network", locale_str)
605 + .await,
606 + };
496 607
497 - #[test]
498 - fn test_login_template_compiles() {}
608 + Ok(template)
499 609 }
M crates/rustwarden-ui/src/handlers/mod.rs
+14 -1
@@ -1,13 +1,26 @@
1 1 /// API handlers module
2 2 ///
3 3 /// Axum request handlers for the Rustwarden framework.
4 +
5 +/// Admin panel handlers (users, roles, settings).
4 6 pub mod admin_handler;
7 +/// Dashboard widget handlers.
5 8 pub mod dashboard_handler;
9 +/// Email delivery status handlers.
6 10 pub mod email_handler;
11 +/// Health-check endpoint handler.
7 12 pub mod health;
13 +/// Home page handler.
8 14 pub mod home_handler;
15 +/// Login and logout handlers.
9 16 pub mod login_handler;
17 +/// Navigation menu handler.
10 18 pub mod navigation_handler;
19 +/// Root redirect handler.
11 20 pub mod root_handler;
12 -pub mod settings_handler;
21 +/// Settings sub-handlers (profile, security, preferences).
22 +pub mod settings;
23 +/// TOTP two-factor authentication handlers.
24 +pub mod totp_handler;
25 +/// User management handlers.
13 26 pub mod user_handler;
M crates/rustwarden-ui/src/handlers/navigation_handler.rs
+6 -0
@@ -9,11 +9,17 @@ use serde::Serialize;
9 9 /// DTO for a navigation item (API response)
10 10 #[derive(Debug, Clone, Serialize)]
11 11 pub struct NavigationItemDto {
12 + /// Unique identifier for this navigation item.
12 13 pub id: String,
14 + /// Display label shown in the navigation menu.
13 15 pub name: String,
16 + /// Icon class or name used by the front-end.
14 17 pub icon: String,
18 + /// Target route path for this navigation item.
15 19 pub route: String,
20 + /// Display order relative to sibling items.
16 21 pub order: i32,
22 + /// Parent item identifier for nested menus.
17 23 #[serde(skip_serializing_if = "Option::is_none")]
18 24 pub parent_id: Option<String>,
19 25 }
M crates/rustwarden-ui/src/handlers/root_handler.rs
+0 -11
@@ -76,14 +76,3 @@ pub async fn root_redirect(request: Request) -> impl IntoResponse {
76 76 }
77 77 }
78 78 }
79 -
80 -#[cfg(test)]
81 -mod tests {
82 - use super::*;
83 -
84 - #[test]
85 - fn test_root_handler_compiles() {
86 - // This test verifies the handler compiles
87 - // Integration tests will test actual behavior
88 - }
89 -}
A crates/rustwarden-ui/src/handlers/settings/mod.rs
+84 -0
@@ -0,0 +1,84 @@
1 +//! Settings handlers — split by domain: profile, security, preferences.
2 +//!
3 +//! Since the module split (see `docs/CHANGELOG_SETTINGS_SPLIT.md`), the
4 +//! handlers that were in `handlers::settings_handler` are distributed across
5 +//! `handlers::settings::profile`, `handlers::settings::security` and
6 +//! `handlers::settings::preferences`. Consumer apps must update their import
7 +//! paths accordingly.
8 +
9 +pub mod preferences;
10 +pub mod profile;
11 +pub mod security;
12 +
13 +use crate::dto::navigation_dto::NavigationDTO;
14 +
15 +/// Custom filters shared by Askama templates in settings pages.
16 +///
17 +/// Exposed as `pub mod` so that the `Template` derive expansion in
18 +/// sub-modules can resolve `filters::default` / `filters::length` after
19 +/// a simple `use super::filters;` import.
20 +#[allow(dead_code, missing_docs)]
21 +pub mod filters {
22 + use askama::Result;
23 +
24 + /// Askama filter: returns `value` as a string, or `default_value` if it is empty.
25 + #[allow(dead_code)]
26 + #[askama::filter_fn]
27 + pub fn default<T: std::fmt::Display>(
28 + value: T,
29 + _values: &dyn askama::Values,
30 + default_value: &str,
31 + ) -> Result<String> {
32 + let value_str = value.to_string();
33 + if value_str.is_empty() {
34 + Ok(default_value.to_string())
35 + } else {
36 + Ok(value_str)
37 + }
38 + }
39 +
40 + /// Askama filter: returns the number of elements in an iterable.
41 + #[askama::filter_fn]
42 + pub fn length<I: IntoIterator>(value: I, _values: &dyn askama::Values) -> Result<usize> {
43 + Ok(value.into_iter().count())
44 + }
45 +}
46 +
47 +/// Simplified navigation item for templates
48 +#[derive(Clone)]
49 +#[allow(dead_code)]
50 +pub(crate) struct NavItem {
51 + pub(crate) id: String,
52 + pub(crate) label: String,
53 + pub(crate) icon: String,
54 + pub(crate) url: String,
55 + pub(crate) active: bool,
56 + pub(crate) enabled: bool,
57 + pub(crate) badge: String,
58 + pub(crate) has_badge: bool,
59 + pub(crate) disabled_message: String,
60 + pub(crate) has_disabled_message: bool,
61 +}
62 +
63 +/// Helper to build NavItems from NavigationDTO
64 +pub(crate) fn build_nav_items_from_dto(
65 + navigation: &NavigationDTO,
66 + active_id: &str,
67 +) -> Vec<NavItem> {
68 + navigation
69 + .items
70 + .iter()
71 + .map(|item| NavItem {
72 + id: item.id.clone(),
73 + label: item.label.clone(),
74 + icon: item.icon.clone(),
75 + url: item.url.clone(),
76 + active: item.id == active_id,
77 + enabled: item.enabled,
78 + badge: item.badge.clone(),
79 + has_badge: item.has_badge,
80 + disabled_message: item.disabled_message.clone(),
81 + has_disabled_message: item.has_disabled_message,
82 + })
83 + .collect()
84 +}
A crates/rustwarden-ui/src/handlers/settings/preferences.rs
+450 -0
@@ -0,0 +1,450 @@
1 +//! Settings - User preferences (theme, language).
2 +
3 +use askama::Template;
4 +use axum::{extract::State, response::IntoResponse, Extension, Json};
5 +use sea_orm::DatabaseConnection;
6 +use serde::Deserialize;
7 +use std::sync::Arc;
8 +use tower_sessions::Session;
9 +
10 +use super::{build_nav_items_from_dto, NavItem};
11 +use crate::dto::navigation_dto::NavigationDTO;
12 +use crate::dto::user_profile_dto::UserProfileDTO;
13 +use rustwarden_core::auth::jwt::Claims;
14 +use rustwarden_core::error::AppError;
15 +use rustwarden_core::i18n::extractor::Locale;
16 +use rustwarden_core::i18n::service::I18nService;
17 +use rustwarden_core::services::app_settings_service::AppSettingsService;
18 +use rustwarden_core::services::user_service::UserService;
19 +
20 +/// Inline DTO: Update preferences request
21 +#[derive(Debug, Deserialize)]
22 +pub struct UpdatePreferencesRequest {
23 + /// UI theme preference (e.g. `"dark"`, `"light"`).
24 + pub theme: Option<String>,
25 + /// Interface language code (e.g. `"en"`, `"fr"`).
26 + pub language: Option<String>,
27 +}
28 +/// GET /settings/preferences - Display user preferences (JSON)
29 +pub async fn get_preferences(
30 + State(db): State<DatabaseConnection>,
31 + Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
32 +) -> Result<impl IntoResponse, AppError> {
33 + use rustwarden_core::services::user_preferences_service::UserPreferencesService;
34 +
35 + let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? {
36 + Some(prefs) => prefs,
37 + None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?,
38 + };
39 +
40 + Ok(Json(serde_json::json!({
41 + "theme": preferences.theme,
42 + "language": preferences.language,
43 + })))
44 +}
45 +
46 +/// PUT /settings/preferences - Update user preferences
47 +pub async fn update_preferences(
48 + State(db): State<DatabaseConnection>,
49 + Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
50 + axum::extract::Form(form): axum::extract::Form<UpdatePreferencesRequest>,
51 +) -> Result<impl IntoResponse, AppError> {
52 + use rustwarden_core::services::user_preferences_service::UserPreferencesService;
53 +
54 + let current_prefs = match UserPreferencesService::get_preferences(&db, claims.sub).await? {
55 + Some(prefs) => prefs,
56 + None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?,
57 + };
58 +
59 + let theme = form.theme.unwrap_or(current_prefs.theme.clone());
60 + let language = form.language.unwrap_or(current_prefs.language.clone());
61 + let language_changed = language != current_prefs.language;
62 +
63 + let updated_prefs =
64 + UserPreferencesService::update_preferences(&db, claims.sub, theme, language.clone())
65 + .await?;
66 +
67 + tracing::info!(
68 + "User {} updated preferences: theme={}, language={}",
69 + claims.sub,
70 + updated_prefs.theme,
71 + updated_prefs.language
72 + );
73 +
74 + let json_response = Json(serde_json::json!({
75 + "message": "Preferences updated successfully",
76 + "theme": updated_prefs.theme,
77 + "language": updated_prefs.language,
78 + "language_changed": language_changed,
79 + "reload_required": language_changed,
80 + }));
81 +
82 + use axum::http::header;
83 + use time::Duration as TimeDuration;
84 +
85 + let theme_cookie =
86 + axum_extra::extract::cookie::Cookie::build(("theme", updated_prefs.theme.clone()))
87 + .http_only(false)
88 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
89 + .path("/")
90 + .max_age(TimeDuration::seconds(31536000));
91 +
92 + let mut response = json_response.into_response();
93 +
94 + if let Ok(hv) = theme_cookie.to_string().parse() {
95 + response.headers_mut().append(header::SET_COOKIE, hv);
96 + }
97 +
98 + if language_changed && matches!(language.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt") {
99 + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", language))
100 + .http_only(false)
101 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
102 + .path("/")
103 + .max_age(TimeDuration::seconds(31536000));
104 +
105 + if let Ok(hv) = locale_cookie.to_string().parse() {
106 + response.headers_mut().append(header::SET_COOKIE, hv);
107 + }
108 + }
109 +
110 + Ok(response)
111 +}
112 +
113 +// ============================================================================
114 +// GET Handlers for HTML Pages (with i18n)
115 +// ============================================================================
116 +
117 +#[derive(Template)]
118 +#[template(path = "settings/preferences.html")]
119 +#[allow(dead_code)]
120 +struct PreferencesTemplate {
121 + page_title: String,
122 + csrf_token: String,
123 + app_name: String,
124 + app_version: String,
125 + app_domain: String,
126 + app_description: String,
127 + username: String,
128 + email: String,
129 + roles_text: String,
130 + initials: String,
131 + avatar_color: String,
132 + theme: String,
133 + is_theme_light: bool,
134 + is_theme_dark: bool,
135 + is_theme_auto: bool,
136 + language: String,
137 + is_language_fr: bool,
138 + is_language_en: bool,
139 + is_language_de: bool,
140 + is_language_es: bool,
141 + is_language_it: bool,
142 + is_language_pt: bool,
143 + current_locale: String,
144 + supported_languages: Vec<(String, String, bool)>,
145 + nav_items: Vec<NavItem>,
146 + has_pending_password_change: bool,
147 + user_menu_profile: String,
148 + user_menu_logout: String,
149 +
150 + // Translated strings (hardcoded FR)
151 + title: String,
152 + subtitle: String,
153 + theme_label: String,
154 + theme_light: String,
155 + theme_light_desc: String,
156 + theme_dark: String,
157 + theme_dark_desc: String,
158 + theme_auto: String,
159 + theme_auto_desc: String,
160 + theme_preview: String,
161 + language_label: String,
162 + language_fr: String,
163 + language_fr_desc: String,
164 + language_en: String,
165 + language_en_desc: String,
166 + language_de: String,
167 + language_de_desc: String,
168 + language_es: String,
169 + language_es_desc: String,
170 + language_it: String,
171 + language_it_desc: String,
172 + language_pt: String,
173 + language_pt_desc: String,
174 + translation_warning: String,
175 + modified: String,
176 + modified_desc: String,
177 + save_button: String,
178 + saving: String,
179 + cancel: String,
180 + saved_success: String,
181 + saved_reload: String,
182 + saved_reload_json: String,
183 + saved_success_json: String,
184 + error_prefix: String,
185 + error_prefix_json: String,
186 + network_error: String,
187 + network_error_json: String,
188 + save_button_json: String,
189 + sync_title: String,
190 + sync_desc: String,
191 + preview_dark: String,
192 + preview_light: String,
193 + preview_auto: String,
194 + breadcrumb_home: String,
195 + breadcrumb_settings: String,
196 + breadcrumb_preferences: String,
197 +}
198 +
199 +rustwarden_core::impl_template_response!(PreferencesTemplate);
200 +
201 +/// Handler for GET /profile - Display profile page (PROTECTED)
202 +pub async fn get_preferences_page(
203 + Extension(claims): Extension<Claims>,
204 + Extension(i18n_service): Extension<Arc<I18nService>>,
205 + Extension(locale): Extension<Locale>,
206 + State(db): State<DatabaseConnection>,
207 + session: Session,
208 +) -> Result<impl IntoResponse, AppError> {
209 + let user_model = UserService::get_user_by_username(&db, &claims.username)
210 + .await?
211 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
212 +
213 + let roles = UserService::get_user_roles(&db, user_model.id).await?;
214 + let user = UserProfileDTO::new(user_model, roles.clone());
215 +
216 + let locale_str = locale.as_str();
217 +
218 + use rustwarden_core::services::user_preferences_service::UserPreferencesService;
219 + let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? {
220 + Some(prefs) => prefs,
221 + None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?,
222 + };
223 +
224 + let current_locale = locale_str.to_string();
225 + let supported_languages: Vec<(String, String, bool)> = vec![
226 + (
227 + "fr".to_string(),
228 + "Français".to_string(),
229 + current_locale == "fr",
230 + ),
231 + (
232 + "en".to_string(),
233 + "English".to_string(),
234 + current_locale == "en",
235 + ),
236 + (
237 + "de".to_string(),
238 + "Deutsch".to_string(),
239 + current_locale == "de",
240 + ),
241 + (
242 + "es".to_string(),
243 + "Español".to_string(),
244 + current_locale == "es",
245 + ),
246 + (
247 + "it".to_string(),
248 + "Italiano".to_string(),
249 + current_locale == "it",
250 + ),
251 + (
252 + "pt".to_string(),
253 + "Português".to_string(),
254 + current_locale == "pt",
255 + ),
256 + ];
257 +
258 + let layout = rustwarden_core::templates::LayoutContext::new("Préférences", "");
259 + use crate::template_helpers::csrf_token_helper;
260 + let csrf_token = csrf_token_helper(&session).await;
261 +
262 + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
263 + let nav_items = build_nav_items_from_dto(&navigation, "settings");
264 +
265 + let app_domain = AppSettingsService::get_setting(&db, "app_domain")
266 + .await?
267 + .map(|s| s.value)
268 + .unwrap_or_else(|| "localhost".to_string());
269 + let app_description = AppSettingsService::get_setting(&db, "app_description")
270 + .await?
271 + .map(|s| s.value)
272 + .unwrap_or_else(|| "Secure password manager".to_string());
273 +
274 + let save_button = i18n_service.translate("preferences.save", locale_str).await;
275 + let saved_success = i18n_service
276 + .translate("preferences.saved_success", locale_str)
277 + .await;
278 + let saved_reload = i18n_service
279 + .translate("preferences.saved_reload", locale_str)
280 + .await;
281 + let error_prefix = i18n_service
282 + .translate("preferences.error", locale_str)
283 + .await;
284 + let network_error = i18n_service
285 + .translate("preferences.network_error", locale_str)
286 + .await;
287 +
288 + let saved_reload_json =
289 + serde_json::to_string(&saved_reload).unwrap_or_else(|_| "\"\"".to_string());
290 + let saved_success_json =
291 + serde_json::to_string(&saved_success).unwrap_or_else(|_| "\"\"".to_string());
292 + let error_prefix_json =
293 + serde_json::to_string(&error_prefix).unwrap_or_else(|_| "\"\"".to_string());
294 + let network_error_json =
295 + serde_json::to_string(&network_error).unwrap_or_else(|_| "\"\"".to_string());
296 + let save_button_json =
297 + serde_json::to_string(&save_button).unwrap_or_else(|_| "\"\"".to_string());
298 +
299 + let template = PreferencesTemplate {
300 + page_title: layout.page_title.clone(),
301 + csrf_token,
302 + app_name: layout.app_name.clone(),
303 + app_version: layout.app_version.clone(),
304 + app_domain,
305 + app_description,
306 + username: user.username.clone(),
307 + email: user.email.clone(),
308 + roles_text: user.roles.join(", "),
309 + initials: user.initials.clone(),
310 + avatar_color: user.avatar_color.clone(),
311 + theme: preferences.theme.clone(),
312 + is_theme_light: preferences.theme == "light",
313 + is_theme_dark: preferences.theme == "dark",
314 + is_theme_auto: preferences.theme == "auto",
315 + language: preferences.language.clone(),
316 + is_language_fr: preferences.language == "fr",
317 + is_language_en: preferences.language == "en",
318 + is_language_de: preferences.language == "de",
319 + is_language_es: preferences.language == "es",
320 + is_language_it: preferences.language == "it",
321 + is_language_pt: preferences.language == "pt",
322 + current_locale,
323 + supported_languages,
324 + nav_items,
325 + has_pending_password_change: false,
326 + user_menu_profile: i18n_service
327 + .translate("user_menu.profile", locale_str)
328 + .await,
329 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
330 +
331 + title: i18n_service
332 + .translate("preferences.title", locale_str)
333 + .await,
334 + subtitle: i18n_service
335 + .translate("preferences.subtitle", locale_str)
336 + .await,
337 + theme_label: i18n_service
338 + .translate("preferences.theme", locale_str)
339 + .await,
340 + theme_light: i18n_service
341 + .translate("preferences.theme_light", locale_str)
342 + .await,
343 + theme_light_desc: i18n_service
344 + .translate("preferences.theme_light_desc", locale_str)
345 + .await,
346 + theme_dark: i18n_service
347 + .translate("preferences.theme_dark", locale_str)
348 + .await,
349 + theme_dark_desc: i18n_service
350 + .translate("preferences.theme_dark_desc", locale_str)
351 + .await,
352 + theme_auto: i18n_service
353 + .translate("preferences.theme_auto", locale_str)
354 + .await,
355 + theme_auto_desc: i18n_service
356 + .translate("preferences.theme_auto_desc", locale_str)
357 + .await,
358 + theme_preview: i18n_service
359 + .translate("preferences.theme_preview", locale_str)
360 + .await,
361 + language_label: i18n_service
362 + .translate("preferences.language", locale_str)
363 + .await,
364 + language_fr: i18n_service
365 + .translate("preferences.language_fr", locale_str)
366 + .await,
367 + language_fr_desc: i18n_service
368 + .translate("preferences.language_fr_desc", locale_str)
369 + .await,
370 + language_en: i18n_service
371 + .translate("preferences.language_en", locale_str)
372 + .await,
373 + language_en_desc: i18n_service
374 + .translate("preferences.language_en_desc", locale_str)
375 + .await,
376 + language_de: i18n_service
377 + .translate("preferences.language_de", locale_str)
378 + .await,
379 + language_de_desc: i18n_service
380 + .translate("preferences.language_de_desc", locale_str)
381 + .await,
382 + language_es: i18n_service
383 + .translate("preferences.language_es", locale_str)
384 + .await,
385 + language_es_desc: i18n_service
386 + .translate("preferences.language_es_desc", locale_str)
387 + .await,
388 + language_it: i18n_service
389 + .translate("preferences.language_it", locale_str)
390 + .await,
391 + language_it_desc: i18n_service
392 + .translate("preferences.language_it_desc", locale_str)
393 + .await,
394 + language_pt: i18n_service
395 + .translate("preferences.language_pt", locale_str)
396 + .await,
397 + language_pt_desc: i18n_service
398 + .translate("preferences.language_pt_desc", locale_str)
399 + .await,
400 + translation_warning: i18n_service
401 + .translate("preferences.translation_warning", locale_str)
402 + .await,
403 + modified: i18n_service
404 + .translate("preferences.modified", locale_str)
405 + .await,
406 + modified_desc: i18n_service
407 + .translate("preferences.modified_desc", locale_str)
408 + .await,
409 + save_button,
410 + saving: i18n_service
411 + .translate("preferences.saving", locale_str)
412 + .await,
413 + cancel: i18n_service
414 + .translate("preferences.cancel", locale_str)
415 + .await,
416 + saved_success,
417 + saved_reload,
418 + saved_reload_json,
419 + saved_success_json,
420 + error_prefix,
421 + error_prefix_json,
422 + network_error,
423 + network_error_json,
424 + save_button_json,
425 + sync_title: i18n_service
426 + .translate("preferences.sync_title", locale_str)
427 + .await,
428 + sync_desc: i18n_service
429 + .translate("preferences.sync_desc", locale_str)
430 + .await,
431 + preview_dark: i18n_service
432 + .translate("preferences.preview_dark", locale_str)
433 + .await,
434 + preview_light: i18n_service
435 + .translate("preferences.preview_light", locale_str)
436 + .await,
437 + preview_auto: i18n_service
438 + .translate("preferences.preview_auto", locale_str)
439 + .await,
440 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
441 + breadcrumb_settings: i18n_service
442 + .translate("breadcrumb.settings", locale_str)
443 + .await,
444 + breadcrumb_preferences: i18n_service
445 + .translate("breadcrumb.preferences", locale_str)
446 + .await,
447 + };
448 +
449 + Ok(template)
450 +}
A crates/rustwarden-ui/src/handlers/settings/profile.rs
+324 -0
@@ -0,0 +1,324 @@
1 +//! Settings - Profile handlers (GET /settings/profile, PUT /settings/profile).
2 +
3 +use askama::Template;
4 +use axum::{extract::State, response::IntoResponse, Extension, Json};
5 +use chrono::Utc;
6 +use sea_orm::DatabaseConnection;
7 +use serde::Deserialize;
8 +use std::sync::Arc;
9 +use tower_sessions::Session;
10 +
11 +use super::{build_nav_items_from_dto, NavItem};
12 +use crate::dto::navigation_dto::NavigationDTO;
13 +use crate::dto::user_profile_dto::UserProfileDTO;
14 +use rustwarden_core::auth::jwt::Claims;
15 +use rustwarden_core::config::email::EmailConfig;
16 +use rustwarden_core::error::AppError;
17 +use rustwarden_core::i18n::extractor::Locale;
18 +use rustwarden_core::i18n::service::I18nService;
19 +use rustwarden_core::services::app_settings_service::AppSettingsService;
20 +use rustwarden_core::services::user_service::UserService;
21 +
22 +/// Inline DTO: Update profile request
23 +#[derive(Debug, Deserialize)]
24 +pub struct UpdateProfileRequest {
25 + /// New username, if the user wants to change it.
26 + pub username: Option<String>,
27 + /// New email address, if the user wants to change it.
28 + pub email: Option<String>,
29 +}
30 +/// PUT /settings/profile — Update the authenticated user's profile.
31 +pub async fn update_profile(
32 + Extension(claims): Extension<Claims>,
33 + State(db): State<DatabaseConnection>,
34 + _session: Session,
35 + Json(request): Json<UpdateProfileRequest>,
36 +) -> Result<impl IntoResponse, AppError> {
37 + let old_user = UserService::get_user_by_id(&db, claims.sub)
38 + .await?
39 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
40 +
41 + let old_email = old_user.email.clone();
42 + let new_email = request.email.clone();
43 + let new_username = request.username.clone();
44 + let email_was_changed = new_email.as_ref().map(|e| e != &old_email).unwrap_or(false);
45 +
46 + let updated_user =
47 + UserService::update_profile(&db, claims.sub, request.username, request.email).await?;
48 +
49 + tracing::info!("Profile updated for user {}", updated_user.username);
50 +
51 + if email_was_changed {
52 + use rustwarden_core::services::password_change_service::PasswordChangeService;
53 + let password_change_service = PasswordChangeService::new();
54 + let invalidated = password_change_service
55 + .invalidate_on_email_change(&db, claims.sub)
56 + .await?;
57 + if invalidated > 0 {
58 + tracing::warn!(
59 + "Invalidated {} pending password change request(s) for user {} due to email change",
60 + invalidated,
61 + updated_user.username
62 + );
63 + }
64 + }
65 +
66 + if let Ok(email_config) = EmailConfig::from_env() {
67 + let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
68 + let app_name = email_config
69 + .smtp_from_name
70 + .clone()
71 + .unwrap_or_else(rustwarden_core::config::app::default_app_name);
72 +
73 + let mut changes = Vec::new();
74 + if new_username.is_some() {
75 + changes.push(rustwarden_core::templates::email_templates::AccountChange {
76 + field: "Username".to_string(),
77 + old_value: Some(old_user.username.clone()),
78 + new_value: updated_user.username.clone(),
79 + });
80 + }
81 + if email_was_changed {
82 + if let Some(ref new_email) = new_email {
83 + changes.push(rustwarden_core::templates::email_templates::AccountChange {
84 + field: "Email".to_string(),
85 + old_value: Some(old_email.clone()),
86 + new_value: new_email.clone(),
87 + });
88 + }
89 + }
90 +
91 + if !changes.is_empty() {
92 + let template =
93 + rustwarden_core::templates::email_templates::AccountModifiedTemplate::new(
94 + Some("fr".to_string()),
95 + updated_user.username.clone(),
96 + modified_at,
97 + changes,
98 + Some(app_name.clone()),
99 + );
100 +
101 + if let (Ok(body_html), body_text) = (template.render_html(), template.render_text()) {
102 + let subject = "Compte modifié".to_string();
103 + use rustwarden_core::services::email_queue_service::EmailQueueService;
104 +
105 + if email_was_changed {
106 + if let Err(e) = EmailQueueService::enqueue(
107 + &db,
108 + updated_user.email.clone(),
109 + Some(updated_user.username.clone()),
110 + email_config.smtp_from.clone(),
111 + email_config.smtp_from_name.clone(),
112 + subject.clone(),
113 + body_html.clone(),
114 + body_text.clone(),
115 + "account_modified".to_string(),
116 + )
117 + .await
118 + {
119 + tracing::warn!(
120 + "Failed to enqueue account modified email to new address: {}",
121 + e
122 + );
123 + }
124 + if let Err(e) = EmailQueueService::enqueue(
125 + &db,
126 + old_email.clone(),
127 + Some(updated_user.username.clone()),
128 + email_config.smtp_from.clone(),
129 + email_config.smtp_from_name.clone(),
130 + subject,
131 + body_html,
132 + body_text,
133 + "account_modified".to_string(),
134 + )
135 + .await
136 + {
137 + tracing::warn!(
138 + "Failed to enqueue account modified email to old address: {}",
139 + e
140 + );
141 + }
142 + } else if let Err(e) = EmailQueueService::enqueue(
143 + &db,
144 + updated_user.email.clone(),
145 + Some(updated_user.username.clone()),
146 + email_config.smtp_from.clone(),
147 + email_config.smtp_from_name.clone(),
148 + subject,
149 + body_html,
150 + body_text,
151 + "account_modified".to_string(),
152 + )
153 + .await
154 + {
155 + tracing::warn!("Failed to enqueue account modified notification: {}", e);
156 + }
157 + }
158 + }
159 + }
160 +
161 + Ok(Json(serde_json::json!({
162 + "message": "Profile updated successfully",
163 + "username": updated_user.username,
164 + "email": updated_user.email,
165 + })))
166 +}
167 +#[derive(Template)]
168 +#[template(path = "profile.html")]
169 +#[allow(dead_code)]
170 +struct ProfileTemplate {
171 + page_title: String,
172 + csrf_token: String,
173 + app_name: String,
174 + app_version: String,
175 + app_domain: String,
176 + app_description: String,
177 + username: String,
178 + email: String,
179 + user_id: String,
180 + created_at: String,
181 + roles: String,
182 + roles_text: String,
183 + initials: String,
184 + avatar_color: String,
185 + nav_items: Vec<NavItem>,
186 + has_pending_password_change: bool,
187 + user_menu_profile: String,
188 + user_menu_logout: String,
189 +
190 + // Translated strings (hardcoded FR)
191 + title: String,
192 + subtitle: String,
193 + account_info: String,
194 + username_label: String,
195 + username_public: String,
196 + username_placeholder: String,
197 + username_pattern: String,
198 + username_help: String,
199 + email_label: String,
200 + email_used_for: String,
201 + email_warning: String,
202 + email_placeholder: String,
203 + email_change_info: String,
204 + save_button: String,
205 + cancel: String,
206 + additional_info: String,
207 + account_created: String,
208 + roles_label: String,
209 + breadcrumb_home: String,
210 + breadcrumb_settings: String,
211 + breadcrumb_profile: String,
212 +}
213 +
214 +rustwarden_core::impl_template_response!(ProfileTemplate);
215 +
216 +/// GET /settings/profile — Display the user profile settings page.
217 +pub async fn get_profile_page(
218 + Extension(claims): Extension<Claims>,
219 + Extension(i18n_service): Extension<Arc<I18nService>>,
220 + Extension(locale): Extension<Locale>,
221 + State(db): State<DatabaseConnection>,
222 + session: Session,
223 +) -> Result<impl IntoResponse, AppError> {
224 + let user_model = UserService::get_user_by_username(&db, &claims.username)
225 + .await?
226 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
227 +
228 + let roles = UserService::get_user_roles(&db, user_model.id).await?;
229 + let user = UserProfileDTO::new(user_model, roles.clone());
230 +
231 + let locale_str = locale.as_str();
232 +
233 + let layout = rustwarden_core::templates::LayoutContext::new("Profil", "");
234 +
235 + use crate::template_helpers::csrf_token_helper;
236 + let csrf_token = csrf_token_helper(&session).await;
237 +
238 + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
239 + let nav_items = build_nav_items_from_dto(&navigation, "settings");
240 +
241 + let app_domain = AppSettingsService::get_setting(&db, "app_domain")
242 + .await?
243 + .map(|s| s.value)
244 + .unwrap_or_else(|| "localhost".to_string());
245 + let app_description = AppSettingsService::get_setting(&db, "app_description")
246 + .await?
247 + .map(|s| s.value)
248 + .unwrap_or_else(|| "Secure password manager".to_string());
249 +
250 + let template = ProfileTemplate {
251 + page_title: layout.page_title.clone(),
252 + csrf_token,
253 + app_name: layout.app_name.clone(),
254 + app_version: layout.app_version.clone(),
255 + app_domain,
256 + app_description,
257 + username: user.username.clone(),
258 + email: user.email.clone(),
259 + user_id: user.id.to_string(),
260 + created_at: user.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
261 + roles: user.roles.join(", "),
262 + roles_text: user.roles.join(", "),
263 + initials: user.initials.clone(),
264 + avatar_color: user.avatar_color.clone(),
265 + nav_items,
266 + has_pending_password_change: false,
267 + user_menu_profile: i18n_service
268 + .translate("user_menu.profile", locale_str)
269 + .await,
270 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
271 +
272 + title: i18n_service.translate("profile.title", locale_str).await,
273 + subtitle: i18n_service.translate("profile.subtitle", locale_str).await,
274 + account_info: i18n_service
275 + .translate("profile.account_info", locale_str)
276 + .await,
277 + username_label: i18n_service.translate("profile.username", locale_str).await,
278 + username_public: i18n_service
279 + .translate("profile.username_public", locale_str)
280 + .await,
281 + username_placeholder: i18n_service
282 + .translate("profile.username_placeholder", locale_str)
283 + .await,
284 + username_pattern: i18n_service
285 + .translate("profile.username_pattern", locale_str)
286 + .await,
287 + username_help: i18n_service
288 + .translate("profile.username_help", locale_str)
289 + .await,
290 + email_label: i18n_service
291 + .translate("profile.email_label", locale_str)
292 + .await,
293 + email_used_for: i18n_service
294 + .translate("profile.email_used_for", locale_str)
295 + .await,
296 + email_warning: i18n_service
297 + .translate("profile.email_warning", locale_str)
298 + .await,
299 + email_placeholder: i18n_service
300 + .translate("profile.email_placeholder", locale_str)
301 + .await,
302 + email_change_info: i18n_service
303 + .translate("profile.email_change_info", locale_str)
304 + .await,
305 + save_button: i18n_service.translate("profile.save", locale_str).await,
306 + cancel: i18n_service.translate("common.cancel", locale_str).await,
307 + additional_info: i18n_service
308 + .translate("profile.additional_info", locale_str)
309 + .await,
310 + account_created: i18n_service
311 + .translate("profile.account_created", locale_str)
312 + .await,
313 + roles_label: i18n_service.translate("profile.roles", locale_str).await,
314 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
315 + breadcrumb_settings: i18n_service
316 + .translate("breadcrumb.settings", locale_str)
317 + .await,
318 + breadcrumb_profile: i18n_service
319 + .translate("breadcrumb.profile", locale_str)
320 + .await,
321 + };
322 +
323 + Ok(template)
324 +}
A crates/rustwarden-ui/src/handlers/settings/security.rs
+871 -0
@@ -0,0 +1,871 @@
1 +//! Settings - Security handlers (password change, active sessions).
2 +
3 +use askama::Template;
4 +use axum::{
5 + extract::{Query, State},
6 + response::IntoResponse,
7 + Extension, Json,
8 +};
9 +use sea_orm::DatabaseConnection;
10 +use serde::Deserialize;
11 +use std::sync::Arc;
12 +use tower_sessions::Session;
13 +
14 +use super::{build_nav_items_from_dto, filters, NavItem};
15 +use crate::dto::navigation_dto::NavigationDTO;
16 +use crate::dto::user_profile_dto::UserProfileDTO;
17 +use rustwarden_core::auth::jwt::Claims;
18 +use rustwarden_core::config::email::EmailConfig;
19 +use rustwarden_core::error::AppError;
20 +use rustwarden_core::i18n::extractor::Locale;
21 +use rustwarden_core::i18n::service::I18nService;
22 +use rustwarden_core::services::app_settings_service::AppSettingsService;
23 +use rustwarden_core::services::refresh_token_service::RefreshTokenService;
24 +use rustwarden_core::services::user_service::UserService;
25 +
26 +/// Inline DTO: Change password request
27 +#[derive(Debug, Deserialize)]
28 +pub struct ChangePasswordRequest {
29 + /// Current password for verification.
30 + pub old_password: String,
31 + /// Desired new password.
32 + pub new_password: String,
33 + /// Confirmation of the new password (must match `new_password`).
34 + pub confirm_password: String,
35 +}
36 +
37 +/// Inline DTO: Change password response
38 +#[derive(Debug, serde::Serialize)]
39 +pub struct ChangePasswordResponse {
40 + /// Human-readable confirmation message.
41 + pub message: String,
42 +}
43 +
44 +/// Inline DTO: Active sessions response
45 +#[derive(Debug, serde::Serialize)]
46 +pub struct ActiveSessionsResponse {
47 + /// List of currently active refresh-token sessions for the user.
48 + pub sessions: Vec<rustwarden_core::services::refresh_token_service::ActiveSession>,
49 +}
50 +
51 +/// Query parameters for password page
52 +#[derive(Deserialize)]
53 +pub struct PasswordPageQuery {
54 + must_change: Option<String>,
55 +}
56 +
57 +/// POST /settings/security/password — Change the authenticated user's password.
58 +pub async fn change_password(
59 + Extension(claims): Extension<Claims>,
60 + State(db): State<DatabaseConnection>,
61 + session: Session,
62 + Json(request): Json<ChangePasswordRequest>,
63 +) -> Result<impl IntoResponse, AppError> {
64 + if request.new_password != request.confirm_password {
65 + return Err(AppError::Validation(
66 + "New password and confirmation do not match".to_string(),
67 + ));
68 + }
69 +
70 + let require_email =
71 + AppSettingsService::get_bool(&db, "password_change_require_email", true).await?;
72 +
73 + if require_email {
74 + let current_token_id: Option<uuid::Uuid> =
75 + session.get("refresh_token_id").await.ok().flatten();
76 +
77 + let (_request_id, token) = UserService::change_password(
78 + &db,
79 + claims.sub,
80 + &request.old_password,
81 + &request.new_password,
82 + current_token_id,
83 + )
84 + .await?;
85 +
86 + tracing::info!(
87 + "Created pending password change request for user {}",
88 + claims.sub
89 + );
90 +
91 + let user = UserService::get_user_by_id(&db, claims.sub)
92 + .await?
93 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
94 +
95 + let email_config = EmailConfig::from_env()
96 + .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?;
97 +
98 + let confirmation_url = format!(
99 + "{}/confirm-password-change?token={}",
100 + email_config.email_base_url, token
101 + );
102 +
103 + let template =
104 + rustwarden_core::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new(
105 + Some("fr".to_string()),
106 + user.username.clone(),
107 + confirmation_url,
108 + Some(
109 + email_config
110 + .smtp_from_name
111 + .clone()
112 + .unwrap_or_else(rustwarden_core::config::app::default_app_name),
113 + ),
114 + );
115 +
116 + let body_html = template
117 + .render_html()
118 + .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
119 + let body_text = template.render_text();
120 +
121 + let subject = "Confirmation de changement de mot de passe".to_string();
122 +
123 + let email_svc = rustwarden_core::services::email_service::EmailService::new(email_config)
124 + .map_err(|e| {
125 + AppError::Internal(format!("Failed to create email service: {}", e))
126 + })?;
127 + email_svc
128 + .send_email(
129 + &user.email,
130 + Some(&user.username),
131 + &subject,
132 + &body_html,
133 + &body_text,
134 + )
135 + .await
136 + .map_err(|e| {
137 + tracing::error!(
138 + "Failed to send password change confirmation email for user {}: {}",
139 + user.username,
140 + e
141 + );
142 + AppError::Internal(
143 + "Failed to send confirmation email. Please try again later.".to_string(),
144 + )
145 + })?;
146 +
147 + tracing::info!(
148 + "Password change confirmation email sent for user {}",
149 + user.username
150 + );
151 +
152 + Ok(Json(ChangePasswordResponse {
153 + message: "email_confirmation_sent".to_string(),
154 + }))
155 + } else {
156 + use rustwarden_core::services::password_service::PasswordService;
157 +
158 + let user = UserService::get_user_by_id(&db, claims.sub)
159 + .await?
160 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
161 +
162 + if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? {
163 + return Err(AppError::Validation(
164 + "Old password is incorrect".to_string(),
165 + ));
166 + }
167 +
168 + rustwarden_core::middleware::password_complexity::validate_password_complexity(
169 + &db,
170 + &request.new_password,
171 + )
172 + .await?;
173 +
174 + let new_hash = PasswordService::hash_password(&request.new_password).await?;
175 + UserService::update_password_hash(&db, claims.sub, &new_hash).await?;
176 + RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?;
177 +
178 + tracing::info!(
179 + "Password changed directly (no email confirmation) for user {}",
180 + claims.sub
181 + );
182 +
183 + Ok(Json(ChangePasswordResponse {
184 + message: "password_changed_directly".to_string(),
185 + }))
186 + }
187 +}
188 +
189 +/// Query parameters for password change confirmation
190 +#[derive(Deserialize)]
191 +pub struct ConfirmPasswordChangeQuery {
192 + token: String,
193 +}
194 +
195 +/// Handler for GET /confirm-password-change?token={token} - Confirm password change (PUBLIC)
196 +pub async fn confirm_password_change(
197 + State(db): State<DatabaseConnection>,
198 + Query(query): Query<ConfirmPasswordChangeQuery>,
199 +) -> Result<impl IntoResponse, AppError> {
200 + use rustwarden_core::services::password_change_service::PasswordChangeService;
201 +
202 + let password_change_service = PasswordChangeService::new();
203 +
204 + let _request = password_change_service
205 + .find_by_token(&db, &query.token)
206 + .await?;
207 +
208 + let user_id = password_change_service
209 + .confirm_request(&db, &query.token)
210 + .await?;
211 +
212 + RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?;
213 +
214 + tracing::info!(
215 + "Password change confirmed and applied for user {}. All sessions invalidated.",
216 + user_id
217 + );
218 +
219 + Ok(Json(serde_json::json!({
220 + "success": true,
221 + "message": "Password change confirmed successfully. Please log in with your new password."
222 + })))
223 +}
224 +
225 +/// Handler for DELETE /settings/password/pending - Cancel pending password change request (PROTECTED)
226 +pub async fn cancel_pending_password_change(
227 + Extension(claims): Extension<Claims>,
228 + State(db): State<DatabaseConnection>,
229 +) -> Result<impl IntoResponse, AppError> {
230 + use rustwarden_core::services::password_change_service::PasswordChangeService;
231 +
232 + let password_change_service = PasswordChangeService::new();
233 +
234 + password_change_service
235 + .cancel_pending_request(&db, claims.sub)
236 + .await?;
237 +
238 + tracing::info!(
239 + "Canceled pending password change request for user {}",
240 + claims.sub
241 + );
242 +
243 + Ok(Json(serde_json::json!({
244 + "success": true,
245 + "message": "Pending password change request canceled successfully."
246 + })))
247 +}
248 +
249 +/// Handler for GET /settings/sessions - List active sessions (PROTECTED)
250 +pub async fn list_sessions(
251 + Extension(claims): Extension<Claims>,
252 + State(db): State<DatabaseConnection>,
253 + session: Session,
254 +) -> Result<impl IntoResponse, AppError> {
255 + let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
256 +
257 + let mut sessions = RefreshTokenService::list_user_sessions(&db, claims.sub).await?;
258 +
259 + if let Some(current_id) = current_token_id {
260 + for session in &mut sessions {
261 + if session.id == current_id {
262 + session.is_current = true;
263 + break;
264 + }
265 + }
266 + }
267 +
268 + Ok(Json(ActiveSessionsResponse { sessions }))
269 +}
270 +
271 +/// Handler for DELETE /settings/sessions/:id - Revoke session (PROTECTED)
272 +pub async fn revoke_session(
273 + Extension(claims): Extension<Claims>,
274 + State(db): State<DatabaseConnection>,
275 + session: Session,
276 + axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
277 +) -> Result<impl IntoResponse, AppError> {
278 + let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
279 +
280 + if Some(session_id) == current_token_id {
281 + return Err(AppError::Validation(
282 + "Cannot revoke current session. Please log out instead.".to_string(),
283 + ));
284 + }
285 +
286 + use rustwarden_core::models::refresh_token;
287 + use sea_orm::EntityTrait;
288 +
289 + let token = refresh_token::Entity::find_by_id(session_id)
290 + .one(&db)
291 + .await?
292 + .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?;
293 +
294 + if token.user_id != claims.sub {
295 + return Err(AppError::Validation(
296 + "You can only revoke your own sessions".to_string(),
297 + ));
298 + }
299 +
300 + RefreshTokenService::revoke_session(&db, session_id).await?;
301 +
302 + tracing::info!("Session {} revoked by user {}", session_id, claims.sub);
303 +
304 + Ok(Json(serde_json::json!({
305 + "message": "Session revoked successfully",
306 + })))
307 +}
308 +#[derive(Template)]
309 +#[template(path = "settings/password.html")]
310 +#[allow(dead_code)]
311 +struct PasswordTemplate {
312 + page_title: String,
313 + csrf_token: String,
314 + app_name: String,
315 + app_version: String,
316 + app_domain: String,
317 + app_description: String,
318 + username: String,
319 + email: String,
320 + roles_text: String,
321 + initials: String,
322 + avatar_color: String,
323 + nav_items: Vec<NavItem>,
324 + user_menu_profile: String,
325 + user_menu_logout: String,
326 +
327 + must_change: bool,
328 + must_change_message: String,
329 + has_pending_request: bool,
330 + pending_request_message: String,
331 + pending_request_info: String,
332 + pending_request_cancel: String,
333 + // Translated strings (hardcoded FR)
334 + title: String,
335 + subtitle: String,
336 + new_password_label: String,
337 + current_password_label: String,
338 + current_password_placeholder: String,
339 + new_password_placeholder: String,
340 + required: String,
341 + requirements: String,
342 + strength_label: String,
343 + strength_weak: String,
344 + strength_medium: String,
345 + strength_strong: String,
346 + strength_too_short: String,
347 + strength_missing_lowercase: String,
348 + strength_missing_uppercase: String,
349 + strength_missing_digit: String,
350 + strength_missing_special: String,
351 + confirm_password_label: String,
352 + confirm_password_placeholder: String,
353 + passwords_match: String,
354 + passwords_not_match: String,
355 + change_button: String,
356 + changing: String,
357 + logout_warning_title: String,
358 + logout_warning_desc: String,
359 + saved: String,
360 + error_all_fields_required: String,
361 + error_empty_data: String,
362 + error_invalid_data: String,
363 + error_incorrect_current: String,
364 + error_reading_response: String,
365 + error_415: String,
366 + network_error: String,
367 + security_tips_title: String,
368 + security_tip_unique: String,
369 + security_tip_share: String,
370 + security_tip_regular: String,
371 + security_tip_2fa: String,
372 + security_tip_manager: String,
373 + breadcrumb_home: String,
374 + breadcrumb_settings: String,
375 + breadcrumb_password: String,
376 + cancel: String,
377 + logout_label: String,
378 + has_pending_password_change: bool,
379 + // Password complexity rules (from app_settings)
380 + pw_min_length: usize,
381 + pw_require_uppercase: bool,
382 + pw_require_lowercase: bool,
383 + pw_require_digits: bool,
384 + pw_require_special: bool,
385 + password_change_require_email: bool,
386 + subtitle_no_email: String,
387 + saved_direct: String,
388 +}
389 +
390 +rustwarden_core::impl_template_response!(PasswordTemplate);
391 +
392 +/// Session item for template
393 +#[derive(Clone, Debug)]
394 +#[allow(dead_code)]
395 +struct SessionItem {
396 + id: String,
397 + device_info: String,
398 + ip_address: String,
399 + last_activity: String,
400 + created_at: String,
401 + is_current: bool,
402 +}
403 +
404 +/// Sessions page template
405 +#[derive(Template)]
406 +#[template(path = "settings/sessions.html")]
407 +#[allow(dead_code)]
408 +struct SessionsTemplate {
409 + page_title: String,
410 + csrf_token: String,
411 + app_name: String,
412 + app_version: String,
413 + app_domain: String,
414 + app_description: String,
415 + username: String,
416 + email: String,
417 + roles_text: String,
418 + initials: String,
419 + avatar_color: String,
420 + sessions: Vec<SessionItem>,
421 + nav_items: Vec<NavItem>,
422 + has_pending_password_change: bool,
423 + user_menu_profile: String,
424 + user_menu_logout: String,
425 +
426 + // Translated strings (hardcoded FR)
427 + title: String,
428 + subtitle: String,
429 + current_session: String,
430 + current_badge: String,
431 + unknown_device: String,
432 + ip_address: String,
433 + created_at: String,
434 + last_activity: String,
435 + session_id: String,
436 + revoke: String,
437 + revoke_confirm_template: String,
438 + revoke_success: String,
439 + revoke_error: String,
440 + no_sessions: String,
441 + no_sessions_desc: String,
442 + danger_zone: String,
443 + danger_zone_desc: String,
444 + revoke_all: String,
445 + revoke_all_confirm: String,
446 + revoke_all_success: String,
447 + revoke_all_error: String,
448 + about_title: String,
449 + about_desc: String,
450 + back_to_settings: String,
451 + breadcrumb_home: String,
452 + breadcrumb_settings: String,
453 + breadcrumb_sessions: String,
454 +}
455 +
456 +rustwarden_core::impl_template_response!(SessionsTemplate);
457 +
458 +/// Preferences page template
459 +pub async fn get_password_page(
460 + Extension(claims): Extension<Claims>,
461 + Extension(i18n_service): Extension<Arc<I18nService>>,
462 + Extension(locale): Extension<Locale>,
463 + State(db): State<DatabaseConnection>,
464 + session: Session,
465 + Query(query): Query<PasswordPageQuery>,
466 +) -> Result<impl IntoResponse, AppError> {
467 + let user_model = UserService::get_user_by_username(&db, &claims.username)
468 + .await?
469 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
470 +
471 + let roles = UserService::get_user_roles(&db, user_model.id).await?;
472 + let user = UserProfileDTO::new(user_model.clone(), roles.clone());
473 +
474 + let locale_str = locale.as_str();
475 +
476 + let must_change = query.must_change.as_deref() == Some("true");
477 + let must_change_message = if must_change {
478 + i18n_service
479 + .translate("password.must_change_message", locale_str)
480 + .await
481 + } else {
482 + String::new()
483 + };
484 +
485 + let pw_rules =
486 + rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db).await;
487 +
488 + use rustwarden_core::services::password_change_service::PasswordChangeService;
489 + let password_change_service = PasswordChangeService::new();
490 + let pending_request = password_change_service
491 + .find_pending_by_user_id(&db, user_model.id)
492 + .await?;
493 + let has_pending_request = pending_request.is_some();
494 + let has_pending_password_change = has_pending_request;
495 +
496 + let pending_request_message = if has_pending_request {
497 + i18n_service
498 + .translate("password.pending_request_message", locale_str)
499 + .await
500 + } else {
501 + String::new()
502 + };
503 + let pending_request_info = if has_pending_request {
504 + i18n_service
505 + .translate("password.pending_request_info", locale_str)
506 + .await
507 + } else {
508 + String::new()
509 + };
510 + let pending_request_cancel = i18n_service
511 + .translate("password.pending_request_cancel", locale_str)
512 + .await;
513 +
514 + let layout = rustwarden_core::templates::LayoutContext::new("Mot de passe", "");
515 + use crate::template_helpers::csrf_token_helper;
516 + let csrf_token = csrf_token_helper(&session).await;
517 +
518 + let mut navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
519 + navigation.set_pending_password_change(has_pending_password_change);
520 + let nav_items = build_nav_items_from_dto(&navigation, "settings");
521 +
522 + let app_domain = AppSettingsService::get_setting(&db, "app_domain")
523 + .await?
524 + .map(|s| s.value)
525 + .unwrap_or_else(|| "localhost".to_string());
526 + let app_description = AppSettingsService::get_setting(&db, "app_description")
527 + .await?
528 + .map(|s| s.value)
529 + .unwrap_or_else(|| "Secure password manager".to_string());
530 +
531 + let page_title = i18n_service
532 + .translate("password.page_title", locale_str)
533 + .await;
534 +
535 + let template = PasswordTemplate {
536 + page_title,
537 + csrf_token,
538 + app_name: layout.app_name.clone(),
539 + app_version: layout.app_version.clone(),
540 + app_domain,
541 + app_description,
542 + username: user.username.clone(),
543 + email: user.email.clone(),
544 + roles_text: user.roles.join(", "),
545 + initials: user.initials.clone(),
546 + avatar_color: user.avatar_color.clone(),
547 + nav_items,
548 + user_menu_profile: i18n_service
549 + .translate("user_menu.profile", locale_str)
550 + .await,
551 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
552 +
553 + must_change,
554 + must_change_message,
555 + has_pending_request,
556 + pending_request_message,
557 + pending_request_info,
558 + pending_request_cancel,
559 + title: i18n_service.translate("password.title", locale_str).await,
560 + subtitle: i18n_service
561 + .translate("password.subtitle", locale_str)
562 + .await,
563 + new_password_label: i18n_service
564 + .translate("password.new_password", locale_str)
565 + .await,
566 + current_password_label: i18n_service
567 + .translate("password.current_password", locale_str)
568 + .await,
569 + current_password_placeholder: i18n_service
570 + .translate("password.current_password_placeholder", locale_str)
571 + .await,
572 + new_password_placeholder: i18n_service
573 + .translate("password.new_password_placeholder", locale_str)
574 + .await,
575 + required: i18n_service
576 + .translate("password.required", locale_str)
577 + .await,
578 + requirements: i18n_service
579 + .translate("password.requirements", locale_str)
580 + .await,
581 + strength_label: i18n_service
582 + .translate("password.strength", locale_str)
583 + .await,
584 + strength_weak: i18n_service
585 + .translate("password.strength_weak", locale_str)
586 + .await,
587 + strength_medium: i18n_service
588 + .translate("password.strength_medium", locale_str)
589 + .await,
590 + strength_strong: i18n_service
591 + .translate("password.strength_strong", locale_str)
592 + .await,
593 + strength_too_short: i18n_service
594 + .translate("password.strength_too_short", locale_str)
595 + .await,
596 + strength_missing_lowercase: i18n_service
597 + .translate("password.strength_missing_lowercase", locale_str)
598 + .await,
599 + strength_missing_uppercase: i18n_service
600 + .translate("password.strength_missing_uppercase", locale_str)
601 + .await,
602 + strength_missing_digit: i18n_service
603 + .translate("password.strength_missing_digit", locale_str)
604 + .await,
605 + strength_missing_special: i18n_service
606 + .translate("password.strength_missing_special", locale_str)
607 + .await,
608 + confirm_password_label: i18n_service
609 + .translate("password.confirm_password", locale_str)
610 + .await,
611 + confirm_password_placeholder: i18n_service
612 + .translate("password.confirm_password_placeholder", locale_str)
613 + .await,
614 + passwords_match: i18n_service
615 + .translate("password.passwords_match", locale_str)
616 + .await,
617 + passwords_not_match: i18n_service
618 + .translate("password.passwords_not_match", locale_str)
619 + .await,
620 + change_button: i18n_service
621 + .translate("password.change_button", locale_str)
622 + .await,
623 + changing: i18n_service
624 + .translate("password.changing", locale_str)
625 + .await,
626 + logout_warning_title: i18n_service
627 + .translate("password.logout_warning_title", locale_str)
628 + .await,
629 + logout_warning_desc: i18n_service
630 + .translate("password.logout_warning_desc", locale_str)
631 + .await,
632 + saved: i18n_service.translate("password.saved", locale_str).await,
633 + error_all_fields_required: i18n_service
634 + .translate("password.error_all_fields_required", locale_str)
635 + .await,
636 + error_empty_data: i18n_service
637 + .translate("password.error_empty_data", locale_str)
638 + .await,
639 + error_invalid_data: i18n_service
640 + .translate("password.error_invalid_data", locale_str)
641 + .await,
642 + error_incorrect_current: i18n_service
643 + .translate("password.error_incorrect_current", locale_str)
644 + .await,
645 + error_reading_response: i18n_service
646 + .translate("password.error_reading_response", locale_str)
647 + .await,
648 + error_415: i18n_service
649 + .translate("password.error_415", locale_str)
650 + .await,
651 + network_error: i18n_service
652 + .translate("password.network_error", locale_str)
653 + .await,
654 + security_tips_title: i18n_service
655 + .translate("password.security_tips_title", locale_str)
656 + .await,
657 + security_tip_unique: i18n_service
658 + .translate("password.security_tip_unique", locale_str)
659 + .await,
660 + security_tip_share: i18n_service
661 + .translate("password.security_tip_share", locale_str)
662 + .await,
663 + security_tip_regular: i18n_service
664 + .translate("password.security_tip_regular", locale_str)
665 + .await,
666 + security_tip_2fa: i18n_service
667 + .translate("password.security_tip_2fa", locale_str)
668 + .await,
669 + security_tip_manager: i18n_service
670 + .translate("password.security_tip_manager", locale_str)
671 + .await,
672 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
673 + breadcrumb_settings: i18n_service
674 + .translate("breadcrumb.settings", locale_str)
675 + .await,
676 + breadcrumb_password: i18n_service
677 + .translate("breadcrumb.password", locale_str)
678 + .await,
679 + cancel: i18n_service.translate("common.cancel", locale_str).await,
680 + logout_label: i18n_service.translate("user_menu.logout", locale_str).await,
681 + has_pending_password_change,
682 + pw_min_length: pw_rules.min_length,
683 + pw_require_uppercase: pw_rules.require_uppercase,
684 + pw_require_lowercase: pw_rules.require_lowercase,
685 + pw_require_digits: pw_rules.require_digits,
686 + pw_require_special: pw_rules.require_special,
687 + password_change_require_email: AppSettingsService::get_bool(
688 + &db,
689 + "password_change_require_email",
690 + true,
691 + )
692 + .await
693 + .unwrap_or(true),
694 + subtitle_no_email: i18n_service
695 + .translate("password.subtitle_no_email", locale_str)
696 + .await,
697 + saved_direct: i18n_service
698 + .translate("password.saved_direct", locale_str)
699 + .await,
700 + };
701 +
702 + Ok(template)
703 +}
704 +
705 +/// Handler for GET /settings/sessions - Display sessions page (PROTECTED)
706 +pub async fn get_sessions_page(
707 + Extension(claims): Extension<Claims>,
708 + Extension(i18n_service): Extension<Arc<I18nService>>,
709 + Extension(locale): Extension<Locale>,
710 + State(db): State<DatabaseConnection>,
711 + session: Session,
712 +) -> Result<impl IntoResponse, AppError> {
713 + let user_model = UserService::get_user_by_username(&db, &claims.username)
714 + .await?
715 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
716 +
717 + let roles = UserService::get_user_roles(&db, user_model.id).await?;
718 + let user = UserProfileDTO::new(user_model, roles.clone());
719 +
720 + let locale_str = locale.as_str();
721 +
722 + let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
723 +
724 + let mut sessions_data = RefreshTokenService::list_user_sessions(&db, claims.sub).await?;
725 +
726 + if let Some(current_id) = current_token_id {
727 + for session_data in &mut sessions_data {
728 + if session_data.id == current_id {
729 + session_data.is_current = true;
730 + break;
731 + }
732 + }
733 + }
734 +
735 + let ip_fallback = i18n_service
736 + .translate("session.ip_unavailable", locale_str)
737 + .await;
738 + let sessions: Vec<SessionItem> = sessions_data
739 + .iter()
740 + .map(|s| SessionItem {
741 + id: s.id.to_string(),
742 + device_info: s
743 + .device_info
744 + .clone()
745 + .unwrap_or_else(|| "Unknown".to_string()),
746 + ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()),
747 + last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(),
748 + created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
749 + is_current: s.is_current,
750 + })
751 + .collect();
752 +
753 + let layout = rustwarden_core::templates::LayoutContext::new("Sessions", "");
754 + use crate::template_helpers::csrf_token_helper;
755 + let csrf_token = csrf_token_helper(&session).await;
756 +
757 + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
758 + let nav_items = build_nav_items_from_dto(&navigation, "settings");
759 +
760 + let app_domain = AppSettingsService::get_setting(&db, "app_domain")
761 + .await?
762 + .map(|s| s.value)
763 + .unwrap_or_else(|| "localhost".to_string());
764 + let app_description = AppSettingsService::get_setting(&db, "app_description")
765 + .await?
766 + .map(|s| s.value)
767 + .unwrap_or_else(|| "Secure password manager".to_string());
768 +
769 + let page_title = i18n_service
770 + .translate("sessions.page_title", locale_str)
771 + .await;
772 +
773 + let template = SessionsTemplate {
774 + page_title,
775 + csrf_token,
776 + app_name: layout.app_name.clone(),
777 + app_version: layout.app_version.clone(),
778 + app_domain,
779 + app_description,
780 + username: user.username.clone(),
781 + email: user.email.clone(),
782 + roles_text: user.roles.join(", "),
783 + initials: user.initials.clone(),
784 + avatar_color: user.avatar_color.clone(),
785 + sessions,
786 + nav_items,
787 + has_pending_password_change: false,
788 + user_menu_profile: i18n_service
789 + .translate("user_menu.profile", locale_str)
790 + .await,
791 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
792 +
793 + title: i18n_service.translate("sessions.title", locale_str).await,
794 + subtitle: i18n_service
795 + .translate("sessions.subtitle", locale_str)
796 + .await,
797 + current_session: i18n_service
798 + .translate("sessions.current_session", locale_str)
799 + .await,
800 + current_badge: i18n_service
801 + .translate("sessions.current_badge", locale_str)
802 + .await,
803 + unknown_device: i18n_service
804 + .translate("sessions.unknown_device", locale_str)
805 + .await,
806 + ip_address: i18n_service
807 + .translate("sessions.ip_address", locale_str)
808 + .await,
809 + created_at: i18n_service
810 + .translate("sessions.created_at", locale_str)
811 + .await,
812 + last_activity: i18n_service
813 + .translate("sessions.last_activity", locale_str)
814 + .await,
815 + session_id: i18n_service
816 + .translate("sessions.session_id", locale_str)
817 + .await,
818 + revoke: i18n_service.translate("sessions.revoke", locale_str).await,
819 + revoke_confirm_template: i18n_service
820 + .translate("sessions.revoke_confirm", locale_str)
821 + .await,
822 + revoke_success: i18n_service
823 + .translate("sessions.revoke_success", locale_str)
824 + .await,
825 + revoke_error: i18n_service
826 + .translate("sessions.revoke_error", locale_str)
827 + .await,
828 + no_sessions: i18n_service
829 + .translate("sessions.no_sessions", locale_str)
830 + .await,
831 + no_sessions_desc: i18n_service
832 + .translate("sessions.no_sessions_desc", locale_str)
833 + .await,
834 + danger_zone: i18n_service
835 + .translate("sessions.danger_zone", locale_str)
836 + .await,
837 + danger_zone_desc: i18n_service
838 + .translate("sessions.danger_zone_desc", locale_str)
839 + .await,
840 + revoke_all: i18n_service
841 + .translate("sessions.revoke_all", locale_str)
842 + .await,
843 + revoke_all_confirm: i18n_service
844 + .translate("sessions.revoke_all_confirm", locale_str)
845 + .await,
846 + revoke_all_success: i18n_service
847 + .translate("sessions.revoke_all_success", locale_str)
848 + .await,
849 + revoke_all_error: i18n_service
850 + .translate("sessions.revoke_all_error", locale_str)
851 + .await,
852 + about_title: i18n_service
853 + .translate("sessions.about_title", locale_str)
854 + .await,
855 + about_desc: i18n_service
856 + .translate("sessions.about_desc", locale_str)
857 + .await,
858 + back_to_settings: i18n_service
859 + .translate("sessions.back_to_settings", locale_str)
860 + .await,
861 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
862 + breadcrumb_settings: i18n_service
863 + .translate("breadcrumb.settings", locale_str)
864 + .await,
865 + breadcrumb_sessions: i18n_service
866 + .translate("breadcrumb.sessions", locale_str)
867 + .await,
868 + };
869 +
870 + Ok(template)
871 +}
D crates/rustwarden-ui/src/handlers/settings_handler.rs
+0 -1358
@@ -1,1358 +0,0 @@
1 -/// Settings Handler
2 -/// Feature 004: UI Post-Auth + Feature 009: Admin & Settings
3 -///
4 -/// Handles the settings page, profile updates, and password changes.
5 -use askama::Template;
6 -use axum::{
7 - extract::{Query, State},
8 - response::IntoResponse,
9 - Extension, Json,
10 -};
11 -use sea_orm::DatabaseConnection;
12 -use serde::Deserialize;
13 -use std::sync::Arc;
14 -use tower_sessions::Session;
15 -
16 -use crate::dto::navigation_dto::NavigationDTO;
17 -use crate::dto::user_profile_dto::UserProfileDTO;
18 -use rustwarden_core::auth::jwt::Claims;
19 -use rustwarden_core::config::email::EmailConfig;
20 -use rustwarden_core::error::AppError;
21 -use rustwarden_core::i18n::extractor::Locale;
22 -use rustwarden_core::i18n::service::I18nService;
23 -use rustwarden_core::services::app_settings_service::AppSettingsService;
24 -use rustwarden_core::services::refresh_token_service::RefreshTokenService;
25 -use rustwarden_core::services::user_service::UserService;
26 -use chrono::Utc;
27 -
28 -/// Inline DTO: Update profile request
29 -#[derive(Debug, Deserialize)]
30 -pub struct UpdateProfileRequest {
31 - pub username: Option<String>,
32 - pub email: Option<String>,
33 -}
34 -
35 -/// Inline DTO: Change password request
36 -#[derive(Debug, Deserialize)]
37 -pub struct ChangePasswordRequest {
38 - pub old_password: String,
39 - pub new_password: String,
40 - pub confirm_password: String,
41 -}
42 -
43 -/// Inline DTO: Change password response
44 -#[derive(Debug, serde::Serialize)]
45 -pub struct ChangePasswordResponse {
46 - pub message: String,
47 -}
48 -
49 -/// Inline DTO: Active sessions response
50 -#[derive(Debug, serde::Serialize)]
51 -pub struct ActiveSessionsResponse {
52 - pub sessions: Vec<rustwarden_core::services::refresh_token_service::ActiveSession>,
53 -}
54 -
55 -/// Inline DTO: Update preferences request
56 -#[derive(Debug, Deserialize)]
57 -pub struct UpdatePreferencesRequest {
58 - pub theme: Option<String>,
59 - pub language: Option<String>,
60 -}
61 -
62 -/// Simplified navigation item for templates
63 -#[derive(Clone)]
64 -#[allow(dead_code)]
65 -struct NavItem {
66 - id: String,
67 - label: String,
68 - icon: String,
69 - url: String,
70 - active: bool,
71 - enabled: bool,
72 - badge: String,
73 - has_badge: bool,
74 - disabled_message: String,
75 - has_disabled_message: bool,
76 -}
77 -
78 -/// Helper to build NavItems from NavigationDTO
79 -fn build_nav_items_from_dto(navigation: &NavigationDTO, active_id: &str) -> Vec<NavItem> {
80 - navigation
81 - .items
82 - .iter()
83 - .map(|item| NavItem {
84 - id: item.id.clone(),
85 - label: item.label.clone(),
86 - icon: item.icon.clone(),
87 - url: item.url.clone(),
88 - active: item.id == active_id,
89 - enabled: item.enabled,
90 - badge: item.badge.clone(),
91 - has_badge: item.has_badge,
92 - disabled_message: item.disabled_message.clone(),
93 - has_disabled_message: item.has_disabled_message,
94 - })
95 - .collect()
96 -}
97 -
98 -
99 -/// Handler for PUT /settings/profile - Update user profile (PROTECTED)
100 -pub async fn update_profile(
101 - Extension(claims): Extension<Claims>,
102 - State(db): State<DatabaseConnection>,
103 - _session: Session,
104 - Json(request): Json<UpdateProfileRequest>,
105 -) -> Result<impl IntoResponse, AppError> {
106 - let old_user = UserService::get_user_by_id(&db, claims.sub)
107 - .await?
108 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
109 -
110 - let old_email = old_user.email.clone();
111 - let new_email = request.email.clone();
112 - let new_username = request.username.clone();
113 - let email_was_changed = new_email.as_ref().map(|e| e != &old_email).unwrap_or(false);
114 -
115 - let updated_user =
116 - UserService::update_profile(&db, claims.sub, request.username, request.email).await?;
117 -
118 - tracing::info!("Profile updated for user {}", updated_user.username);
119 -
120 - if email_was_changed {
121 - use rustwarden_core::services::password_change_service::PasswordChangeService;
122 - let password_change_service = PasswordChangeService::new();
123 - let invalidated = password_change_service
124 - .invalidate_on_email_change(&db, claims.sub)
125 - .await?;
126 - if invalidated > 0 {
127 - tracing::warn!(
128 - "Invalidated {} pending password change request(s) for user {} due to email change",
129 - invalidated,
130 - updated_user.username
131 - );
132 - }
133 - }
134 -
135 - if let Ok(email_config) = EmailConfig::from_env() {
136 - let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
137 - let app_name = email_config
138 - .smtp_from_name
139 - .clone()
140 - .unwrap_or_else(rustwarden_core::config::app::default_app_name);
141 -
142 - let mut changes = Vec::new();
143 - if new_username.is_some() {
144 - changes.push(rustwarden_core::templates::email_templates::AccountChange {
145 - field: "Username".to_string(),
146 - old_value: Some(old_user.username.clone()),
147 - new_value: updated_user.username.clone(),
148 - });
149 - }
150 - if email_was_changed {
151 - if let Some(ref new_email) = new_email {
152 - changes.push(rustwarden_core::templates::email_templates::AccountChange {
153 - field: "Email".to_string(),
154 - old_value: Some(old_email.clone()),
155 - new_value: new_email.clone(),
156 - });
157 - }
158 - }
159 -
160 - if !changes.is_empty() {
161 - let template = rustwarden_core::templates::email_templates::AccountModifiedTemplate::new(
162 - Some("fr".to_string()),
163 - updated_user.username.clone(),
164 - modified_at,
165 - changes,
166 - Some(app_name.clone()),
167 - );
168 -
169 - if let (Ok(body_html), body_text) = (template.render_html(), template.render_text()) {
170 - let subject = "Compte modifié".to_string();
171 - use rustwarden_core::services::email_queue_service::EmailQueueService;
172 -
173 - if email_was_changed {
174 - if let Err(e) = EmailQueueService::enqueue(
175 - &db, updated_user.email.clone(), Some(updated_user.username.clone()),
176 - email_config.smtp_from.clone(), email_config.smtp_from_name.clone(),
177 - subject.clone(), body_html.clone(), body_text.clone(),
178 - "account_modified".to_string(),
179 - ).await {
180 - tracing::warn!("Failed to enqueue account modified email to new address: {}", e);
181 - }
182 - if let Err(e) = EmailQueueService::enqueue(
183 - &db, old_email.clone(), Some(updated_user.username.clone()),
184 - email_config.smtp_from.clone(), email_config.smtp_from_name.clone(),
185 - subject, body_html, body_text,
186 - "account_modified".to_string(),
187 - ).await {
188 - tracing::warn!("Failed to enqueue account modified email to old address: {}", e);
189 - }
190 - } else if let Err(e) = EmailQueueService::enqueue(
191 - &db, updated_user.email.clone(), Some(updated_user.username.clone()),
192 - email_config.smtp_from.clone(), email_config.smtp_from_name.clone(),
193 - subject, body_html, body_text,
194 - "account_modified".to_string(),
195 - ).await {
196 - tracing::warn!("Failed to enqueue account modified notification: {}", e);
197 - }
198 - }
199 - }
200 - }
201 -
202 - Ok(Json(serde_json::json!({
203 - "message": "Profile updated successfully",
204 - "username": updated_user.username,
205 - "email": updated_user.email,
206 - })))
207 -}
208 -
209 -/// Handler for PUT /settings/password - Change user password (PROTECTED)
210 -pub async fn change_password(
211 - Extension(claims): Extension<Claims>,
212 - State(db): State<DatabaseConnection>,
213 - session: Session,
214 - Json(request): Json<ChangePasswordRequest>,
215 -) -> Result<impl IntoResponse, AppError> {
216 - if request.new_password != request.confirm_password {
217 - return Err(AppError::Validation(
218 - "New password and confirmation do not match".to_string(),
219 - ));
220 - }
221 -
222 - let require_email = AppSettingsService::get_bool(
223 - &db,
224 - "password_change_require_email",
225 - true,
226 - )
227 - .await?;
228 -
229 - if require_email {
230 - let current_token_id: Option<uuid::Uuid> =
231 - session.get("refresh_token_id").await.ok().flatten();
232 -
233 - let (_request_id, token) = UserService::change_password(
234 - &db,
235 - claims.sub,
236 - &request.old_password,
237 - &request.new_password,
238 - current_token_id,
239 - )
240 - .await?;
241 -
242 - tracing::info!(
243 - "Created pending password change request for user {}",
244 - claims.sub
245 - );
246 -
247 - let user = UserService::get_user_by_id(&db, claims.sub)
248 - .await?
249 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
250 -
251 - let email_config = EmailConfig::from_env()
252 - .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?;
253 -
254 - let confirmation_url = format!(
255 - "{}/confirm-password-change?token={}",
256 - email_config.email_base_url, token
257 - );
258 -
259 - let template =
260 - rustwarden_core::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new(
261 - Some("fr".to_string()),
262 - user.username.clone(),
263 - confirmation_url,
264 - Some(
265 - email_config
266 - .smtp_from_name
267 - .clone()
268 - .unwrap_or_else(rustwarden_core::config::app::default_app_name),
269 - ),
270 - );
271 -
272 - let body_html = template
273 - .render_html()
274 - .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
275 - let body_text = template.render_text();
276 -
277 - let subject = "Confirmation de changement de mot de passe".to_string();
278 -
279 - let email_svc = rustwarden_core::services::email_service::EmailService::new(email_config)
280 - .map_err(|e| AppError::Internal(format!("Failed to create email service: {}", e)))?;
281 - email_svc
282 - .send_email(
283 - &user.email,
284 - Some(&user.username),
285 - &subject,
286 - &body_html,
287 - &body_text,
288 - )
289 - .await
290 - .map_err(|e| {
291 - tracing::error!(
292 - "Failed to send password change confirmation email for user {}: {}",
293 - user.username,
294 - e
295 - );
296 - AppError::Internal(
297 - "Failed to send confirmation email. Please try again later.".to_string(),
298 - )
299 - })?;
300 -
301 - tracing::info!(
302 - "Password change confirmation email sent for user {}",
303 - user.username
304 - );
305 -
306 - Ok(Json(ChangePasswordResponse {
307 - message: "email_confirmation_sent".to_string(),
308 - }))
309 - } else {
310 - use rustwarden_core::services::password_service::PasswordService;
311 -
312 - let user = UserService::get_user_by_id(&db, claims.sub)
313 - .await?
314 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
315 -
316 - if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? {
317 - return Err(AppError::Validation(
318 - "Old password is incorrect".to_string(),
319 - ));
320 - }
321 -
322 - rustwarden_core::middleware::password_complexity::validate_password_complexity(
323 - &db,
324 - &request.new_password,
325 - )
326 - .await?;
327 -
328 - let new_hash = PasswordService::hash_password(&request.new_password).await?;
329 - UserService::update_password_hash(&db, claims.sub, &new_hash).await?;
330 - RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?;
331 -
332 - tracing::info!(
333 - "Password changed directly (no email confirmation) for user {}",
334 - claims.sub
335 - );
336 -
337 - Ok(Json(ChangePasswordResponse {
338 - message: "password_changed_directly".to_string(),
339 - }))
340 - }
341 -}
342 -
343 -/// Query parameters for password change confirmation
344 -#[derive(Deserialize)]
345 -pub struct ConfirmPasswordChangeQuery {
346 - token: String,
347 -}
348 -
349 -/// Handler for GET /confirm-password-change?token={token} - Confirm password change (PUBLIC)
350 -pub async fn confirm_password_change(
351 - State(db): State<DatabaseConnection>,
352 - Query(query): Query<ConfirmPasswordChangeQuery>,
353 -) -> Result<impl IntoResponse, AppError> {
354 - use rustwarden_core::services::password_change_service::PasswordChangeService;
355 -
356 - let password_change_service = PasswordChangeService::new();
357 -
358 - let _request = password_change_service
359 - .find_by_token(&db, &query.token)
360 - .await?;
361 -
362 - let user_id = password_change_service
363 - .confirm_request(&db, &query.token)
364 - .await?;
365 -
366 - RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?;
367 -
368 - tracing::info!(
369 - "Password change confirmed and applied for user {}. All sessions invalidated.",
370 - user_id
371 - );
372 -
373 - Ok(Json(serde_json::json!({
374 - "success": true,
375 - "message": "Password change confirmed successfully. Please log in with your new password."
376 - })))
377 -}
378 -
379 -/// Handler for DELETE /settings/password/pending - Cancel pending password change request (PROTECTED)
380 -pub async fn cancel_pending_password_change(
381 - Extension(claims): Extension<Claims>,
382 - State(db): State<DatabaseConnection>,
383 -) -> Result<impl IntoResponse, AppError> {
384 - use rustwarden_core::services::password_change_service::PasswordChangeService;
385 -
386 - let password_change_service = PasswordChangeService::new();
387 -
388 - password_change_service
389 - .cancel_pending_request(&db, claims.sub)
390 - .await?;
391 -
392 - tracing::info!(
393 - "Canceled pending password change request for user {}",
394 - claims.sub
395 - );
396 -
397 - Ok(Json(serde_json::json!({
398 - "success": true,
399 - "message": "Pending password change request canceled successfully."
400 - })))
401 -}
402 -
403 -/// Handler for GET /settings/sessions - List active sessions (PROTECTED)
404 -pub async fn list_sessions(
405 - Extension(claims): Extension<Claims>,
406 - State(db): State<DatabaseConnection>,
407 - session: Session,
408 -) -> Result<impl IntoResponse, AppError> {
409 - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
410 -
411 - let mut sessions = RefreshTokenService::list_user_sessions(&db, claims.sub).await?;
412 -
413 - if let Some(current_id) = current_token_id {
414 - for session in &mut sessions {
415 - if session.id == current_id {
416 - session.is_current = true;
417 - break;
418 - }
419 - }
420 - }
421 -
422 - Ok(Json(ActiveSessionsResponse { sessions }))
423 -}
424 -
425 -/// Handler for DELETE /settings/sessions/:id - Revoke session (PROTECTED)
426 -pub async fn revoke_session(
427 - Extension(claims): Extension<Claims>,
428 - State(db): State<DatabaseConnection>,
429 - session: Session,
430 - axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
431 -) -> Result<impl IntoResponse, AppError> {
432 - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
433 -
434 - if Some(session_id) == current_token_id {
435 - return Err(AppError::Validation(
436 - "Cannot revoke current session. Please log out instead.".to_string(),
437 - ));
438 - }
439 -
440 - use rustwarden_core::models::refresh_token;
441 - use sea_orm::EntityTrait;
442 -
443 - let token = refresh_token::Entity::find_by_id(session_id)
444 - .one(&db)
445 - .await?
446 - .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?;
447 -
448 - if token.user_id != claims.sub {
449 - return Err(AppError::Validation(
450 - "You can only revoke your own sessions".to_string(),
451 - ));
452 - }
453 -
454 - RefreshTokenService::revoke_session(&db, session_id).await?;
455 -
456 - tracing::info!("Session {} revoked by user {}", session_id, claims.sub);
457 -
458 - Ok(Json(serde_json::json!({
459 - "message": "Session revoked successfully",
460 - })))
461 -}
462 -
463 -#[cfg(test)]
464 -mod tests {
465 - use super::*;
466 -
467 - #[test]
468 - fn test_settings_template_compiles() {}
469 -}
470 -
471 -// ============================================================================
472 -// User Preferences Handlers
473 -// ============================================================================
474 -
475 -/// GET /settings/preferences - Display user preferences (JSON)
476 -pub async fn get_preferences(
477 - State(db): State<DatabaseConnection>,
478 - Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
479 -) -> Result<impl IntoResponse, AppError> {
480 - use rustwarden_core::services::user_preferences_service::UserPreferencesService;
481 -
482 - let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? {
483 - Some(prefs) => prefs,
484 - None => {
485 - UserPreferencesService::create_default_preferences(&db, claims.sub).await?
486 - }
487 - };
488 -
489 - Ok(Json(serde_json::json!({
490 - "theme": preferences.theme,
491 - "language": preferences.language,
492 - })))
493 -}
494 -
495 -/// PUT /settings/preferences - Update user preferences
496 -pub async fn update_preferences(
497 - State(db): State<DatabaseConnection>,
498 - Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
499 - axum::extract::Form(form): axum::extract::Form<UpdatePreferencesRequest>,
500 -) -> Result<impl IntoResponse, AppError> {
501 - use rustwarden_core::services::user_preferences_service::UserPreferencesService;
502 -
503 - let current_prefs = match UserPreferencesService::get_preferences(&db, claims.sub).await? {
504 - Some(prefs) => prefs,
505 - None => {
506 - UserPreferencesService::create_default_preferences(&db, claims.sub).await?
507 - }
508 - };
509 -
510 - let theme = form.theme.unwrap_or(current_prefs.theme.clone());
511 - let language = form.language.unwrap_or(current_prefs.language.clone());
512 - let language_changed = language != current_prefs.language;
513 -
514 - let updated_prefs =
515 - UserPreferencesService::update_preferences(&db, claims.sub, theme, language.clone())
516 - .await?;
517 -
518 - tracing::info!(
519 - "User {} updated preferences: theme={}, language={}",
520 - claims.sub,
521 - updated_prefs.theme,
522 - updated_prefs.language
523 - );
524 -
525 - let json_response = Json(serde_json::json!({
526 - "message": "Preferences updated successfully",
527 - "theme": updated_prefs.theme,
528 - "language": updated_prefs.language,
529 - "language_changed": language_changed,
530 - "reload_required": language_changed,
531 - }));
532 -
533 - use time::Duration as TimeDuration;
534 - use axum::http::header;
535 -
536 - let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", updated_prefs.theme.clone()))
537 - .http_only(false)
538 - .same_site(axum_extra::extract::cookie::SameSite::Lax)
539 - .path("/")
540 - .max_age(TimeDuration::seconds(31536000));
541 -
542 - let mut response = json_response.into_response();
543 -
544 - if let Ok(hv) = theme_cookie.to_string().parse() {
545 - response.headers_mut().append(header::SET_COOKIE, hv);
546 - }
547 -
548 - if language_changed && matches!(language.as_str(), "fr" | "en" | "de" | "es") {
549 - let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", language))
550 - .http_only(false)
551 - .same_site(axum_extra::extract::cookie::SameSite::Lax)
552 - .path("/")
553 - .max_age(TimeDuration::seconds(31536000));
554 -
555 - if let Ok(hv) = locale_cookie.to_string().parse() {
556 - response.headers_mut().append(header::SET_COOKIE, hv);
557 - }
558 - }
559 -
560 - Ok(response)
561 -}
562 -
563 -// ============================================================================
564 -// GET Handlers for HTML Pages (with i18n)
565 -// ============================================================================
566 -
567 -/// Custom filters for Askama templates
568 -#[allow(dead_code)]
569 -mod filters {
570 - use askama::Result;
571 -
572 - #[allow(dead_code)]
573 - #[askama::filter_fn]
574 - pub fn default<T: std::fmt::Display>(
575 - value: T,
576 - _values: &dyn askama::Values,
577 - default_value: &str,
578 - ) -> Result<String> {
579 - let value_str = value.to_string();
580 - if value_str.is_empty() {
581 - Ok(default_value.to_string())
582 - } else {
583 - Ok(value_str)
584 - }
585 - }
586 -
587 - #[askama::filter_fn]
588 - pub fn length<I: IntoIterator>(value: I, _values: &dyn askama::Values) -> Result<usize> {
589 - Ok(value.into_iter().count())
590 - }
591 -}
592 -
593 -/// Profile page template
594 -#[derive(Template)]
595 -#[template(path = "profile.html")]
596 -#[allow(dead_code)]
597 -struct ProfileTemplate {
598 - page_title: String,
599 - csrf_token: String,
600 - app_name: String,
601 - app_version: String,
602 - app_domain: String,
603 - app_description: String,
604 - username: String,
605 - email: String,
606 - user_id: String,
607 - created_at: String,
608 - roles: String,
609 - roles_text: String,
610 - initials: String,
611 - avatar_color: String,
612 - nav_items: Vec<NavItem>,
613 - has_pending_password_change: bool,
614 - user_menu_profile: String,
615 - user_menu_logout: String,
616 -
617 - // Translated strings (hardcoded FR)
618 - title: String,
619 - subtitle: String,
620 - account_info: String,
621 - username_label: String,
622 - username_public: String,
623 - username_placeholder: String,
624 - username_pattern: String,
625 - username_help: String,
626 - email_label: String,
627 - email_used_for: String,
628 - email_warning: String,
629 - email_placeholder: String,
630 - email_change_info: String,
631 - save_button: String,
632 - cancel: String,
633 - additional_info: String,
634 - account_created: String,
635 - roles_label: String,
636 - breadcrumb_home: String,
637 - breadcrumb_settings: String,
638 - breadcrumb_profile: String,
639 -}
640 -
641 -rustwarden_core::impl_template_response!(ProfileTemplate);
642 -
643 -/// Query parameters for password page
644 -#[derive(Deserialize)]
645 -pub struct PasswordPageQuery {
646 - must_change: Option<String>,
647 -}
648 -
649 -/// Password page template
650 -#[derive(Template)]
651 -#[template(path = "settings/password.html")]
652 -#[allow(dead_code)]
653 -struct PasswordTemplate {
654 - page_title: String,
655 - csrf_token: String,
656 - app_name: String,
657 - app_version: String,
658 - app_domain: String,
659 - app_description: String,
660 - username: String,
661 - email: String,
662 - roles_text: String,
663 - initials: String,
664 - avatar_color: String,
665 - nav_items: Vec<NavItem>,
666 - user_menu_profile: String,
667 - user_menu_logout: String,
668 -
669 - must_change: bool,
670 - must_change_message: String,
671 - has_pending_request: bool,
672 - pending_request_message: String,
673 - pending_request_info: String,
674 - pending_request_cancel: String,
675 - // Translated strings (hardcoded FR)
676 - title: String,
677 - subtitle: String,
678 - new_password_label: String,
679 - current_password_label: String,
680 - current_password_placeholder: String,
681 - new_password_placeholder: String,
682 - required: String,
683 - requirements: String,
684 - strength_label: String,
685 - strength_weak: String,
686 - strength_medium: String,
687 - strength_strong: String,
688 - strength_too_short: String,
689 - strength_missing_lowercase: String,
690 - strength_missing_uppercase: String,
691 - strength_missing_digit: String,
692 - strength_missing_special: String,
693 - confirm_password_label: String,
694 - confirm_password_placeholder: String,
695 - passwords_match: String,
696 - passwords_not_match: String,
697 - change_button: String,
698 - changing: String,
699 - logout_warning_title: String,
700 - logout_warning_desc: String,
701 - saved: String,
702 - error_all_fields_required: String,
703 - error_empty_data: String,
704 - error_invalid_data: String,
705 - error_incorrect_current: String,
706 - error_reading_response: String,
707 - error_415: String,
708 - network_error: String,
709 - security_tips_title: String,
710 - security_tip_unique: String,
711 - security_tip_share: String,
712 - security_tip_regular: String,
713 - security_tip_2fa: String,
714 - security_tip_manager: String,
715 - breadcrumb_home: String,
716 - breadcrumb_settings: String,
717 - breadcrumb_password: String,
718 - cancel: String,
719 - logout_label: String,
720 - has_pending_password_change: bool,
721 - // Password complexity rules (from app_settings)
722 - pw_min_length: usize,
723 - pw_require_uppercase: bool,
724 - pw_require_lowercase: bool,
725 - pw_require_digits: bool,
726 - pw_require_special: bool,
727 - password_change_require_email: bool,
728 - subtitle_no_email: String,
729 - saved_direct: String,
730 -}
731 -
732 -rustwarden_core::impl_template_response!(PasswordTemplate);
733 -
734 -/// Session item for template
735 -#[derive(Clone, Debug)]
736 -#[allow(dead_code)]
737 -struct SessionItem {
738 - id: String,
739 - device_info: String,
740 - ip_address: String,
741 - last_activity: String,
742 - created_at: String,
743 - is_current: bool,
744 -}
745 -
746 -/// Sessions page template
747 -#[derive(Template)]
748 -#[template(path = "settings/sessions.html")]
749 -#[allow(dead_code)]
750 -struct SessionsTemplate {
751 - page_title: String,
752 - csrf_token: String,
753 - app_name: String,
754 - app_version: String,
755 - app_domain: String,
756 - app_description: String,
757 - username: String,
758 - email: String,
759 - roles_text: String,
760 - initials: String,
761 - avatar_color: String,
762 - sessions: Vec<SessionItem>,
763 - nav_items: Vec<NavItem>,
764 - has_pending_password_change: bool,
765 - user_menu_profile: String,
766 - user_menu_logout: String,
767 -
768 - // Translated strings (hardcoded FR)
769 - title: String,
770 - subtitle: String,
771 - current_session: String,
772 - current_badge: String,
773 - unknown_device: String,
774 - ip_address: String,
775 - created_at: String,
776 - last_activity: String,
777 - session_id: String,
778 - revoke: String,
779 - revoke_confirm_template: String,
780 - revoke_success: String,
781 - revoke_error: String,
782 - no_sessions: String,
783 - no_sessions_desc: String,
784 - danger_zone: String,
785 - danger_zone_desc: String,
786 - revoke_all: String,
787 - revoke_all_confirm: String,
788 - revoke_all_success: String,
789 - revoke_all_error: String,
790 - about_title: String,
791 - about_desc: String,
792 - back_to_settings: String,
793 - breadcrumb_home: String,
794 - breadcrumb_settings: String,
795 - breadcrumb_sessions: String,
796 -}
797 -
798 -rustwarden_core::impl_template_response!(SessionsTemplate);
799 -
800 -/// Preferences page template
801 -#[derive(Template)]
802 -#[template(path = "settings/preferences.html")]
803 -#[allow(dead_code)]
804 -struct PreferencesTemplate {
805 - page_title: String,
806 - csrf_token: String,
807 - app_name: String,
808 - app_version: String,
809 - app_domain: String,
810 - app_description: String,
811 - username: String,
812 - email: String,
813 - roles_text: String,
814 - initials: String,
815 - avatar_color: String,
816 - theme: String,
817 - is_theme_light: bool,
818 - is_theme_dark: bool,
819 - is_theme_auto: bool,
820 - language: String,
821 - is_language_fr: bool,
822 - is_language_en: bool,
823 - is_language_de: bool,
824 - is_language_es: bool,
825 - current_locale: String,
826 - supported_languages: Vec<(String, String, bool)>,
827 - nav_items: Vec<NavItem>,
828 - has_pending_password_change: bool,
829 - user_menu_profile: String,
830 - user_menu_logout: String,
831 -
832 - // Translated strings (hardcoded FR)
833 - title: String,
834 - subtitle: String,
835 - theme_label: String,
836 - theme_light: String,
837 - theme_light_desc: String,
838 - theme_dark: String,
839 - theme_dark_desc: String,
840 - theme_auto: String,
841 - theme_auto_desc: String,
842 - theme_preview: String,
843 - language_label: String,
844 - language_fr: String,
845 - language_fr_desc: String,
846 - language_en: String,
847 - language_en_desc: String,
848 - language_de: String,
849 - language_de_desc: String,
850 - language_es: String,
851 - language_es_desc: String,
852 - translation_warning: String,
853 - modified: String,
854 - modified_desc: String,
855 - save_button: String,
856 - saving: String,
857 - cancel: String,
858 - saved_success: String,
859 - saved_reload: String,
860 - saved_reload_json: String,
861 - saved_success_json: String,
862 - error_prefix: String,
863 - error_prefix_json: String,
864 - network_error: String,
865 - network_error_json: String,
866 - save_button_json: String,
867 - sync_title: String,
868 - sync_desc: String,
869 - preview_dark: String,
870 - preview_light: String,
871 - preview_auto: String,
872 - breadcrumb_home: String,
873 - breadcrumb_settings: String,
874 - breadcrumb_preferences: String,
875 -}
876 -
877 -rustwarden_core::impl_template_response!(PreferencesTemplate);
878 -
879 -/// Handler for GET /profile - Display profile page (PROTECTED)
880 -pub async fn get_profile_page(
881 - Extension(claims): Extension<Claims>,
882 - Extension(i18n_service): Extension<Arc<I18nService>>,
883 - Extension(locale): Extension<Locale>,
884 - State(db): State<DatabaseConnection>,
885 - session: Session,
886 -) -> Result<impl IntoResponse, AppError> {
887 - let user_model = UserService::get_user_by_username(&db, &claims.username)
888 - .await?
889 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
890 -
891 - let roles = UserService::get_user_roles(&db, user_model.id).await?;
892 - let user = UserProfileDTO::new(user_model, roles.clone());
893 -
894 - let locale_str = locale.as_str();
895 -
896 - let layout = rustwarden_core::templates::LayoutContext::new("Profil", "");
897 -
898 - use crate::template_helpers::csrf_token_helper;
899 - let csrf_token = csrf_token_helper(&session).await;
900 -
901 - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
902 - let nav_items = build_nav_items_from_dto(&navigation, "settings");
903 -
904 - let app_domain = AppSettingsService::get_setting(&db, "app_domain")
905 - .await?
906 - .map(|s| s.value)
907 - .unwrap_or_else(|| "localhost".to_string());
908 - let app_description = AppSettingsService::get_setting(&db, "app_description")
909 - .await?
910 - .map(|s| s.value)
911 - .unwrap_or_else(|| "Secure password manager".to_string());
912 -
913 - let template = ProfileTemplate {
914 - page_title: layout.page_title.clone(),
915 - csrf_token,
916 - app_name: layout.app_name.clone(),
917 - app_version: layout.app_version.clone(),
918 - app_domain,
919 - app_description,
920 - username: user.username.clone(),
921 - email: user.email.clone(),
922 - user_id: user.id.to_string(),
923 - created_at: user.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
924 - roles: user.roles.join(", "),
925 - roles_text: user.roles.join(", "),
926 - initials: user.initials.clone(),
927 - avatar_color: user.avatar_color.clone(),
928 - nav_items,
929 - has_pending_password_change: false,
930 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
931 - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
932 -
933 - title: i18n_service.translate("profile.title", locale_str).await,
934 - subtitle: i18n_service.translate("profile.subtitle", locale_str).await,
935 - account_info: i18n_service.translate("profile.account_info", locale_str).await,
936 - username_label: i18n_service.translate("profile.username", locale_str).await,
937 - username_public: i18n_service.translate("profile.username_public", locale_str).await,
938 - username_placeholder: i18n_service.translate("profile.username_placeholder", locale_str).await,
939 - username_pattern: i18n_service.translate("profile.username_pattern", locale_str).await,
940 - username_help: i18n_service.translate("profile.username_help", locale_str).await,
941 - email_label: i18n_service.translate("profile.email_label", locale_str).await,
942 - email_used_for: i18n_service.translate("profile.email_used_for", locale_str).await,
943 - email_warning: i18n_service.translate("profile.email_warning", locale_str).await,
944 - email_placeholder: i18n_service.translate("profile.email_placeholder", locale_str).await,
945 - email_change_info: i18n_service.translate("profile.email_change_info", locale_str).await,
946 - save_button: i18n_service.translate("profile.save", locale_str).await,
947 - cancel: i18n_service.translate("common.cancel", locale_str).await,
948 - additional_info: i18n_service.translate("profile.additional_info", locale_str).await,
949 - account_created: i18n_service.translate("profile.account_created", locale_str).await,
950 - roles_label: i18n_service.translate("profile.roles", locale_str).await,
951 - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
952 - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
953 - breadcrumb_profile: i18n_service.translate("breadcrumb.profile", locale_str).await,
954 - };
955 -
956 - Ok(template)
957 -}
958 -
959 -/// Handler for GET /settings/password - Display password change page (PROTECTED)
960 -pub async fn get_password_page(
961 - Extension(claims): Extension<Claims>,
962 - Extension(i18n_service): Extension<Arc<I18nService>>,
963 - Extension(locale): Extension<Locale>,
964 - State(db): State<DatabaseConnection>,
965 - session: Session,
966 - Query(query): Query<PasswordPageQuery>,
967 -) -> Result<impl IntoResponse, AppError> {
968 - let user_model = UserService::get_user_by_username(&db, &claims.username)
969 - .await?
970 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
971 -
972 - let roles = UserService::get_user_roles(&db, user_model.id).await?;
973 - let user = UserProfileDTO::new(user_model.clone(), roles.clone());
974 -
975 - let locale_str = locale.as_str();
976 -
977 - let must_change = query.must_change.as_deref() == Some("true");
978 - let must_change_message = if must_change {
979 - i18n_service.translate("password.must_change_message", locale_str).await
980 - } else {
981 - String::new()
982 - };
983 -
984 - let pw_rules = rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db).await;
985 -
986 - use rustwarden_core::services::password_change_service::PasswordChangeService;
987 - let password_change_service = PasswordChangeService::new();
988 - let pending_request = password_change_service
989 - .find_pending_by_user_id(&db, user_model.id)
990 - .await?;
991 - let has_pending_request = pending_request.is_some();
992 - let has_pending_password_change = has_pending_request;
993 -
994 - let pending_request_message = if has_pending_request {
995 - i18n_service.translate("password.pending_request_message", locale_str).await
996 - } else {
997 - String::new()
998 - };
999 - let pending_request_info = if has_pending_request {
1000 - i18n_service.translate("password.pending_request_info", locale_str).await
1001 - } else {
1002 - String::new()
1003 - };
1004 - let pending_request_cancel = i18n_service.translate("password.pending_request_cancel", locale_str).await;
1005 -
1006 - let layout = rustwarden_core::templates::LayoutContext::new("Mot de passe", "");
1007 - use crate::template_helpers::csrf_token_helper;
1008 - let csrf_token = csrf_token_helper(&session).await;
1009 -
1010 - let mut navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
1011 - navigation.set_pending_password_change(has_pending_password_change);
1012 - let nav_items = build_nav_items_from_dto(&navigation, "settings");
1013 -
1014 - let app_domain = AppSettingsService::get_setting(&db, "app_domain")
1015 - .await?
1016 - .map(|s| s.value)
1017 - .unwrap_or_else(|| "localhost".to_string());
1018 - let app_description = AppSettingsService::get_setting(&db, "app_description")
1019 - .await?
1020 - .map(|s| s.value)
1021 - .unwrap_or_else(|| "Secure password manager".to_string());
1022 -
1023 - let page_title = i18n_service.translate("password.page_title", locale_str).await;
1024 -
1025 - let template = PasswordTemplate {
1026 - page_title,
1027 - csrf_token,
1028 - app_name: layout.app_name.clone(),
1029 - app_version: layout.app_version.clone(),
1030 - app_domain,
1031 - app_description,
1032 - username: user.username.clone(),
1033 - email: user.email.clone(),
1034 - roles_text: user.roles.join(", "),
1035 - initials: user.initials.clone(),
1036 - avatar_color: user.avatar_color.clone(),
1037 - nav_items,
1038 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1039 - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1040 -
1041 - must_change,
1042 - must_change_message,
1043 - has_pending_request,
1044 - pending_request_message,
1045 - pending_request_info,
1046 - pending_request_cancel,
1047 - title: i18n_service.translate("password.title", locale_str).await,
1048 - subtitle: i18n_service.translate("password.subtitle", locale_str).await,
1049 - new_password_label: i18n_service.translate("password.new_password", locale_str).await,
1050 - current_password_label: i18n_service.translate("password.current_password", locale_str).await,
1051 - current_password_placeholder: i18n_service.translate("password.current_password_placeholder", locale_str).await,
1052 - new_password_placeholder: i18n_service.translate("password.new_password_placeholder", locale_str).await,
1053 - required: i18n_service.translate("password.required", locale_str).await,
1054 - requirements: i18n_service.translate("password.requirements", locale_str).await,
1055 - strength_label: i18n_service.translate("password.strength", locale_str).await,
1056 - strength_weak: i18n_service.translate("password.strength_weak", locale_str).await,
1057 - strength_medium: i18n_service.translate("password.strength_medium", locale_str).await,
1058 - strength_strong: i18n_service.translate("password.strength_strong", locale_str).await,
1059 - strength_too_short: i18n_service.translate("password.strength_too_short", locale_str).await,
1060 - strength_missing_lowercase: i18n_service.translate("password.strength_missing_lowercase", locale_str).await,
1061 - strength_missing_uppercase: i18n_service.translate("password.strength_missing_uppercase", locale_str).await,
1062 - strength_missing_digit: i18n_service.translate("password.strength_missing_digit", locale_str).await,
1063 - strength_missing_special: i18n_service.translate("password.strength_missing_special", locale_str).await,
1064 - confirm_password_label: i18n_service.translate("password.confirm_password", locale_str).await,
1065 - confirm_password_placeholder: i18n_service.translate("password.confirm_password_placeholder", locale_str).await,
1066 - passwords_match: i18n_service.translate("password.passwords_match", locale_str).await,
1067 - passwords_not_match: i18n_service.translate("password.passwords_not_match", locale_str).await,
1068 - change_button: i18n_service.translate("password.change_button", locale_str).await,
1069 - changing: i18n_service.translate("password.changing", locale_str).await,
1070 - logout_warning_title: i18n_service.translate("password.logout_warning_title", locale_str).await,
1071 - logout_warning_desc: i18n_service.translate("password.logout_warning_desc", locale_str).await,
1072 - saved: i18n_service.translate("password.saved", locale_str).await,
1073 - error_all_fields_required: i18n_service.translate("password.error_all_fields_required", locale_str).await,
1074 - error_empty_data: i18n_service.translate("password.error_empty_data", locale_str).await,
1075 - error_invalid_data: i18n_service.translate("password.error_invalid_data", locale_str).await,
1076 - error_incorrect_current: i18n_service.translate("password.error_incorrect_current", locale_str).await,
1077 - error_reading_response: i18n_service.translate("password.error_reading_response", locale_str).await,
1078 - error_415: i18n_service.translate("password.error_415", locale_str).await,
1079 - network_error: i18n_service.translate("password.network_error", locale_str).await,
1080 - security_tips_title: i18n_service.translate("password.security_tips_title", locale_str).await,
1081 - security_tip_unique: i18n_service.translate("password.security_tip_unique", locale_str).await,
1082 - security_tip_share: i18n_service.translate("password.security_tip_share", locale_str).await,
1083 - security_tip_regular: i18n_service.translate("password.security_tip_regular", locale_str).await,
1084 - security_tip_2fa: i18n_service.translate("password.security_tip_2fa", locale_str).await,
1085 - security_tip_manager: i18n_service.translate("password.security_tip_manager", locale_str).await,
1086 - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1087 - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
1088 - breadcrumb_password: i18n_service.translate("breadcrumb.password", locale_str).await,
1089 - cancel: i18n_service.translate("common.cancel", locale_str).await,
1090 - logout_label: i18n_service.translate("user_menu.logout", locale_str).await,
1091 - has_pending_password_change,
1092 - pw_min_length: pw_rules.min_length,
1093 - pw_require_uppercase: pw_rules.require_uppercase,
1094 - pw_require_lowercase: pw_rules.require_lowercase,
1095 - pw_require_digits: pw_rules.require_digits,
1096 - pw_require_special: pw_rules.require_special,
1097 - password_change_require_email: AppSettingsService::get_bool(
1098 - &db,
1099 - "password_change_require_email",
1100 - true,
1101 - )
1102 - .await
1103 - .unwrap_or(true),
1104 - subtitle_no_email: i18n_service
1105 - .translate("password.subtitle_no_email", locale_str)
1106 - .await,
1107 - saved_direct: i18n_service
1108 - .translate("password.saved_direct", locale_str)
1109 - .await,
1110 - };
1111 -
1112 - Ok(template)
1113 -}
1114 -
1115 -/// Handler for GET /settings/sessions - Display sessions page (PROTECTED)
1116 -pub async fn get_sessions_page(
1117 - Extension(claims): Extension<Claims>,
1118 - Extension(i18n_service): Extension<Arc<I18nService>>,
1119 - Extension(locale): Extension<Locale>,
1120 - State(db): State<DatabaseConnection>,
1121 - session: Session,
1122 -) -> Result<impl IntoResponse, AppError> {
1123 - let user_model = UserService::get_user_by_username(&db, &claims.username)
1124 - .await?
1125 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
1126 -
1127 - let roles = UserService::get_user_roles(&db, user_model.id).await?;
1128 - let user = UserProfileDTO::new(user_model, roles.clone());
1129 -
1130 - let locale_str = locale.as_str();
1131 -
1132 - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
1133 -
1134 - let mut sessions_data = RefreshTokenService::list_user_sessions(&db, claims.sub).await?;
1135 -
1136 - if let Some(current_id) = current_token_id {
1137 - for session_data in &mut sessions_data {
1138 - if session_data.id == current_id {
1139 - session_data.is_current = true;
1140 - break;
1141 - }
1142 - }
1143 - }
1144 -
1145 - let ip_fallback = i18n_service.translate("session.ip_unavailable", locale_str).await;
1146 - let sessions: Vec<SessionItem> = sessions_data
1147 - .iter()
1148 - .map(|s| SessionItem {
1149 - id: s.id.to_string(),
1150 - device_info: s.device_info.clone().unwrap_or_else(|| "Unknown".to_string()),
1151 - ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()),
1152 - last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(),
1153 - created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
1154 - is_current: s.is_current,
1155 - })
1156 - .collect();
1157 -
1158 - let layout = rustwarden_core::templates::LayoutContext::new("Sessions", "");
1159 - use crate::template_helpers::csrf_token_helper;
1160 - let csrf_token = csrf_token_helper(&session).await;
1161 -
1162 - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
1163 - let nav_items = build_nav_items_from_dto(&navigation, "settings");
1164 -
1165 - let app_domain = AppSettingsService::get_setting(&db, "app_domain")
1166 - .await?
1167 - .map(|s| s.value)
1168 - .unwrap_or_else(|| "localhost".to_string());
1169 - let app_description = AppSettingsService::get_setting(&db, "app_description")
1170 - .await?
1171 - .map(|s| s.value)
1172 - .unwrap_or_else(|| "Secure password manager".to_string());
1173 -
1174 - let page_title = i18n_service.translate("sessions.page_title", locale_str).await;
1175 -
1176 - let template = SessionsTemplate {
1177 - page_title,
1178 - csrf_token,
1179 - app_name: layout.app_name.clone(),
1180 - app_version: layout.app_version.clone(),
1181 - app_domain,
1182 - app_description,
1183 - username: user.username.clone(),
1184 - email: user.email.clone(),
1185 - roles_text: user.roles.join(", "),
1186 - initials: user.initials.clone(),
1187 - avatar_color: user.avatar_color.clone(),
1188 - sessions,
1189 - nav_items,
1190 - has_pending_password_change: false,
1191 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1192 - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1193 -
1194 - title: i18n_service.translate("sessions.title", locale_str).await,
1195 - subtitle: i18n_service.translate("sessions.subtitle", locale_str).await,
1196 - current_session: i18n_service.translate("sessions.current_session", locale_str).await,
1197 - current_badge: i18n_service.translate("sessions.current_badge", locale_str).await,
1198 - unknown_device: i18n_service.translate("sessions.unknown_device", locale_str).await,
1199 - ip_address: i18n_service.translate("sessions.ip_address", locale_str).await,
1200 - created_at: i18n_service.translate("sessions.created_at", locale_str).await,
1201 - last_activity: i18n_service.translate("sessions.last_activity", locale_str).await,
1202 - session_id: i18n_service.translate("sessions.session_id", locale_str).await,
1203 - revoke: i18n_service.translate("sessions.revoke", locale_str).await,
1204 - revoke_confirm_template: i18n_service.translate("sessions.revoke_confirm", locale_str).await,
1205 - revoke_success: i18n_service.translate("sessions.revoke_success", locale_str).await,
1206 - revoke_error: i18n_service.translate("sessions.revoke_error", locale_str).await,
1207 - no_sessions: i18n_service.translate("sessions.no_sessions", locale_str).await,
1208 - no_sessions_desc: i18n_service.translate("sessions.no_sessions_desc", locale_str).await,
1209 - danger_zone: i18n_service.translate("sessions.danger_zone", locale_str).await,
1210 - danger_zone_desc: i18n_service.translate("sessions.danger_zone_desc", locale_str).await,
1211 - revoke_all: i18n_service.translate("sessions.revoke_all", locale_str).await,
1212 - revoke_all_confirm: i18n_service.translate("sessions.revoke_all_confirm", locale_str).await,
1213 - revoke_all_success: i18n_service.translate("sessions.revoke_all_success", locale_str).await,
1214 - revoke_all_error: i18n_service.translate("sessions.revoke_all_error", locale_str).await,
1215 - about_title: i18n_service.translate("sessions.about_title", locale_str).await,
1216 - about_desc: i18n_service.translate("sessions.about_desc", locale_str).await,
1217 - back_to_settings: i18n_service.translate("sessions.back_to_settings", locale_str).await,
1218 - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1219 - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
1220 - breadcrumb_sessions: i18n_service.translate("breadcrumb.sessions", locale_str).await,
1221 - };
1222 -
1223 - Ok(template)
1224 -}
1225 -
1226 -/// Handler for GET /settings/preferences - Display preferences page (PROTECTED)
1227 -pub async fn get_preferences_page(
1228 - Extension(claims): Extension<Claims>,
1229 - Extension(i18n_service): Extension<Arc<I18nService>>,
1230 - Extension(locale): Extension<Locale>,
1231 - State(db): State<DatabaseConnection>,
1232 - session: Session,
1233 -) -> Result<impl IntoResponse, AppError> {
1234 - let user_model = UserService::get_user_by_username(&db, &claims.username)
1235 - .await?
1236 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
1237 -
1238 - let roles = UserService::get_user_roles(&db, user_model.id).await?;
1239 - let user = UserProfileDTO::new(user_model, roles.clone());
1240 -
1241 - let locale_str = locale.as_str();
1242 -
1243 - use rustwarden_core::services::user_preferences_service::UserPreferencesService;
1244 - let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? {
1245 - Some(prefs) => prefs,
1246 - None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?,
1247 - };
1248 -
1249 - let current_locale = locale_str.to_string();
1250 - let supported_languages: Vec<(String, String, bool)> = vec![
1251 - ("fr".to_string(), "Français".to_string(), current_locale == "fr"),
1252 - ("en".to_string(), "English".to_string(), current_locale == "en"),
1253 - ("de".to_string(), "Deutsch".to_string(), current_locale == "de"),
1254 - ("es".to_string(), "Español".to_string(), current_locale == "es"),
1255 - ];
1256 -
1257 - let layout = rustwarden_core::templates::LayoutContext::new("Préférences", "");
1258 - use crate::template_helpers::csrf_token_helper;
1259 - let csrf_token = csrf_token_helper(&session).await;
1260 -
1261 - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
1262 - let nav_items = build_nav_items_from_dto(&navigation, "settings");
1263 -
1264 - let app_domain = AppSettingsService::get_setting(&db, "app_domain")
1265 - .await?
1266 - .map(|s| s.value)
1267 - .unwrap_or_else(|| "localhost".to_string());
1268 - let app_description = AppSettingsService::get_setting(&db, "app_description")
1269 - .await?
1270 - .map(|s| s.value)
1271 - .unwrap_or_else(|| "Secure password manager".to_string());
1272 -
1273 - let save_button = i18n_service.translate("preferences.save", locale_str).await;
1274 - let saved_success = i18n_service.translate("preferences.saved_success", locale_str).await;
1275 - let saved_reload = i18n_service.translate("preferences.saved_reload", locale_str).await;
1276 - let error_prefix = i18n_service.translate("preferences.error", locale_str).await;
1277 - let network_error = i18n_service.translate("preferences.network_error", locale_str).await;
1278 -
1279 - let saved_reload_json = serde_json::to_string(&saved_reload).unwrap_or_else(|_| "\"\"".to_string());
1280 - let saved_success_json = serde_json::to_string(&saved_success).unwrap_or_else(|_| "\"\"".to_string());
1281 - let error_prefix_json = serde_json::to_string(&error_prefix).unwrap_or_else(|_| "\"\"".to_string());
1282 - let network_error_json = serde_json::to_string(&network_error).unwrap_or_else(|_| "\"\"".to_string());
1283 - let save_button_json = serde_json::to_string(&save_button).unwrap_or_else(|_| "\"\"".to_string());
1284 -
1285 - let template = PreferencesTemplate {
1286 - page_title: layout.page_title.clone(),
1287 - csrf_token,
1288 - app_name: layout.app_name.clone(),
1289 - app_version: layout.app_version.clone(),
1290 - app_domain,
1291 - app_description,
1292 - username: user.username.clone(),
1293 - email: user.email.clone(),
1294 - roles_text: user.roles.join(", "),
1295 - initials: user.initials.clone(),
1296 - avatar_color: user.avatar_color.clone(),
1297 - theme: preferences.theme.clone(),
1298 - is_theme_light: preferences.theme == "light",
1299 - is_theme_dark: preferences.theme == "dark",
1300 - is_theme_auto: preferences.theme == "auto",
1301 - language: preferences.language.clone(),
1302 - is_language_fr: preferences.language == "fr",
1303 - is_language_en: preferences.language == "en",
1304 - is_language_de: preferences.language == "de",
1305 - is_language_es: preferences.language == "es",
1306 - current_locale,
1307 - supported_languages,
1308 - nav_items,
1309 - has_pending_password_change: false,
1310 - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1311 - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1312 -
1313 - title: i18n_service.translate("preferences.title", locale_str).await,
1314 - subtitle: i18n_service.translate("preferences.subtitle", locale_str).await,
1315 - theme_label: i18n_service.translate("preferences.theme", locale_str).await,
1316 - theme_light: i18n_service.translate("preferences.theme_light", locale_str).await,
1317 - theme_light_desc: i18n_service.translate("preferences.theme_light_desc", locale_str).await,
1318 - theme_dark: i18n_service.translate("preferences.theme_dark", locale_str).await,
1319 - theme_dark_desc: i18n_service.translate("preferences.theme_dark_desc", locale_str).await,
1320 - theme_auto: i18n_service.translate("preferences.theme_auto", locale_str).await,
1321 - theme_auto_desc: i18n_service.translate("preferences.theme_auto_desc", locale_str).await,
1322 - theme_preview: i18n_service.translate("preferences.theme_preview", locale_str).await,
1323 - language_label: i18n_service.translate("preferences.language", locale_str).await,
1324 - language_fr: i18n_service.translate("preferences.language_fr", locale_str).await,
1325 - language_fr_desc: i18n_service.translate("preferences.language_fr_desc", locale_str).await,
1326 - language_en: i18n_service.translate("preferences.language_en", locale_str).await,
1327 - language_en_desc: i18n_service.translate("preferences.language_en_desc", locale_str).await,
1328 - language_de: i18n_service.translate("preferences.language_de", locale_str).await,
1329 - language_de_desc: i18n_service.translate("preferences.language_de_desc", locale_str).await,
1330 - language_es: i18n_service.translate("preferences.language_es", locale_str).await,
1331 - language_es_desc: i18n_service.translate("preferences.language_es_desc", locale_str).await,
1332 - translation_warning: i18n_service.translate("preferences.translation_warning", locale_str).await,
1333 - modified: i18n_service.translate("preferences.modified", locale_str).await,
1334 - modified_desc: i18n_service.translate("preferences.modified_desc", locale_str).await,
1335 - save_button,
1336 - saving: i18n_service.translate("preferences.saving", locale_str).await,
1337 - cancel: i18n_service.translate("preferences.cancel", locale_str).await,
1338 - saved_success,
1339 - saved_reload,
1340 - saved_reload_json,
1341 - saved_success_json,
1342 - error_prefix,
1343 - error_prefix_json,
1344 - network_error,
1345 - network_error_json,
1346 - save_button_json,
1347 - sync_title: i18n_service.translate("preferences.sync_title", locale_str).await,
1348 - sync_desc: i18n_service.translate("preferences.sync_desc", locale_str).await,
1349 - preview_dark: i18n_service.translate("preferences.preview_dark", locale_str).await,
1350 - preview_light: i18n_service.translate("preferences.preview_light", locale_str).await,
1351 - preview_auto: i18n_service.translate("preferences.preview_auto", locale_str).await,
1352 - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1353 - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
1354 - breadcrumb_preferences: i18n_service.translate("breadcrumb.preferences", locale_str).await,
1355 - };
1356 -
1357 - Ok(template)
1358 -}
A crates/rustwarden-ui/src/handlers/totp_handler.rs
+359 -0
@@ -0,0 +1,359 @@
1 +//! TOTP 2FA settings handlers
2 +//!
3 +//! Handlers for the /settings/security page where users can
4 +//! enable/disable TOTP two-factor authentication.
5 +
6 +use askama::Template;
7 +use axum::{
8 + extract::{Extension, State},
9 + response::IntoResponse,
10 + Json,
11 +};
12 +use sea_orm::DatabaseConnection;
13 +use serde::Deserialize;
14 +use std::sync::Arc;
15 +use tower_sessions::Session;
16 +
17 +use rustwarden_core::auth::jwt::Claims;
18 +use rustwarden_core::error::AppError;
19 +use rustwarden_core::i18n::extractor::Locale;
20 +use rustwarden_core::i18n::service::I18nService;
21 +use rustwarden_core::services::app_settings_service::AppSettingsService;
22 +use rustwarden_core::services::totp_service::TotpService;
23 +use rustwarden_core::services::user_service::UserService;
24 +
25 +use crate::dto::navigation_dto::NavigationDTO;
26 +use crate::dto::user_profile_dto::UserProfileDTO;
27 +use crate::template_helpers::csrf_token_helper;
28 +
29 +/// Simplified navigation item for templates
30 +#[derive(Clone)]
31 +#[allow(dead_code)]
32 +pub(crate) struct NavItem {
33 + id: String,
34 + label: String,
35 + icon: String,
36 + url: String,
37 + active: bool,
38 + enabled: bool,
39 + badge: String,
40 + has_badge: bool,
41 + disabled_message: String,
42 + has_disabled_message: bool,
43 +}
44 +
45 +/// Helper to build NavItems from NavigationDTO
46 +fn build_nav_items_from_dto(navigation: &NavigationDTO, active_id: &str) -> Vec<NavItem> {
47 + navigation
48 + .items
49 + .iter()
50 + .map(|item| NavItem {
51 + id: item.id.clone(),
52 + label: item.label.clone(),
53 + icon: item.icon.clone(),
54 + url: item.url.clone(),
55 + active: item.id == active_id,
56 + enabled: item.enabled,
57 + badge: item.badge.clone(),
58 + has_badge: item.has_badge,
59 + disabled_message: item.disabled_message.clone(),
60 + has_disabled_message: item.has_disabled_message,
61 + })
62 + .collect()
63 +}
64 +
65 +/// Security settings page template
66 +#[derive(Template)]
67 +#[template(path = "settings/security.html")]
68 +#[allow(dead_code)]
69 +struct SecurityTemplate {
70 + page_title: String,
71 + app_name: String,
72 + app_version: String,
73 + app_domain: String,
74 + app_description: String,
75 + csrf_token: String,
76 + username: String,
77 + email: String,
78 + roles_text: String,
79 + initials: String,
80 + avatar_color: String,
81 + current_locale: String,
82 + nav_items: Vec<NavItem>,
83 + user_menu_profile: String,
84 + user_menu_logout: String,
85 + // 2FA state
86 + totp_enabled: bool,
87 + backup_codes_count: i32,
88 + // Translated strings
89 + title: String,
90 + subtitle: String,
91 + totp_title: String,
92 + status_enabled: String,
93 + status_disabled: String,
94 + enabled_message: String,
95 + disabled_message: String,
96 + enable_button: String,
97 + disable_button: String,
98 + scan_qr: String,
99 + manual_entry: String,
100 + enter_code: String,
101 + code_placeholder: String,
102 + verify_button: String,
103 + backup_codes_title: String,
104 + backup_codes_warning: String,
105 + backup_codes_remaining: String,
106 + regenerate_button: String,
107 + regenerate_confirm: String,
108 + confirm_disable: String,
109 + password_label: String,
110 + cancel_button: String,
111 + copy_button: String,
112 + saved_codes_button: String,
113 + breadcrumb_home: String,
114 + breadcrumb_settings: String,
115 + breadcrumb_security: String,
116 + /// True when admin has forced 2FA and the user hasn't set it up yet
117 + force_2fa: bool,
118 + force_2fa_message: String,
119 + force_2fa_logout: String,
120 +}
121 +
122 +rustwarden_core::impl_template_response!(SecurityTemplate);
123 +
124 +/// GET /settings/security — Security settings page
125 +pub async fn security_page(
126 + Extension(claims): Extension<Claims>,
127 + State(db): State<DatabaseConnection>,
128 + Extension(i18n_service): Extension<Arc<I18nService>>,
129 + locale: Locale,
130 + session: Session,
131 +) -> Result<impl IntoResponse, AppError> {
132 + let user_model = UserService::get_user_by_username(&db, &claims.username)
133 + .await?
134 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
135 + let roles = UserService::get_user_roles(&db, user_model.id).await?;
136 + let user = UserProfileDTO::new(user_model, roles.clone());
137 +
138 + let locale_str = locale.as_str();
139 + let csrf_token = csrf_token_helper(&session).await;
140 +
141 + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
142 + let nav_items = build_nav_items_from_dto(&navigation, "settings");
143 +
144 + let app_domain = AppSettingsService::get_setting(&db, "app_domain")
145 + .await?
146 + .map(|s| s.value)
147 + .unwrap_or_else(|| "localhost".to_string());
148 + let app_description = AppSettingsService::get_setting(&db, "app_description")
149 + .await?
150 + .map(|s| s.value)
151 + .unwrap_or_else(|| "Secure password manager".to_string());
152 +
153 + // Check TOTP status
154 + let totp_info = TotpService::get_totp_info(&db, claims.sub).await?;
155 + let totp_enabled = totp_info.as_ref().is_some_and(|t| t.enabled);
156 +
157 + // Check if 2FA is forced globally
158 + let totp_force_enabled =
159 + AppSettingsService::get_bool_with_fallback(&db, &["totp_force_enabled"], false)
160 + .await
161 + .unwrap_or(false);
162 + let force_2fa = totp_force_enabled && !totp_enabled;
163 + let backup_codes_count = totp_info
164 + .as_ref()
165 + .map(|t| t.backup_codes_remaining)
166 + .unwrap_or(0);
167 +
168 + let layout = rustwarden_core::templates::LayoutContext::new("", "");
169 +
170 + let template = SecurityTemplate {
171 + page_title: i18n_service
172 + .translate("settings_security.page_title", locale_str)
173 + .await,
174 + app_name: layout.app_name.clone(),
175 + app_version: layout.app_version.clone(),
176 + app_domain,
177 + app_description,
178 + csrf_token,
179 + username: user.username.clone(),
180 + email: user.email.clone(),
181 + roles_text: user.roles.join(", "),
182 + initials: user.initials.clone(),
183 + avatar_color: user.avatar_color.clone(),
184 + current_locale: locale_str.to_string(),
185 + nav_items,
186 + user_menu_profile: i18n_service
187 + .translate("user_menu.profile", locale_str)
188 + .await,
189 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
190 + totp_enabled,
191 + backup_codes_count,
192 + title: i18n_service
193 + .translate("settings_security.title", locale_str)
194 + .await,
195 + subtitle: i18n_service
196 + .translate("settings_security.subtitle", locale_str)
197 + .await,
198 + totp_title: i18n_service
199 + .translate("settings_security.totp_title", locale_str)
200 + .await,
201 + status_enabled: i18n_service
202 + .translate("settings_security.status_enabled", locale_str)
203 + .await,
204 + status_disabled: i18n_service
205 + .translate("settings_security.status_disabled", locale_str)
206 + .await,
207 + enabled_message: i18n_service
208 + .translate("settings_security.enabled_message", locale_str)
209 + .await,
210 + disabled_message: i18n_service
211 + .translate("settings_security.disabled_message", locale_str)
212 + .await,
213 + enable_button: i18n_service
214 + .translate("settings_security.enable_button", locale_str)
215 + .await,
216 + disable_button: i18n_service
217 + .translate("settings_security.disable_button", locale_str)
218 + .await,
219 + scan_qr: i18n_service
220 + .translate("settings_security.scan_qr", locale_str)
221 + .await,
222 + manual_entry: i18n_service
223 + .translate("settings_security.manual_entry", locale_str)
224 + .await,
225 + enter_code: i18n_service
226 + .translate("settings_security.enter_code", locale_str)
227 + .await,
228 + code_placeholder: "000000".to_string(),
229 + verify_button: i18n_service
230 + .translate("settings_security.verify_button", locale_str)
231 + .await,
232 + backup_codes_title: i18n_service
233 + .translate("settings_security.backup_codes_title", locale_str)
234 + .await,
235 + backup_codes_warning: i18n_service
236 + .translate("settings_security.backup_codes_warning", locale_str)
237 + .await,
238 + backup_codes_remaining: i18n_service
239 + .translate("settings_security.backup_codes_remaining", locale_str)
240 + .await,
241 + regenerate_button: i18n_service
242 + .translate("settings_security.regenerate_button", locale_str)
243 + .await,
244 + regenerate_confirm: i18n_service
245 + .translate("settings_security.regenerate_confirm", locale_str)
246 + .await,
247 + confirm_disable: i18n_service
248 + .translate("settings_security.confirm_disable", locale_str)
249 + .await,
250 + password_label: i18n_service
251 + .translate("settings_security.password_label", locale_str)
252 + .await,
253 + cancel_button: i18n_service.translate("common.cancel", locale_str).await,
254 + copy_button: i18n_service
255 + .translate("settings_security.copy_button", locale_str)
256 + .await,
257 + saved_codes_button: i18n_service
258 + .translate("settings_security.saved_codes_button", locale_str)
259 + .await,
260 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
261 + breadcrumb_settings: i18n_service
262 + .translate("breadcrumb.settings", locale_str)
263 + .await,
264 + breadcrumb_security: i18n_service
265 + .translate("settings_security.breadcrumb", locale_str)
266 + .await,
267 + force_2fa,
268 + force_2fa_message: i18n_service
269 + .translate("settings_security.force_2fa_message", locale_str)
270 + .await,
271 + force_2fa_logout: i18n_service
272 + .translate("settings_security.force_2fa_logout", locale_str)
273 + .await,
274 + };
275 +
276 + Ok(template)
277 +}
278 +
279 +/// POST /settings/security/totp/enable — Start TOTP setup
280 +pub async fn enable_totp(
281 + Extension(claims): Extension<Claims>,
282 + State(db): State<DatabaseConnection>,
283 +) -> Result<impl IntoResponse, AppError> {
284 + let app_name = AppSettingsService::get_setting(&db, "app_name")
285 + .await?
286 + .map(|s| s.value)
287 + .unwrap_or_else(|| "RustWarden".to_string());
288 +
289 + let setup = TotpService::setup_totp(&db, claims.sub, &claims.username, &app_name).await?;
290 +
291 + Ok(Json(serde_json::json!({
292 + "secret_base32": setup.secret_base32,
293 + "otpauth_uri": setup.otpauth_uri,
294 + "qr_code_data_uri": setup.qr_code_data_uri,
295 + })))
296 +}
297 +
298 +/// Request body for verifying a TOTP code.
299 +#[derive(Deserialize)]
300 +pub struct VerifyTotpRequest {
301 + /// Six-digit TOTP code from the authenticator app.
302 + pub code: String,
303 +}
304 +
305 +/// POST /settings/security/totp/verify — Verify code and enable 2FA
306 +pub async fn verify_totp(
307 + Extension(claims): Extension<Claims>,
308 + State(db): State<DatabaseConnection>,
309 + Json(payload): Json<VerifyTotpRequest>,
310 +) -> Result<impl IntoResponse, AppError> {
311 + let backup_codes = TotpService::verify_and_enable(&db, claims.sub, &payload.code).await?;
312 +
313 + // Audit log
314 + tracing::info!(user_id = %claims.sub, "TOTP_ENABLED");
315 +
316 + Ok(Json(serde_json::json!({
317 + "backup_codes": backup_codes,
318 + })))
319 +}
320 +
321 +/// Request body for disabling TOTP two-factor authentication.
322 +#[derive(Deserialize)]
323 +pub struct DisableTotpRequest {
324 + /// Current account password, required to confirm the disable action.
325 + pub password: String,
326 +}
327 +
328 +/// POST /settings/security/totp/disable — Disable 2FA (requires password)
329 +pub async fn disable_totp(
330 + Extension(claims): Extension<Claims>,
331 + State(db): State<DatabaseConnection>,
332 + Json(payload): Json<DisableTotpRequest>,
333 +) -> Result<impl IntoResponse, AppError> {
334 + // Verify password first
335 + use rustwarden_core::services::auth_service::AuthService;
336 + AuthService::verify_credentials(&db, &claims.username, &payload.password).await?;
337 +
338 + TotpService::disable_totp(&db, claims.sub).await?;
339 +
340 + // Audit log
341 + tracing::info!(user_id = %claims.sub, "TOTP_DISABLED");
342 +
343 + Ok(Json(serde_json::json!({ "success": true })))
344 +}
345 +
346 +/// POST /settings/security/backup-codes — Regenerate backup codes
347 +pub async fn regenerate_backup_codes(
348 + Extension(claims): Extension<Claims>,
349 + State(db): State<DatabaseConnection>,
350 +) -> Result<impl IntoResponse, AppError> {
351 + let codes = TotpService::regenerate_backup_codes(&db, claims.sub).await?;
352 +
353 + // Audit log
354 + tracing::info!(user_id = %claims.sub, "BACKUP_CODES_REGENERATED");
355 +
356 + Ok(Json(serde_json::json!({
357 + "backup_codes": codes,
358 + })))
359 +}
M crates/rustwarden-ui/src/handlers/user_handler.rs
+24 -11
@@ -18,37 +18,52 @@ use rustwarden_core::services::{
18 18 /// Inline DTO: Create user request
19 19 #[derive(Debug, Deserialize, Validate)]
20 20 pub struct CreateUserRequest {
21 + /// Desired login name (3–50 characters).
21 22 #[validate(length(min = 3, max = 50))]
22 23 pub username: String,
24 + /// Valid email address for the new account.
23 25 #[validate(email)]
24 26 pub email: String,
27 + /// Initial password, or `None` to generate one automatically.
25 28 pub password: Option<String>,
29 + /// Roles to assign to the new user.
26 30 pub roles: Vec<String>,
27 31 }
28 32
29 33 /// Inline DTO: User response
30 34 #[derive(Debug, serde::Serialize)]
31 35 pub struct UserResponse {
36 + /// User unique identifier.
32 37 pub id: uuid::Uuid,
38 + /// User login name.
33 39 pub username: String,
40 + /// User email address.
34 41 pub email: String,
42 + /// Whether the user must change their password on next login.
35 43 pub must_change_password: bool,
44 + /// Roles assigned to the user.
36 45 pub roles: Vec<String>,
46 + /// Timestamp when the account was created.
37 47 pub created_at: chrono::DateTime<chrono::Utc>,
48 + /// Timestamp of the last account update.
38 49 pub updated_at: chrono::DateTime<chrono::Utc>,
39 50 }
40 51
41 52 /// Inline DTO: User list response
42 53 #[derive(Debug, serde::Serialize)]
43 54 pub struct UserListResponse {
55 + /// Total number of users matching the query.
44 56 pub total: usize,
57 + /// Page of user records.
45 58 pub users: Vec<UserResponse>,
46 59 }
47 60
48 61 /// Query parameters for user list/search
49 62 #[derive(Debug, Deserialize)]
50 63 pub struct UserListQuery {
64 + /// Optional full-text search filter.
51 65 pub search: Option<String>,
66 + /// Maximum number of results to return.
52 67 pub per_page: Option<u64>,
53 68 }
54 69
@@ -174,13 +189,14 @@ pub async fn create_user(
174 189 .clone()
175 190 .unwrap_or_else(rustwarden_core::config::app::default_app_name);
176 191
177 - let template = rustwarden_core::templates::email_templates::AdminNewUserTemplate::new(
178 - Some("fr".to_string()),
179 - user.username.clone(),
180 - user.email.clone(),
181 - registered_at,
182 - Some(app_name),
183 - );
192 + let template =
193 + rustwarden_core::templates::email_templates::AdminNewUserTemplate::new(
194 + Some("fr".to_string()),
195 + user.username.clone(),
196 + user.email.clone(),
197 + registered_at,
198 + Some(app_name),
199 + );
184 200
185 201 if let (Ok(body_html), body_text) =
186 202 (template.render_html(), template.render_text())
@@ -254,10 +270,7 @@ pub async fn list_users(
254 270 );
255 271
256 272 let limit = query.per_page.unwrap_or(10);
257 - let filtered_users = query_builder
258 - .all(&db)
259 - .await
260 - .map_err(AppError::Database)?;
273 + let filtered_users = query_builder.all(&db).await.map_err(AppError::Database)?;
261 274
262 275 filtered_users.into_iter().take(limit as usize).collect()
263 276 } else {
M crates/rustwarden-ui/src/lib.rs
+7 -1
@@ -3,10 +3,15 @@
3 3 //! Default HTML user interface for the Rustwarden framework.
4 4 //! Provides login pages, profile management, admin panel, and all associated routes.
5 5
6 +/// Data Transfer Objects for request/response handling.
6 7 pub mod dto;
8 +/// Custom Axum extractors (e.g. form with duplicate fields).
7 9 pub mod extractors;
10 +/// HTTP request handlers (login, admin, settings, etc.).
8 11 pub mod handlers;
12 +/// Route definitions and router construction.
9 13 pub mod routes;
14 +/// Shared Askama template helpers and base context.
10 15 pub mod template_helpers;
11 16
12 17 use axum::Router;
@@ -20,6 +25,7 @@ pub fn default_routes() -> Router<DatabaseConnection> {
20 25
21 26 /// Returns a static file service for serving CSS, JS, and images.
22 27 pub fn default_static_service() -> ServeDir {
23 - let static_dir = std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string());
28 + let static_dir =
29 + std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string());
24 30 ServeDir::new(static_dir)
25 31 }
M crates/rustwarden-ui/src/routes.rs
+44 -32
@@ -5,8 +5,9 @@ use axum::{
5 5 use sea_orm::DatabaseConnection;
6 6 use tower_http::services::ServeDir;
7 7
8 +use crate::handlers::settings::{preferences, profile, security};
8 9 use crate::handlers::{
9 - admin_handler, health, home_handler, login_handler, root_handler, settings_handler, user_handler,
10 + admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler,
10 11 };
11 12 use rustwarden_core::auth::handlers as auth_handlers;
12 13
@@ -37,7 +38,8 @@ pub fn create_router() -> Router<DatabaseConnection> {
37 38 .route(
38 39 "/api/v1/auth/resend-verification/by-username",
39 40 post(auth_handlers::resend_verification_by_username),
40 - );
41 + )
42 + .route("/api/v1/auth/2fa/verify", post(auth_handlers::verify_2fa));
41 43
42 44 let user_routes = Router::new()
43 45 .route("/api/v1/users", post(user_handler::create_user))
@@ -49,56 +51,65 @@ pub fn create_router() -> Router<DatabaseConnection> {
49 51 "/resend-verification",
50 52 get(login_handler::resend_verification_page),
51 53 )
52 - .route(
53 - "/reset-password",
54 - get(login_handler::reset_password_page),
55 - )
54 + .route("/reset-password", get(login_handler::reset_password_page))
56 55 .route(
57 56 "/confirm-password-change",
58 - get(settings_handler::confirm_password_change),
57 + get(security::confirm_password_change),
59 58 )
60 - .route("/health", get(health::health_check));
59 + .route("/health", get(health::health_check))
60 + .route("/login/2fa", get(login_handler::login_2fa_page));
61 61
62 62 let ui_routes = Router::new()
63 63 .route("/home", get(home_handler::home))
64 - .route("/profile", get(settings_handler::get_profile_page))
65 - .route("/profile", put(settings_handler::update_profile))
64 + .route("/profile", get(profile::get_profile_page))
65 + .route("/profile", put(profile::update_profile))
66 66 .route(
67 67 "/settings/password",
68 - get(settings_handler::get_password_page),
69 - )
70 - .route(
71 - "/settings/password",
72 - put(settings_handler::change_password),
68 + get(security::get_password_page),
73 69 )
70 + .route("/settings/password", put(security::change_password))
74 71 .route(
75 72 "/settings/password/pending",
76 - delete(settings_handler::cancel_pending_password_change),
73 + delete(security::cancel_pending_password_change),
77 74 )
78 75 .route(
79 76 "/settings/sessions",
80 - get(settings_handler::get_sessions_page),
77 + get(security::get_sessions_page),
81 78 )
82 79 .route(
83 80 "/settings/sessions/{id}",
84 - delete(settings_handler::revoke_session),
81 + delete(security::revoke_session),
85 82 )
86 83 .route(
87 84 "/settings/preferences",
88 - get(settings_handler::get_preferences_page),
85 + get(preferences::get_preferences_page),
89 86 )
90 87 .route(
91 88 "/settings/preferences",
92 - put(settings_handler::update_preferences),
89 + put(preferences::update_preferences),
90 + )
91 + .route("/settings/security", get(totp_handler::security_page))
92 + .route(
93 + "/settings/security/totp/enable",
94 + post(totp_handler::enable_totp),
95 + )
96 + .route(
97 + "/settings/security/totp/verify",
98 + post(totp_handler::verify_totp),
99 + )
100 + .route(
101 + "/settings/security/totp/disable",
102 + post(totp_handler::disable_totp),
103 + )
104 + .route(
105 + "/settings/security/backup-codes",
106 + post(totp_handler::regenerate_backup_codes),
93 107 );
94 108
95 109 let admin_routes = Router::new()
96 110 .route("/admin", get(admin_handler::admin_dashboard))
97 111 .route("/admin/users", get(admin_handler::list_users))
98 - .route(
99 - "/admin/users/create",
100 - get(admin_handler::create_user_form),
101 - )
112 + .route("/admin/users/create", get(admin_handler::create_user_form))
102 113 .route("/admin/users", post(admin_handler::create_user))
103 114 .route("/admin/users/{id}", get(admin_handler::edit_user_form))
104 115 .route("/admin/users/{id}", put(admin_handler::update_user))
@@ -112,21 +123,21 @@ pub fn create_router() -> Router<DatabaseConnection> {
112 123 post(admin_handler::admin_reset_password_by_email),
113 124 )
114 125 .route("/admin/roles", get(admin_handler::list_roles))
115 - .route(
116 - "/admin/roles/create",
117 - get(admin_handler::create_role_form),
118 - )
126 + .route("/admin/roles/create", get(admin_handler::create_role_form))
119 127 .route("/admin/roles", post(admin_handler::create_role))
120 128 .route("/admin/roles/{id}", get(admin_handler::edit_role_form))
121 129 .route("/admin/roles/{id}", put(admin_handler::update_role))
122 130 .route("/admin/roles/{id}", delete(admin_handler::delete_role))
123 131 .route("/admin/settings", get(admin_handler::get_app_settings))
124 132 .route("/admin/settings", put(admin_handler::update_app_settings))
125 - .route("/admin/errors", get(admin_handler::admin_errors));
133 + .route("/admin/errors", get(admin_handler::admin_errors))
134 + .route(
135 + "/admin/users/{id}/reset-totp",
136 + post(admin_handler::reset_user_totp),
137 + );
126 138
127 - let static_dir = std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| {
128 - "static".to_string()
129 - });
139 + let static_dir =
140 + std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string());
130 141
131 142 tracing::info!("Serving static files from: {}", static_dir);
132 143 let static_service = ServeDir::new(&static_dir);
@@ -141,6 +152,7 @@ pub fn create_router() -> Router<DatabaseConnection> {
141 152 .nest_service("/static", static_service)
142 153 }
143 154
155 +/// Returns a minimal router with only the health-check route, used in tests.
144 156 #[allow(dead_code)]
145 157 pub fn create_test_router() -> Router {
146 158 Router::new().route("/health", get(health::health_check))
M crates/rustwarden-ui/src/template_helpers.rs
+13 -4
@@ -2,7 +2,6 @@
2 2 //!
3 3 //! Provides helper functions for generating template contexts and data structures.
4 4
5 -
6 5 use sea_orm::DatabaseConnection;
7 6 use std::sync::Arc;
8 7 use tower_sessions::Session;
@@ -11,24 +10,34 @@ use crate::dto::{
11 10 navigation_dto::{NavigationDTO, NavigationItemDTO},
12 11 BreadcrumbItem, SidebarItem, SidebarSection,
13 12 };
14 -use rustwarden_core::middleware::csrf;
15 13 use rustwarden_core::auth::jwt::Claims;
16 14 use rustwarden_core::error::AppError;
17 15 use rustwarden_core::i18n::extractor::Locale;
18 16 use rustwarden_core::i18n::service::I18nService;
17 +use rustwarden_core::middleware::csrf;
19 18
20 19 /// Base template context containing common data for all pages
21 20 #[derive(Debug, Clone)]
22 21 pub struct BaseTemplateContext {
22 + /// Title shown in the browser tab and page header.
23 23 pub page_title: String,
24 + /// Application display name from settings.
24 25 pub app_name: String,
26 + /// Application version string.
25 27 pub app_version: String,
28 + /// Public domain of the application.
26 29 pub app_domain: String,
30 + /// Short description of the application.
27 31 pub app_description: String,
32 + /// Authenticated user's login name.
28 33 pub username: String,
34 + /// Authenticated user's email address.
29 35 pub email: String,
36 + /// Two-letter initials derived from the username for the avatar.
30 37 pub initials: String,
38 + /// CSS color class used for the user's avatar background.
31 39 pub avatar_color: String,
40 + /// Roles assigned to the authenticated user.
32 41 pub roles: Vec<String>,
33 42 }
34 43
@@ -271,8 +280,8 @@ pub fn generate_default_sidebar(
271 280 let mut info_section = SidebarSection::new("Informations");
272 281
273 282 // User info item (display only)
274 - info_section = info_section
275 - .with_item(SidebarItem::info(username, "👤").with_badge(initials.to_string()));
283 + info_section =
284 + info_section.with_item(SidebarItem::info(username, "👤").with_badge(initials.to_string()));
276 285
277 286 // Email info item
278 287 info_section = info_section.with_item(SidebarItem::info(email, "✉️"));
M crates/rustwarden-ui/templates/admin/settings.html
+26 -0
@@ -274,6 +274,32 @@
274 274 </div>
275 275 </div>
276 276
277 + <!-- Two-Factor Authentication Card -->
278 + <div class="card bg-base-100 shadow-xl">
279 + <div class="card-body">
280 + <h2 class="card-title text-xl mb-4">
281 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
282 + Authentification à deux facteurs
283 + </h2>
284 +
285 + <div class="form-control">
286 + <label class="label cursor-pointer justify-start gap-4">
287 + <input
288 + type="checkbox"
289 + name="totp_force_enabled"
290 + value="true"
291 + class="toggle toggle-primary"
292 + {% if settings.totp_force_enabled == "true" %}checked{% endif %}
293 + />
294 + <div>
295 + <span class="label-text font-semibold">Forcer le 2FA pour tous les utilisateurs</span>
296 + <p class="text-xs text-base-content/60 mt-1">Lorsqu'activé, tous les utilisateurs devront configurer l'authentification à deux facteurs avant de pouvoir accéder à l'application.</p>
297 + </div>
298 + </label>
299 + </div>
300 + </div>
301 + </div>
302 +
277 303 <!-- Audit Logging Card -->
278 304 <div class="card bg-base-100 shadow-xl">
279 305 <div class="card-body">
M crates/rustwarden-ui/templates/admin/users_list.html
+10 -0
@@ -107,6 +107,7 @@
107 107 <th>{{ table_username }}</th>
108 108 <th>{{ table_email }}</th>
109 109 <th>{{ table_roles }}</th>
110 + <th>2FA</th>
110 111 <th>{{ table_created_at }}</th>
111 112 <th>{{ table_last_login }}</th>
112 113 <th>{{ table_actions }}</th>
@@ -129,6 +130,15 @@
129 130 </div>
130 131 </td>
131 132 <td>
133 + {% if user.totp_enabled %}
134 + <span class="badge badge-success gap-1">
135 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
136 + </span>
137 + {% else %}
138 + <span class="badge badge-ghost text-xs">-</span>
139 + {% endif %}
140 + </td>
141 + <td>
132 142 <div class="text-sm">{{ user.created_at }}</div>
133 143 </td>
134 144 <td>
M crates/rustwarden-ui/templates/login.html
+27 -7
@@ -98,17 +98,17 @@
98 98 id="login-form"
99 99 class="space-y-4">
100 100
101 - <!-- Username -->
101 + <!-- Username or Email -->
102 102 <div class="form-control">
103 103 <label for="username" class="label">
104 104 <span class="label-text">{{ username_label }}</span>
105 105 </label>
106 - <input
107 - type="text"
108 - id="username"
109 - name="username"
106 + <input
107 + type="text"
108 + id="username"
109 + name="username"
110 110 required
111 - autocomplete="username"
111 + autocomplete="username email"
112 112 class="input input-bordered w-full"
113 113 placeholder="{{ username_placeholder }}">
114 114 </div>
@@ -334,7 +334,13 @@
334 334 if (xhr.status === 200) {
335 335 // Login successful
336 336 const response = JSON.parse(xhr.responseText);
337 -
337 +
338 + // Check if 2FA is required
339 + if (response.requires_2fa && response.challenge_token) {
340 + window.location.href = '/login/2fa?challenge=' + encodeURIComponent(response.challenge_token);
341 + return;
342 + }
343 +
338 344 // Hide error message if visible
339 345 const errorDiv = document.getElementById('error-message');
340 346 if (errorDiv) {
@@ -417,6 +423,12 @@
417 423 }
418 424 })
419 425 .then(data => {
426 + // Check if 2FA is required
427 + if (data.requires_2fa && data.challenge_token) {
428 + window.location.href = '/login/2fa?challenge=' + encodeURIComponent(data.challenge_token);
429 + return;
430 + }
431 +
420 432 // Login successful
421 433 const successDiv = document.getElementById('success-message');
422 434 if (successDiv) {
@@ -447,6 +459,14 @@
447 459 return;
448 460 }
449 461
462 + // Check if user must setup 2FA
463 + if (data.user && data.user.must_setup_2fa === true) {
464 + setTimeout(() => {
465 + window.location.href = '/settings/security?force_2fa=true';
466 + }, 500);
467 + return;
468 + }
469 +
450 470 // SEC-H2: safe redirect after login
451 471 setTimeout(() => {
452 472 window.location.href = getSafeRedirect();
A crates/rustwarden-ui/templates/login_2fa.html
+123 -0
@@ -0,0 +1,123 @@
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>{{ page_title }}</title>
7 + <link href="/static/css/tailwind.css" rel="stylesheet">
8 + <link href="/static/css/custom.css" rel="stylesheet">
9 + <script src="/static/js/alpine.min.js" defer></script>
10 +</head>
11 +<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="card bg-base-100 shadow-xl w-full max-w-md">
14 + <div class="card-body">
15 + <div class="text-center mb-6">
16 + <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-primary mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
17 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
18 + </svg>
19 + <h1 class="text-2xl font-bold">{{ title }}</h1>
20 + <p class="text-base-content/70 mt-1">{{ subtitle }}</p>
21 + </div>
22 +
23 + <!-- TOTP Code Input -->
24 + <div x-show="!useBackupCode">
25 + <label class="label"><span class="label-text font-semibold">{{ code_label }}</span></label>
26 + <input
27 + type="text"
28 + x-model="code"
29 + maxlength="6"
30 + pattern="[0-9]*"
31 + inputmode="numeric"
32 + autocomplete="one-time-code"
33 + class="input input-bordered w-full text-center text-2xl tracking-widest"
34 + placeholder="000000"
35 + @keyup.enter="verify()"
36 + autofocus
37 + />
38 + </div>
39 +
40 + <!-- Backup Code Input -->
41 + <div x-show="useBackupCode" x-cloak>
42 + <label class="label"><span class="label-text font-semibold">{{ backup_code_label }}</span></label>
43 + <input
44 + type="text"
45 + x-model="code"
46 + maxlength="8"
47 + class="input input-bordered w-full text-center text-xl tracking-widest font-mono"
48 + placeholder="ABCD1234"
49 + @keyup.enter="verify()"
50 + />
51 + </div>
52 +
53 + <!-- Error message -->
54 + <p x-show="error" class="text-error text-sm mt-2" x-text="error"></p>
55 +
56 + <!-- Submit button -->
57 + <button class="btn btn-primary w-full mt-4" @click="verify()" :disabled="!code || verifying">
58 + <span x-show="!verifying">{{ verify_button }}</span>
59 + <span x-show="verifying" class="loading loading-spinner loading-sm"></span>
60 + </button>
61 +
62 + <!-- Toggle backup code -->
63 + <div class="text-center mt-4">
64 + <button class="btn btn-ghost btn-sm" @click="useBackupCode = !useBackupCode; code = '';">
65 + <span x-show="!useBackupCode">{{ use_backup_code }}</span>
66 + <span x-show="useBackupCode">{{ use_totp_code }}</span>
67 + </button>
68 + </div>
69 +
70 + <!-- Back to login -->
71 + <div class="text-center mt-2">
72 + <a href="/login" class="link link-hover text-sm">{{ back_to_login }}</a>
73 + </div>
74 + </div>
75 + </div>
76 + </div>
77 +
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 +</body>
123 +</html>
M crates/rustwarden-ui/templates/profile.html
+4 -0
@@ -127,6 +127,10 @@
127 127 <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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
128 128 Préférences d'affichage
129 129 </a>
130 + <a href="/settings/security" class="btn btn-outline btn-warning">
131 + <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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
132 + Sécurité 2FA
133 + </a>
130 134 </div>
131 135 </div>
132 136
M crates/rustwarden-ui/templates/settings/preferences.html
+40 -0
@@ -226,6 +226,46 @@
226 226 </label>
227 227 </div>
228 228
229 + <div class="form-control">
230 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
231 + <input
232 + type="radio"
233 + name="language"
234 + value="it"
235 + class="radio radio-primary"
236 + form="preferencesForm"
237 + {% if is_language_it %}checked{% endif %}
238 + >
239 + <div class="flex-1">
240 + <div class="flex items-center gap-2">
241 + <span class="text-2xl">🇮🇹</span>
242 + <span class="label-text font-semibold">{{ language_it }}</span>
243 + </div>
244 + <span class="label-text-alt text-base-content/70">{{ language_it_desc }}</span>
245 + </div>
246 + </label>
247 + </div>
248 +
249 + <div class="form-control">
250 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
251 + <input
252 + type="radio"
253 + name="language"
254 + value="pt"
255 + class="radio radio-primary"
256 + form="preferencesForm"
257 + {% if is_language_pt %}checked{% endif %}
258 + >
259 + <div class="flex-1">
260 + <div class="flex items-center gap-2">
261 + <span class="text-2xl">🇧🇷</span>
262 + <span class="label-text font-semibold">{{ language_pt }}</span>
263 + </div>
264 + <span class="label-text-alt text-base-content/70">{{ language_pt_desc }}</span>
265 + </div>
266 + </label>
267 + </div>
268 +
229 269 <div class="alert alert-warning">
230 270 <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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
231 271 <span class="text-xs">{{ translation_warning }}</span>
A crates/rustwarden-ui/templates/settings/security.html
+304 -0
@@ -0,0 +1,304 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto max-w-4xl px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/home">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/settings/preferences">{{ breadcrumb_settings }}</a></li>
12 + <li>{{ breadcrumb_security }}</li>
13 + </ul>
14 + </div>
15 +
16 + {% if force_2fa %}
17 + <div class="alert alert-warning mb-6 shadow-lg">
18 + <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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
19 + <div class="flex-1">
20 + <span>{{ force_2fa_message }}</span>
21 + </div>
22 + <button class="btn btn-sm btn-ghost"
23 + onclick="localStorage.removeItem('refresh_token'); localStorage.removeItem('remember_me'); localStorage.removeItem('jwt_expires_in'); fetch('/api/v1/auth/logout', {method:'POST', credentials:'include'}).then(()=>window.location.href='/login');">
24 + {{ force_2fa_logout }}
25 + </button>
26 + </div>
27 + {% endif %}
28 +
29 + <div class="flex items-center gap-3 mb-8">
30 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
31 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
32 + </svg>
33 + <div>
34 + <h1 class="text-2xl font-bold">{{ title }}</h1>
35 + <p class="text-base-content/70">{{ subtitle }}</p>
36 + </div>
37 + </div>
38 +
39 + <div x-data="securityPage()" class="space-y-6">
40 + <!-- Status Card -->
41 + <div class="card bg-base-100 shadow-lg">
42 + <div class="card-body">
43 + <div class="flex items-center justify-between">
44 + <div class="flex items-center gap-3">
45 + <h2 class="card-title">{{ totp_title }}</h2>
46 + {% if totp_enabled %}
47 + <span class="badge badge-success gap-1">
48 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
49 + {{ status_enabled }}
50 + </span>
51 + {% else %}
52 + <span class="badge badge-warning gap-1">
53 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" /></svg>
54 + {{ status_disabled }}
55 + </span>
56 + {% endif %}
57 + </div>
58 + </div>
59 +
60 + {% if totp_enabled %}
61 + <!-- Enabled state -->
62 + <div class="mt-4 space-y-4">
63 + <div class="alert alert-success">
64 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
65 + <span>{{ enabled_message }}</span>
66 + </div>
67 + <p class="text-sm text-base-content/70">{{ backup_codes_remaining }}: <strong>{{ backup_codes_count }}</strong></p>
68 + <div class="flex gap-3">
69 + <button class="btn btn-outline btn-sm" @click="regenerateBackupCodes()">
70 + <span x-show="!regenerating">{{ regenerate_button }}</span>
71 + <span x-show="regenerating" class="loading loading-spinner loading-sm"></span>
72 + </button>
73 + <button class="btn btn-error btn-outline btn-sm" @click="showDisableModal = true">{{ disable_button }}</button>
74 + </div>
75 + </div>
76 + {% else %}
77 + <!-- Disabled state -->
78 + <div class="mt-4">
79 + <p class="text-base-content/70 mb-4">{{ disabled_message }}</p>
80 + <button class="btn btn-primary" @click="startSetup()" x-show="!setupStarted">
81 + {{ enable_button }}
82 + </button>
83 + </div>
84 + {% endif %}
85 +
86 + <!-- Setup flow (shown when user clicks Enable) -->
87 + <div x-show="setupStarted && !setupComplete" x-cloak class="mt-6 space-y-6">
88 + <div class="divider"></div>
89 +
90 + <!-- QR Code -->
91 + <div class="text-center" x-show="qrCode">
92 + <p class="font-semibold mb-3">{{ scan_qr }}</p>
93 + <div class="flex justify-center mb-4">
94 + <img :src="qrCode" alt="QR Code" class="w-48 h-48 rounded-lg border border-base-300 p-2 bg-white" />
95 + </div>
96 + <div class="collapse collapse-arrow bg-base-200 max-w-md mx-auto">
97 + <input type="checkbox" />
98 + <div class="collapse-title text-sm font-medium">{{ manual_entry }}</div>
99 + <div class="collapse-content">
100 + <code class="text-xs break-all select-all" x-text="secretBase32"></code>
101 + </div>
102 + </div>
103 + </div>
104 +
105 + <!-- Verification code input -->
106 + <div class="max-w-sm mx-auto" x-show="qrCode">
107 + <label class="label"><span class="label-text font-semibold">{{ enter_code }}</span></label>
108 + <div class="join w-full">
109 + <input
110 + type="text"
111 + x-model="verifyCode"
112 + maxlength="6"
113 + pattern="[0-9]*"
114 + inputmode="numeric"
115 + autocomplete="one-time-code"
116 + class="input input-bordered join-item flex-1 text-center text-2xl tracking-widest"
117 + placeholder="{{ code_placeholder }}"
118 + @keyup.enter="verifyAndEnable()"
119 + />
120 + <button class="btn btn-primary join-item" @click="verifyAndEnable()" :disabled="verifyCode.length !== 6 || verifying">
121 + <span x-show="!verifying">{{ verify_button }}</span>
122 + <span x-show="verifying" class="loading loading-spinner loading-sm"></span>
123 + </button>
124 + </div>
125 + <p x-show="setupError" class="text-error text-sm mt-2" x-text="setupError"></p>
126 + </div>
127 + </div>
128 + </div>
129 + </div>
130 +
131 + <!-- Backup Codes Modal -->
132 + <dialog id="backupCodesModal" class="modal" :class="{ 'modal-open': showBackupCodes }">
133 + <div class="modal-box max-w-lg">
134 + <h3 class="font-bold text-lg mb-2">{{ backup_codes_title }}</h3>
135 + <div class="alert alert-warning mb-4">
136 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" /></svg>
137 + <span class="text-sm">{{ backup_codes_warning }}</span>
138 + </div>
139 + <div class="grid grid-cols-2 gap-2 mb-4">
140 + <template x-for="code in backupCodes" :key="code">
141 + <code class="bg-base-200 p-2 rounded text-center font-mono text-sm select-all" x-text="code"></code>
142 + </template>
143 + </div>
144 + <div class="flex gap-2">
145 + <button class="btn btn-sm btn-outline" @click="copyBackupCodes()">
146 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
147 + {{ copy_button }}
148 + </button>
149 + </div>
150 + <div class="modal-action">
151 + <button class="btn btn-primary" @click="showBackupCodes = false; window.location.reload();">{{ saved_codes_button }}</button>
152 + </div>
153 + </div>
154 + <form method="dialog" class="modal-backdrop" @click="showBackupCodes = false; window.location.reload();">
155 + <button>close</button>
156 + </form>
157 + </dialog>
158 +
159 + <!-- Disable 2FA Modal -->
160 + <dialog id="disableModal" class="modal" :class="{ 'modal-open': showDisableModal }">
161 + <div class="modal-box">
162 + <h3 class="font-bold text-lg mb-4">{{ confirm_disable }}</h3>
163 + <div class="form-control">
164 + <label class="label"><span class="label-text">{{ password_label }}</span></label>
165 + <input type="password" x-model="disablePassword" class="input input-bordered" @keyup.enter="disableTotp()" />
166 + </div>
167 + <p x-show="disableError" class="text-error text-sm mt-2" x-text="disableError"></p>
168 + <div class="modal-action">
169 + <button class="btn" @click="showDisableModal = false; disablePassword = ''; disableError = '';">{{ cancel_button }}</button>
170 + <button class="btn btn-error" @click="disableTotp()" :disabled="!disablePassword || disabling">
171 + <span x-show="!disabling">{{ disable_button }}</span>
172 + <span x-show="disabling" class="loading loading-spinner loading-sm"></span>
173 + </button>
174 + </div>
175 + </div>
176 + <form method="dialog" class="modal-backdrop" @click="showDisableModal = false;">
177 + <button>close</button>
178 + </form>
179 + </dialog>
180 + </div>
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 +{% endblock %}
A docs/BACKLOG_I18N_LOCALE.md
+115 -0
@@ -0,0 +1,115 @@
1 +# Backlog — Propagation de la locale utilisateur
2 +
3 +> **Status** : ticket différé, à planifier comme chantier dédié.
4 +> **Origine** : audit anti-slop post-AddTeams (24 avril 2026) — 9 `TODO` recensés,
5 +> tous pré-existants (aucun introduit par la branche AddTeams).
6 +>
7 +> **Objectif** : remplacer les locales hardcodées `Some("fr".to_string())` par la
8 +> locale réelle de l'utilisateur (préférence DB → cookie → `Accept-Language` → défaut).
9 +
10 +---
11 +
12 +## 1. Contexte
13 +
14 +Plusieurs services envoient des emails transactionnels en utilisant des templates i18n
15 +(`EmailValidationTemplate`, `PasswordResetEmailTemplate`, `WelcomeEmailTemplate`,
16 +`PasswordChangeConfirmationEmailTemplate`). Aujourd'hui, la locale passée au template
17 +est **hardcodée à `"fr"`** avec un `TODO`. Conséquence : un utilisateur qui a choisi
18 +`en` dans ses préférences reçoit malgré tout un mail en français.
19 +
20 +Par ailleurs, l'extracteur Axum `Locale` n'implémente qu'une partie de la priorité
21 +prévue (cookie + `Accept-Language`) — la lecture de la préférence DB reste à faire.
22 +
23 +## 2. Inventaire des TODO (9)
24 +
25 +| # | Fichier | Ligne | Contenu | Impact |
26 +|---|---|---|---|---|
27 +| 1 | [crates/rustwarden-core/src/i18n/extractor.rs](../crates/rustwarden-core/src/i18n/extractor.rs) | 57 | `T009 - Implement full locale extraction logic` | L'extracteur `Locale` ne lit jamais `user_preferences.preferred_locale`. Priorité 1 (source de vérité). |
28 +| 2 | [crates/rustwarden-core/src/services/email_validation_service.rs](../crates/rustwarden-core/src/services/email_validation_service.rs) | 100 | `Get from user preferences or Accept-Language header` | Email de validation d'adresse toujours en `fr`. |
29 +| 3 | [crates/rustwarden-core/src/services/email_validation_service.rs](../crates/rustwarden-core/src/services/email_validation_service.rs) | 220 | `Get from user preferences` | Email de bienvenue toujours en `fr`. |
30 +| 4 | [crates/rustwarden-core/src/services/password_reset_service.rs](../crates/rustwarden-core/src/services/password_reset_service.rs) | 115 | `Get from user preferences or Accept-Language header` | Email de reset mot de passe toujours en `fr`. |
31 +| 5 | [crates/rustwarden-core/src/tasks/password_change_email_retry.rs](../crates/rustwarden-core/src/tasks/password_change_email_retry.rs) | 197 | `Get from user preferences` | Email de confirmation de changement mot de passe (retry) toujours en `fr`. |
32 +| 6 | [crates/rustwarden-core/src/services/refresh_token_service.rs](../crates/rustwarden-core/src/services/refresh_token_service.rs) | 548 | `Store user_agent in refresh_token table` | Champ `device_info` toujours `None` dans la liste des sessions actives. |
33 +| 7 | [crates/rustwarden-core/src/services/refresh_token_service.rs](../crates/rustwarden-core/src/services/refresh_token_service.rs) | 549 | `Store IP in refresh_token table` | Champ `ip_address` toujours `None`. |
34 +| 8 | [crates/rustwarden-core/src/services/refresh_token_service.rs](../crates/rustwarden-core/src/services/refresh_token_service.rs) | 590 | `Blacklist associated JWT if applicable` | La révocation d'une session ne blackliste pas le JWT. |
35 +| 9 | [crates/rustwarden-ui/src/handlers/email_handler.rs](../crates/rustwarden-ui/src/handlers/email_handler.rs) | 102 | `Get total count for pagination (requires separate query)` | La pagination de la file d'emails retourne `emails.len()` au lieu d'un `COUNT(*)`. |
36 +
37 +Les items #6, #7, #8 et #9 ne sont pas strictement i18n mais font partie du même
38 +backlog "dette technique pré-existante" ; on les garde groupés ici pour un chantier
39 +unique "propagation métadonnées requête → services".
40 +
41 +## 3. Ébauche de solution
42 +
43 +### 3.1 Couche extracteur (item #1)
44 +
45 +Enrichir [`Locale::from_request_parts`](../crates/rustwarden-core/src/i18n/extractor.rs)
46 +pour lire la préférence DB quand un `Claims` est disponible :
47 +
48 +```text
49 +priorité :
50 + 1. user_preferences.preferred_locale (si Claims présent + DB accessible via Extension<DatabaseConnection>)
51 + 2. cookie `locale`
52 + 3. Accept-Language header
53 + 4. "fr"
54 +```
55 +
56 +Contrainte : `from_request_parts` ne peut pas faire de requête DB sans extracteur
57 +d'état. Deux options :
58 +- **(A)** garder `Locale` sans DB et ajouter un helper `Locale::resolve(claims, db, headers)` appelable dans les services.
59 +- **(B)** créer un `Extractor` supplémentaire `UserLocale` qui dépend de `AuthUser` + `Extension<DatabaseConnection>`.
60 +
61 +Préférence à discuter lors du chantier : option (A) pour garder `Locale` purement
62 +HTTP, et ajouter un petit service `LocaleResolver` injecté dans les services emails.
63 +
64 +### 3.2 Services emails (items #2, #3, #4, #5)
65 +
66 +Remplacer `Some("fr".to_string())` par un paramètre `locale: Option<String>` passé
67 +par l'appelant. Les callers sont :
68 +
69 +- `EmailValidationService::send_validation_email` — appelé après `POST /register` : on connaît l'utilisateur, on peut lire `user_preferences`.
70 +- `PasswordResetService::request_reset` — appelé depuis `/forgot-password` (anonyme) : fallback sur `Accept-Language` depuis le handler.
71 +- Task `PasswordChangeEmailRetryProcessor` — appelé périodiquement : doit lire `user_preferences` du user_id stocké en queue.
72 +
73 +Signature cible (exemple) :
74 +
75 +```rust
76 +pub async fn send_validation_email(
77 + &self,
78 + db: &DatabaseConnection,
79 + user: &user::Model,
80 + locale: Option<String>, // déjà résolu par l'appelant
81 +) -> Result<(), AppError>
82 +```
83 +
84 +### 3.3 Stockage métadonnées session (items #6, #7, #8)
85 +
86 +- Migration : ajouter `user_agent VARCHAR(512)` et `ip_address VARCHAR(45)` à `refresh_token`.
87 +- Handler `/login` : extraire `User-Agent` + IP (`x-forwarded-for` ou `ConnectInfo`) et passer à `RefreshTokenService::create`.
88 +- `revoke_session` : si `refresh_token.jwt_id` existe, appeler `JwtBlacklistService::blacklist`.
89 +
90 +### 3.4 Pagination emails (item #9)
91 +
92 +Ajouter une requête `COUNT(*)` dans `email_handler::list` et retourner le vrai total.
93 +
94 +## 4. Critères d'acceptation
95 +
96 +- [ ] Tous les `TODO` listés supprimés (ou déplacés vers un backlog plus précis)
97 +- [ ] Un utilisateur avec `preferred_locale = "en"` reçoit tous les emails en anglais
98 +- [ ] `ActiveSession.device_info` et `ActiveSession.ip_address` renseignés pour les nouvelles sessions
99 +- [ ] Révoquer une session blackliste le JWT associé (testé via `cargo test`)
100 +- [ ] Pagination `/admin/emails` retourne un total correct même avec offset > len
101 +
102 +## 5. Estimation
103 +
104 +- Refacto locale + tests : ~1 j
105 +- Migration `refresh_token` + wiring login + blacklist : ~0.5 j
106 +- Pagination emails : ~1 h
107 +- Relecture PO + QA navigateur : ~0.5 j
108 +
109 +Total ≈ 2-3 jours de dev.
110 +
111 +## 6. Hors-scope
112 +
113 +- Ajout de locales (IT/PT déjà ajoutés dans la branche AddTeams)
114 +- UI de sélection de langue (déjà existante dans `settings/preferences`)
115 +- Conversion des logs / messages d'erreur internes (reste en anglais par convention tech)
A docs/CHANGELOG_FORCE_2FA.md
+233 -0
@@ -0,0 +1,233 @@
1 +# Guide d'intégration — Forçage du 2FA
2 +
3 +**Branche** : `2FA_it_pt`
4 +**Date** : 2026-04-09
5 +**Public cible** : développeurs de projets consommateurs (avec ou sans `rustwarden-ui`)
6 +
7 +---
8 +
9 +## Sommaire
10 +
11 +1. [Contexte](#1-contexte)
12 +2. [Fonctionnement](#2-fonctionnement)
13 +3. [Impact sur l'API de login](#3-impact-sur-lapi-de-login)
14 +4. [Middleware de blocage](#4-middleware-de-blocage)
15 +5. [Interface utilisateur (rustwarden-ui)](#5-interface-utilisateur-rustwarden-ui)
16 +6. [Checklist d'intégration — projets avec UI custom](#6-checklist-dintégration--projets-avec-ui-custom)
17 +
18 +---
19 +
20 +## 1. Contexte
21 +
22 +Le setting `totp_force_enabled` (configurable via `/admin/settings`) existait déjà en base de données mais n'avait aucun effet concret. Cette mise à jour câble la logique de bout en bout : quand un administrateur active le forçage du 2FA, **tout utilisateur sans TOTP configuré** est contraint de l'activer avant de pouvoir naviguer dans l'application.
23 +
24 +Le comportement s'inspire du pattern `must_change_password` déjà en place : l'utilisateur peut se connecter, mais toute navigation est redirigée vers la page de configuration du 2FA.
25 +
26 +---
27 +
28 +## 2. Fonctionnement
29 +
30 +### Flux utilisateur
31 +
32 +```
33 +Utilisateur se connecte (username/email + mot de passe)
34 + │
35 + ▼
36 + Le 2FA est-il activé pour cet utilisateur ?
37 + │
38 + ┌────┴────┐
39 + │ OUI │ NON
40 + ▼ ▼
41 + Flow 2FA totp_force_enabled est-il true ?
42 + classique │
43 + ┌───┴───┐
44 + │ OUI │ NON
45 + ▼ ▼
46 + Réponse JSON avec Login normal
47 + must_setup_2fa:true
48 + │
49 + ▼
50 + Redirection vers /settings/security?force_2fa=true
51 + │
52 + ▼
53 + ┌──────────────────────────────────┐
54 + │ Bandeau d'alerte : │
55 + │ "Vous devez configurer le 2FA" │
56 + │ [Se déconnecter] │
57 + └──────────────────────────────────┘
58 + │
59 + L'utilisateur configure le 2FA
60 + │
61 + ▼
62 + Navigation normale débloquée
63 +```
64 +
65 +### Principes
66 +
67 +- L'utilisateur **peut se connecter** (le JWT est émis normalement)
68 +- L'utilisateur **ne peut pas naviguer** — toute route protégée redirige vers `/settings/security`
69 +- L'utilisateur **peut se déconnecter** s'il ne souhaite pas configurer le 2FA immédiatement
70 +- Dès que le TOTP est activé, la navigation est immédiatement débloquée
71 +
72 +---
73 +
74 +## 3. Impact sur l'API de login
75 +
76 +### Nouveau champ dans la réponse `POST /api/v1/auth/login`
77 +
78 +Le DTO `UserInfo` retourné dans `LoginResponse` contient un nouveau champ optionnel :
79 +
80 +```json
81 +{
82 + "access_token": "eyJ...",
83 + "refresh_token": "550e...",
84 + "token_type": "Bearer",
85 + "expires_in": 900,
86 + "user": {
87 + "id": "...",
88 + "username": "john_doe",
89 + "email": "john@example.com",
90 + "roles": ["user"],
91 + "must_change_password": false,
92 + "must_setup_2fa": true
93 + }
94 +}
95 +```
96 +
97 +| Champ | Type | Présence | Description |
98 +|-------|------|----------|-------------|
99 +| `must_setup_2fa` | `bool` | Optionnel | Présent et `true` uniquement quand `totp_force_enabled` est activé en base ET que l'utilisateur n'a pas de TOTP configuré. Absent sinon. |
100 +
101 +**Note** : ce champ n'apparaît jamais dans la réponse de `POST /api/v1/auth/2fa/verify` (si l'utilisateur passe le 2FA, il l'a déjà configuré).
102 +
103 +---
104 +
105 +## 4. Middleware de blocage
106 +
107 +### Routes protégées
108 +
109 +Le middleware `AuthService` (couche Tower) protège désormais les préfixes suivants :
110 +
111 +```
112 +/home, /profile, /test-base, /settings/*, /admin/*
113 +```
114 +
115 +(Auparavant seuls `/home`, `/profile`, `/test-base` étaient interceptés.)
116 +
117 +### Logique de redirection
118 +
119 +Pour chaque requête sur une route protégée, après validation du JWT :
120 +
121 +1. **Check `must_change_password`** → redirige vers `/settings/password?must_change=true` (inchangé)
122 +2. **Check `totp_force_enabled`** (nouveau) :
123 + - Si le setting `totp_force_enabled` est `true` en base
124 + - ET que l'utilisateur n'a pas de TOTP activé (`TotpService::is_totp_enabled` retourne `false`)
125 + - ET que la route **n'est pas** `/settings/security*` ni `/api/v1/auth/*`
126 + - → Redirection `302` vers `/settings/security?force_2fa=true`
127 +
128 +### Routes exemptées (accessibles même sans 2FA)
129 +
130 +| Route | Raison |
131 +|-------|--------|
132 +| `/settings/security*` | Page de configuration du 2FA (et ses endpoints TOTP) |
133 +| `/api/v1/auth/*` | Login, logout, refresh, 2FA verify |
134 +| `/login*` | Pages publiques |
135 +| `/static/*` | Assets statiques |
136 +
137 +---
138 +
139 +## 5. Interface utilisateur (rustwarden-ui)
140 +
141 +### Page de login (`login.html`)
142 +
143 +Après un login réussi, le JavaScript vérifie `data.user.must_setup_2fa` :
144 +
145 +```javascript
146 +if (data.user && data.user.must_setup_2fa === true) {
147 + setTimeout(() => {
148 + window.location.href = '/settings/security?force_2fa=true';
149 + }, 500);
150 + return;
151 +}
152 +```
153 +
154 +Ce check est placé **après** le check `must_change_password` et **avant** la redirection normale.
155 +
156 +### Page de sécurité (`settings/security.html`)
157 +
158 +Quand `force_2fa=true` :
159 +
160 +- Un **bandeau d'alerte warning** s'affiche en haut de page avec un message expliquant pourquoi l'utilisateur est redirigé ici
161 +- Le bandeau contient un **bouton "Se déconnecter"** qui permet à l'utilisateur de quitter sans configurer le 2FA
162 +- Le bouton effectue un `POST /api/v1/auth/logout` puis redirige vers `/login`
163 +
164 +Le message et le bouton sont traduits dans les 6 langues supportées (fr, en, de, es, it, pt).
165 +
166 +### Nouvelles clés i18n
167 +
168 +| Clé | Description |
169 +|-----|-------------|
170 +| `settings_security.force_2fa_message` | Message du bandeau d'alerte expliquant le forçage |
171 +| `settings_security.force_2fa_logout` | Label du bouton de déconnexion dans le bandeau |
172 +
173 +#### Traductions
174 +
175 +| Langue | `force_2fa_message` | `force_2fa_logout` |
176 +|--------|--------------------|--------------------|
177 +| FR | L'administrateur exige l'activation de l'authentification à deux facteurs. Vous devez configurer le 2FA avant de pouvoir accéder au reste de l'application. | Se déconnecter |
178 +| EN | Your administrator requires two-factor authentication. You must set up 2FA before you can access the rest of the application. | Sign out |
179 +| DE | Ihr Administrator verlangt die Zwei-Faktor-Authentifizierung. Sie müssen 2FA einrichten, bevor Sie auf den Rest der Anwendung zugreifen können. | Abmelden |
180 +| ES | Su administrador requiere la autenticación de dos factores. Debe configurar 2FA antes de poder acceder al resto de la aplicación. | Cerrar sesión |
181 +| IT | L'amministratore richiede l'autenticazione a due fattori. Devi configurare il 2FA prima di poter accedere al resto dell'applicazione. | Disconnettersi |
182 +| PT | O administrador exige a autenticação de dois fatores. Você deve configurar o 2FA antes de poder acessar o restante da aplicação. | Sair |
183 +
184 +---
185 +
186 +## 6. Checklist d'intégration — projets avec UI custom
187 +
188 +Si votre projet **n'utilise pas `rustwarden-ui`**, vous devez implémenter ces comportements dans votre frontend :
189 +
190 +### Obligatoire
191 +
192 +- [ ] **Réponse login** : après `POST /api/v1/auth/login`, vérifier si `user.must_setup_2fa === true` dans la réponse JSON
193 +- [ ] **Redirection** : si `must_setup_2fa` est `true`, rediriger l'utilisateur vers votre page de configuration 2FA (appels à `/settings/security/totp/enable` puis `/settings/security/totp/verify`)
194 +- [ ] **Blocage navigation** : le middleware backend redirige toutes les routes protégées vers `/settings/security?force_2fa=true` — votre frontend doit gérer cette redirection (soit via une page dédiée, soit en interceptant le `302`)
195 +- [ ] **Déconnexion** : offrir à l'utilisateur la possibilité de se déconnecter (`POST /api/v1/auth/logout`) plutôt que de configurer le 2FA immédiatement
196 +
197 +### Optionnel
198 +
199 +- [ ] **Bandeau d'alerte** : afficher un message expliquant pourquoi la navigation est bloquée
200 +- [ ] **Traductions** : si votre app est multilingue, traduire le message de forçage
201 +
202 +### Pour les projets utilisant rustwarden-ui
203 +
204 +- [ ] Rien à faire — tout est intégré (bandeau, redirection, bouton de déconnexion, traductions)
205 +
206 +---
207 +
208 +## Fichiers modifiés
209 +
210 +| Fichier | Modification |
211 +|---------|-------------|
212 +| `crates/rustwarden-core/src/auth/handlers.rs` | Champ `must_setup_2fa` dans `UserInfo` + check `totp_force_enabled` au login |
213 +| `crates/rustwarden-core/src/middleware/auth_service.rs` | Routes protégées élargies (`/settings`, `/admin`) + redirection 2FA forcé |
214 +| `crates/rustwarden-ui/src/handlers/totp_handler.rs` | Champs `force_2fa`, `force_2fa_message`, `force_2fa_logout` dans le template |
215 +| `crates/rustwarden-ui/templates/settings/security.html` | Bandeau d'alerte conditionnel avec bouton déconnexion |
216 +| `crates/rustwarden-ui/templates/login.html` | Redirection `must_setup_2fa` après login |
217 +| `crates/rustwarden-ui/locales/{fr,en,de,es,it,pt}.yml` | Clés `force_2fa_message` et `force_2fa_logout` |
218 +
219 +---
220 +
221 +## Questions fréquentes
222 +
223 +**Q : Un administrateur est-il aussi soumis au forçage du 2FA ?**
224 +Oui. Le setting `totp_force_enabled` s'applique à tous les utilisateurs sans distinction de rôle. Un administrateur sans TOTP sera redirigé vers `/settings/security` comme les autres.
225 +
226 +**Q : Que se passe-t-il si l'admin désactive `totp_force_enabled` après coup ?**
227 +Les utilisateurs qui n'ont pas configuré le 2FA retrouvent immédiatement une navigation normale. Ceux qui l'avaient déjà configuré gardent leur 2FA actif (il n'est pas désactivé automatiquement).
228 +
229 +**Q : L'utilisateur peut-il naviguer vers `/api/v1/auth/logout` pour se déconnecter ?**
230 +Oui. Toutes les routes sous `/api/v1/auth/*` sont exemptées du blocage, y compris le logout.
231 +
232 +**Q : Le check `totp_force_enabled` ajoute-t-il une requête BDD à chaque page ?**
233 +Oui, une requête `app_settings` + une requête `user_totp` sont exécutées par le middleware pour chaque route protégée quand le setting est activé. Ces requêtes sont légères (lookup par clé primaire). Si la performance est critique, un cache en mémoire peut être envisagé ultérieurement.
A docs/CHANGELOG_SETTINGS_SPLIT.md
+85 -0
@@ -0,0 +1,85 @@
1 +# Settings handler split — breaking change
2 +
3 +> **Date** : 2026-04-24
4 +> **Scope** : `rustwarden-ui` — reorganisation interne des handlers `/settings/*`
5 +> **Comportement runtime** : inchange (memes routes HTTP, memes responses)
6 +> **Breaking change** : oui, pour les apps consommatrices qui importent directement des symboles de `rustwarden_ui::handlers::settings_handler`
7 +
8 +## Contexte
9 +
10 +Le fichier `crates/rustwarden-ui/src/handlers/settings_handler.rs` grossit de facon continue (profil utilisateur, changement de mot de passe, sessions actives, preferences, 2FA...). Apres la branche `AddTeams`, il atteignait ~1673 lignes — difficile a relire, et chaque evolution touchait un fichier monolithique.
11 +
12 +Cette modification repartit les handlers par **domaine fonctionnel** dans un sous-module :
13 +
14 +```
15 +crates/rustwarden-ui/src/handlers/
16 +├── settings/
17 +│ ├── mod.rs # NavItem, build_nav_items_from_dto, filters Askama partages
18 +│ ├── profile.rs # profil utilisateur (username, email, avatar)
19 +│ ├── security.rs # mot de passe, sessions actives, entree 2FA
20 +│ └── preferences.rs # locale, theme, notifications
21 +```
22 +
23 +Le fichier `settings_handler.rs` est supprime.
24 +
25 +## Migration des imports
26 +
27 +Les apps consommatrices qui importent directement des symboles doivent adapter leurs `use`.
28 +
29 +| Symbole | Ancien chemin | Nouveau chemin |
30 +|---------|---------------|----------------|
31 +| `update_profile` | `rustwarden_ui::handlers::settings_handler::update_profile` | `rustwarden_ui::handlers::settings::profile::update_profile` |
32 +| `get_profile_page` | `rustwarden_ui::handlers::settings_handler::get_profile_page` | `rustwarden_ui::handlers::settings::profile::get_profile_page` |
33 +| `UpdateProfileRequest` | `rustwarden_ui::handlers::settings_handler::UpdateProfileRequest` | `rustwarden_ui::handlers::settings::profile::UpdateProfileRequest` |
34 +| `ProfileTemplate` | `rustwarden_ui::handlers::settings_handler::ProfileTemplate` | `rustwarden_ui::handlers::settings::profile::ProfileTemplate` |
35 +| `change_password` | `rustwarden_ui::handlers::settings_handler::change_password` | `rustwarden_ui::handlers::settings::security::change_password` |
36 +| `confirm_password_change` | `rustwarden_ui::handlers::settings_handler::confirm_password_change` | `rustwarden_ui::handlers::settings::security::confirm_password_change` |
37 +| `cancel_pending_password_change` | `rustwarden_ui::handlers::settings_handler::cancel_pending_password_change` | `rustwarden_ui::handlers::settings::security::cancel_pending_password_change` |
38 +| `list_sessions` | `rustwarden_ui::handlers::settings_handler::list_sessions` | `rustwarden_ui::handlers::settings::security::list_sessions` |
39 +| `revoke_session` | `rustwarden_ui::handlers::settings_handler::revoke_session` | `rustwarden_ui::handlers::settings::security::revoke_session` |
40 +| `get_password_page` | `rustwarden_ui::handlers::settings_handler::get_password_page` | `rustwarden_ui::handlers::settings::security::get_password_page` |
41 +| `get_sessions_page` | `rustwarden_ui::handlers::settings_handler::get_sessions_page` | `rustwarden_ui::handlers::settings::security::get_sessions_page` |
42 +| `ChangePasswordRequest` | `rustwarden_ui::handlers::settings_handler::ChangePasswordRequest` | `rustwarden_ui::handlers::settings::security::ChangePasswordRequest` |
43 +| `ChangePasswordResponse` | `rustwarden_ui::handlers::settings_handler::ChangePasswordResponse` | `rustwarden_ui::handlers::settings::security::ChangePasswordResponse` |
44 +| `ActiveSessionsResponse` | `rustwarden_ui::handlers::settings_handler::ActiveSessionsResponse` | `rustwarden_ui::handlers::settings::security::ActiveSessionsResponse` |
45 +| `PasswordPageQuery` | `rustwarden_ui::handlers::settings_handler::PasswordPageQuery` | `rustwarden_ui::handlers::settings::security::PasswordPageQuery` |
46 +| `ConfirmPasswordChangeQuery` | `rustwarden_ui::handlers::settings_handler::ConfirmPasswordChangeQuery` | `rustwarden_ui::handlers::settings::security::ConfirmPasswordChangeQuery` |
47 +| `PasswordTemplate` | `rustwarden_ui::handlers::settings_handler::PasswordTemplate` | `rustwarden_ui::handlers::settings::security::PasswordTemplate` |
48 +| `SessionsTemplate` | `rustwarden_ui::handlers::settings_handler::SessionsTemplate` | `rustwarden_ui::handlers::settings::security::SessionsTemplate` |
49 +| `get_preferences` | `rustwarden_ui::handlers::settings_handler::get_preferences` | `rustwarden_ui::handlers::settings::preferences::get_preferences` |
50 +| `update_preferences` | `rustwarden_ui::handlers::settings_handler::update_preferences` | `rustwarden_ui::handlers::settings::preferences::update_preferences` |
51 +| `get_preferences_page` | `rustwarden_ui::handlers::settings_handler::get_preferences_page` | `rustwarden_ui::handlers::settings::preferences::get_preferences_page` |
52 +| `UpdatePreferencesRequest` | `rustwarden_ui::handlers::settings_handler::UpdatePreferencesRequest` | `rustwarden_ui::handlers::settings::preferences::UpdatePreferencesRequest` |
53 +| `PreferencesTemplate` | `rustwarden_ui::handlers::settings_handler::PreferencesTemplate` | `rustwarden_ui::handlers::settings::preferences::PreferencesTemplate` |
54 +
55 +## Ce qui n'a PAS change
56 +
57 +- **Les routes HTTP** : `/profile`, `/settings/password`, `/settings/sessions`, `/settings/preferences`, `/settings/security/*` sont toutes preservees, memes methodes, memes responses.
58 +- **Les templates Askama** : `templates/settings/*.html` sont inchanges.
59 +- **Le comportement** : aucune logique metier modifiee, aucune regression fonctionnelle attendue. Tests existants passent tels quels.
60 +- **Les handlers TOTP / 2FA** : deja dans `handlers/totp_handler.rs`, hors du champ de cette reorganisation.
61 +
62 +## Comment adapter
63 +
64 +Si votre app consommatrice n'importe **aucun** symbole de `settings_handler` directement (cas le plus frequent, vous utilisez juste `rustwarden_ui::default_routes`), **il n'y a rien a faire**. Le serveur continue de servir les memes pages.
65 +
66 +Sinon, recherche-remplace global dans votre code :
67 +
68 +```
69 +settings_handler:: → settings::profile:: # pour profil
70 +settings_handler:: → settings::security:: # pour password/sessions
71 +settings_handler:: → settings::preferences:: # pour theme/locale
72 +```
73 +
74 +Ou bien, pour un filet de compatibilite local temporaire dans votre app :
75 +
76 +```rust
77 +// Compat shim local — a retirer une fois tous vos imports adaptes
78 +pub use rustwarden_ui::handlers::settings::preferences;
79 +pub use rustwarden_ui::handlers::settings::profile;
80 +pub use rustwarden_ui::handlers::settings::security;
81 +```
82 +
83 +## Pourquoi ce split maintenant
84 +
85 +Decide pendant la resolution de dette technique post-audit `AddTeams` (voir [docs/plans/2026-04-24-post-audit-addteams-debt.md](plans/2026-04-24-post-audit-addteams-debt.md)). Le fichier monolithique etait identifie comme dette prioritaire : chaque nouvelle fonctionnalite (2FA, Teams, preferences etendues) l'agrandissait sans que les domaines soient clairement separes. Le Product Owner a retenu l'option "Un sous-module par section (dossier)" pour preparer les futures extensions (notifications, webhooks...) sans toucher le meme fichier.
A docs/RUSTWARDEN_EVOLUTION_PROCESS.md
+434 -0
@@ -0,0 +1,434 @@
1 +# Rustwarden-core — Processus generique d'evolution du framework
2 +
3 +Ce document definit une methodologie **agnostique de l'application consommatrice** pour proposer, evaluer, livrer et retirer des changements sur le framework `rustwarden-core`. Il s'adresse a la fois aux mainteneurs upstream et aux apps downstream (gitrust aujourd'hui, d'autres a venir).
4 +
5 +**Non-buts** :
6 +- Ce n'est pas un catalogue des APIs actuelles (cf `README.md` du framework).
7 +- Ce n'est pas le backlog des improvements specifiques a gitrust (cf [`UPSTREAM_RUSTWARDEN.md`](UPSTREAM_RUSTWARDEN.md)).
8 +- Ce n'est pas un guide utilisateur (cf `docs/SERVICES_REFERENCE.md`).
9 +
10 +Le document vit dans gitrust parce que c'est actuellement le seul consommateur, mais son contenu doit rester generique. Quand un deuxieme consommateur apparait, ce fichier doit migrer dans le depot du framework (ou ses copies restent synchronisees via un simple `git diff`).
11 +
12 +---
13 +
14 +## 1. Principes directeurs
15 +
16 +1. **SemVer strict** : tout changement d'API publique respecte `MAJOR.MINOR.PATCH`. Un renommage public, un ajout de parametre obligatoire, une modification du comportement par defaut sont **breaking** meme si le code compile.
17 +2. **Backwards-compat par defaut** : quand deux solutions techniques sont equivalentes, on choisit celle qui preserve les signatures existantes. Une nouvelle signature vit a cote de l'ancienne, l'ancienne est `#[deprecated]` sur au moins 3 mineures avant suppression.
18 +3. **Lowest common denominator** : le framework ne doit pas embarquer de logique specifique a une app. Si une feature n'a qu'un seul consommateur identifie, elle ne rentre pas dans le framework — elle reste dans l'app ou dans une extension.
19 +4. **Zero-surprise** : un changement qui laisse silencieusement casser un flux existant (ex: token vide qui genere 403 sans erreur lisible) est bloquant jusqu'a clarification.
20 +5. **Test en tant que documentation** : tout nouvel API publique est livree avec un exemple dans la rustdoc, execute comme doctest.
21 +6. **Multi-tenant neutre** : le framework ne suppose ni une app SSR, ni une app SPA, ni une app API-only. Chaque API doit soit fonctionner dans les 3 contextes, soit etre clairement scopee (ex: feature flag).
22 +
23 +---
24 +
25 +## 2. Quand une RFC est obligatoire
26 +
27 +Une RFC (Request For Comments — un document formalise suivant le template section 3) est **obligatoire** pour tout changement qui remplit au moins un critere :
28 +
29 +- [x] Ajout, modification ou suppression d'un item `pub` exporte par le crate.
30 +- [x] Changement d'un comportement par defaut (y compris backwards-compat, car les consommateurs peuvent dependre du defaut).
31 +- [x] Ajout d'une nouvelle dependance directe dans `Cargo.toml`.
32 +- [x] Introduction d'un nouveau middleware, service ou trait que plusieurs apps vont consommer.
33 +- [x] Changement du schema DB ou ajout d'une migration.
34 +- [x] Modification du format serialise (cookies, JWT claims, events JSON, metadata).
35 +- [x] Changement de la politique de securite (ex: nouveau hash, nouveau flow d'auth, nouveau mode CSRF).
36 +
37 +**Pas de RFC** (PR direct suffit) :
38 +
39 +- Fix d'un bug sans changement d'API.
40 +- Mise a jour de la doc ou du CHANGELOG.
41 +- Refactor interne preservant toutes les signatures publiques.
42 +- Bump de version d'une dependance en patch/minor.
43 +- Ajout de test.
44 +
45 +En cas de doute : ecrire la RFC. Le cout d'une RFC prematuree est tres faible ; le cout d'un breaking change accidentel est tres eleve.
46 +
47 +---
48 +
49 +## 3. Template RFC
50 +
51 +Chaque proposition est un fichier unique `docs/rfcs/NNNN-slug.md` dans le depot du framework. `NNNN` est un numero incremental (0001, 0002, ...), `slug` est un identifiant court kebab-case.
52 +
53 +```markdown
54 +# RFC NNNN — <Titre court>
55 +
56 +- **Auteur** : <nom + repo d'origine>
57 +- **Date** : YYYY-MM-DD
58 +- **Statut** : Draft | Under Review | Accepted | Rejected | Superseded by #MMMM
59 +- **Apps consommatrices concernees** : <liste des apps qui beneficient directement>
60 +
61 +## Summary (1 paragraphe)
62 +
63 +Que propose-t-on ? En une phrase qu'un dev non-specialiste doit pouvoir comprendre.
64 +
65 +## Motivation
66 +
67 +- Quel probleme concret cette RFC resout-elle ?
68 +- Qui est impacte aujourd'hui (apps consommatrices, cas d'usage) ?
69 +- Quelles sont les consequences si on ne fait rien ?
70 +- Y a-t-il un delai/urgence (incident securite, deadline upstream) ?
71 +
72 +## Non-goals (explicite)
73 +
74 +Lister ce qui est **hors scope** pour eviter le scope creep. Ex: "cette RFC ne touche pas au flow OAuth2, qui est traite dans la RFC #XXXX".
75 +
76 +## API proposee
77 +
78 +Code Rust complet des nouvelles signatures publiques. Exemple d'utilisation cote consommateur (copie/collable dans un handler/service d'une app fictive).
79 +
80 +```rust
81 +// Signature complete
82 +pub fn new_api(...) -> ... { ... }
83 +
84 +// Exemple d'usage
85 +async fn handler(...) -> Response {
86 + let result = new_api(...)?;
87 + // ...
88 +}
89 +```
90 +
91 +## Analyse de compatibilite
92 +
93 +Checklist :
94 +
95 +- [ ] API existante non modifiee → **non-breaking** (patch/minor possible).
96 +- [ ] API existante etendue avec defaut → **non-breaking** (minor).
97 +- [ ] API existante modifiee ou supprimee → **breaking** (major requis + guide migration).
98 +- [ ] Nouveau behavior par defaut → evaluer l'impact sur les consommateurs, feature-flagger si risque.
99 +- [ ] Changement de schema DB → prevoir une migration reversible.
100 +- [ ] Changement de format serialise → prevoir un migrateur de donnees persistantes existantes.
101 +
102 +## Alternatives considerees
103 +
104 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ca la RFC est incomplete : on ne peut pas juger une proposition sans son cout relatif.
105 +
106 +### Alternative A (recommandee) : ...
107 +**Pros** : ...
108 +**Cons** : ...
109 +
110 +### Alternative B : ...
111 +**Pros** : ...
112 +**Cons** : ...
113 +
114 +### Alternative C (ne rien faire / garder le workaround) : ...
115 +**Pros** : zero cout, zero risque.
116 +**Cons** : dette accumulee dans les apps consommatrices, risque de divergence.
117 +
118 +## Testing plan
119 +
120 +Tests unitaires obligatoires :
121 +- Happy path (au moins 1).
122 +- 3 cas d'erreur minimum (input invalide, contrainte violee, timeout/panic path).
123 +
124 +Tests d'integration si la RFC touche :
125 +- Un service : test avec DB in-memory.
126 +- Un middleware : test avec client axum fake.
127 +- Une migration : test up/down avec verif des donnees existantes.
128 +
129 +Tests de non-regression :
130 +- Code consommateur pre-RFC doit encore compiler sans warning.
131 +
132 +Benchs si sensitive-perf :
133 +- Comparaison avant/apres.
134 +- Regression > 10% bloque le merge sans justification ecrite.
135 +
136 +## Migration guide
137 +
138 +Section a copier telle quelle dans le `CHANGELOG.md` au moment du merge. Format :
139 +
140 +```markdown
141 +### API changes
142 +
143 +**Before (vX.Y.Z)** :
144 +```rust
145 +old_api(foo, bar);
146 +```
147 +
148 +**After (vX'.Y'.Z')** :
149 +```rust
150 +new_api(foo, bar, NewConfig::default());
151 +```
152 +
153 +### Migration steps
154 +
155 +1. Bump `Cargo.toml` a vX'.Y'.Z'.
156 +2. `cargo fix --edition-idioms` handles most of it.
157 +3. Manual: pour chaque appel a `old_api`, decider si `NewConfig::default()` convient.
158 +```
159 +
160 +## Open questions
161 +
162 +Liste des points en suspens. La RFC ne peut pas etre acceptee tant qu'ils ne sont pas resolus :
163 +
164 +- [ ] Question 1
165 +- [ ] Question 2
166 +
167 +## Tracking issue
168 +
169 +Lien vers l'issue GitHub ou la RFC est discutee.
170 +```
171 +
172 +---
173 +
174 +## 4. Grille d'evaluation
175 +
176 +Une fois la RFC deposee, elle est notee sur 7 criteres (1-5 chacun, 35 max). Minimum **25/35** pour passer en "Accepted" :
177 +
178 +| Critere | 1 (mauvais) | 3 (neutre) | 5 (excellent) |
179 +|---------|-------------|------------|---------------|
180 +| **Generalite** | Sert 1 seul consommateur, logique app-specifique | Sert au moins 1 consommateur et anticipe >=1 autre | Resout un besoin architectural commun a toute app d'auth |
181 +| **Stabilite API** | Breaking change necessaire | Non-breaking avec feature flag | Non-breaking par ajout pur |
182 +| **Performance** | Overhead mesure > 5% sur hot path | Overhead imperceptible | Amelioration mesurable |
183 +| **Securite** | Regression possible sans mitigation | Neutre vs l'existant | Hardening net (fix faille, defense-in-depth) |
184 +| **Complexite impl** | > 500 lignes, requiert unsafe/refactor majeur | < 200 lignes, patterns existants reutilises | < 50 lignes, trivial |
185 +| **Charge maintenance** | Dependance externe non maintenue, cas edge nombreux | Normale (1 mainteneur peut gerer) | Zero : pure composition de briques stables |
186 +| **Documentation** | Pas d'exemple, pas de rustdoc, pas de CHANGELOG | Rustdoc + exemple inline | Rustdoc + exemple + entree CHANGELOG + guide migration |
187 +
188 +Un score < 25 n'est pas automatiquement un rejet : c'est un signal qu'il faut reecrire la RFC ou choisir une alternative. Un score >= 30 peut accelerer le merge sans second avis.
189 +
190 +---
191 +
192 +## 5. Strategie de versioning
193 +
194 +### Feature flags pour APIs experimentales
195 +
196 +Toute nouvelle API introduite pour un besoin emergent (ex: seul 1 consommateur identifie) vit derriere un feature flag Cargo :
197 +
198 +```toml
199 +# rustwarden-core/Cargo.toml
200 +[features]
201 +default = ["core"]
202 +core = []
203 +experimental-ssr-forms = [] # CSRF form body parsing
204 +experimental-oauth-github = [] # Provider GitHub OAuth
205 +```
206 +
207 +Cycle de vie d'un feature flag :
208 +
209 +1. **Birth (minor release)** : la feature sort en `experimental-*`, non incluse par defaut. Les consommateurs opt-in dans leur `Cargo.toml`.
210 +2. **Adoption** : suivi dans le CHANGELOG — "X apps ont active `experimental-ssr-forms`".
211 +3. **Promotion (minor release)** : apres **2+ apps adoptantes** et **30 jours sans bug critique**, la feature est promue en `default`. Les deux noms (`experimental-*` et nom final) coexistent pendant 1 minor pour la migration.
212 +4. **Cleanup (major release)** : le flag `experimental-*` est supprime.
213 +
214 +Pas de feature pour quelque chose que tout le monde voudrait : directement en default.
215 +
216 +### Depreciation
217 +
218 +```rust
219 +#[deprecated(
220 + since = "0.8.0",
221 + note = "use `csrf_protection_with_config` instead (accepts form body). Will be removed in 1.0."
222 +)]
223 +pub async fn csrf_protection(...) -> ... { ... }
224 +```
225 +
226 +Regles :
227 +
228 +- **Duree minimale** : N mineures depuis la depreciation (N >= 3). Ex : deprecie en 0.8.0 → supprimable a partir de 1.1.0 minimum.
229 +- **Note obligatoire** : pointer explicitement vers le remplacement avec exemple.
230 +- **CHANGELOG** : entree "Deprecated" dans chaque release entre depreciation et suppression.
231 +- **Tests** : les tests de l'ancienne API continuent de tourner jusqu'a suppression (avec `#[allow(deprecated)]`).
232 +
233 +### Breaking changes
234 +
235 +Aggreges dans une **release major uniquement** :
236 +
237 +- Pas de breaking dans une patch (`x.y.Z+1`).
238 +- Pas de breaking dans une minor (`x.Y+1.0`).
239 +- Une seule major par 6-12 mois, regroupant plusieurs depreciations echues + nouveaux defauts.
240 +
241 +Chaque major est accompagnee d'un guide migration exhaustif (fichier `MIGRATION-VX.md`) avec tous les changements et leurs remediations.
242 +
243 +---
244 +
245 +## 6. Garanties de compatibilite
246 +
247 +### Stables (part du contrat public)
248 +
249 +- Tous les items `pub` exportes par le `lib.rs` du crate.
250 +- Signatures des handlers (`services/*::*`).
251 +- Colonnes et types des tables de migration (ajout de colonne optionnel OK, changement ou suppression = breaking).
252 +- Format serialise des cookies de session, JWT claims, events JSON.
253 +- Noms des variantes d'erreur (kind `AppError::NotFound` par exemple).
254 +- Codes HTTP retournes par les handlers "happy path".
255 +
256 +### Non-stables (peuvent changer en minor)
257 +
258 +- Items exportes dans `pub(crate)` ou derriere `pub` mais dans des modules `internal/*`, `utils/*`, `impl/*`.
259 +- Format des logs `tracing` (tags, niveaux).
260 +- Ordre des champs dans les erreurs (mais pas leur presence).
261 +- Parametres par defaut non documentes comme API publique.
262 +
263 +**Quand c'est ambigu** : le merge suppose que c'est stable. Un refactor pretendu "interne" mais visible par les consommateurs compte comme breaking.
264 +
265 +---
266 +
267 +## 7. Exigences de tests
268 +
269 +Chaque PR implementant une RFC doit livrer :
270 +
271 +| Categorie | Obligatoire ? | Contenu minimum |
272 +|-----------|---------------|-----------------|
273 +| **Unit** | Oui | Happy path + 3 cas d'erreur par fonction publique |
274 +| **Doctest** | Oui | Au moins 1 exemple copie/collable par item publique |
275 +| **Integration** | Si touche service/middleware | Test avec DB in-memory (sea-orm mock ou sqlite) ou client axum fake |
276 +| **Compat** | Si touche API existante | Le code consommateur pre-RFC compile sans warning nouveau |
277 +| **Bench** | Si touche hot path | Comparison avant/apres, regression > 10% = justification ecrite |
278 +| **Fuzz** | Si touche parsing/deserialisation | `cargo fuzz` round pendant 10 min sur l'input exterieur |
279 +
280 +Les consommateurs (gitrust, autres) peuvent etre invites a tourner leur propre CI contre la version RC du framework avant merge — c'est un "compat test" tiers gratuit.
281 +
282 +---
283 +
284 +## 8. Exigences de documentation
285 +
286 +Tout merge inclut :
287 +
288 +1. **Rustdoc** sur chaque nouvel item public, avec exemple testable.
289 +2. **Entree CHANGELOG.md** dans la categorie correcte (`Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`).
290 +3. **Mise a jour du `README.md`** si la feature etend les cas d'usage documentes.
291 +4. **Guide de migration** (dans le CHANGELOG ou fichier dedie) si la feature remplace une API existante.
292 +5. **Exemple dans `examples/`** si la feature introduit un pattern non-trivial (middleware, service compose, flow async).
293 +
294 +Pour une app consommatrice, le reflet de ce merge est :
295 +- Bump de version dans son `Cargo.toml`.
296 +- Mise a jour de son propre fichier `UPSTREAM_<framework>.md` (ajout de la section "resolu upstream").
297 +- Eventuelle mise a jour des workarounds internes.
298 +
299 +---
300 +
301 +## 9. Processus de release
302 +
303 +### Cadence
304 +
305 +| Type | Frequence | Contenu |
306 +|------|-----------|---------|
307 +| **Patch** `x.y.Z+1` | A la demande (bug ou securite) | Fixes sans API change, pas de nouvelle feature |
308 +| **Minor** `x.Y+1.0` | Toutes les 2 semaines | Features non-breaking accumulees depuis la derniere minor |
309 +| **Major** `X+1.0.0` | Tous les 6-12 mois | Depreciations echues, breaking changes, nouveaux defauts |
310 +
311 +### Checklist de release
312 +
313 +- [ ] `CHANGELOG.md` a jour avec toutes les entrees depuis la release precedente.
314 +- [ ] Tests : `cargo test --all-features` passe.
315 +- [ ] Lint : `cargo clippy --workspace --all-features -- -D warnings` passe.
316 +- [ ] Doc : `cargo doc --no-deps` sans warning.
317 +- [ ] Benchs : pas de regression > 10% non justifiee.
318 +- [ ] Tag git annote : `v0.8.0`.
319 +- [ ] GitHub Release avec les notes du CHANGELOG.
320 +- [ ] `cargo publish` sur crates.io.
321 +- [ ] Issue de tracking ouverte sur chaque app consommatrice listant les PRs d'adoption.
322 +
323 +### Notification des consommateurs
324 +
325 +A chaque release, un commentaire est poste sur :
326 +- Le tracking issue upstream (liste des apps consommatrices).
327 +- Le fichier `UPSTREAM_RUSTWARDEN.md` de chaque app (via PR automatique ou manuelle).
328 +
329 +Le consommateur est libre d'adopter la version a son rythme, **sauf pour les patches securite** ou une fenetre de 14 jours d'adoption est attendue.
330 +
331 +---
332 +
333 +## 10. Strategie de rollback
334 +
335 +Si une release contient un bug critique detecte apres publication :
336 +
337 +1. **Sous 24h** : `cargo yank` sur la version defectueuse (empeche les nouvelles installs).
338 +2. **Sous 48h** : patch release `x.y.Z+1` avec le fix, CHANGELOG clairement marque "Security" ou "Critical fix".
339 +3. **Sous 7j** : post-mortem public sur le tracking issue upstream. Sections : contexte, impact, root cause, timeline, mesures pour eviter recurrence.
340 +
341 +Les consommateurs qui ont deja bumpe vers la version yanked doivent eux-memes bumper vers le patch — un message automatise dans leur `UPSTREAM_<framework>.md` les y invite.
342 +
343 +---
344 +
345 +## 11. Guide des consommateurs
346 +
347 +Chaque app consommatrice (gitrust, futurs) est recommandee de :
348 +
349 +### 11.1 Pinning
350 +
351 +- Pendant alpha/beta : **pin exact** `rustwarden-core = "=0.8.3"`. Evite les auto-updates surprises.
352 +- En production stable : **pin minor** `rustwarden-core = "~0.8.3"` (accepte 0.8.x, refuse 0.9.0). Migre a la main sur major.
353 +
354 +### 11.2 Fichier de tracking
355 +
356 +Chaque app maintient un `docs/UPSTREAM_<framework>.md` (cf [`UPSTREAM_RUSTWARDEN.md`](UPSTREAM_RUSTWARDEN.md) dans gitrust). Ce fichier liste :
357 +
358 +- Les services framework utilises (catalog).
359 +- Les hooks implementes.
360 +- Les workarounds en cours avec renvoi vers la RFC correspondante.
361 +- Les breaking changes a venir a partir du changelog upstream.
362 +
363 +### 11.3 Contribution
364 +
365 +Quand un consommateur rencontre un gap :
366 +
367 +1. **Ecrire un workaround local** (comme [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) cote gitrust).
368 +2. **Documenter dans son `UPSTREAM_<framework>.md`** : contexte, workaround, propositions pour le framework.
369 +3. **Deposer une RFC upstream** des que le workaround dure > 1 release mineure — l'objectif est que le code du workaround finisse par disparaitre au profit d'une solution framework.
370 +4. **Contribuer l'implementation** si possible. Le framework est plus attractif quand les gros consommateurs y contribuent, et l'app consommatrice beneficie d'une dette reduite.
371 +
372 +### 11.4 Anti-patterns
373 +
374 +A eviter cote consommateur :
375 +
376 +- **Forker le framework** : bloque l'adoption des fixes securite upstream. Toujours privilegier un workaround dans le crate de l'app.
377 +- **Modifier `crates/rustwarden-core/*`** : idem, casse la synchronisation. Toute evolution passe par une RFC upstream.
378 +- **Dupliquer une service du framework** dans l'app : dette certaine. Meme si le service upstream est incomplet, l'etendre via wrapper/trait impl plutot que le re-implementer.
379 +- **Masquer les workarounds** : chaque contournement doit etre documente pour que la dette reste visible.
380 +
381 +---
382 +
383 +## 12. Communication
384 +
385 +### Canaux officiels
386 +
387 +| Sujet | Canal |
388 +|-------|-------|
389 +| Discussion RFC | Issue GitHub upstream avec label `rfc` |
390 +| Question usage | README + rustdoc ; en derniere instance issue label `question` |
391 +| Bug report | Issue GitHub upstream avec label `bug` + reproducer minimal |
392 +| Coordination consommateurs | Pull request sur `UPSTREAM_<framework>.md` de chaque app |
393 +
394 +### Synchro reguliere
395 +
396 +Une fois les consommateurs > 1, un sync mensuel entre mainteneurs upstream et consommateurs :
397 +- Revue des RFCs ouvertes.
398 +- Adoption prochaine (qui bumpe quand).
399 +- Backlog partage.
400 +
401 +En attendant le second consommateur : la coordination se fait via ce document + `UPSTREAM_RUSTWARDEN.md` de gitrust.
402 +
403 +---
404 +
405 +## 13. Exemples concrets
406 +
407 +Pour illustrer la methode, voici deux exemples tires du contexte actuel.
408 +
409 +### 13.1 Exemple RFC : CSRF compatible SSR form
410 +
411 +Le gap "middleware CSRF ne lit pas le form body" (cf [`UPSTREAM_RUSTWARDEN.md` section 1](UPSTREAM_RUSTWARDEN.md)) est un candidat type pour une RFC. La note technique est deja redigee, il suffit de la formaliser :
412 +
413 +- **Summary** : ajouter `accept_form_body: bool` dans une config optionnelle de `csrf_protection`.
414 +- **Motivation** : toutes les apps SSR futures auront le meme probleme.
415 +- **Alternatives** : (A) config + middleware etendu, (B) helper `extract_csrf_from_form`, (C) feature flag.
416 +- **Migration** : gitrust bump + suppression de `admin_csrf_protection`.
417 +
418 +Score prevu : Generalite=5, Stabilite=5 (additif), Perf=4, Secu=5, Complexite=3, Maintenance=4, Doc=5 → 31/35. Accepte direct.
419 +
420 +### 13.2 Exemple NON-RFC : fix typo dans un message d'erreur
421 +
422 +Correction de "Invalidd token" → "Invalid token" dans un `AppError`. PR direct, changelog entry "Fixed". Pas de RFC necessaire.
423 +
424 +---
425 +
426 +## 14. Revisions de ce document
427 +
428 +Ce document lui-meme evolue. Chaque changement majeur est trace :
429 +
430 +| Date | Auteur | Changement |
431 +|------|--------|------------|
432 +| 2026-04-21 | EBO (gitrust) | Creation initiale, inspire du processus observe lors de l'iteration CSRF ssh-guard. |
433 +
434 +Pour proposer une modification : PR directe sur ce fichier, revue par au moins un mainteneur framework et un mainteneur consommateur.
M docs/SERVICES_REFERENCE.md
+480 -48
@@ -17,13 +17,18 @@
17 17 6. [Mots de passe](#6-mots-de-passe)
18 18 7. [Tokens et sessions](#7-tokens-et-sessions)
19 19 8. [Ressources et partage](#8-ressources-et-partage)
20 -9. [Configuration applicative](#9-configuration-applicative)
21 -10. [Preferences utilisateur](#10-preferences-utilisateur)
22 -11. [Email](#11-email)
23 -12. [Chiffrement](#12-chiffrement)
24 -13. [OAuth](#13-oauth-feature-oauth)
25 -14. [Utilitaires](#14-utilitaires)
26 -15. [Patterns communs](#15-patterns-communs)
20 +9. [Teams (groupes d'utilisateurs)](#9-teams-groupes-dutilisateurs)
21 +10. [Configuration applicative](#10-configuration-applicative)
22 +11. [Preferences utilisateur](#11-preferences-utilisateur)
23 +12. [Email](#12-email)
24 +13. [Chiffrement](#13-chiffrement)
25 +14. [OAuth](#14-oauth-feature-oauth)
26 +15. [Rate limiting](#15-rate-limiting)
27 +16. [Notifications temps-reel et SSE](#16-notifications-temps-reel-et-sse)
28 +17. [Hooks lifecycle et audit](#17-hooks-lifecycle-et-audit)
29 +18. [Middleware (CSRF, auth, rate limit)](#18-middleware-csrf-auth-rate-limit)
30 +19. [Utilitaires](#19-utilitaires)
31 +20. [Patterns communs](#20-patterns-communs)
27 32
28 33 ---
29 34
@@ -43,14 +48,20 @@ Handler (axum) → Service (logique metier) → SeaORM (base de donnees)
43 48 - Aucun SQL brut — tout passe par SeaORM.
44 49
45 50 **Services exportes dans le prelude** (`use rustwarden_core::prelude::*`) :
46 -- `UserService`, `RoleService`, `PermissionService`, `ResourceService`
47 -- `AuthUser`, `Claims`, `JwtService`
48 -- `AppError`, `RustwardenHooks`, `RustwardenApp`
51 +- Users / RBAC : `UserService`, `RoleService`, `PermissionService`
52 +- Ressources : `ResourceService`, `Principal`
53 +- Teams : `TeamService`, `TeamRole`, `TeamSlug`
54 +- Tokens API : `PersonalTokenService`, `TokenScope`
55 +- Auth : `AuthUser`, `Claims`, `JwtService`
56 +- App : `AppError`, `RustwardenHooks`, `RustwardenApp`
49 57
50 58 Les autres services sont accessibles via leur chemin complet :
51 59 ```rust
52 60 use rustwarden_core::services::refresh_token_service::RefreshTokenService;
53 61 use rustwarden_core::services::app_settings_service::AppSettingsService;
62 +use rustwarden_core::services::rate_limit_service::RateLimitService;
63 +use rustwarden_core::notifications::{NotificationBus, InMemoryBus, sse::sse_response};
64 +use rustwarden_core::middleware::csrf::{csrf_protection, csrf_protection_with_config, CsrfProtectionConfig};
54 65 // etc.
55 66 ```
56 67
@@ -279,9 +290,11 @@ ce qui evite de bloquer le runtime Tokio.
279 290
280 291 Flux de changement de mot de passe en deux etapes (demande + confirmation par email).
281 292
293 +Rate limiting delegue a [`RateLimitService`](#15-rate-limiting) via `check_sliding_window("pwd_change:{user_id}", 3, 1h)` — retourne `AppError::TooManyRequests { retry_after_secs }` (HTTP 429 + header `Retry-After`) au-dela.
294 +
282 295 | Methode | Signature | Description |
283 296 |---------|-----------|-------------|
284 -| `create_pending_request` | `(&self, db, user_id: Uuid, new_password: &str) → Result<(Uuid, String)>` | Cree une demande pendante. Rate limited (3/heure/utilisateur). Retourne `(request_id, plain_token)`. |
297 +| `create_pending_request` | `(&self, db, user_id: Uuid, new_password: &str) → Result<(Uuid, String)>` | Cree une demande pendante. Rate limited. Retourne `(request_id, plain_token)`. |
285 298 | `confirm_request` | `(&self, db, token: &str) → Result<Uuid>` | Confirme le changement via le token email. Applique le nouveau hash et **revoque tous les refresh tokens** (SEC-M7). |
286 299 | `find_by_token` | `(&self, db, token: &str) → Result<password_change_request::Model>` | Trouve une demande par token (verifie expiration et statut). |
287 300 | `find_pending_by_user_id` | `(&self, db, user_id: Uuid) → Result<Option<Model>>` | Trouve la demande pendante d'un utilisateur. |
@@ -298,7 +311,7 @@ Flux "Mot de passe oublie" (reset par email).
298 311
299 312 | Methode | Signature | Description |
300 313 |---------|-----------|-------------|
301 -| `request_reset` | `(&self, db, email: &str) → Result<bool>` | Envoie un email de reset. Retourne `false` si l'utilisateur n'existe pas (pas d'erreur, pour la securite). Rate limited (3/heure). |
314 +| `request_reset` | `(&self, db, email: &str) → Result<bool>` | Envoie un email de reset. Retourne `false` si l'utilisateur n'existe pas (pas d'erreur, pour la securite). Rate limited : `check_sliding_window("pwd_reset:{email}", 3, 1h)` → 429 + `Retry-After`. |
302 315 | `validate_token` | `(&self, db, token: &str) → Result<Uuid>` | Valide un token de reset. Retourne le `user_id`. |
303 316 | `reset_password` | `(&self, db, token: &str, new_password: &str) → Result<Uuid>` | Reset le mot de passe. Marque le token comme utilise et **revoque tous les refresh tokens** (SEC-M7). |
304 317
@@ -366,6 +379,69 @@ Blacklist des JWT revoques (logout).
366 379 | `cleanup_expired_tokens` | `(db) → Result<u64>` | Supprime les entrees expirees (tache de fond automatique). |
367 380 | `hash_jwt_token` | `(token: &str) → String` | Hash SHA-256 (utilitaire public). |
368 381
382 +### `PersonalTokenService<S: TokenScope>`
383 +
384 +**Module** : `rustwarden_core::services::personal_token_service`
385 +**Export** : prelude (`PersonalTokenService`, `TokenScope`)
386 +
387 +Tokens API longue-duree (Personal Access Tokens) pour protocoles qui ne peuvent pas supporter la rotation JWT (git push HTTPS, webhooks, integrations tierces). Complete — ne remplace pas — `RefreshTokenService`.
388 +
389 +**Securite** :
390 +- Plaintext (32 octets random hex, 64 chars) retourne **une seule fois** a la creation, jamais persiste.
391 +- Stockage uniquement en SHA-256 hex.
392 +- Comparaison constant-time a la verification (`subtle::ConstantTimeEq`).
393 +- Erreur uniforme `AppError::InvalidToken` sur tout rejet (pas de leak).
394 +
395 +**Generique sur un trait `TokenScope`** implemente par chaque app consommatrice :
396 +
397 +```rust
398 +use rustwarden_core::prelude::{PersonalTokenService, TokenScope};
399 +
400 +#[derive(Clone, Debug)]
401 +enum RepoScope { Read, Write }
402 +
403 +impl TokenScope for RepoScope {
404 + fn as_str(&self) -> &str {
405 + match self { Self::Read => "read:repo", Self::Write => "write:repo" }
406 + }
407 + fn parse(s: &str) -> Option<Self> {
408 + match s {
409 + "read:repo" => Some(Self::Read),
410 + "write:repo" => Some(Self::Write),
411 + _ => None,
412 + }
413 + }
414 +}
415 +```
416 +
417 +| Methode | Signature | Description |
418 +|---------|-----------|-------------|
419 +| `create` | `(db, user_id, name: &str, scopes: &[S], expires_at: Option<DateTime<Utc>>) → Result<(Model, String)>` | Cree un PAT. Retourne `(record, plaintext)`. `expires_at=None` = permanent. `AppError::Validation` si `name` vide. |
420 +| `verify` | `(db, token_clear: &str) → Result<(Model, Vec<S>)>` | Valide un PAT et retourne le record + scopes parses. Update `last_used_at` best-effort. Refuse revoked/expired avec `AppError::InvalidToken`. |
421 +| `list_for_user` | `(db, user_id: Uuid) → Result<Vec<Model>>` | Liste les PATs actifs (non revokes), tries desc par `created_at`. |
422 +| `revoke` | `(db, token_id: Uuid, user_id: Uuid) → Result<()>` | Revoque un PAT. Idempotent. `AppError::Forbidden` si le token n'appartient pas a `user_id`. |
423 +| `cleanup_expired` | `(db) → Result<u64>` | Hard-delete les tokens `expired AND revoked`. Tokens expired-seulement gardes pour audit. Appele par [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup). |
424 +
425 +**Modele `personal_access_token::Model`** :
426 +- `id: Uuid`, `user_id: Uuid`, `name: String`, `token_hash: String` (64 hex chars)
427 +- `scopes: Vec<String>`, `expires_at: Option<DateTime<Utc>>`, `last_used_at: Option<DateTime<Utc>>`
428 +- `revoked_at: Option<DateTime<Utc>>`, `created_at: DateTime<Utc>`
429 +- Helpers : `is_expired()`, `is_revoked()`, `is_active()`
430 +
431 +**Background task** : `PersonalTokenCleanupProcessor::<S>::new(db).start()` — intervalle via env `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` (default 3600).
432 +
433 +**Pattern d'extension `AuthUser`** cote consommateur (exemple gitrust pour git HTTPS) :
434 +```rust
435 +// Dans votre extractor custom : tenter JWT Bearer puis PAT Bearer
436 +if let Some(jwt) = extract_bearer_jwt(headers) { /* ... */ }
437 +if let Some(plain) = extract_bearer_pat(headers) {
438 + let (record, scopes) = PersonalTokenService::<RepoScope>::verify(&db, &plain).await?;
439 + // Construire un AuthUser derivé du token
440 +}
441 +```
442 +
443 +> **Integration consommateur** : voir [DEVELOPER.md §14 — Nettoyage des Personal Access Tokens](../DEVELOPER.md#taches-de-fond) pour l'exemple complet de definition du scope et de demarrage du `PersonalTokenCleanupProcessor<S>` dans `main.rs`.
444 +
369 445 ---
370 446
371 447 ## 8. Ressources et partage
@@ -392,21 +468,38 @@ Systeme generique de registre de ressources et de partage avec controle d'acces.
392 468
393 469 #### Partage
394 470
471 +Les partages acceptent deux types de principals :
472 +
473 +```rust
474 +pub enum Principal {
475 + User(Uuid),
476 + Team(Uuid),
477 +}
478 +```
479 +
480 +`Principal` est exporte via le prelude. Un share row porte exactement un des deux identifiants (contrainte CHECK au niveau DB).
481 +
395 482 | Methode | Signature | Description |
396 483 |---------|-----------|-------------|
397 -| `share` | `(db, resource_type, resource_id, shared_with_user_id, permission_level: &str, shared_by_user_id) → Result<resource_share::Model>` | Partage une ressource. Met a jour le partage existant si deja partage. |
484 +| `share_with` | `(db, resource_type, resource_id, principal: Principal, permission_level: &str, shared_by_user_id) → Result<resource_share::Model>` | **API moderne**. Partage avec un user ou une team. Update si deja partage (uniqueness par `(resource_id, principal)` via partial unique indexes). Refuse si principal == owner. |
485 +| `share` | `(db, resource_type, resource_id, shared_with_user_id, permission_level, shared_by_user_id) → Result<resource_share::Model>` | **Legacy wrapper** sur `share_with(Principal::User(..))`, conserve pour retrocompat. |
398 486 | `revoke_share` | `(db, share_id: Uuid) → Result<()>` | Revoque un partage par ID. |
399 -| `revoke_user_access` | `(db, resource_type, resource_id, user_id) → Result<()>` | Revoque tous les partages pour un couple (ressource, utilisateur). |
400 -| `list_shares` | `(db, resource_type, resource_id) → Result<Vec<resource_share::Model>>` | Liste les partages d'une ressource. |
487 +| `revoke_user_access` | `(db, resource_type, resource_id, user_id) → Result<()>` | Revoque tous les partages user pour un couple (ressource, utilisateur). |
488 +| `list_shares` | `(db, resource_type, resource_id) → Result<Vec<resource_share::Model>>` | Liste tous les partages d'une ressource (user + team). |
401 489
402 490 #### Controle d'acces
403 491
404 492 | Methode | Signature | Description |
405 493 |---------|-----------|-------------|
406 -| `user_can_access` | `(db, user_id, resource_type, resource_id, required_level: &str) → Result<bool>` | Verifie si un utilisateur peut acceder a une ressource au niveau requis. Verifie dans l'ordre : proprietaire → public+read → partage. |
407 -| `effective_permission` | `(db, user_id, resource_type, resource_id) → Result<Option<String>>` | Retourne la permission effective : `"owner"`, le niveau de partage, `"read"` (public), ou `None`. |
408 -| `shared_with_user` | `(db, user_id, resource_type) → Result<Vec<(resource::Model, resource_share::Model)>>` | Ressources partagees avec l'utilisateur. |
409 -| `accessible_by_user` | `(db, user_id, resource_type) → Result<Vec<resource::Model>>` | Toutes les ressources accessibles (possedees + partagees + publiques). |
494 +| `user_can_access` | `(db, user_id, resource_type, resource_id, required_level: &str) → Result<bool>` | Acces au niveau requis. Verifie : proprietaire → public+read → partage direct (user). |
495 +| `effective_permission` | `(db, user_id, resource_type, resource_id) → Result<Option<String>>` | **Legacy** — ignore les teams. Conservee pour retrocompat. |
496 +| `effective_permission_for_user` | `(db, user_id, resource_type, resource_id) → Result<Option<String>>` | **API moderne** — calcule le **max** sur : ownership → partage user direct → partages via chaque team du user → public. Single query avec `Condition::any()`, pas de N+1. |
497 +| `shared_with_user` | `(db, user_id, resource_type) → Result<Vec<(resource::Model, resource_share::Model)>>` | Ressources partagees directement (partage user, pas team). |
498 +| `accessible_by_user` | `(db, user_id, resource_type) → Result<Vec<resource::Model>>` | Toutes les ressources accessibles (possedees + partagees directement + publiques). |
499 +
500 +**Modele `resource_share::Model`** :
501 +- `id: Uuid`, `resource_id: Uuid`, `permission_level: String`, `shared_by_user_id: Uuid`, `created_at`
502 +- `shared_with_user_id: Option<Uuid>` et `shared_with_team_id: Option<Uuid>` — exactement un des deux est `Some`.
410 503
411 504 **Exemple d'utilisation dans un projet consommateur** :
412 505
@@ -416,26 +509,87 @@ use rustwarden_core::prelude::*;
416 509 // Enregistrer un "wall" PasterMan comme ressource
417 510 ResourceService::register(&db, "wall", wall.id, user.user_id).await?;
418 511
419 -// Partager en lecture
420 -ResourceService::share(&db, "wall", wall.id, other_user_id, "read", user.user_id).await?;
512 +// Partager en lecture avec un user
513 +ResourceService::share_with(&db, "wall", wall.id, Principal::User(alice), "read", user.user_id).await?;
421 514
422 -// Verifier l'acces
423 -if ResourceService::user_can_access(&db, visitor_id, "wall", wall.id, "read").await? {
424 - // Afficher le wall
425 -}
515 +// Partager avec une team (tous les membres heritent)
516 +ResourceService::share_with(&db, "wall", wall.id, Principal::Team(backend_team_id), "write", user.user_id).await?;
426 517
427 -// Permission effective
428 -match ResourceService::effective_permission(&db, visitor_id, "wall", wall.id).await? {
429 - Some(ref p) if p == "owner" => /* acces total */,
430 - Some(ref p) if p == "write" => /* edition permise */,
431 - Some(ref p) if p == "read" => /* lecture seule */,
432 - _ => /* acces refuse */,
518 +// Permission effective (prend en compte user + teams, max)
519 +match ResourceService::effective_permission_for_user(&db, visitor, "wall", wall.id).await? {
520 + Some(p) if p == "owner" => /* acces total */,
521 + Some(p) if p == "admin" => /* edition + partage */,
522 + Some(p) if p == "write" => /* edition */,
523 + Some(p) if p == "read" => /* lecture */,
524 + _ => /* pas d'acces */,
433 525 }
434 526 ```
435 527
436 528 ---
437 529
438 -## 9. Configuration applicative
530 +## 9. Teams (groupes d'utilisateurs)
531 +
532 +### `TeamService`
533 +
534 +**Module** : `rustwarden_core::services::team_service`
535 +**Export** : prelude (`TeamService`, `TeamRole`)
536 +
537 +Groupes d'utilisateurs avec membership, owner, role par membre. Un `Principal::Team(id)` est accepte par [`ResourceService::share_with`](#8-ressources-et-partage) — tous les membres heritent alors du share via `effective_permission_for_user`.
538 +
539 +**Enum `TeamRole`** : `Owner | Admin | Member` — persiste en DB comme `"owner"|"admin"|"member"`. Le createur est automatiquement insere comme `owner` member au `create`.
540 +
541 +| Methode | Signature | Description |
542 +|---------|-----------|-------------|
543 +| `create` | `(db, owner_id, slug: TeamSlug, name: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<team::Model>` | Cree une team + insere l'owner comme premier membre (role `Owner`). Cable `on_team_created`. Valide `name` (1-128 chars). |
544 +| `find_by_id` | `(db, team_id) → Result<Option<team::Model>>` | Recherche par ID. |
545 +| `find_by_slug` | `(db, slug: &TeamSlug) → Result<Option<team::Model>>` | Recherche par slug. |
546 +| `add_member` | `(db, team_id, user_id, role: TeamRole, hooks) → Result<team_member::Model>` | **Idempotent** : update le role si l'edge existe, insere sinon. Cable `on_team_member_added`. |
547 +| `remove_member` | `(db, team_id, user_id, hooks) → Result<()>` | Supprime un membre. **Refuse de retirer l'owner** (`AppError::Validation`) — utiliser `delete` pour drop la team. Cable `on_team_member_removed`. |
548 +| `list_members` | `(db, team_id) → Result<Vec<team_member::Model>>` | Liste les membres, tri desc par `joined_at`. |
549 +| `teams_of_user` | `(db, user_id) → Result<Vec<team::Model>>` | Les teams dont le user est membre (inner join). |
550 +| `team_ids_of_user` | `(db, user_id) → Result<Vec<Uuid>>` | Juste les IDs — helper perf pour la resolution de permission. |
551 +| `delete` | `(db, team_id, hooks) → Result<()>` | Hard-delete la team. Cascade DB sur `team_members` + `resource_shares` avec ce team comme principal. Cable `on_team_deleted`. |
552 +
553 +### `TeamSlug` (value object)
554 +
555 +**Module** : `rustwarden_core::types::team_slug`
556 +**Export** : prelude
557 +
558 +Slug URL-safe pour une team. Construction via `TeamSlug::parse(&str)` qui valide :
559 +
560 +- 1 a 64 caracteres
561 +- `[a-z0-9-]+` (lowercase ASCII + digits + hyphen)
562 +- Ni debut ni fin avec `-`
563 +- Pas un des ~15 noms reserves (`admin`, `api`, `teams`, `settings`, ...)
564 +
565 +```rust
566 +use rustwarden_core::prelude::*;
567 +
568 +let slug = TeamSlug::parse("backend")?;
569 +let team = TeamService::create(&db, owner_id, slug, "Backend Team", Some(&hooks)).await?;
570 +TeamService::add_member(&db, team.id, alice_id, TeamRole::Member, Some(&hooks)).await?;
571 +
572 +// Partager un repo avec la team :
573 +ResourceService::share_with(&db, "repo", repo_id, Principal::Team(team.id), "write", owner_id).await?;
574 +
575 +// Alice herite automatiquement du write access :
576 +let perm = ResourceService::effective_permission_for_user(&db, alice_id, "repo", repo_id).await?;
577 +assert_eq!(perm.as_deref(), Some("write"));
578 +```
579 +
580 +**Modeles** :
581 +- `team::Model` : `id`, `slug: String`, `name: String`, `owner_id: Uuid`, `created_at`
582 +- `team_member::Model` (PK composite `(team_id, user_id)`) : `team_id`, `user_id`, `role: String`, `joined_at`
583 +
584 +**Cascade** :
585 +- DELETE user → supprime les teams dont il est owner (cascade) et ses memberships.
586 +- DELETE team → supprime `team_members` + `resource_shares` via `shared_with_team_id`.
587 +
588 +> **Integration consommateur** : `TeamService` est framework-only. Les apps consommatrices exposent leurs propres handlers/routes/templates team selon leur UX. Voir [DEVELOPER.md §12 — Hooks teams](../DEVELOPER.md#hooks--intercepter-les-evenements) pour brancher un audit log sur les evenements team.
589 +
590 +---
591 +
592 +## 10. Configuration applicative
439 593
440 594 ### `AppSettingsService`
441 595
@@ -468,7 +622,7 @@ Stockage cle-valeur en base pour la configuration dynamique.
468 622
469 623 ---
470 624
471 -## 10. Preferences utilisateur
625 +## 11. Preferences utilisateur
472 626
473 627 ### `UserPreferencesService`
474 628
@@ -487,7 +641,7 @@ Stockage cle-valeur en base pour la configuration dynamique.
487 641
488 642 ---
489 643
490 -## 11. Email
644 +## 12. Email
491 645
492 646 ### `EmailService`
493 647
@@ -529,7 +683,7 @@ Verification d'email par token.
529 683 | `new` | `(config: EmailConfig) → Self` | Constructeur. |
530 684 | `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. |
531 685 | `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. |
532 -| `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited (3/heure). |
686 +| `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited : `check_sliding_window("email_verify:{email}", 3, 1h)` → 429 + `Retry-After`. |
533 687
534 688 ### `ImapBounceService`
535 689
@@ -547,7 +701,7 @@ Le service parse les messages DSN (RFC 3464) et met a jour la table `email_deliv
547 701
548 702 ---
549 703
550 -## 12. Chiffrement
704 +## 13. Chiffrement
551 705
552 706 ### `CryptoService`
553 707
@@ -564,7 +718,7 @@ Chiffrement AES-256-GCM pour les secrets en base (secrets OAuth, etc.).
564 718
565 719 ---
566 720
567 -## 13. OAuth (feature `oauth`)
721 +## 14. OAuth (feature `oauth`)
568 722
569 723 > Disponible uniquement si la feature `oauth` est activee dans `Cargo.toml`.
570 724
@@ -598,7 +752,253 @@ Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
598 752
599 753 ---
600 754
601 -## 14. Utilitaires
755 +## 15. Rate limiting
756 +
757 +### `RateLimitService`
758 +
759 +**Module** : `rustwarden_core::services::rate_limit_service`
760 +
761 +Rate limiter generique in-process, backing de toutes les limites du framework (auth, public share, password flows, email validation) et reutilisable par les apps consommatrices.
762 +
763 +| Methode | Signature | Description |
764 +|---------|-----------|-------------|
765 +| `new` | `() → Self` | Nouveau service (maps internes vides). |
766 +| `check` | `(&self, key: &str, max_per_minute: u32) → Result<(), AppError>` | Fixed window 60s. Renvoie `AppError::TooManyRequests { retry_after_secs }` au depassement. |
767 +| `check_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | Idem avec fenetre personnalisable. |
768 +| `check_sliding_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | **Sliding window strict** (stocke chaque timestamp). Preferable pour les flux securite sensibles (password reset, email verify). |
769 +| `cleanup_loop` | `(self, interval: Duration, window: Duration)` async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). |
770 +| `len` / `sliding_len` / `is_empty` | | Compteurs exposes pour metrics/tests. |
771 +
772 +**Cle convention** : `"<scope>:<identifier>"` — ex. `"auth:1.2.3.4"`, `"share:1.2.3.4"`, `"pwd_reset:user@example.com"`, `"import:123e4567-..."`.
773 +
774 +**AppError::TooManyRequests** : mappe vers HTTP **429** + header `Retry-After: N` + body JSON `{"error","status":429,"retry_after_secs":N}`.
775 +
776 +Exemple d'usage pour proteger un endpoint d'import cote app consommateur :
777 +```rust
778 +use std::sync::Arc;
779 +use rustwarden_core::services::rate_limit_service::RateLimitService;
780 +
781 +// Au demarrage de l'app
782 +let limiter = Arc::new(RateLimitService::new());
783 +// Dans un handler
784 +limiter.check(&format!("import:{ip}"), 120)?; // 120/min
785 +```
786 +
787 +Les helpers du middleware [`rate_limit`](#18-middleware-csrf-auth-rate-limit) exposent deja `check_auth_rate_limit(headers)` et `check_public_share_rate_limit(request)`.
788 +
789 +> **Integration consommateur** : voir [DEVELOPER.md §15 — Rate limiting et notifications temps-reel](../DEVELOPER.md#rate-limiting-et-notifications-temps-reel) pour l'exemple d'integration dans un handler Axum.
790 +
791 +---
792 +
793 +## 16. Notifications temps-reel et SSE
794 +
795 +### Trait `NotificationBus<T>` et `InMemoryBus`
796 +
797 +**Module** : `rustwarden_core::notifications`
798 +
799 +Pub/sub bus generique pour pousser des evenements aux clients (Server-Sent Events, progress bars, live logs). Le trait est swappable (futur backend Redis) — aujourd'hui seul `InMemoryBus` est fourni.
800 +
801 +```rust
802 +#[async_trait]
803 +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync {
804 + async fn publish(&self, event: T);
805 + fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>>;
806 +}
807 +
808 +pub type EventFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;
809 +```
810 +
811 +**`InMemoryBus<T>`** — backing `tokio::sync::broadcast` :
812 +- `new(capacity)` / `Default` (capacity 256).
813 +- `receiver_count()` pour metrics.
814 +- Slow consumer drop proprement via `BroadcastStream::filter_map` (pas de panic, pas de stream closure).
815 +- Publisher ignore `Err` quand aucun receiver.
816 +
817 +### Helper `sse::sse_response`
818 +
819 +**Module** : `rustwarden_core::notifications::sse`
820 +
821 +```rust
822 +pub const DEFAULT_SSE_KEEPALIVE: Duration = Duration::from_secs(30);
823 +
824 +pub fn sse_response<T, S>(stream: S) -> Sse<impl Stream<Item = Result<Event, Infallible>>>
825 +where
826 + T: Serialize + Send + 'static,
827 + S: Stream<Item = T> + Send + 'static;
828 +```
829 +
830 +Wrap un stream en reponse axum `Sse` avec keep-alive 30s + serialisation JSON par event + drop silencieux des events non serialisables.
831 +
832 +**Exemple complet** (cote app) :
833 +```rust
834 +use std::sync::Arc;
835 +use axum::extract::Extension;
836 +use axum::response::IntoResponse;
837 +use rustwarden_core::notifications::{InMemoryBus, NotificationBus, sse::sse_response};
838 +
839 +#[derive(Clone, serde::Serialize)]
840 +enum AppEvent {
841 + ImportProgress { job_id: uuid::Uuid, pct: u8 },
842 + CiLog { job_id: uuid::Uuid, line: String },
843 +}
844 +
845 +async fn import_stream(
846 + Extension(bus): Extension<Arc<dyn NotificationBus<AppEvent>>>,
847 + axum::extract::Path(job_id): axum::extract::Path<uuid::Uuid>,
848 +) -> impl IntoResponse {
849 + let filter = Arc::new(move |e: &AppEvent| matches!(e, AppEvent::ImportProgress { job_id: j, .. } if *j == job_id));
850 + sse_response(bus.subscribe(filter))
851 +}
852 +```
853 +
854 +> **Integration consommateur** : voir [DEVELOPER.md §15 — Notifications temps-reel (SSE)](../DEVELOPER.md#rate-limiting-et-notifications-temps-reel) pour l'injection du bus dans le builder et l'abonnement filtre.
855 +
856 +---
857 +
858 +## 17. Hooks lifecycle et audit
859 +
860 +### Trait `RustwardenHooks`
861 +
862 +**Module** : `rustwarden_core::hooks`
863 +**Export** : prelude
864 +
865 +Trait implementable par les apps consommatrices pour reagir aux evenements lifecycle du framework. Toutes les methodes ont un default `Ok(())` — overridez celles qui vous interessent.
866 +
867 +**Event lifecycle (19 hooks)** :
868 +
869 +| Hook | Signature (after `&self, db: &DatabaseConnection`) | Description |
870 +|------|-----|-------------|
871 +| `on_user_registered` | `user_id, username` | Nouveau user cree. |
872 +| `on_user_login` | `user_id, username` | Login reussi. |
873 +| `on_user_logout` | `user_id` | Logout. |
874 +| `on_user_deleted` | `user_id, username` | **Avant** cascade DB — cleanup des ressources metier (walls, repos, ...). |
875 +| `on_password_changed` | `user_id` | Mot de passe modifie (flow direct ou confirme). |
876 +| `on_email_verified` | `user_id` | Email verifie. |
877 +| `on_role_changed` | `user_id, roles: &[String]` | Roles modifies. |
878 +| `on_totp_enabled` / `on_totp_disabled` | `user_id` | 2FA toggle. |
879 +| `on_oauth_login` | `user_id, provider, is_new_user` | Login OAuth. |
880 +| `on_resource_shared` | `rt, rid, shared_with, level, shared_by` | Ressource partagee. |
881 +| `on_resource_unshared` | `rt, rid, user_id` | Partage revoke. |
882 +| `on_resource_registered` | `rt, rid, owner_id` | Nouvelle ressource enregistree. |
883 +| `on_resource_unregistered` | `rt, rid` | Ressource supprimee du registre. |
884 +| `on_team_created` | `team_id, owner_id` | Team creee. |
885 +| `on_team_deleted` | `team_id` | Team supprimee. |
886 +| `on_team_member_added` / `on_team_member_removed` | `team_id, user_id` | Membership. |
887 +| `on_audit_event` | `event: AuditEvent<'_>` | **Escape hatch generique** pour events qui ne meritent pas leur propre hook. |
888 +
889 +### Struct `AuditEvent<'a>`
890 +
891 +```rust
892 +pub struct AuditEvent<'a> {
893 + pub kind: &'a str, // "auth.login_blocked", "pr.merged", ...
894 + pub actor_id: Option<Uuid>,
895 + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id)
896 + pub metadata: &'a serde_json::Value,
897 +}
898 +```
899 +
900 +Emis par :
901 +- `AuthLoggingService::log_blocked_login` → `kind="auth.login_blocked"`, metadata `{email, reason}`.
902 +- Apps consommatrices pour leurs propres events (issues, PR, walls, ...).
903 +
904 +Les hooks implementant `on_audit_event` recoivent tous ces events : un sink unique (SIEM, Langfuse, indexation OpenSearch) se branche une fois pour tout auditer.
905 +
906 +**Exemple d'implementation cote app** :
907 +```rust
908 +use rustwarden_core::hooks::{AuditEvent, RustwardenHooks};
909 +use async_trait::async_trait;
910 +use sea_orm::DatabaseConnection;
911 +use uuid::Uuid;
912 +
913 +pub struct MyHooks;
914 +
915 +#[async_trait]
916 +impl RustwardenHooks for MyHooks {
917 + async fn on_user_deleted(&self, _db: &DatabaseConnection, user_id: Uuid, _: &str) -> anyhow::Result<()> {
918 + // Cleanup du repertoire utilisateur sur le filesystem
919 + std::fs::remove_dir_all(format!("/var/data/users/{user_id}")).ok();
920 + Ok(())
921 + }
922 +
923 + async fn on_audit_event(&self, _db: &DatabaseConnection, event: AuditEvent<'_>) -> anyhow::Result<()> {
924 + // Forward vers un SIEM externe
925 + my_siem::send(event.kind, event.actor_id, event.metadata).await?;
926 + Ok(())
927 + }
928 +}
929 +```
930 +
931 +Installation : `RustwardenApp::builder().hooks(Arc::new(MyHooks)).build()?`.
932 +
933 +> **Integration consommateur** : voir [DEVELOPER.md §12 — Hooks](../DEVELOPER.md#hooks--intercepter-les-evenements) pour l'exemple complet d'un `AuditHooks` implementant les 4 hooks teams.
934 +
935 +---
936 +
937 +## 18. Middleware (CSRF, auth, rate limit)
938 +
939 +### CSRF
940 +
941 +**Module** : `rustwarden_core::middleware::csrf`
942 +
943 +- `csrf_protection` — middleware par defaut, verifie le header `X-CSRF-Token`.
944 +- `csrf_protection_with_config` — lit aussi le body `application/x-www-form-urlencoded` (pour apps SSR sans JS).
945 +- `csrf_protection_impl` — variante qui prend `&DatabaseConnection` au lieu de `State<_>`.
946 +- `get_or_create_csrf_token(&Session)` — acces au token pour injection dans les templates.
947 +- `validate_csrf_token(&Session, &str)` — validation manuelle depuis un handler.
948 +- `extract_csrf_from_form(&[u8]) -> Option<String>` — helper public pour les apps qui bufferisent le body elles-memes.
949 +
950 +**`CsrfProtectionConfig`** :
951 +```rust
952 +pub struct CsrfProtectionConfig {
953 + pub accept_form_body: bool, // default: false
954 + pub max_form_body_bytes: usize, // default: 1 MiB
955 +}
956 +```
957 +
958 +Wire dans le router :
959 +```rust
960 +use std::sync::Arc;
961 +use axum::middleware;
962 +use rustwarden_core::middleware::csrf::{csrf_protection_with_config, CsrfProtectionConfig};
963 +
964 +let cfg = Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() });
965 +let router = Router::new()
966 + .route("/forms/submit", post(handler))
967 + .layer(middleware::from_fn_with_state((db.clone(), cfg), csrf_protection_with_config));
968 +```
969 +
970 +**Securite** :
971 +- Comparaison constant-time (SEC-H4, `subtle::ConstantTimeEq`).
972 +- Skip uniquement pour requetes `Authorization: Bearer ...` (API clients non CSRF-vulnerables).
973 +- Multipart jamais bufferise (skip + 403).
974 +- Body > cap → 413 `PAYLOAD_TOO_LARGE`.
975 +
976 +### Rate limiting helpers
977 +
978 +**Module** : `rustwarden_core::middleware::rate_limit`
979 +
980 +Wrapper pratiques par-dessus `RateLimitService` :
981 +
982 +| Helper | Signature | Description |
983 +|--------|-----------|-------------|
984 +| `check_auth_rate_limit` | `(&HeaderMap) → Result<(), Response>` | Protege login/register/refresh/oauth-callback. Cle `auth:{ip}`. Configurable via env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10). |
985 +| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. |
986 +| `check_public_share_rate_limit_from_headers` | `(&HeaderMap) → Result<(), Response>` | Idem depuis un `HeaderMap` (extractor deja consomme le Request). |
987 +| `extract_client_ip` / `extract_client_ip_from_headers` | | IP utilisateur avec support `X-Forwarded-For` (gated par `TRUSTED_PROXY_HOPS`). |
988 +| `AdminRateLimitConfig::*` | | Layers Tower pre-construits (`admin_limit`, `user_management_limit`, `reset_password_limit`). |
989 +
990 +### Auth / Admin
991 +
992 +**Module** : `rustwarden_core::middleware`
993 +
994 +- `auth_required::require_auth` — middleware qui exige un JWT valide (rejette 401 sinon).
995 +- `admin_required::require_admin` — exige le role `admin`.
996 +- `auth_service::auth_middleware` — auth primaire (extraction JWT + validation + injection `Claims` en Extension).
997 +- `password_complexity::enforce_password_complexity` — helper de validation.
998 +
999 +---
1000 +
1001 +## 19. Utilitaires
602 1002
603 1003 ### `AuthLoggingService`
604 1004
@@ -606,7 +1006,7 @@ Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
606 1006
607 1007 | Methode | Signature | Description |
608 1008 |---------|-----------|-------------|
609 -| `log_blocked_login` | `(db, email: &str, reason: &str) → Result<()>` | Enregistre une tentative de login bloquee dans `audit_logs`. Best-effort (les erreurs ne font pas echouer le flux principal). |
1009 +| `log_blocked_login` | `(db, email: &str, reason: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<()>` | Enregistre dans `audit_logs` + fan-out `on_audit_event(kind="auth.login_blocked", metadata={email,reason})` si hooks fournis. Best-effort — un echec hook/DB logue un warn sans casser le flux auth. |
610 1010
611 1011 ### `email_sanitization`
612 1012
@@ -627,7 +1027,7 @@ Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
627 1027
628 1028 ---
629 1029
630 -## 15. Patterns communs
1030 +## 20. Patterns communs
631 1031
632 1032 ### Gestion d'erreurs
633 1033
@@ -637,32 +1037,64 @@ Tous les services retournent `Result<T, AppError>`. Les variantes principales :
637 1037 |----------|------|-------|
638 1038 | `AppError::NotFound(msg)` | 404 | Ressource introuvable |
639 1039 | `AppError::Validation(msg)` | 400 | Donnees invalides |
640 -| `AppError::Forbidden` | 403 | Acces refuse (RBAC) |
1040 +| `AppError::Forbidden` | 403 | Acces refuse (RBAC, ownership) |
641 1041 | `AppError::Unauthorized` | 401 | Non authentifie |
1042 +| `AppError::InvalidToken` | 401 | JWT / PAT / token reset/verify invalide |
642 1043 | `AppError::TokenExpired` | 401 | JWT expire |
643 1044 | `AppError::TokenReused` | 401 | Refresh token reutilise (attaque detectee) |
1045 +| `AppError::TooManyRequests { retry_after_secs }` | **429** | Rate limit depasse. **Header `Retry-After: N` ajoute automatiquement.** |
644 1046 | `AppError::Conflict(msg)` | 409 | Unicite violee (username, email) |
645 1047 | `AppError::Internal(msg)` | 500 | Erreur interne |
646 1048 | `AppError::Config(msg)` | 500 | Configuration manquante |
647 1049
648 1050 ### Rate limiting
649 1051
650 -Les services sensibles implementent un rate limiter en memoire :
1052 +Toutes les limites passent par [`RateLimitService`](#15-rate-limiting) avec des cles prefixees (single source of truth, pas de duplication) :
1053 +
1054 +| Cle | Limite | Window type | Expose via |
1055 +|-----|--------|-------------|------------|
1056 +| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | `check_auth_rate_limit(headers)` |
1057 +| `share:{ip}` | 30/min | Fixed | `check_public_share_rate_limit*` |
1058 +| `pwd_change:{user_id}` | 3/hour (config) | Sliding strict | `PasswordChangeService::create_pending_request` |
1059 +| `pwd_reset:{email}` | 3/hour | Sliding strict | `PasswordResetService::request_reset` |
1060 +| `email_verify:{email}` | 3/hour | Sliding strict | `EmailValidationService::resend_verification` |
1061 +| `*` (vos cles) | libre | les deux | `RateLimitService::check*` public |
651 1062
652 -| Service | Limite | Cle |
653 -|---------|--------|-----|
654 -| `PasswordChangeService` | 3/heure | `user_id` |
655 -| `PasswordResetService` | 3/heure | `email` |
656 -| `EmailValidationService` | 3/heure | `email` |
1063 +Un depassement renvoie systematiquement `AppError::TooManyRequests { retry_after_secs }` → HTTP 429 + `Retry-After`.
657 1064
658 1065 ### Tokens hashes en base
659 1066
660 -Les tokens sensibles (refresh, reset, verification, changement de mot de passe) sont **toujours stockes en SHA-256** en base. Le token en clair n'est retourne qu'a la creation et jamais persiste.
1067 +Les tokens sensibles sont **toujours stockes en SHA-256** en base. Le plaintext n'est retourne qu'a la creation et jamais persiste. Concerne :
1068 +
1069 +- Refresh tokens (rotation, detection reuse).
1070 +- Password reset / email validation / password change (tokens email).
1071 +- Personal Access Tokens (PATs, static long-lived).
661 1072
662 1073 ### Revocation en cascade (SEC-M7)
663 1074
664 1075 Les operations critiques revoquent automatiquement tous les refresh tokens de l'utilisateur :
665 1076 - `PasswordChangeService::confirm_request()`
666 1077 - `PasswordResetService::reset_password()`
1078 +- `UserService::admin_reset_password()` / `change_password_direct()`
1079 +
1080 +### Principal generique (User ou Team)
1081 +
1082 +Les partages acceptent `Principal::User(uuid) | Principal::Team(uuid)`. La resolution de permission (`effective_permission_for_user`) prend le **max** sur user direct + tous les teams dont l'user est membre — en une seule query SQL avec `Condition::any()` (pas de N+1).
1083 +
1084 +### Observabilite via hooks
1085 +
1086 +Plutot que de forker le framework pour ajouter du monitoring, implementez [`RustwardenHooks`](#17-hooks-lifecycle-et-audit) :
1087 +
1088 +- 18 hooks cibles (`on_user_*`, `on_team_*`, `on_resource_*`, ...) pour de la business logic.
1089 +- `on_audit_event(AuditEvent<'_>)` comme escape hatch generique — `AuthLoggingService` l'emet deja pour `auth.login_blocked`.
1090 +
1091 +### Layouts (CSRF + metadata)
1092 +
1093 +`LayoutContext::from_session(&session, title)` peuple automatiquement `csrf_token`, `app_name` et `app_version` depuis la session. Retire le boilerplate `get_or_create_csrf_token` dans les handlers SSR :
1094 +
1095 +```rust
1096 +use rustwarden_core::templates::LayoutContext;
1097 +let layout = LayoutContext::from_session(&session, "Dashboard").await;
1098 +```
667 1099
668 1100 Cela force la reconnexion sur tous les appareils apres un changement de mot de passe.
M docs/UPSTREAM_RUSTWARDEN.md
+547 -67
@@ -1,112 +1,592 @@
1 -# Notes d'amelioration pour RustWarden-core
1 +# Notes sur l'utilisation de RustWarden-core dans Gitrust
2 2
3 -## 1. `UserService::delete_user` — IMPLEMENTE
3 +Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core.
4 4
5 -**Statut : IMPLEMENTE**
5 +## Services framework utilises
6 6
7 -Methode ajoutee avec :
8 -- Protection contre la suppression du dernier admin
9 -- Hook `on_user_deleted` appele avant la suppression (permet aux consommateurs de nettoyer leurs donnees metier)
10 -- Revocation de tous les refresh tokens
11 -- Cascade DB pour user_roles, resources, shares, oauth_accounts
7 +| Service | Usage dans Gitrust |
8 +|---------|-------------------|
9 +| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` |
10 +| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` |
11 +| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` |
12 +| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` |
13 +| `AuthService` | `authenticate_user` (login) |
14 +| `JwtService` | `generate_token` (login) |
15 +| `EmailValidationService` | Verification email |
16 +| `PasswordResetService` | Flux "mot de passe oublie" |
17 +| `AppSettingsService` | `get_bool` pour `registration_enabled` |
18 +
19 +## Hook `on_user_deleted` implemente
20 +
21 +Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`.
22 +
23 +## Aucun contournement en cours
24 +
25 +Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26.
26 +
27 +---
28 +
29 +## Propositions d'amelioration framework (backlog upstream)
30 +
31 +Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream.
32 +
33 +### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22
34 +
35 +**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
36 +
37 +Proposition A retenue :
38 +- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB).
39 +- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body.
40 +- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B).
41 +- Multipart skip, body > cap → 413, 11 tests TDD.
42 +- `csrf_protection` existante inchangee (backwards-compat).
43 +
44 +---
45 +
46 +### 1bis. Details historiques (avant resolution)
47 +
48 +**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard).
49 +
50 +**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`.
51 +
52 +**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement :
53 +
54 +```rust
55 +// For form data, we would need to parse the body
56 +// This is handled in handlers that process form data
57 +```
58 +
59 +Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de :
60 +- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence),
61 +- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate).
62 +
63 +**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui :
64 +1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification.
65 +2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable).
66 +3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch).
67 +4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`.
68 +
69 +Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust.
70 +
71 +**Propositions d'amelioration** (par ordre de preference) :
72 +
73 +#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body`
74 +
75 +Ajouter un parametre optionnel au middleware pour activer la lecture du body :
76 +
77 +```rust
78 +// Nouveau dans rustwarden_core::middleware::csrf
79 +pub struct CsrfProtectionConfig {
80 + /// Si true, lit le champ `csrf_token` du body form-urlencoded
81 + /// quand le header `X-CSRF-Token` est absent. Bufferise le body
82 + /// en RAM (attention aux uploads multipart — ignores).
83 + /// Defaut : false (compatibilite avec le comportement existant).
84 + pub accept_form_body: bool,
85 + /// Taille max du body bufferise. Defaut : 1 MiB.
86 + pub max_form_body_bytes: usize,
87 +}
88 +
89 +impl Default for CsrfProtectionConfig {
90 + fn default() -> Self {
91 + Self {
92 + accept_form_body: false,
93 + max_form_body_bytes: 1024 * 1024,
94 + }
95 + }
96 +}
97 +
98 +/// Nouveau constructeur pour apps SSR.
99 +pub async fn csrf_protection_with_config(
100 + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
101 + session: Session,
102 + uri: Uri,
103 + request: Request,
104 + next: Next,
105 +) -> Result<Response, AppError> {
106 + // ... flow existant + branche buffer-body si cfg.accept_form_body
107 +}
108 +```
109 +
110 +**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework.
111 +**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered).
112 +
113 +#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>`
114 +
115 +Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware :
116 +
117 +```rust
118 +// Nouveau dans rustwarden_core::middleware::csrf
119 +/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded`
120 +/// URL-encode. Retourne `None` si le champ est absent ou le body malforme.
121 +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... }
122 +```
123 +
124 +**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques.
125 +**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle.
126 +
127 +#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie
128 +
129 +```rust
130 +// Dans rustwarden_core (derriere feature = "ssr-forms")
131 +pub async fn csrf_protection_ssr(
132 + State(db): State<DatabaseConnection>,
133 + session: Session,
134 + uri: Uri,
135 + request: Request,
136 + next: Next,
137 +) -> Result<Response, AppError> {
138 + // Flow complet avec buffer-body + validation
139 +}
140 +```
141 +
142 +**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite.
143 +**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature.
144 +
145 +**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee.
146 +
147 +**Criteres d'acceptation** pour le PR upstream :
148 +- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat).
149 +- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`.
150 +- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body.
151 +- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser).
152 +- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement.
153 +- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413.
154 +- [ ] Doc inline precisant la trade-off buffer vs streaming.
155 +
156 +**Migration cote gitrust apres merge upstream** :
157 +1. Bump version rustwarden-core dans `Cargo.toml`.
158 +2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests.
159 +3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`.
160 +4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu").
161 +
162 +### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22
163 +
164 +**Status** : Implemente en Phase 1.1 du plan d'evolution.
165 +
166 +Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee.
167 +
168 +---
169 +
170 +### 2bis. Details historiques (avant resolution)
171 +
172 +**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client.
173 +
174 +**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`.
175 +
176 +**Proposition** : ajouter un constructeur async qui fait le fetch du token :
177 +
178 +```rust
179 +impl LayoutContext {
180 + /// Version async qui peuple `csrf_token` depuis la session tower-sessions.
181 + /// Fallback silencieux sur `""` si la session est indisponible (log warn).
182 + pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self {
183 + let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session)
184 + .await
185 + .unwrap_or_default();
186 + Self::new(title, &token)
187 + }
188 +}
189 +```
190 +
191 +**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues.
192 +**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`.
193 +
194 +**Criteres d'acceptation** :
195 +- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat).
196 +- [ ] Nouveau `from_session` disponible, teste avec une session mock.
197 +- [ ] Doc inline avec exemple de handler qui l'utilise.
198 +
199 +**Migration cote gitrust apres merge** :
200 +1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`.
201 +2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant.
202 +
203 +### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22
204 +
205 +**Status** : Implemente en Phase 1.2 du plan d'evolution.
206 +
207 +Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees.
208 +
209 +---
210 +
211 +### 3bis. Details historiques (avant resolution)
212 +
213 +**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`.
214 +
215 +**Proposition** : deduplication via une fonction interne commune + 2 wrappers :
216 +```rust
217 +async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... }
218 +pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... }
219 +pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... }
220 +```
221 +
222 +**Priorite** : basse (DRY seulement, pas de bug).
223 +
224 +### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23
225 +
226 +**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
227 +
228 +Livre :
229 +- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`).
230 +- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`.
231 +- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check.
232 +- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s).
233 +- Prelude exporte `PersonalTokenService` et `TokenScope`.
234 +- 15 tests (5 entity + 10 integration DB).
235 +
236 +---
237 +
238 +### 4bis. Details historiques (avant resolution)
239 +
240 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11).
241 +
242 +**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel.
243 +
244 +**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre :
245 +- Token SHA-256 en base, verifie a chaque appel (pas de rotation).
246 +- Scopes structures (`read:repo`, `write:repo`, `admin:repo`).
247 +- `last_used_at` pour UX et nettoyage.
248 +- Expiration optionnelle (`expires_at NULL` = token permanent).
249 +
250 +**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee :
251 +- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at).
252 +- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond).
253 +- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`.
254 +- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust).
255 +
256 +**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur.
12 257
13 258 ```rust
14 -pub async fn delete_user(
15 - db: &DatabaseConnection,
16 - user_id: Uuid,
17 - hooks: Option<&dyn RustwardenHooks>,
18 -) -> Result<(), AppError>
259 +pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
260 + fn as_str(&self) -> &str;
261 + fn parse(s: &str) -> Option<Self>;
262 +}
263 +
264 +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> }
265 +
266 +impl<S: TokenScope> PersonalTokenService<S> {
267 + pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>;
268 + pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>;
269 + pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>;
270 + pub async fn revoke(db, token_id, user_id) -> Result<()>;
271 + pub async fn cleanup_expired(db) -> Result<u64>;
272 +}
19 273 ```
20 274
21 -Le parametre `hooks` est optionnel (`None` si pas de nettoyage metier necessaire).
275 +Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`.
276 +
277 +**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app.
278 +**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe).
279 +
280 +**Criteres d'acceptation** :
281 +- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app.
282 +- [ ] Hash SHA-256 en base, token clair jamais stocke.
283 +- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`.
284 +- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent.
285 +- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT).
286 +
287 +**Migration cote gitrust apres merge** :
288 +1. Bump `rustwarden-core`.
289 +2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`.
290 +3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP).
291 +
292 +### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23
293 +
294 +**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
295 +
296 +Livre :
297 +- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal.
298 +- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests.
299 +- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests.
300 +- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert.
301 +- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`.
302 +
303 +Total : 22 tests nouveaux sur DB reelle, 0 regression.
22 304
23 305 ---
24 306
25 -## 2. `UserService::unassign_role_from_user` — IMPLEMENTE
307 +### 5bis. Details historiques (avant resolution)
26 308
27 -**Statut : IMPLEMENTE**
309 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14).
28 310
29 -Methode ajoutee avec protection contre le retrait du role admin au dernier administrateur.
311 +**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe.
312 +
313 +**Limitation du framework** : rustwarden-core n'expose que :
314 +- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`.
315 +- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`).
316 +
317 +**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger.
318 +
319 +**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee :
320 +- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at).
321 +- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present).
322 +- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`.
323 +
324 +**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`.
30 325
31 326 ```rust
32 -pub async fn unassign_role_from_user(
33 - db: &DatabaseConnection,
34 - user_id: Uuid,
35 - role_id: Uuid,
36 -) -> Result<(), AppError>
327 +pub struct TeamService;
328 +
329 +impl TeamService {
330 + pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>;
331 + pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>;
332 + pub async fn remove_member(db, team_id, user_id) -> Result<()>;
333 + pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>;
334 + pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>;
335 + pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>;
336 +}
337 +
338 +// Evolution de ResourceService
339 +pub enum Principal { User(Uuid), Team(Uuid) }
340 +impl ResourceService {
341 + pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>;
342 + pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>;
343 + // Calcule max(perm_direct, max(perm_via_teams))
344 +}
37 345 ```
38 346
347 +**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit.
348 +**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join).
349 +
350 +**Criteres d'acceptation** :
351 +- [ ] Migration `teams` + `team_members` ajoutable au migrator.
352 +- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust.
353 +- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie).
354 +- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous).
355 +- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`.
356 +- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source.
357 +
358 +**Migration cote gitrust apres merge** :
359 +1. Bump `rustwarden-core`.
360 +2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework.
361 +3. Ajouter UI `/teams` + templates.
362 +4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`.
363 +
364 +### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22
365 +
366 +**Status** : Implemente en Phase 2.3 du plan d'evolution.
367 +
368 +Livre :
369 +- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`.
370 +- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON.
371 +- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.).
372 +- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400.
373 +
39 374 ---
40 375
41 -## 3. `UserService::admin_reset_password` — IMPLEMENTE
376 +### 6bis. Details historiques (avant resolution)
377 +
378 +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`).
42 379
43 -**Statut : IMPLEMENTE**
380 +**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.).
44 381
45 -Methode ajoutee avec :
46 -- Hash bcrypt async (via `spawn_blocking`)
47 -- `must_change_password = true` force au prochain login
48 -- `password_changed_at` mis a jour
49 -- SEC-M7 : revocation de tous les refresh tokens
382 +**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont :
383 +- **Internes** a chaque service, non reutilisables.
384 +- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire.
385 +- Pas d'API publique `RateLimitService::check(key, per_minute)`.
386 +
387 +Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login).
388 +
389 +**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee :
390 +- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante.
391 +- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`.
392 +- Pas de persistence (reset au restart acceptable pour ce scope).
393 +
394 +**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding).
50 395
51 396 ```rust
52 -pub async fn admin_reset_password(
53 - db: &DatabaseConnection,
54 - user_id: Uuid,
55 - new_password: &str,
56 -) -> Result<(), AppError>
397 +pub struct RateLimitService {
398 + buckets: Arc<DashMap<String, WindowCounter>>,
399 +}
400 +
401 +impl RateLimitService {
402 + pub fn new() -> Self;
403 +
404 + /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement.
405 + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>;
406 +
407 + /// Variante avec fenetre custom (ex. 1h pour reset password).
408 + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>;
409 +
410 + /// Purge periodique des buckets expires (tache de fond).
411 + pub async fn cleanup_loop(&self, interval: Duration);
412 +}
57 413 ```
58 414
415 +Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`.
416 +
417 +**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`).
418 +**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1).
419 +
420 +**Criteres d'acceptation** :
421 +- [ ] `RateLimitService::check(key, per_minute)` public.
422 +- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`.
423 +- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif).
424 +- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes).
425 +- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere.
426 +
427 +**Migration cote gitrust apres merge** :
428 +1. Bump `rustwarden-core`.
429 +2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement.
430 +3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import.
431 +
432 +### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23
433 +
434 +**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
435 +
436 +Livre :
437 +- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`.
438 +- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop.
439 +- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables.
440 +- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration).
441 +- Feature `redis-bus` documentee comme extension v2 (hors scope v1).
442 +
59 443 ---
60 444
61 -## 4. `UserService::change_password_direct` — IMPLEMENTE
445 +### 7bis. Details historiques (avant resolution)
446 +
447 +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`).
448 +
449 +**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events :
450 +- Barre de progression d'import de repo (`GET /imports/:id/stream`).
451 +- Notifications CI (deja en prod cote gitrust — Phase 5).
452 +- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR.
62 453
63 -**Statut : IMPLEMENTE**
454 +**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`).
64 455
65 -Methode ajoutee avec :
66 -- Verification de l'ancien mot de passe
67 -- Validation de complexite via `validate_password_complexity`
68 -- Hash bcrypt async
69 -- `must_change_password = false`, `password_changed_at` mis a jour
70 -- SEC-M7 : revocation de tous les refresh tokens
456 +**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`.
457 +
458 +**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut.
71 459
72 460 ```rust
73 -pub async fn change_password_direct(
74 - db: &DatabaseConnection,
75 - user_id: Uuid,
76 - old_password: &str,
77 - new_password: &str,
78 -) -> Result<(), AppError>
461 +#[async_trait]
462 +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync {
463 + /// Publie a tous les abonnes matchant le filtre.
464 + async fn publish(&self, event: T);
465 +
466 + /// Souscrit avec filtre. Retourne un flux tokio-stream.
467 + fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static)
468 + -> Pin<Box<dyn Stream<Item = T> + Send>>;
469 +}
470 +
471 +pub struct InMemoryBus<T> { tx: broadcast::Sender<T> }
472 +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... }
473 +
474 +// Helper axum pour convertir une souscription en reponse SSE
475 +pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>;
79 476 ```
80 477
478 +Feature optionnelle `redis-bus` pour impl multi-instance.
479 +
480 +**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete).
481 +**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep.
482 +
483 +**Criteres d'acceptation** :
484 +- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`.
485 +- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut).
486 +- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher.
487 +- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`).
488 +- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`).
489 +
490 +**Migration cote gitrust apres merge** :
491 +1. Bump `rustwarden-core`.
492 +2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework.
493 +3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`.
494 +4. Publier `ImportProgress` sur le bus depuis `import_worker`.
495 +
496 +### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22
497 +
498 +**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
499 +
500 +Ajoutes avec impl par defaut no-op (backwards-compatible) :
501 +- `on_resource_registered(db, resource_type, resource_id, owner_id)`
502 +- `on_resource_unregistered(db, resource_type, resource_id)`
503 +- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)`
504 +- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)`
505 +- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique
506 +
507 +`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests).
508 +
81 509 ---
82 510
83 -## Hook ajoute : `on_user_deleted`
511 +### 8bis. Details historiques (avant resolution)
512 +
513 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR).
514 +**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques.
515 +
516 +**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements :
517 +- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted`
518 +- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login`
519 +- RBAC : `on_role_changed`
520 +- Resources : `on_resource_shared`, `on_resource_unshared`
521 +
522 +**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent :
523 +- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service.
524 +- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5).
525 +- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook).
526 +
527 +**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs).
84 528
85 -**Trait `RustwardenHooks`** — nouveau hook :
529 +**Workaround actuel cote gitrust** :
530 +- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add).
531 +- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants).
532 +
533 +**Proposition upstream** : evoluer `RustwardenHooks` en deux temps.
534 +
535 +**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) :
86 536
87 537 ```rust
88 -async fn on_user_deleted(
89 - &self,
90 - _db: &DatabaseConnection,
91 - _user_id: Uuid,
92 - _username: &str,
93 -) -> anyhow::Result<()> {
94 - Ok(())
538 +#[async_trait]
539 +pub trait RustwardenHooks: Send + Sync {
540 + // Existants
541 + async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) }
542 + async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) }
543 + async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) }
544 + async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) }
545 +
546 + // Nouveaux
547 + async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
548 + async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) }
549 + async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
550 + async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) }
551 + async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
552 + async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
553 +
554 + // Generique (escape hatch pour evenements custom)
555 + async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) }
556 +}
557 +
558 +pub struct AuditEvent<'a> {
559 + pub kind: &'a str, // ex. "issue.created", "pr.merged"
560 + pub actor_id: Option<Uuid>,
561 + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id)
562 + pub metadata: &'a serde_json::Value,
95 563 }
96 564 ```
97 565
98 -Appele par `delete_user` avant la suppression en base. Permet aux consommateurs de
99 -nettoyer leurs donnees metier (walls, repos, workspaces, etc.).
566 +**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison.
567 +
568 +**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService).
569 +**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework.
570 +
571 +**Criteres d'acceptation** :
572 +- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif).
573 +- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage.
574 +- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B).
575 +- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee.
576 +
577 +**Migration cote gitrust apres merge** :
578 +1. Bump `rustwarden-core`.
579 +2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`).
580 +3. Implementer `on_team_*` quand LOT 14 arrive.
581 +4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local.
100 582
101 583 ---
102 584
103 -## Contournements obsoletes dans les projets consommateurs
585 +## Protocole de remontee upstream
104 586
105 -Les contournements suivants peuvent etre remplaces par les methodes du framework :
587 +Quand une amelioration est mergee upstream :
588 +1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`.
589 +2. Supprimer la section correspondante de ce fichier.
590 +3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version).
106 591
107 -| Contournement | Remplacer par |
108 -|--------------|---------------|
109 -| `Entity::delete_by_id()` direct | `UserService::delete_user(db, user_id, Some(&hooks))` |
110 -| `user_role::Entity::delete_many()` direct | `UserService::unassign_role_from_user(db, user_id, role_id)` |
111 -| `hash_password()` + `update_password_hash()` manuels | `UserService::admin_reset_password(db, user_id, new_password)` |
112 -| `verify_password()` + `hash_password()` + `update_password_hash()` | `UserService::change_password_direct(db, user_id, old, new)` |
592 +Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif.
A docs/plans/2026-04-22-upstream-rustwarden-evolution.md
+494 -0
@@ -0,0 +1,494 @@
1 +# Plan d'evolution Rustwarden — roadmap upstream etape par etape
2 +
3 +**Source** : [docs/UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md)
4 +**Date** : 2026-04-22
5 +**Auteur** : Claude Code (audit + planification)
6 +
7 +## Context
8 +
9 +Rustwarden est consomme par plusieurs projets (Gitrust, PasterMan). Ces consommateurs ont identifie 8 gaps dans le framework qui les forcent a dupliquer de la logique ou a ecrire des workarounds locaux. Ce plan traduit les propositions du doc upstream en une sequence d'implementation verifiee, ordonnee par impact/risque, avec criteres d'acceptation concrets.
10 +
11 +**Verification prealable effectuee** (2026-04-22) :
12 +
13 +| # | Gap | Verdict |
14 +|---|---|---|
15 +| 1 | CSRF form body | Confirme — [csrf.rs:73-84](../../crates/rustwarden-core/src/middleware/csrf.rs#L73-L84) |
16 +| 2 | `LayoutContext::from_session` | Confirme — [templates/mod.rs:42-51](../../crates/rustwarden-core/src/templates/mod.rs#L42-L51) |
17 +| 3 | `csrf_protection_impl` duplique | Confirme — [csrf.rs:123](../../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [L175](../../crates/rustwarden-core/src/middleware/csrf.rs#L175) |
18 +| 4 | PAT service | Confirme — absent |
19 +| 5 | TeamService | Confirme — absent |
20 +| 6 | RateLimitService generique | Confirme + aggrave — 2 copies quasi-identiques (`AuthRateLimiter` [L307](../../crates/rustwarden-core/src/middleware/rate_limit.rs#L307) et `PublicShareRateLimiter` [L96](../../crates/rustwarden-core/src/middleware/rate_limit.rs#L96)) + 3 rate limiters inlines dans les services password/email |
21 +| 7 | SSE broadcaster | Confirme — absent |
22 +| 8 | Hooks limites | **Obsolete a corriger** — [hooks.rs](../../crates/rustwarden-core/src/hooks.rs) expose deja 12 hooks. Manquent vraiment : `on_team_*`, `on_audit_event`, `on_resource_registered/unregistered` |
23 +
24 +## Principes directeurs
25 +
26 +- **Backwards-compat systematique** : toute API publique existante reste intacte. Les nouveautes arrivent en ajout (`*_with_config`, `from_session`, nouvelles methodes trait avec defaults).
27 +- **Dog-fooding** : chaque nouveau service generique (rate limit, bus SSE) refactore les impls internes existantes pour eviter les duplications.
28 +- **Tests d'abord** pour les refactors a risque (Phase 5 ResourceService) : couverture >95% avant modification.
29 +- **Bump de version semver** : ajouts mineurs = minor bump, breaking = major (aucun breaking prevu dans ce plan).
30 +
31 +---
32 +
33 +## Phase 1 — Quick wins (1-2 jours, risque faible)
34 +
35 +### Etape 1.1 — `LayoutContext::from_session()`
36 +
37 +**Fichiers** :
38 +- [crates/rustwarden-core/src/templates/mod.rs](../../crates/rustwarden-core/src/templates/mod.rs) — ajouter methode async
39 +- [crates/rustwarden-core/src/lib.rs](../../crates/rustwarden-core/src/lib.rs) — verifier re-export dans `prelude`
40 +
41 +**Implementation** :
42 +```rust
43 +impl LayoutContext {
44 + pub async fn from_session(session: &tower_sessions::Session, page_title: &str) -> Self {
45 + let token = crate::middleware::csrf::get_or_create_csrf_token(session)
46 + .await
47 + .unwrap_or_default();
48 + Self::new(page_title, &token)
49 + }
50 +}
51 +```
52 +
53 +**Critere d'acceptation** :
54 +- [ ] `LayoutContext::new` inchangee.
55 +- [ ] Nouvelle methode `from_session` disponible.
56 +- [ ] Test unitaire avec session mock (tower-sessions memory store).
57 +- [ ] Doc inline avec exemple handler.
58 +
59 +### Etape 1.2 — Dedup CSRF middleware
60 +
61 +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](../../crates/rustwarden-core/src/middleware/csrf.rs)
62 +
63 +**Implementation** : extraire `csrf_check_inner(session, uri, &request) -> Option<Response>` et faire des deux publics (`csrf_protection`, `csrf_protection_impl`) des wrappers minces.
64 +
65 +**Critere d'acceptation** :
66 +- [ ] Les 2 signatures publiques restent identiques.
67 +- [ ] Tests existants passent sans modif.
68 +- [ ] Plus de code duplique entre [L123-L172](../../crates/rustwarden-core/src/middleware/csrf.rs#L123-L172) et [L175-L210](../../crates/rustwarden-core/src/middleware/csrf.rs#L175-L210).
69 +
70 +### Etape 1.3 — Corriger le doc upstream
71 +
72 +**Fichier** : [docs/UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md)
73 +
74 +**Action** : section 8 — remplacer "limites a 4 evenements" par la liste reelle (12 hooks existants) et recentrer la proposition sur les vrais manques.
75 +
76 +---
77 +
78 +## Phase 2 — CSRF SSR + RateLimitService (3-5 jours, risque moyen)
79 +
80 +### Etape 2.1 — `csrf_protection_with_config(accept_form_body)`
81 +
82 +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](../../crates/rustwarden-core/src/middleware/csrf.rs)
83 +
84 +**Dependances a ajouter** : aucune (utiliser `http_body_util::BodyExt::collect` deja transitif via axum).
85 +
86 +**Implementation** :
87 +```rust
88 +#[derive(Clone)]
89 +pub struct CsrfProtectionConfig {
90 + pub accept_form_body: bool,
91 + pub max_form_body_bytes: usize,
92 +}
93 +
94 +impl Default for CsrfProtectionConfig {
95 + fn default() -> Self {
96 + Self { accept_form_body: false, max_form_body_bytes: 1024 * 1024 }
97 + }
98 +}
99 +
100 +pub async fn csrf_protection_with_config(
101 + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
102 + session: Session,
103 + uri: Uri,
104 + request: Request,
105 + next: Next,
106 +) -> Result<Response, AppError> { /* ... */ }
107 +
108 +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { /* url-decode */ }
109 +```
110 +
111 +**Sequence du flow buffered** :
112 +1. Safe method → passthrough.
113 +2. Bearer → passthrough.
114 +3. Header `X-CSRF-Token` present → valider et continuer (pas de buffering).
115 +4. Sinon si `accept_form_body` et Content-Type commence par `application/x-www-form-urlencoded` :
116 + a. `Body::to_bytes` avec cap `max_form_body_bytes` → 413 si depasse.
117 + b. Parse via `form_urlencoded::parse`, extraire `csrf_token`.
118 + c. Valider (constant-time).
119 + d. Reconstruire `Request::from_parts(parts, Body::from(bytes))`.
120 +5. Sinon → 403.
121 +
122 +**Critere d'acceptation** :
123 +- [ ] `csrf_protection` existante inchangee.
124 +- [ ] Nouveau wrapper disponible avec config.
125 +- [ ] `multipart/form-data` skip avec warning (trop risque).
126 +- [ ] Respect `max_form_body_bytes` → 413 + header `Content-Length-Required`.
127 +- [ ] Tests : (a) header valide, (b) form token valide, (c) absent, (d) mismatch, (e) body > cap, (f) multipart ignore.
128 +- [ ] Doc inline avec exemple d'integration dans `RustwardenApp::builder`.
129 +
130 +### Etape 2.2 — `AppError::TooManyRequests`
131 +
132 +**Fichier** : [crates/rustwarden-core/src/error.rs](../../crates/rustwarden-core/src/error.rs)
133 +
134 +**Implementation** :
135 +```rust
136 +pub enum AppError {
137 + // ... existants
138 + TooManyRequests { retry_after_secs: u64 },
139 +}
140 +
141 +// IntoResponse
142 +AppError::TooManyRequests { retry_after_secs } => (
143 + StatusCode::TOO_MANY_REQUESTS,
144 + [("Retry-After", retry_after_secs.to_string())],
145 + Json(json!({ "error": "Too many requests" })),
146 +).into_response(),
147 +```
148 +
149 +### Etape 2.3 — `RateLimitService` generique
150 +
151 +**Fichiers** :
152 +- Nouveau : `crates/rustwarden-core/src/services/rate_limit_service.rs`
153 +- Refactor : [rate_limit.rs](../../crates/rustwarden-core/src/middleware/rate_limit.rs), [password_change_service.rs](../../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service.rs](../../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service.rs](../../crates/rustwarden-core/src/services/email_validation_service.rs)
154 +- [services/mod.rs](../../crates/rustwarden-core/src/services/mod.rs) — declarer le module.
155 +
156 +**Dependance** : `dashmap = "6"` (ajouter dans `Cargo.toml` si absent).
157 +
158 +**Implementation** :
159 +```rust
160 +pub struct RateLimitService {
161 + buckets: Arc<DashMap<String, WindowCounter>>,
162 +}
163 +
164 +struct WindowCounter { count: u32, window_start: Instant }
165 +
166 +impl RateLimitService {
167 + pub fn new() -> Self;
168 + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>;
169 + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>;
170 + pub async fn cleanup_loop(&self, interval: Duration); // task de fond
171 +}
172 +```
173 +
174 +**Refactor dog-food** : remplacer dans cet ordre (tests a chaque etape) :
175 +1. `AuthRateLimiter` → `RateLimitService` avec `key = format!("auth:{ip}")`.
176 +2. `PublicShareRateLimiter` → idem avec `key = format!("share:{ip}")`.
177 +3. Limiteurs inlines dans `password_change_service`, `password_reset_service`, `email_validation_service`.
178 +
179 +**Critere d'acceptation** :
180 +- [ ] `RateLimitService::check(key, per_minute)` public + documente.
181 +- [ ] `AppError::TooManyRequests` avec mapping HTTP 429 + `Retry-After`.
182 +- [ ] Les 5 call-sites internes utilisent le service (suppression de ~200 lignes dupliquees).
183 +- [ ] Tests : 10 OK, 11e refuse, expiration libere, cleanup purge les cles mortes.
184 +- [ ] Cleanup loop cable dans `RustwardenApp::builder` (nouvelle background task).
185 +
186 +---
187 +
188 +## Phase 3 — Hooks d'audit et de ressources (2-3 jours, risque faible)
189 +
190 +### Etape 3.1 — Ajouter hooks manquants
191 +
192 +**Fichier** : [crates/rustwarden-core/src/hooks.rs](../../crates/rustwarden-core/src/hooks.rs)
193 +
194 +**Implementation** :
195 +```rust
196 +pub struct AuditEvent<'a> {
197 + pub kind: &'a str,
198 + pub actor_id: Option<Uuid>,
199 + pub target: Option<(&'a str, Uuid)>,
200 + pub metadata: &'a serde_json::Value,
201 +}
202 +
203 +#[async_trait]
204 +pub trait RustwardenHooks: Send + Sync {
205 + // ... 12 hooks existants inchanges
206 +
207 + async fn on_resource_registered(&self, _db: &DatabaseConnection, _rt: &str, _rid: Uuid, _owner_id: Uuid) -> anyhow::Result<()> { Ok(()) }
208 + async fn on_resource_unregistered(&self, _db: &DatabaseConnection, _rt: &str, _rid: Uuid) -> anyhow::Result<()> { Ok(()) }
209 + async fn on_team_created(&self, _db: &DatabaseConnection, _team_id: Uuid, _owner_id: Uuid) -> anyhow::Result<()> { Ok(()) }
210 + async fn on_team_deleted(&self, _db: &DatabaseConnection, _team_id: Uuid) -> anyhow::Result<()> { Ok(()) }
211 + async fn on_team_member_added(&self, _db: &DatabaseConnection, _team_id: Uuid, _user_id: Uuid) -> anyhow::Result<()> { Ok(()) }
212 + async fn on_team_member_removed(&self, _db: &DatabaseConnection, _team_id: Uuid, _user_id: Uuid) -> anyhow::Result<()> { Ok(()) }
213 + async fn on_audit_event(&self, _db: &DatabaseConnection, _event: AuditEvent<'_>) -> anyhow::Result<()> { Ok(()) }
214 +}
215 +```
216 +
217 +### Etape 3.2 — Cabler `on_audit_event` dans `AuthLoggingService`
218 +
219 +**Fichier** : [crates/rustwarden-core/src/services/auth_logging.rs](../../crates/rustwarden-core/src/services/auth_logging.rs)
220 +
221 +**Action** : chaque `log_*` appelle aussi `hooks.on_audit_event(AuditEvent { kind: "auth.login_success", ... })`. Permet aux consommateurs de brancher monitoring/SIEM sans reecrire la liaison.
222 +
223 +**Critere d'acceptation** :
224 +- [ ] Impl existante `NoopHooks` compile sans modif.
225 +- [ ] Gitrust-hooks existant compile sans modif (defaults no-op).
226 +- [ ] Tests : impl complete recoit chaque nouveau variant ; erreur dans hook propagee (pas swallow).
227 +- [ ] Doc inline avec 3 exemples d'usage de `on_audit_event`.
228 +- [ ] Section 8 de [UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) marquee "resolu upstream".
229 +
230 +---
231 +
232 +## Phase 4 — PersonalTokenService (5-7 jours, risque moyen)
233 +
234 +### Etape 4.1 — Migration + entity SeaORM
235 +
236 +**Fichiers** :
237 +- Nouveau : `crates/rustwarden-core/migrations/src/m{YYYYMMDD}_000NNN_personal_access_tokens.rs`
238 +- Declarer dans [migrations/src/lib.rs](../../crates/rustwarden-core/migrations/src/lib.rs) **et** [src/migrations.rs](../../crates/rustwarden-core/src/migrations.rs) (double declaration obligatoire — cf. CLAUDE.md).
239 +- Nouveau : `crates/rustwarden-core/src/models/personal_access_token.rs` + declaration dans `models/mod.rs`.
240 +
241 +**Schema** :
242 +```sql
243 +CREATE TABLE personal_access_tokens (
244 + id UUID PRIMARY KEY,
245 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
246 + name VARCHAR(128) NOT NULL,
247 + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex
248 + scopes TEXT[] NOT NULL DEFAULT '{}',
249 + expires_at TIMESTAMPTZ NULL,
250 + last_used_at TIMESTAMPTZ NULL,
251 + revoked_at TIMESTAMPTZ NULL,
252 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
253 + CONSTRAINT pat_name_unique_per_user UNIQUE (user_id, name)
254 +);
255 +CREATE INDEX idx_pat_token_hash ON personal_access_tokens(token_hash);
256 +CREATE INDEX idx_pat_user_id ON personal_access_tokens(user_id);
257 +```
258 +
259 +### Etape 4.2 — `PersonalTokenService<S: TokenScope>`
260 +
261 +**Fichier** : nouveau `crates/rustwarden-core/src/services/personal_token_service.rs`
262 +
263 +**Implementation** :
264 +```rust
265 +pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
266 + fn as_str(&self) -> &str;
267 + fn parse(s: &str) -> Option<Self>;
268 +}
269 +
270 +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> }
271 +
272 +impl<S: TokenScope> PersonalTokenService<S> {
273 + pub async fn create(db: &DatabaseConnection, user_id: Uuid, name: &str, scopes: Vec<S>, expires_at: Option<DateTime<Utc>>) -> Result<(PatRecord, String)>;
274 + pub async fn verify(db: &DatabaseConnection, token_clear: &str) -> Result<(PatRecord, Vec<S>)>;
275 + pub async fn list_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<PatRecord>>;
276 + pub async fn revoke(db: &DatabaseConnection, token_id: Uuid, user_id: Uuid) -> Result<()>;
277 + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64>;
278 +}
279 +```
280 +
281 +**Securite** :
282 +- Hash : `sha2::Sha256` (32 bytes → 64 hex). Jamais stocker le clair.
283 +- Generation : 32 octets random hex (64 chars clair) + prefix au choix du consommateur (`gtr_`, `ptm_`).
284 +- Verify : timing-safe via `subtle::ConstantTimeEq` sur le hash.
285 +- `last_used_at` : update async (non bloquant).
286 +
287 +### Etape 4.3 — Background task cleanup
288 +
289 +**Fichier** : [crates/rustwarden-core/src/config/tasks.rs](../../crates/rustwarden-core/src/config/tasks.rs)
290 +
291 +**Action** : ajouter `pat_cleanup_task` qui tourne toutes les heures → `PersonalTokenService::cleanup_expired`.
292 +
293 +**Critere d'acceptation** :
294 +- [ ] Migration ajoutable, rollback testee.
295 +- [ ] Hash SHA-256 verifie en constant-time.
296 +- [ ] API complete (`create`/`verify`/`list`/`revoke`/`cleanup`).
297 +- [ ] Tests : create + verify ok, scope match/mismatch, token expire refuse, revoke idempotent, `last_used_at` update.
298 +- [ ] Doc : pattern d'extension `AuthUser` cote consommateur (JWT puis PAT).
299 +- [ ] Section 4 de [UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) marquee "resolu".
300 +
301 +---
302 +
303 +## Phase 5 — TeamService + ResourceService::Principal (7-10 jours, risque ELEVE)
304 +
305 +**Prerequis** : Phase 3 mergee (hooks `on_team_*`).
306 +
307 +### Etape 5.1 — Couverture de test preventive
308 +
309 +Avant tout touch de `ResourceService` : **porter la couverture a >95%** sur :
310 +- [crates/rustwarden-core/src/services/resource_service.rs](../../crates/rustwarden-core/src/services/resource_service.rs)
311 +- [crates/rustwarden-core/src/services/permission_service.rs](../../crates/rustwarden-core/src/services/permission_service.rs)
312 +
313 +Sans filet, le risque de regression sur un service central est inacceptable.
314 +
315 +### Etape 5.2 — Migrations + entities
316 +
317 +**Fichiers** : migrations + entities `teams`, `team_members`.
318 +
319 +**Schema** :
320 +```sql
321 +CREATE TABLE teams (
322 + id UUID PRIMARY KEY,
323 + slug VARCHAR(64) NOT NULL UNIQUE,
324 + name VARCHAR(128) NOT NULL,
325 + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
326 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
327 +);
328 +CREATE TABLE team_members (
329 + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
330 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
331 + role VARCHAR(32) NOT NULL DEFAULT 'member', -- 'owner' | 'admin' | 'member'
332 + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
333 + PRIMARY KEY (team_id, user_id)
334 +);
335 +CREATE INDEX idx_team_members_user ON team_members(user_id);
336 +```
337 +
338 +### Etape 5.3 — `TeamSlug` value object
339 +
340 +**Fichier** : nouveau `crates/rustwarden-core/src/types/team_slug.rs`
341 +
342 +Regles : lowercase, `[a-z0-9-]{1,64}`, reserved names (`admin`, `api`, `settings`, ...).
343 +
344 +### Etape 5.4 — `TeamService`
345 +
346 +**Fichier** : nouveau `crates/rustwarden-core/src/services/team_service.rs`
347 +
348 +API : `create`, `delete` (avec hook), `add_member`, `remove_member`, `list_members`, `teams_of_user`.
349 +
350 +### Etape 5.5 — `ResourceService::share_with(Principal)`
351 +
352 +**Fichier** : [crates/rustwarden-core/src/services/resource_service.rs](../../crates/rustwarden-core/src/services/resource_service.rs)
353 +
354 +**Implementation** :
355 +```rust
356 +pub enum Principal { User(Uuid), Team(Uuid) }
357 +
358 +impl ResourceService {
359 + // Nouveau (garder share() existant comme wrapper pour backward-compat)
360 + pub async fn share_with(db, rt, rid, principal: Principal, permission) -> Result<()>;
361 +
362 + // Nouveau : calcul effective permission cote user (inclut team memberships)
363 + pub async fn effective_permission_for_user(db, rt, rid, user_id) -> Result<Option<Permission>>;
364 +}
365 +```
366 +
367 +**Requete SQL (single JOIN, pas N+1)** :
368 +```sql
369 +SELECT MAX(permission_level) FROM resource_shares rs
370 +LEFT JOIN team_members tm ON tm.team_id = rs.shared_with_id AND rs.shared_with_type = 'team'
371 +WHERE rs.resource_type = $1 AND rs.resource_id = $2
372 + AND (
373 + (rs.shared_with_type = 'user' AND rs.shared_with_id = $3)
374 + OR (rs.shared_with_type = 'team' AND tm.user_id = $3)
375 + );
376 +```
377 +
378 +**Migration resource_shares** : ajouter colonne `shared_with_type VARCHAR(8) NOT NULL DEFAULT 'user'` avec contrainte `CHECK (shared_with_type IN ('user','team'))`.
379 +
380 +### Etape 5.6 — Tests de charge
381 +
382 +Verifier :
383 +- 1000 users × 10 teams × 100 repos : `effective_permission_for_user` reste < 10ms p99.
384 +- Pas de N+1 (utiliser `sea-orm` query log + count).
385 +
386 +**Critere d'acceptation** :
387 +- [ ] `TeamService` + API complete.
388 +- [ ] `ResourceService::share_with(Principal::User|Team)` + `effective_permission_for_user`.
389 +- [ ] Wrapper de compat : ancien `share(user_id, perm)` appelle `share_with(Principal::User(user_id), perm)`.
390 +- [ ] Suppression team → cascade `team_members` + revoque shares `Team(id)`.
391 +- [ ] Hooks `on_team_*` appeles.
392 +- [ ] Tests : user dans 2 teams (read + write) sur meme repo → effective = write. User retire d'une team perd son acces.
393 +- [ ] Perf : p99 < 10ms sur fixture 1000×10×100.
394 +
395 +---
396 +
397 +## Phase 6 — Bus SSE `NotificationBus<T>` (5-7 jours, risque moyen)
398 +
399 +### Etape 6.1 — Trait + impl in-memory
400 +
401 +**Fichier** : nouveau `crates/rustwarden-core/src/notifications/mod.rs` (nouveau module `pub mod notifications` dans `lib.rs`).
402 +
403 +**Implementation** :
404 +```rust
405 +#[async_trait]
406 +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync {
407 + async fn publish(&self, event: T);
408 + fn subscribe(&self, filter: Arc<dyn Fn(&T) -> bool + Send + Sync>)
409 + -> Pin<Box<dyn Stream<Item = T> + Send>>;
410 +}
411 +
412 +pub struct InMemoryBus<T> {
413 + tx: tokio::sync::broadcast::Sender<T>,
414 +}
415 +
416 +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> {
417 + // publish() envoie sur broadcast (drop si channel vide)
418 + // subscribe() wrap BroadcastStream + filter_map, gere Lagged proprement
419 +}
420 +```
421 +
422 +### Etape 6.2 — Helper `sse_response`
423 +
424 +**Fichier** : `crates/rustwarden-core/src/notifications/sse.rs`
425 +
426 +```rust
427 +pub fn sse_response<T: Serialize + Send + 'static>(
428 + stream: impl Stream<Item = T> + Send + 'static,
429 +) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
430 + Sse::new(stream.map(|ev| Ok(Event::default().json_data(ev).unwrap())))
431 + .keep_alive(KeepAlive::new().interval(Duration::from_secs(30)))
432 +}
433 +```
434 +
435 +### Etape 6.3 — Feature `redis-bus` (optionnelle v2)
436 +
437 +Hors scope v1 : documenter le point d'extension dans la doc du trait, mais pas d'impl.
438 +
439 +**Critere d'acceptation** :
440 +- [ ] Trait + impl memoire.
441 +- [ ] Helper `sse_response` avec KeepAlive 30s.
442 +- [ ] Client disconnect → drop du subscriber, pas de panique publisher.
443 +- [ ] Gestion `Lagged` : skip events perdus, continue stream.
444 +- [ ] Tests : publish + 3 subscribers, filter discriminant, slow consumer.
445 +- [ ] Doc complete : worker publie, handler souscrit, client JS `EventSource`.
446 +
447 +---
448 +
449 +## Recapitulatif et ordre d'execution
450 +
451 +| Phase | Etapes | Effort | Risque | Debloquera |
452 +|---|---|---|---|---|
453 +| 1 | 1.1-1.3 | 1-2 j | Faible | Reduit boilerplate immediat, DRY |
454 +| 2 | 2.1-2.3 | 3-5 j | Moyen | Gitrust SSR CSRF + toutes apps rate-limit |
455 +| 3 | 3.1-3.2 | 2-3 j | Faible | Audit centralise, prereq Phase 5 |
456 +| 4 | 4.1-4.3 | 5-7 j | Moyen | Gitrust LOT 11 (Smart HTTP PAT) |
457 +| 5 | 5.1-5.6 | 7-10 j | **Eleve** | Gitrust LOT 14 (Teams) + PasterMan |
458 +| 6 | 6.1-6.3 | 5-7 j | Moyen | Gitrust imports async, CI logs, PasterMan walls |
459 +
460 +**Chemin critique** : 1 → 2 → 3 → 4 → 6 → 5. Justification :
461 +- Phase 5 (Teams) en dernier car touche `ResourceService` qui est central.
462 +- Phase 3 avant 5 car hooks `on_team_*` doivent preexister.
463 +- Phase 6 (SSE) avant Phase 5 pour livrer de la valeur sans bloquer sur le refactor ResourceService.
464 +
465 +**Total** : ~25-35 jours dev (hors review et integration consommateurs).
466 +
467 +## Verification end-to-end par phase
468 +
469 +Chaque phase se conclut par :
470 +
471 +```bash
472 +# Quality gates (impose par CLAUDE.md)
473 +cargo fmt --check
474 +cargo clippy -- -D warnings
475 +cargo test --workspace
476 +cargo doc --no-deps
477 +
478 +# App de reference tourne
479 +cargo run -p rustwarden-app # demarre sur :3000
480 +
481 +# Integration consommateur (gitrust)
482 +cd ../gitrust && cargo update -p rustwarden-core && cargo test
483 +```
484 +
485 +Et mise a jour :
486 +- [docs/UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) — supprimer la section du gap resolu.
487 +- `CHANGELOG.md` — ajouter l'entree de version.
488 +- Bump `Cargo.toml` (minor pour tous ces ajouts).
489 +
490 +## Hors scope de ce plan
491 +
492 +- Impl Redis du bus SSE (feature v2).
493 +- Migration des apps consommatrices (Gitrust, PasterMan) : faite apres merge upstream, suit le "Protocole de remontee upstream" du doc source.
494 +- CLI wizard update ([tools/rustwarden-cli/src/templates.rs](../../tools/rustwarden-cli/src/templates.rs)) : seulement si l'API du builder change (prevu uniquement Phase 2 pour `csrf_protection_with_config`).
A docs/plans/2026-04-23-feedback-app-cahier-des-charges.md
+498 -0
@@ -0,0 +1,498 @@
1 +# Cahier des charges — Application web de gestion des demandes Rustwarden
2 +
3 +- **Date** : 2026-04-23
4 +- **Auteur** : EBO
5 +- **Statut** : Draft (a valider)
6 +- **Nom de travail** : `rustwarden-forge` (a arbitrer : alternatives `warden-hub`, `rw-backlog`, `feedback-forge`)
7 +- **Type** : Application web externe au framework, dediee a la centralisation et au triage assiste par IA des demandes (bugs, ameliorations, RFC) provenant des apps consommatrices.
8 +
9 +---
10 +
11 +## 1. Vision
12 +
13 +Construire une **application web autonome** qui sert de point d'entree unique pour toutes les demandes touchant le framework Rustwarden (bugs, ameliorations, RFC). L'application :
14 +
15 +- Recoit les demandes via UI web, API REST ou webhooks (GitHub, CLI).
16 +- Utilise un **agent IA** (Claude API) pour pre-triager, classifier, scorer, detecter les doublons et generer des brouillons de RFC.
17 +- Centralise le backlog upstream (P2).
18 +- Pilote la boucle de feedback post-release (P6).
19 +- Genere automatiquement une partie de la documentation transmise aux consommateurs (P3).
20 +
21 +**Principe meta** : l'application est elle-meme construite **sur** rustwarden-core (eat your own dogfood). Elle sert de vitrine, de bench reel et de premier consommateur "non-gitrust" pour stresser le framework.
22 +
23 +---
24 +
25 +## 2. Objectifs mesurables
26 +
27 +| Objectif | Metrique cible |
28 +|----------|---------------|
29 +| Reduire le temps de triage d'une demande | < 2 min (vs ~15 min manuel aujourd'hui) |
30 +| Detecter les doublons | > 90% precision sur jeu de test |
31 +| Suggerer la categorie correcte (bug/improvement/RFC) | > 85% accord avec le mainteneur |
32 +| Generer un brouillon de RFC accepte avec < 20% de modifications | > 50% des cas |
33 +| Tracker l'adoption d'une release par consommateur | 100% visibilite (statut par app) |
34 +| Synchro doc SERVICES_REFERENCE avec le code | 0 drift (regen sur tag) |
35 +
36 +---
37 +
38 +## 3. Personas
39 +
40 +### 3.1 Mainteneur framework (Toi, EBO)
41 +- Recoit toutes les demandes, arbitre, merge.
42 +- Veut voir un **tableau de bord** avec le backlog priorise.
43 +- Veut **valider/corriger** les suggestions de l'agent IA en 1 clic.
44 +- Veut **generer un RFC** sans repartir d'une page blanche.
45 +
46 +### 3.2 Mainteneur d'app consommatrice (Toi en mode "gitrust", futur "PasterMan")
47 +- Detecte un gap pendant l'integration.
48 +- Veut **soumettre la demande sans quitter son terminal** (CLI) ou via UI rapide.
49 +- Veut **suivre l'etat** de ses demandes deposees (acceptee, en cours, livree).
50 +- Veut etre **notifie** quand une release ferme une de ses demandes.
51 +
52 +### 3.3 Agent IA (Claude)
53 +- Lit la demande brute.
54 +- Produit une fiche structuree (categorie, score, doublons, brouillon RFC si applicable).
55 +- Apprend des corrections du mainteneur (RAG sur historique des decisions).
56 +
57 +### 3.4 Contributeur externe (futur)
58 +- Decouvre le framework, soumet un bug.
59 +- Doit pouvoir le faire sans compte (webhook depuis issue GitHub) ou avec compte minimal.
60 +
61 +---
62 +
63 +## 4. Perimetre fonctionnel — par phase
64 +
65 +### Phase 1 : Triage 3 voies (P1)
66 +
67 +**User stories** :
68 +- En tant que consommateur, je depose une demande via formulaire.
69 +- L'agent IA classifie automatiquement : `bug` / `improvement` / `rfc`.
70 +- Le mainteneur peut **overrider** la classification en 1 clic.
71 +- Si `rfc` : un brouillon de RFC est genere a partir du template existant ([RUSTWARDEN_EVOLUTION_PROCESS.md section 3](../RUSTWARDEN_EVOLUTION_PROCESS.md)).
72 +
73 +**Regles de classification** (heuristiques + LLM) :
74 +- Modifie une signature `pub` existante → `rfc`
75 +- Touche au schema DB → `rfc`
76 +- Change un comportement par defaut → `rfc`
77 +- Ajoute une fonctionnalite < 50 LOC, additif, non-breaking → `improvement`
78 +- Reproducer + comportement vs attendu → `bug`
79 +
80 +**Livrables** :
81 +- Formulaire de soumission (3 champs minimum : titre, contexte, comportement attendu).
82 +- Dashboard de triage avec colonnes Kanban (Inbox / Bug / Improvement / RFC / Rejected).
83 +- Action "Override" + justification (alimente l'apprentissage IA).
84 +
85 +### Phase 2 : Backlog centralise (P2)
86 +
87 +**User stories** :
88 +- Toutes les demandes vivent dans **une seule base** (vs `UPSTREAM_RUSTWARDEN.md` par app).
89 +- Filtrage par : consommateur source, categorie, score, statut, tag.
90 +- Vue "par consommateur" : ce que gitrust/PasterMan attendent.
91 +- Vue "par release cible" : qu'est-ce qui rentre dans la 0.9.0 ?
92 +
93 +**Livrables** :
94 +- Modele de donnees (cf section 6).
95 +- Vues filtrables (URL parametree pour bookmark).
96 +- Export markdown : regenere `UPSTREAM_<consommateur>.md` a la demande pour conserver la coherence avec l'ecosysteme actuel.
97 +
98 +### Phase 3 : Generation auto de SERVICES_REFERENCE (P3)
99 +
100 +**User stories** :
101 +- Sur tag git de rustwarden-core, l'app declenche un build de `cargo doc --no-deps` + extraction des items `pub`.
102 +- Genere une section "API auto-generee" dans `SERVICES_REFERENCE.md`.
103 +- Garde la section "Patterns d'usage" ecrite a la main (intacte).
104 +- Ouvre une PR sur le repo framework avec le diff.
105 +
106 +**Livrables** :
107 +- Worker (background task) qui clone rustwarden, lance cargo doc, parse JSON output.
108 +- Templates Tera/Askama pour rendre la section auto.
109 +- Integration GitHub API pour creer la PR.
110 +
111 +### Phase 4 : Templates et channels (P4)
112 +
113 +**User stories** :
114 +- Webhook GitHub : nouvelle issue avec label `rustwarden` → ingest auto dans l'app.
115 +- Endpoint API REST : `POST /api/demands` (auth par token, depuis CLI consommateur).
116 +- Webhook depuis `rustwarden-cli` : `rustwarden-cli report-bug "..."` envoie directement.
117 +
118 +**Livrables** :
119 +- Templates GitHub `.github/ISSUE_TEMPLATE/` deployes sur les repos consommateurs.
120 +- Endpoint `POST /api/demands` documente (OpenAPI).
121 +- Commande `rustwarden-cli feedback` (sous-commande nouvelle).
122 +
123 +### Phase 5 : Gate CI consumer-compat (P5)
124 +
125 +**User stories** :
126 +- Sur PR rustwarden-core touchant du `pub`, l'app declenche un build de chaque consommateur enregistre contre la branche RC.
127 +- Reporte le statut sur la PR (check vert/rouge).
128 +- Liste les API impactees (diff signatures pub).
129 +
130 +**Livrables** :
131 +- GitHub Action `consumer-compat-check.yml` triggere par webhook.
132 +- Dashboard "Compat status par PR".
133 +- Notification Slack/email si rouge.
134 +
135 +### Phase 6 : Adoption tracking post-release (P6)
136 +
137 +**User stories** :
138 +- A chaque release rustwarden-core (tag git), creation auto d'un objet "Release Tracker".
139 +- Pour chaque consommateur enregistre : statut adoption (Pending / Adopted / Regression).
140 +- Le consommateur met a jour son statut via API ou UI.
141 +- Si Regression : creation auto d'un bug lie a la release.
142 +
143 +**Livrables** :
144 +- Webhook tag GitHub → creation tracker.
145 +- UI consommateur : "valider l'adoption de v0.9.0" en 1 clic.
146 +- Email/notif pour rappel J+7 si Pending.
147 +
148 +---
149 +
150 +## 5. Architecture technique
151 +
152 +### 5.1 Stack (alignee Rustwarden)
153 +
154 +| Couche | Choix | Justification |
155 +|--------|-------|---------------|
156 +| Backend | **Rust + Axum 0.8** | Coherence ecosysteme, perf, type safety |
157 +| ORM | **SeaORM** | Idem Rustwarden, reuse competences |
158 +| Auth | **rustwarden-core** (eat-our-own-dogfood) | Premier consommateur non-gitrust → bench framework |
159 +| Templates | **Askama 0.15** | Idem Rustwarden |
160 +| Frontend | **HTMX + Alpine.js** | Idem Rustwarden, SSR-first |
161 +| CSS | **Tailwind 4 + DaisyUI 5** | Idem Rustwarden |
162 +| DB | **PostgreSQL 18** | Idem Rustwarden |
163 +| IA | **Claude API (Anthropic SDK Rust)** | Modeles a jour, prompt caching pour RAG historique |
164 +| Background | **Tokio + SeaORM polling** | Pas de Redis, simple |
165 +| GitHub integration | **octocrab** crate | Standard Rust |
166 +| OpenAPI | **utoipa** crate | Doc auto pour `/api` |
167 +
168 +### 5.2 Modules principaux
169 +
170 +```
171 +rustwarden-forge/
172 +├── crates/
173 +│ ├── forge-core/ # Domain (demand, release, consumer, classification)
174 +│ ├── forge-ai/ # Wrapper Claude API + prompts + RAG
175 +│ ├── forge-github/ # Webhooks, PR creation, CI integration
176 +│ ├── forge-web/ # Handlers Axum + templates Askama
177 +│ └── forge-cli/ # CLI admin (regen doc, replay AI sur historique)
178 +├── migrations/
179 +└── docker-compose.yml
180 +```
181 +
182 +### 5.3 Diagramme de flux (demande type)
183 +
184 +```
185 +[Consommateur]
186 + ├─ via UI web ─→ POST /demands
187 + ├─ via CLI ────→ POST /api/demands (token)
188 + └─ via GitHub ─→ webhook → ingest
189 +
190 + ↓
191 +[Forge-web ingest]
192 + ├─ persist Demand (status=inbox)
193 + └─ enqueue AI job
194 +
195 + ↓
196 +[Forge-ai worker]
197 + ├─ load context (similar past demands via embeddings)
198 + ├─ call Claude API (classify + score + duplicates + draft RFC)
199 + ├─ persist Classification + DraftRFC
200 + └─ notify mainteneur (push/email)
201 +
202 + ↓
203 +[Mainteneur]
204 + ├─ valide/corrige sur dashboard
205 + ├─ assigne a une release cible
206 + └─ (si RFC) genere fichier docs/rfcs/NNNN-*.md via PR auto
207 +
208 + ↓
209 +[Implementation manuelle dans rustwarden-core]
210 + ↓
211 +[Tag release]
212 + ├─ webhook → Forge cree Release Tracker
213 + ├─ regen SERVICES_REFERENCE → PR auto
214 + └─ notif consommateurs concernes
215 +```
216 +
217 +---
218 +
219 +## 6. Modele de donnees (entites SeaORM)
220 +
221 +### `consumer`
222 +Une app consommatrice du framework.
223 +- `id` UUID
224 +- `slug` text unique (`gitrust`, `pasterman`)
225 +- `name` text
226 +- `repo_url` text
227 +- `api_token` text (pour POST /api/demands)
228 +- `auto_compat_check` bool (active P5)
229 +- `created_at` timestamp
230 +
231 +### `demand`
232 +La demande brute deposee.
233 +- `id` UUID
234 +- `title` text
235 +- `body` text (markdown)
236 +- `source` enum (`web_ui`, `api_cli`, `github_webhook`)
237 +- `consumer_id` FK consumer (nullable pour anonyme)
238 +- `external_ref` text (issue GitHub URL si source=webhook)
239 +- `status` enum (`inbox`, `triaged`, `accepted`, `rejected`, `in_progress`, `released`, `regression`)
240 +- `created_at` / `updated_at`
241 +
242 +### `classification`
243 +Sortie de l'agent IA pour une demande.
244 +- `id` UUID
245 +- `demand_id` FK
246 +- `category` enum (`bug`, `improvement`, `rfc`)
247 +- `score` int (0-35, grille existante)
248 +- `score_breakdown` jsonb (7 sous-scores)
249 +- `confidence` float (0-1)
250 +- `duplicates` jsonb (liste demand_ids similaires + similarity)
251 +- `reasoning` text (justification IA)
252 +- `model_version` text (`claude-opus-4-7`)
253 +- `prompt_version` text (versionning des prompts)
254 +- `created_at`
255 +- `overridden_by` UUID nullable (user_id si humain a corrige)
256 +- `override_reason` text nullable
257 +
258 +### `draft_rfc`
259 +Brouillon de RFC genere par l'IA.
260 +- `id` UUID
261 +- `demand_id` FK
262 +- `content` text (markdown complet)
263 +- `status` enum (`draft`, `validated`, `published`)
264 +- `pr_url` text nullable (si publie via PR)
265 +
266 +### `release_tracker`
267 +Suivi adoption d'une release.
268 +- `id` UUID
269 +- `version` text (`0.9.0`)
270 +- `tag_url` text
271 +- `released_at` timestamp
272 +- `closed_at` timestamp nullable
273 +
274 +### `release_adoption`
275 +Statut adoption par consommateur.
276 +- `release_tracker_id` FK
277 +- `consumer_id` FK
278 +- `status` enum (`pending`, `adopted`, `regression`)
279 +- `notes` text
280 +- `updated_at`
281 +
282 +### `compat_check`
283 +Resultat d'un check P5.
284 +- `id` UUID
285 +- `pr_url` text
286 +- `consumer_id` FK
287 +- `status` enum (`pending`, `pass`, `fail`)
288 +- `log_url` text
289 +- `pub_diff` jsonb (liste signatures impactees)
290 +
291 +### Relations
292 +```
293 +consumer 1─N demand
294 +demand 1─1 classification
295 +demand 0─1 draft_rfc
296 +release_tracker 1─N release_adoption
297 +consumer 1─N release_adoption
298 +consumer 1─N compat_check
299 +```
300 +
301 +---
302 +
303 +## 7. Agent IA — design detaille
304 +
305 +### 7.1 Modeles
306 +- **Classification + scoring** : `claude-haiku-4-5` (rapide, cout bas).
307 +- **Generation RFC + reasoning complexe** : `claude-opus-4-7`.
308 +- **Embeddings pour duplicates** : a determiner (Voyage AI ou OpenAI text-embedding-3 pendant qu'Anthropic n'expose pas encore d'API embeddings).
309 +
310 +### 7.2 Prompt structure (versionne)
311 +
312 +```
313 +SYSTEM:
314 +Tu es l'agent de triage de Rustwarden Forge.
315 +Connaissances : [INJECT process complet RUSTWARDEN_EVOLUTION_PROCESS.md]
316 +Regles : [INJECT grille 7 criteres]
317 +Historique : [INJECT 10 demandes similaires + leurs decisions finales]
318 +
319 +USER:
320 +Nouvelle demande:
321 +Titre: {{title}}
322 +Body: {{body}}
323 +Source: {{source}}
324 +Consommateur: {{consumer_slug}}
325 +
326 +Output JSON strict:
327 +{
328 + "category": "bug|improvement|rfc",
329 + "confidence": 0.0-1.0,
330 + "score": { "generalite": 1-5, ... 7 keys },
331 + "score_total": 0-35,
332 + "duplicates": [ { "demand_id": "...", "similarity": 0.0-1.0 } ],
333 + "reasoning": "explication courte",
334 + "draft_rfc": null | "<markdown du brouillon>"
335 +}
336 +```
337 +
338 +### 7.3 Prompt caching
339 +- Cache le SYSTEM prompt (process + grille + 10 historiques) → economie >90% sur tokens repetes.
340 +- Refresh cache toutes les 5 min ou sur changement du process.
341 +
342 +### 7.4 RAG sur historique
343 +- Toutes les `classification` validees alimentent un index vectoriel.
344 +- Pour une nouvelle demande : retrieve top-5 similaires + leur classification finale (avec override si applicable).
345 +- Injection dans le prompt comme exemples few-shot.
346 +
347 +### 7.5 Apprentissage des overrides
348 +- Chaque override mainteneur est un signal fort.
349 +- Les overrides recents (30 derniers) sont prioritaires dans la retrieval.
350 +- Metric tracker : taux d'accord IA/mainteneur par mois (cible : monter au-dessus de 85%).
351 +
352 +### 7.6 Cout estime
353 +- Hypothese : 50 demandes/mois.
354 +- Haiku classification : ~5K tokens entree (cached) + 1K sortie = ~$0.005/demande.
355 +- Opus RFC draft : seulement pour ~30% des demandes = ~$0.05/demande.
356 +- **Total mensuel : < $5**. Negligeable.
357 +
358 +---
359 +
360 +## 8. Securite et acces
361 +
362 +### 8.1 Roles
363 +- `admin` : mainteneur framework (toi). Tout droit.
364 +- `consumer_owner` : un mainteneur d'app consommatrice. Lecture/ecriture sur ses demandes.
365 +- `viewer` : lecture seule du backlog public.
366 +- `bot` : token API pour CLI/webhooks (scoped par consumer).
367 +
368 +### 8.2 Auth
369 +- Login admin/consumer_owner : **rustwarden-core auth** (eat-our-own-dogfood).
370 +- Auth bot : **personal access token** (rustwarden-core `PersonalTokenService`, deja existant).
371 +- Webhook GitHub : signature HMAC verifiee.
372 +
373 +### 8.3 Donnees sensibles
374 +- Pas de secret framework/consommateur stocke.
375 +- Token GitHub pour CI : encrypted en DB (rustwarden-core `EncryptionService`).
376 +- Pas de log des bodies de demandes en clair > 90 jours.
377 +
378 +### 8.4 Rate limiting
379 +- `RateLimitService` (rustwarden-core) sur `/api/demands` : 10/min/consumer.
380 +- Sur l'IA : queue avec back-pressure (max 5 jobs concurrents).
381 +
382 +---
383 +
384 +## 9. Integrations externes
385 +
386 +| Integration | Direction | Usage |
387 +|-------------|-----------|-------|
388 +| GitHub Webhooks | In | Issues labelled `rustwarden` → demand |
389 +| GitHub API (octocrab) | Out | Creation PR (RFC, SERVICES_REFERENCE), commentaires sur PR |
390 +| GitHub Actions | In/Out | Trigger compat-check, recoit resultats |
391 +| Anthropic API | Out | Classification, scoring, draft RFC |
392 +| Voyage/OpenAI Embeddings | Out | Detection doublons |
393 +| Email SMTP | Out | Notifications mainteneur/consommateurs |
394 +| `rustwarden-cli` | In | Sous-commande `feedback` POST /api |
395 +
396 +---
397 +
398 +## 10. MVP vs version complete
399 +
400 +### MVP (4-6 semaines, en solo)
401 +- Phase 1 : triage 3 voies + IA classification.
402 +- Phase 2 : backlog centralise UI.
403 +- Auth via rustwarden-core.
404 +- 1 consommateur enregistre (gitrust).
405 +- Pas de RFC drafting, pas de generation doc, pas de CI gate.
406 +
407 +### V1 complete (3-4 mois)
408 +- Toutes les phases 1-6.
409 +- 2+ consommateurs.
410 +- Generation RFC IA.
411 +- Generation SERVICES_REFERENCE auto.
412 +- CI compat gate operationnel.
413 +
414 +### V2 (futur)
415 +- Plugins pour autres frameworks Rust (devenir un produit standalone).
416 +- Multi-tenant (plusieurs frameworks heberges).
417 +- Analytics (qui contribue, quelle vitesse de cycle...).
418 +
419 +---
420 +
421 +## 11. Roadmap par jalons
422 +
423 +| Jalon | Contenu | Critere de sortie |
424 +|-------|---------|-------------------|
425 +| **M0 — Bootstrap** (1 sem) | Init projet, builder rustwarden, schema DB, login fonctionnel | Login admin OK, page vide |
426 +| **M1 — Phase 1 manuelle** (1 sem) | Formulaire soumission, listing, override manuel sans IA | Une demande peut etre saisie et categorisee a la main |
427 +| **M2 — Phase 1 IA** (2 sem) | Integration Claude API, classification auto, scoring, prompt caching | 10 demandes test classifiees auto, accord >70% |
428 +| **M3 — Phase 2** (1 sem) | Backlog filtres, vues par consommateur, export markdown | Regen `UPSTREAM_RUSTWARDEN.md` identique au manuel |
429 +| **M4 — Phase 4 partielle** (1 sem) | API REST, webhook GitHub, sous-commande CLI | gitrust peut soumettre via 3 canaux |
430 +| **M5 — RFC drafting IA** (2 sem) | Generation brouillon RFC, PR auto sur framework | 1 RFC generee acceptee avec < 30% modifs |
431 +| **M6 — Phase 6 release tracking** (1 sem) | Webhook tag, tracker creation, UI adoption | 1 release tracking complete |
432 +| **M7 — Phase 3 doc auto** (2 sem) | Worker cargo doc, regeneration SERVICES_REFERENCE | PR auto sur tag, diff valide |
433 +| **M8 — Phase 5 CI gate** (2 sem) | GitHub Action, dashboard compat | 1 PR rustwarden testee contre gitrust en CI |
434 +
435 +**Total estime** : ~13 semaines de travail solo, etalable selon disponibilite.
436 +
437 +---
438 +
439 +## 12. Criteres de reussite (KPI)
440 +
441 +| Indicateur | Mesure | Cible J+90 |
442 +|-----------|--------|-----------|
443 +| Taux d'accord IA/mainteneur sur categorie | sur derniers 30 jours | > 85% |
444 +| Detection doublons (precision) | manuel sur 50 cas | > 90% |
445 +| Temps median entre soumission et triage | ms | < 2 min |
446 +| RFC drafts acceptes avec < 20% modifs | ratio | > 50% |
447 +| Apps consommatrices enregistrees | count | >= 2 |
448 +| Ratio demandes traitees / soumises | sur 30j glissants | > 90% |
449 +| Cout mensuel IA | $ | < $20 |
450 +
451 +---
452 +
453 +## 13. Risques et mitigations
454 +
455 +| Risque | Probabilite | Impact | Mitigation |
456 +|--------|------------|--------|-----------|
457 +| L'IA hallucine une RFC plausible mais fausse | Moyenne | Eleve | Mainteneur valide TOUJOURS avant PR, jamais d'auto-merge |
458 +| Drift entre prompt versions et grille du process | Moyenne | Moyen | Prompt versionne, lien vers commit du process file |
459 +| Couplage trop fort avec rustwarden-core (qui evolue) | Eleve | Moyen | Pin minor sur rustwarden-core, bench compat hebdomadaire |
460 +| Solo dev → bus factor 1 | Eleve | Eleve | Rendre le code open-source rapidement, doc d'install detaillee |
461 +| Cout API Anthropic explose | Faible | Faible | Quota mensuel + alerte + fallback Haiku-only |
462 +| Webhook GitHub down → demandes perdues | Faible | Moyen | File d'attente DB pour replay, idempotence sur external_ref |
463 +
464 +---
465 +
466 +## 14. Questions ouvertes a arbitrer
467 +
468 +- [ ] Hebergement : VPS perso, Fly.io, ou self-hosted maison ?
469 +- [ ] Nom definitif (`rustwarden-forge` vs alternatives) ?
470 +- [ ] Open-source des le debut ou prive jusqu'a V1 ?
471 +- [ ] L'app gere-t-elle UNIQUEMENT rustwarden, ou est-elle generique pour N'IMPORTE quel framework Rust (positionnement produit) ?
472 +- [ ] Embeddings : Voyage AI, OpenAI, ou self-hosted (sentence-transformers via candle) ?
473 +- [ ] Authent : OAuth GitHub (login facile contributeurs externes) ou seulement local ?
474 +- [ ] Frequence des syncs avec le repo framework : webhook seul ou polling de garantie ?
475 +- [ ] Place de PasterMan : on attend qu'il soit pret ou on simule un 2eme consommateur pour developper la logique multi-app ?
476 +
477 +---
478 +
479 +## 15. Premiere tache concrete (pour demarrer)
480 +
481 +Si validation du plan :
482 +
483 +1. **Reserver le nom** + creer le repo `rustwarden-forge` (ou nom retenu).
484 +2. **Init workspace Cargo** avec les 5 crates listes section 5.2.
485 +3. **Scaffolder** via `rustwarden-cli start-project` → premier test reel du wizard sur un projet non-trivial.
486 +4. **Migration initiale** : tables `consumer`, `demand`, `classification`.
487 +5. **Premier endpoint** : `POST /demands` (sans IA) + page de listing.
488 +6. **Demo end-to-end** : depot manuel d'une demande, lecture dans la liste.
489 +
490 +A partir de la, iterer M2 → M8 selon roadmap.
491 +
492 +---
493 +
494 +## 16. Notes finales
495 +
496 +Cette application est un **investissement strategique** : elle automatise un process documente mais aujourd'hui purement manuel, et elle sert simultanement de premier consommateur non-trivial du framework. Tout bug decouvert pendant son developpement est un bug evite chez les futurs consommateurs.
497 +
498 +Le risque principal reste la **discipline d'usage** : l'app n'apportera de la valeur que si toutes les demandes y transitent (pas de remontee orale ou par DM qui contournent le process). Cette discipline doit etre encodee dans le `RUSTWARDEN_EVOLUTION_PROCESS.md` une fois l'app en prod : "tout besoin se depose dans Forge, sinon il n'existe pas".
A docs/plans/2026-04-23-rustwarden-ui-evolution.md
+416 -0
@@ -0,0 +1,416 @@
1 +# Plan d'evolution rustwarden-ui — exposer les nouvelles capacites backend
2 +
3 +> **Copie depot** : apres approbation, ce plan doit etre copie dans
4 +> `docs/plans/2026-04-23-rustwarden-ui-evolution.md` (regle CLAUDE.md sur la
5 +> co-localisation plan ↔ code).
6 +
7 +## Context
8 +
9 +`rustwarden-core` a recu 6 phases d'ameliorations (Phases 1-6 du plan upstream) :
10 +PATs, Teams, `Principal` pour partage, rate limit 429, SSE bus, hooks audit,
11 +CSRF form-body middleware, `LayoutContext::from_session`. Cote `rustwarden-ui`,
12 +l'admin existant (users / roles / settings / sessions / preferences / TOTP) ne
13 +couvre **aucune** de ces surfaces : pas de gestion PAT, pas de teams, le
14 +partage de ressources ne sait cibler qu'un user, les 429 ne sont pas formates,
15 +le boilerplate CSRF est toujours manuel dans 10 handlers.
16 +
17 +**But** : livrer les surfaces UI user-facing qui manquent (PATs, Teams,
18 +Sharing avec Principal) sans bloquer sur un admin complet, puis une phase de
19 +finition technique (LayoutContext + CSRF SSR + 429 UX). Choix valide :
20 +- **Portee** : User-facing d'abord (PATs, Teams, Sharing) puis finition.
21 +- **Scopes PAT** : trait `PatScopesProvider` implemente par l'app consommatrice
22 + → UI genere des checkboxes dynamiquement.
23 +
24 +Total estime : ~10-15 jours dev + 2-3 jours finition technique.
25 +
26 +---
27 +
28 +## Stack et conventions (contraintes CLAUDE.md)
29 +
30 +- **Templates** Askama dans `crates/rustwarden-ui/templates/`, struct Template
31 + dans un handler, route dans `src/routes.rs`.
32 +- **CSS** Tailwind 4 + DaisyUI 5 (variables `var(--color-primary)` etc.).
33 +- **Interactions** : HTMX pour formulaire → HTML, Alpine.js pour JSON API
34 + (clipboard, toggle UI), JS natif pour edge cases.
35 +- **Contrat `base.html`** : chaque Template doit fournir `page_title`,
36 + `app_name`, `app_version`, `csrf_token` + (authentifie) `username`, `email`,
37 + `initials`, `avatar_color`, `roles_text`, `nav_items`, `app_domain`,
38 + `app_description`.
39 +- **DTOs** dans `src/dto/`, declares dans `dto/mod.rs`.
40 +- **Zero stub**, `cargo clippy -- -D warnings`, `cargo fmt`.
41 +
42 +Patterns a **reutiliser** :
43 +- [`LayoutContext::from_session`](../../crates/rustwarden-core/src/templates/mod.rs) pour
44 + le boilerplate CSRF (livre en Phase 1 backend).
45 +- [`NavigationDTO::for_user`](../../crates/rustwarden-ui/src/dto/navigation_dto.rs) pour
46 + ajouter les entrees de menu sans dupliquer la logique admin/user.
47 +- [`SidebarSection`](../../crates/rustwarden-ui/src/dto/sidebar_dto.rs) pour les menus
48 + secondaires.
49 +- Pattern handler existant dans
50 + [admin_handler.rs](../../crates/rustwarden-ui/src/handlers/admin_handler.rs) :
51 + `#[derive(Template)] + impl_template_response! + Extension<Claims> +
52 + Services`.
53 +- Pattern formulaire HTMX + CSRF hidden field visible dans
54 + [users_list.html](../../crates/rustwarden-ui/templates/admin/users_list.html).
55 +
56 +---
57 +
58 +## Phase UI-1 — Personal Access Tokens self-service (~3-4 jours)
59 +
60 +### Surface utilisateur
61 +
62 +Nouveau menu "Mes tokens API" dans `/settings/tokens`. L'utilisateur :
63 +- voit la liste de ses PATs actifs (nom, scopes, `last_used_at`, `expires_at`, date de creation),
64 +- cree un nouveau token (nom + scopes + expiration optionnelle),
65 +- voit le plaintext **une seule fois** apres creation (clipboard + warning),
66 +- revoque un token.
67 +
68 +### Nouveau trait framework
69 +
70 +**Fichier** : nouveau `crates/rustwarden-core/src/services/pat_scopes_provider.rs`
71 +
72 +```rust
73 +/// Implemente par l'app consommatrice pour exposer ses scopes a l'UI.
74 +///
75 +/// Le framework ne connait pas la semantique des scopes (RepoScope,
76 +/// WallScope, ...) — ce trait sert de pont pour peupler la liste de
77 +/// checkboxes a la creation d'un PAT.
78 +pub trait PatScopesProvider: Send + Sync {
79 + /// Retourne la liste complete des scopes exposables, tries par categorie.
80 + fn available_scopes(&self) -> Vec<PatScopeDescriptor>;
81 +}
82 +
83 +pub struct PatScopeDescriptor {
84 + pub value: String, // "read:repo"
85 + pub label: String, // "Lire les repos"
86 + pub description: String, // "Clone, fetch, git ls-remote."
87 + pub category: String, // "Repositories"
88 + pub default: bool, // coche par defaut ?
89 +}
90 +```
91 +
92 +L'app branche son provider via le builder : `RustwardenApp::builder().pat_scopes_provider(Arc::new(RepoScopesProvider))`.
93 +
94 +Si aucun provider : l'UI affiche un champ libre text (fallback minimal).
95 +
96 +### Fichiers a creer
97 +
98 +- `crates/rustwarden-ui/src/dto/token_dto.rs` :
99 + - `CreateTokenRequest { name, scopes: Vec<String>, expires_in_days: Option<i64> }`
100 + - `CreateTokenResponse { id, name, plaintext }` (plaintext retourne UNE fois)
101 + - `TokenInfo { id, name, scopes, last_used_at, expires_at, created_at }`
102 + - `ListTokensResponse { tokens: Vec<TokenInfo> }`
103 +- `crates/rustwarden-ui/src/handlers/tokens_handler.rs` :
104 + - `list_tokens` (GET `/settings/tokens`) → template `settings/tokens_list.html`
105 + - `create_token_form` (GET `/settings/tokens/new`) → template `settings/tokens_new.html`
106 + - `create_token` (POST `/settings/tokens`) → template `settings/tokens_created.html` (affiche plaintext + clipboard)
107 + - `revoke_token` (DELETE `/settings/tokens/:id`) → HTMX `hx-swap="outerHTML"`
108 +- `crates/rustwarden-ui/templates/settings/tokens_list.html`,
109 + `tokens_new.html`, `tokens_created.html`.
110 +
111 +### Reutiliser
112 +
113 +- [`PersonalTokenService::<S>::create/verify/list_for_user/revoke`](../../crates/rustwarden-core/src/services/personal_token_service.rs) — cote core.
114 +- Le service est generique sur `S: TokenScope`. L'UI framework **ne peut pas**
115 + instancier `PersonalTokenService<ConcreteScope>`. Solution : un type-erased
116 + wrapper `dyn PatScopesProvider` + storage des scopes en `Vec<String>` brut
117 + dans les handlers UI. Le binding vers le type concret reste dans l'app.
118 +
119 +### Routes a ajouter (dans `routes.rs`)
120 +
121 +```rust
122 +.route("/settings/tokens", get(tokens_handler::list_tokens))
123 +.route("/settings/tokens/new", get(tokens_handler::create_token_form))
124 +.route("/settings/tokens", post(tokens_handler::create_token))
125 +.route("/settings/tokens/:id", delete(tokens_handler::revoke_token))
126 +```
127 +
128 +### Navigation
129 +
130 +Ajouter une entree "Tokens API" dans `NavigationDTO::for_user` sous la section
131 +Settings.
132 +
133 +### Critere d'acceptation
134 +
135 +- [ ] Tests d'integration DB (create → list → revoke → verify echoue apres revoke).
136 +- [ ] Clipboard copy du plaintext fonctionne (Alpine.js).
137 +- [ ] Refresh de la page apres creation ne re-affiche PAS le plaintext.
138 +- [ ] Scope checkboxes dynamiques si provider branche, text field sinon.
139 +- [ ] 429 sur `create` affiche le retry hint (voir Phase Finition).
140 +
141 +---
142 +
143 +## Phase UI-2 — Teams management (~4-5 jours)
144 +
145 +### Surface utilisateur
146 +
147 +**Two niveaux** :
148 +1. `/settings/teams` — user : "Mes teams" (teams dont je suis membre, avec indication du role).
149 +2. `/admin/teams` — admin : liste/CRUD de toutes les teams du tenant + vue membership.
150 +
151 +### Fichiers a creer
152 +
153 +**DTOs** (`src/dto/team_dto.rs`) :
154 +- `TeamInfo { id, slug, name, owner_username, member_count, created_at, my_role: Option<String> }`
155 +- `CreateTeamRequest { slug: String, name: String }`
156 +- `AddMemberRequest { username: String, role: String }` (username pour UX, resolve to user_id cote handler)
157 +- `TeamDetailResponse { team: TeamInfo, members: Vec<TeamMember> }`
158 +
159 +**Handlers** (`src/handlers/teams_handler.rs` — user-side, `src/handlers/admin_teams_handler.rs` — admin-side) :
160 +
161 +User-side :
162 +- `GET /settings/teams` — liste des teams du user.
163 +- `GET /settings/teams/:slug` — detail (membres) si member, sinon 403.
164 +
165 +Admin-side :
166 +- `GET /admin/teams` — liste paginee + search par slug.
167 +- `POST /admin/teams` — creer (form TeamSlug validated).
168 +- `GET /admin/teams/:slug` — detail + form add/remove member.
169 +- `POST /admin/teams/:slug/members` — ajouter un membre (HTMX).
170 +- `DELETE /admin/teams/:slug/members/:user_id` — retirer (HTMX).
171 +- `PUT /admin/teams/:slug/members/:user_id` — changer le role.
172 +- `DELETE /admin/teams/:slug` — supprimer (confirmation HTMX).
173 +
174 +**Templates** :
175 +- `templates/settings/teams_list.html` — cartes "Ma team Backend (Member)" avec lien vers detail.
176 +- `templates/settings/team_detail.html` — liste membres + owner badge, pas d'actions si non-admin.
177 +- `templates/admin/teams_list.html` — table avec actions.
178 +- `templates/admin/team_form.html` — create/edit form.
179 +- `templates/admin/team_detail.html` — membership management.
180 +
181 +### Reutiliser
182 +
183 +- [`TeamService::create/add_member/remove_member/list_members/teams_of_user/delete`](../../crates/rustwarden-core/src/services/team_service.rs) — core.
184 +- [`TeamSlug::parse`](../../crates/rustwarden-core/src/types/team_slug.rs) cote handler pour valider avant DB.
185 +- [`UserService::get_user_by_username`](../../crates/rustwarden-core/src/services/user_service.rs) pour resolve username → uuid dans `AddMemberRequest`.
186 +- Pattern pagination/search de `admin/users_list.html`.
187 +
188 +### Routes
189 +
190 +```rust
191 +// User self-service
192 +.route("/settings/teams", get(teams_handler::list_my_teams))
193 +.route("/settings/teams/:slug", get(teams_handler::team_detail))
194 +// Admin
195 +.route("/admin/teams", get(admin_teams_handler::list_teams).post(admin_teams_handler::create_team))
196 +.route("/admin/teams/:slug", get(admin_teams_handler::team_detail).delete(admin_teams_handler::delete_team))
197 +.route("/admin/teams/:slug/members", post(admin_teams_handler::add_member))
198 +.route("/admin/teams/:slug/members/:user_id", delete(admin_teams_handler::remove_member).put(admin_teams_handler::update_member_role))
199 +```
200 +
201 +### Hooks admin
202 +
203 +Aucun nouveau hook — `TeamService::create/delete/add_member/remove_member`
204 +cablent deja `on_team_*`. Verifier qu'on passe bien le `hooks:
205 +Option<&Arc<dyn RustwardenHooks>>` depuis le handler (pattern identique a
206 +`UserService::delete_user`).
207 +
208 +### Navigation
209 +
210 +- "Teams" dans menu user (sous Settings).
211 +- "Teams" dans menu admin (sidebar).
212 +
213 +### Critere d'acceptation
214 +
215 +- [ ] Creation team avec slug invalide → 400 + message `TeamSlug::parse`.
216 +- [ ] Owner ne peut etre retire des membres (backend deja protege).
217 +- [ ] Suppression team cascade DB (teams + team_members + resource_shares).
218 +- [ ] User non-membre sur `/settings/teams/:slug` → 403.
219 +- [ ] i18n fr/en/de/es au minimum (reutiliser la structure existante).
220 +
221 +---
222 +
223 +## Phase UI-3 — Resource sharing avec Principal (user OR team) (~3-4 jours)
224 +
225 +### Surface
226 +
227 +Partie **transverse** — le framework ne possede pas de pages de partage
228 +specifiques (chaque app consommatrice expose ses ressources). La valeur
229 +livree ici est un **composant UI reutilisable** que les apps consommatrices
230 +inclueront dans leurs pages de partage.
231 +
232 +### Fichiers a creer
233 +
234 +**Composant template reutilisable** :
235 +- `templates/components/share_principal_picker.html` — fragment Askama pour
236 + picker user/team (autocomplete HTMX).
237 +
238 +**DTO** (`src/dto/share_dto.rs`) :
239 +- `SharePrincipalRequest { principal_kind: "user"|"team", principal_id: String, permission_level: String }`
240 +- `ShareEntryView { id, principal_kind, principal_label, permission_level, created_at }`
241 +
242 +**Handler generique** (`src/handlers/share_handler.rs`) :
243 +- `GET /api/share/search-principals?q=alice&types=user,team` — retourne JSON
244 + `[{kind, id, label}]` filtre par prefix. Alimente l'autocomplete.
245 +- Helpers `list_shares_view(db, resource_type, resource_id)` qui
246 + hydrate les shares avec les labels (`username` ou `team.slug`) en
247 + batch — evite les N+1 cote templates.
248 +
249 +### Reutiliser
250 +
251 +- [`ResourceService::share_with(Principal)`](../../crates/rustwarden-core/src/services/resource_service.rs) — core.
252 +- [`ResourceService::list_shares`](../../crates/rustwarden-core/src/services/resource_service.rs) — deja rendu hybride user+team par Phase 5.
253 +- [`ResourceService::effective_permission_for_user`](../../crates/rustwarden-core/src/services/resource_service.rs) pour l'affichage "permission effective" sur la fiche ressource.
254 +
255 +### Routes
256 +
257 +```rust
258 +.route("/api/share/search-principals", get(share_handler::search_principals))
259 +```
260 +
261 +### Design de la requete de search
262 +
263 +```
264 +GET /api/share/search-principals?q=ab&types=user,team&limit=10
265 +→ 200 [
266 + {"kind":"user","id":"uuid...","label":"alice (alice@ex.com)"},
267 + {"kind":"team","id":"uuid...","label":"@backend (Backend Team)"}
268 +]
269 +```
270 +
271 +Implementation :
272 +- `UserService::search_by_prefix(db, q, limit)` — **a ajouter au core** si absent (likely missing).
273 +- `TeamService::search_by_slug_prefix(db, q, limit)` — **a ajouter au core** si absent.
274 +- Rate limit la recherche (`RateLimitService::check("share_search:{user_id}", 60)`).
275 +
276 +### Composant UI
277 +
278 +```html
279 +<!-- share_principal_picker.html -->
280 +<div x-data="{ q: '', results: [], selected: null }" hx-get="/api/share/search-principals"
281 + hx-trigger="keyup changed delay:200ms from:#share-search" hx-target="#share-results">
282 + <input id="share-search" name="q" placeholder="Chercher un user ou @team">
283 + <div id="share-results"></div>
284 + <select name="permission_level">
285 + <option>read</option><option>write</option><option>admin</option>
286 + </select>
287 + <button type="submit">Partager</button>
288 +</div>
289 +```
290 +
291 +### Critere d'acceptation
292 +
293 +- [ ] Autocomplete renvoie user ET team prefixes.
294 +- [ ] Rate limit actif.
295 +- [ ] Selection d'un team partage a tous les membres (verifie via
296 + `effective_permission_for_user` cote test).
297 +- [ ] Composant documente dans `SERVICES_REFERENCE.md` + `DEVELOPER.md` comme
298 + include Askama reutilisable.
299 +
300 +---
301 +
302 +## Phase UI-4 — Finition technique (~2-3 jours)
303 +
304 +Cette phase nettoie 3 dettes techniques qui sont des quick wins maintenant
305 +que l'infrastructure backend existe.
306 +
307 +### 4.1 — Adoption `LayoutContext::from_session`
308 +
309 +Le backend expose `LayoutContext::from_session(session, title)` depuis
310 +Phase 1. Les 10 occurrences actuelles de `LayoutContext::new(title, "")` ou
311 +`LayoutContext::new(title, &csrf_token)` dans :
312 +
313 +- [totp_handler.rs](../../crates/rustwarden-ui/src/handlers/totp_handler.rs)
314 +- [settings_handler.rs](../../crates/rustwarden-ui/src/handlers/settings_handler.rs)
315 +- [home_handler.rs](../../crates/rustwarden-ui/src/handlers/home_handler.rs)
316 +- [login_handler.rs](../../crates/rustwarden-ui/src/handlers/login_handler.rs)
317 +
318 +migrent vers `LayoutContext::from_session(&session, title).await`.
319 +
320 +Retire ~15-20 lignes de boilerplate par handler.
321 +
322 +### 4.2 — Wire `csrf_protection_with_config` pour les formulaires SSR
323 +
324 +Les 10 templates (`settings/security.html`, `admin/settings.html`, etc.)
325 +utilisent deja un `<input type="hidden" name="csrf_token" ...>`. Aujourd'hui
326 +la protection CSRF n'applique pas sur ces forms si JS absent. Wire dans
327 +`routes.rs` :
328 +
329 +```rust
330 +use rustwarden_core::middleware::csrf::{csrf_protection_with_config, CsrfProtectionConfig};
331 +
332 +let csrf_cfg = Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() });
333 +router.layer(from_fn_with_state((db.clone(), csrf_cfg.clone()), csrf_protection_with_config))
334 +```
335 +
336 +Les forms SSR fonctionnent apres ce wiring, sans changer une ligne de
337 +template.
338 +
339 +### 4.3 — 429 UX
340 +
341 +Quand le backend renvoie `AppError::TooManyRequests { retry_after_secs }`, le
342 +flash message actuel affiche "Erreur serveur". Ajouter :
343 +
344 +- Un template partiel `templates/fragments/rate_limit_notice.html`.
345 +- Un error handler cote UI qui, pour 429, extrait le header `Retry-After` et
346 + affiche "Trop de tentatives, reessayez dans N secondes".
347 +- Pour les formulaires HTMX : handler `htmx:responseError` qui affiche un
348 + toast avec le compte a rebours.
349 +
350 +### Critere d'acceptation phase 4
351 +
352 +- [ ] `grep LayoutContext::new` dans `crates/rustwarden-ui/src/handlers/` → 0
353 + occurrences avec token argument vide ou explicite.
354 +- [ ] Un formulaire SSR (ex. `settings/preferences.html`) soumis sans JS
355 + passe la CSRF.
356 +- [ ] Un 429 force affiche le toast avec le retry hint et non "Erreur serveur".
357 +
358 +---
359 +
360 +## Cross-phase : hors scope volontairement
361 +
362 +- **Admin audit log browser** — renvoye a une phase ulterieure (les logs sont
363 + deja en DB via Phase 3, pas bloquant).
364 +- **SSE demo page** — le framework n'emet pas d'events aujourd'hui, `InMemoryBus`
365 + attend d'etre cable par un consommateur. Wait for a real use case.
366 +- **Admin PAT view** (voir/revoquer PATs de tous users) — pas demande
367 + explicitement, risque de galere UX (liste sur un tenant de 10k users).
368 +
369 +## Fichiers critiques modifies (resume)
370 +
371 +**Core** (ajouts mineurs) :
372 +- `crates/rustwarden-core/src/services/pat_scopes_provider.rs` — nouveau trait.
373 +- `crates/rustwarden-core/src/app.rs` — builder `pat_scopes_provider`.
374 +- Potentiellement `UserService::search_by_prefix` et `TeamService::search_by_slug_prefix`.
375 +
376 +**UI** :
377 +- DTOs : `token_dto.rs`, `team_dto.rs`, `share_dto.rs` dans `src/dto/`.
378 +- Handlers : `tokens_handler.rs`, `teams_handler.rs`, `admin_teams_handler.rs`,
379 + `share_handler.rs` dans `src/handlers/`.
380 +- Templates : `settings/tokens_*.html`, `settings/teams_*.html`,
381 + `admin/teams_*.html`, `components/share_principal_picker.html`,
382 + `fragments/rate_limit_notice.html`.
383 +- Routes : edit `src/routes.rs`.
384 +- Navigation : edit `src/dto/navigation_dto.rs`.
385 +
386 +**i18n** :
387 +- `/locales/{fr,en,de,es,it,pt}.yml` — ajouter les cles `tokens.*`, `teams.*`, `sharing.*`.
388 +
389 +## Verification end-to-end
390 +
391 +Apres chaque phase :
392 +
393 +```bash
394 +# Quality gates
395 +cargo fmt --check
396 +cargo clippy --workspace --lib --tests -- -D warnings
397 +cargo test --workspace
398 +
399 +# Smoke test UI
400 +cd database && docker compose up -d
401 +cargo run -p rustwarden-app # port 3000
402 +# Ouvrir http://localhost:3000 et verifier :
403 +# - Phase UI-1 : /settings/tokens affiche la liste, create + revoke fonctionnent
404 +# - Phase UI-2 : /settings/teams liste membership, /admin/teams CRUD complet
405 +# - Phase UI-3 : /api/share/search-principals retourne user+team, composant integrable
406 +# - Phase UI-4 : form SSR sans JS passe, 429 affiche le retry hint
407 +```
408 +
409 +## Ordre d'execution
410 +
411 +1. **UI-1 Tokens** — valeur user immediate, isole des autres phases.
412 +2. **UI-2 Teams** — preparatoire au partage Principal.
413 +3. **UI-3 Sharing Principal** — depend de UI-2 pour tester.
414 +4. **UI-4 Finition** — apres pour beneficier de tout le surfaces testables.
415 +
416 +Total : ~12-18 jours dev hors revue.
A docs/plans/2026-04-24-post-audit-addteams-debt.md
+250 -0
@@ -0,0 +1,250 @@
1 +# Plan — Résolution dette technique post-audit AddTeams
2 +
3 +## Context
4 +
5 +L'audit anti-slop de la branche `AddTeams` (tests verts, clippy 0 warning, fmt OK, 164+21 tests passants) a identifié **quatre items de cohérence produit / contrat framework** non-bloquants techniquement mais à traiter pour que la branche soit vraiment prête à être consommée par des projets externes. Les orientations ci-dessous ont été validées par le Product Owner :
6 +
7 +1. **Teams reste une primitive framework** — pas d'UI par défaut dans `rustwarden-ui`, les apps consommatrices exposent leurs propres handlers/routes team. Documenter l'usage et les hooks suffit.
8 +2. **CLI wizard doit exposer toutes les nouvelles APIs publiques** (Teams, PAT, RateLimit, SSE, hooks `on_team_*`) avec exemples runtime, sauf le scope `PersonalTokenService` qui reste un TODO commenté + lien doc.
9 +3. **Découper `settings_handler.rs`** (proche de 2000 lignes, +691 sur cette branche) en sous-module `handlers/settings/{profile,security,preferences}.rs`. **Breaking change assumé**, documenté dans un CHANGELOG thématique.
10 +4. **9 TODO i18n locale hardcodée `"fr"`** pré-existants — hors scope de ce plan, ticket séparé.
11 +
12 +Résultat visé : la branche `AddTeams` peut être mergée et les nouveaux projets scaffoldés via `rustwarden-cli start-project` découvrent naturellement les primitives Teams / PAT / SSE, avec un contrat framework stable et documenté.
13 +
14 +---
15 +
16 +## Scope
17 +
18 +### In
19 +- Phase A — Découpe settings en sous-module + CHANGELOG thématique
20 +- Phase B — Extension DEVELOPER.md pour Teams, PAT cleanup, SSE, hooks `on_team_*`
21 +- Phase C — MAJ CLI wizard (prelude re-exports + snippets exemples)
22 +- Phase D — Création du ticket différé `docs/BACKLOG_I18N_LOCALE.md`
23 +
24 +### Out
25 +- Ajout de handlers/routes/templates Team dans `rustwarden-ui` → framework-only, PO décision
26 +- Migration inline → `RateLimitService` → **déjà complète** (vérifié : 3 services + middleware utilisent `RateLimitService`)
27 +- Refactor des `#[allow(dead_code)]` sur templates Askama → légitime (contrat `base.html`)
28 +- Traitement des 9 TODO i18n → traité par ticket backlog
29 +- Modification du comportement métier ou de la DB → refactor pur
30 +
31 +---
32 +
33 +## Phase A — Découpe `settings_handler.rs`
34 +
35 +### A.1 Nouvelle arborescence
36 +
37 +```
38 +crates/rustwarden-ui/src/handlers/
39 +├── settings/
40 +│ ├── mod.rs # re-exports + utils communs (LayoutCtx, traductions partagées)
41 +│ ├── profile.rs # username, email, avatar, delete_account
42 +│ ├── security.rs # password change, sessions list/revoke, 2FA entry
43 +│ └── preferences.rs # locale, theme, notifications
44 +```
45 +
46 +Fichier supprimé : `crates/rustwarden-ui/src/handlers/settings_handler.rs`
47 +
48 +### A.2 Répartition des fonctions
49 +
50 +Lire `crates/rustwarden-ui/src/handlers/settings_handler.rs` (+691 l sur cette branche). Déplacer chaque handler selon son domaine :
51 +- `profile_page`, `update_profile`, `delete_account`, `change_username_request` → `profile.rs`
52 +- `change_password_page`, `post_change_password`, `sessions_page`, `revoke_session`, `security_page_entry` → `security.rs`
53 +- `preferences_page`, `update_preferences`, `update_locale`, `update_theme` → `preferences.rs`
54 +
55 +Tests `#[cfg(test)] mod tests` accompagnent leur handler (3 modules tests, pas un seul global).
56 +
57 +Utilitaires partagés (structs `LayoutCtx`-like, helpers i18n répétés) → `settings/mod.rs`.
58 +
59 +### A.3 Ajustements amont
60 +
61 +- `crates/rustwarden-ui/src/handlers/mod.rs` : `pub mod settings_handler;` → `pub mod settings;`
62 +- `crates/rustwarden-ui/src/routes.rs` : réécrire les `use` et les paths `settings_handler::X` → `settings::{profile,security,preferences}::X`. Aucun changement de route HTTP.
63 +
64 +### A.4 CHANGELOG thématique
65 +
66 +Créer `docs/CHANGELOG_SETTINGS_SPLIT.md` (convention existante : `CHANGELOG_FORCE_2FA.md`). Contenu :
67 +- Ancien import : `rustwarden_ui::handlers::settings_handler::X`
68 +- Nouveau : `rustwarden_ui::handlers::settings::{profile|security|preferences}::X`
69 +- Tableau complet des symboles déplacés (liste exhaustive)
70 +- Raison : taille excessive, cohérence avec `totp_handler.rs`
71 +- Date de la release concernée
72 +
73 +### A.5 Vérification phase A
74 +- `cargo build --all-targets` vert
75 +- `cargo clippy --all-targets -- -D warnings` vert
76 +- `cargo fmt --check` vert
77 +- `cargo test -p rustwarden-ui` : tests préfs / profile / security **tous déplacés** passent
78 +- Démarrer `cargo run -p rustwarden-app` et naviguer manuellement : `/settings/profile`, `/settings/security`, `/settings/preferences`, `/settings/security/2fa` → pages rendues, formulaires fonctionnels, sessions listées
79 +
80 +---
81 +
82 +## Phase B — Extension DEVELOPER.md
83 +
84 +La doc API de référence ([docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md)) couvre déjà §9 Teams, §15 Rate limiting, §16 Notifications SSE, §17 Hooks, §7 PersonalTokenService. Le gap est dans [DEVELOPER.md](DEVELOPER.md) — guide d'extension.
85 +
86 +### B.1 Sections à enrichir (pas créer)
87 +
88 +- **§12 Hooks** : ajouter un bloc *"Hooks teams"* listant `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` avec un exemple minimal d'implémentation (audit log). Cf. [crates/rustwarden-core/src/hooks.rs:205-244](crates/rustwarden-core/src/hooks.rs) pour les signatures exactes.
89 +- **§14 Taches de fond** : ajouter un bloc *"Nettoyage PAT"* montrant comment une app consommatrice instancie `PersonalTokenCleanupProcessor<MyAppScope>::new(db).start()` après définition de son enum scope qui implémente `TokenScope`. Pointeur vers §7 de SERVICES_REFERENCE.md pour le trait.
90 +- **§15 Middleware** (ou créer §15bis *"Rate limiting et notifications"*) : pointeur bref vers SERVICES_REFERENCE.md §15 et §16, plus exemple de consommation SSE côté app (3-5 lignes, type `AppEvent` + `sse_response(bus.subscribe(filter))`).
91 +
92 +### B.2 Cross-refs
93 +Dans `docs/SERVICES_REFERENCE.md`, ajouter en bas des sections §9 / §7 / §16 / §17 une note *"Voir DEVELOPER.md §X pour un exemple d'intégration consommateur"*.
94 +
95 +### B.3 Vérification phase B
96 +- `cargo doc --workspace --no-deps` sans warning
97 +- Relecture par PO : les 3 scénarios (hook team, cleanup PAT, SSE) sont exécutables en copier-coller
98 +
99 +---
100 +
101 +## Phase C — MAJ CLI wizard
102 +
103 +Cible : [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) et [tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs).
104 +
105 +### C.1 Prélude : vérifier les re-exports dans `rustwarden-core`
106 +
107 +Lire `crates/rustwarden-core/src/lib.rs` section `prelude` et s'assurer que sont exposés :
108 +- `TeamSlug` (déjà présent selon grep)
109 +- `TeamService`
110 +- `RateLimitService`
111 +- `PersonalTokenService`, `TokenScope`, `PersonalTokenCleanupProcessor`
112 +- `notifications::{NotificationBus, InMemoryBus, EventFilter}` et `notifications::sse::sse_response`
113 +
114 +Ajouter ce qui manque (pure re-exports, 0 impact runtime).
115 +
116 +### C.2 Templates wizard — main.rs généré
117 +
118 +Ajouter dans le `main.rs` template une section commentée runtime-ready :
119 +
120 +```rust
121 +// ---- Optionnel : tâches de fond framework ----
122 +// Personal Access Token cleanup (périodique).
123 +// TODO: définir votre enum Scope (impl TokenScope) et décommenter :
124 +// use rustwarden_core::prelude::PersonalTokenCleanupProcessor;
125 +// PersonalTokenCleanupProcessor::<MyScope>::new(db_arc.clone()).start();
126 +// Voir docs/SERVICES_REFERENCE.md#7-tokens-et-sessions
127 +```
128 +
129 +### C.3 Templates wizard — hooks.rs généré (si applicable)
130 +
131 +Si le wizard génère un fichier `hooks.rs`, ajouter des stubs des nouveaux hooks teams dans l'impl `RustwardenHooks` générée (défaut : ne rien faire, mais présents pour découverte) :
132 +
133 +```rust
134 +async fn on_team_created(&self, _db: &DatabaseConnection, _team_id: Uuid, _creator_id: Uuid) -> anyhow::Result<()> {
135 + Ok(())
136 +}
137 +// idem on_team_deleted, on_team_member_added, on_team_member_removed
138 +```
139 +
140 +Si les hooks ont un default impl, **ne pas surcharger** — juste un commentaire `// Voir RustwardenHooks pour les hooks on_team_*, on_totp_*, on_resource_*`.
141 +
142 +### C.4 README wizard
143 +
144 +Mettre à jour [tools/rustwarden-cli/README.md](tools/rustwarden-cli/README.md) pour mentionner les primitives disponibles out-of-the-box : Auth, RBAC, OAuth, Email, **Teams, PAT, SSE, Rate limiting** (3 dernières sont nouvelles à ajouter).
145 +
146 +### C.5 Vérification phase C
147 +- `cargo run -p rustwarden-cli -- start-project` → scaffold un projet-test dans `/tmp/` avec prompts par défaut
148 +- Le projet généré compile : `cd /tmp/<nom>/ && cargo build`
149 +- Le `main.rs` généré contient le bloc commenté PAT cleanup avec lien vers docs
150 +- `cargo doc -p <projet-généré>` sans warning
151 +
152 +---
153 +
154 +## Phase D — Ticket différé i18n
155 +
156 +Créer `docs/BACKLOG_I18N_LOCALE.md` listant les 9 TODO actuels :
157 +
158 +- [crates/rustwarden-core/src/i18n/extractor.rs:57](crates/rustwarden-core/src/i18n/extractor.rs) — T009 extraction locale
159 +- [crates/rustwarden-core/src/services/email_validation_service.rs:100,220](crates/rustwarden-core/src/services/email_validation_service.rs) — locale depuis user pref
160 +- [crates/rustwarden-core/src/services/password_reset_service.rs:115](crates/rustwarden-core/src/services/password_reset_service.rs)
161 +- [crates/rustwarden-core/src/tasks/password_change_email_retry.rs:197](crates/rustwarden-core/src/tasks/password_change_email_retry.rs)
162 +- [crates/rustwarden-core/src/services/refresh_token_service.rs:548,549,590](crates/rustwarden-core/src/services/refresh_token_service.rs) — device_info, IP, JWT blacklist
163 +- [crates/rustwarden-ui/src/handlers/email_handler.rs:102](crates/rustwarden-ui/src/handlers/email_handler.rs) — pagination count
164 +
165 +Format : problème, fichier, ligne, impact, ébauche de solution. Ce document sert de brief pour un plan ultérieur dédié.
166 +
167 +---
168 +
169 +## Fichiers critiques
170 +
171 +| Fichier | Phase | Action |
172 +|---|---|---|
173 +| [crates/rustwarden-ui/src/handlers/settings_handler.rs](crates/rustwarden-ui/src/handlers/settings_handler.rs) | A | Supprimé, contenu réparti |
174 +| `crates/rustwarden-ui/src/handlers/settings/mod.rs` | A | Créé |
175 +| `crates/rustwarden-ui/src/handlers/settings/profile.rs` | A | Créé |
176 +| `crates/rustwarden-ui/src/handlers/settings/security.rs` | A | Créé |
177 +| `crates/rustwarden-ui/src/handlers/settings/preferences.rs` | A | Créé |
178 +| [crates/rustwarden-ui/src/handlers/mod.rs](crates/rustwarden-ui/src/handlers/mod.rs) | A | `pub mod settings;` |
179 +| [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) | A | Imports + paths |
180 +| `docs/CHANGELOG_SETTINGS_SPLIT.md` | A | Créé |
181 +| [DEVELOPER.md](DEVELOPER.md) | B | §12, §14, §15 enrichies |
182 +| [docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md) | B | Cross-refs vers DEVELOPER.md |
183 +| [crates/rustwarden-core/src/lib.rs](crates/rustwarden-core/src/lib.rs) | C | Prelude re-exports |
184 +| [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) | C | Snippets PAT + hooks teams |
185 +| [tools/rustwarden-cli/README.md](tools/rustwarden-cli/README.md) | C | Liste primitives |
186 +| `docs/BACKLOG_I18N_LOCALE.md` | D | Créé |
187 +
188 +## Fichiers de référence existants (à réutiliser, NE PAS dupliquer)
189 +
190 +- [crates/rustwarden-core/src/hooks.rs:205-244](crates/rustwarden-core/src/hooks.rs) — signatures `on_team_*` canoniques
191 +- [crates/rustwarden-core/src/tasks/personal_token_cleanup.rs](crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) — exemple `start()` à référencer tel quel dans DEVELOPER.md
192 +- [crates/rustwarden-core/src/notifications/mod.rs](crates/rustwarden-core/src/notifications/mod.rs) — doc-comment `//!` qui contient déjà un exemple usable à recopier
193 +- [docs/CHANGELOG_FORCE_2FA.md](docs/CHANGELOG_FORCE_2FA.md) — modèle stylistique pour `CHANGELOG_SETTINGS_SPLIT.md`
194 +
195 +---
196 +
197 +## Règle CLAUDE.md appliquée
198 +
199 +Selon [CLAUDE.md §"Plans et documents de conception"](CLAUDE.md), dès que ce plan est approuvé et **avant toute modification du code**, le copier dans le dépôt :
200 +
201 +```
202 +cp /home/monsieurb/.claude/plans/je-veut-resoudre-cette-smooth-harbor.md \
203 + docs/plans/2026-04-24-post-audit-addteams-debt.md
204 +```
205 +
206 +Puis `git add docs/plans/2026-04-24-post-audit-addteams-debt.md`. Les phases suivantes travaillent sur la copie in-repo.
207 +
208 +---
209 +
210 +## Vérification end-to-end (tous phases)
211 +
212 +1. **Quality gates** :
213 + - `cargo build --all-targets` → exit 0
214 + - `cargo clippy --all-targets -- -D warnings` → exit 0
215 + - `cargo fmt --all --check` → exit 0
216 + - `cargo test --workspace` → 185+ tests verts (régression-safe : aucun test ne doit disparaître ou être `#[ignore]`)
217 + - `cargo doc --workspace --no-deps` → zéro warning
218 +
219 +2. **CLI wizard end-to-end** :
220 + - `cargo run -p rustwarden-cli -- start-project` dans `/tmp/`, prompts par défaut
221 + - Le projet généré `cargo build` avec succès
222 + - Le `main.rs` généré contient le bloc PAT cleanup commenté
223 + - Lien `docs/SERVICES_REFERENCE.md#7-tokens-et-sessions` résolu
224 +
225 +3. **App de référence** :
226 + - `cd database && docker compose up -d`
227 + - `cargo run -p rustwarden-app`
228 + - Navigation manuelle sur `/settings/profile`, `/settings/security`, `/settings/security/2fa`, `/settings/preferences` — chaque page rendue, formulaires fonctionnels
229 + - Login / logout / 2FA / change password cycle complet sans régression
230 +
231 +4. **Docs** :
232 + - DEVELOPER.md §12 / §14 / §15 relus par PO avec les 3 scénarios copier-coller testés (hook team, PAT cleanup, SSE)
233 + - `docs/CHANGELOG_SETTINGS_SPLIT.md` lu — tableau des imports complet
234 + - `docs/BACKLOG_I18N_LOCALE.md` créé — 9 TODO listés avec fichier:ligne
235 +
236 +5. **Lien Docs ↔ Code** :
237 + - Chaque exemple de DEVELOPER.md compile (sous `doc-tests` si possible, sinon revue manuelle)
238 + - Aucun lien markdown cassé dans les 3 docs modifiés
239 +
240 +---
241 +
242 +## Ordre d'exécution recommandé
243 +
244 +1. **Copier le plan dans `docs/plans/`** (règle CLAUDE.md)
245 +2. **Phase D d'abord** (BACKLOG_I18N_LOCALE.md) — 10 min, pas de risque, retire le bruit TODO du radar
246 +3. **Phase C** (wizard) avant Phase A — le wizard doit référencer des APIs stables, donc Phase C fige les exports avant le refactor
247 +4. **Phase B** (docs DEVELOPER.md) — les exemples sont plus clairs avec le wizard mis à jour en référence
248 +5. **Phase A en dernier** (split settings) — c'est le seul refactor avec risque fonctionnel, on le fait avec le reste stable et tests renforcés
249 +
250 +Durée estimée : Phase D 15 min, Phase C 1-2 h, Phase B 1-2 h, Phase A 2-3 h + vérif manuelle navigateur. Total ≈ 5-8 h.
M tools/rustwarden-cli/Cargo.toml
+2 -2
@@ -15,5 +15,5 @@ console = "0.15"
15 15 rand = "0.9"
16 16 chrono = "0.4"
17 17
18 -[lints.clippy]
19 -empty_line_after_doc_comments = "allow"
18 +[lints]
19 +workspace = true
M tools/rustwarden-cli/README.md
+32 -0
@@ -36,6 +36,38 @@ Commandes disponibles :
36 36 | `start-project` | Creer un nouveau projet applicatif Rustwarden |
37 37 | `update-project` | Mettre a jour les crates du framework dans un projet existant |
38 38
39 +## Primitives fournies par le framework
40 +
41 +Chaque projet genere embarque l'ensemble des primitives du framework. Elles
42 +sont toutes accessibles via `rustwarden_core::prelude::*` :
43 +
44 +| Domaine | Types cles | Reference |
45 +|---------|-----------|-----------|
46 +| Authentification | `AuthUser`, `Claims`, `JwtService` | SERVICES_REFERENCE.md §4 |
47 +| RBAC | `RoleService`, `PermissionService` | SERVICES_REFERENCE.md §5 |
48 +| Mots de passe | `PasswordService`, `PasswordChangeService`, `PasswordResetService` | SERVICES_REFERENCE.md §6 |
49 +| Tokens de session | `RefreshTokenService`, `JwtBlacklistService` | SERVICES_REFERENCE.md §7 |
50 +| Personal Access Tokens | `PersonalTokenService<S>`, `TokenScope`, `PersonalTokenCleanupProcessor<S>` | SERVICES_REFERENCE.md §7 |
51 +| Teams | `TeamService`, `TeamSlug`, `TeamRole` | SERVICES_REFERENCE.md §9 |
52 +| Ressources partagees | `ResourceService`, `Principal` | SERVICES_REFERENCE.md §8 |
53 +| OAuth / SSO | `OAuthService`, providers Google/GitHub/Discord/Microsoft | SERVICES_REFERENCE.md §14 |
54 +| Email | `EmailService`, `EmailValidationService`, templates Askama | SERVICES_REFERENCE.md §12 |
55 +| Rate limiting | `RateLimitService` (sliding window generique) | SERVICES_REFERENCE.md §15 |
56 +| Notifications temps-reel (SSE) | `NotificationBus`, `InMemoryBus`, `sse_response` | SERVICES_REFERENCE.md §16 |
57 +| Hooks lifecycle | `RustwardenHooks` (on_user_*, on_team_*, on_totp_*, on_resource_*, on_audit_event) | SERVICES_REFERENCE.md §17, DEVELOPER.md §12 |
58 +
59 +Le `main.rs` genere contient un bloc commente listant les primitives
60 +optionnelles a activer (PAT cleanup, hooks, SSE). Voir `DEVELOPER.md` du
61 +projet genere pour les exemples concrets d'integration.
62 +
63 +cargo run --bin change_password -- admin "ChangeMe123!" 2>&1 | tail -5
64 +cargo run --bin migrate 2>&1 | tail -10
65 +- ralph | Password: happydays123
66 + - joanie | Password: happydays123
67 +
68 +cargo run --bin change_password -- admin "ChangeMe123!"
69 +cargo run --bin change_password -- joanie "happydays123"
70 +cargo run --bin change_password -- richie "happydays123"
39 71 ## Commande `start-project`
40 72
41 73 Le wizard pose une serie de questions pour configurer le projet, puis genere un dossier complet pret a l'emploi.
M tools/rustwarden-cli/src/main.rs
+5 -3
@@ -1,3 +1,5 @@
1 +//! Interactive CLI wizard for scaffolding Rustwarden projects.
2 +
1 3 mod start_project;
2 4 mod templates;
3 5 mod update_project;
@@ -9,7 +11,7 @@ use clap::{Parser, Subcommand};
9 11 #[command(
10 12 name = "rustwarden-cli",
11 13 version,
12 - about = "CLI wizard pour scaffolder des applications Rustwarden"
14 + about = "CLI wizard for scaffolding Rustwarden applications"
13 15 )]
14 16 struct Cli {
15 17 #[command(subcommand)]
@@ -18,9 +20,9 @@ struct Cli {
18 20
19 21 #[derive(Subcommand)]
20 22 enum Commands {
21 - /// Creer un nouveau projet applicatif Rustwarden
23 + /// Create a new Rustwarden application project
22 24 StartProject,
23 - /// Mettre a jour les crates du framework dans un projet existant
25 + /// Update the framework crates in an existing project
24 26 UpdateProject,
25 27 }
26 28
M tools/rustwarden-cli/src/start_project.rs
+2 -8
@@ -533,14 +533,8 @@ fn copy_resources(
533 533 }
534 534
535 535 /// Copy framework documentation relevant to consumer projects.
536 -fn copy_docs(
537 - rustwarden_root: &Path,
538 - project_dir: &Path,
539 -) -> Result<(), Box<dyn std::error::Error>> {
540 - let docs_to_copy = [
541 - "docs/SERVICES_REFERENCE.md",
542 - "docs/MIGRATION_GUIDE.md",
543 - ];
536 +fn copy_docs(rustwarden_root: &Path, project_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
537 + let docs_to_copy = ["docs/SERVICES_REFERENCE.md", "docs/MIGRATION_GUIDE.md"];
544 538
545 539 let dst_dir = project_dir.join("docs");
546 540 fs::create_dir_all(&dst_dir)?;
M tools/rustwarden-cli/src/templates.rs
+40 -0
@@ -147,6 +147,32 @@ fn app_routes() -> Router<DatabaseConnection> {{
147 147 async fn hello() -> &'static str {{
148 148 "Hello from {name}!"
149 149 }}
150 +
151 +// ----------------------------------------------------------------------------
152 +// Primitives framework optionnelles (Teams, PAT, SSE, Hooks)
153 +//
154 +// Voir docs/SERVICES_REFERENCE.md pour la ref d'API complete et
155 +// DEVELOPER.md (sections §12, §14, §15) pour les exemples d'integration.
156 +// ----------------------------------------------------------------------------
157 +//
158 +// Personal Access Token cleanup (tache de fond periodique).
159 +// Le framework fournit un `PersonalTokenCleanupProcessor<S: TokenScope>`. Le
160 +// scope `S` est un enum metier defini par votre app (ex. ApiRead, ApiWrite).
161 +// TODO: definir votre enum Scope + impl TokenScope, puis decommenter dans main
162 +// apres le `.build().await?` mais avant `.run()`.
163 +//
164 +// // use rustwarden_core::prelude::PersonalTokenCleanupProcessor;
165 +// // PersonalTokenCleanupProcessor::<MyScope>::new(db_arc.clone()).start();
166 +//
167 +// Voir: docs/SERVICES_REFERENCE.md#7-tokens-et-sessions
168 +//
169 +// Hooks lifecycle (on_user_registered, on_team_created, on_totp_enabled, ...).
170 +// Implementer `RustwardenHooks` et passer l'instance via `.hooks(MyHooks)` au
171 +// builder. Voir DEVELOPER.md §12.
172 +//
173 +// Notifications temps-reel (SSE):
174 +// rustwarden_core::prelude::{{NotificationBus, InMemoryBus, sse_response}}
175 +// Voir DEVELOPER.md §15 pour un exemple de handler SSE.
150 176 "#,
151 177 ui_line = ui_line,
152 178 migrate_line = migrate_line,
@@ -931,11 +957,25 @@ RustwardenApp::builder()
931 957 | `on_user_registered` | Apres inscription d'un utilisateur |
932 958 | `on_user_login` | Apres connexion reussie |
933 959 | `on_user_logout` | Apres deconnexion |
960 +| `on_user_deleted` | Apres suppression d'un utilisateur |
934 961 | `on_password_changed` | Apres changement de mot de passe |
935 962 | `on_email_verified` | Apres verification de l'email |
936 963 | `on_role_changed` | Apres modification des roles |
964 +| `on_oauth_login` | Apres connexion OAuth/SSO reussie |
965 +| `on_totp_enabled` | Apres activation 2FA/TOTP |
966 +| `on_totp_disabled` | Apres desactivation 2FA/TOTP |
967 +| `on_resource_registered` | Apres enregistrement d'une ressource |
968 +| `on_resource_unregistered` | Apres desenregistrement d'une ressource |
937 969 | `on_resource_shared` | Apres partage d'une ressource |
938 970 | `on_resource_unshared` | Apres revocation d'un partage |
971 +| `on_team_created` | Apres creation d'une team |
972 +| `on_team_deleted` | Apres suppression d'une team |
973 +| `on_team_member_added` | Apres ajout d'un membre dans une team |
974 +| `on_team_member_removed` | Apres retrait d'un membre d'une team |
975 +| `on_audit_event` | Tout evenement audit (authentification, acces admin, etc.) |
976 +
977 +Tous les hooks ont une implementation par defaut vide dans le trait. Vous
978 +n'implementez que ceux qui vous interessent.
939 979
940 980 ---
941 981