page profil

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

2c0d0390ff5dad05ef12184aea5c2dc3b626d1fe

1 parent(s)

33 fichiers modifiés +1477 -368
M locales/de.yml
+35 -0
@@ -229,6 +229,9 @@ admin:
229 229 audit_last_24h: "Letzte 24h"
230 230 audit_by_level: "Nach Stufe"
231 231 chart_label: "Logs nach Stufe"
232 + roles_card_title: "Rollenverwaltung"
233 + roles_card_desc: "Rollen und granulare Berechtigungen verwalten"
234 + roles_stat_title: "Rollen"
232 235 settings_card_title: "Anwendungseinstellungen"
233 236 settings_card_desc: "Globale Anwendungseinstellungen konfigurieren"
234 237 apps_card_title: "Anwendungsverwaltung"
@@ -237,6 +240,38 @@ admin:
237 240 orgs_card_desc: "Organisationen und deren Mitglieder erstellen und verwalten"
238 241 orgs_stat_title: "Organisationen"
239 242 orgs_members_stat_title: "Mitglieder gesamt"
243 + roles_list:
244 + page_title: "Rollenverwaltung"
245 + breadcrumb_roles: "Rollen"
246 + title: "Rollen"
247 + create_role: "Rolle erstellen"
248 + table_name: "Name"
249 + table_description: "Beschreibung"
250 + table_users: "Benutzer"
251 + table_permissions: "Berechtigungen"
252 + table_actions: "Aktionen"
253 + edit: "Bearbeiten"
254 + delete: "Löschen"
255 + no_roles: "Keine Rollen gefunden."
256 + protected_hint: "Geschützt"
257 + delete_confirm_title: "Löschen bestätigen"
258 + delete_confirm_message: "Möchten Sie die Rolle wirklich löschen"
259 + delete_button: "Löschen"
260 + role_form:
261 + title_create: "Rolle erstellen"
262 + title_edit: "Rolle bearbeiten"
263 + breadcrumb_create: "Erstellen"
264 + breadcrumb_edit: "Bearbeiten"
265 + back_to_list: "Zurück zur Liste"
266 + name_label: "Rollenname"
267 + name_placeholder: "z.B. moderator"
268 + description_label: "Beschreibung"
269 + description_placeholder: "Rollenbeschreibung..."
270 + permissions_label: "Berechtigungen"
271 + protected_name_hint: "Der Name dieser Rolle kann nicht geändert werden."
272 + cancel: "Abbrechen"
273 + submit_create: "Rolle erstellen"
274 + submit_edit: "Änderungen speichern"
240 275 errors:
241 276 title: "Fehlercodes"
242 277 description: "Dokumentation der Anwendungsfehlercodes. Jeder Fehlercode ist mit einer Middleware verknüpft und liefert Kontextinformationen für die Fehlersuche."
M locales/en.yml
+35 -0
@@ -234,6 +234,9 @@ admin:
234 234 audit_last_24h: "Last 24h"
235 235 audit_by_level: "By level"
236 236 chart_label: "Logs by level"
237 + roles_card_title: "Roles Management"
238 + roles_card_desc: "Manage roles and granular permissions"
239 + roles_stat_title: "Roles"
237 240 settings_card_title: "Application Settings"
238 241 settings_card_desc: "Configure global application settings"
239 242 apps_card_title: "Application Management"
@@ -242,6 +245,38 @@ admin:
242 245 orgs_card_desc: "Create and manage organisations and their members"
243 246 orgs_stat_title: "Organisations"
244 247 orgs_members_stat_title: "Total members"
248 + roles_list:
249 + page_title: "Roles Management"
250 + breadcrumb_roles: "Roles"
251 + title: "Roles"
252 + create_role: "Create a role"
253 + table_name: "Name"
254 + table_description: "Description"
255 + table_users: "Users"
256 + table_permissions: "Permissions"
257 + table_actions: "Actions"
258 + edit: "Edit"
259 + delete: "Delete"
260 + no_roles: "No roles found."
261 + protected_hint: "Protected"
262 + delete_confirm_title: "Confirm deletion"
263 + delete_confirm_message: "Are you sure you want to delete the role"
264 + delete_button: "Delete"
265 + role_form:
266 + title_create: "Create a role"
267 + title_edit: "Edit role"
268 + breadcrumb_create: "Create"
269 + breadcrumb_edit: "Edit"
270 + back_to_list: "Back to list"
271 + name_label: "Role name"
272 + name_placeholder: "e.g. moderator"
273 + description_label: "Description"
274 + description_placeholder: "Role description..."
275 + permissions_label: "Permissions"
276 + protected_name_hint: "This role name cannot be changed."
277 + cancel: "Cancel"
278 + submit_create: "Create role"
279 + submit_edit: "Save changes"
245 280 errors:
246 281 title: "Error Codes"
247 282 description: "Application error code documentation. Each error code is associated with a middleware and provides context information for debugging."
M locales/es.yml
+35 -0
@@ -229,6 +229,9 @@ admin:
229 229 audit_last_24h: "Últimas 24h"
230 230 audit_by_level: "Por nivel"
231 231 chart_label: "Logs por nivel"
232 + roles_card_title: "Gestión de Roles"
233 + roles_card_desc: "Gestionar roles y permisos granulares"
234 + roles_stat_title: "Roles"
232 235 settings_card_title: "Configuración de la Aplicación"
233 236 settings_card_desc: "Configurar los parámetros globales de la aplicación"
234 237 apps_card_title: "Gestión de Aplicaciones"
@@ -237,6 +240,38 @@ admin:
237 240 orgs_card_desc: "Crear y gestionar organizaciones y sus miembros"
238 241 orgs_stat_title: "Organizaciones"
239 242 orgs_members_stat_title: "Miembros totales"
243 + roles_list:
244 + page_title: "Gestión de roles"
245 + breadcrumb_roles: "Roles"
246 + title: "Roles"
247 + create_role: "Crear un rol"
248 + table_name: "Nombre"
249 + table_description: "Descripción"
250 + table_users: "Usuarios"
251 + table_permissions: "Permisos"
252 + table_actions: "Acciones"
253 + edit: "Editar"
254 + delete: "Eliminar"
255 + no_roles: "No se encontraron roles."
256 + protected_hint: "Protegido"
257 + delete_confirm_title: "Confirmar eliminación"
258 + delete_confirm_message: "¿Está seguro de que desea eliminar el rol"
259 + delete_button: "Eliminar"
260 + role_form:
261 + title_create: "Crear un rol"
262 + title_edit: "Editar rol"
263 + breadcrumb_create: "Crear"
264 + breadcrumb_edit: "Editar"
265 + back_to_list: "Volver a la lista"
266 + name_label: "Nombre del rol"
267 + name_placeholder: "ej: moderator"
268 + description_label: "Descripción"
269 + description_placeholder: "Descripción del rol..."
270 + permissions_label: "Permisos"
271 + protected_name_hint: "El nombre de este rol no se puede cambiar."
272 + cancel: "Cancelar"
273 + submit_create: "Crear rol"
274 + submit_edit: "Guardar cambios"
240 275 errors:
241 276 title: "Códigos de Error"
242 277 description: "Documentación de los códigos de error de la aplicación. Cada código de error está asociado a un middleware y proporciona información de contexto para la depuración."
M locales/fr.yml
+35 -0
@@ -236,6 +236,9 @@ admin:
236 236 audit_last_24h: "Dernières 24h"
237 237 audit_by_level: "Par niveau"
238 238 chart_label: "Logs par niveau"
239 + roles_card_title: "Gestion des Rôles"
240 + roles_card_desc: "Gérer les rôles et les permissions granulaires"
241 + roles_stat_title: "Rôles"
239 242 settings_card_title: "Paramètres Application"
240 243 settings_card_desc: "Configurer les paramètres globaux de l'application"
241 244 apps_card_title: "Gestion des Applications"
@@ -244,6 +247,38 @@ admin:
244 247 orgs_card_desc: "Créer et gérer les organisations et leurs membres"
245 248 orgs_stat_title: "Organisations"
246 249 orgs_members_stat_title: "Membres totaux"
250 + roles_list:
251 + page_title: "Gestion des rôles"
252 + breadcrumb_roles: "Rôles"
253 + title: "Rôles"
254 + create_role: "Créer un rôle"
255 + table_name: "Nom"
256 + table_description: "Description"
257 + table_users: "Utilisateurs"
258 + table_permissions: "Permissions"
259 + table_actions: "Actions"
260 + edit: "Modifier"
261 + delete: "Supprimer"
262 + no_roles: "Aucun rôle trouvé."
263 + protected_hint: "Protégé"
264 + delete_confirm_title: "Confirmer la suppression"
265 + delete_confirm_message: "Êtes-vous sûr de vouloir supprimer le rôle"
266 + delete_button: "Supprimer"
267 + role_form:
268 + title_create: "Créer un rôle"
269 + title_edit: "Modifier le rôle"
270 + breadcrumb_create: "Créer"
271 + breadcrumb_edit: "Modifier"
272 + back_to_list: "Retour à la liste"
273 + name_label: "Nom du rôle"
274 + name_placeholder: "ex: moderator"
275 + description_label: "Description"
276 + description_placeholder: "Description du rôle..."
277 + permissions_label: "Permissions"
278 + protected_name_hint: "Le nom de ce rôle ne peut pas être modifié."
279 + cancel: "Annuler"
280 + submit_create: "Créer le rôle"
281 + submit_edit: "Enregistrer les modifications"
247 282 errors:
248 283 title: "Codes d'Erreur"
249 284 description: "Documentation des codes d'erreur de l'application. Chaque code d'erreur est associé à un middleware et fournit des informations de contexte pour le débogage."
M migrations/src/lib.rs
+2 -0
@@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
2 2
3 3 pub mod m20260305_000001_initial_schema;
4 4 pub mod m20260306_000002_create_app_settings_table;
5 +pub mod m20260309_000003_create_permissions_tables;
5 6
6 7 pub struct Migrator;
7 8
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
11 12 vec![
12 13 Box::new(m20260305_000001_initial_schema::Migration),
13 14 Box::new(m20260306_000002_create_app_settings_table::Migration),
15 + Box::new(m20260309_000003_create_permissions_tables::Migration),
14 16 ]
15 17 }
16 18 }
A migrations/src/m20260309_000003_create_permissions_tables.rs
+179 -0
@@ -0,0 +1,179 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +#[derive(DeriveMigrationName)]
4 +pub struct Migration;
5 +
6 +#[async_trait::async_trait]
7 +impl MigrationTrait for Migration {
8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 + // --- 1. permissions table ---
10 + manager
11 + .create_table(
12 + Table::create()
13 + .table(Permissions::Table)
14 + .if_not_exists()
15 + .col(
16 + ColumnDef::new(Permissions::Id)
17 + .uuid()
18 + .not_null()
19 + .primary_key(),
20 + )
21 + .col(
22 + ColumnDef::new(Permissions::Name)
23 + .string()
24 + .string_len(100)
25 + .not_null()
26 + .unique_key(),
27 + )
28 + .col(
29 + ColumnDef::new(Permissions::Description)
30 + .text()
31 + .null(),
32 + )
33 + .col(
34 + ColumnDef::new(Permissions::Category)
35 + .string()
36 + .string_len(50)
37 + .not_null(),
38 + )
39 + .col(
40 + ColumnDef::new(Permissions::CreatedAt)
41 + .timestamp_with_time_zone()
42 + .not_null(),
43 + )
44 + .to_owned(),
45 + )
46 + .await?;
47 +
48 + // --- 2. role_permissions junction table ---
49 + manager
50 + .create_table(
51 + Table::create()
52 + .table(RolePermissions::Table)
53 + .if_not_exists()
54 + .col(ColumnDef::new(RolePermissions::RoleId).uuid().not_null())
55 + .col(
56 + ColumnDef::new(RolePermissions::PermissionId)
57 + .uuid()
58 + .not_null(),
59 + )
60 + .primary_key(
61 + Index::create()
62 + .col(RolePermissions::RoleId)
63 + .col(RolePermissions::PermissionId),
64 + )
65 + .foreign_key(
66 + ForeignKey::create()
67 + .name("fk_role_permissions_role_id")
68 + .from(RolePermissions::Table, RolePermissions::RoleId)
69 + .to(Roles::Table, Roles::Id)
70 + .on_delete(ForeignKeyAction::Cascade),
71 + )
72 + .foreign_key(
73 + ForeignKey::create()
74 + .name("fk_role_permissions_permission_id")
75 + .from(RolePermissions::Table, RolePermissions::PermissionId)
76 + .to(Permissions::Table, Permissions::Id)
77 + .on_delete(ForeignKeyAction::Cascade),
78 + )
79 + .to_owned(),
80 + )
81 + .await?;
82 +
83 + // --- 3. Indexes ---
84 + manager
85 + .create_index(
86 + Index::create()
87 + .if_not_exists()
88 + .name("idx_permissions_category")
89 + .table(Permissions::Table)
90 + .col(Permissions::Category)
91 + .to_owned(),
92 + )
93 + .await?;
94 +
95 + manager
96 + .create_index(
97 + Index::create()
98 + .if_not_exists()
99 + .name("idx_role_permissions_role_id")
100 + .table(RolePermissions::Table)
101 + .col(RolePermissions::RoleId)
102 + .to_owned(),
103 + )
104 + .await?;
105 +
106 + // --- 4. Seed default permissions ---
107 + manager
108 + .get_connection()
109 + .execute_unprepared(
110 + r#"
111 + INSERT INTO permissions (id, name, description, category, created_at) VALUES
112 + (gen_random_uuid(), 'users.list', 'View the list of users', 'users', NOW()),
113 + (gen_random_uuid(), 'users.create', 'Create new users', 'users', NOW()),
114 + (gen_random_uuid(), 'users.edit', 'Edit existing users', 'users', NOW()),
115 + (gen_random_uuid(), 'users.delete', 'Delete users', 'users', NOW()),
116 + (gen_random_uuid(), 'users.reset_password', 'Reset user passwords', 'users', NOW()),
117 + (gen_random_uuid(), 'roles.list', 'View the list of roles', 'roles', NOW()),
118 + (gen_random_uuid(), 'roles.create', 'Create new roles', 'roles', NOW()),
119 + (gen_random_uuid(), 'roles.edit', 'Edit existing roles', 'roles', NOW()),
120 + (gen_random_uuid(), 'roles.delete', 'Delete roles', 'roles', NOW()),
121 + (gen_random_uuid(), 'settings.view', 'View application settings', 'settings', NOW()),
122 + (gen_random_uuid(), 'settings.edit', 'Edit application settings', 'settings', NOW()),
123 + (gen_random_uuid(), 'audit.view', 'View audit logs', 'audit', NOW())
124 + ON CONFLICT (name) DO NOTHING
125 + "#,
126 + )
127 + .await?;
128 +
129 + // --- 5. Assign all permissions to the admin role ---
130 + manager
131 + .get_connection()
132 + .execute_unprepared(
133 + r#"
134 + INSERT INTO role_permissions (role_id, permission_id)
135 + SELECT r.id, p.id
136 + FROM roles r
137 + CROSS JOIN permissions p
138 + WHERE r.name = 'admin'
139 + ON CONFLICT DO NOTHING
140 + "#,
141 + )
142 + .await?;
143 +
144 + Ok(())
145 + }
146 +
147 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
148 + manager
149 + .drop_table(Table::drop().table(RolePermissions::Table).to_owned())
150 + .await?;
151 + manager
152 + .drop_table(Table::drop().table(Permissions::Table).to_owned())
153 + .await?;
154 + Ok(())
155 + }
156 +}
157 +
158 +#[derive(DeriveIden)]
159 +enum Permissions {
160 + Table,
161 + Id,
162 + Name,
163 + Description,
164 + Category,
165 + CreatedAt,
166 +}
167 +
168 +#[derive(DeriveIden)]
169 +enum RolePermissions {
170 + Table,
171 + RoleId,
172 + PermissionId,
173 +}
174 +
175 +#[derive(DeriveIden)]
176 +enum Roles {
177 + Table,
178 + Id,
179 +}
M src/api/dto/navigation_dto.rs
+4 -23
@@ -194,7 +194,6 @@ impl Default for NavigationDTO {
194 194 Self {
195 195 items: vec![
196 196 NavigationItemDTO::regular("home", "Accueil", "home", "/home"),
197 - NavigationItemDTO::regular("settings", "Paramètres", "settings", "/settings"),
198 197 ],
199 198 secondary_items: vec![],
200 199 has_pending_password_change: false,
@@ -230,12 +229,12 @@ impl NavigationDTO {
230 229 ///
231 230 /// // Non-admin user
232 231 /// let nav = NavigationDTO::for_user(&[], &i18n_service, &locale, None).await;
233 - /// assert_eq!(nav.items.len(), 2);
232 + /// assert_eq!(nav.items.len(), 1);
234 233 ///
235 234 /// // Admin user
236 235 /// let nav = NavigationDTO::for_user(&["admin".to_string()], &i18n_service, &locale, None).await;
237 - /// assert_eq!(nav.items.len(), 3);
238 - /// assert_eq!(nav.items[2].id, "admin");
236 + /// assert_eq!(nav.items.len(), 2);
237 + /// assert_eq!(nav.items[1].id, "admin");
239 238 /// # }
240 239 /// ```
241 240 pub async fn for_user(
@@ -247,7 +246,6 @@ impl NavigationDTO {
247 246 let locale_str = locale.as_str();
248 247
249 248 let home_label = i18n_service.translate("nav.home", locale_str).await;
250 - let settings_label = i18n_service.translate("nav.settings", locale_str).await;
251 249 let admin_label = i18n_service.translate("nav.admin", locale_str).await;
252 250
253 251 let mut items = vec![
@@ -264,19 +262,6 @@ impl NavigationDTO {
264 262 has_disabled_message: false,
265 263 order: 0,
266 264 },
267 - NavigationItemDTO {
268 - id: "settings".to_string(),
269 - label: settings_label,
270 - icon: "settings".to_string(),
271 - url: "/settings".to_string(),
272 - active: false,
273 - enabled: true,
274 - badge: String::new(),
275 - has_badge: false,
276 - disabled_message: String::new(),
277 - has_disabled_message: false,
278 - order: 20,
279 - },
280 265 ];
281 266
282 267 if roles.contains(&"admin".to_string()) {
@@ -365,16 +350,12 @@ mod tests {
365 350 fn test_navigation_dto_default() {
366 351 let nav = NavigationDTO::default();
367 352
368 - assert_eq!(nav.items.len(), 2);
353 + assert_eq!(nav.items.len(), 1);
369 354 assert_eq!(nav.secondary_items.len(), 0);
370 355
371 356 let home = &nav.items[0];
372 357 assert_eq!(home.id, "home");
373 358 assert!(home.enabled);
374 -
375 - let settings = &nav.items[1];
376 - assert_eq!(settings.id, "settings");
377 - assert!(settings.enabled);
378 359 }
379 360
380 361 #[test]
M src/api/handlers/admin_handler.rs
+475 -24
@@ -126,9 +126,6 @@ struct AdminDashboardTemplate {
126 126 active_sessions: u64,
127 127 total_admins: u64,
128 128 last_activity: String,
129 - // Organisation statistics (not used in Rustwarden)
130 - total_organisations: u64,
131 - total_organisation_members: u64,
132 129 // Audit log statistics
133 130 has_audit_logs: bool,
134 131 audit_total_logs: u64,
@@ -140,7 +137,7 @@ struct AdminDashboardTemplate {
140 137 // User menu
141 138 user_menu_profile: String,
142 139 user_menu_logout: String,
143 - user_menu_settings: String,
140 +
144 141 // Translated strings
145 142 breadcrumb_home: String,
146 143 breadcrumb_admin: String,
@@ -159,14 +156,14 @@ struct AdminDashboardTemplate {
159 156 audit_last_24h: String,
160 157 audit_by_level: String,
161 158 chart_label: String,
159 + roles_card_title: String,
160 + roles_card_desc: String,
161 + roles_stat_title: String,
162 + total_roles: u64,
162 163 settings_card_title: String,
163 164 settings_card_desc: String,
164 165 apps_card_title: String,
165 166 apps_card_desc: String,
166 - orgs_card_title: String,
167 - orgs_card_desc: String,
168 - orgs_stat_title: String,
169 - orgs_members_stat_title: String,
170 167 }
171 168
172 169 crate::impl_template_response!(AdminDashboardTemplate);
@@ -208,6 +205,10 @@ pub async fn admin_dashboard(
208 205 0
209 206 };
210 207
208 + use crate::services::role_service::RoleService;
209 + let all_roles_list = RoleService::list_all_roles(&db).await?;
210 + let total_roles = all_roles_list.len() as u64;
211 +
211 212 let template = AdminDashboardTemplate {
212 213 page_title: i18n_service.translate("admin.dashboard.title", locale_str).await,
213 214 csrf_token,
@@ -224,8 +225,6 @@ pub async fn admin_dashboard(
224 225 active_sessions,
225 226 total_admins,
226 227 last_activity: "N/A".to_string(),
227 - total_organisations: 0,
228 - total_organisation_members: 0,
229 228 has_audit_logs: false,
230 229 audit_total_logs: 0,
231 230 audit_logs_last_24h: 0,
@@ -233,9 +232,10 @@ pub async fn admin_dashboard(
233 232 audit_info_count: 0,
234 233 audit_warn_count: 0,
235 234 audit_error_count: 0,
235 + total_roles,
236 236 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
237 237 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
238 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
238 +
239 239 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
240 240 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
241 241 heading: i18n_service.translate("admin.dashboard.heading", locale_str).await,
@@ -253,14 +253,13 @@ pub async fn admin_dashboard(
253 253 audit_last_24h: i18n_service.translate("admin.dashboard.audit_last_24h", locale_str).await,
254 254 audit_by_level: i18n_service.translate("admin.dashboard.audit_by_level", locale_str).await,
255 255 chart_label: i18n_service.translate("admin.dashboard.chart_label", locale_str).await,
256 + roles_card_title: i18n_service.translate("admin.dashboard.roles_card_title", locale_str).await,
257 + roles_card_desc: i18n_service.translate("admin.dashboard.roles_card_desc", locale_str).await,
258 + roles_stat_title: i18n_service.translate("admin.dashboard.roles_stat_title", locale_str).await,
256 259 settings_card_title: i18n_service.translate("admin.dashboard.settings_card_title", locale_str).await,
257 260 settings_card_desc: i18n_service.translate("admin.dashboard.settings_card_desc", locale_str).await,
258 261 apps_card_title: i18n_service.translate("admin.dashboard.apps_card_title", locale_str).await,
259 262 apps_card_desc: i18n_service.translate("admin.dashboard.apps_card_desc", locale_str).await,
260 - orgs_card_title: i18n_service.translate("admin.dashboard.orgs_card_title", locale_str).await,
261 - orgs_card_desc: i18n_service.translate("admin.dashboard.orgs_card_desc", locale_str).await,
262 - orgs_stat_title: i18n_service.translate("admin.dashboard.orgs_stat_title", locale_str).await,
263 - orgs_members_stat_title: i18n_service.translate("admin.dashboard.orgs_members_stat_title", locale_str).await,
264 263 };
265 264
266 265 Ok(template)
@@ -335,7 +334,7 @@ struct UserListTemplate {
335 334 // User menu
336 335 user_menu_profile: String,
337 336 user_menu_logout: String,
338 - user_menu_settings: String,
337 +
339 338 // Translated strings
340 339 title: String,
341 340 breadcrumb_home: String,
@@ -421,7 +420,7 @@ struct UserFormTemplate {
421 420 must_change_password: bool,
422 421 user_menu_profile: String,
423 422 user_menu_logout: String,
424 - user_menu_settings: String,
423 +
425 424 // Translated strings
426 425 breadcrumb_home: String,
427 426 breadcrumb_admin: String,
@@ -634,7 +633,7 @@ pub async fn list_users(
634 633 cancel: i18n_service.translate("common.cancel", locale_str).await,
635 634 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
636 635 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
637 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
636 +
638 637 resetting: i18n_service.translate("admin.users_list.resetting", locale_str).await,
639 638 reset_by_email_title: i18n_service.translate("admin.users_list.reset_by_email_title", locale_str).await,
640 639 close: i18n_service.translate("admin.users_list.close", locale_str).await,
@@ -699,7 +698,7 @@ pub async fn create_user_form(
699 698 must_change_password: false,
700 699 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
701 700 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
702 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
701 +
703 702 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
704 703 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
705 704 breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
@@ -854,7 +853,7 @@ pub async fn edit_user_form(
854 853 must_change_password: user.must_change_password,
855 854 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
856 855 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
857 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
856 +
858 857 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
859 858 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
860 859 breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
@@ -1095,7 +1094,7 @@ struct AdminSettingsTemplate {
1095 1094 settings: AppSettingsTemplateContext,
1096 1095 user_menu_profile: String,
1097 1096 user_menu_logout: String,
1098 - user_menu_settings: String,
1097 +
1099 1098 // Translated strings
1100 1099 title: String,
1101 1100 subtitle: String,
@@ -1254,7 +1253,7 @@ pub async fn get_app_settings(
1254 1253 breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await,
1255 1254 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1256 1255 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1257 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
1256 +
1258 1257 password_security: i18n_service.translate("admin.settings.password_security", locale_str).await,
1259 1258 password_min_length: i18n_service.translate("admin.settings.password_min_length", locale_str).await,
1260 1259 password_min_length_hint: i18n_service.translate("admin.settings.password_min_length_hint", locale_str).await,
@@ -1367,6 +1366,458 @@ pub async fn update_app_settings(
1367 1366 }
1368 1367
1369 1368 // ============================================================================
1369 +// Admin Roles CRUD
1370 +// ============================================================================
1371 +
1372 +/// Permission display info for role form checkboxes
1373 +#[derive(Debug, Clone)]
1374 +struct PermissionDisplay {
1375 + id: String,
1376 + name: String,
1377 + description: String,
1378 + is_selected: bool,
1379 +}
1380 +
1381 +/// Grouped permissions by category for the form
1382 +#[derive(Debug, Clone)]
1383 +struct PermissionGroup {
1384 + category: String,
1385 + permissions: Vec<PermissionDisplay>,
1386 +}
1387 +
1388 +/// Role info for the list table
1389 +#[derive(Debug, Clone)]
1390 +struct RoleInfoDisplay {
1391 + id: String,
1392 + name: String,
1393 + description: String,
1394 + user_count: u64,
1395 + permission_count: usize,
1396 + is_protected: bool,
1397 +}
1398 +
1399 +/// Role list template
1400 +#[derive(Template)]
1401 +#[template(path = "admin/roles_list.html")]
1402 +#[allow(dead_code)]
1403 +struct RolesListTemplate {
1404 + page_title: String,
1405 + csrf_token: String,
1406 + app_name: String,
1407 + app_version: String,
1408 + app_domain: String,
1409 + app_description: String,
1410 + username: String,
1411 + email: String,
1412 + initials: String,
1413 + avatar_color: String,
1414 + roles_text: String,
1415 + roles: Vec<RoleInfoDisplay>,
1416 + user_menu_profile: String,
1417 + user_menu_logout: String,
1418 +
1419 + breadcrumb_home: String,
1420 + breadcrumb_admin: String,
1421 + breadcrumb_roles: String,
1422 + title: String,
1423 + create_role: String,
1424 + table_name: String,
1425 + table_description: String,
1426 + table_users: String,
1427 + table_permissions: String,
1428 + table_actions: String,
1429 + edit: String,
1430 + delete_label: String,
1431 + no_roles: String,
1432 + protected_hint: String,
1433 + delete_confirm_title: String,
1434 + delete_confirm_message: String,
1435 + delete_button: String,
1436 + cancel: String,
1437 +}
1438 +
1439 +impl_template_response!(RolesListTemplate);
1440 +
1441 +/// Role form template (create/edit)
1442 +#[derive(Template)]
1443 +#[template(path = "admin/role_form.html")]
1444 +#[allow(dead_code)]
1445 +struct RoleFormTemplate {
1446 + page_title: String,
1447 + csrf_token: String,
1448 + app_name: String,
1449 + app_version: String,
1450 + app_domain: String,
1451 + app_description: String,
1452 + username: String,
1453 + email: String,
1454 + initials: String,
1455 + avatar_color: String,
1456 + roles_text: String,
1457 + mode: String,
1458 + role_id: String,
1459 + form_name: String,
1460 + form_description: String,
1461 + is_protected: bool,
1462 + permission_groups: Vec<PermissionGroup>,
1463 + user_menu_profile: String,
1464 + user_menu_logout: String,
1465 +
1466 + breadcrumb_home: String,
1467 + breadcrumb_admin: String,
1468 + breadcrumb_roles: String,
1469 + breadcrumb_mode: String,
1470 + form_title: String,
1471 + back_to_list: String,
1472 + name_label: String,
1473 + name_placeholder: String,
1474 + description_label: String,
1475 + description_placeholder: String,
1476 + permissions_label: String,
1477 + protected_name_hint: String,
1478 + cancel: String,
1479 + submit_label: String,
1480 +}
1481 +
1482 +impl_template_response!(RoleFormTemplate);
1483 +
1484 +/// GET /admin/roles - List all roles
1485 +pub async fn list_roles(
1486 + Extension(claims): Extension<Claims>,
1487 + Extension(i18n_service): Extension<Arc<I18nService>>,
1488 + Extension(locale): Extension<Locale>,
1489 + State(db): State<DatabaseConnection>,
1490 + session: Session,
1491 +) -> Result<impl IntoResponse, AppError> {
1492 + let locale_str = locale.as_str();
1493 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
1494 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
1495 + let (app_domain, app_description) = load_app_domain_and_description(&db).await?;
1496 +
1497 + use crate::services::permission_service::PermissionService;
1498 + use crate::services::role_service::RoleService;
1499 +
1500 + let all_roles = RoleService::list_all_roles(&db).await?;
1501 + let mut roles_display = Vec::new();
1502 +
1503 + for r in &all_roles {
1504 + let user_count = RoleService::count_users_for_role(&db, r.id).await?;
1505 + let perms = PermissionService::get_permissions_for_role(&db, r.id).await?;
1506 + let is_protected = r.name == "admin" || r.name == "user";
1507 + roles_display.push(RoleInfoDisplay {
1508 + id: r.id.to_string(),
1509 + name: r.name.clone(),
1510 + description: r.description.clone().unwrap_or_default(),
1511 + user_count,
1512 + permission_count: perms.len(),
1513 + is_protected,
1514 + });
1515 + }
1516 +
1517 + let template = RolesListTemplate {
1518 + page_title: i18n_service.translate("admin.roles_list.page_title", locale_str).await,
1519 + csrf_token,
1520 + app_name: base_context.app_name,
1521 + app_version: base_context.app_version,
1522 + app_domain,
1523 + app_description,
1524 + username: base_context.username,
1525 + email: base_context.email,
1526 + initials: base_context.initials,
1527 + avatar_color: base_context.avatar_color,
1528 + roles_text: base_context.roles.join(", "),
1529 + roles: roles_display,
1530 + user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1531 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1532 +
1533 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1534 + breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1535 + breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await,
1536 + title: i18n_service.translate("admin.roles_list.title", locale_str).await,
1537 + create_role: i18n_service.translate("admin.roles_list.create_role", locale_str).await,
1538 + table_name: i18n_service.translate("admin.roles_list.table_name", locale_str).await,
1539 + table_description: i18n_service.translate("admin.roles_list.table_description", locale_str).await,
1540 + table_users: i18n_service.translate("admin.roles_list.table_users", locale_str).await,
1541 + table_permissions: i18n_service.translate("admin.roles_list.table_permissions", locale_str).await,
1542 + table_actions: i18n_service.translate("admin.roles_list.table_actions", locale_str).await,
1543 + edit: i18n_service.translate("admin.roles_list.edit", locale_str).await,
1544 + delete_label: i18n_service.translate("admin.roles_list.delete", locale_str).await,
1545 + no_roles: i18n_service.translate("admin.roles_list.no_roles", locale_str).await,
1546 + protected_hint: i18n_service.translate("admin.roles_list.protected_hint", locale_str).await,
1547 + delete_confirm_title: i18n_service.translate("admin.roles_list.delete_confirm_title", locale_str).await,
1548 + delete_confirm_message: i18n_service.translate("admin.roles_list.delete_confirm_message", locale_str).await,
1549 + delete_button: i18n_service.translate("admin.roles_list.delete_button", locale_str).await,
1550 + cancel: i18n_service.translate("common.cancel", locale_str).await,
1551 + };
1552 +
1553 + Ok(template)
1554 +}
1555 +
1556 +/// Build permission groups from DB for the role form
1557 +async fn build_permission_groups(
1558 + db: &DatabaseConnection,
1559 + selected_ids: &[uuid::Uuid],
1560 +) -> Result<Vec<PermissionGroup>, AppError> {
1561 + use crate::services::permission_service::PermissionService;
1562 +
1563 + let all_perms = PermissionService::list_all(db).await?;
1564 + let mut groups: std::collections::BTreeMap<String, Vec<PermissionDisplay>> =
1565 + std::collections::BTreeMap::new();
1566 +
1567 + for p in all_perms {
1568 + let display = PermissionDisplay {
1569 + id: p.id.to_string(),
1570 + name: p.name.clone(),
1571 + description: p.description.clone().unwrap_or_default(),
1572 + is_selected: selected_ids.contains(&p.id),
1573 + };
1574 + groups.entry(p.category.clone()).or_default().push(display);
1575 + }
1576 +
1577 + Ok(groups
1578 + .into_iter()
1579 + .map(|(category, permissions)| PermissionGroup {
1580 + category,
1581 + permissions,
1582 + })
1583 + .collect())
1584 +}
1585 +
1586 +/// GET /admin/roles/create - Role creation form
1587 +pub async fn create_role_form(
1588 + Extension(claims): Extension<Claims>,
1589 + Extension(i18n_service): Extension<Arc<I18nService>>,
1590 + Extension(locale): Extension<Locale>,
1591 + State(db): State<DatabaseConnection>,
1592 + session: Session,
1593 +) -> Result<impl IntoResponse, AppError> {
1594 + let locale_str = locale.as_str();
1595 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
1596 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
1597 + let (app_domain, app_description) = load_app_domain_and_description(&db).await?;
1598 +
1599 + let permission_groups = build_permission_groups(&db, &[]).await?;
1600 +
1601 + let template = RoleFormTemplate {
1602 + page_title: i18n_service.translate("admin.role_form.title_create", locale_str).await,
1603 + csrf_token,
1604 + app_name: base_context.app_name,
1605 + app_version: base_context.app_version,
1606 + app_domain,
1607 + app_description,
1608 + username: base_context.username,
1609 + email: base_context.email,
1610 + initials: base_context.initials,
1611 + avatar_color: base_context.avatar_color,
1612 + roles_text: base_context.roles.join(", "),
1613 + mode: "create".to_string(),
1614 + role_id: String::new(),
1615 + form_name: String::new(),
1616 + form_description: String::new(),
1617 + is_protected: false,
1618 + permission_groups,
1619 + user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1620 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1621 +
1622 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1623 + breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1624 + breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await,
1625 + breadcrumb_mode: i18n_service.translate("admin.role_form.breadcrumb_create", locale_str).await,
1626 + form_title: i18n_service.translate("admin.role_form.title_create", locale_str).await,
1627 + back_to_list: i18n_service.translate("admin.role_form.back_to_list", locale_str).await,
1628 + name_label: i18n_service.translate("admin.role_form.name_label", locale_str).await,
1629 + name_placeholder: i18n_service.translate("admin.role_form.name_placeholder", locale_str).await,
1630 + description_label: i18n_service.translate("admin.role_form.description_label", locale_str).await,
1631 + description_placeholder: i18n_service.translate("admin.role_form.description_placeholder", locale_str).await,
1632 + permissions_label: i18n_service.translate("admin.role_form.permissions_label", locale_str).await,
1633 + protected_name_hint: i18n_service.translate("admin.role_form.protected_name_hint", locale_str).await,
1634 + cancel: i18n_service.translate("admin.role_form.cancel", locale_str).await,
1635 + submit_label: i18n_service.translate("admin.role_form.submit_create", locale_str).await,
1636 + };
1637 +
1638 + Ok(template)
1639 +}
1640 +
1641 +/// Form data for role create/update
1642 +#[derive(Debug, serde::Deserialize)]
1643 +pub struct RoleFormData {
1644 + pub name: String,
1645 + pub description: Option<String>,
1646 + #[serde(default)]
1647 + pub permissions: Vec<String>,
1648 +}
1649 +
1650 +/// POST /admin/roles - Create a new role
1651 +pub async fn create_role(
1652 + Extension(claims): Extension<Claims>,
1653 + State(db): State<DatabaseConnection>,
1654 + _session: Session,
1655 + axum::Form(form_data): axum::Form<RoleFormData>,
1656 +) -> Result<impl IntoResponse, AppError> {
1657 + use crate::services::permission_service::PermissionService;
1658 + use crate::services::role_service::RoleService;
1659 +
1660 + if form_data.name.is_empty() {
1661 + return Ok(FormMessageTemplate {
1662 + message_type: "error".to_string(),
1663 + message: "Role name is required".to_string(),
1664 + }
1665 + .into_response());
1666 + }
1667 +
1668 + if RoleService::get_role_by_name(&db, &form_data.name).await?.is_some() {
1669 + return Ok(FormMessageTemplate {
1670 + message_type: "error".to_string(),
1671 + message: format!("Role '{}' already exists", form_data.name),
1672 + }
1673 + .into_response());
1674 + }
1675 +
1676 + let role = RoleService::create_role(&db, form_data.name, form_data.description).await?;
1677 +
1678 + let perm_ids: Vec<uuid::Uuid> = form_data
1679 + .permissions
1680 + .iter()
1681 + .filter_map(|s| s.parse().ok())
1682 + .collect();
1683 + PermissionService::set_role_permissions(&db, role.id, perm_ids).await?;
1684 +
1685 + tracing::info!("Admin {} created role {}", claims.sub, role.name);
1686 +
1687 + let mut response = FormMessageTemplate {
1688 + message_type: "success".to_string(),
1689 + message: "Role created successfully.".to_string(),
1690 + }
1691 + .into_response();
1692 + response.headers_mut().insert(
1693 + axum::http::HeaderName::from_static("hx-redirect"),
1694 + axum::http::HeaderValue::from_static("/admin/roles"),
1695 + );
1696 + Ok(response)
1697 +}
1698 +
1699 +/// GET /admin/roles/:id - Edit role form
1700 +pub async fn edit_role_form(
1701 + Extension(claims): Extension<Claims>,
1702 + Extension(i18n_service): Extension<Arc<I18nService>>,
1703 + Extension(locale): Extension<Locale>,
1704 + State(db): State<DatabaseConnection>,
1705 + session: Session,
1706 + axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
1707 +) -> Result<impl IntoResponse, AppError> {
1708 + let locale_str = locale.as_str();
1709 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
1710 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
1711 + let (app_domain, app_description) = load_app_domain_and_description(&db).await?;
1712 +
1713 + use crate::services::permission_service::PermissionService;
1714 + use crate::services::role_service::RoleService;
1715 +
1716 + let role = RoleService::get_role_by_id(&db, role_id)
1717 + .await?
1718 + .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;
1719 +
1720 + let role_perms = PermissionService::get_permissions_for_role(&db, role_id).await?;
1721 + let selected_ids: Vec<uuid::Uuid> = role_perms.iter().map(|p| p.id).collect();
1722 + let permission_groups = build_permission_groups(&db, &selected_ids).await?;
1723 + let is_protected = role.name == "admin" || role.name == "user";
1724 +
1725 + let template = RoleFormTemplate {
1726 + page_title: format!(
1727 + "{} {}",
1728 + i18n_service.translate("admin.role_form.title_edit", locale_str).await,
1729 + role.name
1730 + ),
1731 + csrf_token,
1732 + app_name: base_context.app_name,
1733 + app_version: base_context.app_version,
1734 + app_domain,
1735 + app_description,
1736 + username: base_context.username,
1737 + email: base_context.email,
1738 + initials: base_context.initials,
1739 + avatar_color: base_context.avatar_color,
1740 + roles_text: base_context.roles.join(", "),
1741 + mode: "edit".to_string(),
1742 + role_id: role_id.to_string(),
1743 + form_name: role.name,
1744 + form_description: role.description.unwrap_or_default(),
1745 + is_protected,
1746 + permission_groups,
1747 + user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1748 + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1749 +
1750 + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1751 + breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1752 + breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await,
1753 + breadcrumb_mode: i18n_service.translate("admin.role_form.breadcrumb_edit", locale_str).await,
1754 + form_title: i18n_service.translate("admin.role_form.title_edit", locale_str).await,
1755 + back_to_list: i18n_service.translate("admin.role_form.back_to_list", locale_str).await,
1756 + name_label: i18n_service.translate("admin.role_form.name_label", locale_str).await,
1757 + name_placeholder: i18n_service.translate("admin.role_form.name_placeholder", locale_str).await,
1758 + description_label: i18n_service.translate("admin.role_form.description_label", locale_str).await,
1759 + description_placeholder: i18n_service.translate("admin.role_form.description_placeholder", locale_str).await,
1760 + permissions_label: i18n_service.translate("admin.role_form.permissions_label", locale_str).await,
1761 + protected_name_hint: i18n_service.translate("admin.role_form.protected_name_hint", locale_str).await,
1762 + cancel: i18n_service.translate("admin.role_form.cancel", locale_str).await,
1763 + submit_label: i18n_service.translate("admin.role_form.submit_edit", locale_str).await,
1764 + };
1765 +
1766 + Ok(template)
1767 +}
1768 +
1769 +/// PUT /admin/roles/:id - Update a role
1770 +pub async fn update_role(
1771 + Extension(claims): Extension<Claims>,
1772 + State(db): State<DatabaseConnection>,
1773 + _session: Session,
1774 + axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
1775 + axum::Form(form_data): axum::Form<RoleFormData>,
1776 +) -> Result<impl IntoResponse, AppError> {
1777 + use crate::services::permission_service::PermissionService;
1778 + use crate::services::role_service::RoleService;
1779 +
1780 + let existing = RoleService::get_role_by_id(&db, role_id)
1781 + .await?
1782 + .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;
1783 +
1784 + let is_protected = existing.name == "admin" || existing.name == "user";
1785 +
1786 + let name_to_set = if is_protected {
1787 + None
1788 + } else {
1789 + Some(form_data.name)
1790 + };
1791 +
1792 + RoleService::update_role(&db, role_id, name_to_set, form_data.description).await?;
1793 +
1794 + let perm_ids: Vec<uuid::Uuid> = form_data
1795 + .permissions
1796 + .iter()
1797 + .filter_map(|s| s.parse().ok())
1798 + .collect();
1799 + PermissionService::set_role_permissions(&db, role_id, perm_ids).await?;
1800 +
1801 + tracing::info!("Admin {} updated role {}", claims.sub, existing.name);
1802 + Ok(axum::response::Redirect::to("/admin/roles"))
1803 +}
1804 +
1805 +/// DELETE /admin/roles/:id - Delete a role
1806 +pub async fn delete_role(
1807 + Extension(claims): Extension<Claims>,
1808 + State(db): State<DatabaseConnection>,
1809 + _session: Session,
1810 + axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
1811 +) -> Result<impl IntoResponse, AppError> {
1812 + use crate::services::role_service::RoleService;
1813 +
1814 + RoleService::delete_role(&db, role_id).await?;
1815 +
1816 + tracing::info!("Admin {} deleted role {}", claims.sub, role_id);
1817 + Ok(axum::response::Redirect::to("/admin/roles"))
1818 +}
1819 +
1820 +// ============================================================================
1370 1821 // Admin Errors Page
1371 1822 // ============================================================================
1372 1823
@@ -1401,7 +1852,7 @@ struct AdminErrorsTemplate {
1401 1852 // User menu
1402 1853 user_menu_profile: String,
1403 1854 user_menu_logout: String,
1404 - user_menu_settings: String,
1855 +
1405 1856 // Translated strings
1406 1857 breadcrumb_home: String,
1407 1858 breadcrumb_admin: String,
@@ -1491,7 +1942,7 @@ pub async fn admin_errors(
1491 1942 error_codes,
1492 1943 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1493 1944 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1494 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
1945 +
1495 1946 breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
1496 1947 breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
1497 1948 breadcrumb_errors: i18n_service.translate("admin.errors.breadcrumb_errors", locale_str).await,
M src/api/handlers/home_handler.rs
+2 -5
@@ -44,7 +44,7 @@ struct HomeTemplate {
44 44 account_stats: Vec<AccountStatDTO>,
45 45 user_menu_profile: String,
46 46 user_menu_logout: String,
47 - user_menu_settings: String,
47 +
48 48 }
49 49
50 50 /// Simplified navigation item for templates
@@ -123,9 +123,7 @@ pub async fn home(
123 123 let user_menu_logout = i18n_service
124 124 .translate("user_menu.logout", locale_str)
125 125 .await;
126 - let user_menu_settings = i18n_service
127 - .translate("user_menu.settings", locale_str)
128 - .await;
126 +
129 127
130 128 let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
131 129 let nav_items: Vec<NavItem> = navigation
@@ -178,7 +176,6 @@ pub async fn home(
178 176 account_stats,
179 177 user_menu_profile,
180 178 user_menu_logout,
181 - user_menu_settings,
182 179 };
183 180
184 181 Ok(template)
M src/api/handlers/navigation_handler.rs
+0 -8
@@ -34,14 +34,6 @@ pub async fn get_navigation() -> impl IntoResponse {
34 34 parent_id: None,
35 35 },
36 36 NavigationItemDto {
37 - id: "settings".to_string(),
38 - name: "Paramètres".to_string(),
39 - icon: "settings".to_string(),
40 - route: "/settings".to_string(),
41 - order: 10,
42 - parent_id: None,
43 - },
44 - NavigationItemDto {
45 37 id: "admin".to_string(),
46 38 name: "Administration".to_string(),
47 39 icon: "shield".to_string(),
M src/api/handlers/settings_handler.rs
+8 -141
@@ -95,139 +95,6 @@ fn build_nav_items_from_dto(navigation: &NavigationDTO, active_id: &str) -> Vec<
95 95 .collect()
96 96 }
97 97
98 -/// Settings page template
99 -#[derive(Template)]
100 -#[template(path = "settings.html")]
101 -#[allow(dead_code)]
102 -struct SettingsTemplate {
103 - page_title: String,
104 - csrf_token: String,
105 - app_name: String,
106 - app_version: String,
107 - app_domain: String,
108 - app_description: String,
109 - username: String,
110 - email: String,
111 - initials: String,
112 - avatar_color: String,
113 - roles_text: String,
114 - nav_items: Vec<NavItem>,
115 - has_pending_password_change: bool,
116 - user_menu_profile: String,
117 - user_menu_logout: String,
118 - user_menu_settings: String,
119 - settings_title: String,
120 - settings_subtitle: String,
121 - profile_title: String,
122 - profile_desc: String,
123 - password_title: String,
124 - password_desc: String,
125 - sessions_title: String,
126 - sessions_desc: String,
127 - preferences_title: String,
128 - preferences_desc: String,
129 - access_button: String,
130 - info_title: String,
131 - info_desc: String,
132 - breadcrumb_home: String,
133 - breadcrumb_settings: String,
134 -}
135 -
136 -crate::impl_template_response!(SettingsTemplate);
137 -
138 -/// Handler for GET /settings - Settings page (PROTECTED)
139 -pub async fn settings(
140 - Extension(claims): Extension<Claims>,
141 - Extension(i18n_service): Extension<Arc<I18nService>>,
142 - Extension(locale): Extension<Locale>,
143 - State(db): State<DatabaseConnection>,
144 -) -> Result<impl IntoResponse, AppError> {
145 - let username = claims.username.clone();
146 - tracing::debug!("Rendering settings page for user: {}", username);
147 -
148 - let user_model = UserService::get_user_by_username(&db, &username)
149 - .await?
150 - .ok_or_else(|| {
151 - tracing::warn!("User {} from session not found in database", username);
152 - AppError::NotFound("User not found".to_string())
153 - })?;
154 -
155 - let roles = UserService::get_user_roles(&db, user_model.id).await?;
156 - let user = UserProfileDTO::new(user_model, roles.clone());
157 -
158 - let locale_str = locale.as_str();
159 -
160 - let user_menu_profile = i18n_service.translate("user_menu.profile", locale_str).await;
161 - let user_menu_logout = i18n_service.translate("user_menu.logout", locale_str).await;
162 - let user_menu_settings = i18n_service.translate("user_menu.settings", locale_str).await;
163 -
164 - let settings_page_title = i18n_service.translate("settings.page_title", locale_str).await;
165 - let settings_title = i18n_service.translate("settings.title", locale_str).await;
166 - let settings_subtitle = i18n_service.translate("settings.subtitle", locale_str).await;
167 - let profile_title = i18n_service.translate("settings.profile_title", locale_str).await;
168 - let profile_desc = i18n_service.translate("settings.profile_desc", locale_str).await;
169 - let password_title = i18n_service.translate("settings.password_title", locale_str).await;
170 - let password_desc = i18n_service.translate("settings.password_desc", locale_str).await;
171 - let sessions_title = i18n_service.translate("settings.sessions_title", locale_str).await;
172 - let sessions_desc = i18n_service.translate("settings.sessions_desc", locale_str).await;
173 - let preferences_title = i18n_service.translate("settings.preferences_title", locale_str).await;
174 - let preferences_desc = i18n_service.translate("settings.preferences_desc", locale_str).await;
175 - let access_button = i18n_service.translate("settings.access", locale_str).await;
176 - let info_title = i18n_service.translate("settings.info_title", locale_str).await;
177 - let info_desc = i18n_service.translate("settings.info_desc", locale_str).await;
178 - let breadcrumb_home = i18n_service.translate("breadcrumb.home", locale_str).await;
179 - let breadcrumb_settings = i18n_service.translate("breadcrumb.settings", locale_str).await;
180 -
181 - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
182 - let nav_items = build_nav_items_from_dto(&navigation, "settings");
183 -
184 - let layout = crate::templates::LayoutContext::new("", "");
185 -
186 - let app_domain = AppSettingsService::get_setting(&db, "app_domain")
187 - .await?
188 - .map(|s| s.value)
189 - .unwrap_or_else(|| "localhost".to_string());
190 - let app_description = AppSettingsService::get_setting(&db, "app_description")
191 - .await?
192 - .map(|s| s.value)
193 - .unwrap_or_else(|| "Secure password manager".to_string());
194 -
195 - let template = SettingsTemplate {
196 - page_title: settings_page_title,
197 - csrf_token: layout.csrf_token.clone(),
198 - app_name: layout.app_name.clone(),
199 - app_version: layout.app_version.clone(),
200 - app_domain,
201 - app_description,
202 - username: user.username.clone(),
203 - email: user.email.clone(),
204 - initials: user.initials.clone(),
205 - avatar_color: user.avatar_color.clone(),
206 - roles_text: user.roles.join(", "),
207 - nav_items,
208 - has_pending_password_change: false,
209 - user_menu_profile,
210 - user_menu_logout,
211 - user_menu_settings,
212 - settings_title,
213 - settings_subtitle,
214 - profile_title,
215 - profile_desc,
216 - password_title,
217 - password_desc,
218 - sessions_title,
219 - sessions_desc,
220 - preferences_title,
221 - preferences_desc,
222 - access_button,
223 - info_title,
224 - info_desc,
225 - breadcrumb_home,
226 - breadcrumb_settings,
227 - };
228 -
229 - Ok(template)
230 -}
231 98
232 99 /// Handler for PUT /settings/profile - Update user profile (PROTECTED)
233 100 pub async fn update_profile(
@@ -745,7 +612,7 @@ struct ProfileTemplate {
745 612 has_pending_password_change: bool,
746 613 user_menu_profile: String,
747 614 user_menu_logout: String,
748 - user_menu_settings: String,
615 +
749 616 // Translated strings (hardcoded FR)
750 617 title: String,
751 618 subtitle: String,
@@ -797,7 +664,7 @@ struct PasswordTemplate {
797 664 nav_items: Vec<NavItem>,
798 665 user_menu_profile: String,
799 666 user_menu_logout: String,
800 - user_menu_settings: String,
667 +
801 668 must_change: bool,
802 669 must_change_message: String,
803 670 has_pending_request: bool,
@@ -896,7 +763,7 @@ struct SessionsTemplate {
896 763 has_pending_password_change: bool,
897 764 user_menu_profile: String,
898 765 user_menu_logout: String,
899 - user_menu_settings: String,
766 +
900 767 // Translated strings (hardcoded FR)
901 768 title: String,
902 769 subtitle: String,
@@ -960,7 +827,7 @@ struct PreferencesTemplate {
960 827 has_pending_password_change: bool,
961 828 user_menu_profile: String,
962 829 user_menu_logout: String,
963 - user_menu_settings: String,
830 +
964 831 // Translated strings (hardcoded FR)
965 832 title: String,
966 833 subtitle: String,
@@ -1061,7 +928,7 @@ pub async fn get_profile_page(
1061 928 has_pending_password_change: false,
1062 929 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1063 930 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1064 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
931 +
1065 932 title: i18n_service.translate("profile.title", locale_str).await,
1066 933 subtitle: i18n_service.translate("profile.subtitle", locale_str).await,
1067 934 account_info: i18n_service.translate("profile.account_info", locale_str).await,
@@ -1169,7 +1036,7 @@ pub async fn get_password_page(
1169 1036 nav_items,
1170 1037 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1171 1038 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1172 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
1039 +
1173 1040 must_change,
1174 1041 must_change_message,
1175 1042 has_pending_request,
@@ -1322,7 +1189,7 @@ pub async fn get_sessions_page(
1322 1189 has_pending_password_change: false,
1323 1190 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1324 1191 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1325 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
1192 +
1326 1193 title: i18n_service.translate("sessions.title", locale_str).await,
1327 1194 subtitle: i18n_service.translate("sessions.subtitle", locale_str).await,
1328 1195 current_session: i18n_service.translate("sessions.current_session", locale_str).await,
@@ -1441,7 +1308,7 @@ pub async fn get_preferences_page(
1441 1308 has_pending_password_change: false,
1442 1309 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1443 1310 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
1444 - user_menu_settings: i18n_service.translate("user_menu.settings", locale_str).await,
1311 +
1445 1312 title: i18n_service.translate("preferences.title", locale_str).await,
1446 1313 subtitle: i18n_service.translate("preferences.subtitle", locale_str).await,
1447 1314 theme_label: i18n_service.translate("preferences.theme", locale_str).await,
M src/api/middleware/auth_service.rs
+1 -2
@@ -46,9 +46,8 @@ where
46 46
47 47 let protected_routes = [
48 48 "/home",
49 - "/settings",
49 + "/profile",
50 50 "/test-base",
51 - "/profil",
52 51 ];
53 52 let is_protected = protected_routes.iter().any(|route| path.starts_with(route));
54 53
M src/api/routes.rs
+9 -1
@@ -61,7 +61,6 @@ pub fn create_router() -> Router<DatabaseConnection> {
61 61
62 62 let ui_routes = Router::new()
63 63 .route("/home", get(home_handler::home))
64 - .route("/settings", get(settings_handler::settings))
65 64 .route("/profile", get(settings_handler::get_profile_page))
66 65 .route("/profile", put(settings_handler::update_profile))
67 66 .route(
@@ -112,6 +111,15 @@ pub fn create_router() -> Router<DatabaseConnection> {
112 111 "/admin/users/{id}/reset-password-email",
113 112 post(admin_handler::admin_reset_password_by_email),
114 113 )
114 + .route("/admin/roles", get(admin_handler::list_roles))
115 + .route(
116 + "/admin/roles/create",
117 + get(admin_handler::create_role_form),
118 + )
119 + .route("/admin/roles", post(admin_handler::create_role))
120 + .route("/admin/roles/{id}", get(admin_handler::edit_role_form))
121 + .route("/admin/roles/{id}", put(admin_handler::update_role))
122 + .route("/admin/roles/{id}", delete(admin_handler::delete_role))
115 123 .route("/admin/settings", get(admin_handler::get_app_settings))
116 124 .route("/admin/settings", put(admin_handler::update_app_settings))
117 125 .route("/admin/errors", get(admin_handler::admin_errors));
M src/auth/extractors.rs
+44 -2
@@ -75,9 +75,11 @@ pub struct AuthUser {
75 75 /// Username (from JWT username claim)
76 76 pub username: String,
77 77
78 - #[allow(dead_code)]
79 78 /// User roles (from JWT roles claim)
80 79 pub roles: Vec<String>,
80 +
81 + /// User permissions (from JWT permissions claim)
82 + pub permissions: Vec<String>,
81 83 }
82 84
83 85 impl AuthUser {
@@ -128,6 +130,15 @@ impl AuthUser {
128 130 pub fn has_all_roles(&self, roles: &[&str]) -> bool {
129 131 roles.iter().all(|role| self.has_role(role))
130 132 }
133 +
134 + /// Check if user has a specific permission.
135 + /// Admin role is a superuser and has all permissions implicitly.
136 + pub fn has_permission(&self, permission: &str) -> bool {
137 + if self.has_role("admin") {
138 + return true;
139 + }
140 + self.permissions.iter().any(|p| p == permission)
141 + }
131 142 }
132 143
133 144 impl<S> FromRequestParts<S> for AuthUser
@@ -210,11 +221,11 @@ where
210 221
211 222 tracing::debug!("User {} authenticated via JWT", claims.username);
212 223
213 - // Create AuthUser from validated claims
214 224 Ok(AuthUser {
215 225 user_id: claims.sub,
216 226 username: claims.username,
217 227 roles: claims.roles,
228 + permissions: claims.permissions,
218 229 })
219 230 }
220 231 }
@@ -229,6 +240,7 @@ mod tests {
229 240 user_id: Uuid::new_v4(),
230 241 username: "test_user".to_string(),
231 242 roles: vec!["user".to_string(), "admin".to_string()],
243 + permissions: vec![],
232 244 };
233 245
234 246 assert_eq!(user.username, "test_user");
@@ -241,6 +253,7 @@ mod tests {
241 253 user_id: Uuid::new_v4(),
242 254 username: "test_user".to_string(),
243 255 roles: vec!["user".to_string(), "admin".to_string()],
256 + permissions: vec![],
244 257 };
245 258
246 259 assert!(user.has_role("user"));
@@ -254,6 +267,7 @@ mod tests {
254 267 user_id: Uuid::new_v4(),
255 268 username: "test_user".to_string(),
256 269 roles: vec!["user".to_string()],
270 + permissions: vec![],
257 271 };
258 272
259 273 assert!(user.has_any_role(&["admin", "user"]));
@@ -267,10 +281,38 @@ mod tests {
267 281 user_id: Uuid::new_v4(),
268 282 username: "test_user".to_string(),
269 283 roles: vec!["user".to_string(), "admin".to_string()],
284 + permissions: vec![],
270 285 };
271 286
272 287 assert!(user.has_all_roles(&["user", "admin"]));
273 288 assert!(user.has_all_roles(&["user"]));
274 289 assert!(!user.has_all_roles(&["user", "admin", "moderator"]));
275 290 }
291 +
292 + #[test]
293 + fn test_has_permission() {
294 + let user = AuthUser {
295 + user_id: Uuid::new_v4(),
296 + username: "test_user".to_string(),
297 + roles: vec!["user".to_string()],
298 + permissions: vec!["users.list".to_string(), "settings.view".to_string()],
299 + };
300 +
301 + assert!(user.has_permission("users.list"));
302 + assert!(user.has_permission("settings.view"));
303 + assert!(!user.has_permission("users.delete"));
304 + }
305 +
306 + #[test]
307 + fn test_admin_has_all_permissions() {
308 + let user = AuthUser {
309 + user_id: Uuid::new_v4(),
310 + username: "admin_user".to_string(),
311 + roles: vec!["admin".to_string()],
312 + permissions: vec![],
313 + };
314 +
315 + assert!(user.has_permission("users.delete"));
316 + assert!(user.has_permission("any.permission"));
317 + }
276 318 }
M src/auth/handlers.rs
+9 -3
@@ -270,7 +270,10 @@ pub async fn login(
270 270 // Fetch user roles
271 271 let roles = UserService::get_user_roles(&db, user.id).await?;
272 272
273 - tracing::debug!("User {} has roles: {:?}", user.username, roles);
273 + // Fetch user permissions
274 + let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?;
275 +
276 + tracing::debug!("User {} has roles: {:?}, permissions: {:?}", user.username, roles, permissions);
274 277
275 278 // Load auth configuration
276 279 let auth_config = AuthConfig::from_env().map_err(|e| {
@@ -280,7 +283,7 @@ pub async fn login(
280 283
281 284 // Generate JWT token
282 285 let access_token =
283 - JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone())?;
286 + JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?;
284 287
285 288 // JWT token is already generated above
286 289 // No need to store in session serveur - JWT is stateless
@@ -733,6 +736,9 @@ pub async fn refresh_token(
733 736 // Get user roles
734 737 let roles = UserService::get_user_roles(&db, user.id).await?;
735 738
739 + // Get user permissions
740 + let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?;
741 +
736 742 // Load auth configuration
737 743 let auth_config = AuthConfig::from_env().map_err(|e| {
738 744 tracing::error!("Failed to load auth config: {}", e);
@@ -741,7 +747,7 @@ pub async fn refresh_token(
741 747
742 748 // Generate new JWT access token
743 749 let access_token =
744 - JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone())?;
750 + JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?;
745 751
746 752 // Generate new refresh token (rotation)
747 753 let new_refresh_token = RefreshTokenService::create_refresh_token(
M src/auth/jwt.rs
+13 -4
@@ -45,6 +45,7 @@ use crate::error::AppError;
45 45 /// sub: Uuid::new_v4(),
46 46 /// username: "john_doe".to_string(),
47 47 /// roles: vec!["user".to_string()],
48 +/// permissions: vec!["users.list".to_string()],
48 49 /// exp: Utc::now().timestamp() + 900,
49 50 /// iat: Utc::now().timestamp(),
50 51 /// iss: "rustwarden".to_string(),
@@ -61,6 +62,10 @@ pub struct Claims {
61 62 /// User roles (e.g., ["admin"], ["user"])
62 63 pub roles: Vec<String>,
63 64
65 + /// User permissions (e.g., ["users.list", "roles.edit"])
66 + #[serde(default)]
67 + pub permissions: Vec<String>,
68 +
64 69 /// Expiration time (Unix timestamp)
65 70 pub exp: i64,
66 71
@@ -112,12 +117,14 @@ impl JwtService {
112 117 /// let config = AuthConfig::from_env().unwrap();
113 118 /// let user_id = Uuid::new_v4();
114 119 /// let roles = vec!["user".to_string()];
120 + /// let permissions = vec!["users.list".to_string()];
115 121 ///
116 122 /// let token = JwtService::generate_token(
117 123 /// &config,
118 124 /// user_id,
119 125 /// "john_doe",
120 - /// roles
126 + /// roles,
127 + /// permissions,
121 128 /// ).unwrap();
122 129 ///
123 130 /// println!("Generated token: {}", token);
@@ -127,6 +134,7 @@ impl JwtService {
127 134 user_id: Uuid,
128 135 username: &str,
129 136 roles: Vec<String>,
137 + permissions: Vec<String>,
130 138 ) -> Result<String, AppError> {
131 139 let now = Utc::now();
132 140 let expiration = now + config.jwt_expiration_duration();
@@ -135,6 +143,7 @@ impl JwtService {
135 143 sub: user_id,
136 144 username: username.to_string(),
137 145 roles,
146 + permissions,
138 147 exp: expiration.timestamp(),
139 148 iat: now.timestamp(),
140 149 iss: config.jwt_issuer.clone(),
@@ -324,34 +333,34 @@ mod tests {
324 333 sub: Uuid::new_v4(),
325 334 username: "test_user".to_string(),
326 335 roles: vec!["user".to_string()],
336 + permissions: vec!["users.list".to_string()],
327 337 exp: Utc::now().timestamp() + 900,
328 338 iat: Utc::now().timestamp(),
329 339 iss: "rustwarden".to_string(),
330 340 };
331 341
332 - // Test that claims can be serialized
333 342 let json = serde_json::to_string(&claims);
334 343 assert!(json.is_ok());
335 344 }
336 345
337 346 #[test]
338 347 fn test_is_expired() {
339 - // Non-expired token
340 348 let claims = Claims {
341 349 sub: Uuid::new_v4(),
342 350 username: "test".to_string(),
343 351 roles: vec![],
352 + permissions: vec![],
344 353 exp: (Utc::now() + chrono::Duration::hours(1)).timestamp(),
345 354 iat: Utc::now().timestamp(),
346 355 iss: "rustwarden".to_string(),
347 356 };
348 357 assert!(!JwtService::is_expired(&claims));
349 358
350 - // Expired token
351 359 let expired_claims = Claims {
352 360 sub: Uuid::new_v4(),
353 361 username: "test".to_string(),
354 362 roles: vec![],
363 + permissions: vec![],
355 364 exp: (Utc::now() - chrono::Duration::hours(1)).timestamp(),
356 365 iat: Utc::now().timestamp(),
357 366 iss: "rustwarden".to_string(),
M src/models/mod.rs
+4 -0
@@ -10,8 +10,10 @@ pub mod email_validation_token;
10 10 pub mod jwt_blacklist;
11 11 pub mod password_change_request;
12 12 pub mod password_reset_token;
13 +pub mod permission;
13 14 pub mod refresh_token;
14 15 pub mod role;
16 +pub mod role_permission;
15 17 pub mod user;
16 18 pub mod user_preferences;
17 19 pub mod user_role;
@@ -24,8 +26,10 @@ pub use email_validation_token::Entity as EmailValidationToken;
24 26 pub use jwt_blacklist::Entity as JwtBlacklist;
25 27 pub use password_change_request::Entity as PasswordChangeRequest;
26 28 pub use password_reset_token::Entity as PasswordResetToken;
29 +pub use permission::Entity as Permission;
27 30 pub use refresh_token::Entity as RefreshToken;
28 31 pub use role::Entity as Role;
32 +pub use role_permission::Entity as RolePermission;
29 33 pub use user::Entity as User;
30 34 pub use user_preferences::Entity as UserPreferences;
31 35 pub use user_role::Entity as UserRole;
A src/models/permission.rs
+22 -0
@@ -0,0 +1,22 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
7 +#[sea_orm(table_name = "permissions")]
8 +pub struct Model {
9 + #[sea_orm(primary_key)]
10 + pub id: Uuid,
11 + #[sea_orm(unique)]
12 + pub name: String,
13 + #[sea_orm(nullable)]
14 + pub description: Option<String>,
15 + pub category: String,
16 + pub created_at: DateTime<Utc>,
17 +}
18 +
19 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
20 +pub enum Relation {}
21 +
22 +impl ActiveModelBehavior for ActiveModel {}
A src/models/role_permission.rs
+31 -0
@@ -0,0 +1,31 @@
1 +use sea_orm::entity::prelude::*;
2 +use uuid::Uuid;
3 +
4 +#[derive(Clone, Debug, DeriveEntityModel)]
5 +#[sea_orm(table_name = "role_permissions")]
6 +pub struct Model {
7 + #[sea_orm(primary_key)]
8 + pub role_id: Uuid,
9 + #[sea_orm(primary_key)]
10 + pub permission_id: Uuid,
11 +}
12 +
13 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
14 +pub enum Relation {
15 + #[sea_orm(
16 + belongs_to = "super::role::Entity",
17 + from = "Column::RoleId",
18 + to = "super::role::Column::Id",
19 + on_delete = "Cascade"
20 + )]
21 + Role,
22 + #[sea_orm(
23 + belongs_to = "super::permission::Entity",
24 + from = "Column::PermissionId",
25 + to = "super::permission::Column::Id",
26 + on_delete = "Cascade"
27 + )]
28 + Permission,
29 +}
30 +
31 +impl ActiveModelBehavior for ActiveModel {}
M src/services/mod.rs
+1 -0
@@ -15,6 +15,7 @@ pub mod jwt_blacklist_service;
15 15 pub mod password_change_service;
16 16 pub mod password_reset_service;
17 17 pub mod password_service;
18 +pub mod permission_service;
18 19 pub mod refresh_token_service;
19 20 pub mod role_service;
20 21 pub mod user_preferences_service;
A src/services/permission_service.rs
+178 -0
@@ -0,0 +1,178 @@
1 +use crate::error::AppError;
2 +use crate::models::{permission, role_permission, user_role};
3 +use chrono::Utc;
4 +use sea_orm::{
5 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
6 +};
7 +use uuid::Uuid;
8 +
9 +pub struct PermissionService;
10 +
11 +impl PermissionService {
12 + pub async fn list_all(
13 + db: &DatabaseConnection,
14 + ) -> Result<Vec<permission::Model>, AppError> {
15 + let perms = permission::Entity::find().all(db).await?;
16 + Ok(perms)
17 + }
18 +
19 + pub async fn get_by_id(
20 + db: &DatabaseConnection,
21 + id: Uuid,
22 + ) -> Result<Option<permission::Model>, AppError> {
23 + let perm = permission::Entity::find_by_id(id).one(db).await?;
24 + Ok(perm)
25 + }
26 +
27 + pub async fn get_by_name(
28 + db: &DatabaseConnection,
29 + name: &str,
30 + ) -> Result<Option<permission::Model>, AppError> {
31 + let perm = permission::Entity::find()
32 + .filter(permission::Column::Name.eq(name))
33 + .one(db)
34 + .await?;
35 + Ok(perm)
36 + }
37 +
38 + pub async fn create(
39 + db: &DatabaseConnection,
40 + name: String,
41 + description: Option<String>,
42 + category: String,
43 + ) -> Result<permission::Model, AppError> {
44 + let perm = permission::ActiveModel {
45 + id: Set(Uuid::new_v4()),
46 + name: Set(name),
47 + description: Set(description),
48 + category: Set(category),
49 + created_at: Set(Utc::now()),
50 + };
51 + let result = perm.insert(db).await?;
52 + Ok(result)
53 + }
54 +
55 + pub async fn delete(
56 + db: &DatabaseConnection,
57 + id: Uuid,
58 + ) -> Result<(), AppError> {
59 + permission::Entity::delete_by_id(id).exec(db).await?;
60 + Ok(())
61 + }
62 +
63 + /// Get all permission names for a given user (union of all role permissions).
64 + pub async fn get_permissions_for_user(
65 + db: &DatabaseConnection,
66 + user_id: Uuid,
67 + ) -> Result<Vec<String>, AppError> {
68 + let ur_rows = user_role::Entity::find()
69 + .filter(user_role::Column::UserId.eq(user_id))
70 + .all(db)
71 + .await?;
72 +
73 + let role_ids: Vec<Uuid> = ur_rows.iter().map(|ur| ur.role_id).collect();
74 + if role_ids.is_empty() {
75 + return Ok(vec![]);
76 + }
77 +
78 + let rp_rows = role_permission::Entity::find()
79 + .filter(role_permission::Column::RoleId.is_in(role_ids))
80 + .all(db)
81 + .await?;
82 +
83 + let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
84 + if perm_ids.is_empty() {
85 + return Ok(vec![]);
86 + }
87 +
88 + let perms = permission::Entity::find()
89 + .filter(permission::Column::Id.is_in(perm_ids))
90 + .all(db)
91 + .await?;
92 +
93 + let names: Vec<String> = perms.into_iter().map(|p| p.name).collect();
94 + Ok(names)
95 + }
96 +
97 + /// Get permissions assigned to a specific role.
98 + pub async fn get_permissions_for_role(
99 + db: &DatabaseConnection,
100 + role_id: Uuid,
101 + ) -> Result<Vec<permission::Model>, AppError> {
102 + let rp_rows = role_permission::Entity::find()
103 + .filter(role_permission::Column::RoleId.eq(role_id))
104 + .all(db)
105 + .await?;
106 +
107 + let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
108 + if perm_ids.is_empty() {
109 + return Ok(vec![]);
110 + }
111 +
112 + let perms = permission::Entity::find()
113 + .filter(permission::Column::Id.is_in(perm_ids))
114 + .all(db)
115 + .await?;
116 +
117 + Ok(perms)
118 + }
119 +
120 + /// Replace the permissions of a role atomically.
121 + pub async fn set_role_permissions(
122 + db: &DatabaseConnection,
123 + role_id: Uuid,
124 + permission_ids: Vec<Uuid>,
125 + ) -> Result<(), AppError> {
126 + // Delete existing role permissions
127 + role_permission::Entity::delete_many()
128 + .filter(role_permission::Column::RoleId.eq(role_id))
129 + .exec(db)
130 + .await?;
131 +
132 + // Insert new
133 + for perm_id in permission_ids {
134 + let rp = role_permission::ActiveModel {
135 + role_id: Set(role_id),
136 + permission_id: Set(perm_id),
137 + };
138 + rp.insert(db).await?;
139 + }
140 +
141 + Ok(())
142 + }
143 +
144 + /// Seed default permissions (idempotent).
145 + pub async fn initialize_default_permissions(
146 + db: &DatabaseConnection,
147 + ) -> Result<(), AppError> {
148 + let defaults = vec![
149 + ("users.list", "View the list of users", "users"),
150 + ("users.create", "Create new users", "users"),
151 + ("users.edit", "Edit existing users", "users"),
152 + ("users.delete", "Delete users", "users"),
153 + ("users.reset_password", "Reset user passwords", "users"),
154 + ("roles.list", "View the list of roles", "roles"),
155 + ("roles.create", "Create new roles", "roles"),
156 + ("roles.edit", "Edit existing roles", "roles"),
157 + ("roles.delete", "Delete roles", "roles"),
158 + ("settings.view", "View application settings", "settings"),
159 + ("settings.edit", "Edit application settings", "settings"),
160 + ("audit.view", "View audit logs", "audit"),
161 + ];
162 +
163 + for (name, desc, category) in defaults {
164 + if Self::get_by_name(db, name).await?.is_none() {
165 + Self::create(
166 + db,
167 + name.to_string(),
168 + Some(desc.to_string()),
169 + category.to_string(),
170 + )
171 + .await?;
172 + tracing::info!("Created default permission: {}", name);
173 + }
174 + }
175 +
176 + Ok(())
177 + }
178 +}
M src/services/role_service.rs
+62 -16
@@ -1,7 +1,7 @@
1 1 use crate::error::AppError;
2 -use crate::models::role;
2 +use crate::models::{role, user_role};
3 3 use chrono::Utc;
4 -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
4 +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
5 5 use uuid::Uuid;
6 6
7 7 /// Service for managing roles in the RBAC system
@@ -139,22 +139,68 @@ impl RoleService {
139 139 Ok((admin_role, user_role, app_manager_role))
140 140 }
141 141
142 - /// List all available roles
143 - ///
144 - /// # Arguments
145 - ///
146 - /// * `db` - Database connection
147 - ///
148 - /// # Returns
149 - ///
150 - /// A vector of all roles in the database
151 - ///
152 - /// # Errors
153 - ///
154 - /// Returns `AppError::DbErr` if database operation fails
155 - #[allow(dead_code)] // Will be used in future features
156 142 pub async fn list_all_roles(db: &DatabaseConnection) -> Result<Vec<role::Model>, AppError> {
157 143 let roles = role::Entity::find().all(db).await?;
158 144 Ok(roles)
159 145 }
146 +
147 + pub async fn get_role_by_id(
148 + db: &DatabaseConnection,
149 + id: Uuid,
150 + ) -> Result<Option<role::Model>, AppError> {
151 + let role = role::Entity::find_by_id(id).one(db).await?;
152 + Ok(role)
153 + }
154 +
155 + pub async fn update_role(
156 + db: &DatabaseConnection,
157 + id: Uuid,
158 + name: Option<String>,
159 + description: Option<String>,
160 + ) -> Result<role::Model, AppError> {
161 + let existing = Self::get_role_by_id(db, id)
162 + .await?
163 + .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;
164 +
165 + let mut active: role::ActiveModel = existing.into();
166 +
167 + if let Some(n) = name {
168 + active.name = Set(n);
169 + }
170 + active.description = Set(description);
171 + active.updated_at = Set(Utc::now());
172 +
173 + let updated = active.update(db).await?;
174 + Ok(updated)
175 + }
176 +
177 + pub async fn delete_role(db: &DatabaseConnection, id: Uuid) -> Result<(), AppError> {
178 + let existing = Self::get_role_by_id(db, id)
179 + .await?
180 + .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;
181 +
182 + let protected = ["admin", "user"];
183 + if protected.contains(&existing.name.as_str()) {
184 + return Err(AppError::Validation(format!(
185 + "Cannot delete protected role '{}'",
186 + existing.name
187 + )));
188 + }
189 +
190 + role::Entity::delete_by_id(id).exec(db).await?;
191 + Ok(())
192 + }
193 +
194 + /// Count the number of users assigned to a role.
195 + pub async fn count_users_for_role(
196 + db: &DatabaseConnection,
197 + role_id: Uuid,
198 + ) -> Result<u64, AppError> {
199 + use sea_orm::PaginatorTrait;
200 + let count = user_role::Entity::find()
201 + .filter(user_role::Column::RoleId.eq(role_id))
202 + .count(db)
203 + .await?;
204 + Ok(count)
205 + }
160 206 }
M src/templates/helpers.rs
+0 -3
@@ -290,9 +290,6 @@ pub fn generate_default_sidebar(
290 290 // Section: Raccourcis
291 291 let mut shortcuts_section = SidebarSection::new("Raccourcis");
292 292
293 - shortcuts_section =
294 - shortcuts_section.with_item(SidebarItem::link("Paramètres", "⚙️", "/settings"));
295 -
296 293 shortcuts_section = shortcuts_section.with_item(SidebarItem::link(
297 294 "Déconnexion",
298 295 "🚪",
M templates/admin/dashboard.html
+16 -19
@@ -95,6 +95,22 @@
95 95
96 96 <!-- Navigation Links -->
97 97 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
98 + <a href="/admin/roles" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
99 + <div class="card-body p-4">
100 + <h2 class="card-title text-warning text-base">🛡️ {{ roles_card_title }}</h2>
101 + <p class="text-sm">{{ roles_card_desc }}</p>
102 + <div class="stats shadow-sm mt-2">
103 + <div class="stat py-1 px-2">
104 + <div class="stat-title text-xs">{{ roles_stat_title }}</div>
105 + <div class="stat-value text-base">{{ total_roles }}</div>
106 + </div>
107 + </div>
108 + <div class="card-actions justify-end mt-2">
109 + <button class="btn btn-warning btn-sm">{{ access_button }}</button>
110 + </div>
111 + </div>
112 + </a>
113 +
98 114 <a href="/admin/settings" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
99 115 <div class="card-body p-4">
100 116 <h2 class="card-title text-secondary text-base">⚙️ {{ settings_card_title }}</h2>
@@ -115,25 +131,6 @@
115 131 </div>
116 132 </a>
117 133
118 - <a href="/admin/organisations" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
119 - <div class="card-body p-4">
120 - <h2 class="card-title text-info text-base">🏢 {{ orgs_card_title }}</h2>
121 - <p class="text-sm">{{ orgs_card_desc }}</p>
122 - <div class="stats stats-vertical lg:stats-horizontal shadow-sm mt-2">
123 - <div class="stat py-1 px-2">
124 - <div class="stat-title text-xs">{{ orgs_stat_title }}</div>
125 - <div class="stat-value text-base">{{ total_organisations }}</div>
126 - </div>
127 - <div class="stat py-1 px-2">
128 - <div class="stat-title text-xs">{{ orgs_members_stat_title }}</div>
129 - <div class="stat-value text-base">{{ total_organisation_members }}</div>
130 - </div>
131 - </div>
132 - <div class="card-actions justify-end mt-2">
133 - <button class="btn btn-info btn-sm">{{ access_button }}</button>
134 - </div>
135 - </div>
136 - </a>
137 134 </div>
138 135 </div>
139 136 {% endblock %}
A templates/admin/role_form.html
+135 -0
@@ -0,0 +1,135 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li><a href="/admin/roles">{{ breadcrumb_roles }}</a></li>
13 + <li>{{ breadcrumb_mode }}</li>
14 + </ul>
15 + </div>
16 +
17 + <div class="flex items-center gap-4 mb-6">
18 + <a href="/admin/roles" class="btn btn-ghost btn-sm">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
21 + </svg>
22 + {{ back_to_list }}
23 + </a>
24 + <h1 class="text-3xl font-bold">{{ form_title }}</h1>
25 + </div>
26 +
27 + <div class="card bg-base-100 shadow-xl max-w-3xl">
28 + <div class="card-body">
29 + <form id="roleForm"
30 + {% if mode == "create" %}
31 + hx-post="/admin/roles"
32 + {% else %}
33 + action="/admin/roles/{{ role_id }}"
34 + method="POST"
35 + {% endif %}
36 + hx-target="#form-message"
37 + hx-swap="innerHTML">
38 + {% if mode == "edit" %}
39 + <input type="hidden" name="_method" value="PUT">
40 + {% endif %}
41 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
42 +
43 + <!-- Name -->
44 + <div class="form-control mb-4">
45 + <label class="label">
46 + <span class="label-text font-semibold">{{ name_label }}</span>
47 + </label>
48 + {% if is_protected %}
49 + <input type="text" name="name" value="{{ form_name }}" class="input input-bordered" readonly>
50 + <label class="label">
51 + <span class="label-text-alt text-warning">{{ protected_name_hint }}</span>
52 + </label>
53 + {% else %}
54 + <input type="text" name="name" value="{{ form_name }}" class="input input-bordered"
55 + placeholder="{{ name_placeholder }}" required>
56 + {% endif %}
57 + </div>
58 +
59 + <!-- Description -->
60 + <div class="form-control mb-4">
61 + <label class="label">
62 + <span class="label-text font-semibold">{{ description_label }}</span>
63 + </label>
64 + <textarea name="description" class="textarea textarea-bordered"
65 + placeholder="{{ description_placeholder }}" rows="3">{{ form_description }}</textarea>
66 + </div>
67 +
68 + <!-- Permissions grouped by category -->
69 + <div class="form-control mb-6">
70 + <label class="label">
71 + <span class="label-text font-semibold">{{ permissions_label }}</span>
72 + </label>
73 +
74 + {% for group in permission_groups %}
75 + <div class="collapse collapse-arrow bg-base-200 mb-2">
76 + <input type="checkbox" checked>
77 + <div class="collapse-title font-medium capitalize">
78 + {{ group.category }}
79 + <span class="badge badge-sm badge-ghost ml-2">{{ group.permissions.len() }}</span>
80 + </div>
81 + <div class="collapse-content">
82 + <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
83 + {% for perm in group.permissions %}
84 + <label class="flex items-start gap-3 cursor-pointer p-2 rounded hover:bg-base-300 transition-colors">
85 + <input type="checkbox" name="permissions" value="{{ perm.id }}"
86 + class="checkbox checkbox-sm checkbox-primary mt-0.5"
87 + {% if perm.is_selected %}checked{% endif %}>
88 + <div>
89 + <div class="text-sm font-mono">{{ perm.name }}</div>
90 + {% if !perm.description.is_empty() %}
91 + <div class="text-xs text-base-content/60">{{ perm.description }}</div>
92 + {% endif %}
93 + </div>
94 + </label>
95 + {% endfor %}
96 + </div>
97 + </div>
98 + </div>
99 + {% endfor %}
100 + </div>
101 +
102 + <!-- Message area -->
103 + <div id="form-message" class="mb-4"></div>
104 +
105 + <!-- Actions -->
106 + <div class="flex justify-end gap-4">
107 + <a href="/admin/roles" class="btn btn-ghost">{{ cancel }}</a>
108 + <button type="submit" class="btn btn-primary">{{ submit_label }}</button>
109 + </div>
110 + </form>
111 + </div>
112 + </div>
113 +</div>
114 +
115 +{% if mode == "edit" %}
116 +<script>
117 +document.getElementById('roleForm').addEventListener('submit', function(e) {
118 + e.preventDefault();
119 + const form = e.target;
120 + const formData = new FormData(form);
121 + fetch(form.action, {
122 + method: 'PUT',
123 + headers: {
124 + 'X-CSRF-Token': '{{ csrf_token }}',
125 + },
126 + body: new URLSearchParams(formData),
127 + }).then(function(resp) {
128 + if (resp.redirected) {
129 + window.location.href = resp.url;
130 + }
131 + });
132 +});
133 +</script>
134 +{% endif %}
135 +{% endblock %}
A templates/admin/roles_list.html
+132 -0
@@ -0,0 +1,132 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li>{{ breadcrumb_roles }}</li>
13 + </ul>
14 + </div>
15 +
16 + <div class="flex justify-between items-center mb-6">
17 + <h1 class="text-3xl font-bold">{{ title }}</h1>
18 + <a href="/admin/roles/create" class="btn btn-primary">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
21 + </svg>
22 + {{ create_role }}
23 + </a>
24 + </div>
25 +
26 + <!-- Roles Table -->
27 + <div class="card bg-base-100 shadow-xl">
28 + <div class="card-body p-0">
29 + {% if roles.is_empty() %}
30 + <div class="p-8 text-center text-base-content/60">
31 + <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
32 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
33 + </svg>
34 + <p>{{ no_roles }}</p>
35 + </div>
36 + {% else %}
37 + <div class="overflow-x-auto">
38 + <table class="table">
39 + <thead>
40 + <tr>
41 + <th>{{ table_name }}</th>
42 + <th>{{ table_description }}</th>
43 + <th class="text-center">{{ table_users }}</th>
44 + <th class="text-center">{{ table_permissions }}</th>
45 + <th class="text-right">{{ table_actions }}</th>
46 + </tr>
47 + </thead>
48 + <tbody>
49 + {% for role in roles %}
50 + <tr>
51 + <td>
52 + <div class="font-bold">{{ role.name }}</div>
53 + {% if role.is_protected %}
54 + <span class="badge badge-ghost badge-sm">{{ protected_hint }}</span>
55 + {% endif %}
56 + </td>
57 + <td>{{ role.description }}</td>
58 + <td class="text-center">
59 + <span class="badge badge-outline">{{ role.user_count }}</span>
60 + </td>
61 + <td class="text-center">
62 + <span class="badge badge-outline">{{ role.permission_count }}</span>
63 + </td>
64 + <td class="text-right">
65 + <div class="flex justify-end gap-2">
66 + <a href="/admin/roles/{{ role.id }}" class="btn btn-ghost btn-sm">
67 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
68 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
69 + </svg>
70 + {{ edit }}
71 + </a>
72 + {% if !role.is_protected %}
73 + <button class="btn btn-ghost btn-sm text-error"
74 + onclick="confirmDeleteRole('{{ role.id }}', '{{ role.name }}')">
75 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
76 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
77 + </svg>
78 + {{ delete_label }}
79 + </button>
80 + {% endif %}
81 + </div>
82 + </td>
83 + </tr>
84 + {% endfor %}
85 + </tbody>
86 + </table>
87 + </div>
88 + {% endif %}
89 + </div>
90 + </div>
91 +</div>
92 +
93 +<!-- Delete Confirmation Modal -->
94 +<dialog id="deleteRoleModal" class="modal">
95 + <div class="modal-box">
96 + <h3 class="text-lg font-bold">{{ delete_confirm_title }}</h3>
97 + <p class="py-4">{{ delete_confirm_message }} <strong id="deleteRoleName"></strong>?</p>
98 + <div class="modal-action">
99 + <form method="dialog">
100 + <button class="btn">{{ cancel }}</button>
101 + </form>
102 + <button id="confirmDeleteBtn" class="btn btn-error">{{ delete_button }}</button>
103 + </div>
104 + </div>
105 + <form method="dialog" class="modal-backdrop">
106 + <button>close</button>
107 + </form>
108 +</dialog>
109 +
110 +<script>
111 +let deleteRoleId = null;
112 +
113 +function confirmDeleteRole(id, name) {
114 + deleteRoleId = id;
115 + document.getElementById('deleteRoleName').textContent = name;
116 + document.getElementById('deleteRoleModal').showModal();
117 +}
118 +
119 +document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
120 + if (!deleteRoleId) return;
121 + fetch('/admin/roles/' + deleteRoleId, {
122 + method: 'DELETE',
123 + headers: {
124 + 'X-CSRF-Token': '{{ csrf_token }}',
125 + }
126 + }).then(function() {
127 + window.location.reload();
128 + });
129 + document.getElementById('deleteRoleModal').close();
130 +});
131 +</script>
132 +{% endblock %}
M templates/errors/403.html
+2 -2
@@ -27,8 +27,8 @@
27 27 <a href="/home" class="btn btn-primary w-full">
28 28 Retour à l'Accueil
29 29 </a>
30 - <a href="/settings" class="btn btn-ghost w-full">
31 - Paramètres
30 + <a href="/profile" class="btn btn-ghost w-full">
31 + Mon profil
32 32 </a>
33 33 </div>
34 34 </div>
M templates/fragments/user_menu_dropdown.html
+0 -7
@@ -32,13 +32,6 @@
32 32 </a>
33 33 </li>
34 34
35 -<li>
36 - <a href="/settings" class="gap-2">
37 - <span>⚙️</span>
38 - <span>{{ user_menu_settings }}</span>
39 - </a>
40 -</li>
41 -
42 35 <!-- Divider -->
43 36 <li><hr class="my-2"></li>
44 37
M templates/profile.html
+2 -2
@@ -12,7 +12,7 @@
12 12 <div class="text-sm breadcrumbs mb-6">
13 13 <ul>
14 14 <li><a href="/">{{ breadcrumb_home }}</a></li>
15 - <li><a href="/settings">{{ breadcrumb_settings }}</a></li>
15 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
16 16 <li>{{ breadcrumb_profile }}</li>
17 17 </ul>
18 18 </div>
@@ -82,7 +82,7 @@
82 82
83 83 <!-- Form Actions -->
84 84 <div class="card-actions justify-end">
85 - <a href="/settings" class="btn btn-ghost">{{ cancel }}</a>
85 + <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
86 86 <button type="submit" class="btn btn-primary">
87 87 <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
88 88 {{ save_button }}
D templates/settings.html
+0 -100
@@ -1,100 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block navbar %}
6 -{% include "components/navbar.html" %}
7 -{% endblock %}
8 -
9 -{% block content %}
10 -<div class="container mx-auto px-4 py-8">
11 - <!-- Breadcrumb -->
12 - <div class="text-sm breadcrumbs mb-6">
13 - <ul>
14 - <li><a href="/">{{ breadcrumb_home }}</a></li>
15 - <li>{{ breadcrumb_settings }}</li>
16 - </ul>
17 - </div>
18 -
19 -<!-- Page Header -->
20 -<div class="mb-8">
21 - <h1 class="text-3xl font-bold mb-2">⚙️ {{ settings_title }}</h1>
22 - <p class="text-base-content/70">{{ settings_subtitle }}</p>
23 -</div>
24 -
25 - <!-- Settings Navigation Cards -->
26 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
27 - <!-- Profile Card -->
28 - <a href="/profile" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
29 - <div class="card-body">
30 - <h2 class="card-title text-primary">
31 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
32 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
33 - </svg>
34 - {{ profile_title }}
35 - </h2>
36 - <p class="text-sm text-base-content/70">{{ profile_desc }}</p>
37 - <div class="card-actions justify-end">
38 - <button class="btn btn-primary btn-sm">{{ access_button }}</button>
39 - </div>
40 - </div>
41 - </a>
42 -
43 - <!-- Password Card -->
44 - <a href="/settings/password" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
45 - <div class="card-body">
46 - <h2 class="card-title text-secondary">
47 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
48 - <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" />
49 - </svg>
50 - {{ password_title }}
51 - </h2>
52 - <p class="text-sm text-base-content/70">{{ password_desc }}</p>
53 - <div class="card-actions justify-end">
54 - <button class="btn btn-secondary btn-sm">{{ access_button }}</button>
55 - </div>
56 - </div>
57 - </a>
58 -
59 - <!-- Sessions Card -->
60 - <a href="/settings/sessions" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
61 - <div class="card-body">
62 - <h2 class="card-title text-accent">
63 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
64 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
65 - </svg>
66 - {{ sessions_title }}
67 - </h2>
68 - <p class="text-sm text-base-content/70">{{ sessions_desc }}</p>
69 - <div class="card-actions justify-end">
70 - <button class="btn btn-accent btn-sm">{{ access_button }}</button>
71 - </div>
72 - </div>
73 - </a>
74 -
75 - <!-- Preferences Card -->
76 - <a href="/settings/preferences" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
77 - <div class="card-body">
78 - <h2 class="card-title text-info">
79 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
80 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
81 - </svg>
82 - {{ preferences_title }}
83 - </h2>
84 - <p class="text-sm text-base-content/70">{{ preferences_desc }}</p>
85 - <div class="card-actions justify-end">
86 - <button class="btn btn-info btn-sm">{{ access_button }}</button>
87 - </div>
88 - </div>
89 - </a>
90 - </div>
91 -
92 - <!-- Quick Info -->
93 - <div class="mt-8 card bg-base-200 shadow-xl">
94 - <div class="card-body">
95 - <h3 class="card-title text-lg">ℹ️ {{ info_title }}</h3>
96 - <p class="text-sm text-base-content/70">{{ info_desc }}</p>
97 - </div>
98 - </div>
99 -</div>
100 -{% endblock %}
M templates/settings/password.html
+2 -2
@@ -24,7 +24,7 @@
24 24 <div class="text-sm breadcrumbs mb-6">
25 25 <ul>
26 26 <li><a href="/">{{ breadcrumb_home }}</a></li>
27 - <li><a href="/settings">{{ breadcrumb_settings }}</a></li>
27 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
28 28 <li>{{ breadcrumb_password }}</li>
29 29 </ul>
30 30 </div>
@@ -159,7 +159,7 @@
159 159 {{ logout_label }}
160 160 </a>
161 161 {% else %}
162 - <a href="/settings" class="btn btn-ghost">{{ cancel }}</a>
162 + <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
163 163 {% endif %}
164 164 <button type="submit" class="btn btn-error" id="submitBtn" disabled>
165 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>
M templates/settings/preferences.html
+2 -2
@@ -45,7 +45,7 @@
45 45 <div class="text-sm breadcrumbs mb-6">
46 46 <ul>
47 47 <li><a href="/">{{ breadcrumb_home }}</a></li>
48 - <li><a href="/settings">{{ breadcrumb_settings }}</a></li>
48 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
49 49 <li>{{ breadcrumb_preferences }}</li>
50 50 </ul>
51 51 </div>
@@ -244,7 +244,7 @@
244 244 <p class="text-sm text-base-content/70">Enregistrez vos changements pour les appliquer</p>
245 245 </div>
246 246 <div class="flex gap-2">
247 - <a href="/settings" class="btn btn-ghost">Annuler</a>
247 + <a href="/profile" class="btn btn-ghost">Annuler</a>
248 248 <button type="submit" form="preferencesForm" class="btn btn-primary" id="saveBtn">
249 249 <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>
250 250 Enregistrer les préférences
M templates/settings/sessions.html
+2 -2
@@ -12,7 +12,7 @@
12 12 <div class="text-sm breadcrumbs mb-6">
13 13 <ul>
14 14 <li><a href="/">{{ breadcrumb_home }}</a></li>
15 - <li><a href="/settings">{{ breadcrumb_settings }}</a></li>
15 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
16 16 <li>{{ breadcrumb_sessions }}</li>
17 17 </ul>
18 18 </div>
@@ -142,7 +142,7 @@
142 142
143 143 <!-- Back to Settings -->
144 144 <div class="mt-8">
145 - <a href="/settings" class="btn btn-outline">
145 + <a href="/profile" class="btn btn-outline">
146 146 <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="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
147 147 {{ back_to_settings }}
148 148 </a>