template_helpers.rs
539 lignes · 16870 octets
//! Template helpers for Feature 007 //! //! Provides helper functions for generating template contexts and data structures. use sea_orm::DatabaseConnection; use std::sync::Arc; use tower_sessions::Session; use crate::dto::{ BreadcrumbItem, SidebarItem, SidebarSection, navigation_dto::{NavigationDTO, NavigationItemDTO}, }; use rustwarden_core::auth::jwt::Claims; use rustwarden_core::error::AppError; use rustwarden_core::i18n::extractor::Locale; use rustwarden_core::i18n::service::I18nService; use rustwarden_core::middleware::csrf; /// Base template context containing common data for all pages #[derive(Debug, Clone)] pub struct BaseTemplateContext { /// Title shown in the browser tab and page header. pub page_title: String, /// Application display name from settings. pub app_name: String, /// Application version string. pub app_version: String, /// Public domain of the application. pub app_domain: String, /// Short description of the application. pub app_description: String, /// Authenticated user's login name. pub username: String, /// Authenticated user's email address. pub email: String, /// Two-letter initials derived from the username for the avatar. pub initials: String, /// CSS color class used for the user's avatar background. pub avatar_color: String, /// Roles assigned to the authenticated user. pub roles: Vec<String>, } /// Build base template context from session and database /// /// Extracts user information from the session and prepares common template variables. /// Loads app settings from database (app_name, app_domain, app_description). /// /// # Arguments /// /// * `session` - Tower session containing user claims /// * `db` - Database connection (for loading app settings) /// /// # Returns /// /// * `Ok(BaseTemplateContext)` if user is authenticated /// * `Err(AppError)` if session is invalid or user not found /// /// # Example /// /// ```rust,ignore /// use rustwarden::templates::helpers::build_base_context; /// /// let context = build_base_context(&session, &db).await?; /// ``` pub async fn build_base_context( session: &Session, db: &DatabaseConnection, ) -> Result<BaseTemplateContext, AppError> { // Extract JWT claims from session let claims: Claims = session .get("user") .await .map_err(|e| { tracing::error!("Failed to get user from session: {}", e); AppError::Internal("Session error".to_string()) })? .ok_or_else(|| { tracing::warn!("No user claims found in session"); AppError::Unauthorized })?; // Extract email from session (if available) let email = session .get::<String>("email") .await .ok() .flatten() .unwrap_or_else(|| format!("{}@rustwarden.local", claims.username)); build_base_context_from_claims(db, &claims, email).await } /// Build base template context from provided claims /// /// Creates a base context with user information from Claims. /// Used when Claims are extracted from request extensions (e.g., in admin routes). /// Loads app settings from database (app_name, app_domain, app_description). /// /// # Arguments /// /// * `db` - Database connection (for loading app settings) /// * `claims` - User claims from JWT /// * `email` - User's email address /// /// # Returns /// /// * `Ok(BaseTemplateContext)` always succeeds pub async fn build_base_context_from_claims( db: &DatabaseConnection, claims: &Claims, email: String, ) -> Result<BaseTemplateContext, AppError> { use rustwarden_core::services::app_settings_service::AppSettingsService; // Load app settings from database (T175 - Application des Paramètres) let app_name = AppSettingsService::get_setting(db, "app_name") .await? .map(|s| s.value) .unwrap_or_else(rustwarden_core::config::app::default_app_name); let app_domain = AppSettingsService::get_setting(db, "app_domain") .await? .map(|s| s.value) .unwrap_or_else(|| "localhost".to_string()); let app_description = AppSettingsService::get_setting(db, "app_description") .await? .map(|s| s.value) .unwrap_or_else(|| "Secure cloud storage".to_string()); // Calculate initials from username let initials = calculate_initials(&claims.username); // Calculate avatar color from username (deterministic hash) let avatar_color = calculate_avatar_color(&claims.username); Ok(BaseTemplateContext { page_title: app_name.clone(), // Default, can be overridden app_name, app_version: env!("CARGO_PKG_VERSION").to_string(), app_domain, app_description, username: claims.username.clone(), email, initials, avatar_color, roles: claims.roles.clone(), }) } /// Build base template context from session and provided claims /// /// Creates a base context with user information from Claims. /// Extracts email from session if available. /// Loads app settings from database (app_name, app_domain, app_description). /// /// # Arguments /// /// * `session` - Tower session (for email) /// * `db` - Database connection (for loading app settings) /// * `claims` - User claims from JWT /// /// # Returns /// /// * `Ok(BaseTemplateContext)` always succeeds pub async fn build_base_context_with_claims( session: &Session, db: &DatabaseConnection, claims: &Claims, ) -> Result<BaseTemplateContext, AppError> { // Extract email from session (if available) let email = session .get::<String>("email") .await .ok() .flatten() .unwrap_or_else(|| format!("{}@rustwarden.local", claims.username)); build_base_context_from_claims(db, claims, email).await } /// Generate breadcrumb items from a path string /// /// Parses a URL path and generates breadcrumb items automatically. /// Supports manual override by passing a custom list. /// /// # Arguments /// /// * `path` - URL path (e.g., "/home/settings/account") /// /// # Returns /// /// Vector of breadcrumb items, with the last item being the current page (no URL) /// /// # Example /// /// ```rust,ignore /// use rustwarden::templates::helpers::generate_breadcrumb; /// /// let breadcrumbs = generate_breadcrumb("/home/settings"); /// // Returns: [ /// // BreadcrumbItem { label: "Accueil", url: Some("/home") }, /// // BreadcrumbItem { label: "Paramètres", url: None } /// // ] /// ``` pub fn generate_breadcrumb(path: &str) -> Vec<BreadcrumbItem> { let mut items = Vec::new(); // Always start with "Accueil" if path != "/" { items.push(BreadcrumbItem::with_url("Accueil", "/home")); } // Parse path segments let segments: Vec<&str> = path .trim_start_matches('/') .split('/') .filter(|s| !s.is_empty()) .collect(); // Generate breadcrumb items for each segment for (index, segment) in segments.iter().enumerate() { let is_last = index == segments.len() - 1; let label = translate_path_segment(segment); if is_last { // Last item is current page (no URL) items.push(BreadcrumbItem::current(label)); } else { // Build URL up to this segment let url = format!("/{}", segments[..=index].join("/")); items.push(BreadcrumbItem::with_url(label, url)); } } items } /// Generate default sidebar content for a user /// /// Creates a standard sidebar with user information and common shortcuts. /// Can be overridden per page for contextual content. /// /// # Arguments /// /// * `username` - User's username /// * `email` - User's email /// * `initials` - User's initials for avatar /// * `avatar_color` - Color for avatar background /// * `roles` - User's roles /// /// # Returns /// /// Vector of sidebar sections with default content /// /// # Example /// /// ```rust,ignore /// use rustwarden::templates::helpers::generate_default_sidebar; /// /// let sidebar = generate_default_sidebar( /// "john_doe", /// "john@example.com", /// "JD", /// "#0082c9", /// vec!["user".to_string()] /// ); /// ``` pub fn generate_default_sidebar( username: &str, email: &str, initials: &str, _avatar_color: &str, // Reserved for future use (avatar display) roles: &[String], ) -> Vec<SidebarSection> { let mut sections = Vec::new(); // Section: Informations let mut info_section = SidebarSection::new("Informations"); // User info item (display only) info_section = info_section.with_item(SidebarItem::info(username, "👤").with_badge(initials.to_string())); // Email info item info_section = info_section.with_item(SidebarItem::info(email, "✉️")); // Roles info item if !roles.is_empty() { let roles_text = roles.join(", "); info_section = info_section.with_item(SidebarItem::info(format!("Rôles: {}", roles_text), "🔑")); } sections.push(info_section); // Section: Raccourcis let mut shortcuts_section = SidebarSection::new("Raccourcis"); shortcuts_section = shortcuts_section.with_item(SidebarItem::link( "Déconnexion", "🚪", "/api/v1/auth/logout", )); sections.push(shortcuts_section); sections } /// Generate navigation items for a specific page based on user roles /// /// Creates a contextualized navigation menu using `NavigationDTO::for_user()` and marks /// the current page as active. This helper reduces code duplication in handlers. /// /// # Arguments /// /// * `page_id` - Identifier of the current page (e.g., "home", "settings", "admin") /// * `roles` - Slice of role strings for the current user /// * `i18n_service` - I18nService for translations /// * `locale` - Locale for translations /// /// # Returns /// /// Vector of navigation items with the current page marked as active /// /// # Example /// /// ```rust,ignore /// use rustwarden::templates::helpers::generate_nav_items_for_page; /// /// let nav_items = generate_nav_items_for_page("settings", &["user".to_string()], &i18n_service, &locale).await; /// // Returns navigation items with "settings" marked as active /// ``` pub async fn generate_nav_items_for_page( page_id: &str, roles: &[String], i18n_service: &Arc<I18nService>, locale: &Locale, ) -> Vec<NavigationItemDTO> { let mut nav = NavigationDTO::for_user(roles, i18n_service, locale, None).await; // Mark the current page as active for item in &mut nav.items { if item.id == page_id { item.active = true; break; } } nav.items } /// Get CSRF token for templates /// /// This function can be used in Askama templates to get or create a CSRF token. /// The token is stored in the session and reused across requests. /// /// # Arguments /// /// * `session` - Tower session /// /// # Returns /// /// CSRF token string, or empty string if error (error is logged) /// /// # Example in template /// /// ```html /// <form method="post"> /// <input type="hidden" name="csrf_token" value="{{ csrf_token(session) }}"> /// <!-- form fields --> /// </form> /// ``` pub async fn csrf_token_helper(session: &Session) -> String { match csrf::get_or_create_csrf_token(session).await { Ok(token) => token, Err(e) => { tracing::warn!("Failed to get CSRF token: {}", e); String::new() } } } // Helper: Calculate initials from username fn calculate_initials(username: &str) -> String { let parts: Vec<&str> = username.split_whitespace().collect(); match parts.len() { 0 => "??".to_string(), 1 => { // Single word: take first 2 characters (uppercase) username.chars().take(2).collect::<String>().to_uppercase() } _ => { // Multiple words: take first char of first two words format!( "{}{}", parts[0].chars().next().unwrap_or('?'), parts[1].chars().next().unwrap_or('?') ) .to_uppercase() } } } // Helper: Calculate deterministic color from username fn calculate_avatar_color(username: &str) -> String { // Simple hash function to generate a color let hash: u32 = username .bytes() .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); // Generate a color in the blue-green range (RustWarden theme) let hue = hash % 180; // 0-180 degrees (blue to green) let saturation = 60 + (hash % 20) as u8; // 60-80% let lightness = 45 + (hash % 15) as u8; // 45-60% format!("hsl({}, {}%, {}%)", hue, saturation, lightness) } // Helper: Translate path segment to French label fn translate_path_segment(segment: &str) -> String { match segment { "home" => "Accueil".to_string(), "settings" => "Paramètres".to_string(), "account" => "Compte".to_string(), "profile" => "Profil".to_string(), "admin" => "Administration".to_string(), _ => { // Capitalize first letter let mut chars = segment.chars(); match chars.next() { None => String::new(), Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_calculate_initials() { assert_eq!(calculate_initials("john"), "JO"); assert_eq!(calculate_initials("john doe"), "JD"); assert_eq!(calculate_initials("j"), "J"); } #[test] fn test_translate_path_segment() { assert_eq!(translate_path_segment("home"), "Accueil"); assert_eq!(translate_path_segment("settings"), "Paramètres"); assert_eq!(translate_path_segment("unknown"), "Unknown"); } #[test] fn test_generate_breadcrumb() { // For "/home", the function adds "Accueil" link + "home" segment let breadcrumbs = generate_breadcrumb("/home"); assert_eq!(breadcrumbs.len(), 2); assert_eq!(breadcrumbs[0].label, "Accueil"); assert_eq!(breadcrumbs[0].url, "/home"); assert_eq!(breadcrumbs[1].label, "Accueil"); // "home" translates to "Accueil" assert_eq!(breadcrumbs[1].url, ""); // Last item has no URL let breadcrumbs = generate_breadcrumb("/home/settings"); assert_eq!(breadcrumbs.len(), 3); assert_eq!(breadcrumbs[0].label, "Accueil"); assert_eq!(breadcrumbs[0].url, "/home"); assert_eq!(breadcrumbs[1].label, "Accueil"); // "home" segment assert_eq!(breadcrumbs[1].url, "/home"); assert_eq!(breadcrumbs[2].label, "Paramètres"); assert_eq!(breadcrumbs[2].url, ""); } #[test] fn test_generate_default_sidebar() { let sidebar = generate_default_sidebar( "john_doe", "john@example.com", "JD", "#0082c9", &["user".to_string()], ); assert_eq!(sidebar.len(), 2); assert_eq!(sidebar[0].title, "Informations"); assert_eq!(sidebar[1].title, "Raccourcis"); } #[tokio::test] async fn test_generate_nav_items_for_page_non_admin() { let i18n_service = Arc::new(I18nService::new()); let locale = Locale("fr".to_string()); let nav_items = generate_nav_items_for_page("home", &[], &i18n_service, &locale).await; assert_eq!(nav_items.len(), 1); let home = nav_items.iter().find(|item| item.id == "home").unwrap(); assert!(home.active); assert!(!nav_items.iter().any(|item| item.id == "admin")); } #[tokio::test] async fn test_generate_nav_items_for_page_admin() { let i18n_service = Arc::new(I18nService::new()); let locale = Locale("fr".to_string()); let nav_items = generate_nav_items_for_page("admin", &["admin".to_string()], &i18n_service, &locale) .await; assert_eq!(nav_items.len(), 2); let admin = nav_items.iter().find(|item| item.id == "admin").unwrap(); assert!(admin.active); let home = nav_items.iter().find(|item| item.id == "home").unwrap(); assert!(!home.active); } #[tokio::test] async fn test_generate_nav_items_for_page_home() { let i18n_service = Arc::new(I18nService::new()); let locale = Locale("fr".to_string()); let nav_items = generate_nav_items_for_page("home", &["user".to_string()], &i18n_service, &locale) .await; assert_eq!(nav_items.len(), 1); let home = nav_items.iter().find(|item| item.id == "home").unwrap(); assert!(home.active); } }
GitRust