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 | ||
| 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 | ||
| 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(|×tamp| { |
| 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, '"') + '" 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 |
GitRust