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> |
GitRust