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);
    }
}