ready ?

EBO <eric.bouhana@softalys.com> committé le 2026-03-09 11:51

d29c0a9c8e073b8d5fe893f0daa77a6b910e27d8

1 parent(s)

42 fichiers modifiés +2651 -2027
D Todo.md
+0 -1075
@@ -1,1075 +0,0 @@
1 -# Rustwarden — Spécification d'extraction du framework
2 -
3 -## 1. Vision du projet
4 -
5 -**Rustwarden** est un framework/boilerplate Rust réutilisable de gestion des utilisateurs, extrait du projet **rustcloud**. L'objectif est de fournir un socle complet et prêt à l'emploi pour tout projet web nécessitant :
6 -
7 -- Authentification (login/password, JWT, sessions)
8 -- Gestion des utilisateurs (CRUD, profil, préférences)
9 -- Système de rôles (RBAC)
10 -- Vérification d'email
11 -- Réinitialisation et changement de mot de passe
12 -- Middleware d'authentification et d'autorisation
13 -- Rate limiting
14 -- Évolution future vers OAuth2, OIDC, LDAP
15 -
16 ----
17 -
18 -## 2. Architecture cible
19 -
20 -```
21 -Rustwarden/
22 -├── Cargo.toml # Workspace + package principal
23 -├── .env.sample # Variables d'environnement
24 -├── package.json # Scripts build Tailwind CSS
25 -├── tailwind.config.js # Config Tailwind + thème DaisyUI
26 -├── README.md # Documentation
27 -├── SPECIFICATION.md # Ce document
28 -│
29 -├── static/ # Fichiers statiques servis par Axum
30 -│ ├── css/
31 -│ │ ├── tailwind.src.css # Source Tailwind (import + thème DaisyUI)
32 -│ │ ├── tailwind.css # CSS compilé (généré par npm run build:css)
33 -│ │ └── custom.css # Styles personnalisés (variables, avatar, scrollbar)
34 -│ ├── js/
35 -│ │ ├── htmx.min.js # HTMX (interactions sans rechargement)
36 -│ │ └── alpine.min.js # Alpine.js (composants réactifs)
37 -│ └── images/
38 -│ ├── favicon.svg # Favicon SVG
39 -│ ├── apple-touch-icon.png # Icône Apple Touch
40 -│ ├── logo.svg # Logo SVG
41 -│ └── logo.png # Logo PNG
42 -│
43 -├── migrations/
44 -│ └── src/
45 -│ ├── main.rs # Point d'entrée des migrations
46 -│ ├── lib.rs # Registre des migrations
47 -│ └── m20260305_000001_initial_schema.rs # Migration unique — schéma complet + RBAC
48 -│
49 -├── src/
50 -│ ├── main.rs # Point d'entrée de l'application
51 -│ ├── app_state.rs # État global de l'application
52 -│ │
53 -│ ├── auth/ # Module d'authentification
54 -│ │ ├── mod.rs
55 -│ │ ├── session.rs # Gestion des sessions (Memory/Redis)
56 -│ │ ├── jwt.rs # Service JWT (génération/validation)
57 -│ │ ├── jwt_extractor.rs # Extraction JWT depuis requêtes
58 -│ │ ├── handlers.rs # Handlers auth (login, register, etc.)
59 -│ │ └── extractors.rs # Extracteur AuthUser
60 -│ │
61 -│ ├── api/
62 -│ │ ├── routes.rs # Définition des routes
63 -│ │ ├── middleware/
64 -│ │ │ ├── mod.rs
65 -│ │ │ ├── auth_service.rs # Middleware auth (routes protégées)
66 -│ │ │ ├── admin_service.rs # Middleware admin (vérification rôle)
67 -│ │ │ ├── auth_required.rs # Helpers auth
68 -│ │ │ ├── admin_helper.rs # Helpers admin
69 -│ │ │ └── rate_limit.rs # Rate limiting
70 -│ │ └── handlers/
71 -│ │ ├── login_handler.rs # Pages login/reset/verification
72 -│ │ ├── user_handler.rs # API CRUD utilisateurs
73 -│ │ ├── admin_handler.rs # Interface admin utilisateurs
74 -│ │ └── settings_handler.rs # Profil, sessions, préférences
75 -│ │
76 -│ ├── config/
77 -│ │ ├── mod.rs
78 -│ │ ├── auth.rs # Configuration auth (JWT, sessions, etc.)
79 -│ │ └── database.rs # Configuration base de données
80 -│ │
81 -│ ├── models/ # Entités SeaORM
82 -│ │ ├── mod.rs
83 -│ │ ├── user.rs # Modèle User
84 -│ │ ├── role.rs # Modèle Role
85 -│ │ ├── user_role.rs # Table de liaison User↔Role
86 -│ │ ├── refresh_token.rs # Refresh tokens
87 -│ │ ├── jwt_blacklist.rs # Blacklist JWT
88 -│ │ ├── user_preferences.rs # Préférences utilisateur
89 -│ │ ├── password_reset_token.rs # Tokens de réinitialisation
90 -│ │ ├── email_validation_token.rs # Tokens de validation email
91 -│ │ └── password_change_request.rs # Demandes de changement password
92 -│ │
93 -│ ├── services/ # Logique métier
94 -│ │ ├── mod.rs
95 -│ │ ├── auth_service.rs # Authentification (verify credentials)
96 -│ │ ├── user_service.rs # CRUD utilisateurs
97 -│ │ ├── role_service.rs # Gestion des rôles
98 -│ │ ├── password_service.rs # Hash/validation bcrypt
99 -│ │ ├── password_reset_service.rs # Reset password par email
100 -│ │ ├── password_change_service.rs # Changement password avec confirm email
101 -│ │ ├── jwt_blacklist_service.rs # Blacklist JWT au logout
102 -│ │ ├── refresh_token_service.rs # Refresh tokens et rotation
103 -│ │ ├── email_validation_service.rs # Validation email
104 -│ │ └── auth_logging.rs # Logs d'authentification
105 -│ │
106 -│ ├── templates/ # Code Rust des templates Askama
107 -│ │ ├── mod.rs # Macro impl_template_response, LayoutContext
108 -│ │ ├── email_templates.rs # Structs Askama pour les emails
109 -│ │ └── helpers.rs # Contextes, breadcrumb, sidebar, CSRF, avatar
110 -│ │
111 -│ └── bin/ # Utilitaires CLI
112 -│ ├── seed.rs # Seeding de la base
113 -│ ├── hash_password.rs # Hash un mot de passe
114 -│ └── change_password.rs # Changement de mot de passe CLI
115 -│
116 -└── templates/ # Templates HTML Askama
117 - ├── base.html # Layout HTML de base (Tailwind/DaisyUI/HTMX/Alpine)
118 - ├── base_with_theme.html # Snippet thème utilisateur
119 - ├── login.html # Page login/register avec onglets
120 - ├── home.html # Page d'accueil post-login
121 - ├── profile.html # Page profil utilisateur
122 - ├── email_verified.html # Page de confirmation email vérifié
123 - ├── resend_verification.html # Page renvoi vérification
124 - ├── reset_password.html # Page réinitialisation mot de passe
125 - ├── settings.html # Page paramètres
126 - ├── components/
127 - │ ├── header.html # En-tête avec logo et navigation
128 - │ ├── navbar.html # Barre de navigation DaisyUI
129 - │ ├── navigation.html # Liens de navigation (home, settings, admin)
130 - │ ├── sidebar.html # Sidebar avec sections
131 - │ ├── user_menu.html # Menu utilisateur (avatar, dropdown)
132 - │ ├── breadcrumb.html # Fil d'Ariane
133 - │ └── password_change_badge.html # Badge alerte changement password
134 - ├── fragments/
135 - │ ├── user_menu_dropdown.html # Dropdown : profil, paramètres, déconnexion
136 - │ └── form_message.html # Fragment messages succès/erreur
137 - ├── admin/
138 - │ ├── dashboard.html # Dashboard admin (stats, liens)
139 - │ ├── users_list.html # Liste utilisateurs (recherche, filtres, pagination)
140 - │ ├── user_form.html # Formulaire création/édition utilisateur
141 - │ └── errors.html # Page erreurs admin
142 - ├── settings/
143 - │ ├── password.html # Formulaire changement mot de passe
144 - │ ├── preferences.html # Préférences (thème, langue)
145 - │ └── sessions.html # Sessions actives + révocation
146 - ├── emails/
147 - │ ├── email_validation.html # Email validation adresse
148 - │ ├── password_reset.html # Email réinitialisation password
149 - │ ├── password_changed.html # Notification password changé
150 - │ ├── password_change_confirmation.html # Confirmation changement password
151 - │ ├── welcome.html # Email de bienvenue
152 - │ ├── admin_new_user.html # Notification admin nouvel utilisateur
153 - │ └── account_modified.html # Notification modification compte
154 - └── errors/
155 - ├── 401.html # Non authentifié
156 - ├── 403.html # Accès refusé
157 - └── 500.html # Erreur serveur
158 -```
159 -
160 ----
161 -
162 -## 3. Inventaire complet des éléments à copier depuis rustcloud
163 -
164 -### 3.1 Migration de base de données — UNIQUE ET CONSOLIDÉE
165 -
166 -Puisque Rustwarden est un projet neuf, les 11 migrations incrémentales de rustcloud sont **fusionnées en une seule migration initiale** qui crée le schéma complet avec le RBAC dès le départ.
167 -
168 -**Fichiers à créer :**
169 -
170 -| Fichier | Rôle |
171 -|---|---|
172 -| `migrations/src/main.rs` | Point d'entrée du binaire `migrate` |
173 -| `migrations/src/lib.rs` | Registre des migrations (une seule entrée) |
174 -| `migrations/src/m20260305_000001_initial_schema.rs` | **Migration unique — schéma complet** |
175 -
176 -**Fichiers source rustcloud à consulter** (pour en extraire et fusionner la logique) :
177 -
178 -| Migration rustcloud | Intégrée dans |
179 -|---|---|
180 -| `m20241209_000001_create_users_table` | Table `users` (avec `last_login_at/ip`, `email_verified/at`, `password_changed_at` déjà inclus) |
181 -| `m20241209_000002_create_roles_table` | Table `roles` |
182 -| `m20241209_000003_create_user_roles_table` | Table `user_roles` |
183 -| `m20241209_000004_create_refresh_tokens_table` | Table `refresh_tokens` |
184 -| `m20241211_000005_create_jwt_blacklist_table` | Table `jwt_blacklist` |
185 -| `m20241211_000006_create_user_preferences_table` | Table `user_preferences` |
186 -| `m20241211_000008_add_last_login_to_users` | ⟶ Fusionné dans la table `users` |
187 -| `m20241214_000010_create_password_reset_tokens_table` | Table `password_reset_tokens` |
188 -| `m20241214_000011_create_email_validation_tokens_table` | Table `email_validation_tokens` |
189 -| `m20241214_000012_add_email_verified_to_users` | ⟶ Fusionné dans la table `users` |
190 -| `m20251213_000021_create_password_change_requests_table` | Table `password_change_requests` |
191 -
192 -**Migrations rustcloud EXCLUES** (domaine métier) :
193 -- `m20250127_000018_create_organisations_table` — hors périmètre
194 -- `m20250127_000019_create_organisation_members_table` — hors périmètre
195 -- `m20250127_000020_create_shares_table` — hors périmètre
196 -
197 -**Schéma SQL consolidé** de la migration unique `m20260305_000001_initial_schema` :
198 -
199 -```sql
200 --- =============================================
201 --- Rustwarden — Migration initiale
202 --- Schéma complet : utilisateurs, RBAC, auth
203 --- =============================================
204 -
205 --- 1. Table des utilisateurs (fusion de 3 migrations rustcloud)
206 -CREATE TABLE users (
207 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
208 - username VARCHAR(64) NOT NULL UNIQUE,
209 - email VARCHAR(255) NOT NULL UNIQUE,
210 - password_hash VARCHAR(255) NOT NULL,
211 - must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
212 - last_login_at TIMESTAMPTZ,
213 - last_login_ip VARCHAR(45),
214 - password_changed_at TIMESTAMPTZ,
215 - email_verified BOOLEAN NOT NULL DEFAULT FALSE,
216 - email_verified_at TIMESTAMPTZ,
217 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
218 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
219 -);
220 -
221 --- 2. Table des rôles
222 -CREATE TABLE roles (
223 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
224 - name VARCHAR(64) NOT NULL UNIQUE,
225 - description TEXT,
226 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
227 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
228 -);
229 -
230 --- 3. Table de liaison utilisateurs ↔ rôles (RBAC)
231 -CREATE TABLE user_roles (
232 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
233 - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
234 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
235 - PRIMARY KEY (user_id, role_id)
236 -);
237 -
238 --- 4. Refresh tokens (rotation de tokens JWT)
239 -CREATE TABLE refresh_tokens (
240 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
241 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
242 - token VARCHAR(255) NOT NULL UNIQUE,
243 - expires_at TIMESTAMPTZ NOT NULL,
244 - revoked BOOLEAN NOT NULL DEFAULT FALSE,
245 - revoked_at TIMESTAMPTZ,
246 - replaced_by UUID,
247 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
248 -);
249 -
250 --- 5. Blacklist JWT (tokens invalidés au logout)
251 -CREATE TABLE jwt_blacklist (
252 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
253 - token_hash VARCHAR(255) NOT NULL UNIQUE,
254 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
255 - expires_at TIMESTAMPTZ NOT NULL,
256 - revoked_at TIMESTAMPTZ,
257 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
258 -);
259 -
260 --- 6. Préférences utilisateur
261 -CREATE TABLE user_preferences (
262 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
263 - user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
264 - theme VARCHAR(16) NOT NULL DEFAULT 'auto',
265 - language VARCHAR(8) NOT NULL DEFAULT 'fr',
266 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
267 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
268 -);
269 -
270 --- 7. Tokens de réinitialisation de mot de passe
271 -CREATE TABLE password_reset_tokens (
272 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
273 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
274 - token_hash VARCHAR(255) NOT NULL UNIQUE,
275 - expires_at TIMESTAMPTZ NOT NULL,
276 - used_at TIMESTAMPTZ,
277 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
278 -);
279 -
280 --- 8. Tokens de validation d'email
281 -CREATE TABLE email_validation_tokens (
282 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
283 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
284 - token_hash VARCHAR(255) NOT NULL UNIQUE,
285 - expires_at TIMESTAMPTZ NOT NULL,
286 - used_at TIMESTAMPTZ,
287 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
288 -);
289 -
290 --- 9. Demandes de changement de mot de passe (avec confirmation email)
291 -CREATE TABLE password_change_requests (
292 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
293 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
294 - token_hash VARCHAR(255) NOT NULL UNIQUE,
295 - new_password_hash VARCHAR(255) NOT NULL,
296 - expires_at TIMESTAMPTZ NOT NULL,
297 - confirmed BOOLEAN NOT NULL DEFAULT FALSE,
298 - used_at TIMESTAMPTZ,
299 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
300 -);
301 -
302 --- =============================================
303 --- Index pour les performances
304 --- =============================================
305 -CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
306 -CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
307 -CREATE INDEX idx_jwt_blacklist_token_hash ON jwt_blacklist(token_hash);
308 -CREATE INDEX idx_jwt_blacklist_expires_at ON jwt_blacklist(expires_at);
309 -CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
310 -CREATE INDEX idx_email_validation_tokens_user_id ON email_validation_tokens(user_id);
311 -CREATE INDEX idx_password_change_requests_user_id ON password_change_requests(user_id);
312 -
313 --- =============================================
314 --- Seed RBAC : rôles par défaut
315 --- =============================================
316 -INSERT INTO roles (id, name, description) VALUES
317 - (gen_random_uuid(), 'admin', 'Administrateur avec accès complet'),
318 - (gen_random_uuid(), 'user', 'Utilisateur standard');
319 -```
320 -
321 -> **Note** : Le seed RBAC (insertion des rôles `admin` et `user`) est inclus directement dans la migration pour garantir que les rôles de base existent toujours. Le seed de l'utilisateur admin reste dans `src/bin/seed.rs` car il dépend de variables d'environnement (mot de passe, email).
322 -
323 -### 3.2 Modèles SeaORM (COPIER + ADAPTER)
324 -
325 -| Fichier source | Action | Modifications |
326 -|---|---|---|
327 -| `src/models/user.rs` | Copier | Supprimer les relations avec organisations/shares |
328 -| `src/models/role.rs` | Copier tel quel | Aucune |
329 -| `src/models/user_role.rs` | Copier tel quel | Aucune |
330 -| `src/models/refresh_token.rs` | Copier tel quel | Aucune |
331 -| `src/models/jwt_blacklist.rs` | Copier tel quel | Aucune |
332 -| `src/models/user_preferences.rs` | Copier tel quel | Aucune |
333 -| `src/models/password_reset_token.rs` | Copier tel quel | Aucune |
334 -| `src/models/email_validation_token.rs` | Copier tel quel | Aucune |
335 -| `src/models/password_change_request.rs` | Copier tel quel | Aucune |
336 -
337 -**Modèles à NE PAS copier** :
338 -- Tout modèle lié à organisation, share, files, todo, apps
339 -
340 -### 3.3 Module Auth (COPIER + ADAPTER)
341 -
342 -| Fichier source | Action | Modifications |
343 -|---|---|---|
344 -| `src/auth/mod.rs` | Copier | Supprimer les exports liés au domaine métier |
345 -| `src/auth/session.rs` | Copier tel quel | Aucune — déjà générique |
346 -| `src/auth/jwt.rs` | Copier | Renommer issuer `rustcloud` → `rustwarden` |
347 -| `src/auth/jwt_extractor.rs` | Copier tel quel | Aucune |
348 -| `src/auth/handlers.rs` | Copier | Supprimer les redirections vers `/home` rustcloud, rendre configurable |
349 -| `src/auth/extractors.rs` | Copier tel quel | Aucune — déjà générique |
350 -
351 -### 3.4 Services (COPIER + ADAPTER)
352 -
353 -| Fichier source | Action | Modifications |
354 -|---|---|---|
355 -| `src/services/auth_service.rs` | Copier | Supprimer les dépendances vers le domaine métier |
356 -| `src/services/user_service.rs` | Copier | Retirer les fonctions liées aux organisations/shares, garder uniquement CRUD user + rôles |
357 -| `src/services/role_service.rs` | Copier tel quel | Aucune |
358 -| `src/services/password_service.rs` | Copier tel quel | Aucune |
359 -| `src/services/password_reset_service.rs` | Copier tel quel | Aucune |
360 -| `src/services/password_change_service.rs` | Copier tel quel | Aucune |
361 -| `src/services/jwt_blacklist_service.rs` | Copier tel quel | Aucune |
362 -| `src/services/refresh_token_service.rs` | Copier tel quel | Aucune |
363 -| `src/services/email_validation_service.rs` | Copier tel quel | Aucune |
364 -| `src/services/auth_logging.rs` | Copier tel quel | Aucune |
365 -
366 -### 3.5 Middleware (COPIER + ADAPTER)
367 -
368 -| Fichier source | Action | Modifications |
369 -|---|---|---|
370 -| `src/api/middleware/auth_service.rs` | Copier | Rendre les routes protégées **configurables** au lieu d'un hardcodé `/home`, `/settings`, etc. |
371 -| `src/api/middleware/admin_service.rs` | Copier | Rendre les routes admin configurables |
372 -| `src/api/middleware/auth_required.rs` | Copier tel quel | Aucune |
373 -| `src/api/middleware/admin_helper.rs` | Copier tel quel | Aucune |
374 -| `src/api/middleware/rate_limit.rs` | Copier tel quel | Aucune |
375 -
376 -### 3.6 Configuration (COPIER + ADAPTER)
377 -
378 -| Fichier source | Action | Modifications |
379 -|---|---|---|
380 -| `src/config/auth.rs` | Copier | Renommer `rustcloud` → `rustwarden` dans les valeurs par défaut |
381 -| `env.sample` | Copier | Simplifier, retirer les variables spécifiques au domaine métier |
382 -
383 -### 3.7 Handlers / Routes API (COPIER + ADAPTER)
384 -
385 -| Fichier source | Action | Modifications |
386 -|---|---|---|
387 -| `src/api/routes.rs` | Copier | **Épurer** : garder uniquement les routes auth, user, admin, settings. Supprimer files, shares, todo, apps, organisations |
388 -| `src/api/handlers/login_handler.rs` | Copier | Adapter les templates |
389 -| `src/api/handlers/user_handler.rs` | Copier | Garder uniquement le CRUD user |
390 -| `src/api/handlers/admin_handler.rs` | Copier | Retirer la gestion d'organisations/apps/settings domaine |
391 -| `src/api/handlers/settings_handler.rs` | Copier | Garder profil, password, sessions, préférences |
392 -
393 -### 3.8 Utilitaires CLI (COPIER + ADAPTER)
394 -
395 -| Fichier source | Action | Modifications |
396 -|---|---|---|
397 -| `src/bin/seed.rs` | Copier | Adapter les données de seed, simplifier |
398 -| `src/bin/hash_password.rs` | Copier tel quel | Aucune |
399 -| `src/bin/change_password.rs` | Copier tel quel | Aucune |
400 -
401 -### 3.9 Code Rust des templates — `src/templates/` (COPIER + ADAPTER)
402 -
403 -| Fichier source | Action | Modifications |
404 -|---|---|---|
405 -| `src/templates/mod.rs` | Copier + adapter | Macro `impl_template_response`, `LayoutContext` — renommer RustCloud → RustWarden, adapter les DTOs |
406 -| `src/templates/email_templates.rs` | Copier + adapter | Structs Askama pour tous les emails (reset, validation, welcome, etc.) — renommer le branding |
407 -| `src/templates/helpers.rs` | Copier + adapter | Contexte de base (session, claims), breadcrumb, sidebar, navigation, CSRF, initiales, couleur avatar — adapter les chemins et références |
408 -| `src/templates/helpers/sharing_helpers.rs` | **NE PAS COPIER** | Spécifique au domaine métier (organisations, partages) |
409 -
410 -### 3.10 Templates HTML — `templates/` (COPIER + ADAPTER)
411 -
412 -#### Layout et base
413 -
414 -| Fichier source | Action | Modifications |
415 -|---|---|---|
416 -| `templates/base.html` | Copier + adapter | Layout HTML de base (Tailwind/DaisyUI, HTMX, Alpine.js) — renommer RustCloud → RustWarden |
417 -| `templates/base_with_theme.html` | **Copier tel quel** | Snippet thème utilisateur — déjà générique |
418 -
419 -#### Pages publiques (auth)
420 -
421 -| Fichier source | Action | Modifications |
422 -|---|---|---|
423 -| `templates/login.html` | Copier + adapter | Page login/register avec onglets, i18n — adapter le branding |
424 -| `templates/resend_verification.html` | Copier + adapter | Page renvoi de vérification — adapter le branding |
425 -| `templates/reset_password.html` | Copier + adapter | Page réinitialisation mot de passe — adapter le branding |
426 -| `templates/email_verified.html` | Copier + adapter | Page confirmation email vérifié — adapter le branding |
427 -
428 -#### Pages utilisateur
429 -
430 -| Fichier source | Action | Modifications |
431 -|---|---|---|
432 -| `templates/home.html` | Copier + **réécrire** | Page d'accueil post-login — contenu spécifique rustcloud à remplacer par un dashboard Rustwarden |
433 -| `templates/profile.html` | Copier + adapter | Page profil utilisateur — adapter le branding |
434 -| `templates/settings.html` | Copier + adapter | Page paramètres — retirer les entrées spécifiques au domaine |
435 -| `templates/settings/password.html` | Copier + adapter | Formulaire changement mot de passe — adapter le branding |
436 -| `templates/settings/preferences.html` | Copier + adapter | Préférences (thème, langue) — adapter le branding |
437 -| `templates/settings/sessions.html` | Copier + adapter | Sessions actives + révocation — adapter le branding |
438 -
439 -#### Composants réutilisables
440 -
441 -| Fichier source | Action | Modifications |
442 -|---|---|---|
443 -| `templates/components/header.html` | Copier + adapter | En-tête avec logo et navigation — adapter branding et liens |
444 -| `templates/components/navbar.html` | Copier + adapter | Barre de navigation DaisyUI — adapter les liens |
445 -| `templates/components/navigation.html` | Copier + adapter | Liens de navigation — **retirer** "Fichiers" et liens domaine, garder home/settings/admin |
446 -| `templates/components/sidebar.html` | **Copier tel quel** | Sidebar avec sections — déjà générique |
447 -| `templates/components/user_menu.html` | **Copier tel quel** | Menu utilisateur (avatar, dropdown) — déjà générique |
448 -| `templates/components/breadcrumb.html` | **Copier tel quel** | Fil d'Ariane DaisyUI — déjà générique |
449 -| `templates/components/password_change_badge.html` | **Copier tel quel** | Badge alerte changement password — déjà générique |
450 -
451 -#### Fragments HTMX
452 -
453 -| Fichier source | Action | Modifications |
454 -|---|---|---|
455 -| `templates/fragments/user_menu_dropdown.html` | **Copier tel quel** | Dropdown profil/paramètres/déconnexion — déjà générique |
456 -| `templates/fragments/form_message.html` | **Copier tel quel** | Fragment messages succès/erreur — déjà générique |
457 -
458 -#### Admin
459 -
460 -| Fichier source | Action | Modifications |
461 -|---|---|---|
462 -| `templates/admin/dashboard.html` | Copier + adapter | Dashboard admin — **retirer** les sections organisations/apps |
463 -| `templates/admin/users_list.html` | Copier + adapter | Liste utilisateurs — adapter le branding |
464 -| `templates/admin/user_form.html` | Copier + adapter | Formulaire création/édition — adapter le branding |
465 -| `templates/admin/errors.html` | Copier + adapter | Page erreurs admin — adapter le branding |
466 -| `templates/admin/settings.html` | Copier + adapter | Paramètres admin — retirer les paramètres domaine |
467 -
468 -#### Emails
469 -
470 -| Fichier source | Action | Modifications |
471 -|---|---|---|
472 -| `templates/emails/email_validation.html` | Copier + adapter | Validation email — renommer RustCloud → RustWarden |
473 -| `templates/emails/password_reset.html` | Copier + adapter | Réinitialisation password — renommer |
474 -| `templates/emails/password_changed.html` | Copier + adapter | Notification password changé — renommer |
475 -| `templates/emails/password_change_confirmation.html` | Copier + adapter | Confirmation changement password — renommer |
476 -| `templates/emails/welcome.html` | Copier + adapter | Email de bienvenue — renommer |
477 -| `templates/emails/admin_new_user.html` | Copier + adapter | Notification admin nouvel utilisateur — renommer |
478 -| `templates/emails/account_modified.html` | Copier + adapter | Notification modification compte — renommer |
479 -
480 -#### Pages d'erreur
481 -
482 -| Fichier source | Action | Modifications |
483 -|---|---|---|
484 -| `templates/errors/401.html` | Copier + adapter | Non authentifié — adapter le branding |
485 -| `templates/errors/403.html` | Copier + adapter | Accès refusé — adapter le branding |
486 -| `templates/errors/500.html` | Copier + adapter | Erreur serveur — adapter le branding |
487 -
488 -#### Templates à NE PAS copier (domaine métier rustcloud)
489 -
490 -| Fichier | Raison |
491 -|---|---|
492 -| `templates/admin/organisation_form.html` | Spécifique organisations |
493 -| `templates/admin/organisation_members.html` | Spécifique organisations |
494 -| `templates/admin/organisations_list.html` | Spécifique organisations |
495 -| `templates/admin/organisations_list_rows.html` | Spécifique organisations |
496 -| `templates/admin/apps_list.html` | Spécifique apps modulaires |
497 -| `templates/components/organisation_selector.html` | Spécifique organisations |
498 -| `templates/components/permissions_selector.html` | Spécifique partage |
499 -| `templates/components/share_button.html` | Spécifique partage |
500 -| `templates/components/share_list_item.html` | Spécifique partage |
501 -| `templates/fragments/member_row.html` | Spécifique organisations |
502 -| `templates/fragments/organisation_row.html` | Spécifique organisations |
503 -| `templates/sharing/permissions_customize.html` | Spécifique partage |
504 -| `templates/sharing/share_modal.html` | Spécifique partage |
505 -| `templates/sharing/shares_received.html` | Spécifique partage |
506 -| `templates/test.html` | Page de test |
507 -| `templates/test_base.html` | Page de test |
508 -
509 -### 3.11 Fichiers statiques — `static/` (COPIER + ADAPTER)
510 -
511 -#### CSS
512 -
513 -| Fichier source | Taille | Action | Modifications |
514 -|---|---|---|---|
515 -| `static/css/tailwind.src.css` | 2,2 Ko | Copier + adapter | Fichier source Tailwind 4 + DaisyUI 5 — renommer le thème `rustcloud` → `rustwarden` |
516 -| `static/css/tailwind.css` | 297 Ko | **Regénérer** | Fichier compilé — ne pas copier, le regénérer avec `npm run build:css` |
517 -| `static/css/custom.css` | 1,4 Ko | Copier + adapter | Variables CSS (`--rc-primary`, etc.), couleurs avatars, scrollbar — renommer le préfixe `--rc-` → `--rw-` |
518 -
519 -#### JavaScript
520 -
521 -| Fichier source | Taille | Action | Modifications |
522 -|---|---|---|---|
523 -| `static/js/htmx.min.js` | 47 Ko | **Copier tel quel** | Librairie HTMX utilisée dans base.html et login.html |
524 -| `static/js/alpine.min.js` | 43 Ko | **Copier tel quel** | Librairie Alpine.js pour les composants réactifs (dropdowns, onglets, etc.) |
525 -
526 -#### Images
527 -
528 -| Fichier source | Taille | Action | Modifications |
529 -|---|---|---|---|
530 -| `static/images/favicon.svg` | 4,3 Ko | **Remplacer** | Créer un nouveau favicon pour Rustwarden |
531 -| `static/images/apple-touch-icon.png` | 2,4 Ko | **Remplacer** | Créer une nouvelle icône Apple Touch |
532 -| `static/images/logo.svg` | 26 Ko | **Remplacer** | Créer un nouveau logo SVG Rustwarden |
533 -| `static/images/logo.png` | 19 Ko | **Remplacer** | Créer un nouveau logo PNG Rustwarden |
534 -
535 -#### Fichier à NE PAS copier
536 -
537 -| Fichier | Raison |
538 -|---|---|
539 -| `static/test.txt` | Fichier de test, inutile |
540 -
541 -### 3.12 Configuration frontend (COPIER + ADAPTER)
542 -
543 -| Fichier source | Action | Modifications |
544 -|---|---|---|
545 -| `package.json` | Copier + adapter | Renommer `rustcloud` → `rustwarden`, garder les scripts `build:css` / `build:css:watch` / `build:css:min` |
546 -| `tailwind.config.js` | Copier + adapter | Renommer le thème DaisyUI `rustcloud` → `rustwarden`, adapter les couleurs primaire/secondaire |
547 -
548 -**Configuration Tailwind adaptée pour Rustwarden :**
549 -
550 -```js
551 -// tailwind.config.js
552 -module.exports = {
553 - content: ["./templates/**/*.html"],
554 - theme: {
555 - extend: {
556 - colors: {
557 - 'rustwarden': {
558 - 'blue': '#3b82f6', // Couleur primaire à définir
559 - 'rust': '#f97316', // Couleur accent à définir
560 - }
561 - }
562 - },
563 - },
564 - plugins: [require('daisyui')],
565 - daisyui: {
566 - themes: [
567 - {
568 - rustwarden: {
569 - "primary": "#3b82f6",
570 - "secondary": "#1e293b",
571 - "accent": "#10b981",
572 - "neutral": "#f5f5f5",
573 - "base-100": "#ffffff",
574 - "info": "#3b82f6",
575 - "success": "#10b981",
576 - "warning": "#f59e0b",
577 - "error": "#ef4444",
578 - },
579 - },
580 - "light",
581 - "dark",
582 - ],
583 - },
584 -}
585 -```
586 -
587 -**Commande de build CSS :**
588 -
589 -```bash
590 -npm install # Installer Tailwind + DaisyUI
591 -npm run build:css # Compiler le CSS
592 -npm run build:css:watch # Mode watch (développement)
593 -npm run build:css:min # Compiler + minifier (production)
594 -```
595 -
596 -> **Bugs à corriger** (hérités de rustcloud) :
597 -> - `templates/base.html` référence `/static/images/favicon.png` qui n'existe pas → utiliser `favicon.svg`
598 -> - `templates/email_verified.html` référence `output.css` au lieu de `tailwind.css` → corriger
599 -
600 -### 3.13 Fichiers à NE PAS copier (résumé global)
601 -
602 -| Catégorie | Exemples |
603 -|---|---|
604 -| Apps modulaires | `crates/apps/files/`, `crates/apps/todo/` |
605 -| Core métier | `crates/rustcloud-core/` (sauf `sharing/permissions.rs` si on veut le système de permissions bitmask) |
606 -| SDK | `crates/rustcloud-app-sdk/` |
607 -| i18n spécifique | Fichiers de traduction spécifiques au domaine |
608 -| WebDAV | `crates/apps/files/src/webdav/` |
609 -| Organisations | Tout ce qui touche aux organisations |
610 -| Partages | Tout ce qui touche aux shares |
611 -
612 ----
613 -
614 -## 4. Modifications structurelles à effectuer
615 -
616 -### 4.1 Cargo.toml — Nouveau fichier
617 -
618 -```toml
619 -[package]
620 -name = "rustwarden"
621 -version = "0.1.0"
622 -edition = "2021"
623 -default-run = "rustwarden"
624 -
625 -[dependencies]
626 -# Web framework
627 -axum = { version = "0.8", features = ["macros"] }
628 -tokio = { version = "1", features = ["full"] }
629 -async-trait = "0.1"
630 -# Serialization
631 -serde = { version = "1.0", features = ["derive"] }
632 -serde_json = "1.0"
633 -# Middleware
634 -tower = { version = "0.5", features = ["limit", "util"] }
635 -tower-http = { version = "0.6", features = ["cors", "trace", "limit", "fs"] }
636 -# Templates
637 -askama = "0.14"
638 -# Logging
639 -tracing = "0.1"
640 -tracing-subscriber = { version = "0.3", features = ["env-filter"] }
641 -# Env
642 -dotenvy = "0.15"
643 -# Database ORM
644 -sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
645 -sea-orm-migration = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls"] }
646 -# Password hashing
647 -bcrypt = "0.15"
648 -# Validation
649 -validator = { version = "0.20", features = ["derive"] }
650 -# UUID & Datetime
651 -uuid = { version = "1.0", features = ["v4", "serde"] }
652 -chrono = { version = "0.4", features = ["serde"] }
653 -time = { version = "0.3", features = ["macros", "serde"] }
654 -# Error handling
655 -thiserror = "1.0"
656 -anyhow = "1.0"
657 -# Authentication — JWT & Sessions
658 -jsonwebtoken = "9.3"
659 -axum-login = "0.18"
660 -tower-sessions = "0.14"
661 -tower-sessions-seaorm-store = "0.1.1"
662 -tower-sessions-redis-store = { version = "0.14", optional = true }
663 -tower-csrf = "0.1"
664 -axum-extra = { version = "0.9", features = ["cookie"] }
665 -rand = { version = "0.8", features = ["std"] }
666 -# JWT Blacklist
667 -sha2 = "0.10"
668 -base64 = "0.22"
669 -hex = "0.4"
670 -# Email
671 -lettre = { version = "0.11", features = ["tokio1-native-tls", "builder"] }
672 -
673 -[features]
674 -default = []
675 -redis-session = ["tower-sessions-redis-store"]
676 -
677 -[[bin]]
678 -name = "migrate"
679 -path = "migrations/src/main.rs"
680 -
681 -[[bin]]
682 -name = "hash_password"
683 -path = "src/bin/hash_password.rs"
684 -
685 -[[bin]]
686 -name = "seed"
687 -path = "src/bin/seed.rs"
688 -
689 -[[bin]]
690 -name = "change_password"
691 -path = "src/bin/change_password.rs"
692 -```
693 -
694 -### 4.2 Renommages globaux
695 -
696 -| Ancien | Nouveau |
697 -|---|---|
698 -| `rustcloud` | `rustwarden` |
699 -| `RustCloud` | `RustWarden` |
700 -| `RUSTCLOUD` | `RUSTWARDEN` |
701 -
702 -À appliquer dans tous les fichiers copiés (noms de crates, imports, commentaires, issuer JWT, noms de cookies, etc.).
703 -
704 -### 4.3 Rendre les routes protégées configurables
705 -
706 -Actuellement dans rustcloud, les routes protégées sont hardcodées dans `AuthService` :
707 -
708 -```rust
709 -// Ancien (hardcodé dans rustcloud)
710 -let protected_paths = ["/home", "/settings", "/test-base", "/profil", "/apps/", "/shares"];
711 -```
712 -
713 -**Modification** : les rendre configurables via la structure `AuthConfig` :
714 -
715 -```rust
716 -// Nouveau (configurable dans rustwarden)
717 -pub struct AuthConfig {
718 - // ... champs existants ...
719 - pub protected_paths: Vec<String>,
720 - pub admin_paths: Vec<String>,
721 - pub public_paths: Vec<String>,
722 -}
723 -```
724 -
725 -### 4.4 Supprimer les dépendances au domaine métier
726 -
727 -Dans `user_service.rs` et `auth_service.rs`, retirer :
728 -- Toute référence aux organisations
729 -- Toute référence aux shares/partages
730 -- Toute référence aux apps modulaires
731 -- Les événements spécifiques (UserCreatedEvent lié au EventBus de rustcloud)
732 -
733 -### 4.5 Simplifier `main.rs`
734 -
735 -Le `main.rs` de Rustwarden doit être épuré :
736 -- Garder : connexion DB, migrations, session layer, auth middleware, admin middleware, rate limiting
737 -- Retirer : chargement des apps, EventBus, WebDAV, organisations, i18n complexe
738 -
739 ----
740 -
741 -## 5. Schéma de base de données Rustwarden
742 -
743 -```
744 -┌─────────────────────────┐
745 -│ users │
746 -├─────────────────────────┤
747 -│ id UUID PK │
748 -│ username VARCHAR(64) │ UNIQUE
749 -│ email VARCHAR(255) │ UNIQUE
750 -│ password_hash VARCHAR │
751 -│ must_change_password BOOL│
752 -│ last_login_at TIMESTAMP │
753 -│ last_login_ip VARCHAR │
754 -│ password_changed_at TS │
755 -│ email_verified BOOL │
756 -│ email_verified_at TS │
757 -│ created_at TIMESTAMP │
758 -│ updated_at TIMESTAMP │
759 -└──────────┬──────────────┘
760 - │
761 - ┌─────┼──────────────────────────────────┐
762 - │ │ │
763 - ▼ ▼ ▼
764 -┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
765 -│ user_roles │ │ roles │ │ user_preferences │
766 -├──────────────────┤ ├──────────────────┤ ├──────────────────────┤
767 -│ user_id FK │──│ id UUID PK │ │ id UUID PK │
768 -│ role_id FK │ │ name UNIQUE │ │ user_id FK UNIQUE │
769 -│ created_at │ │ description │ │ theme ENUM │
770 -│ (PK composite) │ │ created_at │ │ language ENUM │
771 -└──────────────────┘ │ updated_at │ │ created_at │
772 - └──────────────────┘ │ updated_at │
773 - └──────────────────────┘
774 - │
775 - ├──────────────────────────────────────────────────────┐
776 - │ │ │
777 - ▼ ▼ ▼
778 -┌────────────────────┐ ┌────────────────────────┐ ┌─────────────────────────┐
779 -│ refresh_token │ │ jwt_blacklist │ │ password_reset_tokens │
780 -├────────────────────┤ ├────────────────────────┤ ├─────────────────────────┤
781 -│ id UUID PK │ │ id UUID PK │ │ id UUID PK │
782 -│ user_id FK │ │ token_hash UNIQUE │ │ user_id FK │
783 -│ token UNIQUE │ │ user_id FK │ │ token_hash UNIQUE │
784 -│ expires_at │ │ expires_at │ │ expires_at │
785 -│ revoked BOOL │ │ revoked_at │ │ used_at │
786 -│ revoked_at │ │ created_at │ │ created_at │
787 -│ replaced_by │ └────────────────────────┘ └─────────────────────────┘
788 -│ created_at │
789 -└────────────────────┘
790 - ┌──────────────────────────┐ ┌─────────────────────────────┐
791 - │ email_validation_tokens │ │ password_change_requests │
792 - ├──────────────────────────┤ ├─────────────────────────────┤
793 - │ id UUID PK │ │ id UUID PK │
794 - │ user_id FK │ │ user_id FK │
795 - │ token_hash UNIQUE │ │ token_hash UNIQUE │
796 - │ expires_at │ │ new_password_hash │
797 - │ used_at │ │ expires_at │
798 - │ created_at │ │ confirmed / used_at │
799 - └──────────────────────────┘ │ created_at │
800 - └─────────────────────────────┘
801 -```
802 -
803 ----
804 -
805 -## 6. API REST — Endpoints Rustwarden
806 -
807 -### 6.1 Authentification (`/api/v1/auth`)
808 -
809 -| Méthode | Endpoint | Description | Auth requise |
810 -|---|---|---|---|
811 -| POST | `/api/v1/auth/login` | Connexion (retourne JWT + refresh token) | Non |
812 -| POST | `/api/v1/auth/register` | Inscription | Non |
813 -| POST | `/api/v1/auth/logout` | Déconnexion (blacklist JWT) | Oui |
814 -| POST | `/api/v1/auth/refresh` | Rafraîchir le token JWT | Non (refresh token) |
815 -| GET | `/api/v1/auth/me` | Utilisateur courant | Oui |
816 -| POST | `/api/v1/auth/forgot-password` | Demande de reset password | Non |
817 -| POST | `/api/v1/auth/reset-password` | Réinitialiser le mot de passe | Non (token) |
818 -| GET | `/api/v1/auth/verify-email/{token}` | Vérifier l'email | Non (token) |
819 -| POST | `/api/v1/auth/resend-verification` | Renvoyer l'email de vérification | Oui |
820 -| POST | `/api/v1/auth/resend-verification/by-username` | Renvoyer par username | Non |
821 -
822 -### 6.2 Gestion utilisateurs (`/api/v1/users`)
823 -
824 -| Méthode | Endpoint | Description | Auth requise |
825 -|---|---|---|---|
826 -| GET | `/api/v1/users` | Lister les utilisateurs | Admin |
827 -| POST | `/api/v1/users` | Créer un utilisateur | Admin |
828 -
829 -### 6.3 Administration (`/admin`)
830 -
831 -| Méthode | Endpoint | Description | Auth requise |
832 -|---|---|---|---|
833 -| GET | `/admin` | Dashboard admin | Admin |
834 -| GET | `/admin/users` | Liste des utilisateurs | Admin |
835 -| GET | `/admin/users/create` | Formulaire création | Admin |
836 -| POST | `/admin/users` | Créer un utilisateur | Admin |
837 -| GET | `/admin/users/{id}` | Formulaire édition | Admin |
838 -| PUT | `/admin/users/{id}` | Modifier un utilisateur | Admin |
839 -| DELETE | `/admin/users/{id}` | Supprimer un utilisateur | Admin |
840 -| POST | `/admin/users/{id}/reset-password` | Reset password (admin) | Admin |
841 -| POST | `/admin/users/{id}/reset-password-email` | Reset password par email | Admin |
842 -
843 -### 6.4 Paramètres utilisateur (`/settings`)
844 -
845 -| Méthode | Endpoint | Description | Auth requise |
846 -|---|---|---|---|
847 -| GET | `/settings` | Page paramètres | Oui |
848 -| GET | `/profile` | Page profil | Oui |
849 -| PUT | `/profile` | Modifier profil | Oui |
850 -| GET | `/settings/password` | Page mot de passe | Oui |
851 -| PUT | `/settings/password` | Changer mot de passe | Oui |
852 -| DELETE | `/settings/password/pending` | Annuler changement en cours | Oui |
853 -| GET | `/settings/sessions` | Sessions actives | Oui |
854 -| DELETE | `/settings/sessions/{id}` | Révoquer une session | Oui |
855 -| GET | `/settings/preferences` | Préférences | Oui |
856 -| PUT | `/settings/preferences` | Modifier préférences | Oui |
857 -| GET | `/confirm-password-change` | Confirmer changement password | Non (token) |
858 -
859 -### 6.5 Pages publiques
860 -
861 -| Méthode | Endpoint | Description |
862 -|---|---|---|
863 -| GET | `/login` | Page de connexion |
864 -| GET | `/resend-verification` | Page de renvoi de vérification |
865 -| GET | `/reset-password` | Page de réinitialisation |
866 -
867 ----
868 -
869 -## 7. Stack technique
870 -
871 -| Composant | Technologie | Version |
872 -|---|---|---|
873 -| Langage | Rust | 1.91+ |
874 -| Framework web | Axum | 0.8 |
875 -| ORM | SeaORM | 2.0.0-rc.20 |
876 -| Base de données | PostgreSQL | 14+ |
877 -| Runtime async | Tokio | 1.x |
878 -| JWT | jsonwebtoken | 10.3.0 |
879 -| Hashing passwords | bcrypt | 0.15 |
880 -| Sessions | tower-sessions | 0.14 |
881 -| Sessions store | tower-sessions-seaorm-store | 0.1.1 |
882 -| Sessions Redis (opt.) | tower-sessions-redis-store | 0.16 |
883 -| CSRF | tower-csrf | 0.1 |
884 -| Templates | Askama | 0.15.4 |
885 -| Email | Lettre | 0.11.19 |
886 -| Validation | validator | 0.20 |
887 -| Rate limiting | Custom (tower middleware) | — |
888 -
889 ----
890 -
891 -## 8. Variables d'environnement
892 -
893 -```env
894 -# Base de données
895 -DATABASE_URL=postgres://rustwarden_user:password@localhost:5432/rustwarden
896 -
897 -# Serveur
898 -SERVER_HOST=0.0.0.0
899 -SERVER_PORT=3000
900 -
901 -# Logging
902 -RUST_LOG=debug
903 -
904 -# JWT
905 -JWT_SECRET=<min 32 caractères>
906 -JWT_EXPIRATION_MINUTES=15
907 -JWT_ISSUER=rustwarden
908 -
909 -# Refresh tokens
910 -REFRESH_TOKEN_EXPIRATION_DAYS=7
911 -
912 -# Sessions
913 -SESSION_SECRET=<min 64 caractères>
914 -SESSION_TIMEOUT_MINUTES=30
915 -SESSION_BACKEND=memory # memory | seaorm | redis
916 -
917 -# Redis (si SESSION_BACKEND=redis)
918 -REDIS_URL=redis://localhost:6379
919 -
920 -# Rate Limiting
921 -RATE_LIMIT_LOGIN_PER_MINUTE=5
922 -RATE_LIMIT_REFRESH_PER_MINUTE=10
923 -RATE_LIMIT_GENERAL_PER_MINUTE=100
924 -
925 -# Cookies
926 -COOKIE_SECURE=false # true en production
927 -COOKIE_SAME_SITE=Lax # Lax | Strict
928 -
929 -# Admin initial
930 -ADMIN_USERNAME=admin
931 -ADMIN_EMAIL=admin@localhost
932 -ADMIN_PASSWORD=ChangeMe123!
933 -
934 -# Email SMTP
935 -SMTP_HOST=localhost
936 -SMTP_PORT=587
937 -SMTP_USERNAME=
938 -SMTP_PASSWORD=
939 -SMTP_FROM=noreply@rustwarden.local
940 -```
941 -
942 ----
943 -
944 -## 9. Fonctionnalités du framework — Détail
945 -
946 -### 9.1 Authentification JWT
947 -- Algorithme HS256
948 -- Claims : `sub` (user_id), `username`, `roles`, `exp`, `iat`, `iss`
949 -- Extraction depuis header `Authorization: Bearer <token>` OU cookie `jwt_token`
950 -- Blacklist au logout (hash SHA256 stocké en base)
951 -- Refresh token avec rotation (ancien token révoqué, nouveau créé)
952 -
953 -### 9.2 Sessions
954 -- Backends interchangeables : Memory (dev), SeaORM (prod), Redis (haute dispo)
955 -- Données de session : `user_id`, `username`, `roles`
956 -- Cookies : HttpOnly, Secure (configurable), SameSite (Lax/Strict)
957 -
958 -### 9.3 RBAC (Role-Based Access Control)
959 -- Rôles nommés (admin, user, etc.)
960 -- Table de liaison many-to-many `user_roles`
961 -- Extracteur `AuthUser` avec méthodes : `has_role()`, `has_any_role()`, `has_all_roles()`
962 -- Middleware `AdminService` : vérifie le rôle "admin"
963 -
964 -### 9.4 Gestion des mots de passe
965 -- Hash bcrypt
966 -- Réinitialisation par email (token à usage unique avec expiration)
967 -- Changement avec confirmation par email (double validation)
968 -- Flag `must_change_password` pour forcer le changement à la prochaine connexion
969 -
970 -### 9.5 Vérification email
971 -- Token envoyé par email à l'inscription
972 -- Endpoint de vérification par token
973 -- Renvoi de l'email de vérification (par auth ou par username)
974 -- Champs `email_verified` et `email_verified_at`
975 -
976 -### 9.6 Rate Limiting
977 -- Par endpoint (login, refresh, admin, general)
978 -- Configurable via variables d'environnement
979 -- Implémenté en middleware Tower
980 -
981 -### 9.7 Middleware d'authentification
982 -- `AuthService` : protège les routes nécessitant une connexion
983 -- `AdminService` : protège les routes nécessitant le rôle admin
984 -- Chaîne : `Request → AdminService → AuthService → SessionLayer → Router`
985 -- Redirection vers `/login?redirect=...` si non authentifié
986 -
987 ----
988 -
989 -## 10. Plan d'implémentation par phases
990 -
991 -### Phase 1 — Socle (Fondations)
992 -1. Initialiser le projet Cargo
993 -2. Copier et adapter le `Cargo.toml`
994 -3. Créer la migration unique consolidée `m20260305_000001_initial_schema.rs` (fusion des 11 migrations rustcloud + seed RBAC)
995 -4. Créer `migrations/src/main.rs` et `migrations/src/lib.rs`
996 -5. Copier tous les modèles SeaORM
997 -6. Copier le module `config/`
998 -7. Copier `env.sample`
999 -8. Vérifier la compilation et tester `cargo run --bin migrate`
1000 -
1001 -### Phase 2 — Services
1002 -1. Copier `services/password_service.rs` (aucune dépendance)
1003 -2. Copier `services/auth_service.rs` (dépend de user model + password)
1004 -3. Copier `services/user_service.rs` (épurer les dépendances métier)
1005 -4. Copier `services/role_service.rs`
1006 -5. Copier les services JWT, refresh token, blacklist
1007 -6. Copier les services email, reset password, password change
1008 -7. Copier `services/auth_logging.rs`
1009 -
1010 -### Phase 3 — Auth & Middleware
1011 -1. Copier le module `auth/` complet
1012 -2. Copier le module `api/middleware/`
1013 -3. Rendre les routes protégées configurables
1014 -4. Tester la chaîne d'authentification
1015 -
1016 -### Phase 4 — Handlers & Routes
1017 -1. Copier les handlers auth (login, register, etc.)
1018 -2. Copier les handlers admin (CRUD users)
1019 -3. Copier les handlers settings (profil, password, sessions, preferences)
1020 -4. Copier et adapter `api/routes.rs`
1021 -5. Créer un `main.rs` simplifié
1022 -
1023 -### Phase 5 — Templates & UI
1024 -1. Copier les templates login/register/reset
1025 -2. Copier les templates admin
1026 -3. Copier les templates settings
1027 -4. Adapter le branding (rustcloud → rustwarden)
1028 -5. Nettoyer la navigation (retirer les liens vers les fonctionnalités métier)
1029 -
1030 -### Phase 6 — CLI & Seed
1031 -1. Copier les binaires utilitaires
1032 -2. Adapter le seed pour Rustwarden
1033 -3. Tester le flux complet : seed → login → admin → settings
1034 -
1035 -### Phase 7 — Évolutions futures (post-MVP)
1036 -1. OAuth2 / OpenID Connect (login via Google, GitHub, etc.)
1037 -2. LDAP / Active Directory
1038 -3. 2FA (TOTP / WebAuthn)
1039 -4. Audit log complet
1040 -5. API keys pour applications tierces
1041 -6. Système de permissions granulaire (bitmask, inspiré de `SharePermissions`)
1042 -
1043 ----
1044 -
1045 -## 11. Critères de validation
1046 -
1047 -Le framework Rustwarden sera considéré comme fonctionnel quand :
1048 -
1049 -- [ ] `cargo run --bin migrate` crée toutes les tables
1050 -- [ ] `cargo run --bin seed` crée l'utilisateur admin et les rôles
1051 -- [ ] `cargo run` démarre le serveur sur le port configuré
1052 -- [ ] La page `/login` s'affiche correctement
1053 -- [ ] Le login/logout fonctionne (JWT + cookie)
1054 -- [ ] Le refresh token fonctionne
1055 -- [ ] L'inscription crée un utilisateur et envoie un email de vérification
1056 -- [ ] La réinitialisation de mot de passe fonctionne par email
1057 -- [ ] L'admin peut lister, créer, éditer, supprimer des utilisateurs
1058 -- [ ] Le middleware bloque l'accès aux routes protégées sans auth
1059 -- [ ] Le middleware admin bloque l'accès sans rôle admin
1060 -- [ ] Le rate limiting fonctionne sur `/api/v1/auth/login`
1061 -- [ ] Les préférences utilisateur (thème, langue) sont sauvegardées
1062 -- [ ] La gestion des sessions actives fonctionne (liste, révocation)
1063 -
1064 ----
1065 -
1066 -## 12. Ressources de test existantes
1067 -
1068 -Le projet rustcloud dispose de deux suites de tests réutilisables :
1069 -
1070 -| Projet | Type | Chemin |
1071 -|---|---|---|
1072 -| `sele_rustcloud` | Tests E2E Selenium (Python/pytest) | `/home/monsieurb/Documents/devel/specKit/sele_rustcloud` |
1073 -| `Bruno_rustcloud` | Collection API Bruno | `/home/monsieurb/Documents/devel/specKit/Bruno_rustcloud` |
1074 -
1075 -Ces suites pourront être adaptées pour Rustwarden en modifiant les URLs et les données de seed.
A Todo.old
+1075 -0
@@ -0,0 +1,1075 @@
1 +# Rustwarden — Spécification d'extraction du framework
2 +
3 +## 1. Vision du projet
4 +
5 +**Rustwarden** est un framework/boilerplate Rust réutilisable de gestion des utilisateurs, extrait du projet **rustcloud**. L'objectif est de fournir un socle complet et prêt à l'emploi pour tout projet web nécessitant :
6 +
7 +- Authentification (login/password, JWT, sessions)
8 +- Gestion des utilisateurs (CRUD, profil, préférences)
9 +- Système de rôles (RBAC)
10 +- Vérification d'email
11 +- Réinitialisation et changement de mot de passe
12 +- Middleware d'authentification et d'autorisation
13 +- Rate limiting
14 +- Évolution future vers OAuth2, OIDC, LDAP
15 +
16 +---
17 +
18 +## 2. Architecture cible
19 +
20 +```
21 +Rustwarden/
22 +├── Cargo.toml # Workspace + package principal
23 +├── .env.sample # Variables d'environnement
24 +├── package.json # Scripts build Tailwind CSS
25 +├── tailwind.config.js # Config Tailwind + thème DaisyUI
26 +├── README.md # Documentation
27 +├── SPECIFICATION.md # Ce document
28 +│
29 +├── static/ # Fichiers statiques servis par Axum
30 +│ ├── css/
31 +│ │ ├── tailwind.src.css # Source Tailwind (import + thème DaisyUI)
32 +│ │ ├── tailwind.css # CSS compilé (généré par npm run build:css)
33 +│ │ └── custom.css # Styles personnalisés (variables, avatar, scrollbar)
34 +│ ├── js/
35 +│ │ ├── htmx.min.js # HTMX (interactions sans rechargement)
36 +│ │ └── alpine.min.js # Alpine.js (composants réactifs)
37 +│ └── images/
38 +│ ├── favicon.svg # Favicon SVG
39 +│ ├── apple-touch-icon.png # Icône Apple Touch
40 +│ ├── logo.svg # Logo SVG
41 +│ └── logo.png # Logo PNG
42 +│
43 +├── migrations/
44 +│ └── src/
45 +│ ├── main.rs # Point d'entrée des migrations
46 +│ ├── lib.rs # Registre des migrations
47 +│ └── m20260305_000001_initial_schema.rs # Migration unique — schéma complet + RBAC
48 +│
49 +├── src/
50 +│ ├── main.rs # Point d'entrée de l'application
51 +│ ├── app_state.rs # État global de l'application
52 +│ │
53 +│ ├── auth/ # Module d'authentification
54 +│ │ ├── mod.rs
55 +│ │ ├── session.rs # Gestion des sessions (Memory/Redis)
56 +│ │ ├── jwt.rs # Service JWT (génération/validation)
57 +│ │ ├── jwt_extractor.rs # Extraction JWT depuis requêtes
58 +│ │ ├── handlers.rs # Handlers auth (login, register, etc.)
59 +│ │ └── extractors.rs # Extracteur AuthUser
60 +│ │
61 +│ ├── api/
62 +│ │ ├── routes.rs # Définition des routes
63 +│ │ ├── middleware/
64 +│ │ │ ├── mod.rs
65 +│ │ │ ├── auth_service.rs # Middleware auth (routes protégées)
66 +│ │ │ ├── admin_service.rs # Middleware admin (vérification rôle)
67 +│ │ │ ├── auth_required.rs # Helpers auth
68 +│ │ │ ├── admin_helper.rs # Helpers admin
69 +│ │ │ └── rate_limit.rs # Rate limiting
70 +│ │ └── handlers/
71 +│ │ ├── login_handler.rs # Pages login/reset/verification
72 +│ │ ├── user_handler.rs # API CRUD utilisateurs
73 +│ │ ├── admin_handler.rs # Interface admin utilisateurs
74 +│ │ └── settings_handler.rs # Profil, sessions, préférences
75 +│ │
76 +│ ├── config/
77 +│ │ ├── mod.rs
78 +│ │ ├── auth.rs # Configuration auth (JWT, sessions, etc.)
79 +│ │ └── database.rs # Configuration base de données
80 +│ │
81 +│ ├── models/ # Entités SeaORM
82 +│ │ ├── mod.rs
83 +│ │ ├── user.rs # Modèle User
84 +│ │ ├── role.rs # Modèle Role
85 +│ │ ├── user_role.rs # Table de liaison User↔Role
86 +│ │ ├── refresh_token.rs # Refresh tokens
87 +│ │ ├── jwt_blacklist.rs # Blacklist JWT
88 +│ │ ├── user_preferences.rs # Préférences utilisateur
89 +│ │ ├── password_reset_token.rs # Tokens de réinitialisation
90 +│ │ ├── email_validation_token.rs # Tokens de validation email
91 +│ │ └── password_change_request.rs # Demandes de changement password
92 +│ │
93 +│ ├── services/ # Logique métier
94 +│ │ ├── mod.rs
95 +│ │ ├── auth_service.rs # Authentification (verify credentials)
96 +│ │ ├── user_service.rs # CRUD utilisateurs
97 +│ │ ├── role_service.rs # Gestion des rôles
98 +│ │ ├── password_service.rs # Hash/validation bcrypt
99 +│ │ ├── password_reset_service.rs # Reset password par email
100 +│ │ ├── password_change_service.rs # Changement password avec confirm email
101 +│ │ ├── jwt_blacklist_service.rs # Blacklist JWT au logout
102 +│ │ ├── refresh_token_service.rs # Refresh tokens et rotation
103 +│ │ ├── email_validation_service.rs # Validation email
104 +│ │ └── auth_logging.rs # Logs d'authentification
105 +│ │
106 +│ ├── templates/ # Code Rust des templates Askama
107 +│ │ ├── mod.rs # Macro impl_template_response, LayoutContext
108 +│ │ ├── email_templates.rs # Structs Askama pour les emails
109 +│ │ └── helpers.rs # Contextes, breadcrumb, sidebar, CSRF, avatar
110 +│ │
111 +│ └── bin/ # Utilitaires CLI
112 +│ ├── seed.rs # Seeding de la base
113 +│ ├── hash_password.rs # Hash un mot de passe
114 +│ └── change_password.rs # Changement de mot de passe CLI
115 +│
116 +└── templates/ # Templates HTML Askama
117 + ├── base.html # Layout HTML de base (Tailwind/DaisyUI/HTMX/Alpine)
118 + ├── base_with_theme.html # Snippet thème utilisateur
119 + ├── login.html # Page login/register avec onglets
120 + ├── home.html # Page d'accueil post-login
121 + ├── profile.html # Page profil utilisateur
122 + ├── email_verified.html # Page de confirmation email vérifié
123 + ├── resend_verification.html # Page renvoi vérification
124 + ├── reset_password.html # Page réinitialisation mot de passe
125 + ├── settings.html # Page paramètres
126 + ├── components/
127 + │ ├── header.html # En-tête avec logo et navigation
128 + │ ├── navbar.html # Barre de navigation DaisyUI
129 + │ ├── navigation.html # Liens de navigation (home, settings, admin)
130 + │ ├── sidebar.html # Sidebar avec sections
131 + │ ├── user_menu.html # Menu utilisateur (avatar, dropdown)
132 + │ ├── breadcrumb.html # Fil d'Ariane
133 + │ └── password_change_badge.html # Badge alerte changement password
134 + ├── fragments/
135 + │ ├── user_menu_dropdown.html # Dropdown : profil, paramètres, déconnexion
136 + │ └── form_message.html # Fragment messages succès/erreur
137 + ├── admin/
138 + │ ├── dashboard.html # Dashboard admin (stats, liens)
139 + │ ├── users_list.html # Liste utilisateurs (recherche, filtres, pagination)
140 + │ ├── user_form.html # Formulaire création/édition utilisateur
141 + │ └── errors.html # Page erreurs admin
142 + ├── settings/
143 + │ ├── password.html # Formulaire changement mot de passe
144 + │ ├── preferences.html # Préférences (thème, langue)
145 + │ └── sessions.html # Sessions actives + révocation
146 + ├── emails/
147 + │ ├── email_validation.html # Email validation adresse
148 + │ ├── password_reset.html # Email réinitialisation password
149 + │ ├── password_changed.html # Notification password changé
150 + │ ├── password_change_confirmation.html # Confirmation changement password
151 + │ ├── welcome.html # Email de bienvenue
152 + │ ├── admin_new_user.html # Notification admin nouvel utilisateur
153 + │ └── account_modified.html # Notification modification compte
154 + └── errors/
155 + ├── 401.html # Non authentifié
156 + ├── 403.html # Accès refusé
157 + └── 500.html # Erreur serveur
158 +```
159 +
160 +---
161 +
162 +## 3. Inventaire complet des éléments à copier depuis rustcloud
163 +
164 +### 3.1 Migration de base de données — UNIQUE ET CONSOLIDÉE
165 +
166 +Puisque Rustwarden est un projet neuf, les 11 migrations incrémentales de rustcloud sont **fusionnées en une seule migration initiale** qui crée le schéma complet avec le RBAC dès le départ.
167 +
168 +**Fichiers à créer :**
169 +
170 +| Fichier | Rôle |
171 +|---|---|
172 +| `migrations/src/main.rs` | Point d'entrée du binaire `migrate` |
173 +| `migrations/src/lib.rs` | Registre des migrations (une seule entrée) |
174 +| `migrations/src/m20260305_000001_initial_schema.rs` | **Migration unique — schéma complet** |
175 +
176 +**Fichiers source rustcloud à consulter** (pour en extraire et fusionner la logique) :
177 +
178 +| Migration rustcloud | Intégrée dans |
179 +|---|---|
180 +| `m20241209_000001_create_users_table` | Table `users` (avec `last_login_at/ip`, `email_verified/at`, `password_changed_at` déjà inclus) |
181 +| `m20241209_000002_create_roles_table` | Table `roles` |
182 +| `m20241209_000003_create_user_roles_table` | Table `user_roles` |
183 +| `m20241209_000004_create_refresh_tokens_table` | Table `refresh_tokens` |
184 +| `m20241211_000005_create_jwt_blacklist_table` | Table `jwt_blacklist` |
185 +| `m20241211_000006_create_user_preferences_table` | Table `user_preferences` |
186 +| `m20241211_000008_add_last_login_to_users` | ⟶ Fusionné dans la table `users` |
187 +| `m20241214_000010_create_password_reset_tokens_table` | Table `password_reset_tokens` |
188 +| `m20241214_000011_create_email_validation_tokens_table` | Table `email_validation_tokens` |
189 +| `m20241214_000012_add_email_verified_to_users` | ⟶ Fusionné dans la table `users` |
190 +| `m20251213_000021_create_password_change_requests_table` | Table `password_change_requests` |
191 +
192 +**Migrations rustcloud EXCLUES** (domaine métier) :
193 +- `m20250127_000018_create_organisations_table` — hors périmètre
194 +- `m20250127_000019_create_organisation_members_table` — hors périmètre
195 +- `m20250127_000020_create_shares_table` — hors périmètre
196 +
197 +**Schéma SQL consolidé** de la migration unique `m20260305_000001_initial_schema` :
198 +
199 +```sql
200 +-- =============================================
201 +-- Rustwarden — Migration initiale
202 +-- Schéma complet : utilisateurs, RBAC, auth
203 +-- =============================================
204 +
205 +-- 1. Table des utilisateurs (fusion de 3 migrations rustcloud)
206 +CREATE TABLE users (
207 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
208 + username VARCHAR(64) NOT NULL UNIQUE,
209 + email VARCHAR(255) NOT NULL UNIQUE,
210 + password_hash VARCHAR(255) NOT NULL,
211 + must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
212 + last_login_at TIMESTAMPTZ,
213 + last_login_ip VARCHAR(45),
214 + password_changed_at TIMESTAMPTZ,
215 + email_verified BOOLEAN NOT NULL DEFAULT FALSE,
216 + email_verified_at TIMESTAMPTZ,
217 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
218 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
219 +);
220 +
221 +-- 2. Table des rôles
222 +CREATE TABLE roles (
223 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
224 + name VARCHAR(64) NOT NULL UNIQUE,
225 + description TEXT,
226 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
227 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
228 +);
229 +
230 +-- 3. Table de liaison utilisateurs ↔ rôles (RBAC)
231 +CREATE TABLE user_roles (
232 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
233 + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
234 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
235 + PRIMARY KEY (user_id, role_id)
236 +);
237 +
238 +-- 4. Refresh tokens (rotation de tokens JWT)
239 +CREATE TABLE refresh_tokens (
240 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
241 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
242 + token VARCHAR(255) NOT NULL UNIQUE,
243 + expires_at TIMESTAMPTZ NOT NULL,
244 + revoked BOOLEAN NOT NULL DEFAULT FALSE,
245 + revoked_at TIMESTAMPTZ,
246 + replaced_by UUID,
247 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
248 +);
249 +
250 +-- 5. Blacklist JWT (tokens invalidés au logout)
251 +CREATE TABLE jwt_blacklist (
252 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
253 + token_hash VARCHAR(255) NOT NULL UNIQUE,
254 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
255 + expires_at TIMESTAMPTZ NOT NULL,
256 + revoked_at TIMESTAMPTZ,
257 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
258 +);
259 +
260 +-- 6. Préférences utilisateur
261 +CREATE TABLE user_preferences (
262 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
263 + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
264 + theme VARCHAR(16) NOT NULL DEFAULT 'auto',
265 + language VARCHAR(8) NOT NULL DEFAULT 'fr',
266 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
267 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
268 +);
269 +
270 +-- 7. Tokens de réinitialisation de mot de passe
271 +CREATE TABLE password_reset_tokens (
272 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
273 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
274 + token_hash VARCHAR(255) NOT NULL UNIQUE,
275 + expires_at TIMESTAMPTZ NOT NULL,
276 + used_at TIMESTAMPTZ,
277 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
278 +);
279 +
280 +-- 8. Tokens de validation d'email
281 +CREATE TABLE email_validation_tokens (
282 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
283 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
284 + token_hash VARCHAR(255) NOT NULL UNIQUE,
285 + expires_at TIMESTAMPTZ NOT NULL,
286 + used_at TIMESTAMPTZ,
287 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
288 +);
289 +
290 +-- 9. Demandes de changement de mot de passe (avec confirmation email)
291 +CREATE TABLE password_change_requests (
292 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
293 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
294 + token_hash VARCHAR(255) NOT NULL UNIQUE,
295 + new_password_hash VARCHAR(255) NOT NULL,
296 + expires_at TIMESTAMPTZ NOT NULL,
297 + confirmed BOOLEAN NOT NULL DEFAULT FALSE,
298 + used_at TIMESTAMPTZ,
299 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
300 +);
301 +
302 +-- =============================================
303 +-- Index pour les performances
304 +-- =============================================
305 +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
306 +CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
307 +CREATE INDEX idx_jwt_blacklist_token_hash ON jwt_blacklist(token_hash);
308 +CREATE INDEX idx_jwt_blacklist_expires_at ON jwt_blacklist(expires_at);
309 +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
310 +CREATE INDEX idx_email_validation_tokens_user_id ON email_validation_tokens(user_id);
311 +CREATE INDEX idx_password_change_requests_user_id ON password_change_requests(user_id);
312 +
313 +-- =============================================
314 +-- Seed RBAC : rôles par défaut
315 +-- =============================================
316 +INSERT INTO roles (id, name, description) VALUES
317 + (gen_random_uuid(), 'admin', 'Administrateur avec accès complet'),
318 + (gen_random_uuid(), 'user', 'Utilisateur standard');
319 +```
320 +
321 +> **Note** : Le seed RBAC (insertion des rôles `admin` et `user`) est inclus directement dans la migration pour garantir que les rôles de base existent toujours. Le seed de l'utilisateur admin reste dans `src/bin/seed.rs` car il dépend de variables d'environnement (mot de passe, email).
322 +
323 +### 3.2 Modèles SeaORM (COPIER + ADAPTER)
324 +
325 +| Fichier source | Action | Modifications |
326 +|---|---|---|
327 +| `src/models/user.rs` | Copier | Supprimer les relations avec organisations/shares |
328 +| `src/models/role.rs` | Copier tel quel | Aucune |
329 +| `src/models/user_role.rs` | Copier tel quel | Aucune |
330 +| `src/models/refresh_token.rs` | Copier tel quel | Aucune |
331 +| `src/models/jwt_blacklist.rs` | Copier tel quel | Aucune |
332 +| `src/models/user_preferences.rs` | Copier tel quel | Aucune |
333 +| `src/models/password_reset_token.rs` | Copier tel quel | Aucune |
334 +| `src/models/email_validation_token.rs` | Copier tel quel | Aucune |
335 +| `src/models/password_change_request.rs` | Copier tel quel | Aucune |
336 +
337 +**Modèles à NE PAS copier** :
338 +- Tout modèle lié à organisation, share, files, todo, apps
339 +
340 +### 3.3 Module Auth (COPIER + ADAPTER)
341 +
342 +| Fichier source | Action | Modifications |
343 +|---|---|---|
344 +| `src/auth/mod.rs` | Copier | Supprimer les exports liés au domaine métier |
345 +| `src/auth/session.rs` | Copier tel quel | Aucune — déjà générique |
346 +| `src/auth/jwt.rs` | Copier | Renommer issuer `rustcloud` → `rustwarden` |
347 +| `src/auth/jwt_extractor.rs` | Copier tel quel | Aucune |
348 +| `src/auth/handlers.rs` | Copier | Supprimer les redirections vers `/home` rustcloud, rendre configurable |
349 +| `src/auth/extractors.rs` | Copier tel quel | Aucune — déjà générique |
350 +
351 +### 3.4 Services (COPIER + ADAPTER)
352 +
353 +| Fichier source | Action | Modifications |
354 +|---|---|---|
355 +| `src/services/auth_service.rs` | Copier | Supprimer les dépendances vers le domaine métier |
356 +| `src/services/user_service.rs` | Copier | Retirer les fonctions liées aux organisations/shares, garder uniquement CRUD user + rôles |
357 +| `src/services/role_service.rs` | Copier tel quel | Aucune |
358 +| `src/services/password_service.rs` | Copier tel quel | Aucune |
359 +| `src/services/password_reset_service.rs` | Copier tel quel | Aucune |
360 +| `src/services/password_change_service.rs` | Copier tel quel | Aucune |
361 +| `src/services/jwt_blacklist_service.rs` | Copier tel quel | Aucune |
362 +| `src/services/refresh_token_service.rs` | Copier tel quel | Aucune |
363 +| `src/services/email_validation_service.rs` | Copier tel quel | Aucune |
364 +| `src/services/auth_logging.rs` | Copier tel quel | Aucune |
365 +
366 +### 3.5 Middleware (COPIER + ADAPTER)
367 +
368 +| Fichier source | Action | Modifications |
369 +|---|---|---|
370 +| `src/api/middleware/auth_service.rs` | Copier | Rendre les routes protégées **configurables** au lieu d'un hardcodé `/home`, `/settings`, etc. |
371 +| `src/api/middleware/admin_service.rs` | Copier | Rendre les routes admin configurables |
372 +| `src/api/middleware/auth_required.rs` | Copier tel quel | Aucune |
373 +| `src/api/middleware/admin_helper.rs` | Copier tel quel | Aucune |
374 +| `src/api/middleware/rate_limit.rs` | Copier tel quel | Aucune |
375 +
376 +### 3.6 Configuration (COPIER + ADAPTER)
377 +
378 +| Fichier source | Action | Modifications |
379 +|---|---|---|
380 +| `src/config/auth.rs` | Copier | Renommer `rustcloud` → `rustwarden` dans les valeurs par défaut |
381 +| `env.sample` | Copier | Simplifier, retirer les variables spécifiques au domaine métier |
382 +
383 +### 3.7 Handlers / Routes API (COPIER + ADAPTER)
384 +
385 +| Fichier source | Action | Modifications |
386 +|---|---|---|
387 +| `src/api/routes.rs` | Copier | **Épurer** : garder uniquement les routes auth, user, admin, settings. Supprimer files, shares, todo, apps, organisations |
388 +| `src/api/handlers/login_handler.rs` | Copier | Adapter les templates |
389 +| `src/api/handlers/user_handler.rs` | Copier | Garder uniquement le CRUD user |
390 +| `src/api/handlers/admin_handler.rs` | Copier | Retirer la gestion d'organisations/apps/settings domaine |
391 +| `src/api/handlers/settings_handler.rs` | Copier | Garder profil, password, sessions, préférences |
392 +
393 +### 3.8 Utilitaires CLI (COPIER + ADAPTER)
394 +
395 +| Fichier source | Action | Modifications |
396 +|---|---|---|
397 +| `src/bin/seed.rs` | Copier | Adapter les données de seed, simplifier |
398 +| `src/bin/hash_password.rs` | Copier tel quel | Aucune |
399 +| `src/bin/change_password.rs` | Copier tel quel | Aucune |
400 +
401 +### 3.9 Code Rust des templates — `src/templates/` (COPIER + ADAPTER)
402 +
403 +| Fichier source | Action | Modifications |
404 +|---|---|---|
405 +| `src/templates/mod.rs` | Copier + adapter | Macro `impl_template_response`, `LayoutContext` — renommer RustCloud → RustWarden, adapter les DTOs |
406 +| `src/templates/email_templates.rs` | Copier + adapter | Structs Askama pour tous les emails (reset, validation, welcome, etc.) — renommer le branding |
407 +| `src/templates/helpers.rs` | Copier + adapter | Contexte de base (session, claims), breadcrumb, sidebar, navigation, CSRF, initiales, couleur avatar — adapter les chemins et références |
408 +| `src/templates/helpers/sharing_helpers.rs` | **NE PAS COPIER** | Spécifique au domaine métier (organisations, partages) |
409 +
410 +### 3.10 Templates HTML — `templates/` (COPIER + ADAPTER)
411 +
412 +#### Layout et base
413 +
414 +| Fichier source | Action | Modifications |
415 +|---|---|---|
416 +| `templates/base.html` | Copier + adapter | Layout HTML de base (Tailwind/DaisyUI, HTMX, Alpine.js) — renommer RustCloud → RustWarden |
417 +| `templates/base_with_theme.html` | **Copier tel quel** | Snippet thème utilisateur — déjà générique |
418 +
419 +#### Pages publiques (auth)
420 +
421 +| Fichier source | Action | Modifications |
422 +|---|---|---|
423 +| `templates/login.html` | Copier + adapter | Page login/register avec onglets, i18n — adapter le branding |
424 +| `templates/resend_verification.html` | Copier + adapter | Page renvoi de vérification — adapter le branding |
425 +| `templates/reset_password.html` | Copier + adapter | Page réinitialisation mot de passe — adapter le branding |
426 +| `templates/email_verified.html` | Copier + adapter | Page confirmation email vérifié — adapter le branding |
427 +
428 +#### Pages utilisateur
429 +
430 +| Fichier source | Action | Modifications |
431 +|---|---|---|
432 +| `templates/home.html` | Copier + **réécrire** | Page d'accueil post-login — contenu spécifique rustcloud à remplacer par un dashboard Rustwarden |
433 +| `templates/profile.html` | Copier + adapter | Page profil utilisateur — adapter le branding |
434 +| `templates/settings.html` | Copier + adapter | Page paramètres — retirer les entrées spécifiques au domaine |
435 +| `templates/settings/password.html` | Copier + adapter | Formulaire changement mot de passe — adapter le branding |
436 +| `templates/settings/preferences.html` | Copier + adapter | Préférences (thème, langue) — adapter le branding |
437 +| `templates/settings/sessions.html` | Copier + adapter | Sessions actives + révocation — adapter le branding |
438 +
439 +#### Composants réutilisables
440 +
441 +| Fichier source | Action | Modifications |
442 +|---|---|---|
443 +| `templates/components/header.html` | Copier + adapter | En-tête avec logo et navigation — adapter branding et liens |
444 +| `templates/components/navbar.html` | Copier + adapter | Barre de navigation DaisyUI — adapter les liens |
445 +| `templates/components/navigation.html` | Copier + adapter | Liens de navigation — **retirer** "Fichiers" et liens domaine, garder home/settings/admin |
446 +| `templates/components/sidebar.html` | **Copier tel quel** | Sidebar avec sections — déjà générique |
447 +| `templates/components/user_menu.html` | **Copier tel quel** | Menu utilisateur (avatar, dropdown) — déjà générique |
448 +| `templates/components/breadcrumb.html` | **Copier tel quel** | Fil d'Ariane DaisyUI — déjà générique |
449 +| `templates/components/password_change_badge.html` | **Copier tel quel** | Badge alerte changement password — déjà générique |
450 +
451 +#### Fragments HTMX
452 +
453 +| Fichier source | Action | Modifications |
454 +|---|---|---|
455 +| `templates/fragments/user_menu_dropdown.html` | **Copier tel quel** | Dropdown profil/paramètres/déconnexion — déjà générique |
456 +| `templates/fragments/form_message.html` | **Copier tel quel** | Fragment messages succès/erreur — déjà générique |
457 +
458 +#### Admin
459 +
460 +| Fichier source | Action | Modifications |
461 +|---|---|---|
462 +| `templates/admin/dashboard.html` | Copier + adapter | Dashboard admin — **retirer** les sections organisations/apps |
463 +| `templates/admin/users_list.html` | Copier + adapter | Liste utilisateurs — adapter le branding |
464 +| `templates/admin/user_form.html` | Copier + adapter | Formulaire création/édition — adapter le branding |
465 +| `templates/admin/errors.html` | Copier + adapter | Page erreurs admin — adapter le branding |
466 +| `templates/admin/settings.html` | Copier + adapter | Paramètres admin — retirer les paramètres domaine |
467 +
468 +#### Emails
469 +
470 +| Fichier source | Action | Modifications |
471 +|---|---|---|
472 +| `templates/emails/email_validation.html` | Copier + adapter | Validation email — renommer RustCloud → RustWarden |
473 +| `templates/emails/password_reset.html` | Copier + adapter | Réinitialisation password — renommer |
474 +| `templates/emails/password_changed.html` | Copier + adapter | Notification password changé — renommer |
475 +| `templates/emails/password_change_confirmation.html` | Copier + adapter | Confirmation changement password — renommer |
476 +| `templates/emails/welcome.html` | Copier + adapter | Email de bienvenue — renommer |
477 +| `templates/emails/admin_new_user.html` | Copier + adapter | Notification admin nouvel utilisateur — renommer |
478 +| `templates/emails/account_modified.html` | Copier + adapter | Notification modification compte — renommer |
479 +
480 +#### Pages d'erreur
481 +
482 +| Fichier source | Action | Modifications |
483 +|---|---|---|
484 +| `templates/errors/401.html` | Copier + adapter | Non authentifié — adapter le branding |
485 +| `templates/errors/403.html` | Copier + adapter | Accès refusé — adapter le branding |
486 +| `templates/errors/500.html` | Copier + adapter | Erreur serveur — adapter le branding |
487 +
488 +#### Templates à NE PAS copier (domaine métier rustcloud)
489 +
490 +| Fichier | Raison |
491 +|---|---|
492 +| `templates/admin/organisation_form.html` | Spécifique organisations |
493 +| `templates/admin/organisation_members.html` | Spécifique organisations |
494 +| `templates/admin/organisations_list.html` | Spécifique organisations |
495 +| `templates/admin/organisations_list_rows.html` | Spécifique organisations |
496 +| `templates/admin/apps_list.html` | Spécifique apps modulaires |
497 +| `templates/components/organisation_selector.html` | Spécifique organisations |
498 +| `templates/components/permissions_selector.html` | Spécifique partage |
499 +| `templates/components/share_button.html` | Spécifique partage |
500 +| `templates/components/share_list_item.html` | Spécifique partage |
501 +| `templates/fragments/member_row.html` | Spécifique organisations |
502 +| `templates/fragments/organisation_row.html` | Spécifique organisations |
503 +| `templates/sharing/permissions_customize.html` | Spécifique partage |
504 +| `templates/sharing/share_modal.html` | Spécifique partage |
505 +| `templates/sharing/shares_received.html` | Spécifique partage |
506 +| `templates/test.html` | Page de test |
507 +| `templates/test_base.html` | Page de test |
508 +
509 +### 3.11 Fichiers statiques — `static/` (COPIER + ADAPTER)
510 +
511 +#### CSS
512 +
513 +| Fichier source | Taille | Action | Modifications |
514 +|---|---|---|---|
515 +| `static/css/tailwind.src.css` | 2,2 Ko | Copier + adapter | Fichier source Tailwind 4 + DaisyUI 5 — renommer le thème `rustcloud` → `rustwarden` |
516 +| `static/css/tailwind.css` | 297 Ko | **Regénérer** | Fichier compilé — ne pas copier, le regénérer avec `npm run build:css` |
517 +| `static/css/custom.css` | 1,4 Ko | Copier + adapter | Variables CSS (`--rc-primary`, etc.), couleurs avatars, scrollbar — renommer le préfixe `--rc-` → `--rw-` |
518 +
519 +#### JavaScript
520 +
521 +| Fichier source | Taille | Action | Modifications |
522 +|---|---|---|---|
523 +| `static/js/htmx.min.js` | 47 Ko | **Copier tel quel** | Librairie HTMX utilisée dans base.html et login.html |
524 +| `static/js/alpine.min.js` | 43 Ko | **Copier tel quel** | Librairie Alpine.js pour les composants réactifs (dropdowns, onglets, etc.) |
525 +
526 +#### Images
527 +
528 +| Fichier source | Taille | Action | Modifications |
529 +|---|---|---|---|
530 +| `static/images/favicon.svg` | 4,3 Ko | **Remplacer** | Créer un nouveau favicon pour Rustwarden |
531 +| `static/images/apple-touch-icon.png` | 2,4 Ko | **Remplacer** | Créer une nouvelle icône Apple Touch |
532 +| `static/images/logo.svg` | 26 Ko | **Remplacer** | Créer un nouveau logo SVG Rustwarden |
533 +| `static/images/logo.png` | 19 Ko | **Remplacer** | Créer un nouveau logo PNG Rustwarden |
534 +
535 +#### Fichier à NE PAS copier
536 +
537 +| Fichier | Raison |
538 +|---|---|
539 +| `static/test.txt` | Fichier de test, inutile |
540 +
541 +### 3.12 Configuration frontend (COPIER + ADAPTER)
542 +
543 +| Fichier source | Action | Modifications |
544 +|---|---|---|
545 +| `package.json` | Copier + adapter | Renommer `rustcloud` → `rustwarden`, garder les scripts `build:css` / `build:css:watch` / `build:css:min` |
546 +| `tailwind.config.js` | Copier + adapter | Renommer le thème DaisyUI `rustcloud` → `rustwarden`, adapter les couleurs primaire/secondaire |
547 +
548 +**Configuration Tailwind adaptée pour Rustwarden :**
549 +
550 +```js
551 +// tailwind.config.js
552 +module.exports = {
553 + content: ["./templates/**/*.html"],
554 + theme: {
555 + extend: {
556 + colors: {
557 + 'rustwarden': {
558 + 'blue': '#3b82f6', // Couleur primaire à définir
559 + 'rust': '#f97316', // Couleur accent à définir
560 + }
561 + }
562 + },
563 + },
564 + plugins: [require('daisyui')],
565 + daisyui: {
566 + themes: [
567 + {
568 + rustwarden: {
569 + "primary": "#3b82f6",
570 + "secondary": "#1e293b",
571 + "accent": "#10b981",
572 + "neutral": "#f5f5f5",
573 + "base-100": "#ffffff",
574 + "info": "#3b82f6",
575 + "success": "#10b981",
576 + "warning": "#f59e0b",
577 + "error": "#ef4444",
578 + },
579 + },
580 + "light",
581 + "dark",
582 + ],
583 + },
584 +}
585 +```
586 +
587 +**Commande de build CSS :**
588 +
589 +```bash
590 +npm install # Installer Tailwind + DaisyUI
591 +npm run build:css # Compiler le CSS
592 +npm run build:css:watch # Mode watch (développement)
593 +npm run build:css:min # Compiler + minifier (production)
594 +```
595 +
596 +> **Bugs à corriger** (hérités de rustcloud) :
597 +> - `templates/base.html` référence `/static/images/favicon.png` qui n'existe pas → utiliser `favicon.svg`
598 +> - `templates/email_verified.html` référence `output.css` au lieu de `tailwind.css` → corriger
599 +
600 +### 3.13 Fichiers à NE PAS copier (résumé global)
601 +
602 +| Catégorie | Exemples |
603 +|---|---|
604 +| Apps modulaires | `crates/apps/files/`, `crates/apps/todo/` |
605 +| Core métier | `crates/rustcloud-core/` (sauf `sharing/permissions.rs` si on veut le système de permissions bitmask) |
606 +| SDK | `crates/rustcloud-app-sdk/` |
607 +| i18n spécifique | Fichiers de traduction spécifiques au domaine |
608 +| WebDAV | `crates/apps/files/src/webdav/` |
609 +| Organisations | Tout ce qui touche aux organisations |
610 +| Partages | Tout ce qui touche aux shares |
611 +
612 +---
613 +
614 +## 4. Modifications structurelles à effectuer
615 +
616 +### 4.1 Cargo.toml — Nouveau fichier
617 +
618 +```toml
619 +[package]
620 +name = "rustwarden"
621 +version = "0.1.0"
622 +edition = "2021"
623 +default-run = "rustwarden"
624 +
625 +[dependencies]
626 +# Web framework
627 +axum = { version = "0.8", features = ["macros"] }
628 +tokio = { version = "1", features = ["full"] }
629 +async-trait = "0.1"
630 +# Serialization
631 +serde = { version = "1.0", features = ["derive"] }
632 +serde_json = "1.0"
633 +# Middleware
634 +tower = { version = "0.5", features = ["limit", "util"] }
635 +tower-http = { version = "0.6", features = ["cors", "trace", "limit", "fs"] }
636 +# Templates
637 +askama = "0.14"
638 +# Logging
639 +tracing = "0.1"
640 +tracing-subscriber = { version = "0.3", features = ["env-filter"] }
641 +# Env
642 +dotenvy = "0.15"
643 +# Database ORM
644 +sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
645 +sea-orm-migration = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls"] }
646 +# Password hashing
647 +bcrypt = "0.15"
648 +# Validation
649 +validator = { version = "0.20", features = ["derive"] }
650 +# UUID & Datetime
651 +uuid = { version = "1.0", features = ["v4", "serde"] }
652 +chrono = { version = "0.4", features = ["serde"] }
653 +time = { version = "0.3", features = ["macros", "serde"] }
654 +# Error handling
655 +thiserror = "1.0"
656 +anyhow = "1.0"
657 +# Authentication — JWT & Sessions
658 +jsonwebtoken = "9.3"
659 +axum-login = "0.18"
660 +tower-sessions = "0.14"
661 +tower-sessions-seaorm-store = "0.1.1"
662 +tower-sessions-redis-store = { version = "0.14", optional = true }
663 +tower-csrf = "0.1"
664 +axum-extra = { version = "0.9", features = ["cookie"] }
665 +rand = { version = "0.8", features = ["std"] }
666 +# JWT Blacklist
667 +sha2 = "0.10"
668 +base64 = "0.22"
669 +hex = "0.4"
670 +# Email
671 +lettre = { version = "0.11", features = ["tokio1-native-tls", "builder"] }
672 +
673 +[features]
674 +default = []
675 +redis-session = ["tower-sessions-redis-store"]
676 +
677 +[[bin]]
678 +name = "migrate"
679 +path = "migrations/src/main.rs"
680 +
681 +[[bin]]
682 +name = "hash_password"
683 +path = "src/bin/hash_password.rs"
684 +
685 +[[bin]]
686 +name = "seed"
687 +path = "src/bin/seed.rs"
688 +
689 +[[bin]]
690 +name = "change_password"
691 +path = "src/bin/change_password.rs"
692 +```
693 +
694 +### 4.2 Renommages globaux
695 +
696 +| Ancien | Nouveau |
697 +|---|---|
698 +| `rustcloud` | `rustwarden` |
699 +| `RustCloud` | `RustWarden` |
700 +| `RUSTCLOUD` | `RUSTWARDEN` |
701 +
702 +À appliquer dans tous les fichiers copiés (noms de crates, imports, commentaires, issuer JWT, noms de cookies, etc.).
703 +
704 +### 4.3 Rendre les routes protégées configurables
705 +
706 +Actuellement dans rustcloud, les routes protégées sont hardcodées dans `AuthService` :
707 +
708 +```rust
709 +// Ancien (hardcodé dans rustcloud)
710 +let protected_paths = ["/home", "/settings", "/test-base", "/profil", "/apps/", "/shares"];
711 +```
712 +
713 +**Modification** : les rendre configurables via la structure `AuthConfig` :
714 +
715 +```rust
716 +// Nouveau (configurable dans rustwarden)
717 +pub struct AuthConfig {
718 + // ... champs existants ...
719 + pub protected_paths: Vec<String>,
720 + pub admin_paths: Vec<String>,
721 + pub public_paths: Vec<String>,
722 +}
723 +```
724 +
725 +### 4.4 Supprimer les dépendances au domaine métier
726 +
727 +Dans `user_service.rs` et `auth_service.rs`, retirer :
728 +- Toute référence aux organisations
729 +- Toute référence aux shares/partages
730 +- Toute référence aux apps modulaires
731 +- Les événements spécifiques (UserCreatedEvent lié au EventBus de rustcloud)
732 +
733 +### 4.5 Simplifier `main.rs`
734 +
735 +Le `main.rs` de Rustwarden doit être épuré :
736 +- Garder : connexion DB, migrations, session layer, auth middleware, admin middleware, rate limiting
737 +- Retirer : chargement des apps, EventBus, WebDAV, organisations, i18n complexe
738 +
739 +---
740 +
741 +## 5. Schéma de base de données Rustwarden
742 +
743 +```
744 +┌─────────────────────────┐
745 +│ users │
746 +├─────────────────────────┤
747 +│ id UUID PK │
748 +│ username VARCHAR(64) │ UNIQUE
749 +│ email VARCHAR(255) │ UNIQUE
750 +│ password_hash VARCHAR │
751 +│ must_change_password BOOL│
752 +│ last_login_at TIMESTAMP │
753 +│ last_login_ip VARCHAR │
754 +│ password_changed_at TS │
755 +│ email_verified BOOL │
756 +│ email_verified_at TS │
757 +│ created_at TIMESTAMP │
758 +│ updated_at TIMESTAMP │
759 +└──────────┬──────────────┘
760 + │
761 + ┌─────┼──────────────────────────────────┐
762 + │ │ │
763 + ▼ ▼ ▼
764 +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
765 +│ user_roles │ │ roles │ │ user_preferences │
766 +├──────────────────┤ ├──────────────────┤ ├──────────────────────┤
767 +│ user_id FK │──│ id UUID PK │ │ id UUID PK │
768 +│ role_id FK │ │ name UNIQUE │ │ user_id FK UNIQUE │
769 +│ created_at │ │ description │ │ theme ENUM │
770 +│ (PK composite) │ │ created_at │ │ language ENUM │
771 +└──────────────────┘ │ updated_at │ │ created_at │
772 + └──────────────────┘ │ updated_at │
773 + └──────────────────────┘
774 + │
775 + ├──────────────────────────────────────────────────────┐
776 + │ │ │
777 + ▼ ▼ ▼
778 +┌────────────────────┐ ┌────────────────────────┐ ┌─────────────────────────┐
779 +│ refresh_token │ │ jwt_blacklist │ │ password_reset_tokens │
780 +├────────────────────┤ ├────────────────────────┤ ├─────────────────────────┤
781 +│ id UUID PK │ │ id UUID PK │ │ id UUID PK │
782 +│ user_id FK │ │ token_hash UNIQUE │ │ user_id FK │
783 +│ token UNIQUE │ │ user_id FK │ │ token_hash UNIQUE │
784 +│ expires_at │ │ expires_at │ │ expires_at │
785 +│ revoked BOOL │ │ revoked_at │ │ used_at │
786 +│ revoked_at │ │ created_at │ │ created_at │
787 +│ replaced_by │ └────────────────────────┘ └─────────────────────────┘
788 +│ created_at │
789 +└────────────────────┘
790 + ┌──────────────────────────┐ ┌─────────────────────────────┐
791 + │ email_validation_tokens │ │ password_change_requests │
792 + ├──────────────────────────┤ ├─────────────────────────────┤
793 + │ id UUID PK │ │ id UUID PK │
794 + │ user_id FK │ │ user_id FK │
795 + │ token_hash UNIQUE │ │ token_hash UNIQUE │
796 + │ expires_at │ │ new_password_hash │
797 + │ used_at │ │ expires_at │
798 + │ created_at │ │ confirmed / used_at │
799 + └──────────────────────────┘ │ created_at │
800 + └─────────────────────────────┘
801 +```
802 +
803 +---
804 +
805 +## 6. API REST — Endpoints Rustwarden
806 +
807 +### 6.1 Authentification (`/api/v1/auth`)
808 +
809 +| Méthode | Endpoint | Description | Auth requise |
810 +|---|---|---|---|
811 +| POST | `/api/v1/auth/login` | Connexion (retourne JWT + refresh token) | Non |
812 +| POST | `/api/v1/auth/register` | Inscription | Non |
813 +| POST | `/api/v1/auth/logout` | Déconnexion (blacklist JWT) | Oui |
814 +| POST | `/api/v1/auth/refresh` | Rafraîchir le token JWT | Non (refresh token) |
815 +| GET | `/api/v1/auth/me` | Utilisateur courant | Oui |
816 +| POST | `/api/v1/auth/forgot-password` | Demande de reset password | Non |
817 +| POST | `/api/v1/auth/reset-password` | Réinitialiser le mot de passe | Non (token) |
818 +| GET | `/api/v1/auth/verify-email/{token}` | Vérifier l'email | Non (token) |
819 +| POST | `/api/v1/auth/resend-verification` | Renvoyer l'email de vérification | Oui |
820 +| POST | `/api/v1/auth/resend-verification/by-username` | Renvoyer par username | Non |
821 +
822 +### 6.2 Gestion utilisateurs (`/api/v1/users`)
823 +
824 +| Méthode | Endpoint | Description | Auth requise |
825 +|---|---|---|---|
826 +| GET | `/api/v1/users` | Lister les utilisateurs | Admin |
827 +| POST | `/api/v1/users` | Créer un utilisateur | Admin |
828 +
829 +### 6.3 Administration (`/admin`)
830 +
831 +| Méthode | Endpoint | Description | Auth requise |
832 +|---|---|---|---|
833 +| GET | `/admin` | Dashboard admin | Admin |
834 +| GET | `/admin/users` | Liste des utilisateurs | Admin |
835 +| GET | `/admin/users/create` | Formulaire création | Admin |
836 +| POST | `/admin/users` | Créer un utilisateur | Admin |
837 +| GET | `/admin/users/{id}` | Formulaire édition | Admin |
838 +| PUT | `/admin/users/{id}` | Modifier un utilisateur | Admin |
839 +| DELETE | `/admin/users/{id}` | Supprimer un utilisateur | Admin |
840 +| POST | `/admin/users/{id}/reset-password` | Reset password (admin) | Admin |
841 +| POST | `/admin/users/{id}/reset-password-email` | Reset password par email | Admin |
842 +
843 +### 6.4 Paramètres utilisateur (`/settings`)
844 +
845 +| Méthode | Endpoint | Description | Auth requise |
846 +|---|---|---|---|
847 +| GET | `/settings` | Page paramètres | Oui |
848 +| GET | `/profile` | Page profil | Oui |
849 +| PUT | `/profile` | Modifier profil | Oui |
850 +| GET | `/settings/password` | Page mot de passe | Oui |
851 +| PUT | `/settings/password` | Changer mot de passe | Oui |
852 +| DELETE | `/settings/password/pending` | Annuler changement en cours | Oui |
853 +| GET | `/settings/sessions` | Sessions actives | Oui |
854 +| DELETE | `/settings/sessions/{id}` | Révoquer une session | Oui |
855 +| GET | `/settings/preferences` | Préférences | Oui |
856 +| PUT | `/settings/preferences` | Modifier préférences | Oui |
857 +| GET | `/confirm-password-change` | Confirmer changement password | Non (token) |
858 +
859 +### 6.5 Pages publiques
860 +
861 +| Méthode | Endpoint | Description |
862 +|---|---|---|
863 +| GET | `/login` | Page de connexion |
864 +| GET | `/resend-verification` | Page de renvoi de vérification |
865 +| GET | `/reset-password` | Page de réinitialisation |
866 +
867 +---
868 +
869 +## 7. Stack technique
870 +
871 +| Composant | Technologie | Version |
872 +|---|---|---|
873 +| Langage | Rust | 1.91+ |
874 +| Framework web | Axum | 0.8 |
875 +| ORM | SeaORM | 2.0.0-rc.20 |
876 +| Base de données | PostgreSQL | 14+ |
877 +| Runtime async | Tokio | 1.x |
878 +| JWT | jsonwebtoken | 10.3.0 |
879 +| Hashing passwords | bcrypt | 0.15 |
880 +| Sessions | tower-sessions | 0.14 |
881 +| Sessions store | tower-sessions-seaorm-store | 0.1.1 |
882 +| Sessions Redis (opt.) | tower-sessions-redis-store | 0.16 |
883 +| CSRF | tower-csrf | 0.1 |
884 +| Templates | Askama | 0.15.4 |
885 +| Email | Lettre | 0.11.19 |
886 +| Validation | validator | 0.20 |
887 +| Rate limiting | Custom (tower middleware) | — |
888 +
889 +---
890 +
891 +## 8. Variables d'environnement
892 +
893 +```env
894 +# Base de données
895 +DATABASE_URL=postgres://rustwarden_user:password@localhost:5432/rustwarden
896 +
897 +# Serveur
898 +SERVER_HOST=0.0.0.0
899 +SERVER_PORT=3000
900 +
901 +# Logging
902 +RUST_LOG=debug
903 +
904 +# JWT
905 +JWT_SECRET=<min 32 caractères>
906 +JWT_EXPIRATION_MINUTES=15
907 +JWT_ISSUER=rustwarden
908 +
909 +# Refresh tokens
910 +REFRESH_TOKEN_EXPIRATION_DAYS=7
911 +
912 +# Sessions
913 +SESSION_SECRET=<min 64 caractères>
914 +SESSION_TIMEOUT_MINUTES=30
915 +SESSION_BACKEND=memory # memory | seaorm | redis
916 +
917 +# Redis (si SESSION_BACKEND=redis)
918 +REDIS_URL=redis://localhost:6379
919 +
920 +# Rate Limiting
921 +RATE_LIMIT_LOGIN_PER_MINUTE=5
922 +RATE_LIMIT_REFRESH_PER_MINUTE=10
923 +RATE_LIMIT_GENERAL_PER_MINUTE=100
924 +
925 +# Cookies
926 +COOKIE_SECURE=false # true en production
927 +COOKIE_SAME_SITE=Lax # Lax | Strict
928 +
929 +# Admin initial
930 +ADMIN_USERNAME=admin
931 +ADMIN_EMAIL=admin@localhost
932 +ADMIN_PASSWORD=ChangeMe123!
933 +
934 +# Email SMTP
935 +SMTP_HOST=localhost
936 +SMTP_PORT=587
937 +SMTP_USERNAME=
938 +SMTP_PASSWORD=
939 +SMTP_FROM=noreply@rustwarden.local
940 +```
941 +
942 +---
943 +
944 +## 9. Fonctionnalités du framework — Détail
945 +
946 +### 9.1 Authentification JWT
947 +- Algorithme HS256
948 +- Claims : `sub` (user_id), `username`, `roles`, `exp`, `iat`, `iss`
949 +- Extraction depuis header `Authorization: Bearer <token>` OU cookie `jwt_token`
950 +- Blacklist au logout (hash SHA256 stocké en base)
951 +- Refresh token avec rotation (ancien token révoqué, nouveau créé)
952 +
953 +### 9.2 Sessions
954 +- Backends interchangeables : Memory (dev), SeaORM (prod), Redis (haute dispo)
955 +- Données de session : `user_id`, `username`, `roles`
956 +- Cookies : HttpOnly, Secure (configurable), SameSite (Lax/Strict)
957 +
958 +### 9.3 RBAC (Role-Based Access Control)
959 +- Rôles nommés (admin, user, etc.)
960 +- Table de liaison many-to-many `user_roles`
961 +- Extracteur `AuthUser` avec méthodes : `has_role()`, `has_any_role()`, `has_all_roles()`
962 +- Middleware `AdminService` : vérifie le rôle "admin"
963 +
964 +### 9.4 Gestion des mots de passe
965 +- Hash bcrypt
966 +- Réinitialisation par email (token à usage unique avec expiration)
967 +- Changement avec confirmation par email (double validation)
968 +- Flag `must_change_password` pour forcer le changement à la prochaine connexion
969 +
970 +### 9.5 Vérification email
971 +- Token envoyé par email à l'inscription
972 +- Endpoint de vérification par token
973 +- Renvoi de l'email de vérification (par auth ou par username)
974 +- Champs `email_verified` et `email_verified_at`
975 +
976 +### 9.6 Rate Limiting
977 +- Par endpoint (login, refresh, admin, general)
978 +- Configurable via variables d'environnement
979 +- Implémenté en middleware Tower
980 +
981 +### 9.7 Middleware d'authentification
982 +- `AuthService` : protège les routes nécessitant une connexion
983 +- `AdminService` : protège les routes nécessitant le rôle admin
984 +- Chaîne : `Request → AdminService → AuthService → SessionLayer → Router`
985 +- Redirection vers `/login?redirect=...` si non authentifié
986 +
987 +---
988 +
989 +## 10. Plan d'implémentation par phases
990 +
991 +### Phase 1 — Socle (Fondations)
992 +1. Initialiser le projet Cargo
993 +2. Copier et adapter le `Cargo.toml`
994 +3. Créer la migration unique consolidée `m20260305_000001_initial_schema.rs` (fusion des 11 migrations rustcloud + seed RBAC)
995 +4. Créer `migrations/src/main.rs` et `migrations/src/lib.rs`
996 +5. Copier tous les modèles SeaORM
997 +6. Copier le module `config/`
998 +7. Copier `env.sample`
999 +8. Vérifier la compilation et tester `cargo run --bin migrate`
1000 +
1001 +### Phase 2 — Services
1002 +1. Copier `services/password_service.rs` (aucune dépendance)
1003 +2. Copier `services/auth_service.rs` (dépend de user model + password)
1004 +3. Copier `services/user_service.rs` (épurer les dépendances métier)
1005 +4. Copier `services/role_service.rs`
1006 +5. Copier les services JWT, refresh token, blacklist
1007 +6. Copier les services email, reset password, password change
1008 +7. Copier `services/auth_logging.rs`
1009 +
1010 +### Phase 3 — Auth & Middleware
1011 +1. Copier le module `auth/` complet
1012 +2. Copier le module `api/middleware/`
1013 +3. Rendre les routes protégées configurables
1014 +4. Tester la chaîne d'authentification
1015 +
1016 +### Phase 4 — Handlers & Routes
1017 +1. Copier les handlers auth (login, register, etc.)
1018 +2. Copier les handlers admin (CRUD users)
1019 +3. Copier les handlers settings (profil, password, sessions, preferences)
1020 +4. Copier et adapter `api/routes.rs`
1021 +5. Créer un `main.rs` simplifié
1022 +
1023 +### Phase 5 — Templates & UI
1024 +1. Copier les templates login/register/reset
1025 +2. Copier les templates admin
1026 +3. Copier les templates settings
1027 +4. Adapter le branding (rustcloud → rustwarden)
1028 +5. Nettoyer la navigation (retirer les liens vers les fonctionnalités métier)
1029 +
1030 +### Phase 6 — CLI & Seed
1031 +1. Copier les binaires utilitaires
1032 +2. Adapter le seed pour Rustwarden
1033 +3. Tester le flux complet : seed → login → admin → settings
1034 +
1035 +### Phase 7 — Évolutions futures (post-MVP)
1036 +1. OAuth2 / OpenID Connect (login via Google, GitHub, etc.)
1037 +2. LDAP / Active Directory
1038 +3. 2FA (TOTP / WebAuthn)
1039 +4. Audit log complet
1040 +5. API keys pour applications tierces
1041 +6. Système de permissions granulaire (bitmask, inspiré de `SharePermissions`)
1042 +
1043 +---
1044 +
1045 +## 11. Critères de validation
1046 +
1047 +Le framework Rustwarden sera considéré comme fonctionnel quand :
1048 +
1049 +- [ ] `cargo run --bin migrate` crée toutes les tables
1050 +- [ ] `cargo run --bin seed` crée l'utilisateur admin et les rôles
1051 +- [ ] `cargo run` démarre le serveur sur le port configuré
1052 +- [ ] La page `/login` s'affiche correctement
1053 +- [ ] Le login/logout fonctionne (JWT + cookie)
1054 +- [ ] Le refresh token fonctionne
1055 +- [ ] L'inscription crée un utilisateur et envoie un email de vérification
1056 +- [ ] La réinitialisation de mot de passe fonctionne par email
1057 +- [ ] L'admin peut lister, créer, éditer, supprimer des utilisateurs
1058 +- [ ] Le middleware bloque l'accès aux routes protégées sans auth
1059 +- [ ] Le middleware admin bloque l'accès sans rôle admin
1060 +- [ ] Le rate limiting fonctionne sur `/api/v1/auth/login`
1061 +- [ ] Les préférences utilisateur (thème, langue) sont sauvegardées
1062 +- [ ] La gestion des sessions actives fonctionne (liste, révocation)
1063 +
1064 +---
1065 +
1066 +## 12. Ressources de test existantes
1067 +
1068 +Le projet rustcloud dispose de deux suites de tests réutilisables :
1069 +
1070 +| Projet | Type | Chemin |
1071 +|---|---|---|
1072 +| `sele_rustcloud` | Tests E2E Selenium (Python/pytest) | `/home/monsieurb/Documents/devel/specKit/sele_rustcloud` |
1073 +| `Bruno_rustcloud` | Collection API Bruno | `/home/monsieurb/Documents/devel/specKit/Bruno_rustcloud` |
1074 +
1075 +Ces suites pourront être adaptées pour Rustwarden en modifiant les URLs et les données de seed.
A docs/configuration-flows.md
+606 -0
@@ -0,0 +1,606 @@
1 +# RustWarden — Variables d'environnement & Diagrammes de séquence
2 +
3 +Ce document décrit l'ensemble des variables d'environnement de RustWarden et les diagrammes de séquence montrant où chacune intervient dans les flux applicatifs.
4 +
5 +---
6 +
7 +## Table des matières
8 +
9 +1. [Démarrage du serveur](#1-démarrage-du-serveur)
10 +2. [Authentification (Login)](#2-authentification-login)
11 +3. [Rendu des pages (i18n, thème, navigation)](#3-rendu-des-pages)
12 +4. [Inscription & Validation email](#4-inscription--validation-email)
13 +5. [Réinitialisation de mot de passe (Forgot Password)](#5-réinitialisation-de-mot-de-passe)
14 +6. [Changement de mot de passe (avec confirmation email)](#6-changement-de-mot-de-passe)
15 +7. [Traitement de la queue email (Background)](#7-traitement-de-la-queue-email)
16 +8. [Détection des bounces IMAP (Background)](#8-détection-des-bounces-imap)
17 +9. [Nettoyage périodique (Background Tasks)](#9-nettoyage-périodique)
18 +10. [Administration — Liste & Pagination](#10-administration--pagination)
19 +11. [Préférences utilisateur (Thème & Langue)](#11-préférences-utilisateur)
20 +12. [Référence complète des variables](#12-référence-complète)
21 +
22 +---
23 +
24 +## 1. Démarrage du serveur
25 +
26 +Variables impliquées : `SERVER_HOST`, `SERVER_PORT`, `DATABASE_URL`, `RUST_LOG`, `DEFAULT_LOCALE`, `CREATE_DEFAULT_ADMIN`, `ADMIN_*`
27 +
28 +```mermaid
29 +sequenceDiagram
30 + participant Env as .env
31 + participant Main as main.rs
32 + participant DB as PostgreSQL
33 + participant I18n as I18nService
34 + participant Tasks as Background Tasks
35 +
36 + Main->>Env: SERVER_HOST (défaut: 0.0.0.0)
37 + Main->>Env: SERVER_PORT (défaut: 3000)
38 + Main->>Env: RUST_LOG
39 + Main->>Env: DATABASE_URL
40 + Main->>DB: Connexion SeaORM
41 + Main->>DB: Migrations (sea-orm-migration)
42 +
43 + Main->>Env: CREATE_DEFAULT_ADMIN
44 + alt CREATE_DEFAULT_ADMIN = true
45 + Main->>Env: ADMIN_USERNAME, ADMIN_EMAIL, ADMIN_PASSWORD
46 + Main->>DB: Créer compte admin par défaut
47 + end
48 +
49 + Main->>Env: DEFAULT_LOCALE (défaut: "fr")
50 + Main->>I18n: Charger locales/*.yml
51 +
52 + Main->>Env: EMAIL_QUEUE_INTERVAL_SECS (défaut: 30)
53 + Main->>Env: PASSWORD_CHANGE_RETRY_INTERVAL_SECS (défaut: 600)
54 + Main->>Env: PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS (défaut: 86400)
55 + Main->>Env: JWT_CLEANUP_INTERVAL_SECS (défaut: 3600)
56 + Main->>Tasks: Lancer 4 tâches de fond
57 + Main->>Main: bind(SERVER_HOST:SERVER_PORT)
58 +```
59 +
60 +---
61 +
62 +## 2. Authentification (Login)
63 +
64 +Variables impliquées : `JWT_SECRET`, `JWT_EXPIRATION_MINUTES`, `JWT_ISSUER`, `COOKIE_SECURE`, `COOKIE_SAME_SITE`, `REFRESH_TOKEN_EXPIRATION_DAYS`, `EMAIL_VALIDATION_REQUIRED`, `RATE_LIMIT_LOGIN_PER_MINUTE`, `COOKIE_PREFERENCE_MAX_AGE_SECS`
65 +
66 +```mermaid
67 +sequenceDiagram
68 + participant User as Navigateur
69 + participant Server as POST /api/v1/auth/login
70 + participant Auth as AuthService
71 + participant JWT as JwtService
72 + participant DB as PostgreSQL
73 + participant Env as .env
74 +
75 + User->>Server: { username, password }
76 +
77 + Server->>Env: RATE_LIMIT_LOGIN_PER_MINUTE (défaut: 5)
78 + Server->>Server: Vérifier rate limit
79 +
80 + Server->>Auth: authenticate_user()
81 + Auth->>DB: SELECT user WHERE username
82 + Auth->>Auth: bcrypt::verify(password)
83 +
84 + Server->>Env: EMAIL_VALIDATION_REQUIRED (défaut: true)
85 + alt EMAIL_VALIDATION_REQUIRED && !email_verified
86 + Server-->>User: 403 "Email non vérifié"
87 + end
88 +
89 + Server->>Env: JWT_SECRET (obligatoire, min 32 chars)
90 + Server->>Env: JWT_EXPIRATION_MINUTES (défaut: 15)
91 + Server->>Env: JWT_ISSUER (défaut: "rustwarden")
92 + Server->>JWT: generate_token(user_id, roles)
93 +
94 + Server->>Env: REFRESH_TOKEN_EXPIRATION_DAYS (défaut: 7)
95 + Server->>DB: INSERT refresh_token
96 +
97 + Server->>Env: COOKIE_SECURE (défaut: false)
98 + Server->>Env: COOKIE_SAME_SITE (défaut: "Strict")
99 + Server->>User: Set-Cookie: jwt_token (HttpOnly)
100 +
101 + Server->>DB: SELECT user_preferences
102 + Server->>Env: COOKIE_PREFERENCE_MAX_AGE_SECS (défaut: 31536000)
103 + Server->>User: Set-Cookie: theme={pref.theme}
104 + Server->>User: Set-Cookie: locale={pref.language}
105 +
106 + Server-->>User: 200 { access_token, refresh_token, user }
107 +```
108 +
109 +---
110 +
111 +## 3. Rendu des pages
112 +
113 +Variables impliquées : `DEFAULT_LOCALE`, `RUSTWARDEN_DEBUG_I18N`, `APP_DOMAIN`
114 +
115 +```mermaid
116 +sequenceDiagram
117 + participant User as Navigateur
118 + participant MW as Middleware (locale)
119 + participant Handler as Page Handler
120 + participant I18n as I18nService
121 + participant DB as app_settings
122 + participant Env as .env
123 +
124 + User->>MW: GET /home (Cookie: locale=de, theme=dark)
125 + MW->>MW: Lire cookie "locale"
126 + alt cookie "locale" présent et valide
127 + MW->>MW: locale = cookie value
128 + else Accept-Language header
129 + MW->>MW: Extraire langue du header
130 + else aucun
131 + MW->>Env: DEFAULT_LOCALE (défaut: "fr")
132 + end
133 + MW->>Handler: Extension(Locale("de"))
134 +
135 + Handler->>I18n: translate("home.title", "de")
136 + Note over I18n,Env: Si clé manquante et<br/>RUSTWARDEN_DEBUG_I18N=1<br/>→ log warning détaillé
137 + I18n-->>Handler: "Startseite"
138 +
139 + Handler->>DB: app_settings("app_domain")
140 + alt setting trouvé
141 + Handler->>Handler: domain = setting.value
142 + else
143 + Handler->>Env: APP_DOMAIN (fallback "localhost")
144 + end
145 +
146 + Handler-->>User: HTML rendu (data-theme="dark")
147 + Note over User: Le script base.html lit<br/>Cookie "theme" au chargement<br/>et applique data-theme
148 +```
149 +
150 +---
151 +
152 +## 4. Inscription & Validation email
153 +
154 +Variables impliquées : `ALLOW_REGISTRATION`, `EMAIL_VALIDATION_EXPIRATION_HOURS`, `SMTP_*`, `EMAIL_BASE_URL`, `EMAIL_WELCOME_ENABLED`, `SMTP_FROM_NAME`
155 +
156 +```mermaid
157 +sequenceDiagram
158 + participant User as Navigateur
159 + participant Server as POST /api/v1/auth/register
160 + participant ValSvc as EmailValidationService
161 + participant Queue as email_queue (DB)
162 + participant Env as .env
163 +
164 + User->>Server: { username, email, password }
165 +
166 + Server->>Env: ALLOW_REGISTRATION (défaut: true)
167 + alt ALLOW_REGISTRATION = false
168 + Server-->>User: 403 Forbidden
169 + end
170 +
171 + Server->>Server: Valider & créer utilisateur
172 +
173 + Server->>ValSvc: send_validation_email(user_id)
174 + ValSvc->>Env: EMAIL_VALIDATION_EXPIRATION_HOURS (défaut: 24)
175 + ValSvc->>ValSvc: expires_at = now + 24h
176 + ValSvc->>ValSvc: token = UUID v4, hash = SHA-256
177 +
178 + ValSvc->>Env: EMAIL_BASE_URL
179 + ValSvc->>ValSvc: url = EMAIL_BASE_URL/verify-email/{token}
180 +
181 + ValSvc->>Env: SMTP_FROM, SMTP_FROM_NAME (fallback "RustWarden")
182 + ValSvc->>Queue: INSERT email_queue (type: "email_validation")
183 + Note over Queue: Sujet traduit selon locale :<br/>fr: "Vérification de votre adresse email"<br/>en: "Verify Your Email Address"<br/>de: "Bestätigung Ihrer E-Mail-Adresse"<br/>es: "Verifique su dirección de correo"
184 +
185 + Server-->>User: 201 Created
186 +
187 + Note over User,Queue: --- Clic sur le lien reçu par email ---
188 +
189 + User->>Server: GET /api/v1/auth/verify-email/{token}
190 + Server->>ValSvc: verify_email(token)
191 + ValSvc->>ValSvc: Vérifier hash + expiration
192 +
193 + alt EMAIL_WELCOME_ENABLED = true
194 + ValSvc->>Env: EMAIL_WELCOME_ENABLED
195 + ValSvc->>Queue: INSERT email_queue (type: "welcome")
196 + Note over Queue: Sujet: "Bienvenue sur RustWarden !"
197 + end
198 +
199 + Server-->>User: ✅ (message i18n selon locale)
200 +```
201 +
202 +---
203 +
204 +## 5. Réinitialisation de mot de passe
205 +
206 +Variables impliquées : `PASSWORD_RESET_EXPIRATION_HOURS`, `SMTP_*`, `EMAIL_BASE_URL`, `RATE_LIMIT_GENERAL_PER_MINUTE`
207 +
208 +```mermaid
209 +sequenceDiagram
210 + participant User as Navigateur
211 + participant Server as auth_handlers
212 + participant ResetSvc as PasswordResetService
213 + participant Queue as email_queue (DB)
214 + participant DB as PostgreSQL
215 + participant Env as .env
216 +
217 + User->>Server: POST /api/v1/auth/forgot-password { email }
218 +
219 + Server->>ResetSvc: request_reset(email)
220 + ResetSvc->>DB: SELECT user WHERE email
221 + ResetSvc->>DB: Invalider anciens tokens
222 +
223 + ResetSvc->>Env: PASSWORD_RESET_EXPIRATION_HOURS (défaut: 1)
224 + ResetSvc->>ResetSvc: expires_at = now + 1h
225 + ResetSvc->>DB: INSERT password_reset_token
226 +
227 + ResetSvc->>Env: EMAIL_BASE_URL
228 + ResetSvc->>ResetSvc: url = EMAIL_BASE_URL/reset-password?token={token}
229 +
230 + ResetSvc->>Env: SMTP_FROM, SMTP_FROM_NAME (fallback "RustWarden")
231 + ResetSvc->>Queue: INSERT email_queue
232 + Note over Queue: Sujet traduit (4 langues)
233 +
234 + Server-->>User: 200 OK (toujours, pour sécurité)
235 +
236 + Note over User,Queue: --- Email envoyé via queue processor ---
237 +
238 + User->>Server: POST /api/v1/auth/reset-password { token, new_password }
239 + Server->>ResetSvc: reset_password(token, new_password)
240 + ResetSvc->>DB: Vérifier token hash + expiration
241 + ResetSvc->>DB: UPDATE user.password_hash
242 +
243 + Server-->>User: 200 OK
244 +```
245 +
246 +---
247 +
248 +## 6. Changement de mot de passe
249 +
250 +Variables impliquées : `PASSWORD_CHANGE_EXPIRATION_HOURS`, `PASSWORD_CHANGE_RATE_LIMIT_MAX`, `PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS`, `PASSWORD_CHANGE_MAX_RETRIES`, `PASSWORD_CHANGE_RETRY_GAP_MINUTES`
251 +
252 +```mermaid
253 +sequenceDiagram
254 + participant User as Navigateur
255 + participant Server as settings_handler
256 + participant PwSvc as PasswordChangeService
257 + participant Queue as email_queue (DB)
258 + participant Retry as RetryProcessor (bg)
259 + participant Env as .env
260 +
261 + User->>Server: POST /settings/password { old, new }
262 +
263 + Server->>PwSvc: create_change_request(user_id, new_password)
264 +
265 + PwSvc->>Env: PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS (défaut: 3600)
266 + PwSvc->>Env: PASSWORD_CHANGE_RATE_LIMIT_MAX (défaut: 3)
267 + PwSvc->>PwSvc: Vérifier: < 3 demandes dans la dernière heure ?
268 + alt rate limit dépassé
269 + PwSvc-->>Server: 422 "Too many requests"
270 + end
271 +
272 + PwSvc->>Env: PASSWORD_CHANGE_EXPIRATION_HOURS (défaut: 24)
273 + PwSvc->>PwSvc: expires_at = now + 24h
274 + PwSvc->>Queue: INSERT email_queue (confirmation)
275 +
276 + Server-->>User: 200 "Email de confirmation envoyé"
277 +
278 + Note over Retry: Tâche de fond — retry processor
279 +
280 + loop Toutes les PASSWORD_CHANGE_RETRY_INTERVAL_SECS (600s)
281 + Retry->>Env: PASSWORD_CHANGE_RETRY_INTERVAL_SECS
282 + Retry->>Env: PASSWORD_CHANGE_MAX_RETRIES (défaut: 3)
283 + Retry->>Env: PASSWORD_CHANGE_RETRY_GAP_MINUTES (défaut: 20)
284 + Retry->>Retry: SELECT demandes WHERE retry_count < 3<br/>AND last_sent > 20 min ago
285 + alt demande en attente
286 + Retry->>Queue: INSERT email_queue (retry)
287 + Retry->>Retry: retry_count++
288 + end
289 + end
290 +
291 + User->>Server: GET /confirm-password-change?token={token}
292 + Server->>PwSvc: confirm(token)
293 + PwSvc->>PwSvc: UPDATE user.password_hash
294 + Server-->>User: ✅ Mot de passe changé
295 +```
296 +
297 +---
298 +
299 +## 7. Traitement de la queue email
300 +
301 +Variables impliquées : `EMAIL_QUEUE_INTERVAL_SECS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_USE_STARTTLS`, `SMTP_ACCEPT_INVALID_CERTS`, `SMTP_CA_CERT_PATH`, `EMAIL_REQUEST_DSN`
302 +
303 +```mermaid
304 +sequenceDiagram
305 + participant Timer as Tokio Timer
306 + participant Proc as EmailQueueProcessor
307 + participant DB as email_queue (DB)
308 + participant SMTP as Serveur SMTP
309 + participant Env as .env
310 +
311 + Proc->>Env: EMAIL_QUEUE_INTERVAL_SECS (défaut: 30)
312 +
313 + loop Toutes les 30 secondes
314 + Timer->>Proc: tick()
315 + Proc->>DB: SELECT * FROM email_queue WHERE status='pending'
316 +
317 + loop Pour chaque email
318 + Proc->>Proc: attempt_count++
319 +
320 + Proc->>Env: SMTP_HOST, SMTP_PORT (défaut: 587)
321 + Proc->>Env: SMTP_USER, SMTP_PASSWORD
322 + Proc->>Env: SMTP_USE_TLS, SMTP_USE_STARTTLS
323 + Proc->>Env: SMTP_ACCEPT_INVALID_CERTS
324 + Proc->>Env: SMTP_CA_CERT_PATH (optionnel)
325 + Proc->>Env: EMAIL_REQUEST_DSN (défaut: true)
326 +
327 + Proc->>SMTP: Envoyer email (lettre)
328 +
329 + alt Succès
330 + Proc->>DB: UPDATE status='sent', smtp_code, smtp_response
331 + else Échec
332 + alt Max attempts atteint
333 + Proc->>DB: UPDATE status='failed'
334 + else Tentatives restantes
335 + Proc->>DB: UPDATE attempt_count (retry au prochain tick)
336 + end
337 + end
338 + end
339 + end
340 +```
341 +
342 +---
343 +
344 +## 8. Détection des bounces IMAP
345 +
346 +Variables impliquées : `IMAP_HOST`, `IMAP_PORT`, `IMAP_USE_TLS`, `IMAP_USE_STARTTLS`, `IMAP_MAILBOX`, `IMAP_POLL_INTERVAL_SECONDS`, `IMAP_ACCEPT_INVALID_CERTS`, `IMAP_CA_CERT_PATH`, `IMAP_SOCKET_TIMEOUT_SECS`
347 +
348 +```mermaid
349 +sequenceDiagram
350 + participant Timer as Tokio Timer
351 + participant IMAP as ImapBounceService
352 + participant Mail as Serveur IMAP
353 + participant DB as email_delivery_status
354 + participant Env as .env
355 +
356 + IMAP->>Env: IMAP_POLL_INTERVAL_SECONDS (défaut: 300)
357 +
358 + loop Toutes les 300 secondes
359 + Timer->>IMAP: tick()
360 +
361 + IMAP->>Env: IMAP_HOST, IMAP_PORT (défaut: 993)
362 + IMAP->>Env: IMAP_SOCKET_TIMEOUT_SECS (défaut: 30)
363 + IMAP->>IMAP: set_read_timeout(30s)
364 + IMAP->>IMAP: set_write_timeout(30s)
365 +
366 + IMAP->>Env: IMAP_USE_TLS, IMAP_USE_STARTTLS
367 + IMAP->>Env: IMAP_ACCEPT_INVALID_CERTS
368 + IMAP->>Env: IMAP_CA_CERT_PATH (optionnel)
369 + IMAP->>Mail: Connexion TLS / STARTTLS
370 +
371 + IMAP->>Env: IMAP_MAILBOX (défaut: "INBOX")
372 + IMAP->>Mail: SELECT INBOX
373 + IMAP->>Mail: SEARCH UNSEEN
374 +
375 + loop Pour chaque message non lu
376 + IMAP->>Mail: FETCH message
377 + IMAP->>IMAP: Analyser DSN / bounces
378 + alt Bounce détecté
379 + IMAP->>DB: INSERT email_delivery_status (bounce)
380 + IMAP->>DB: UPDATE email_queue.status = 'bounced'
381 + end
382 + IMAP->>Mail: STORE +FLAGS (\Seen)
383 + end
384 + end
385 +```
386 +
387 +---
388 +
389 +## 9. Nettoyage périodique
390 +
391 +Variables impliquées : `JWT_CLEANUP_INTERVAL_SECS`, `PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS`
392 +
393 +```mermaid
394 +sequenceDiagram
395 + participant Timer as Tokio Timer
396 + participant JWTClean as JWT Blacklist Cleanup
397 + participant PWClean as Password Change Cleanup
398 + participant DB as PostgreSQL
399 + participant Env as .env
400 +
401 + Note over JWTClean: Tâche 1 : Nettoyage blacklist JWT
402 +
403 + JWTClean->>Env: JWT_CLEANUP_INTERVAL_SECS (défaut: 3600)
404 + loop Toutes les heures
405 + Timer->>JWTClean: tick()
406 + JWTClean->>DB: DELETE FROM jwt_blacklist<br/>WHERE expires_at < now()
407 + JWTClean->>JWTClean: log "{n} tokens nettoyés"
408 + end
409 +
410 + Note over PWClean: Tâche 2 : Nettoyage demandes mot de passe
411 +
412 + PWClean->>Env: PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS (défaut: 86400)
413 + loop Toutes les 24 heures
414 + Timer->>PWClean: tick()
415 + PWClean->>DB: DELETE FROM password_change_request<br/>WHERE expires_at < now()<br/>OR confirmed_at IS NOT NULL
416 + PWClean->>PWClean: log "{n} demandes supprimées"
417 + end
418 +```
419 +
420 +---
421 +
422 +## 10. Administration — Pagination
423 +
424 +Variables impliquées : `PAGINATION_DEFAULT_PER_PAGE`, `PAGINATION_MAX_PER_PAGE`, `RATE_LIMIT_*`
425 +
426 +```mermaid
427 +sequenceDiagram
428 + participant Admin as Navigateur (Admin)
429 + participant MW as AdminService Middleware
430 + participant Handler as admin_handler
431 + participant DB as PostgreSQL
432 + participant Env as .env
433 +
434 + Admin->>MW: GET /admin/users?page=2&per_page=25
435 + MW->>MW: Vérifier JWT + rôle "admin"
436 +
437 + MW->>Handler: Extension(Claims)
438 +
439 + Handler->>Env: PAGINATION_DEFAULT_PER_PAGE (défaut: 50)
440 + Handler->>Env: PAGINATION_MAX_PER_PAGE (défaut: 100)
441 + Handler->>Handler: per_page = min(request.per_page, 100)
442 + Handler->>Handler: si absent → per_page = 50
443 +
444 + Handler->>DB: SELECT users LIMIT 25 OFFSET 25
445 + Handler->>DB: SELECT COUNT(*) FROM users
446 +
447 + Handler-->>Admin: HTML (table paginée)
448 +```
449 +
450 +---
451 +
452 +## 11. Préférences utilisateur
453 +
454 +Variables impliquées : `COOKIE_PREFERENCE_MAX_AGE_SECS`
455 +
456 +```mermaid
457 +sequenceDiagram
458 + participant User as Navigateur
459 + participant JS as JavaScript (preferences.html)
460 + participant Server as PUT /settings/preferences
461 + participant DB as user_preferences
462 + participant Env as .env
463 +
464 + User->>JS: Sélectionne thème "auto" + langue "de"
465 + JS->>Server: fetch({ theme: "auto", language: "de" })
466 +
467 + Server->>DB: UPDATE user_preferences SET theme, language
468 + Server->>Env: COOKIE_PREFERENCE_MAX_AGE_SECS (défaut: 31536000)
469 +
470 + Server-->>User: Set-Cookie: theme=auto; max-age=31536000
471 + Server-->>User: Set-Cookie: locale=de; max-age=31536000
472 + Server-->>User: 200 { reload_required: true }
473 +
474 + JS->>JS: document.cookie = "theme=auto"
475 + JS->>User: window.location.reload()
476 +
477 + Note over User: Au rechargement (base.html) :<br/>1. Script inline lit cookie "theme"<br/>2. theme="auto" → prefers-color-scheme<br/>3. Applique data-theme="dark" ou "light"<br/>4. Middleware lit cookie "locale"<br/>5. Page rendue en allemand
478 +```
479 +
480 +---
481 +
482 +## 12. Référence complète
483 +
484 +### Serveur & Base de données
485 +
486 +| Variable | Défaut | Description |
487 +|----------|--------|-------------|
488 +| `SERVER_HOST` | `0.0.0.0` | Adresse d'écoute |
489 +| `SERVER_PORT` | `3000` | Port d'écoute |
490 +| `DATABASE_URL` | — (obligatoire) | URI PostgreSQL |
491 +| `RUST_LOG` | `rustwarden=debug` | Niveau de log |
492 +
493 +### Authentification & JWT
494 +
495 +| Variable | Défaut | Description |
496 +|----------|--------|-------------|
497 +| `JWT_SECRET` | — (obligatoire, ≥32 chars) | Clé secrète HMAC |
498 +| `JWT_EXPIRATION_MINUTES` | `15` | Durée de vie du JWT |
499 +| `JWT_ISSUER` | `rustwarden` | Champ `iss` du JWT |
500 +| `REFRESH_TOKEN_EXPIRATION_DAYS` | `7` | Durée refresh token |
501 +| `SESSION_TIMEOUT_MINUTES` | `30` | Timeout session serveur |
502 +| `SESSION_BACKEND` | `seaorm` | Backend: `seaorm` / `memory` / `redis` |
503 +| `REDIS_URL` | — | URL Redis (si backend=redis) |
504 +| `COOKIE_SECURE` | `false` | `true` en production (HTTPS) |
505 +| `COOKIE_SAME_SITE` | `Lax` | `Strict` / `Lax` / `None` |
506 +
507 +### Rate Limits (Auth)
508 +
509 +| Variable | Défaut | Description |
510 +|----------|--------|-------------|
511 +| `RATE_LIMIT_LOGIN_PER_MINUTE` | `5` | Tentatives login/min |
512 +| `RATE_LIMIT_REFRESH_PER_MINUTE` | `10` | Refresh token/min |
513 +| `RATE_LIMIT_GENERAL_PER_MINUTE` | `100` | Requêtes API/min |
514 +
515 +### Email (SMTP)
516 +
517 +| Variable | Défaut | Description |
518 +|----------|--------|-------------|
519 +| `SMTP_HOST` | — | Hôte SMTP |
520 +| `SMTP_PORT` | `587` | Port SMTP |
521 +| `SMTP_USER` | — | Utilisateur SMTP |
522 +| `SMTP_PASSWORD` | — | Mot de passe SMTP |
523 +| `SMTP_FROM` | — | Adresse expéditeur |
524 +| `SMTP_FROM_NAME` | `RustWarden` | Nom expéditeur |
525 +| `SMTP_USE_TLS` | `false` | TLS direct |
526 +| `SMTP_USE_STARTTLS` | `false` | STARTTLS |
527 +| `SMTP_ACCEPT_INVALID_CERTS` | `false` | Ignorer erreurs cert |
528 +| `SMTP_CA_CERT_PATH` | — | Certificat CA custom |
529 +| `EMAIL_REQUEST_DSN` | `true` | Demander accusés DSN |
530 +| `EMAIL_BASE_URL` | — | URL de base pour les liens |
531 +| `EMAIL_VALIDATION_REQUIRED` | `true` | Exiger validation email |
532 +| `EMAIL_WELCOME_ENABLED` | `true` | Email de bienvenue |
533 +| `EMAIL_ADMIN_NOTIFICATIONS_ENABLED` | `false` | Notifier admins |
534 +
535 +### Email (IMAP / Bounces)
536 +
537 +| Variable | Défaut | Description |
538 +|----------|--------|-------------|
539 +| `IMAP_HOST` | — | Hôte IMAP |
540 +| `IMAP_PORT` | `993` | Port IMAP |
541 +| `IMAP_USE_TLS` | `false` | TLS direct |
542 +| `IMAP_USE_STARTTLS` | `false` | STARTTLS |
543 +| `IMAP_MAILBOX` | `INBOX` | Boîte à surveiller |
544 +| `IMAP_POLL_INTERVAL_SECONDS` | `300` | Intervalle de poll |
545 +| `IMAP_ACCEPT_INVALID_CERTS` | `false` | Ignorer erreurs cert |
546 +| `IMAP_CA_CERT_PATH` | — | Certificat CA custom |
547 +| `IMAP_SOCKET_TIMEOUT_SECS` | `30` | Timeout r/w socket |
548 +
549 +### Tâches de fond & Timeouts
550 +
551 +| Variable | Défaut | Description |
552 +|----------|--------|-------------|
553 +| `EMAIL_QUEUE_INTERVAL_SECS` | `30` | Intervalle traitement queue |
554 +| `PASSWORD_CHANGE_RETRY_INTERVAL_SECS` | `600` | Intervalle retry (10 min) |
555 +| `PASSWORD_CHANGE_RETRY_GAP_MINUTES` | `20` | Gap entre 2 retries |
556 +| `PASSWORD_CHANGE_MAX_RETRIES` | `3` | Max tentatives email |
557 +| `PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS` | `86400` | Nettoyage quotidien |
558 +| `JWT_CLEANUP_INTERVAL_SECS` | `3600` | Nettoyage blacklist JWT |
559 +
560 +### Expiration des tokens
561 +
562 +| Variable | Défaut | Description |
563 +|----------|--------|-------------|
564 +| `PASSWORD_CHANGE_EXPIRATION_HOURS` | `24` | Expiration demande changement |
565 +| `PASSWORD_RESET_EXPIRATION_HOURS` | `1` | Expiration token reset |
566 +| `EMAIL_VALIDATION_EXPIRATION_HOURS` | `24` | Expiration token validation |
567 +
568 +### Rate Limits (Password Change)
569 +
570 +| Variable | Défaut | Description |
571 +|----------|--------|-------------|
572 +| `PASSWORD_CHANGE_RATE_LIMIT_MAX` | `3` | Max requêtes/fenêtre |
573 +| `PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS` | `3600` | Fenêtre (1 heure) |
574 +
575 +### Application & UI
576 +
577 +| Variable | Défaut | Description |
578 +|----------|--------|-------------|
579 +| `APP_THEME` | `light` | Thème par défaut |
580 +| `APP_DEBUG` | `false` | Mode debug |
581 +| `APP_DOMAIN` | `localhost` | Domaine de l'app |
582 +| `DEFAULT_LOCALE` | `fr` | Locale par défaut |
583 +| `RUSTWARDEN_DEBUG_I18N` | — | Log clés i18n manquantes |
584 +| `COOKIE_PREFERENCE_MAX_AGE_SECS` | `31536000` | Max-age cookies pref (1 an) |
585 +
586 +### Pagination
587 +
588 +| Variable | Défaut | Description |
589 +|----------|--------|-------------|
590 +| `PAGINATION_DEFAULT_PER_PAGE` | `50` | Taille page par défaut |
591 +| `PAGINATION_MAX_PER_PAGE` | `100` | Taille page max |
592 +
593 +### Admin par défaut
594 +
595 +| Variable | Défaut | Description |
596 +|----------|--------|-------------|
597 +| `CREATE_DEFAULT_ADMIN` | `true` | Créer admin au 1er démarrage |
598 +| `ADMIN_USERNAME` | `admin` | Nom utilisateur admin |
599 +| `ADMIN_EMAIL` | `admin@localhost` | Email admin |
600 +| `ADMIN_PASSWORD` | — (généré si absent) | Mot de passe admin |
601 +
602 +### Fichiers statiques
603 +
604 +| Variable | Défaut | Description |
605 +|----------|--------|-------------|
606 +| `RUSTWARDEN_STATIC_DIR` | `static` | Répertoire fichiers statiques |
M locales/de.yml
+28 -22
@@ -122,27 +122,10 @@ home:
122 122 welcome: "Willkommen, {username}!"
123 123 subtitle: "Willkommen in Ihrem persönlichen Bereich"
124 124 my_account: "Mein Konto"
125 - upcoming_features: "Kommende Funktionen"
126 125 stat_username: "Benutzername"
127 126 stat_email: "E-Mail"
128 127 stat_member_since: "Mitglied seit"
129 128 stat_roles: "Rollen"
130 - feature_secrets_name: "Geheimnisse-Manager"
131 - feature_secrets_desc: "Sichere Speicherung von Passwörtern und Geheimnissen"
132 - feature_2fa_name: "2FA-Authentifizierung"
133 - feature_2fa_desc: "Zwei-Faktor-Authentifizierung"
134 - feature_files_name: "Dateimanager"
135 - feature_files_desc: "Dateien und Ordner durchsuchen, erstellen, umbenennen und löschen"
136 - feature_upload_name: "Upload/Download"
137 - feature_upload_desc: "Dateien mit Fortschrittsbalken hoch- und herunterladen"
138 - feature_webdav_name: "WebDAV & Synchronisierung"
139 - feature_webdav_desc: "Dateien mit Desktop-Clients über WebDAV synchronisieren"
140 - feature_mobile_name: "Mobile API"
141 - feature_mobile_desc: "Synchronisierung mit mobilen Apps (iOS/Android)"
142 - badge_soon: "Bald"
143 - badge_planned: "Geplant"
144 - did_you_know: "Wussten Sie schon?"
145 - did_you_know_text: "RustWarden ist ein sicherer Passwort-Manager, der in Rust geschrieben wurde, um bessere Leistung und erhöhte Sicherheit zu bieten. Bleiben Sie dran für neue Funktionen!"
146 129
147 130 # Allgemeine Nachrichten
148 131 common:
@@ -367,6 +350,8 @@ admin:
367 350 require_lowercase: "Kleinbuchstabe erforderlich"
368 351 require_digits: "Ziffer erforderlich"
369 352 require_special: "Sonderzeichen erforderlich"
353 + password_change_require_email: "E-Mail-Bestätigung für Passwortänderung"
354 + password_change_require_email_desc: "Wenn aktiviert, wird eine Bestätigungs-E-Mail gesendet, um jede Passwortänderung zu validieren. Andernfalls wird die Änderung sofort angewendet."
370 355 password_expiration: "Passwortablauf"
371 356 password_expiration_desc: "Periodische Passwortänderung erzwingen"
372 357 expiration_days: "Ablauf nach (Tagen)"
@@ -444,7 +429,9 @@ admin:
444 429 password:
445 430 title: "Mein Passwort ändern"
446 431 page_title: "Mein Passwort ändern - RustCloud"
447 - subtitle: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern"
432 + subtitle: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern. Eine Bestätigungs-E-Mail wird gesendet, um die Änderung zu validieren."
433 + subtitle_no_email: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern."
434 + must_change_message: "⚠️ Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können."
448 435 new_password: "Neues Passwort"
449 436 current_password: "Aktuelles Passwort"
450 437 current_password_placeholder: "Ihr aktuelles Passwort"
@@ -470,7 +457,8 @@ password:
470 457 logout_warning_desc: "Alle Ihre anderen aktiven Sitzungen werden nach der Passwortänderung automatisch abgemeldet. Nur Ihre aktuelle Sitzung bleibt aktiv."
471 458 save: "Neues Passwort speichern"
472 459 saving: "Speichern..."
473 - saved: "Passwort erfolgreich geändert! Ihre anderen Sitzungen wurden abgemeldet."
460 + saved: "Eine Bestätigungs-E-Mail wurde an Ihre Adresse gesendet. Klicken Sie auf den Link in der E-Mail, um die Passwortänderung zu bestätigen."
461 + saved_direct: "Passwort erfolgreich geändert! Ihre anderen Sitzungen wurden abgemeldet."
474 462 error: "Fehler beim Ändern des Passworts"
475 463 error_all_fields_required: "Alle Felder sind erforderlich."
476 464 error_empty_data: "Die zu sendenden Daten sind leer."
@@ -517,11 +505,8 @@ sessions:
517 505 # Navigation menu (navbar)
518 506 nav:
519 507 home: "Startseite"
520 - files: "Dateien"
521 508 settings: "Einstellungen"
522 509 admin: "Administration"
523 - files_badge: "Bald"
524 - files_disabled_message: "Dateiverwaltung kommt bald (Feature 005)"
525 510
526 511 # User menu
527 512 user_menu:
@@ -529,3 +514,24 @@ user_menu:
529 514 logout: "Abmelden"
530 515 settings: "Einstellungen"
531 516
517 +# Email verification messages
518 +email_verify:
519 + config_error: "Server-Konfigurationsfehler. Bitte kontaktieren Sie den Administrator."
520 + success: "Ihre E-Mail-Adresse wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen."
521 + invalid_link: "Der Verifizierungslink ist ungültig oder abgelaufen."
522 +
523 +# Email subjects
524 +email_subject:
525 + password_reset: "Passwort zurücksetzen"
526 + email_validation: "Bestätigung Ihrer E-Mail-Adresse"
527 + welcome: "Willkommen bei RustWarden!"
528 +
529 +# Rate limiting
530 +rate_limit:
531 + exceeded: "Zu viele Anfragen. Bitte versuchen Sie es später erneut."
532 + password_change: "Zu viele Passwortänderungsanfragen. Bitte warten Sie, bevor Sie es erneut versuchen."
533 +
534 +# Sessions
535 +session:
536 + ip_unavailable: "Nicht verfügbar"
537 +
M locales/en.yml
+27 -22
@@ -122,27 +122,10 @@ home:
122 122 welcome: "Welcome, {username}!"
123 123 subtitle: "Welcome to your personal space"
124 124 my_account: "My Account"
125 - upcoming_features: "Upcoming Features"
126 125 stat_username: "Username"
127 126 stat_email: "Email"
128 127 stat_member_since: "Member since"
129 128 stat_roles: "Roles"
130 - feature_secrets_name: "Secrets Manager"
131 - feature_secrets_desc: "Secure storage for passwords and secrets"
132 - feature_2fa_name: "2FA Authentication"
133 - feature_2fa_desc: "Two-factor authentication"
134 - feature_files_name: "File Manager"
135 - feature_files_desc: "Browse, create, rename and delete your files and folders"
136 - feature_upload_name: "Upload/Download"
137 - feature_upload_desc: "Download and upload files with progress bar"
138 - feature_webdav_name: "WebDAV & Sync"
139 - feature_webdav_desc: "Sync your files with desktop clients via WebDAV"
140 - feature_mobile_name: "Mobile API"
141 - feature_mobile_desc: "Sync with mobile apps (iOS/Android)"
142 - badge_soon: "Soon"
143 - badge_planned: "Planned"
144 - did_you_know: "Did You Know?"
145 - did_you_know_text: "RustWarden is a secure password manager written in Rust for better performance and enhanced security. Stay tuned for new features!"
146 129
147 130 # Common messages
148 131 common:
@@ -372,6 +355,8 @@ admin:
372 355 require_lowercase: "Lowercase required"
373 356 require_digits: "Digit required"
374 357 require_special: "Special character required"
358 + password_change_require_email: "Email confirmation for password change"
359 + password_change_require_email_desc: "When enabled, a confirmation email is sent to validate any password change. Otherwise, the change is applied immediately."
375 360 password_expiration: "Password expiration"
376 361 password_expiration_desc: "Force periodic password change"
377 362 expiration_days: "Expiration after (days)"
@@ -449,7 +434,8 @@ admin:
449 434 password:
450 435 title: "Change my password"
451 436 page_title: "Change my password - RustCloud"
452 - subtitle: "Modify your password to secure your account"
437 + subtitle: "Modify your password to secure your account. A confirmation email will be sent to validate the change."
438 + subtitle_no_email: "Modify your password to secure your account."
453 439 must_change_message: "⚠️ You must change your password before continuing."
454 440 new_password: "New password"
455 441 current_password: "Current password"
@@ -476,7 +462,8 @@ password:
476 462 logout_warning_desc: "All your other active sessions will be automatically logged out after the password change. Only your current session will remain active."
477 463 save: "Save new password"
478 464 saving: "Saving..."
479 - saved: "Password changed successfully! Your other sessions have been logged out."
465 + saved: "A confirmation email has been sent to your address. Click the link in the email to validate the password change."
466 + saved_direct: "Password changed successfully! Your other sessions have been logged out."
480 467 error: "Error changing password"
481 468 error_all_fields_required: "All fields are required."
482 469 error_empty_data: "The data to send is empty."
@@ -539,11 +526,8 @@ sessions:
539 526 # Navigation menu (navbar)
540 527 nav:
541 528 home: "Home"
542 - files: "Files"
543 529 settings: "Settings"
544 530 admin: "Administration"
545 - files_badge: "Soon"
546 - files_disabled_message: "File management coming soon (Feature 005)"
547 531
548 532 # User menu
549 533 user_menu:
@@ -551,3 +535,24 @@ user_menu:
551 535 logout: "Logout"
552 536 settings: "Settings"
553 537
538 +# Email verification messages
539 +email_verify:
540 + config_error: "Server configuration error. Please contact the administrator."
541 + success: "Your email address has been successfully verified. You can now log in to your account."
542 + invalid_link: "The verification link is invalid or has expired."
543 +
544 +# Email subjects
545 +email_subject:
546 + password_reset: "Password Reset"
547 + email_validation: "Verify Your Email Address"
548 + welcome: "Welcome to RustWarden!"
549 +
550 +# Rate limiting
551 +rate_limit:
552 + exceeded: "Too many requests. Please try again later."
553 + password_change: "Too many password change requests. Please wait before trying again."
554 +
555 +# Sessions
556 +session:
557 + ip_unavailable: "Unavailable"
558 +
M locales/es.yml
+28 -22
@@ -122,27 +122,10 @@ home:
122 122 welcome: "¡Bienvenido, {username}!"
123 123 subtitle: "Bienvenido a tu espacio personal"
124 124 my_account: "Mi Cuenta"
125 - upcoming_features: "Próximas Funcionalidades"
126 125 stat_username: "Nombre de usuario"
127 126 stat_email: "Correo electrónico"
128 127 stat_member_since: "Miembro desde"
129 128 stat_roles: "Roles"
130 - feature_secrets_name: "Gestor de secretos"
131 - feature_secrets_desc: "Almacenamiento seguro de contraseñas y secretos"
132 - feature_2fa_name: "Autenticación 2FA"
133 - feature_2fa_desc: "Autenticación de dos factores"
134 - feature_files_name: "Gestor de Archivos"
135 - feature_files_desc: "Explorar, crear, renombrar y eliminar archivos y carpetas"
136 - feature_upload_name: "Subir/Descargar"
137 - feature_upload_desc: "Descargar y subir archivos con barra de progreso"
138 - feature_webdav_name: "WebDAV y Sincronización"
139 - feature_webdav_desc: "Sincronizar archivos con clientes de escritorio vía WebDAV"
140 - feature_mobile_name: "API Móvil"
141 - feature_mobile_desc: "Sincronización con aplicaciones móviles (iOS/Android)"
142 - badge_soon: "Pronto"
143 - badge_planned: "Planificado"
144 - did_you_know: "¿Sabías que...?"
145 - did_you_know_text: "RustWarden es un gestor de contraseñas seguro escrito en Rust para ofrecer mejor rendimiento y seguridad mejorada. ¡Mantente atento a las nuevas funcionalidades!"
146 129
147 130 # Mensajes comunes
148 131 common:
@@ -367,6 +350,8 @@ admin:
367 350 require_lowercase: "Minúscula requerida"
368 351 require_digits: "Dígito requerido"
369 352 require_special: "Carácter especial requerido"
353 + password_change_require_email: "Confirmación por email del cambio de contraseña"
354 + password_change_require_email_desc: "Cuando está activado, se envía un correo de confirmación para validar cualquier cambio de contraseña. De lo contrario, el cambio se aplica inmediatamente."
370 355 password_expiration: "Expiración de contraseñas"
371 356 password_expiration_desc: "Forzar cambio periódico de contraseña"
372 357 expiration_days: "Expiración después de (días)"
@@ -444,7 +429,9 @@ admin:
444 429 password:
445 430 title: "Cambiar mi contraseña"
446 431 page_title: "Cambiar mi contraseña - RustCloud"
447 - subtitle: "Modifique su contraseña para asegurar su cuenta"
432 + subtitle: "Modifique su contraseña para asegurar su cuenta. Se enviará un correo electrónico de confirmación para validar el cambio."
433 + subtitle_no_email: "Modifique su contraseña para asegurar su cuenta."
434 + must_change_message: "⚠️ Debe cambiar su contraseña antes de continuar."
448 435 new_password: "Nueva contraseña"
449 436 current_password: "Contraseña actual"
450 437 current_password_placeholder: "Su contraseña actual"
@@ -470,7 +457,8 @@ password:
470 457 logout_warning_desc: "Todas sus otras sesiones activas se cerrarán automáticamente después del cambio de contraseña. Solo su sesión actual permanecerá activa."
471 458 save: "Guardar nueva contraseña"
472 459 saving: "Guardando..."
473 - saved: "¡Contraseña cambiada con éxito! Sus otras sesiones han sido cerradas."
460 + saved: "Se ha enviado un correo electrónico de confirmación a su dirección. Haga clic en el enlace del correo para validar el cambio de contraseña."
461 + saved_direct: "¡Contraseña cambiada con éxito! Sus otras sesiones han sido cerradas."
474 462 error: "Error al cambiar la contraseña"
475 463 error_all_fields_required: "Todos los campos son obligatorios."
476 464 error_empty_data: "Los datos a enviar están vacíos."
@@ -517,11 +505,8 @@ sessions:
517 505 # Navigation menu (navbar)
518 506 nav:
519 507 home: "Inicio"
520 - files: "Archivos"
521 508 settings: "Configuración"
522 509 admin: "Administración"
523 - files_badge: "Próximamente"
524 - files_disabled_message: "Gestión de archivos próximamente (Feature 005)"
525 510
526 511 # User menu
527 512 user_menu:
@@ -529,3 +514,24 @@ user_menu:
529 514 logout: "Cerrar sesión"
530 515 settings: "Configuración"
531 516
517 +# Email verification messages
518 +email_verify:
519 + config_error: "Error de configuración del servidor. Por favor, contacte al administrador."
520 + success: "Su dirección de correo electrónico ha sido verificada con éxito. Ahora puede iniciar sesión en su cuenta."
521 + invalid_link: "El enlace de verificación es inválido o ha expirado."
522 +
523 +# Email subjects
524 +email_subject:
525 + password_reset: "Restablecimiento de contraseña"
526 + email_validation: "Verifique su dirección de correo electrónico"
527 + welcome: "¡Bienvenido a RustWarden!"
528 +
529 +# Rate limiting
530 +rate_limit:
531 + exceeded: "Demasiadas solicitudes. Por favor, inténtelo de nuevo más tarde."
532 + password_change: "Demasiadas solicitudes de cambio de contraseña. Por favor, espere antes de intentarlo de nuevo."
533 +
534 +# Sessions
535 +session:
536 + ip_unavailable: "No disponible"
537 +
M locales/fr.yml
+28 -23
@@ -122,27 +122,10 @@ home:
122 122 welcome: "Bienvenue, {username} !"
123 123 subtitle: "Bienvenue sur votre espace personnel"
124 124 my_account: "Mon Compte"
125 - upcoming_features: "Fonctionnalités à Venir"
126 125 stat_username: "Nom d'utilisateur"
127 126 stat_email: "Email"
128 127 stat_member_since: "Membre depuis"
129 128 stat_roles: "Rôles"
130 - feature_secrets_name: "Gestion des secrets"
131 - feature_secrets_desc: "Stockage sécurisé de mots de passe et secrets"
132 - feature_2fa_name: "Authentification 2FA"
133 - feature_2fa_desc: "Authentification à deux facteurs"
134 - feature_files_name: "Gestionnaire de Fichiers"
135 - feature_files_desc: "Parcourir, créer, renommer et supprimer vos fichiers et dossiers"
136 - feature_upload_name: "Upload/Download"
137 - feature_upload_desc: "Télécharger et téléverser des fichiers avec barre de progression"
138 - feature_webdav_name: "WebDAV et Synchronisation"
139 - feature_webdav_desc: "Synchroniser vos fichiers avec clients desktop via WebDAV"
140 - feature_mobile_name: "API Mobile"
141 - feature_mobile_desc: "Synchronisation avec applications mobiles (iOS/Android)"
142 - badge_soon: "Bientôt"
143 - badge_planned: "Prévu"
144 - did_you_know: "Le Saviez-Vous ?"
145 - did_you_know_text: "RustWarden est un gestionnaire de mots de passe sécurisé écrit en Rust pour offrir de meilleures performances et une sécurité renforcée. Restez à l'écoute pour les nouvelles fonctionnalités !"
146 129
147 130 # Messages généraux
148 131 common:
@@ -374,6 +357,8 @@ admin:
374 357 require_lowercase: "Minuscule requise"
375 358 require_digits: "Chiffre requis"
376 359 require_special: "Caractère spécial requis"
360 + password_change_require_email: "Confirmation par email du changement de mot de passe"
361 + password_change_require_email_desc: "Lorsqu'activé, un email de confirmation est envoyé pour valider tout changement de mot de passe. Sinon, le changement est appliqué immédiatement."
377 362 password_expiration: "Expiration des mots de passe"
378 363 password_expiration_desc: "Forcer le changement périodique"
379 364 expiration_days: "Expiration après (jours)"
@@ -452,14 +437,15 @@ admin:
452 437 password:
453 438 title: "Changer mon mot de passe"
454 439 page_title: "Changer mon mot de passe - RustCloud"
455 - subtitle: "Modifiez votre mot de passe pour sécuriser votre compte"
440 + subtitle: "Modifiez votre mot de passe pour sécuriser votre compte. Un email de confirmation vous sera envoyé pour valider le changement."
441 + subtitle_no_email: "Modifiez votre mot de passe pour sécuriser votre compte."
456 442 must_change_message: "⚠️ Vous devez changer votre mot de passe avant de continuer."
457 443 new_password: "Nouveau mot de passe"
458 444 current_password: "Mot de passe actuel"
459 445 current_password_placeholder: "Votre mot de passe actuel"
460 446 new_password_placeholder: "Nouveau mot de passe sécurisé"
461 447 required: "Requis"
462 - requirements: "📌 Exigences : 8+ caractères, majuscule, minuscule, chiffre, caractère spécial"
448 + requirements: "📌 Respectez les exigences de complexité indiquées ci-dessous."
463 449 strength: "Force du mot de passe :"
464 450 strength_weak: "Faible"
465 451 strength_medium: "Moyen"
@@ -479,7 +465,8 @@ password:
479 465 logout_warning_desc: "Toutes vos autres sessions actives seront automatiquement déconnectées après le changement de mot de passe. Seule votre session actuelle restera active."
480 466 save: "Enregistrer le nouveau mot de passe"
481 467 saving: "Enregistrement..."
482 - saved: "Mot de passe changé avec succès ! Vos autres sessions ont été déconnectées."
468 + saved: "Un email de confirmation a été envoyé à votre adresse. Cliquez sur le lien dans l'email pour valider le changement de mot de passe."
469 + saved_direct: "Mot de passe changé avec succès ! Vos autres sessions ont été déconnectées."
483 470 error: "Erreur lors de la modification du mot de passe"
484 471 error_all_fields_required: "Tous les champs sont requis."
485 472 error_empty_data: "Les données à envoyer sont vides."
@@ -542,11 +529,8 @@ sessions:
542 529 # Navigation menu (navbar)
543 530 nav:
544 531 home: "Accueil"
545 - files: "Fichiers"
546 532 settings: "Paramètres"
547 533 admin: "Administration"
548 - files_badge: "Bientôt"
549 - files_disabled_message: "Gestion de fichiers à venir (Feature 005)"
550 534
551 535 # User menu
552 536 user_menu:
@@ -653,3 +637,24 @@ sharing:
653 637 all_value: "15"
654 638 all_description: "Peut consulter, modifier, supprimer et partager"
655 639
640 +# Email verification messages
641 +email_verify:
642 + config_error: "Erreur de configuration du serveur. Veuillez contacter l'administrateur."
643 + success: "Votre adresse email a été vérifiée avec succès. Vous pouvez maintenant vous connecter à votre compte."
644 + invalid_link: "Le lien de vérification est invalide ou a expiré."
645 +
646 +# Email subjects
647 +email_subject:
648 + password_reset: "Réinitialisation de votre mot de passe"
649 + email_validation: "Vérification de votre adresse email"
650 + welcome: "Bienvenue sur RustWarden !"
651 +
652 +# Rate limiting
653 +rate_limit:
654 + exceeded: "Trop de requêtes. Veuillez réessayer plus tard."
655 + password_change: "Trop de demandes de changement de mot de passe. Veuillez patienter avant de réessayer."
656 +
657 +# Sessions
658 +session:
659 + ip_unavailable: "Non disponible"
660 +
M src/api/dto/home_dto.rs
+0 -193
@@ -1,42 +1,9 @@
1 -use super::user_profile_dto::UserProfileDTO;
2 1 /// Home Page Data Transfer Objects
3 2 /// Feature 004: UI Post-Auth
4 3 ///
5 4 /// Defines the data structures for the home page after authentication.
6 5 use serde::{Deserialize, Serialize};
7 6
8 -/// An upcoming feature to display on the home page
9 -#[derive(Debug, Clone, Serialize, Deserialize)]
10 -pub struct UpcomingFeatureDTO {
11 - /// Feature identifier (e.g., "005", "006")
12 - pub id: String,
13 -
14 - /// Feature name
15 - pub name: String,
16 -
17 - /// Brief description
18 - pub description: String,
19 -
20 - /// Icon name
21 - pub icon: String,
22 -
23 - /// Badge text (e.g., "Coming Soon", "In Progress")
24 - pub badge: String,
25 -}
26 -
27 -impl UpcomingFeatureDTO {
28 - /// Create a new upcoming feature
29 - pub fn new(id: &str, name: &str, description: &str, icon: &str, badge: &str) -> Self {
30 - Self {
31 - id: id.to_string(),
32 - name: name.to_string(),
33 - description: description.to_string(),
34 - icon: icon.to_string(),
35 - badge: badge.to_string(),
36 - }
37 - }
38 -}
39 -
40 7 /// An account statistic to display
41 8 #[derive(Debug, Clone, Serialize, Deserialize)]
42 9 pub struct AccountStatDTO {
@@ -61,121 +28,9 @@ impl AccountStatDTO {
61 28 }
62 29 }
63 30
64 -/// Home page data
65 -#[derive(Debug, Clone, Serialize, Deserialize)]
66 -pub struct HomePageDTO {
67 - /// Welcome message personalized with username
68 - pub welcome_message: String,
69 -
70 - /// Account statistics (username, email, created date, roles)
71 - pub account_stats: Vec<AccountStatDTO>,
72 -
73 - /// Upcoming features (005-008)
74 - pub upcoming_features: Vec<UpcomingFeatureDTO>,
75 -}
76 -
77 -impl HomePageDTO {
78 - /// Create home page data for a user
79 - ///
80 - /// # Examples
81 - ///
82 - /// ```
83 - /// use rustwarden::api::dto::home_dto::HomePageDTO;
84 - /// use rustwarden::api::dto::user_profile_dto::UserProfileDTO;
85 - /// use uuid::Uuid;
86 - /// use chrono::Utc;
87 - ///
88 - /// let user = UserProfileDTO {
89 - /// id: Uuid::new_v4(),
90 - /// username: "john_doe".to_string(),
91 - /// email: "john@example.com".to_string(),
92 - /// roles: vec!["user".to_string()],
93 - /// created_at: Utc::now(),
94 - /// initials: "JD".to_string(),
95 - /// avatar_color: "#0082c9".to_string(),
96 - /// };
97 - ///
98 - /// let home = HomePageDTO::new(user);
99 - ///
100 - /// assert!(home.welcome_message.contains("john_doe"));
101 - /// assert_eq!(home.account_stats.len(), 4);
102 - /// assert_eq!(home.upcoming_features.len(), 4);
103 - /// ```
104 - pub fn new(user: UserProfileDTO) -> Self {
105 - Self {
106 - welcome_message: format!("Bienvenue, {} !", user.username),
107 - account_stats: vec![
108 - AccountStatDTO::new("Nom d'utilisateur", &user.username, "user"),
109 - AccountStatDTO::new("Email", &user.email, "mail"),
110 - AccountStatDTO::new(
111 - "Membre depuis",
112 - &user.created_at.format("%d/%m/%Y").to_string(),
113 - "calendar",
114 - ),
115 - AccountStatDTO::new("Rôles", &user.roles.join(", "), "shield"),
116 - ],
117 - upcoming_features: vec![
118 - UpcomingFeatureDTO::new(
119 - "005",
120 - "Gestionnaire de Fichiers",
121 - "Parcourir, créer, renommer et supprimer vos fichiers et dossiers",
122 - "folder",
123 - "Bientôt",
124 - ),
125 - UpcomingFeatureDTO::new(
126 - "006",
127 - "Upload/Download",
128 - "Télécharger et téléverser des fichiers avec barre de progression",
129 - "upload",
130 - "Bientôt",
131 - ),
132 - UpcomingFeatureDTO::new(
133 - "007",
134 - "WebDAV et Synchronisation",
135 - "Synchroniser vos fichiers avec clients desktop via WebDAV",
136 - "cloud",
137 - "Prévu",
138 - ),
139 - UpcomingFeatureDTO::new(
140 - "008",
141 - "API Mobile",
142 - "Synchronisation avec applications mobiles (iOS/Android)",
143 - "smartphone",
144 - "Prévu",
145 - ),
146 - ],
147 - }
148 - }
149 -}
150 -
151 31 #[cfg(test)]
152 32 mod tests {
153 33 use super::*;
154 - use chrono::Utc;
155 - use uuid::Uuid;
156 -
157 - fn create_test_user() -> UserProfileDTO {
158 - UserProfileDTO {
159 - id: Uuid::new_v4(),
160 - username: "test_user".to_string(),
161 - email: "test@example.com".to_string(),
162 - roles: vec!["user".to_string(), "admin".to_string()],
163 - created_at: Utc::now(),
164 - initials: "TU".to_string(),
165 - avatar_color: "#0082c9".to_string(),
166 - }
167 - }
168 -
169 - #[test]
170 - fn test_upcoming_feature_new() {
171 - let feature = UpcomingFeatureDTO::new("005", "Files", "File management", "folder", "Soon");
172 -
173 - assert_eq!(feature.id, "005");
174 - assert_eq!(feature.name, "Files");
175 - assert_eq!(feature.description, "File management");
176 - assert_eq!(feature.icon, "folder");
177 - assert_eq!(feature.badge, "Soon");
178 - }
179 34
180 35 #[test]
181 36 fn test_account_stat_new() {
@@ -185,52 +40,4 @@ mod tests {
185 40 assert_eq!(stat.value, "john_doe");
186 41 assert_eq!(stat.icon, "user");
187 42 }
188 -
189 - #[test]
190 - fn test_home_page_dto_new() {
191 - let user = create_test_user();
192 - let home = HomePageDTO::new(user.clone());
193 -
194 - // Verify welcome message is personalized
195 - assert!(home.welcome_message.contains(&user.username));
196 - assert!(home.welcome_message.contains("Bienvenue"));
197 -
198 - // Verify account stats count
199 - assert_eq!(home.account_stats.len(), 4);
200 -
201 - // Verify account stats content
202 - assert_eq!(home.account_stats[0].value, user.username);
203 - assert_eq!(home.account_stats[1].value, user.email);
204 - assert_eq!(home.account_stats[3].value, "user, admin");
205 -
206 - // Verify upcoming features count
207 - assert_eq!(home.upcoming_features.len(), 4);
208 -
209 - // Verify upcoming features IDs
210 - assert_eq!(home.upcoming_features[0].id, "005");
211 - assert_eq!(home.upcoming_features[1].id, "006");
212 - assert_eq!(home.upcoming_features[2].id, "007");
213 - assert_eq!(home.upcoming_features[3].id, "008");
214 - }
215 -
216 - #[test]
217 - fn test_home_page_dto_features_content() {
218 - let user = create_test_user();
219 - let home = HomePageDTO::new(user);
220 -
221 - // Verify feature 005 content
222 - let files = &home.upcoming_features[0];
223 - assert_eq!(files.name, "Gestionnaire de Fichiers");
224 - assert_eq!(files.icon, "folder");
225 - assert_eq!(files.badge, "Bientôt");
226 -
227 - // Verify all features have required fields
228 - for feature in &home.upcoming_features {
229 - assert!(!feature.id.is_empty());
230 - assert!(!feature.name.is_empty());
231 - assert!(!feature.description.is_empty());
232 - assert!(!feature.icon.is_empty());
233 - assert!(!feature.badge.is_empty());
234 - }
235 - }
236 43 }
M src/api/dto/navigation_dto.rs
+27 -78
@@ -10,13 +10,13 @@ use std::sync::Arc;
10 10 /// A single navigation item
11 11 #[derive(Debug, Clone, Serialize, Deserialize)]
12 12 pub struct NavigationItemDTO {
13 - /// Item identifier (e.g., "home", "files", "settings")
13 + /// Item identifier (e.g., "home", "settings")
14 14 pub id: String,
15 15
16 16 /// Display label
17 17 pub label: String,
18 18
19 - /// Icon name (e.g., "home", "folder", "settings")
19 + /// Icon name (e.g., "home", "settings")
20 20 pub icon: String,
21 21
22 22 /// Target URL
@@ -28,7 +28,7 @@ pub struct NavigationItemDTO {
28 28 /// Whether this item is enabled
29 29 pub enabled: bool,
30 30
31 - /// Badge text (e.g., "Soon", "New") - empty string if None (for Askama compatibility)
31 + /// Badge text (e.g., "New") - empty string if None (for Askama compatibility)
32 32 pub badge: String,
33 33
34 34 /// Helper flag for Askama compatibility (true if badge is not empty)
@@ -77,7 +77,7 @@ impl NavigationItemDTO {
77 77 has_badge: false,
78 78 disabled_message: String::new(),
79 79 has_disabled_message: false,
80 - order: 0, // Default order
80 + order: 0,
81 81 }
82 82 }
83 83
@@ -89,17 +89,17 @@ impl NavigationItemDTO {
89 89 /// use rustwarden::api::dto::navigation_dto::NavigationItemDTO;
90 90 ///
91 91 /// let item = NavigationItemDTO::disabled(
92 - /// "files",
93 - /// "Files",
94 - /// "folder",
95 - /// "/files",
96 - /// "Coming soon",
92 + /// "module",
93 + /// "Module",
94 + /// "box",
95 + /// "/module",
96 + /// "Soon",
97 97 /// Some("Feature in development")
98 98 /// );
99 99 ///
100 100 /// assert!(!item.active);
101 101 /// assert!(!item.enabled);
102 - /// assert_eq!(item.badge, "Coming soon".to_string());
102 + /// assert_eq!(item.badge, "Soon".to_string());
103 103 /// ```
104 104 pub fn disabled(
105 105 id: &str,
@@ -122,7 +122,7 @@ impl NavigationItemDTO {
122 122 has_badge: !badge_str.is_empty(),
123 123 disabled_message: disabled_msg.clone(),
124 124 has_disabled_message: !disabled_msg.is_empty(),
125 - order: 0, // Default order
125 + order: 0,
126 126 }
127 127 }
128 128
@@ -155,7 +155,7 @@ impl NavigationItemDTO {
155 155 has_badge: false,
156 156 disabled_message: String::new(),
157 157 has_disabled_message: false,
158 - order: 0, // Default order
158 + order: 0,
159 159 }
160 160 }
161 161 }
@@ -188,21 +188,12 @@ impl NavigationDTO {
188 188 ///
189 189 /// Provides the standard navigation menu with:
190 190 /// - Home (enabled)
191 -/// - Files (disabled - coming in Feature 005)
192 191 /// - Settings (enabled)
193 192 impl Default for NavigationDTO {
194 193 fn default() -> Self {
195 194 Self {
196 195 items: vec![
197 196 NavigationItemDTO::regular("home", "Accueil", "home", "/home"),
198 - NavigationItemDTO::disabled(
199 - "files",
200 - "Fichiers",
201 - "folder",
202 - "/files",
203 - "Bientôt",
204 - Some("Gestion de fichiers à venir (Feature 005)"),
205 - ),
206 197 NavigationItemDTO::regular("settings", "Paramètres", "settings", "/settings"),
207 198 ],
208 199 secondary_items: vec![],
@@ -216,15 +207,14 @@ impl NavigationDTO {
216 207 ///
217 208 /// Creates a contextualized navigation menu that includes:
218 209 /// - Home (enabled)
219 - /// - Files (disabled - coming in Feature 005)
220 210 /// - Settings (enabled)
221 211 /// - Administration (enabled, only if user has "admin" role)
222 212 ///
223 213 /// # Arguments
224 214 ///
225 215 /// * `roles` - Slice of role strings for the current user
226 - /// * `i18n_service` - I18nService for translations (T097)
227 - /// * `locale` - Locale for translations (T097)
216 + /// * `i18n_service` - I18nService for translations
217 + /// * `locale` - Locale for translations
228 218 ///
229 219 /// # Examples
230 220 ///
@@ -240,12 +230,12 @@ impl NavigationDTO {
240 230 ///
241 231 /// // Non-admin user
242 232 /// let nav = NavigationDTO::for_user(&[], &i18n_service, &locale, None).await;
243 - /// assert_eq!(nav.items.len(), 3);
233 + /// assert_eq!(nav.items.len(), 2);
244 234 ///
245 235 /// // Admin user
246 236 /// let nav = NavigationDTO::for_user(&["admin".to_string()], &i18n_service, &locale, None).await;
247 - /// assert_eq!(nav.items.len(), 4);
248 - /// assert_eq!(nav.items[3].id, "admin");
237 + /// assert_eq!(nav.items.len(), 3);
238 + /// assert_eq!(nav.items[2].id, "admin");
249 239 /// # }
250 240 /// ```
251 241 pub async fn for_user(
@@ -256,18 +246,9 @@ impl NavigationDTO {
256 246 ) -> Self {
257 247 let locale_str = locale.as_str();
258 248
259 - // Translate navigation labels (T097)
260 249 let home_label = i18n_service.translate("nav.home", locale_str).await;
261 - let files_label = i18n_service.translate("nav.files", locale_str).await;
262 250 let settings_label = i18n_service.translate("nav.settings", locale_str).await;
263 251 let admin_label = i18n_service.translate("nav.admin", locale_str).await;
264 - let files_badge = i18n_service.translate("nav.files_badge", locale_str).await;
265 - let files_disabled_message = i18n_service
266 - .translate("nav.files_disabled_message", locale_str)
267 - .await;
268 -
269 - // Note: App registry integration (Feature 013) removed - rustwarden_core not available.
270 - // Navigation items from modular apps would be added here when core is available.
271 252
272 253 let mut items = vec![
273 254 NavigationItemDTO {
@@ -284,19 +265,6 @@ impl NavigationDTO {
284 265 order: 0,
285 266 },
286 267 NavigationItemDTO {
287 - id: "files".to_string(),
288 - label: files_label,
289 - icon: "folder".to_string(),
290 - url: "/files".to_string(),
291 - active: false,
292 - enabled: false,
293 - badge: files_badge.clone(),
294 - has_badge: !files_badge.is_empty(),
295 - disabled_message: files_disabled_message.clone(),
296 - has_disabled_message: !files_disabled_message.is_empty(),
297 - order: 10,
298 - },
299 - NavigationItemDTO {
300 268 id: "settings".to_string(),
301 269 label: settings_label,
302 270 icon: "settings".to_string(),
@@ -311,7 +279,6 @@ impl NavigationDTO {
311 279 },
312 280 ];
313 281
314 - // Add "Administration" link if user has "admin" role
315 282 if roles.contains(&"admin".to_string()) {
316 283 items.push(NavigationItemDTO {
317 284 id: "admin".to_string(),
@@ -328,12 +295,8 @@ impl NavigationDTO {
328 295 });
329 296 }
330 297
331 - // Sort all items by order (lower order = first)
332 298 items.sort_by_key(|item| item.order);
333 299
334 - // Feature 026: Check for pending password change request
335 - // Note: We need user_id to check for pending requests, but for_user() doesn't have access to it.
336 - // Handlers that call for_user() will set this flag separately using set_pending_password_change().
337 300 let has_pending_password_change = false;
338 301
339 302 Self {
@@ -344,8 +307,6 @@ impl NavigationDTO {
344 307 }
345 308
346 309 /// Set the pending password change flag (Feature 026)
347 - ///
348 - /// This method allows handlers to set the flag after checking for pending requests.
349 310 pub fn set_pending_password_change(&mut self, has_pending: bool) {
350 311 self.has_pending_password_change = has_pending;
351 312 }
@@ -372,18 +333,18 @@ mod tests {
372 333 #[test]
373 334 fn test_navigation_item_disabled() {
374 335 let item = NavigationItemDTO::disabled(
375 - "files",
376 - "Files",
377 - "folder",
378 - "/files",
336 + "module",
337 + "Module",
338 + "box",
339 + "/module",
379 340 "Soon",
380 341 Some("Coming soon"),
381 342 );
382 343
383 - assert_eq!(item.id, "files");
384 - assert_eq!(item.label, "Files");
385 - assert_eq!(item.icon, "folder");
386 - assert_eq!(item.url, "/files");
344 + assert_eq!(item.id, "module");
345 + assert_eq!(item.label, "Module");
346 + assert_eq!(item.icon, "box");
347 + assert_eq!(item.url, "/module");
387 348 assert!(!item.active);
388 349 assert!(!item.enabled);
389 350 assert_eq!(item.badge, "Soon");
@@ -404,22 +365,14 @@ mod tests {
404 365 fn test_navigation_dto_default() {
405 366 let nav = NavigationDTO::default();
406 367
407 - assert_eq!(nav.items.len(), 3);
368 + assert_eq!(nav.items.len(), 2);
408 369 assert_eq!(nav.secondary_items.len(), 0);
409 370
410 - // Verify Home item
411 371 let home = &nav.items[0];
412 372 assert_eq!(home.id, "home");
413 373 assert!(home.enabled);
414 374
415 - // Verify Files item (disabled)
416 - let files = &nav.items[1];
417 - assert_eq!(files.id, "files");
418 - assert!(!files.enabled);
419 - assert!(!files.badge.is_empty());
420 -
421 - // Verify Settings item
422 - let settings = &nav.items[2];
375 + let settings = &nav.items[1];
423 376 assert_eq!(settings.id, "settings");
424 377 assert!(settings.enabled);
425 378 }
@@ -436,8 +389,4 @@ mod tests {
436 389 assert_eq!(nav.items.len(), 1);
437 390 assert_eq!(nav.secondary_items.len(), 1);
438 391 }
439 -
440 - // Note: Tests for for_user() are now async and require i18n_service and locale
441 - // These tests are disabled as they require async runtime and i18n setup
442 - // Integration tests should verify the navigation functionality
443 392 }
M src/api/handlers/admin_handler.rs
+84 -11
@@ -391,6 +391,7 @@ struct UserListTemplate {
391 391 password_copied: String,
392 392 password_set_success: String,
393 393 reset_error: String,
394 + pw_min_length: usize,
394 395 }
395 396
396 397 crate::impl_template_response!(UserListTemplate);
@@ -644,6 +645,9 @@ pub async fn list_users(
644 645 password_copied: i18n_service.translate("admin.users_list.password_copied", locale_str).await,
645 646 password_set_success: i18n_service.translate("admin.users_list.password_set_success", locale_str).await,
646 647 reset_error: i18n_service.translate("admin.users_list.reset_error", locale_str).await,
648 + pw_min_length: crate::api::middleware::password_complexity::PasswordRules::from_db(&db)
649 + .await
650 + .min_length,
647 651 };
648 652
649 653 Ok(template)
@@ -1055,7 +1059,7 @@ pub async fn reset_user_password(
1055 1059 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
1056 1060 let mut active: user::ActiveModel = user_model.into();
1057 1061 active.password_hash = Set(password_hash);
1058 - active.must_change_password = Set(true);
1062 + active.must_change_password = Set(false);
1059 1063 active.update(&db).await?;
1060 1064
1061 1065 tracing::info!("Admin {} reset password for user {}", claims.sub, user_id);
@@ -1121,6 +1125,8 @@ struct AdminSettingsTemplate {
1121 1125 require_lowercase: String,
1122 1126 require_digits: String,
1123 1127 require_special: String,
1128 + password_change_require_email: String,
1129 + password_change_require_email_desc: String,
1124 1130 password_expiration: String,
1125 1131 password_expiration_desc: String,
1126 1132 expiration_days: String,
@@ -1160,6 +1166,7 @@ struct AppSettingsTemplateContext {
1160 1166 password_require_lowercase: String,
1161 1167 password_require_digits: String,
1162 1168 password_require_special: String,
1169 + password_change_require_email: String,
1163 1170 password_expiration_enabled: String,
1164 1171 password_expiration_days: String,
1165 1172 password_expiration_alert_enabled: String,
@@ -1197,13 +1204,14 @@ pub async fn get_app_settings(
1197 1204 app_name: base_context.app_name.clone(),
1198 1205 app_domain: app_domain.clone(),
1199 1206 app_description: app_description.clone(),
1200 - allow_registration: get_val("allow_registration", "true"),
1201 - validation_email_requise: get_val("validation_email_required", "false"),
1207 + allow_registration: get_val("allow_registration", "false"),
1208 + validation_email_requise: get_val("validation_email_required", "true"),
1202 1209 password_min_length: get_val("password_min_length", "8"),
1203 - password_require_uppercase: get_val("password_require_uppercase", "true"),
1204 - password_require_lowercase: get_val("password_require_lowercase", "true"),
1205 - password_require_digits: get_val("password_require_digits", "true"),
1210 + password_require_uppercase: get_val("password_require_uppercase", "false"),
1211 + password_require_lowercase: get_val("password_require_lowercase", "false"),
1212 + password_require_digits: get_val("password_require_digits", "false"),
1206 1213 password_require_special: get_val("password_require_special", "false"),
1214 + password_change_require_email: get_val("password_change_require_email", "true"),
1207 1215 password_expiration_enabled: get_val("password_expiration_enabled", "false"),
1208 1216 password_expiration_days: get_val("password_expiration_days", "90"),
1209 1217 password_expiration_alert_enabled: get_val("password_expiration_alert_enabled", "false"),
@@ -1255,6 +1263,8 @@ pub async fn get_app_settings(
1255 1263 require_lowercase: i18n_service.translate("admin.settings.require_lowercase", locale_str).await,
1256 1264 require_digits: i18n_service.translate("admin.settings.require_digits", locale_str).await,
1257 1265 require_special: i18n_service.translate("admin.settings.require_special", locale_str).await,
1266 + password_change_require_email: i18n_service.translate("admin.settings.password_change_require_email", locale_str).await,
1267 + password_change_require_email_desc: i18n_service.translate("admin.settings.password_change_require_email_desc", locale_str).await,
1258 1268 password_expiration: i18n_service.translate("admin.settings.password_expiration", locale_str).await,
1259 1269 password_expiration_desc: i18n_service.translate("admin.settings.password_expiration_desc", locale_str).await,
1260 1270 expiration_days: i18n_service.translate("admin.settings.expiration_days", locale_str).await,
@@ -1280,16 +1290,79 @@ pub async fn get_app_settings(
1280 1290 Ok(template)
1281 1291 }
1282 1292
1283 -/// PUT /admin/settings - Update app settings (placeholder - no AppSettingsService)
1293 +/// Form data received from admin settings page (url-encoded)
1294 +#[derive(Debug, serde::Deserialize)]
1295 +pub struct AdminSettingsFormData {
1296 + pub csrf_token: Option<String>,
1297 + pub app_name: Option<String>,
1298 + pub app_domain: Option<String>,
1299 + pub app_description: Option<String>,
1300 + pub allow_registration: Option<String>,
1301 + pub validation_email_requise: Option<String>,
1302 + pub password_min_length: Option<String>,
1303 + pub password_require_uppercase: Option<String>,
1304 + pub password_require_lowercase: Option<String>,
1305 + pub password_require_digits: Option<String>,
1306 + pub password_require_special: Option<String>,
1307 + pub password_change_require_email: Option<String>,
1308 + pub password_expiration_enabled: Option<String>,
1309 + pub password_expiration_days: Option<String>,
1310 + pub password_expiration_alert_enabled: Option<String>,
1311 + pub password_expiration_alert_days: Option<String>,
1312 + pub audit_log_level: Option<String>,
1313 + pub audit_log_retention_days: Option<String>,
1314 +}
1315 +
1316 +/// PUT /admin/settings - Update app settings
1284 1317 pub async fn update_app_settings(
1285 - State(_db): State<DatabaseConnection>,
1318 + State(db): State<DatabaseConnection>,
1286 1319 Extension(claims): Extension<crate::auth::jwt::Claims>,
1320 + axum::Form(form): axum::Form<AdminSettingsFormData>,
1287 1321 ) -> Result<impl IntoResponse, AppError> {
1288 - tracing::info!("Admin {} attempted to update settings (not yet implemented)", claims.username);
1322 + let user_id = Some(claims.sub);
1323 +
1324 + let checkbox_val = |opt: &Option<String>| -> String {
1325 + match opt {
1326 + Some(v) if v == "true" || v == "on" => "true".to_string(),
1327 + _ => "false".to_string(),
1328 + }
1329 + };
1330 +
1331 + let settings_to_save: Vec<(&str, String)> = vec![
1332 + ("app_name", form.app_name.unwrap_or_default()),
1333 + ("app_domain", form.app_domain.unwrap_or_default()),
1334 + ("app_description", form.app_description.unwrap_or_default()),
1335 + ("allow_registration", checkbox_val(&form.allow_registration)),
1336 + ("validation_email_required", checkbox_val(&form.validation_email_requise)),
1337 + ("password_min_length", form.password_min_length.unwrap_or_else(|| "8".to_string())),
1338 + ("password_require_uppercase", checkbox_val(&form.password_require_uppercase)),
1339 + ("password_require_lowercase", checkbox_val(&form.password_require_lowercase)),
1340 + ("password_require_digits", checkbox_val(&form.password_require_digits)),
1341 + ("password_require_special", checkbox_val(&form.password_require_special)),
1342 + ("password_change_require_email", checkbox_val(&form.password_change_require_email)),
1343 + ("password_expiration_enabled", checkbox_val(&form.password_expiration_enabled)),
1344 + ("password_expiration_days", form.password_expiration_days.unwrap_or_else(|| "90".to_string())),
1345 + ("password_expiration_alert_enabled", checkbox_val(&form.password_expiration_alert_enabled)),
1346 + ("password_expiration_alert_days_before", form.password_expiration_alert_days.unwrap_or_else(|| "7".to_string())),
1347 + ("audit_log_level", form.audit_log_level.unwrap_or_else(|| "INFO".to_string())),
1348 + ("audit_log_retention_days", form.audit_log_retention_days.unwrap_or_else(|| "365".to_string())),
1349 + ];
1350 +
1351 + let mut updated_count = 0u32;
1352 + for (key, value) in &settings_to_save {
1353 + AppSettingsService::set_setting(&db, key.to_string(), value.clone(), user_id).await?;
1354 + updated_count += 1;
1355 + }
1356 +
1357 + tracing::info!(
1358 + "Admin {} updated {} app settings",
1359 + claims.username,
1360 + updated_count
1361 + );
1289 1362
1290 1363 Ok(Json(serde_json::json!({
1291 - "message": "Settings update not yet implemented in Rustwarden",
1292 - "updated_count": 0,
1364 + "message": "Settings saved successfully",
1365 + "updated_count": updated_count,
1293 1366 })))
1294 1367 }
1295 1368
M src/api/handlers/home_handler.rs
+1 -77
@@ -11,7 +11,7 @@ use sea_orm::DatabaseConnection;
11 11 use std::collections::HashMap;
12 12 use std::sync::Arc;
13 13
14 -use crate::api::dto::home_dto::{AccountStatDTO, UpcomingFeatureDTO};
14 +use crate::api::dto::home_dto::AccountStatDTO;
15 15 use crate::api::dto::navigation_dto::NavigationDTO;
16 16 use crate::api::dto::user_profile_dto::UserProfileDTO;
17 17 use crate::auth::jwt::Claims;
@@ -41,11 +41,7 @@ struct HomeTemplate {
41 41 welcome_message: String,
42 42 home_subtitle: String,
43 43 home_my_account: String,
44 - home_upcoming_features: String,
45 - home_did_you_know: String,
46 - home_did_you_know_text: String,
47 44 account_stats: Vec<AccountStatDTO>,
48 - upcoming_features: Vec<UpcomingFeatureDTO>,
49 45 user_menu_profile: String,
50 46 user_menu_logout: String,
51 47 user_menu_settings: String,
@@ -92,27 +88,15 @@ pub async fn home(
92 88
93 89 let locale_str = locale.as_str();
94 90
95 - // Translate welcome message with variable interpolation
96 91 let mut vars = HashMap::new();
97 92 vars.insert("username".to_string(), user.username.clone());
98 93 let welcome_message = i18n_service
99 94 .translate_with_vars("home.welcome", locale_str, &vars)
100 95 .await;
101 96
102 - // Translate section titles
103 97 let home_subtitle = i18n_service.translate("home.subtitle", locale_str).await;
104 98 let home_my_account = i18n_service.translate("home.my_account", locale_str).await;
105 - let home_upcoming_features = i18n_service
106 - .translate("home.upcoming_features", locale_str)
107 - .await;
108 - let home_did_you_know = i18n_service
109 - .translate("home.did_you_know", locale_str)
110 - .await;
111 - let home_did_you_know_text = i18n_service
112 - .translate("home.did_you_know_text", locale_str)
113 - .await;
114 99
115 - // Build translated account stats
116 100 let stat_username_label = i18n_service
117 101 .translate("home.stat_username", locale_str)
118 102 .await;
@@ -133,60 +117,6 @@ pub async fn home(
133 117 AccountStatDTO::new(&stat_roles_label, &user.roles.join(", "), "shield"),
134 118 ];
135 119
136 - // Build translated upcoming features
137 - let badge_soon = i18n_service.translate("home.badge_soon", locale_str).await;
138 - let badge_planned = i18n_service
139 - .translate("home.badge_planned", locale_str)
140 - .await;
141 -
142 - let upcoming_features = vec![
143 - UpcomingFeatureDTO::new(
144 - "005",
145 - &i18n_service
146 - .translate("home.feature_files_name", locale_str)
147 - .await,
148 - &i18n_service
149 - .translate("home.feature_files_desc", locale_str)
150 - .await,
151 - "folder",
152 - &badge_soon,
153 - ),
154 - UpcomingFeatureDTO::new(
155 - "006",
156 - &i18n_service
157 - .translate("home.feature_upload_name", locale_str)
158 - .await,
159 - &i18n_service
160 - .translate("home.feature_upload_desc", locale_str)
161 - .await,
162 - "upload",
163 - &badge_soon,
164 - ),
165 - UpcomingFeatureDTO::new(
166 - "007",
167 - &i18n_service
168 - .translate("home.feature_webdav_name", locale_str)
169 - .await,
170 - &i18n_service
171 - .translate("home.feature_webdav_desc", locale_str)
172 - .await,
173 - "cloud",
174 - &badge_planned,
175 - ),
176 - UpcomingFeatureDTO::new(
177 - "008",
178 - &i18n_service
179 - .translate("home.feature_mobile_name", locale_str)
180 - .await,
181 - &i18n_service
182 - .translate("home.feature_mobile_desc", locale_str)
183 - .await,
184 - "smartphone",
185 - &badge_planned,
186 - ),
187 - ];
188 -
189 - // Translate user menu
190 120 let user_menu_profile = i18n_service
191 121 .translate("user_menu.profile", locale_str)
192 122 .await;
@@ -197,7 +127,6 @@ pub async fn home(
197 127 .translate("user_menu.settings", locale_str)
198 128 .await;
199 129
200 - // Build navigation
201 130 let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
202 131 let nav_items: Vec<NavItem> = navigation
203 132 .items
@@ -217,7 +146,6 @@ pub async fn home(
217 146 })
218 147 .collect();
219 148
220 - // Load app settings from database
221 149 use crate::services::app_settings_service::AppSettingsService;
222 150 let app_domain = AppSettingsService::get_setting(&db, "app_domain")
223 151 .await?
@@ -247,11 +175,7 @@ pub async fn home(
247 175 welcome_message,
248 176 home_subtitle,
249 177 home_my_account,
250 - home_upcoming_features,
251 - home_did_you_know,
252 - home_did_you_know_text,
253 178 account_stats,
254 - upcoming_features,
255 179 user_menu_profile,
256 180 user_menu_logout,
257 181 user_menu_settings,
M src/api/handlers/login_handler.rs
+5 -0
@@ -56,6 +56,7 @@ struct LoginTemplate {
56 56 resend_prompt: String,
57 57 register_success: String,
58 58 register_error: String,
59 + pw_min_length: usize,
59 60 }
60 61
61 62 /// Resend verification template
@@ -115,6 +116,7 @@ crate::impl_template_response!(ResetPasswordTemplate);
115 116 /// Handler for GET /login - Login page (PUBLIC)
116 117 pub async fn login_page(
117 118 headers: HeaderMap,
119 + State(db): State<DatabaseConnection>,
118 120 Extension(i18n_service): Extension<Arc<I18nService>>,
119 121 ) -> Result<impl IntoResponse, crate::error::AppError> {
120 122 tracing::debug!("Rendering login page");
@@ -280,6 +282,9 @@ pub async fn login_page(
280 282 resend_prompt,
281 283 register_success,
282 284 register_error,
285 + pw_min_length: crate::api::middleware::password_complexity::PasswordRules::from_db(&db)
286 + .await
287 + .min_length,
283 288 };
284 289
285 290 Ok(template)
M src/api/handlers/settings_handler.rs
+160 -74
@@ -354,81 +354,125 @@ pub async fn change_password(
354 354 ));
355 355 }
356 356
357 - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();
358 -
359 - let (_request_id, token) = UserService::change_password(
357 + let require_email = AppSettingsService::get_bool(
360 358 &db,
361 - claims.sub,
362 - &request.old_password,
363 - &request.new_password,
364 - current_token_id,
359 + "password_change_require_email",
360 + true,
365 361 )
366 362 .await?;
367 363
368 - tracing::info!(
369 - "Created pending password change request for user {}",
370 - claims.sub
371 - );
364 + if require_email {
365 + let current_token_id: Option<uuid::Uuid> =
366 + session.get("refresh_token_id").await.ok().flatten();
367 +
368 + let (_request_id, token) = UserService::change_password(
369 + &db,
370 + claims.sub,
371 + &request.old_password,
372 + &request.new_password,
373 + current_token_id,
374 + )
375 + .await?;
372 376
373 - let user = UserService::get_user_by_id(&db, claims.sub)
374 - .await?
375 - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
377 + tracing::info!(
378 + "Created pending password change request for user {}",
379 + claims.sub
380 + );
376 381
377 - let email_config = EmailConfig::from_env()
378 - .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?;
382 + let user = UserService::get_user_by_id(&db, claims.sub)
383 + .await?
384 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
379 385
380 - let confirmation_url = format!(
381 - "{}/confirm-password-change?token={}",
382 - email_config.email_base_url, token
383 - );
386 + let email_config = EmailConfig::from_env()
387 + .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?;
384 388
385 - let template = crate::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new(
386 - Some("fr".to_string()),
387 - user.username.clone(),
388 - confirmation_url,
389 - Some(
390 - email_config
391 - .smtp_from_name
392 - .clone()
393 - .unwrap_or_else(|| "RustWarden".to_string()),
394 - ),
395 - );
389 + let confirmation_url = format!(
390 + "{}/confirm-password-change?token={}",
391 + email_config.email_base_url, token
392 + );
396 393
397 - let body_html = template
398 - .render_html()
399 - .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
400 - let body_text = template.render_text();
401 -
402 - let subject = "Confirmation de changement de mot de passe".to_string();
403 -
404 - let email_svc = crate::services::email_service::EmailService::new(email_config)
405 - .map_err(|e| AppError::Internal(format!("Failed to create email service: {}", e)))?;
406 - email_svc.send_email(
407 - &user.email,
408 - Some(&user.username),
409 - &subject,
410 - &body_html,
411 - &body_text,
412 - )
413 - .await
414 - .map_err(|e| {
415 - tracing::error!(
416 - "Failed to send password change confirmation email for user {}: {}",
417 - user.username,
418 - e
394 + let template =
395 + crate::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new(
396 + Some("fr".to_string()),
397 + user.username.clone(),
398 + confirmation_url,
399 + Some(
400 + email_config
401 + .smtp_from_name
402 + .clone()
403 + .unwrap_or_else(|| "RustWarden".to_string()),
404 + ),
405 + );
406 +
407 + let body_html = template
408 + .render_html()
409 + .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
410 + let body_text = template.render_text();
411 +
412 + let subject = "Confirmation de changement de mot de passe".to_string();
413 +
414 + let email_svc = crate::services::email_service::EmailService::new(email_config)
415 + .map_err(|e| AppError::Internal(format!("Failed to create email service: {}", e)))?;
416 + email_svc
417 + .send_email(
418 + &user.email,
419 + Some(&user.username),
420 + &subject,
421 + &body_html,
422 + &body_text,
423 + )
424 + .await
425 + .map_err(|e| {
426 + tracing::error!(
427 + "Failed to send password change confirmation email for user {}: {}",
428 + user.username,
429 + e
430 + );
431 + AppError::Internal(
432 + "Failed to send confirmation email. Please try again later.".to_string(),
433 + )
434 + })?;
435 +
436 + tracing::info!(
437 + "Password change confirmation email sent for user {}",
438 + user.username
419 439 );
420 - AppError::Internal("Failed to send confirmation email. Please try again later.".to_string())
421 - })?;
422 440
423 - tracing::info!(
424 - "Password change confirmation email sent for user {}",
425 - user.username
426 - );
441 + Ok(Json(ChangePasswordResponse {
442 + message: "email_confirmation_sent".to_string(),
443 + }))
444 + } else {
445 + use crate::services::password_service::PasswordService;
446 +
447 + let user = UserService::get_user_by_id(&db, claims.sub)
448 + .await?
449 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
450 +
451 + if !PasswordService::verify_password(&request.old_password, &user.password_hash)? {
452 + return Err(AppError::Validation(
453 + "Old password is incorrect".to_string(),
454 + ));
455 + }
456 +
457 + crate::api::middleware::password_complexity::validate_password_complexity(
458 + &db,
459 + &request.new_password,
460 + )
461 + .await?;
462 +
463 + let new_hash = PasswordService::hash_password(&request.new_password)?;
464 + UserService::update_password_hash(&db, claims.sub, &new_hash).await?;
465 + RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?;
466 +
467 + tracing::info!(
468 + "Password changed directly (no email confirmation) for user {}",
469 + claims.sub
470 + );
427 471
428 - Ok(Json(ChangePasswordResponse {
429 - message: "Confirmation email sent! Please check your inbox to confirm the password change."
430 - .to_string(),
431 - }))
472 + Ok(Json(ChangePasswordResponse {
473 + message: "password_changed_directly".to_string(),
474 + }))
475 + }
432 476 }
433 477
434 478 /// Query parameters for password change confirmation
@@ -621,24 +665,34 @@ pub async fn update_preferences(
621 665 "reload_required": language_changed,
622 666 }));
623 667
668 + use time::Duration as TimeDuration;
669 + use axum::http::header;
670 +
671 + let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", updated_prefs.theme.clone()))
672 + .http_only(false)
673 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
674 + .path("/")
675 + .max_age(TimeDuration::seconds(31536000));
676 +
677 + let mut response = json_response.into_response();
678 +
679 + if let Ok(hv) = theme_cookie.to_string().parse() {
680 + response.headers_mut().append(header::SET_COOKIE, hv);
681 + }
682 +
624 683 if language_changed && matches!(language.as_str(), "fr" | "en" | "de" | "es") {
625 - use time::Duration as TimeDuration;
626 - let cookie = axum_extra::extract::cookie::Cookie::build(("locale", language))
684 + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", language))
627 685 .http_only(false)
628 686 .same_site(axum_extra::extract::cookie::SameSite::Lax)
629 687 .path("/")
630 688 .max_age(TimeDuration::seconds(31536000));
631 689
632 - let mut response = json_response.into_response();
633 - if let Ok(header_value) = cookie.to_string().parse() {
634 - response
635 - .headers_mut()
636 - .insert(axum::http::header::SET_COOKIE, header_value);
690 + if let Ok(hv) = locale_cookie.to_string().parse() {
691 + response.headers_mut().append(header::SET_COOKIE, hv);
637 692 }
638 - Ok(response)
639 - } else {
640 - Ok(json_response.into_response())
641 693 }
694 +
695 + Ok(response)
642 696 }
643 697
644 698 // ============================================================================
@@ -794,7 +848,17 @@ struct PasswordTemplate {
794 848 breadcrumb_settings: String,
795 849 breadcrumb_password: String,
796 850 cancel: String,
851 + logout_label: String,
797 852 has_pending_password_change: bool,
853 + // Password complexity rules (from app_settings)
854 + pw_min_length: usize,
855 + pw_require_uppercase: bool,
856 + pw_require_lowercase: bool,
857 + pw_require_digits: bool,
858 + pw_require_special: bool,
859 + password_change_require_email: bool,
860 + subtitle_no_email: String,
861 + saved_direct: String,
798 862 }
799 863
800 864 crate::impl_template_response!(PasswordTemplate);
@@ -1049,6 +1113,8 @@ pub async fn get_password_page(
1049 1113 String::new()
1050 1114 };
1051 1115
1116 + let pw_rules = crate::api::middleware::password_complexity::PasswordRules::from_db(&db).await;
1117 +
1052 1118 use crate::services::password_change_service::PasswordChangeService;
1053 1119 let password_change_service = PasswordChangeService::new();
1054 1120 let pending_request = password_change_service
@@ -1153,7 +1219,26 @@ pub async fn get_password_page(
1153 1219 breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
1154 1220 breadcrumb_password: i18n_service.translate("breadcrumb.password", locale_str).await,
1155 1221 cancel: i18n_service.translate("common.cancel", locale_str).await,
1222 + logout_label: i18n_service.translate("user_menu.logout", locale_str).await,
1156 1223 has_pending_password_change,
1224 + pw_min_length: pw_rules.min_length,
1225 + pw_require_uppercase: pw_rules.require_uppercase,
1226 + pw_require_lowercase: pw_rules.require_lowercase,
1227 + pw_require_digits: pw_rules.require_digits,
1228 + pw_require_special: pw_rules.require_special,
1229 + password_change_require_email: AppSettingsService::get_bool(
1230 + &db,
1231 + "password_change_require_email",
1232 + true,
1233 + )
1234 + .await
1235 + .unwrap_or(true),
1236 + subtitle_no_email: i18n_service
1237 + .translate("password.subtitle_no_email", locale_str)
1238 + .await,
1239 + saved_direct: i18n_service
1240 + .translate("password.saved_direct", locale_str)
1241 + .await,
1157 1242 };
1158 1243
1159 1244 Ok(template)
@@ -1189,12 +1274,13 @@ pub async fn get_sessions_page(
1189 1274 }
1190 1275 }
1191 1276
1277 + let ip_fallback = i18n_service.translate("session.ip_unavailable", locale_str).await;
1192 1278 let sessions: Vec<SessionItem> = sessions_data
1193 1279 .iter()
1194 1280 .map(|s| SessionItem {
1195 1281 id: s.id.to_string(),
1196 1282 device_info: s.device_info.clone().unwrap_or_else(|| "Unknown".to_string()),
1197 - ip_address: s.ip_address.clone().unwrap_or_else(|| "Non disponible".to_string()),
1283 + ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()),
1198 1284 last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(),
1199 1285 created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
1200 1286 is_current: s.is_current,
M src/api/middleware/auth_service.rs
+15 -1
@@ -42,7 +42,7 @@ where
42 42
43 43 fn call(&mut self, req: Request<B>) -> Self::Future {
44 44 let uri = req.uri().clone();
45 - let path = uri.path();
45 + let path = uri.path().to_owned();
46 46
47 47 let protected_routes = [
48 48 "/home",
@@ -120,6 +120,8 @@ where
120 120 let client_ip = extract_client_ip(headers);
121 121
122 122 if let Ok(Some(user_model)) = user::Entity::find_by_id(user_id).one(&db).await {
123 + let must_change = user_model.must_change_password;
124 +
123 125 let mut user_active: user::ActiveModel = user_model.into();
124 126 user_active.last_login_at = Set(Some(Utc::now()));
125 127 if let Some(ip) = client_ip {
@@ -135,6 +137,18 @@ where
135 137 } else {
136 138 tracing::debug!("Updated last_login for user {}", user_id);
137 139 }
140 +
141 + if must_change && !path.starts_with("/settings/password") {
142 + tracing::info!(
143 + "User {} must change password, redirecting from {}",
144 + user_id,
145 + path
146 + );
147 + return Ok(
148 + axum::response::Redirect::to("/settings/password?must_change=true")
149 + .into_response(),
150 + );
151 + }
138 152 }
139 153 }
140 154
M src/api/middleware/password_complexity.rs
+47 -29
@@ -1,7 +1,8 @@
1 1 /// Password Complexity Validation Middleware
2 2 ///
3 -/// Middleware to validate password complexity according to configurable rules.
4 -/// Rules include minimum length and required character types.
3 +/// Middleware to validate password complexity according to configurable rules
4 +/// stored in the `app_settings` table. Rules include minimum length and
5 +/// required character types.
5 6 use axum::{
6 7 extract::{Request, State},
7 8 middleware::Next,
@@ -10,32 +11,59 @@ use axum::{
10 11 use sea_orm::DatabaseConnection;
11 12
12 13 use crate::error::AppError;
14 +use crate::services::app_settings_service::AppSettingsService;
13 15
14 -/// Password validation rules
15 -#[derive(Debug, Clone)]
16 -struct PasswordRules {
17 - min_length: usize,
18 - require_uppercase: bool,
19 - require_lowercase: bool,
20 - require_digits: bool,
21 - require_special: bool,
16 +/// Password validation rules (loaded from DB or defaults)
17 +#[derive(Debug, Clone, serde::Serialize)]
18 +pub struct PasswordRules {
19 + pub min_length: usize,
20 + pub require_uppercase: bool,
21 + pub require_lowercase: bool,
22 + pub require_digits: bool,
23 + pub require_special: bool,
22 24 }
23 25
24 26 impl Default for PasswordRules {
25 27 fn default() -> Self {
26 28 Self {
27 29 min_length: 8,
28 - require_uppercase: true,
29 - require_lowercase: true,
30 - require_digits: true,
30 + require_uppercase: false,
31 + require_lowercase: false,
32 + require_digits: false,
31 33 require_special: false,
32 34 }
33 35 }
34 36 }
35 37
36 38 impl PasswordRules {
37 - /// Validate password against rules
38 - fn validate(&self, password: &str) -> Result<(), Vec<String>> {
39 + /// Load password rules from the `app_settings` table.
40 + /// Falls back to defaults for any missing key.
41 + pub async fn from_db(db: &DatabaseConnection) -> Self {
42 + let get_bool = |key: &'static str, default: bool| {
43 + let db = db;
44 + async move {
45 + AppSettingsService::get_bool(db, key, default)
46 + .await
47 + .unwrap_or(default)
48 + }
49 + };
50 +
51 + let min_length = match AppSettingsService::get_setting(db, "password_min_length").await {
52 + Ok(Some(s)) => s.value.parse::<usize>().unwrap_or(8),
53 + _ => 8,
54 + };
55 +
56 + Self {
57 + min_length,
58 + require_uppercase: get_bool("password_require_uppercase", false).await,
59 + require_lowercase: get_bool("password_require_lowercase", false).await,
60 + require_digits: get_bool("password_require_digits", false).await,
61 + require_special: get_bool("password_require_special", false).await,
62 + }
63 + }
64 +
65 + /// Validate a password against these rules.
66 + pub fn validate(&self, password: &str) -> Result<(), Vec<String>> {
39 67 let mut errors = Vec::new();
40 68
41 69 if password.len() < self.min_length {
@@ -77,30 +105,20 @@ impl PasswordRules {
77 105 }
78 106
79 107 /// Middleware that validates password complexity for password-related operations
80 -///
81 -/// This middleware should be applied to routes that accept password changes:
82 -/// - POST /admin/users (user creation)
83 -/// - PUT /admin/users/:id (user update with password)
84 -/// - PUT /settings/password (user password change)
85 -/// - POST /admin/users/:id/reset-password (admin reset password)
86 108 pub async fn password_complexity_validation(
87 109 State(_db): State<DatabaseConnection>,
88 110 request: Request,
89 111 next: Next,
90 112 ) -> Result<Response, AppError> {
91 - // Validation is done in handlers that parse the request body.
92 - // This middleware serves as a foundation for future implementation.
93 113 Ok(next.run(request).await)
94 114 }
95 115
96 -/// Validate password against configured rules (helper function for handlers)
97 -///
98 -/// This function can be called from handlers to validate passwords
99 -/// according to the configured rules.
100 -pub fn validate_password_complexity(
116 +/// Validate password against rules loaded from the database.
117 +pub async fn validate_password_complexity(
118 + db: &DatabaseConnection,
101 119 password: &str,
102 120 ) -> Result<(), AppError> {
103 - let rules = PasswordRules::default();
121 + let rules = PasswordRules::from_db(db).await;
104 122 match rules.validate(password) {
105 123 Ok(()) => Ok(()),
106 124 Err(errors) => Err(AppError::Validation(errors.join("; "))),
M src/auth/handlers.rs
+48 -17
@@ -11,8 +11,9 @@ use axum::{
11 11 extract::{Path, State},
12 12 http::HeaderMap,
13 13 response::{Html, IntoResponse, Response},
14 - Json,
14 + Extension, Json,
15 15 };
16 +use std::sync::Arc;
16 17 use axum_extra::extract::CookieJar;
17 18 use chrono::{DateTime, Utc};
18 19 use sea_orm::DatabaseConnection;
@@ -31,7 +32,8 @@ use crate::services::{
31 32 email_validation_service::EmailValidationService,
32 33 jwt_blacklist_service::JwtBlacklistService, password_reset_service::PasswordResetService,
33 34 password_service::PasswordService, refresh_token_service::RefreshTokenService,
34 - role_service::RoleService, user_service::UserService,
35 + role_service::RoleService, user_preferences_service::UserPreferencesService,
36 + user_service::UserService,
35 37 };
36 38
37 39 // --- Auth DTOs (inlined, no separate dto module) ---
@@ -242,9 +244,13 @@ pub async fn login(
242 244 // Authenticate user (verifies credentials and checks must_change_password)
243 245 let user = AuthService::authenticate_user(&db, &payload.username, &payload.password).await?;
244 246
245 - let email_validation_required = std::env::var("EMAIL_VALIDATION_REQUIRED")
246 - .ok()
247 - .and_then(|v| v.parse::<bool>().ok())
247 + let email_validation_required =
248 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
249 + &db,
250 + &["validation_email_required", "validation_email_requise"],
251 + true,
252 + )
253 + .await
248 254 .unwrap_or(true);
249 255
250 256 if email_validation_required && !user.email_verified {
@@ -349,8 +355,8 @@ pub async fn login(
349 355 let json_response: Response = Json(response).into_response();
350 356 let (mut parts, body) = json_response.into_parts();
351 357
352 - // Add cookie to response headers
353 - parts.headers.insert(
358 + // Add JWT cookie
359 + parts.headers.append(
354 360 axum::http::header::SET_COOKIE,
355 361 cookie
356 362 .to_string()
@@ -358,6 +364,27 @@ pub async fn login(
358 364 .map_err(|e| AppError::Internal(format!("Failed to create cookie header: {}", e)))?,
359 365 );
360 366
367 + // Load user preferences and set theme/locale cookies
368 + if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await {
369 + let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme))
370 + .http_only(false)
371 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
372 + .path("/")
373 + .max_age(Duration::seconds(31536000));
374 + if let Ok(hv) = theme_cookie.to_string().parse() {
375 + parts.headers.append(axum::http::header::SET_COOKIE, hv);
376 + }
377 +
378 + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language))
379 + .http_only(false)
380 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
381 + .path("/")
382 + .max_age(Duration::seconds(31536000));
383 + if let Ok(hv) = locale_cookie.to_string().parse() {
384 + parts.headers.append(axum::http::header::SET_COOKIE, hv);
385 + }
386 + }
387 +
361 388 Ok(Response::from_parts(parts, body))
362 389 }
363 390
@@ -880,29 +907,29 @@ pub async fn reset_password(
880 907 /// ```
881 908 pub async fn verify_email(
882 909 State(db): State<DatabaseConnection>,
910 + Extension(i18n_service): Extension<Arc<crate::i18n::service::I18nService>>,
911 + Extension(locale): Extension<crate::i18n::extractor::Locale>,
883 912 Path(token): Path<String>,
884 913 ) -> impl IntoResponse {
885 - // Load email config
914 + let loc = locale.0.as_str();
915 +
886 916 let email_config = match EmailConfig::from_env() {
887 917 Ok(config) => config,
888 918 Err(e) => {
889 919 tracing::error!("Email configuration error: {}", e);
890 920 return EmailVerifiedTemplate {
891 921 success: false,
892 - message: "Erreur de configuration du serveur. Veuillez contacter l'administrateur."
893 - .to_string(),
922 + message: i18n_service.translate("email_verify.config_error", loc).await,
894 923 };
895 924 }
896 925 };
897 926
898 - // Create email validation service
899 927 let validation_service = EmailValidationService::new(email_config);
900 928
901 - // Verify email
902 929 match validation_service.verify_email(&db, &token).await {
903 930 Ok(_) => EmailVerifiedTemplate {
904 931 success: true,
905 - message: "Votre adresse email a été vérifiée avec succès. Vous pouvez maintenant vous connecter à votre compte.".to_string(),
932 + message: i18n_service.translate("email_verify.success", loc).await,
906 933 },
907 934 Err(e) => {
908 935 tracing::warn!("Email verification failed: {}", e);
@@ -911,7 +938,7 @@ pub async fn verify_email(
911 938 message: match e {
912 939 AppError::Validation(msg) => msg,
913 940 AppError::NotFound(msg) => msg,
914 - _ => "Le lien de vérification est invalide ou a expiré.".to_string(),
941 + _ => i18n_service.translate("email_verify.invalid_link", loc).await,
915 942 },
916 943 }
917 944 }
@@ -1060,9 +1087,13 @@ pub async fn register(
1060 1087 AppError::Validation(format!("Invalid registration request: {}", e))
1061 1088 })?;
1062 1089
1063 - let allow_registration = std::env::var("ALLOW_REGISTRATION")
1064 - .ok()
1065 - .and_then(|v| v.parse::<bool>().ok())
1090 + let allow_registration =
1091 + crate::services::app_settings_service::AppSettingsService::get_bool(
1092 + &db,
1093 + "allow_registration",
1094 + false,
1095 + )
1096 + .await
1066 1097 .unwrap_or(false);
1067 1098
1068 1099 if !allow_registration {
M src/config/mod.rs
+1 -0
@@ -7,3 +7,4 @@ pub mod auth;
7 7 pub mod database;
8 8 pub mod email;
9 9 pub mod server;
10 +pub mod tasks;
A src/config/tasks.rs
+157 -0
@@ -0,0 +1,157 @@
1 +//! Background tasks and timing configuration
2 +//!
3 +//! All timeouts, intervals, expiration durations, and rate limits
4 +//! that were previously hardcoded. Each reads from an environment
5 +//! variable with a sensible default.
6 +
7 +use std::env;
8 +
9 +fn env_u64(key: &str, default: u64) -> u64 {
10 + env::var(key)
11 + .ok()
12 + .and_then(|v| v.parse().ok())
13 + .unwrap_or(default)
14 +}
15 +
16 +fn env_i64(key: &str, default: i64) -> i64 {
17 + env::var(key)
18 + .ok()
19 + .and_then(|v| v.parse().ok())
20 + .unwrap_or(default)
21 +}
22 +
23 +fn env_usize(key: &str, default: usize) -> usize {
24 + env::var(key)
25 + .ok()
26 + .and_then(|v| v.parse().ok())
27 + .unwrap_or(default)
28 +}
29 +
30 +// ── Email queue processor ──────────────────────────────────────────
31 +
32 +/// Interval between email queue processing runs (seconds).
33 +/// Env: `EMAIL_QUEUE_INTERVAL_SECS` — default 30
34 +pub fn email_queue_interval_secs() -> u64 {
35 + env_u64("EMAIL_QUEUE_INTERVAL_SECS", 30)
36 +}
37 +
38 +// ── Password change email retry ────────────────────────────────────
39 +
40 +/// Interval between password-change email retry runs (seconds).
41 +/// Env: `PASSWORD_CHANGE_RETRY_INTERVAL_SECS` — default 600 (10 min)
42 +pub fn password_change_retry_interval_secs() -> u64 {
43 + env_u64("PASSWORD_CHANGE_RETRY_INTERVAL_SECS", 600)
44 +}
45 +
46 +/// Minimum gap between two retry attempts for the same request (minutes).
47 +/// Env: `PASSWORD_CHANGE_RETRY_GAP_MINUTES` — default 20
48 +pub fn password_change_retry_gap_minutes() -> i64 {
49 + env_i64("PASSWORD_CHANGE_RETRY_GAP_MINUTES", 20)
50 +}
51 +
52 +/// Max retry count for password-change confirmation emails.
53 +/// Env: `PASSWORD_CHANGE_MAX_RETRIES` — default 3
54 +pub fn password_change_max_retries() -> i64 {
55 + env_i64("PASSWORD_CHANGE_MAX_RETRIES", 3)
56 +}
57 +
58 +// ── Password change cleanup ────────────────────────────────────────
59 +
60 +/// Interval between cleanup runs for expired password-change requests (seconds).
61 +/// Env: `PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS` — default 86400 (24 h)
62 +pub fn password_change_cleanup_interval_secs() -> u64 {
63 + env_u64("PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS", 86400)
64 +}
65 +
66 +// ── JWT blacklist cleanup ──────────────────────────────────────────
67 +
68 +/// Interval between JWT blacklist cleanup runs (seconds).
69 +/// Env: `JWT_CLEANUP_INTERVAL_SECS` — default 3600 (1 h)
70 +pub fn jwt_cleanup_interval_secs() -> u64 {
71 + env_u64("JWT_CLEANUP_INTERVAL_SECS", 3600)
72 +}
73 +
74 +// ── Token expiration durations ─────────────────────────────────────
75 +
76 +/// Password-change request expiration (hours).
77 +/// Env: `PASSWORD_CHANGE_EXPIRATION_HOURS` — default 24
78 +pub fn password_change_expiration_hours() -> i64 {
79 + env_i64("PASSWORD_CHANGE_EXPIRATION_HOURS", 24)
80 +}
81 +
82 +/// Password-reset token expiration (hours).
83 +/// Env: `PASSWORD_RESET_EXPIRATION_HOURS` — default 1
84 +pub fn password_reset_expiration_hours() -> i64 {
85 + env_i64("PASSWORD_RESET_EXPIRATION_HOURS", 1)
86 +}
87 +
88 +/// Email validation token expiration (hours).
89 +/// Env: `EMAIL_VALIDATION_EXPIRATION_HOURS` — default 24
90 +pub fn email_validation_expiration_hours() -> i64 {
91 + env_i64("EMAIL_VALIDATION_EXPIRATION_HOURS", 24)
92 +}
93 +
94 +// ── Rate limits (password change) ──────────────────────────────────
95 +
96 +/// Max password-change requests per user in the rate-limit window.
97 +/// Env: `PASSWORD_CHANGE_RATE_LIMIT_MAX` — default 3
98 +pub fn password_change_rate_limit_max() -> usize {
99 + env_usize("PASSWORD_CHANGE_RATE_LIMIT_MAX", 3)
100 +}
101 +
102 +/// Rate-limit window for password-change requests (seconds).
103 +/// Env: `PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS` — default 3600 (1 h)
104 +pub fn password_change_rate_limit_window_secs() -> u64 {
105 + env_u64("PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS", 3600)
106 +}
107 +
108 +// ── IMAP timeouts ──────────────────────────────────────────────────
109 +
110 +/// IMAP read/write socket timeout (seconds).
111 +/// Env: `IMAP_SOCKET_TIMEOUT_SECS` — default 30
112 +pub fn imap_socket_timeout_secs() -> u64 {
113 + env_u64("IMAP_SOCKET_TIMEOUT_SECS", 30)
114 +}
115 +
116 +// ── Cookie max-age ─────────────────────────────────────────────────
117 +
118 +/// Max-age for non-session cookies like locale and theme (seconds).
119 +/// Env: `COOKIE_PREFERENCE_MAX_AGE_SECS` — default 31536000 (1 year)
120 +pub fn cookie_preference_max_age_secs() -> i64 {
121 + env_i64("COOKIE_PREFERENCE_MAX_AGE_SECS", 31536000)
122 +}
123 +
124 +// ── Pagination defaults ────────────────────────────────────────────
125 +
126 +/// Default page size when not specified by the client.
127 +/// Env: `PAGINATION_DEFAULT_PER_PAGE` — default 50
128 +pub fn pagination_default_per_page() -> u64 {
129 + env_u64("PAGINATION_DEFAULT_PER_PAGE", 50)
130 +}
131 +
132 +/// Maximum page size a client may request.
133 +/// Env: `PAGINATION_MAX_PER_PAGE` — default 100
134 +pub fn pagination_max_per_page() -> u64 {
135 + env_u64("PAGINATION_MAX_PER_PAGE", 100)
136 +}
137 +
138 +// ── Default locale ─────────────────────────────────────────────────
139 +
140 +/// Default locale when none is set.
141 +/// Env: `DEFAULT_LOCALE` — default "fr"
142 +pub fn default_locale() -> String {
143 + env::var("DEFAULT_LOCALE").unwrap_or_else(|_| "fr".to_string())
144 +}
145 +
146 +#[cfg(test)]
147 +mod tests {
148 + use super::*;
149 +
150 + #[test]
151 + fn test_defaults() {
152 + assert_eq!(email_queue_interval_secs(), 30);
153 + assert_eq!(password_reset_expiration_hours(), 1);
154 + assert_eq!(default_locale(), "fr");
155 + assert_eq!(pagination_max_per_page(), 100);
156 + }
157 +}
M src/error.rs
+1 -0
@@ -37,6 +37,7 @@ pub enum AppError {
37 37 #[error("Token reused - possible security breach")]
38 38 TokenReused,
39 39
40 + #[allow(dead_code)]
40 41 #[error("Password change required")]
41 42 MustChangePassword,
42 43
M src/i18n/service.rs
+1 -1
@@ -166,7 +166,7 @@ impl I18nService {
166 166 }
167 167
168 168 // Log missing translation in debug mode (per T047)
169 - if cfg!(debug_assertions) || std::env::var("RUSTCLOUD_DEBUG_I18N").is_ok() {
169 + if cfg!(debug_assertions) || std::env::var("RUSTWARDEN_DEBUG_I18N").is_ok() {
170 170 // Log available locales and keys for debugging
171 171 let available_locales: Vec<String> = trans.keys().cloned().collect();
172 172 let available_keys: Vec<String> = trans
M src/main.rs
+14 -2
@@ -38,7 +38,7 @@ async fn locale_middleware(request: Request, next: Next) -> Response {
38 38 .filter(|s| matches!(s.as_str(), "fr" | "en" | "de" | "es"))
39 39 })
40 40 })
41 - .unwrap_or_else(|| "fr".to_string());
41 + .unwrap_or_else(|| rustwarden::config::tasks::default_locale());
42 42
43 43 parts.extensions.insert(Locale(locale_str));
44 44
@@ -79,6 +79,17 @@ async fn main() {
79 79 return;
80 80 }
81 81
82 + if let Err(e) =
83 + rustwarden::services::app_settings_service::AppSettingsService::initialize_default_settings(
84 + &db,
85 + )
86 + .await
87 + {
88 + tracing::error!("Failed to initialize default app settings: {}", e);
89 + return;
90 + }
91 + tracing::info!("Default app settings initialized");
92 +
82 93 let auth_config = match AuthConfig::from_env() {
83 94 Ok(config) => config,
84 95 Err(e) => {
@@ -151,7 +162,8 @@ async fn main() {
151 162 // Periodic cleanup of expired JWT tokens from the blacklist
152 163 let db_for_cleanup = db.clone();
153 164 tokio::spawn(async move {
154 - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600));
165 + let secs = rustwarden::config::tasks::jwt_cleanup_interval_secs();
166 + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(secs));
155 167 loop {
156 168 interval.tick().await;
157 169 match rustwarden::services::jwt_blacklist_service::JwtBlacklistService::cleanup_expired_tokens(&db_for_cleanup).await {
M src/models/user.rs
+1 -1
@@ -5,7 +5,7 @@ use uuid::Uuid;
5 5
6 6 /// User entity model
7 7 ///
8 -/// Represents a user in the RustCloud system.
8 +/// Represents a user in the RustWarden system.
9 9 /// Users can have multiple roles through the many-to-many relationship.
10 10 #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
11 11 #[sea_orm(table_name = "users")]
M src/services/app_settings_service.rs
+10 -10
@@ -225,16 +225,6 @@ impl AppSettingsService {
225 225 Some("Application domain name"),
226 226 ),
227 227 (
228 - "validation_email_requise",
229 - "true",
230 - Some("Require email validation before login"),
231 - ),
232 - (
233 - "audit_log_level",
234 - "INFO",
235 - Some("Audit log level: DEBUG, INFO, WARN, ERROR"),
236 - ),
237 - (
238 228 "allow_registration",
239 229 "false",
240 230 Some("Allow user registration"),
@@ -245,6 +235,11 @@ impl AppSettingsService {
245 235 Some("Require email validation to login"),
246 236 ),
247 237 (
238 + "audit_log_level",
239 + "INFO",
240 + Some("Audit log level: DEBUG, INFO, WARN, ERROR"),
241 + ),
242 + (
248 243 "password_min_length",
249 244 "8",
250 245 Some("Minimum password length (6-128)"),
@@ -270,6 +265,11 @@ impl AppSettingsService {
270 265 Some("Require special characters in password"),
271 266 ),
272 267 (
268 + "password_change_require_email",
269 + "true",
270 + Some("Require email confirmation for password changes"),
271 + ),
272 + (
273 273 "password_expiration_enabled",
274 274 "false",
275 275 Some("Enable password expiration"),
M src/services/auth_service.rs
+7 -5
@@ -54,12 +54,15 @@ impl AuthService {
54 54 Ok(user)
55 55 }
56 56
57 - /// Authenticate user and check must_change_password flag
57 + /// Authenticate user and return the model with must_change_password flag
58 58 ///
59 59 /// This is a higher-level authentication method that:
60 60 /// 1. Verifies credentials
61 - /// 2. Checks if user must change password
62 - /// 3. Returns appropriate error if password change required
61 + /// 2. Logs if user must change password (but does NOT block login)
62 + ///
63 + /// The caller (login handler) is responsible for including
64 + /// `must_change_password` in the response so the frontend can
65 + /// redirect the user to a password-change page.
63 66 pub async fn authenticate_user(
64 67 db: &DatabaseConnection,
65 68 username: &str,
@@ -68,8 +71,7 @@ impl AuthService {
68 71 let user = Self::verify_credentials(db, username, password).await?;
69 72
70 73 if user.must_change_password {
71 - tracing::info!("User {} must change password before continuing", username);
72 - return Err(AppError::MustChangePassword);
74 + tracing::info!("User {} should change password (flag set)", username);
73 75 }
74 76
75 77 Ok(user)
M src/services/email_validation_service.rs
+17 -15
@@ -75,8 +75,7 @@ impl EmailValidationService {
75 75 // Hash token with SHA-256
76 76 let token_hash = Self::hash_token(&token);
77 77
78 - // Create validation token (expires in 24 hours)
79 - let expires_at = Utc::now() + Duration::hours(24);
78 + let expires_at = Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours());
80 79
81 80 let validation_token = email_validation_token::ActiveModel {
82 81 id: Set(Uuid::new_v4()),
@@ -105,7 +104,7 @@ impl EmailValidationService {
105 104 self.email_config
106 105 .smtp_from_name
107 106 .clone()
108 - .unwrap_or_else(|| "RustCloud".to_string()),
107 + .unwrap_or_else(|| "RustWarden".to_string()),
109 108 ),
110 109 );
111 110
@@ -115,12 +114,13 @@ impl EmailValidationService {
115 114 .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
116 115 let body_text = template.render_text();
117 116
118 - // Subject based on locale
119 - let subject = if template.locale == "fr" {
120 - "Vérification de votre adresse email".to_string()
121 - } else {
122 - "Verify Your Email Address".to_string()
123 - };
117 + let subject = match template.locale.as_str() {
118 + "fr" => "Vérification de votre adresse email",
119 + "de" => "Bestätigung Ihrer E-Mail-Adresse",
120 + "es" => "Verifique su dirección de correo electrónico",
121 + _ => "Verify Your Email Address",
122 + }
123 + .to_string();
124 124
125 125 // Enqueue email
126 126 EmailQueueService::enqueue(
@@ -224,7 +224,7 @@ impl EmailValidationService {
224 224 self.email_config
225 225 .smtp_from_name
226 226 .clone()
227 - .unwrap_or_else(|| "RustCloud".to_string()),
227 + .unwrap_or_else(|| "RustWarden".to_string()),
228 228 ),
229 229 );
230 230
@@ -232,11 +232,13 @@ impl EmailValidationService {
232 232 if let (Ok(body_html), body_text) = (template.render_html(), template.render_text())
233 233 {
234 234 // Subject based on locale
235 - let subject = if locale.as_deref() == Some("fr") {
236 - "Bienvenue sur RustCloud !".to_string()
237 - } else {
238 - "Welcome to RustCloud!".to_string()
239 - };
235 + let subject = match locale.as_deref() {
236 + Some("fr") => "Bienvenue sur RustWarden !",
237 + Some("de") => "Willkommen bei RustWarden!",
238 + Some("es") => "¡Bienvenido a RustWarden!",
239 + _ => "Welcome to RustWarden!",
240 + }
241 + .to_string();
240 242
241 243 // Enqueue welcome email (non-blocking - errors are logged but don't fail verification)
242 244 if let Err(e) =
M src/services/imap_bounce_service.rs
+3 -3
@@ -231,12 +231,12 @@ impl ImapBounceService {
231 231
232 232 tracing::debug!("TCP connection established to {}:{}", host, port);
233 233
234 - // Set read/write timeouts to avoid hanging
234 + let timeout_secs = crate::config::tasks::imap_socket_timeout_secs();
235 235 stream
236 - .set_read_timeout(Some(std::time::Duration::from_secs(30)))
236 + .set_read_timeout(Some(std::time::Duration::from_secs(timeout_secs)))
237 237 .map_err(|e| AppError::Internal(format!("Failed to set read timeout: {}", e)))?;
238 238 stream
239 - .set_write_timeout(Some(std::time::Duration::from_secs(30)))
239 + .set_write_timeout(Some(std::time::Duration::from_secs(timeout_secs)))
240 240 .map_err(|e| AppError::Internal(format!("Failed to set write timeout: {}", e)))?;
241 241
242 242 // Build TLS connector for STARTTLS upgrade
M src/services/password_change_service.rs
+6 -7
@@ -93,8 +93,7 @@ impl PasswordChangeService {
93 93 // Hash token with SHA-256
94 94 let token_hash = Self::hash_token(&token);
95 95
96 - // Create request (expires in 24 hours)
97 - let expires_at = Utc::now() + Duration::hours(24);
96 + let expires_at = Utc::now() + Duration::hours(crate::config::tasks::password_change_expiration_hours());
98 97
99 98 let request = password_change_request::ActiveModel {
100 99 id: Set(Uuid::new_v4()),
@@ -483,14 +482,14 @@ impl PasswordChangeService {
483 482 // Get user's request history
484 483 let requests = store.entry(user_id).or_insert_with(Vec::new);
485 484
486 - // Remove requests older than 1 hour
487 - let one_hour_ago = now - 3600;
485 + let window_secs = crate::config::tasks::password_change_rate_limit_window_secs();
486 + let cutoff = now - window_secs;
488 487 requests.retain(|&timestamp| {
489 - timestamp.duration_since(UNIX_EPOCH).unwrap().as_secs() > one_hour_ago
488 + timestamp.duration_since(UNIX_EPOCH).unwrap().as_secs() > cutoff
490 489 });
491 490
492 - // Check if limit exceeded (3 requests per hour)
493 - if requests.len() >= 3 {
491 + let max_requests = crate::config::tasks::password_change_rate_limit_max();
492 + if requests.len() >= max_requests {
494 493 return Err(AppError::Validation(
495 494 "Too many password change requests. Please wait before trying again.".to_string(),
496 495 ));
M src/services/password_reset_service.rs
+9 -9
@@ -84,8 +84,7 @@ impl PasswordResetService {
84 84 // Hash token with SHA-256
85 85 let token_hash = Self::hash_token(&token);
86 86
87 - // Create reset token (expires in 1 hour)
88 - let expires_at = Utc::now() + Duration::hours(1);
87 + let expires_at = Utc::now() + Duration::hours(crate::config::tasks::password_reset_expiration_hours());
89 88
90 89 let reset_token = password_reset_token::ActiveModel {
91 90 id: Set(Uuid::new_v4()),
@@ -115,7 +114,7 @@ impl PasswordResetService {
115 114 self.email_config
116 115 .smtp_from_name
117 116 .clone()
118 - .unwrap_or_else(|| "RustCloud".to_string()),
117 + .unwrap_or_else(|| "RustWarden".to_string()),
119 118 ),
120 119 );
121 120
@@ -125,12 +124,13 @@ impl PasswordResetService {
125 124 .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
126 125 let body_text = template.render_text();
127 126
128 - // Subject based on locale
129 - let subject = if template.locale == "fr" {
130 - "Réinitialisation de votre mot de passe".to_string()
131 - } else {
132 - "Password Reset".to_string()
133 - };
127 + let subject = match template.locale.as_str() {
128 + "fr" => "Réinitialisation de votre mot de passe",
129 + "de" => "Passwort zurücksetzen",
130 + "es" => "Restablecimiento de contraseña",
131 + _ => "Password Reset",
132 + }
133 + .to_string();
134 134
135 135 // Enqueue email
136 136 EmailQueueService::enqueue(
M src/services/password_service.rs
+4 -19
@@ -133,26 +133,11 @@ impl PasswordService {
133 133 password.into_iter().collect()
134 134 }
135 135
136 - /// Validate password strength (Feature 009 - Phase 6/8)
136 + /// Validate password strength (legacy hardcoded version)
137 137 ///
138 - /// Checks that a password meets minimum security requirements:
139 - /// - Minimum 8 characters
140 - /// - At least one uppercase letter
141 - /// - At least one lowercase letter
142 - /// - At least one digit
143 - /// - At least one special character
144 - ///
145 - /// # Arguments
146 - ///
147 - /// * `password` - The password to validate
148 - ///
149 - /// # Returns
150 - ///
151 - /// Ok(()) if password meets requirements
152 - ///
153 - /// # Errors
154 - ///
155 - /// Returns `AppError::Validation` with specific error message if validation fails
138 + /// **Deprecated**: Use `crate::api::middleware::password_complexity::validate_password_complexity`
139 + /// which reads rules from `app_settings` in the database.
140 + #[allow(dead_code)]
156 141 pub fn validate_password_strength(password: &str) -> Result<(), AppError> {
157 142 if password.len() < 8 {
158 143 return Err(AppError::Validation(
M src/services/user_service.rs
+2 -1
@@ -238,7 +238,8 @@ impl UserService {
238 238 ));
239 239 }
240 240
241 - PasswordService::validate_password_strength(new_password)?;
241 + crate::api::middleware::password_complexity::validate_password_complexity(db, new_password)
242 + .await?;
242 243
243 244 let password_change_service = PasswordChangeService::new();
244 245 let (request_id, token) = password_change_service
M src/tasks/email_queue_processor.rs
+2 -1
@@ -63,7 +63,8 @@ impl EmailQueueProcessor {
63 63 /// 3. Wait 30 seconds before next batch
64 64 pub fn start(self) {
65 65 tokio::spawn(async move {
66 - let mut interval = tokio::time::interval(Duration::from_secs(30));
66 + let secs = crate::config::tasks::email_queue_interval_secs();
67 + let mut interval = tokio::time::interval(Duration::from_secs(secs));
67 68 interval.tick().await; // Skip first tick (immediate execution)
68 69
69 70 loop {
M src/tasks/password_change_cleanup.rs
+2 -1
@@ -42,7 +42,8 @@ impl PasswordChangeCleanupProcessor {
42 42 pub fn start(self) {
43 43 tokio::spawn(async move {
44 44 // Run daily (24 hours = 86400 seconds)
45 - let mut interval = tokio::time::interval(TokioDuration::from_secs(86400));
45 + let secs = crate::config::tasks::password_change_cleanup_interval_secs();
46 + let mut interval = tokio::time::interval(TokioDuration::from_secs(secs));
46 47 interval.tick().await; // Skip first tick (immediate execution)
47 48
48 49 loop {
M src/tasks/password_change_email_retry.rs
+4 -3
@@ -58,7 +58,8 @@ impl PasswordChangeEmailRetryProcessor {
58 58 pub fn start(self) {
59 59 tokio::spawn(async move {
60 60 // Run every 10 minutes (as specified in T037)
61 - let mut interval = tokio::time::interval(TokioDuration::from_secs(600));
61 + let secs = crate::config::tasks::password_change_retry_interval_secs();
62 + let mut interval = tokio::time::interval(TokioDuration::from_secs(secs));
62 63 interval.tick().await; // Skip first tick (immediate execution)
63 64
64 65 loop {
@@ -111,7 +112,7 @@ impl PasswordChangeEmailRetryProcessor {
111 112 let pending_requests = password_change_request::Entity::find()
112 113 .filter(password_change_request::Column::ConfirmedAt.is_null())
113 114 .filter(password_change_request::Column::ExpiresAt.gt(now))
114 - .filter(password_change_request::Column::RetryCount.lt(3))
115 + .filter(password_change_request::Column::RetryCount.lt(crate::config::tasks::password_change_max_retries()))
115 116 .all(&*self.db)
116 117 .await?;
117 118
@@ -120,7 +121,7 @@ impl PasswordChangeEmailRetryProcessor {
120 121 }
121 122
122 123 let mut processed = 0;
123 - let retry_interval = Duration::minutes(20);
124 + let retry_interval = Duration::minutes(crate::config::tasks::password_change_retry_gap_minutes());
124 125
125 126 for request in pending_requests {
126 127 // Check if enough time has passed since last attempt
M src/templates/helpers.rs
+3 -3
@@ -494,7 +494,7 @@ mod tests {
494 494 let locale = Locale("fr".to_string());
495 495 let nav_items = generate_nav_items_for_page("settings", &[], &i18n_service, &locale).await;
496 496
497 - assert_eq!(nav_items.len(), 3);
497 + assert_eq!(nav_items.len(), 2);
498 498
499 499 // Verify settings is active
500 500 let settings = nav_items.iter().find(|item| item.id == "settings").unwrap();
@@ -516,7 +516,7 @@ mod tests {
516 516 generate_nav_items_for_page("admin", &["admin".to_string()], &i18n_service, &locale)
517 517 .await;
518 518
519 - assert_eq!(nav_items.len(), 4);
519 + assert_eq!(nav_items.len(), 3);
520 520
521 521 // Verify admin is active
522 522 let admin = nav_items.iter().find(|item| item.id == "admin").unwrap();
@@ -535,7 +535,7 @@ mod tests {
535 535 generate_nav_items_for_page("home", &["user".to_string()], &i18n_service, &locale)
536 536 .await;
537 537
538 - assert_eq!(nav_items.len(), 3);
538 + assert_eq!(nav_items.len(), 2);
539 539
540 540 // Verify home is active
541 541 let home = nav_items.iter().find(|item| item.id == "home").unwrap();
M src/templates/mod.rs
+0 -36
@@ -132,42 +132,6 @@ impl Default for LayoutContext {
132 132 }
133 133
134 134 /// Home page context
135 -///
136 -/// Combines the base layout context with home page specific data.
137 -/// Uses serde's flatten to merge fields from layout into the same level.
138 -#[derive(Debug, Clone, Serialize, Deserialize)]
139 -pub struct HomePageContext {
140 - /// Base layout context (flattened)
141 - #[serde(flatten)]
142 - pub layout: LayoutContext,
143 -
144 - /// User profile
145 - pub user: crate::api::dto::user_profile_dto::UserProfileDTO,
146 -
147 - /// Navigation menu
148 - pub navigation: crate::api::dto::navigation_dto::NavigationDTO,
149 -
150 - /// Home page data
151 - pub home: crate::api::dto::home_dto::HomePageDTO,
152 -}
153 -
154 -impl HomePageContext {
155 - /// Create a new home page context
156 - pub fn new(
157 - layout: LayoutContext,
158 - user: crate::api::dto::user_profile_dto::UserProfileDTO,
159 - navigation: crate::api::dto::navigation_dto::NavigationDTO,
160 - home: crate::api::dto::home_dto::HomePageDTO,
161 - ) -> Self {
162 - Self {
163 - layout,
164 - user,
165 - navigation,
166 - home,
167 - }
168 - }
169 -}
170 -
171 135 #[cfg(test)]
172 136 mod tests {
173 137 use super::*;
M templates/admin/settings.html
+20 -3
@@ -189,12 +189,29 @@
189 189 </div>
190 190 </div>
191 191
192 + <!-- Password change email confirmation -->
193 + <div class="form-control mt-4">
194 + <label class="label cursor-pointer justify-start gap-4">
195 + <input
196 + type="checkbox"
197 + name="password_change_require_email"
198 + value="true"
199 + class="toggle toggle-primary"
200 + {% if settings.password_change_require_email == "true" %}checked{% endif %}
201 + >
202 + <div>
203 + <span class="label-text font-medium">{{ password_change_require_email }}</span>
204 + <p class="text-xs text-base-content/60 mt-0.5">{{ password_change_require_email_desc }}</p>
205 + </div>
206 + </label>
207 + </div>
208 +
192 209 <!-- Password Expiration -->
193 210 <div class="form-control">
194 211 <label class="label cursor-pointer justify-start gap-4">
195 - <input
196 - type="checkbox"
197 - name="password_expiration_enabled"
212 + <input
213 + type="checkbox"
214 + name="password_expiration_enabled"
198 215 value="true"
199 216 class="checkbox checkbox-primary"
200 217 id="expirationEnabled"
M templates/admin/users_list.html
+43 -77
@@ -251,7 +251,7 @@
251 251 id="newPassword"
252 252 placeholder="{{ reset_new_password_placeholder }}"
253 253 class="input input-bordered w-full"
254 - minlength="8"
254 + minlength="{{ pw_min_length }}"
255 255 >
256 256 <label class="label">
257 257 <span class="label-text-alt text-warning">
@@ -387,97 +387,63 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
387 387 }
388 388
389 389 if (response.ok) {
390 + // Close the reset password dialog first so success UI is visible
391 + document.getElementById('resetPasswordModal').close();
392 + submitBtn.disabled = false;
393 + submitBtn.innerHTML = originalBtnText;
394 +
390 395 if (resetType === 'email') {
391 - // For email reset, show success message from HTML fragment
392 - const htmlFragment = await response.text();
393 - const messageDiv = document.createElement('div');
396 + var htmlFragment = await response.text();
397 + var messageDiv = document.createElement('div');
394 398 messageDiv.innerHTML = htmlFragment;
395 - const message = messageDiv.querySelector('.alert') || messageDiv;
399 + var message = messageDiv.querySelector('.alert') || messageDiv;
396 400
397 - // Show success message
398 - const successModal = document.createElement('div');
401 + var successModal = document.createElement('div');
399 402 successModal.className = 'modal modal-open';
400 - successModal.innerHTML = `
401 - <div class="modal-box">
402 - <h3 class="font-bold text-lg mb-4">{{ reset_by_email_title }}</h3>
403 - <div class="mb-4">${message.innerHTML}</div>
404 - <div class="modal-action">
405 - <button type="button" class="btn btn-primary" onclick="this.closest('.modal').remove(); document.getElementById('resetPasswordModal').close();">
406 - {{ close }}
407 - </button>
408 - </div>
409 - </div>
410 - <form method="dialog" class="modal-backdrop">
411 - <button>close</button>
412 - </form>
413 - `;
403 + successModal.innerHTML = '<div class="modal-box">'
404 + + '<h3 class="font-bold text-lg mb-4">{{ reset_by_email_title }}</h3>'
405 + + '<div class="mb-4">' + message.innerHTML + '</div>'
406 + + '<div class="modal-action">'
407 + + '<button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove();">{{ close }}</button>'
408 + + '</div></div>'
409 + + '<form method="dialog" class="modal-backdrop"><button>close</button></form>';
414 410 document.body.appendChild(successModal);
415 -
416 - submitBtn.disabled = false;
417 - submitBtn.innerHTML = originalBtnText;
418 411 return;
419 412 }
420 413
421 - const result = await response.json();
414 + var result = await response.json();
422 415
423 - // Display the generated password in a modal or alert
424 - if (result.password) {
425 - // Create a password display modal
426 - const passwordDisplay = document.createElement('div');
416 + if (resetType === 'custom') {
417 + alert('{{ password_set_success }}');
418 + window.location.reload();
419 + } else if (result.password) {
420 + var passwordDisplay = document.createElement('div');
427 421 passwordDisplay.className = 'modal modal-open';
428 - passwordDisplay.innerHTML = `
429 - <div class="modal-box">
430 - <h3 class="font-bold text-lg mb-4">✅ {{ password_reset_success_title }}</h3>
431 - <div class="alert alert-warning mb-4">
432 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
433 - <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" />
434 - </svg>
435 - <span>⚠️ {{ password_warning_once }}</span>
436 - </div>
437 - <div class="form-control mb-4">
438 - <label class="label">
439 - <span class="label-text font-semibold">{{ new_password_label }}</span>
440 - </label>
441 - <div class="input-group">
442 - <input
443 - type="text"
444 - id="displayPassword"
445 - value="${result.password}"
446 - class="input input-bordered w-full font-mono text-lg"
447 - readonly
448 - >
449 - <button
450 - type="button"
451 - class="btn btn-primary"
452 - onclick="copyPassword()"
453 - >
454 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
455 - <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" />
456 - </svg>
457 - {{ copy }}
458 - </button>
459 - </div>
460 - </div>
461 - <div class="modal-action">
462 - <button type="button" class="btn btn-primary" onclick="this.closest('.modal').remove(); document.getElementById('resetPasswordModal').close(); window.location.reload();">
463 - {{ close }}
464 - </button>
465 - </div>
466 - </div>
467 - `;
422 + passwordDisplay.innerHTML = '<div class="modal-box">'
423 + + '<h3 class="font-bold text-lg mb-4">{{ password_reset_success_title }}</h3>'
424 + + '<div class="alert alert-warning mb-4">'
425 + + '<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>'
426 + + '<span>{{ password_warning_once }}</span></div>'
427 + + '<div class="form-control mb-4"><label class="label"><span class="label-text font-semibold">{{ new_password_label }}</span></label>'
428 + + '<div class="join"><input type="text" id="displayPassword" value="' + result.password.replace(/"/g, '&quot;') + '" class="input input-bordered join-item w-full font-mono text-lg" readonly>'
429 + + '<button type="button" class="btn btn-primary join-item" onclick="copyPassword()"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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> {{ copy }}</button>'
430 + + '</div></div>'
431 + + '<div class="modal-action"><button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove(); window.location.reload();">{{ close }}</button></div>'
432 + + '</div>';
468 433 document.body.appendChild(passwordDisplay);
469 434
470 - // Copy function
471 435 window.copyPassword = function() {
472 - const passwordInput = document.getElementById('displayPassword');
473 - passwordInput.select();
474 - document.execCommand('copy');
475 - alert('✅ {{ password_copied }}');
436 + var passwordInput = document.getElementById('displayPassword');
437 + navigator.clipboard.writeText(passwordInput.value).then(function() {
438 + alert('{{ password_copied }}');
439 + }).catch(function() {
440 + passwordInput.select();
441 + document.execCommand('copy');
442 + alert('{{ password_copied }}');
443 + });
476 444 };
477 445 } else {
478 - // No password to display (custom password was set)
479 - alert('✅ {{ password_set_success }}');
480 - document.getElementById('resetPasswordModal').close();
446 + alert('{{ password_set_success }}');
481 447 window.location.reload();
482 448 }
483 449 } else {
M templates/base.html
+22 -39
@@ -1,6 +1,20 @@
1 1 <!DOCTYPE html>
2 -<html lang="fr" data-theme="rustwarden">
2 +<html lang="fr" data-theme="light">
3 3 <head>
4 + <script>
5 + (function() {
6 + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
7 + var t = m ? m[1] : 'auto';
8 + if (t === 'dark') {
9 + document.documentElement.setAttribute('data-theme', 'dark');
10 + } else if (t === 'light') {
11 + document.documentElement.setAttribute('data-theme', 'light');
12 + } else {
13 + var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
14 + document.documentElement.setAttribute('data-theme', d);
15 + }
16 + })();
17 + </script>
4 18 <meta charset="UTF-8">
5 19 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 20 <meta name="description" content="{% if app_description != "" %}{{ app_description }}{% else %}RustWarden - web boilerplate solution written in Rust{% endif %}">
@@ -129,52 +143,21 @@
129 143 });
130 144 }
131 145
132 - // Global function to change language (used by login page and navbar)
133 146 window.changeLanguage = function(locale) {
134 - console.log('changeLanguage called with locale:', locale);
135 -
136 147 if (!['fr', 'en', 'de', 'es'].includes(locale)) {
137 - console.error('Invalid locale:', locale);
138 148 return false;
139 149 }
140 150
141 - // Set cookie immediately
142 - document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`; // 1 year
143 - console.log('Cookie set to:', locale);
151 + document.cookie = 'locale=' + locale + '; path=/; max-age=31536000; SameSite=Lax';
144 152
145 - // Try to update preference via API (server will handle authentication)
146 - // If user is authenticated, the API will update the preference
147 - // If not authenticated, we'll just use the cookie
148 - fetch('/api/v1/users/me/language', {
153 + fetch('/settings/preferences', {
149 154 method: 'PUT',
150 - headers: {
151 - 'Content-Type': 'application/json'
152 - },
153 - credentials: 'include', // Include HttpOnly cookies (JWT)
154 - body: JSON.stringify({ language: locale })
155 - }).then(response => {
156 - console.log('API response status:', response.status);
157 - if (response.status === 401) {
158 - // User not authenticated, that's OK - just use cookie
159 - console.log('User not authenticated, using cookie only');
160 - window.location.reload();
161 - return;
162 - }
163 - if (!response.ok) {
164 - return response.json().then(err => {
165 - throw new Error(err.error || 'Failed to update language preference');
166 - });
167 - }
168 - return response.json();
169 - }).then(data => {
170 - if (data) {
171 - console.log('Language preference updated:', data);
172 - }
173 - // Reload page to apply new language
155 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
156 + credentials: 'include',
157 + body: 'language=' + encodeURIComponent(locale)
158 + }).then(function() {
174 159 window.location.reload();
175 - }).catch(err => {
176 - console.error('Failed to update language preference:', err);
177 - // Still reload to apply cookie change
160 + }).catch(function() {
178 161 window.location.reload();
179 162 });
180 163
M templates/home.html
+0 -47
@@ -34,51 +34,4 @@
34 34 {% endfor %}
35 35 </div>
36 36 </div>
37 -
38 -<!-- Upcoming Features -->
39 -<div>
40 - <h2 class="text-xl font-semibold text-base-content mb-4">{{ home_upcoming_features }}</h2>
41 - <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
42 - {% for feature in upcoming_features %}
43 - <div class="card bg-base-100 shadow-md hover:shadow-xl transition-shadow">
44 - <div class="card-body p-6">
45 - <!-- Header -->
46 - <div class="flex items-start justify-between mb-4">
47 - <div class="flex items-center space-x-3">
48 - <div class="flex items-center justify-center w-12 h-12 rounded-lg bg-primary/20">
49 - <span class="text-2xl">
50 - {% if feature.icon == "folder" %}📁
51 - {% elif feature.icon == "upload" %}📤
52 - {% elif feature.icon == "cloud" %}☁️
53 - {% elif feature.icon == "smartphone" %}📱
54 - {% else %}✨{% endif %}
55 - </span>
56 - </div>
57 - <div>
58 - <h3 class="card-title text-lg">{{ feature.name }}</h3>
59 - <span class="badge badge-warning">
60 - {{ feature.badge }}
61 - </span>
62 - </div>
63 - </div>
64 - </div>
65 -
66 - <!-- Description -->
67 - <p class="text-base-content/70 text-sm">{{ feature.description }}</p>
68 -
69 - <!-- Feature ID -->
70 - <div class="mt-4 text-xs text-base-content/40">Feature {{ feature.id }}</div>
71 - </div>
72 - </div>
73 - {% endfor %}
74 - </div>
75 -</div>
76 -
77 -<!-- Quick Actions -->
78 -<div class="mt-12 card bg-info/10 border border-info/20">
79 - <div class="card-body p-6">
80 - <h3 class="card-title text-lg mb-2">💡 {{ home_did_you_know }}</h3>
81 - <p class="text-base-content/80">{{ home_did_you_know_text }}</p>
82 - </div>
83 -</div>
84 37 {% endblock %}
M templates/login.html
+1 -1
@@ -224,7 +224,7 @@
224 224 id="register-password"
225 225 name="password"
226 226 required
227 - minlength="8"
227 + minlength="{{ pw_min_length }}"
228 228 autocomplete="new-password"
229 229 class="input input-bordered w-full"
230 230 placeholder="{{ password_placeholder_register }}">
M templates/settings/password.html
+98 -31
@@ -3,12 +3,24 @@
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 5 {% block navbar %}
6 +{% if !must_change %}
6 7 {% include "components/navbar.html" %}
8 +{% endif %}
7 9 {% endblock %}
8 10
9 11 {% block content %}
12 +{% if must_change %}
13 +<div class="min-h-screen flex items-center justify-center bg-base-200 px-4 py-12">
14 + <div class="w-full max-w-lg">
15 + <div class="text-center mb-8">
16 + <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-warning/20 mb-4">
17 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
18 + </div>
19 + <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
20 + <p class="text-base-content/70 text-lg">{{ must_change_message }}</p>
21 + </div>
22 +{% else %}
10 23 <div class="container mx-auto px-4 py-8">
11 - <!-- Breadcrumb -->
12 24 <div class="text-sm breadcrumbs mb-6">
13 25 <ul>
14 26 <li><a href="/">{{ breadcrumb_home }}</a></li>
@@ -17,19 +29,15 @@
17 29 </ul>
18 30 </div>
19 31
20 - <!-- Page Header -->
21 32 <div class="mb-8">
22 33 <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
34 + {% if password_change_require_email %}
23 35 <p class="text-base-content/70">{{ subtitle }}</p>
36 + {% else %}
37 + <p class="text-base-content/70">{{ subtitle_no_email }}</p>
38 + {% endif %}
24 39 </div>
25 -
26 - <!-- T130: Must change password alert -->
27 - {% if must_change %}
28 - <div class="alert alert-warning mb-6 max-w-2xl">
29 - <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>
30 - <span>{{ must_change_message }}</span>
31 - </div>
32 - {% endif %}
40 +{% endif %}
33 41
34 42 <!-- Feature 026: Pending password change request alert -->
35 43 {% if has_pending_request %}
@@ -93,7 +101,7 @@
93 101 placeholder="{{ new_password_placeholder }}"
94 102 class="input input-bordered w-full"
95 103 required
96 - minlength="8"
104 + minlength="{{ pw_min_length }}"
97 105 autocomplete="new-password"
98 106 >
99 107 <label class="label">
@@ -126,7 +134,7 @@
126 134 placeholder="{{ confirm_password_placeholder }}"
127 135 class="input input-bordered w-full"
128 136 required
129 - minlength="8"
137 + minlength="{{ pw_min_length }}"
130 138 autocomplete="new-password"
131 139 >
132 140 <label class="label">
@@ -145,7 +153,14 @@
145 153
146 154 <!-- Form Actions -->
147 155 <div class="card-actions justify-end">
156 + {% if must_change %}
157 + <a href="/api/v1/auth/logout" class="btn btn-ghost"
158 + onclick="event.preventDefault(); fetch('/api/v1/auth/logout', {method:'POST', credentials:'include', headers:{'Accept':'text/html'}}).then(()=>window.location.href='/login');">
159 + {{ logout_label }}
160 + </a>
161 + {% else %}
148 162 <a href="/settings" class="btn btn-ghost">{{ cancel }}</a>
163 + {% endif %}
149 164 <button type="submit" class="btn btn-error" id="submitBtn" disabled>
150 165 <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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
151 166 {{ change_button }}
@@ -155,7 +170,13 @@
155 170 </div>
156 171 </div>
157 172
173 + {% if must_change %}
174 + </div>
175 +</div>
176 + {% endif %}
177 +
158 178 <!-- Security Tips Card -->
179 + {% if !must_change %}
159 180 <div class="card bg-base-200 shadow-xl max-w-2xl mt-6">
160 181 <div class="card-body">
161 182 <h3 class="card-title text-base">
@@ -171,7 +192,11 @@
171 192 </ul>
172 193 </div>
173 194 </div>
195 + {% endif %}
196 +
197 +{% if !must_change %}
174 198 </div>
199 +{% endif %}
175 200
176 201 <script>
177 202 const newPasswordInput = document.getElementById('newPassword');
@@ -182,24 +207,46 @@ const strengthProgress = document.getElementById('strengthProgress');
182 207 const strengthText = document.getElementById('strengthText');
183 208 const matchIndicator = document.getElementById('matchIndicator');
184 209
185 -// Password strength checker
210 +// Password rules from app_settings (injected by server)
211 +const PW_MIN_LENGTH = {{ pw_min_length }};
212 +const PW_REQ_UPPER = {{ pw_require_uppercase }};
213 +const PW_REQ_LOWER = {{ pw_require_lowercase }};
214 +const PW_REQ_DIGITS = {{ pw_require_digits }};
215 +const PW_REQ_SPECIAL= {{ pw_require_special }};
216 +
186 217 function checkPasswordStrength(password) {
187 218 let strength = 0;
188 219 let feedback = [];
189 -
190 - if (password.length >= 8) strength += 20;
191 - if (password.length >= 12) strength += 10;
192 - if (/[a-z]/.test(password)) strength += 20;
193 - if (/[A-Z]/.test(password)) strength += 20;
194 - if (/[0-9]/.test(password)) strength += 20;
195 - if (/[^a-zA-Z0-9]/.test(password)) strength += 20;
196 -
197 - if (password.length < 8) feedback.push('trop court');
198 - if (!/[a-z]/.test(password)) feedback.push('minuscule manquante');
199 - if (!/[A-Z]/.test(password)) feedback.push('majuscule manquante');
200 - if (!/[0-9]/.test(password)) feedback.push('chiffre manquant');
201 - if (!/[^a-zA-Z0-9]/.test(password)) feedback.push('caractère spécial manquant');
202 -
220 + const totalChecks = 1
221 + + (PW_REQ_UPPER ? 1 : 0)
222 + + (PW_REQ_LOWER ? 1 : 0)
223 + + (PW_REQ_DIGITS ? 1 : 0)
224 + + (PW_REQ_SPECIAL? 1 : 0);
225 + const pointsPerCheck = Math.floor(100 / (totalChecks + 1));
226 +
227 + if (password.length >= PW_MIN_LENGTH) strength += pointsPerCheck;
228 + if (password.length >= PW_MIN_LENGTH + 4) strength += pointsPerCheck;
229 + if (password.length < PW_MIN_LENGTH) feedback.push('{{ strength_too_short }}');
230 +
231 + if (PW_REQ_LOWER) {
232 + if (/[a-z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_lowercase }}');
233 + }
234 + if (PW_REQ_UPPER) {
235 + if (/[A-Z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_uppercase }}');
236 + }
237 + if (PW_REQ_DIGITS) {
238 + if (/[0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_digit }}');
239 + }
240 + if (PW_REQ_SPECIAL) {
241 + if (/[^a-zA-Z0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_special }}');
242 + }
243 +
244 + if (!PW_REQ_LOWER && /[a-z]/.test(password)) strength += 5;
245 + if (!PW_REQ_UPPER && /[A-Z]/.test(password)) strength += 5;
246 + if (!PW_REQ_DIGITS && /[0-9]/.test(password)) strength += 5;
247 + if (!PW_REQ_SPECIAL&& /[^a-zA-Z0-9]/.test(password)) strength += 5;
248 +
249 + strength = Math.min(strength, 100);
203 250 return { strength, feedback };
204 251 }
205 252
@@ -256,10 +303,11 @@ function checkFormValidity() {
256 303 const newPassword = newPasswordInput.value;
257 304 const confirmPassword = confirmPasswordInput.value;
258 305
306 + const { strength, feedback } = checkPasswordStrength(newPassword);
259 307 const isValid = oldPassword.length > 0 &&
260 - newPassword.length >= 8 &&
308 + newPassword.length >= PW_MIN_LENGTH &&
261 309 confirmPassword === newPassword &&
262 - checkPasswordStrength(newPassword).strength >= 60;
310 + feedback.length === 0;
263 311
264 312 submitBtn.disabled = !isValid;
265 313 }
@@ -350,7 +398,8 @@ document.getElementById('passwordForm').addEventListener('submit', async functio
350 398 }
351 399 }
352 400
353 - const savedMsg = "{{ saved }}";
401 + const savedEmailMsg = "{{ saved }}";
402 + const savedDirectMsg = "{{ saved_direct }}";
354 403 const errorAllFields = "{{ error_all_fields_required }}";
355 404 const errorEmpty = "{{ error_empty_data }}";
356 405 const errorInvalid = "{{ error_invalid_data }}";
@@ -360,7 +409,12 @@ document.getElementById('passwordForm').addEventListener('submit', async functio
360 409 const changeBtnText = "{{ change_button }}";
361 410
362 411 if (response.ok) {
363 - alert('✅ ' + savedMsg);
412 + const msgKey = result && result.message;
413 + if (msgKey === 'email_confirmation_sent') {
414 + alert('✅ ' + savedEmailMsg);
415 + } else {
416 + alert('✅ ' + savedDirectMsg);
417 + }
364 418 window.location.href = '/settings';
365 419 } else {
366 420 let errorMessage = 'Échec du changement de mot de passe';
@@ -435,3 +489,16 @@ async function cancelPendingRequest() {
435 489 }
436 490 </script>
437 491 {% endblock %}
492 +
493 +{% block footer %}
494 +{% if !must_change %}
495 +<footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
496 + <div class="container mx-auto px-4 text-center text-base-content text-sm">
497 + <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
498 + {% if app_description != "" %}
499 + <p class="text-base-content/70 mt-1">{{ app_description }}</p>
500 + {% endif %}
501 + </div>
502 +</footer>
503 +{% endif %}
504 +{% endblock %}
M templates/settings/preferences.html
+44 -68
@@ -269,100 +269,76 @@
269 269 </div>
270 270
271 271 <script>
272 -// Form submission
272 +var SAVED_RELOAD_MSG = {{ saved_reload_json|safe }};
273 +var SAVED_SUCCESS_MSG = {{ saved_success_json|safe }};
274 +var ERROR_MSG = {{ error_prefix_json|safe }};
275 +var NETWORK_ERROR_MSG = {{ network_error_json|safe }};
276 +var SAVE_BUTTON_TEXT = {{ save_button_json|safe }};
277 +var SAVE_BUTTON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> ';
278 +
279 +function resetSaveBtn() {
280 + var saveBtn = document.getElementById('saveBtn');
281 + saveBtn.disabled = false;
282 + saveBtn.innerHTML = SAVE_BUTTON_SVG + SAVE_BUTTON_TEXT;
283 +}
284 +
273 285 document.getElementById('preferencesForm').addEventListener('submit', async function(e) {
274 286 e.preventDefault();
275 287
276 - const formData = new FormData(this);
277 - const saveBtn = document.getElementById('saveBtn');
288 + var formData = new FormData(this);
289 + var saveBtn = document.getElementById('saveBtn');
290 + var theme = formData.get('theme');
291 + var language = formData.get('language');
278 292
279 293 saveBtn.disabled = true;
280 - const savingText = "{{ saving }}";
281 - saveBtn.innerHTML = '<span class="loading loading-spinner"></span> ' + savingText;
294 + saveBtn.innerHTML = '<span class="loading loading-spinner"></span> ' + "{{ saving }}";
282 295
283 296 try {
284 - const theme = formData.get('theme');
285 - const language = formData.get('language');
286 -
287 - // Update language preference via API
288 - if (language) {
289 - // Get JWT token from cookie
290 - const getCookie = (name) => {
291 - const value = `; ${document.cookie}`;
292 - const parts = value.split(`; ${name}=`);
293 - if (parts.length === 2) return parts.pop().split(';').shift();
294 - return null;
295 - };
296 -
297 - const jwtToken = getCookie('jwt_token');
298 - const langResponse = await fetch('/api/v1/users/me/language', {
299 - method: 'PUT',
300 - headers: {
301 - 'Content-Type': 'application/json',
302 - ...(jwtToken ? { 'Authorization': 'Bearer ' + jwtToken } : {}),
303 - },
304 - credentials: 'include', // Include cookies
305 - body: JSON.stringify({ language: language })
306 - });
307 -
308 - if (!langResponse.ok) {
309 - const langResult = await langResponse.json();
310 - throw new Error(langResult.error || 'Failed to update language preference');
311 - }
312 -
313 - const langResult = await langResponse.json();
314 - if (langResult.reload_required) {
315 - // Set cookie for immediate effect
316 - document.cookie = `locale=${language}; path=/; max-age=31536000`; // 1 year
317 - }
318 - }
319 -
320 - // Update theme preference via existing endpoint
321 - const response = await fetch('/settings/preferences', {
297 + var params = new URLSearchParams();
298 + if (theme) params.set('theme', theme);
299 + if (language) params.set('language', language);
300 + params.set('csrf_token', formData.get('csrf_token') || '');
301 +
302 + var response = await fetch('/settings/preferences', {
322 303 method: 'PUT',
323 - headers: {
324 - 'Content-Type': 'application/x-www-form-urlencoded',
325 - },
326 - body: new URLSearchParams({ theme: theme || '', csrf_token: formData.get('csrf_token') })
304 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
305 + credentials: 'include',
306 + body: params.toString()
327 307 });
328 308
329 - const result = await response.json();
309 + var result = await response.json();
330 310
331 311 if (response.ok) {
332 - // Apply theme immediately
312 + document.cookie = 'theme=' + (theme || 'auto') + '; path=/; max-age=31536000; SameSite=Lax';
313 +
333 314 if (theme === 'dark') {
334 315 document.documentElement.setAttribute('data-theme', 'dark');
335 316 } else if (theme === 'light') {
336 317 document.documentElement.setAttribute('data-theme', 'light');
337 318 } else {
338 - // Auto theme based on system preference
339 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
319 + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
340 320 document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
341 321 }
342 322
343 - // Show confirmation message with reload option
344 - // Use JSON.parse to properly handle escaped newlines and special characters
345 - const savedReloadMsg = JSON.parse({{ saved_reload_json|safe }});
346 - const savedSuccessMsg = JSON.parse({{ saved_success_json|safe }});
347 - const errorMsg = JSON.parse({{ error_prefix_json|safe }});
348 - const networkErrorMsg = JSON.parse({{ network_error_json|safe }});
349 - const saveButtonText = JSON.parse({{ save_button_json|safe }});
350 -
351 - if (language && confirm('✅ ' + savedReloadMsg)) {
352 - window.location.reload();
323 + if (result.reload_required) {
324 + document.cookie = 'locale=' + language + '; path=/; max-age=31536000; SameSite=Lax';
325 + if (confirm('✅ ' + SAVED_RELOAD_MSG)) {
326 + window.location.reload();
327 + return;
328 + }
353 329 } else {
354 - alert('✅ ' + savedSuccessMsg);
330 + alert('✅ ' + SAVED_SUCCESS_MSG);
355 331 }
332 + resetSaveBtn();
356 333 } else {
357 - alert('❌ ' + errorMsg.replace('{message}', result.error || result.message || 'Échec de l\'enregistrement'));
358 - saveBtn.disabled = false;
359 - saveBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> ' + saveButtonText;
334 + var detail = result.error || result.message || '';
335 + alert('❌ ' + ERROR_MSG.replace('{message}', detail));
336 + resetSaveBtn();
360 337 }
361 338 } catch (error) {
362 339 console.error('Error:', error);
363 - alert('❌ ' + networkErrorMsg);
364 - saveBtn.disabled = false;
365 - saveBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> ' + saveButtonText;
340 + alert('❌ ' + NETWORK_ERROR_MSG);
341 + resetSaveBtn();
366 342 }
367 343 });
368 344