admin_handler.rs 3485 lignes · 128420 octets
/// Admin Handler
/// Feature 009: Admin & Settings
///
/// Handles admin endpoints including dashboard and user management.
use askama::Template;
use axum::{
    extract::{Query, State},
    response::{IntoResponse, Json},
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use std::sync::Arc;
use tower_sessions::Session;

use crate::dto::{DashboardCardDTO, NavigationDTO};
use crate::template_helpers::build_base_context_with_claims;
use axum::extract::Extension;
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::impl_template_response;
use rustwarden_core::middleware::csrf;
use rustwarden_core::models::{role, user_role};
use rustwarden_core::services::app_settings_service::AppSettingsService;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};

#[derive(Debug, Clone)]
struct NavItem {
    id: String,
    label: String,
    icon: String,
    url: String,
    active: bool,
    enabled: bool,
    badge: String,
    has_badge: bool,
    disabled_message: String,
    has_disabled_message: bool,
}

async fn build_admin_nav_items(
    roles: &[String],
    i18n_service: &Arc<I18nService>,
    locale: &Locale,
    db: &DatabaseConnection,
) -> Vec<NavItem> {
    let navigation = NavigationDTO::for_user(roles, i18n_service, locale, Some(db)).await;
    navigation
        .items
        .iter()
        .map(|item| NavItem {
            id: item.id.clone(),
            label: item.label.clone(),
            icon: item.icon.clone(),
            url: item.url.clone(),
            active: item.id == "admin",
            enabled: item.enabled,
            badge: item.badge.clone(),
            has_badge: item.has_badge,
            disabled_message: item.disabled_message.clone(),
            has_disabled_message: item.has_disabled_message,
        })
        .collect()
}

/// Load app_domain and app_description from the database with sane defaults.
async fn load_app_domain_and_description(
    db: &DatabaseConnection,
) -> Result<(String, String), AppError> {
    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(|| "User accounts and authentication".to_string());
    Ok((app_domain, app_description))
}

/// Inline DTO: Create user request from admin form
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CreateUserRequest {
    /// Login name for the new user.
    pub username: String,
    /// Email address for the new user.
    pub email: String,
    /// Initial password for the new user.
    pub password: String,
    /// Roles to assign to the new user.
    #[serde(default)]
    #[serde(deserialize_with = "deserialize_string_or_vec")]
    pub roles: Vec<String>,
}

/// Inline DTO: Reset password request
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ResetPasswordRequest {
    /// New password to set, or `None` to generate one automatically.
    pub new_password: Option<String>,
}

/// Inline DTO: Reset password response
#[derive(Debug, Clone, serde::Serialize)]
pub struct ResetPasswordResponse {
    /// The new password (shown once when auto-generated).
    pub password: Option<String>,
    /// Human-readable confirmation message.
    pub message: String,
}

fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de;

    struct StringOrVec;

    impl<'de> de::Visitor<'de> for StringOrVec {
        type Value = Vec<String>;

        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("string or list of strings")
        }

        fn visit_str<E: de::Error>(self, value: &str) -> Result<Vec<String>, E> {
            Ok(vec![value.to_string()])
        }

        fn visit_seq<S: de::SeqAccess<'de>>(self, mut seq: S) -> Result<Vec<String>, S::Error> {
            let mut vec = Vec::new();
            while let Some(value) = seq.next_element()? {
                vec.push(value);
            }
            Ok(vec)
        }
    }

    deserializer.deserialize_any(StringOrVec)
}

/// Form message fragment template for HTMX responses
#[derive(Template)]
#[template(path = "fragments/form_message.html")]
struct FormMessageTemplate {
    message_type: String, // "success" or "error"
    message: String,
}

impl_template_response!(FormMessageTemplate);

/// Admin dashboard template
#[derive(Template)]
#[template(path = "admin/dashboard.html")]
#[allow(dead_code)]
struct AdminDashboardTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    // User fields
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    // Dashboard statistics
    total_users: u64,
    active_sessions: u64,
    total_admins: u64,
    last_activity: String,
    // Audit log statistics
    has_audit_logs: bool,
    audit_total_logs: u64,
    audit_logs_last_24h: u64,
    audit_debug_count: u64,
    audit_info_count: u64,
    audit_warn_count: u64,
    audit_error_count: u64,
    // User menu
    user_menu_profile: String,
    user_menu_logout: String,

    // Translated strings
    breadcrumb_home: String,
    breadcrumb_admin: String,
    heading: String,
    users_card_title: String,
    users_card_subtitle: String,
    access_button: String,
    sessions_card_title: String,
    sessions_card_subtitle: String,
    admins_card_title: String,
    admins_card_subtitle: String,
    last_activity_card_title: String,
    last_activity_card_subtitle: String,
    audit_section_title: String,
    audit_total_logs_label: String,
    audit_last_24h: String,
    audit_by_level: String,
    chart_label: String,
    view_logs: String,
    logs_card_title: String,
    logs_card_desc: String,
    roles_card_title: String,
    roles_card_desc: String,
    roles_stat_title: String,
    total_roles: u64,
    settings_card_title: String,
    settings_card_desc: String,
    apps_card_title: String,
    apps_card_desc: String,
    // Extra cards injected by companion crates (e.g.
    // `rustwarden-fediverse-ui`) via
    // `RustwardenBuilder::add_dashboard_card`. Empty when no extension
    // is wired.
    extra_cards: Vec<LocalizedDashboardCard>,
}

/// Pre-translated dashboard card consumed by `admin/dashboard.html`.
///
/// Built at render time from the [`DashboardCardDTO`] list injected via
/// `Extension<Vec<DashboardCardDTO>>`; resolves the i18n keys against
/// the active locale so the template can render directly.
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct LocalizedDashboardCard {
    title: String,
    desc: String,
    href: String,
    icon: String,
}

rustwarden_core::impl_template_response!(AdminDashboardTemplate);

/// Handler for GET /admin - Admin Dashboard (PROTECTED - Admin only)
pub async fn admin_dashboard(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    extra_cards: Option<Extension<Vec<DashboardCardDTO>>>,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    // Compute basic stats from DB
    use rustwarden_core::models::user;
    let all_users = user::Entity::find().all(&db).await?;
    let total_users = all_users.len() as u64;

    let active_sessions = rustwarden_core::services::refresh_token_service::RefreshTokenService::count_active_sessions(&db)
        .await
        .unwrap_or(0);

    // Count admins
    let admin_role = role::Entity::find()
        .filter(role::Column::Name.eq("admin"))
        .one(&db)
        .await?;
    let total_admins = if let Some(admin_role) = admin_role {
        user_role::Entity::find()
            .filter(user_role::Column::RoleId.eq(admin_role.id))
            .all(&db)
            .await?
            .len() as u64
    } else {
        0
    };

    use rustwarden_core::services::role_service::RoleService;
    let all_roles_list = RoleService::list_all_roles(&db).await?;
    let total_roles = all_roles_list.len() as u64;

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
    let audit_stats = load_audit_stats(&db).await;

    let template = AdminDashboardTemplate {
        page_title: i18n_service
            .translate("admin.dashboard.title", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        total_users,
        active_sessions,
        total_admins,
        last_activity: "N/A".to_string(),
        has_audit_logs: audit_stats.has_rows,
        audit_total_logs: audit_stats.total,
        audit_logs_last_24h: audit_stats.last_24h,
        audit_debug_count: audit_stats.debug,
        audit_info_count: audit_stats.info,
        audit_warn_count: audit_stats.warn,
        audit_error_count: audit_stats.error,
        total_roles,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        heading: i18n_service
            .translate("admin.dashboard.heading", locale_str)
            .await,
        users_card_title: i18n_service
            .translate("admin.dashboard.users_card_title", locale_str)
            .await,
        users_card_subtitle: i18n_service
            .translate("admin.dashboard.users_card_subtitle", locale_str)
            .await,
        access_button: i18n_service
            .translate("admin.dashboard.access_button", locale_str)
            .await,
        sessions_card_title: i18n_service
            .translate("admin.dashboard.sessions_card_title", locale_str)
            .await,
        sessions_card_subtitle: i18n_service
            .translate("admin.dashboard.sessions_card_subtitle", locale_str)
            .await,
        admins_card_title: i18n_service
            .translate("admin.dashboard.admins_card_title", locale_str)
            .await,
        admins_card_subtitle: i18n_service
            .translate("admin.dashboard.admins_card_subtitle", locale_str)
            .await,
        last_activity_card_title: i18n_service
            .translate("admin.dashboard.last_activity_card_title", locale_str)
            .await,
        last_activity_card_subtitle: i18n_service
            .translate("admin.dashboard.last_activity_card_subtitle", locale_str)
            .await,
        audit_section_title: i18n_service
            .translate("admin.dashboard.audit_section_title", locale_str)
            .await,
        audit_total_logs_label: i18n_service
            .translate("admin.dashboard.audit_total_logs", locale_str)
            .await,
        audit_last_24h: i18n_service
            .translate("admin.dashboard.audit_last_24h", locale_str)
            .await,
        audit_by_level: i18n_service
            .translate("admin.dashboard.audit_by_level", locale_str)
            .await,
        chart_label: i18n_service
            .translate("admin.dashboard.chart_label", locale_str)
            .await,
        view_logs: i18n_service
            .translate("admin.dashboard.view_logs", locale_str)
            .await,
        logs_card_title: i18n_service
            .translate("admin.dashboard.logs_card_title", locale_str)
            .await,
        logs_card_desc: i18n_service
            .translate("admin.dashboard.logs_card_desc", locale_str)
            .await,
        roles_card_title: i18n_service
            .translate("admin.dashboard.roles_card_title", locale_str)
            .await,
        roles_card_desc: i18n_service
            .translate("admin.dashboard.roles_card_desc", locale_str)
            .await,
        roles_stat_title: i18n_service
            .translate("admin.dashboard.roles_stat_title", locale_str)
            .await,
        settings_card_title: i18n_service
            .translate("admin.dashboard.settings_card_title", locale_str)
            .await,
        settings_card_desc: i18n_service
            .translate("admin.dashboard.settings_card_desc", locale_str)
            .await,
        apps_card_title: i18n_service
            .translate("admin.dashboard.apps_card_title", locale_str)
            .await,
        apps_card_desc: i18n_service
            .translate("admin.dashboard.apps_card_desc", locale_str)
            .await,
        extra_cards: {
            let cards = extra_cards.map(|Extension(c)| c).unwrap_or_default();
            let mut out = Vec::with_capacity(cards.len());
            for card in cards {
                out.push(LocalizedDashboardCard {
                    title: i18n_service.translate(&card.title_key, locale_str).await,
                    desc: i18n_service.translate(&card.desc_key, locale_str).await,
                    href: card.href,
                    icon: card.icon,
                });
            }
            out
        },
    };

    Ok(template)
}

struct AuditStats {
    has_rows: bool,
    total: u64,
    last_24h: u64,
    debug: u64,
    info: u64,
    warn: u64,
    error: u64,
}

/// Counts rows in `audit_logs`. A missing table leaves the dashboard section hidden.
async fn load_audit_stats(db: &DatabaseConnection) -> AuditStats {
    use rustwarden_core::models::audit_log;

    let total = match audit_log::Entity::find().count(db).await {
        Ok(n) => n,
        Err(e) => {
            tracing::warn!("audit_logs count failed: {e}");
            return AuditStats {
                has_rows: false,
                total: 0,
                last_24h: 0,
                debug: 0,
                info: 0,
                warn: 0,
                error: 0,
            };
        }
    };
    let since = chrono::Utc::now() - chrono::Duration::hours(24);
    let last_24h = audit_log::Entity::find()
        .filter(audit_log::Column::CreatedAt.gte(since))
        .count(db)
        .await
        .unwrap_or(0);
    let count_level = |level: &'static str| async move {
        audit_log::Entity::find()
            .filter(audit_log::Column::Level.eq(level))
            .count(db)
            .await
            .unwrap_or(0)
    };
    AuditStats {
        has_rows: total > 0,
        total,
        last_24h,
        debug: count_level("DEBUG").await,
        info: count_level("INFO").await,
        warn: count_level("WARN").await,
        error: count_level("ERROR").await,
    }
}

struct AuditLogRow {
    created_at: String,
    level: String,
    action: String,
    message: String,
}

#[derive(Template)]
#[template(path = "admin/logs.html")]
struct AuditLogsTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    user_menu_profile: String,
    user_menu_logout: String,
    breadcrumb_home: String,
    breadcrumb_admin: String,
    heading: String,
    empty: String,
    col_date: String,
    col_level: String,
    col_action: String,
    col_message: String,
    rows: Vec<AuditLogRow>,
    has_rows: bool,
}

rustwarden_core::impl_template_response!(AuditLogsTemplate);

/// GET /admin/logs — latest audit_logs rows (PROTECTED - Admin only).
pub async fn admin_logs(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::models::audit_log;

    let locale_str = locale.as_str();
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;
    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let logs = audit_log::Entity::find()
        .order_by_desc(audit_log::Column::CreatedAt)
        .limit(100)
        .all(&db)
        .await
        .unwrap_or_default();
    let rows: Vec<AuditLogRow> = logs
        .into_iter()
        .map(|log| AuditLogRow {
            created_at: log.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
            level: log.level,
            action: log.action,
            message: log.message,
        })
        .collect();
    let has_rows = !rows.is_empty();

    Ok(AuditLogsTemplate {
        page_title: i18n_service
            .translate("admin.logs.page_title", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        heading: i18n_service
            .translate("admin.logs.heading", locale_str)
            .await,
        empty: i18n_service.translate("admin.logs.empty", locale_str).await,
        col_date: i18n_service
            .translate("admin.logs.col_date", locale_str)
            .await,
        col_level: i18n_service
            .translate("admin.logs.col_level", locale_str)
            .await,
        col_action: i18n_service
            .translate("admin.logs.col_action", locale_str)
            .await,
        col_message: i18n_service
            .translate("admin.logs.col_message", locale_str)
            .await,
        rows,
        has_rows,
    })
}

/// Query parameters for user list endpoint
#[derive(Debug, Deserialize)]
pub struct UserListQuery {
    /// Page number (1-based).
    pub page: Option<u64>,
    /// Number of results per page.
    pub per_page: Option<u64>,
    /// Optional full-text search filter.
    pub search: Option<String>,
    /// Filter by role name.
    pub role_filter: Option<String>,
}

/// User role display info
#[derive(Debug, Clone)]
struct RoleDisplay {
    name: String,
    is_admin: bool,
}

/// Role display info for forms
#[derive(Debug, Clone)]
struct RoleFormDisplayWithSelected {
    name: String,
    description: String,
    is_selected: bool,
}

/// User info for template display
#[derive(Debug, Clone)]
struct UserInfoDisplay {
    id: uuid::Uuid,
    username: String,
    email: String,
    roles: Vec<RoleDisplay>,
    created_at: String,
    has_last_login: bool,
    last_login_at: String,
    has_last_login_ip: bool,
    last_login_ip: String,
    totp_enabled: bool,
}

/// User list template
#[derive(Template)]
#[template(path = "admin/users_list.html")]
#[allow(dead_code)]
struct UserListTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    users: Vec<UserInfoDisplay>,
    total: u64,
    page: u64,
    per_page: u64,
    total_pages: u64,
    current_search: String,
    current_role_filter: String,
    has_search: bool,
    has_role_filter: bool,
    is_admin_filter: bool,
    is_user_filter: bool,
    locale: String,
    // User menu
    user_menu_profile: String,
    user_menu_logout: String,

    // Translated strings
    title: String,
    breadcrumb_home: String,
    breadcrumb_admin: String,
    breadcrumb_users: String,
    create_user: String,
    search_label: String,
    search_placeholder: String,
    role_filter_label: String,
    all_roles: String,
    per_page_label: String,
    search_button: String,
    results_summary: String,
    page_info: String,
    table_username: String,
    table_email: String,
    table_roles: String,
    table_created_at: String,
    table_last_login: String,
    table_actions: String,
    never: String,
    edit: String,
    delete: String,
    reset_password: String,
    no_users: String,
    reset_password_title: String,
    reset_generate: String,
    reset_custom: String,
    reset_email: String,
    reset_new_password_label: String,
    reset_new_password_placeholder: String,
    reset_password_requirements: String,
    reset_invalidate_sessions: String,
    reset_invalidate_sessions_desc: String,
    reset_button: String,
    delete_confirm_title: String,
    delete_confirm_message: String,
    delete_confirm_username: String,
    delete_confirm_email: String,
    delete_irreversible: String,
    delete_button: String,
    delete_error: String,
    network_error: String,
    cancel: String,
    // JS modal strings
    resetting: String,
    reset_by_email_title: String,
    close: String,
    password_reset_success_title: String,
    password_warning_once: String,
    new_password_label: String,
    copy: String,
    password_copied: String,
    password_set_success: String,
    reset_error: String,
    pw_min_length: usize,
}

rustwarden_core::impl_template_response!(UserListTemplate);

/// User form template (for create/edit)
#[derive(Template)]
#[template(path = "admin/user_form.html")]
#[allow(dead_code)]
struct UserFormTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    mode: String,
    user_id: String,
    form_username: String,
    form_email: String,
    form_roles: Vec<String>,
    available_roles: Vec<RoleFormDisplayWithSelected>,
    must_change_password: bool,
    user_menu_profile: String,
    user_menu_logout: String,

    // Translated strings
    breadcrumb_home: String,
    breadcrumb_admin: String,
    breadcrumb_users: String,
    breadcrumb_mode: String,
    form_title: String,
    back_to_list: String,
    username_label: String,
    username_placeholder: String,
    username_pattern: String,
    username_readonly_hint: String,
    email_label: String,
    email_placeholder: String,
    email_change_hint: String,
    password_label: String,
    password_optional: String,
    password_placeholder: String,
    password_hint: String,
    roles_label: String,
    roles_hint: String,
    must_change_password_label: String,
    must_change_password_desc: String,
    cancel: String,
    submit_label: String,
}

rustwarden_core::impl_template_response!(UserFormTemplate);

/// Handler for GET /admin/users - User List (PROTECTED - Admin only)
///
/// Displays a paginated list of users with search and role filtering.
pub async fn list_users(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
    Query(params): Query<UserListQuery>,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    // Clamp `page` to >= 1 — `?page=0` would underflow `(page - 1) * per_page`
    // below and crash the worker thread (`u64` subtraction).
    let page = params.page.unwrap_or(1).max(1);
    let per_page = params.per_page.unwrap_or(50).max(1);
    let search = params.search;
    let role_filter = params.role_filter;

    // Get all users from DB
    use rustwarden_core::models::user;
    let mut all_users = user::Entity::find().all(&db).await?;

    // Apply search filter
    if let Some(ref s) = search {
        if !s.is_empty() {
            let s_lower = s.to_lowercase();
            all_users.retain(|u| {
                u.username.to_lowercase().contains(&s_lower)
                    || u.email.to_lowercase().contains(&s_lower)
            });
        }
    }

    // Apply role filter
    if let Some(ref rf) = role_filter {
        if !rf.is_empty() {
            let role_opt = role::Entity::find()
                .filter(role::Column::Name.eq(rf.as_str()))
                .one(&db)
                .await?;
            if let Some(role_model) = role_opt {
                let user_roles_for_filter = user_role::Entity::find()
                    .filter(user_role::Column::RoleId.eq(role_model.id))
                    .all(&db)
                    .await?;
                let user_ids: Vec<uuid::Uuid> =
                    user_roles_for_filter.iter().map(|ur| ur.user_id).collect();
                all_users.retain(|u| user_ids.contains(&u.id));
            }
        }
    }

    let total = all_users.len() as u64;
    let total_pages = if total == 0 {
        1
    } else {
        total.div_ceil(per_page)
    };
    let start = ((page - 1) * per_page) as usize;
    let page_users: Vec<_> = all_users
        .into_iter()
        .skip(start)
        .take(per_page as usize)
        .collect();

    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    let mut users_display = Vec::new();
    for u in page_users {
        let user_roles_db = user_role::Entity::find()
            .filter(user_role::Column::UserId.eq(u.id))
            .all(&db)
            .await?;
        let mut role_names = Vec::new();
        for ur in &user_roles_db {
            if let Some(r) = role::Entity::find_by_id(ur.role_id).one(&db).await? {
                role_names.push(r.name);
            }
        }

        let roles_display: Vec<RoleDisplay> = role_names
            .into_iter()
            .map(|name| RoleDisplay {
                is_admin: name == "admin",
                name,
            })
            .collect();

        let last_login_at = u.last_login_at;
        let (has_last_login, last_login_str) = match last_login_at {
            Some(dt) => (true, dt.format("%d/%m/%Y %H:%M").to_string()),
            None => (false, String::new()),
        };

        // Check TOTP status
        let totp_enabled =
            rustwarden_core::services::totp_service::TotpService::is_totp_enabled(&db, u.id)
                .await
                .unwrap_or(false);

        users_display.push(UserInfoDisplay {
            id: u.id,
            username: u.username,
            email: u.email,
            roles: roles_display,
            created_at: u.created_at.format("%d/%m/%Y %H:%M").to_string(),
            has_last_login,
            last_login_at: last_login_str,
            has_last_login_ip: u.last_login_ip.is_some(),
            last_login_ip: u.last_login_ip.unwrap_or_default(),
            totp_enabled,
        });
    }

    let results_summary_tpl = i18n_service
        .translate("admin.users_list.results_summary", locale_str)
        .await;
    let results_summary = results_summary_tpl
        .replace("{count}", &users_display.len().to_string())
        .replace("{total}", &total.to_string());

    let page_info = if total_pages > 1 {
        let page_info_tpl = i18n_service
            .translate("admin.users_list.page_info", locale_str)
            .await;
        page_info_tpl
            .replace("{page}", &page.to_string())
            .replace("{total_pages}", &total_pages.to_string())
    } else {
        String::new()
    };

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = UserListTemplate {
        page_title: i18n_service
            .translate("admin.users_list.page_title", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        locale: locale_str.to_string(),
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        users: users_display,
        total,
        page,
        per_page,
        total_pages,
        current_search: search.clone().unwrap_or_default(),
        current_role_filter: role_filter.clone().unwrap_or_default(),
        has_search: search.as_ref().is_some_and(|s| !s.is_empty()),
        has_role_filter: role_filter.as_ref().is_some_and(|r| !r.is_empty()),
        is_admin_filter: role_filter.as_ref().map(|r| r == "admin").unwrap_or(false),
        is_user_filter: role_filter.as_ref().map(|r| r == "user").unwrap_or(false),
        title: i18n_service
            .translate("admin.users_list.title", locale_str)
            .await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
        create_user: i18n_service
            .translate("admin.users_list.create_user", locale_str)
            .await,
        search_label: i18n_service
            .translate("admin.users_list.search_label", locale_str)
            .await,
        search_placeholder: i18n_service
            .translate("admin.users_list.search_placeholder", locale_str)
            .await,
        role_filter_label: i18n_service
            .translate("admin.users_list.role_filter_label", locale_str)
            .await,
        all_roles: i18n_service
            .translate("admin.users_list.all_roles", locale_str)
            .await,
        per_page_label: i18n_service
            .translate("admin.users_list.per_page_label", locale_str)
            .await,
        search_button: i18n_service
            .translate("admin.users_list.search_button", locale_str)
            .await,
        results_summary,
        page_info,
        table_username: i18n_service
            .translate("admin.users_list.table_username", locale_str)
            .await,
        table_email: i18n_service
            .translate("admin.users_list.table_email", locale_str)
            .await,
        table_roles: i18n_service
            .translate("admin.users_list.table_roles", locale_str)
            .await,
        table_created_at: i18n_service
            .translate("admin.users_list.table_created_at", locale_str)
            .await,
        table_last_login: i18n_service
            .translate("admin.users_list.table_last_login", locale_str)
            .await,
        table_actions: i18n_service
            .translate("admin.users_list.table_actions", locale_str)
            .await,
        never: i18n_service
            .translate("admin.users_list.never", locale_str)
            .await,
        edit: i18n_service
            .translate("admin.users_list.edit", locale_str)
            .await,
        delete: i18n_service
            .translate("admin.users_list.delete", locale_str)
            .await,
        reset_password: i18n_service
            .translate("admin.users_list.reset_password", locale_str)
            .await,
        no_users: i18n_service
            .translate("admin.users_list.no_users", locale_str)
            .await,
        reset_password_title: i18n_service
            .translate("admin.users_list.reset_password_title", locale_str)
            .await,
        reset_generate: i18n_service
            .translate("admin.users_list.reset_generate", locale_str)
            .await,
        reset_custom: i18n_service
            .translate("admin.users_list.reset_custom", locale_str)
            .await,
        reset_email: i18n_service
            .translate("admin.users_list.reset_email", locale_str)
            .await,
        reset_new_password_label: i18n_service
            .translate("admin.users_list.reset_new_password_label", locale_str)
            .await,
        reset_new_password_placeholder: i18n_service
            .translate(
                "admin.users_list.reset_new_password_placeholder",
                locale_str,
            )
            .await,
        reset_password_requirements: i18n_service
            .translate("admin.users_list.reset_password_requirements", locale_str)
            .await,
        reset_invalidate_sessions: i18n_service
            .translate("admin.users_list.reset_invalidate_sessions", locale_str)
            .await,
        reset_invalidate_sessions_desc: i18n_service
            .translate(
                "admin.users_list.reset_invalidate_sessions_desc",
                locale_str,
            )
            .await,
        reset_button: i18n_service
            .translate("admin.users_list.reset_button", locale_str)
            .await,
        delete_confirm_title: i18n_service
            .translate("admin.users_list.delete_confirm_title", locale_str)
            .await,
        delete_confirm_message: i18n_service
            .translate("admin.users_list.delete_confirm_message", locale_str)
            .await,
        delete_confirm_username: i18n_service
            .translate("admin.users_list.delete_confirm_username", locale_str)
            .await,
        delete_confirm_email: i18n_service
            .translate("admin.users_list.delete_confirm_email", locale_str)
            .await,
        delete_irreversible: i18n_service
            .translate("admin.users_list.delete_irreversible", locale_str)
            .await,
        delete_button: i18n_service
            .translate("admin.users_list.delete_button", locale_str)
            .await,
        delete_error: i18n_service
            .translate("admin.users_list.delete_error", locale_str)
            .await,
        network_error: i18n_service
            .translate("admin.users_list.network_error", locale_str)
            .await,
        cancel: i18n_service.translate("common.cancel", locale_str).await,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        resetting: i18n_service
            .translate("admin.users_list.resetting", locale_str)
            .await,
        reset_by_email_title: i18n_service
            .translate("admin.users_list.reset_by_email_title", locale_str)
            .await,
        close: i18n_service
            .translate("admin.users_list.close", locale_str)
            .await,
        password_reset_success_title: i18n_service
            .translate("admin.users_list.password_reset_success_title", locale_str)
            .await,
        password_warning_once: i18n_service
            .translate("admin.users_list.password_warning_once", locale_str)
            .await,
        new_password_label: i18n_service
            .translate("admin.users_list.new_password_label", locale_str)
            .await,
        copy: i18n_service
            .translate("admin.users_list.copy", locale_str)
            .await,
        password_copied: i18n_service
            .translate("admin.users_list.password_copied", locale_str)
            .await,
        password_set_success: i18n_service
            .translate("admin.users_list.password_set_success", locale_str)
            .await,
        reset_error: i18n_service
            .translate("admin.users_list.reset_error", locale_str)
            .await,
        pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(
            &db,
        )
        .await
        .min_length,
    };

    Ok(template)
}

/// Handler for GET /admin/users/create - User creation form (PROTECTED - Admin only)
pub async fn create_user_form(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    use rustwarden_core::services::role_service::RoleService;
    let all_roles = RoleService::list_all_roles(&db).await?;

    let available_roles_display: Vec<RoleFormDisplayWithSelected> = all_roles
        .into_iter()
        .map(|r| RoleFormDisplayWithSelected {
            name: r.name.clone(),
            description: r.description.unwrap_or_default(),
            is_selected: false,
        })
        .collect();

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = UserFormTemplate {
        page_title: i18n_service
            .translate("admin.users_list.create_user", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        mode: "create".to_string(),
        user_id: String::new(),
        form_username: String::new(),
        form_email: String::new(),
        form_roles: Vec::new(),
        available_roles: available_roles_display,
        must_change_password: false,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
        breadcrumb_mode: i18n_service
            .translate("admin.user_form.breadcrumb_create", locale_str)
            .await,
        form_title: i18n_service
            .translate("admin.user_form.title_create", locale_str)
            .await,
        back_to_list: i18n_service
            .translate("admin.user_form.back_to_list", locale_str)
            .await,
        username_label: i18n_service
            .translate("admin.user_form.username_label", locale_str)
            .await,
        username_placeholder: i18n_service
            .translate("admin.user_form.username_placeholder", locale_str)
            .await,
        username_pattern: i18n_service
            .translate("admin.user_form.username_pattern", locale_str)
            .await,
        username_readonly_hint: i18n_service
            .translate("admin.user_form.username_readonly_hint", locale_str)
            .await,
        email_label: i18n_service
            .translate("admin.user_form.email_label", locale_str)
            .await,
        email_placeholder: i18n_service
            .translate("admin.user_form.email_placeholder", locale_str)
            .await,
        email_change_hint: i18n_service
            .translate("admin.user_form.email_change_hint", locale_str)
            .await,
        password_label: i18n_service
            .translate("admin.user_form.password_label", locale_str)
            .await,
        password_optional: i18n_service
            .translate("admin.user_form.password_optional", locale_str)
            .await,
        password_placeholder: i18n_service
            .translate("admin.user_form.password_placeholder", locale_str)
            .await,
        password_hint: i18n_service
            .translate("admin.user_form.password_hint", locale_str)
            .await,
        roles_label: i18n_service
            .translate("admin.user_form.roles_label", locale_str)
            .await,
        roles_hint: i18n_service
            .translate("admin.user_form.roles_hint", locale_str)
            .await,
        must_change_password_label: i18n_service
            .translate("admin.user_form.must_change_password_label", locale_str)
            .await,
        must_change_password_desc: i18n_service
            .translate("admin.user_form.must_change_password_desc", locale_str)
            .await,
        cancel: i18n_service
            .translate("admin.user_form.cancel", locale_str)
            .await,
        submit_label: i18n_service
            .translate("admin.user_form.submit_create", locale_str)
            .await,
    };

    Ok(template)
}

/// Handler for POST /admin/users - Create user (PROTECTED - Admin only)
pub async fn create_user(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::Form(form_data): axum::Form<CreateUserRequest>,
) -> Result<impl IntoResponse, AppError> {
    if form_data.roles.is_empty() {
        let error_template = FormMessageTemplate {
            message_type: "error".to_string(),
            message: "Veuillez sélectionner au moins un rôle".to_string(),
        };
        return Ok(error_template.into_response());
    }

    use rustwarden_core::services::password_service::PasswordService;
    use rustwarden_core::services::role_service::RoleService;
    use rustwarden_core::services::user_service::UserService;

    // Check if username or email already exists
    if UserService::get_user_by_username(&db, &form_data.username)
        .await?
        .is_some()
    {
        let error_template = FormMessageTemplate {
            message_type: "error".to_string(),
            message: format!("Le nom d'utilisateur '{}' existe déjà", form_data.username),
        };
        return Ok(error_template.into_response());
    }

    if UserService::get_user_by_email(&db, &form_data.email)
        .await?
        .is_some()
    {
        let error_template = FormMessageTemplate {
            message_type: "error".to_string(),
            message: format!("L'email '{}' est déjà utilisé", form_data.email),
        };
        return Ok(error_template.into_response());
    }

    let password_hash = PasswordService::hash_password(&form_data.password).await?;
    let user = UserService::create_user(
        &db,
        form_data.username,
        form_data.email,
        password_hash,
        false,
    )
    .await?;

    for role_name in &form_data.roles {
        if let Some(r) = RoleService::get_role_by_name(&db, role_name).await? {
            UserService::assign_role_to_user(&db, user.id, r.id).await?;
        }
    }

    tracing::info!("Admin {} created user {}", claims.sub, user.username);

    let success_template = FormMessageTemplate {
        message_type: "success".to_string(),
        message: "Utilisateur créé avec succès.".to_string(),
    };

    let mut response = success_template.into_response();
    response.headers_mut().insert(
        axum::http::HeaderName::from_static("hx-redirect"),
        axum::http::HeaderValue::from_static("/admin/users"),
    );
    Ok(response)
}

/// Handler for GET /admin/users/:id - Edit user form (PROTECTED - Admin only)
pub async fn edit_user_form(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    use rustwarden_core::services::user_service::UserService;
    let user = UserService::get_user_by_id(&db, user_id)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

    let user_roles = user_role::Entity::find()
        .filter(user_role::Column::UserId.eq(user_id))
        .all(&db)
        .await?;

    let mut current_role_names = Vec::new();
    for ur in user_roles {
        if let Some(r) = role::Entity::find_by_id(ur.role_id).one(&db).await? {
            current_role_names.push(r.name);
        }
    }

    use rustwarden_core::services::role_service::RoleService;
    let all_roles = RoleService::list_all_roles(&db).await?;

    let available_roles_display: Vec<RoleFormDisplayWithSelected> = all_roles
        .iter()
        .map(|r| RoleFormDisplayWithSelected {
            name: r.name.clone(),
            description: r.description.clone().unwrap_or_default(),
            is_selected: current_role_names.contains(&r.name),
        })
        .collect();

    let edit_title = i18n_service
        .translate("admin.users_list.edit_user_title", locale_str)
        .await;

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = UserFormTemplate {
        page_title: format!("{} {}", edit_title, user.username),
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        mode: "edit".to_string(),
        user_id: user_id.to_string(),
        form_username: user.username,
        form_email: user.email,
        form_roles: current_role_names,
        available_roles: available_roles_display,
        must_change_password: user.must_change_password,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await,
        breadcrumb_mode: i18n_service
            .translate("admin.user_form.breadcrumb_edit", locale_str)
            .await,
        form_title: i18n_service
            .translate("admin.user_form.title_edit", locale_str)
            .await,
        back_to_list: i18n_service
            .translate("admin.user_form.back_to_list", locale_str)
            .await,
        username_label: i18n_service
            .translate("admin.user_form.username_label", locale_str)
            .await,
        username_placeholder: i18n_service
            .translate("admin.user_form.username_placeholder", locale_str)
            .await,
        username_pattern: i18n_service
            .translate("admin.user_form.username_pattern", locale_str)
            .await,
        username_readonly_hint: i18n_service
            .translate("admin.user_form.username_readonly_hint", locale_str)
            .await,
        email_label: i18n_service
            .translate("admin.user_form.email_label", locale_str)
            .await,
        email_placeholder: i18n_service
            .translate("admin.user_form.email_placeholder", locale_str)
            .await,
        email_change_hint: i18n_service
            .translate("admin.user_form.email_change_hint", locale_str)
            .await,
        password_label: i18n_service
            .translate("admin.user_form.password_label", locale_str)
            .await,
        password_optional: i18n_service
            .translate("admin.user_form.password_optional", locale_str)
            .await,
        password_placeholder: i18n_service
            .translate("admin.user_form.password_placeholder", locale_str)
            .await,
        password_hint: i18n_service
            .translate("admin.user_form.password_hint", locale_str)
            .await,
        roles_label: i18n_service
            .translate("admin.user_form.roles_label", locale_str)
            .await,
        roles_hint: i18n_service
            .translate("admin.user_form.roles_hint", locale_str)
            .await,
        must_change_password_label: i18n_service
            .translate("admin.user_form.must_change_password_label", locale_str)
            .await,
        must_change_password_desc: i18n_service
            .translate("admin.user_form.must_change_password_desc", locale_str)
            .await,
        cancel: i18n_service
            .translate("admin.user_form.cancel", locale_str)
            .await,
        submit_label: i18n_service
            .translate("admin.user_form.submit_edit", locale_str)
            .await,
    };

    Ok(template)
}

/// Form data for user update
#[derive(serde::Deserialize)]
pub struct UpdateUserFormData {
    /// Updated email address.
    pub email: String,
    /// Updated list of roles.
    #[serde(default)]
    #[serde(deserialize_with = "deserialize_string_or_vec")]
    pub roles: Vec<String>,
    /// Whether to force a password change on next login.
    #[serde(default)]
    pub must_change_password: Option<bool>,
}

/// Handler for PUT /admin/users/:id - Update user (PROTECTED - Admin only)
pub async fn update_user(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
    axum::Form(form_data): axum::Form<UpdateUserFormData>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::role_service::RoleService;
    use rustwarden_core::services::user_service::UserService;

    let email = if form_data.email.is_empty() {
        None
    } else {
        Some(form_data.email)
    };

    // RFC 0041: an admin address write goes through admin_force_change.
    // update_profile refuses any real change. The same address is a no-op,
    // so an unchanged field must not require SMTP.
    // Changing one's own address revokes that account's sessions.
    let mut self_email_change = false;
    if let Some(ref new_email) = email {
        let current = UserService::get_user_by_id(&db, user_id)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
        if new_email.trim() != current.email.as_str() {
            use rustwarden_core::config::email::EmailConfig;
            use rustwarden_core::services::email_change_service::EmailChangeService;

            let email_config = EmailConfig::from_env()
                .map_err(|e| AppError::Config(format!("Email configuration error: {e}")))?;
            EmailChangeService::new(email_config)
                .admin_force_change(&db, claims.sub, user_id, new_email, None)
                .await?;
            self_email_change = claims.sub == user_id;
        }
    }

    // Update must_change_password if provided
    if let Some(must_change) = form_data.must_change_password {
        use rustwarden_core::models::user;
        use sea_orm::{ActiveModelTrait, Set};
        let user_model = user::Entity::find_by_id(user_id)
            .one(&db)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
        let mut active: user::ActiveModel = user_model.into();
        active.must_change_password = Set(must_change);
        active.update(&db).await?;
    }

    // Update roles: remove existing, add new
    use sea_orm::ModelTrait;
    let existing_roles = user_role::Entity::find()
        .filter(user_role::Column::UserId.eq(user_id))
        .all(&db)
        .await?;
    for ur in existing_roles {
        ur.delete(&db).await?;
    }
    for role_name in &form_data.roles {
        if let Some(r) = RoleService::get_role_by_name(&db, role_name).await? {
            UserService::assign_role_to_user(&db, user_id, r.id).await?;
        }
    }

    tracing::info!("Admin {} updated user {}", claims.sub, user_id);

    // The edit form is an HTMX request targeting #form-messages. A 303 would
    // be followed and the next page (the user list, or the login page once
    // this account's sessions are revoked) would be inserted into that div.
    // HX-Redirect navigates the whole window instead.
    let (message, redirect) = if self_email_change {
        (
            "Adresse mise à jour. Reconnectez-vous : les sessions de ce compte ont été fermées.",
            "/login",
        )
    } else {
        ("Utilisateur mis à jour.", "/admin/users")
    };
    let success_template = FormMessageTemplate {
        message_type: "success".to_string(),
        message: message.to_string(),
    };
    let mut response = success_template.into_response();
    response.headers_mut().insert(
        axum::http::HeaderName::from_static("hx-redirect"),
        axum::http::HeaderValue::from_static(redirect),
    );
    Ok(response)
}

/// Handler for DELETE /admin/users/:id - Delete user (PROTECTED - Admin only)
pub async fn delete_user(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    // Safety: prevent deleting the last admin
    let admin_role = role::Entity::find()
        .filter(role::Column::Name.eq("admin"))
        .one(&db)
        .await?;
    if let Some(admin_role) = admin_role {
        let admin_user_roles = user_role::Entity::find()
            .filter(user_role::Column::RoleId.eq(admin_role.id))
            .all(&db)
            .await?;
        let is_admin = admin_user_roles.iter().any(|ur| ur.user_id == user_id);
        if is_admin && admin_user_roles.len() <= 1 {
            return Err(AppError::Validation(
                "Cannot delete the last admin user".to_string(),
            ));
        }
    }

    // Delete user roles first
    let user_roles = user_role::Entity::find()
        .filter(user_role::Column::UserId.eq(user_id))
        .all(&db)
        .await?;
    for ur in user_roles {
        use sea_orm::ModelTrait;
        ur.delete(&db).await?;
    }

    // Delete user
    use rustwarden_core::models::user;
    let user_model = user::Entity::find_by_id(user_id)
        .one(&db)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
    use sea_orm::ModelTrait;
    user_model.delete(&db).await?;

    tracing::info!("Admin {} deleted user {}", claims.sub, user_id);
    Ok(axum::response::Redirect::to("/admin/users"))
}

/// Handler for POST /admin/users/:id/reset-password-email - Reset user password by email (PROTECTED - Admin only)
pub async fn admin_reset_password_by_email(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::user_service::UserService;
    let user = UserService::get_user_by_id(&db, user_id)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

    use rustwarden_core::config::email::EmailConfig;
    let email_config = EmailConfig::from_env()
        .map_err(|e| AppError::Internal(format!("Email configuration error: {}", e)))?;

    use rustwarden_core::services::password_reset_service::PasswordResetService;
    let password_reset_service = PasswordResetService::new(email_config);

    let email_sent = password_reset_service
        .request_reset(&db, &user.email)
        .await?;

    if !email_sent {
        return Err(AppError::Internal(
            "Failed to send password reset email".to_string(),
        ));
    }

    tracing::info!(
        "Admin {} initiated password reset by email for user {} ({})",
        claims.sub,
        user.username,
        user.email
    );

    let success_template = FormMessageTemplate {
        message_type: "success".to_string(),
        message: format!(
            "Un email de réinitialisation de mot de passe a été envoyé à {}.",
            user.email
        ),
    };

    Ok(success_template.into_response())
}

/// Handler for POST /admin/users/:id/reset-password - Reset user password (PROTECTED - Admin only)
pub async fn reset_user_password(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
    axum::Json(request): axum::Json<ResetPasswordRequest>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::password_service::PasswordService;
    use rustwarden_core::services::user_service::UserService;

    let _user = UserService::get_user_by_id(&db, user_id)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

    let new_password = match request.new_password {
        Some(pwd) => pwd,
        None => PasswordService::generate_default_password(),
    };

    let password_hash = PasswordService::hash_password(&new_password).await?;

    use rustwarden_core::models::user;
    use sea_orm::{ActiveModelTrait, Set};
    let user_model = user::Entity::find_by_id(user_id)
        .one(&db)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
    let mut active: user::ActiveModel = user_model.into();
    active.password_hash = Set(password_hash);
    active.must_change_password = Set(false);
    active.update(&db).await?;

    tracing::info!("Admin {} reset password for user {}", claims.sub, user_id);

    let response = ResetPasswordResponse {
        password: Some(new_password),
        message: "Password reset successfully. This password will only be displayed once."
            .to_string(),
    };

    Ok(axum::Json(response))
}

// ============================================================================
// Admin Settings
// ============================================================================

/// Admin application settings HTML template
#[derive(Template)]
#[template(path = "admin/settings.html")]
#[allow(dead_code)]
struct AdminSettingsTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    settings: AppSettingsTemplateContext,
    user_menu_profile: String,
    user_menu_logout: String,

    // Translated strings
    title: String,
    subtitle: String,
    general: String,
    app_name_label: String,
    app_domain_label: String,
    app_domain_placeholder: String,
    app_description_label: String,
    allow_registration_label: String,
    allow_registration_desc: String,
    email_validation_required_label: String,
    email_validation_required_desc: String,
    save_button: String,
    cancel: String,
    saved: String,
    error: String,
    network_error: String,
    breadcrumb_home: String,
    breadcrumb_admin: String,
    breadcrumb_settings: String,
    // Password security section
    password_security: String,
    password_min_length: String,
    password_min_length_hint: String,
    password_complexity: String,
    require_uppercase: String,
    require_lowercase: String,
    require_digits: String,
    require_special: String,
    password_change_require_email: String,
    password_change_require_email_desc: String,
    password_expiration: String,
    password_expiration_desc: String,
    expiration_days: String,
    expiration_alert: String,
    expiration_alert_days: String,
    // Audit section
    audit_logging: String,
    log_level: String,
    log_level_debug: String,
    log_level_info: String,
    log_level_warn: String,
    log_level_error: String,
    log_level_hint: String,
    log_retention: String,
    log_retention_hint: String,
    audit_info_message: String,
    // Save section
    save_changes: String,
    save_changes_desc: String,
    saving: String,
    save_success: String,
    save_error_prefix: String,
    // OAuth / SSO section
    oauth_sso_title: String,
    oauth_enabled_label: String,
    oauth_enabled_desc: String,
    oauth_redirect_url_label: String,
    oauth_redirect_url_desc: String,
    oauth_auto_register_label: String,
    oauth_auto_register_desc: String,
    oauth_link_existing_label: String,
    oauth_link_existing_desc: String,
    oauth_client_id: String,
    oauth_client_secret: String,
    oauth_tenant: String,
    oauth_redirect_uri: String,
    oauth_copy_uri: String,
    oauth_secret_configured: String,
    oauth_secret_not_configured: String,
    oauth_secret_change: String,
    oauth_restart_warning: String,
    oauth_env_info: String,
    oauth_google_label: String,
    oauth_google_desc: String,
    oauth_github_label: String,
    oauth_github_desc: String,
    oauth_discord_label: String,
    oauth_discord_desc: String,
    oauth_microsoft_label: String,
    oauth_microsoft_desc: String,
    // Audience measurement section
    analytics_title: String,
    analytics_desc: String,
    analytics_enabled_label: String,
    analytics_enabled_desc: String,
    analytics_site_id_label: String,
    analytics_site_id_desc: String,
    analytics_attributes_label: String,
    analytics_attributes_desc: String,
    analytics_script_url_label: String,
    analytics_script_url_desc: String,
    analytics_not_configured: String,
}

impl_template_response!(AdminSettingsTemplate);

/// Application settings data for template
#[derive(Debug, Clone)]
struct AppSettingsTemplateContext {
    app_name: String,
    app_domain: String,
    app_description: String,
    allow_registration: String,
    validation_email_requise: String,
    password_min_length: String,
    password_require_uppercase: String,
    password_require_lowercase: String,
    password_require_digits: String,
    password_require_special: String,
    password_change_require_email: String,
    password_expiration_enabled: String,
    password_expiration_days: String,
    password_expiration_alert_enabled: String,
    password_expiration_alert_days_before: String,
    audit_log_level: String,
    audit_log_retention_days: String,
    // OAuth
    oauth_enabled: String,
    oauth_redirect_base_url: String,
    oauth_auto_register: String,
    oauth_link_existing_account: String,
    oauth_google_enabled: String,
    oauth_google_client_id: String,
    oauth_google_has_secret: bool,
    oauth_github_enabled: String,
    oauth_github_client_id: String,
    oauth_github_has_secret: bool,
    oauth_discord_enabled: String,
    oauth_discord_client_id: String,
    oauth_discord_has_secret: bool,
    oauth_microsoft_enabled: String,
    oauth_microsoft_client_id: String,
    oauth_microsoft_has_secret: bool,
    oauth_microsoft_tenant: String,
    // TOTP 2FA
    totp_force_enabled: String,
    // Audience measurement. `analytics_script_url` is read-only: it feeds the
    // CSP, which is frozen at startup, so it stays an environment setting.
    analytics_enabled: String,
    analytics_site_id: String,
    analytics_attributes: String,
    analytics_script_url: String,
    analytics_configured: bool,
}

/// GET /admin/settings - Application settings page
pub async fn get_app_settings(
    Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    State(db): State<DatabaseConnection>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;

    // Load all settings from DB in one query for efficiency
    let all_settings = AppSettingsService::get_all_settings(&db).await?;
    let get_val = |key: &str, default: &str| -> String {
        all_settings
            .iter()
            .find(|s| s.key == key)
            .map(|s| s.value.clone())
            .unwrap_or_else(|| default.to_string())
    };

    let app_domain = get_val("app_domain", "localhost");
    let app_description = get_val("app_description", "User management and authentication");

    let settings_ctx = AppSettingsTemplateContext {
        app_name: base_context.app_name.clone(),
        app_domain: app_domain.clone(),
        app_description: app_description.clone(),
        allow_registration: get_val("allow_registration", "false"),
        validation_email_requise: get_val("validation_email_required", "true"),
        password_min_length: get_val("password_min_length", "8"),
        password_require_uppercase: get_val("password_require_uppercase", "false"),
        password_require_lowercase: get_val("password_require_lowercase", "false"),
        password_require_digits: get_val("password_require_digits", "false"),
        password_require_special: get_val("password_require_special", "false"),
        password_change_require_email: get_val("password_change_require_email", "true"),
        password_expiration_enabled: get_val("password_expiration_enabled", "false"),
        password_expiration_days: get_val("password_expiration_days", "90"),
        password_expiration_alert_enabled: get_val("password_expiration_alert_enabled", "false"),
        password_expiration_alert_days_before: get_val(
            "password_expiration_alert_days_before",
            "7",
        ),
        audit_log_level: get_val("audit_log_level", "INFO"),
        audit_log_retention_days: get_val("audit_log_retention_days", "365"),
        // OAuth
        oauth_enabled: get_val("oauth_enabled", "false"),
        oauth_redirect_base_url: get_val("oauth_redirect_base_url", ""),
        oauth_auto_register: get_val("oauth_auto_register", "true"),
        oauth_link_existing_account: get_val("oauth_link_existing_account", "true"),
        oauth_google_enabled: get_val("oauth_google_enabled", "false"),
        oauth_google_client_id: get_val("oauth_google_client_id", ""),
        oauth_google_has_secret: !get_val("oauth_google_client_secret", "").is_empty(),
        oauth_github_enabled: get_val("oauth_github_enabled", "false"),
        oauth_github_client_id: get_val("oauth_github_client_id", ""),
        oauth_github_has_secret: !get_val("oauth_github_client_secret", "").is_empty(),
        oauth_discord_enabled: get_val("oauth_discord_enabled", "false"),
        oauth_discord_client_id: get_val("oauth_discord_client_id", ""),
        oauth_discord_has_secret: !get_val("oauth_discord_client_secret", "").is_empty(),
        oauth_microsoft_enabled: get_val("oauth_microsoft_enabled", "false"),
        oauth_microsoft_client_id: get_val("oauth_microsoft_client_id", ""),
        oauth_microsoft_has_secret: !get_val("oauth_microsoft_client_secret", "").is_empty(),
        oauth_microsoft_tenant: get_val("oauth_microsoft_tenant", ""),
        // TOTP 2FA
        totp_force_enabled: get_val("totp_force_enabled", "false"),
        // Audience measurement
        analytics_enabled: get_val("analytics_enabled", "false"),
        analytics_site_id: get_val("analytics_site_id", ""),
        analytics_attributes: get_val("analytics_attributes", "{}"),
        analytics_script_url: analytics.script_url.clone(),
        analytics_configured: analytics.is_configured(),
    };

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = AdminSettingsTemplate {
        page_title: i18n_service
            .translate("admin.settings.title", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        settings: settings_ctx,
        title: i18n_service
            .translate("admin.settings.title", locale_str)
            .await,
        subtitle: i18n_service
            .translate("admin.settings.subtitle", locale_str)
            .await,
        general: i18n_service
            .translate("admin.settings.general", locale_str)
            .await,
        app_name_label: i18n_service
            .translate("admin.settings.app_name", locale_str)
            .await,
        app_domain_label: i18n_service
            .translate("admin.settings.app_domain", locale_str)
            .await,
        app_domain_placeholder: i18n_service
            .translate("admin.settings.app_domain_placeholder", locale_str)
            .await,
        app_description_label: i18n_service
            .translate("admin.settings.app_description", locale_str)
            .await,
        allow_registration_label: i18n_service
            .translate("admin.settings.allow_registration", locale_str)
            .await,
        allow_registration_desc: i18n_service
            .translate("admin.settings.allow_registration_desc", locale_str)
            .await,
        email_validation_required_label: i18n_service
            .translate("admin.settings.email_validation_required", locale_str)
            .await,
        email_validation_required_desc: i18n_service
            .translate("admin.settings.email_validation_required_desc", locale_str)
            .await,
        save_button: i18n_service
            .translate("admin.settings.save", locale_str)
            .await,
        cancel: i18n_service
            .translate("admin.settings.cancel", locale_str)
            .await,
        saved: i18n_service
            .translate("admin.settings.saved", locale_str)
            .await,
        error: i18n_service
            .translate("admin.settings.error", locale_str)
            .await,
        network_error: i18n_service
            .translate("admin.settings.network_error", locale_str)
            .await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_settings: i18n_service
            .translate("breadcrumb.settings", locale_str)
            .await,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        password_security: i18n_service
            .translate("admin.settings.password_security", locale_str)
            .await,
        password_min_length: i18n_service
            .translate("admin.settings.password_min_length", locale_str)
            .await,
        password_min_length_hint: i18n_service
            .translate("admin.settings.password_min_length_hint", locale_str)
            .await,
        password_complexity: i18n_service
            .translate("admin.settings.password_complexity", locale_str)
            .await,
        require_uppercase: i18n_service
            .translate("admin.settings.require_uppercase", locale_str)
            .await,
        require_lowercase: i18n_service
            .translate("admin.settings.require_lowercase", locale_str)
            .await,
        require_digits: i18n_service
            .translate("admin.settings.require_digits", locale_str)
            .await,
        require_special: i18n_service
            .translate("admin.settings.require_special", locale_str)
            .await,
        password_change_require_email: i18n_service
            .translate("admin.settings.password_change_require_email", locale_str)
            .await,
        password_change_require_email_desc: i18n_service
            .translate(
                "admin.settings.password_change_require_email_desc",
                locale_str,
            )
            .await,
        password_expiration: i18n_service
            .translate("admin.settings.password_expiration", locale_str)
            .await,
        password_expiration_desc: i18n_service
            .translate("admin.settings.password_expiration_desc", locale_str)
            .await,
        expiration_days: i18n_service
            .translate("admin.settings.expiration_days", locale_str)
            .await,
        expiration_alert: i18n_service
            .translate("admin.settings.expiration_alert", locale_str)
            .await,
        expiration_alert_days: i18n_service
            .translate("admin.settings.expiration_alert_days", locale_str)
            .await,
        audit_logging: i18n_service
            .translate("admin.settings.audit_logging", locale_str)
            .await,
        log_level: i18n_service
            .translate("admin.settings.log_level", locale_str)
            .await,
        log_level_debug: i18n_service
            .translate("admin.settings.log_level_debug", locale_str)
            .await,
        log_level_info: i18n_service
            .translate("admin.settings.log_level_info", locale_str)
            .await,
        log_level_warn: i18n_service
            .translate("admin.settings.log_level_warn", locale_str)
            .await,
        log_level_error: i18n_service
            .translate("admin.settings.log_level_error", locale_str)
            .await,
        log_level_hint: i18n_service
            .translate("admin.settings.log_level_hint", locale_str)
            .await,
        log_retention: i18n_service
            .translate("admin.settings.log_retention", locale_str)
            .await,
        log_retention_hint: i18n_service
            .translate("admin.settings.log_retention_hint", locale_str)
            .await,
        audit_info_message: i18n_service
            .translate("admin.settings.audit_info_message", locale_str)
            .await,
        save_changes: i18n_service
            .translate("admin.settings.save_changes", locale_str)
            .await,
        save_changes_desc: i18n_service
            .translate("admin.settings.save_changes_desc", locale_str)
            .await,
        saving: i18n_service
            .translate("admin.settings.saving", locale_str)
            .await,
        save_success: i18n_service
            .translate("admin.settings.save_success", locale_str)
            .await,
        save_error_prefix: i18n_service
            .translate("admin.settings.save_error_prefix", locale_str)
            .await,
        // OAuth / SSO
        oauth_sso_title: i18n_service
            .translate("admin.settings.oauth_sso_title", locale_str)
            .await,
        oauth_enabled_label: i18n_service
            .translate("admin.settings.oauth_enabled_label", locale_str)
            .await,
        oauth_enabled_desc: i18n_service
            .translate("admin.settings.oauth_enabled_desc", locale_str)
            .await,
        oauth_redirect_url_label: i18n_service
            .translate("admin.settings.oauth_redirect_url_label", locale_str)
            .await,
        oauth_redirect_url_desc: i18n_service
            .translate("admin.settings.oauth_redirect_url_desc", locale_str)
            .await,
        oauth_auto_register_label: i18n_service
            .translate("admin.settings.oauth_auto_register_label", locale_str)
            .await,
        oauth_auto_register_desc: i18n_service
            .translate("admin.settings.oauth_auto_register_desc", locale_str)
            .await,
        oauth_link_existing_label: i18n_service
            .translate("admin.settings.oauth_link_existing_label", locale_str)
            .await,
        oauth_link_existing_desc: i18n_service
            .translate("admin.settings.oauth_link_existing_desc", locale_str)
            .await,
        oauth_client_id: i18n_service
            .translate("admin.settings.oauth_client_id", locale_str)
            .await,
        oauth_client_secret: i18n_service
            .translate("admin.settings.oauth_client_secret", locale_str)
            .await,
        oauth_tenant: i18n_service
            .translate("admin.settings.oauth_tenant", locale_str)
            .await,
        oauth_redirect_uri: i18n_service
            .translate("admin.settings.oauth_redirect_uri", locale_str)
            .await,
        oauth_copy_uri: i18n_service
            .translate("admin.settings.oauth_copy_uri", locale_str)
            .await,
        oauth_secret_configured: i18n_service
            .translate("admin.settings.oauth_secret_configured", locale_str)
            .await,
        oauth_secret_not_configured: i18n_service
            .translate("admin.settings.oauth_secret_not_configured", locale_str)
            .await,
        oauth_secret_change: i18n_service
            .translate("admin.settings.oauth_secret_change", locale_str)
            .await,
        oauth_restart_warning: i18n_service
            .translate("admin.settings.oauth_restart_warning", locale_str)
            .await,
        oauth_env_info: i18n_service
            .translate("admin.settings.oauth_env_info", locale_str)
            .await,
        oauth_google_label: i18n_service
            .translate("admin.settings.oauth_google_label", locale_str)
            .await,
        oauth_google_desc: i18n_service
            .translate("admin.settings.oauth_google_desc", locale_str)
            .await,
        oauth_github_label: i18n_service
            .translate("admin.settings.oauth_github_label", locale_str)
            .await,
        oauth_github_desc: i18n_service
            .translate("admin.settings.oauth_github_desc", locale_str)
            .await,
        oauth_discord_label: i18n_service
            .translate("admin.settings.oauth_discord_label", locale_str)
            .await,
        oauth_discord_desc: i18n_service
            .translate("admin.settings.oauth_discord_desc", locale_str)
            .await,
        oauth_microsoft_label: i18n_service
            .translate("admin.settings.oauth_microsoft_label", locale_str)
            .await,
        oauth_microsoft_desc: i18n_service
            .translate("admin.settings.oauth_microsoft_desc", locale_str)
            .await,
        analytics_title: i18n_service
            .translate("admin.settings.analytics_title", locale_str)
            .await,
        analytics_desc: i18n_service
            .translate("admin.settings.analytics_desc", locale_str)
            .await,
        analytics_enabled_label: i18n_service
            .translate("admin.settings.analytics_enabled_label", locale_str)
            .await,
        analytics_enabled_desc: i18n_service
            .translate("admin.settings.analytics_enabled_desc", locale_str)
            .await,
        analytics_site_id_label: i18n_service
            .translate("admin.settings.analytics_site_id_label", locale_str)
            .await,
        analytics_site_id_desc: i18n_service
            .translate("admin.settings.analytics_site_id_desc", locale_str)
            .await,
        analytics_attributes_label: i18n_service
            .translate("admin.settings.analytics_attributes_label", locale_str)
            .await,
        analytics_attributes_desc: i18n_service
            .translate("admin.settings.analytics_attributes_desc", locale_str)
            .await,
        analytics_script_url_label: i18n_service
            .translate("admin.settings.analytics_script_url_label", locale_str)
            .await,
        analytics_script_url_desc: i18n_service
            .translate("admin.settings.analytics_script_url_desc", locale_str)
            .await,
        analytics_not_configured: i18n_service
            .translate("admin.settings.analytics_not_configured", locale_str)
            .await,
    };

    Ok(template)
}

/// Form data received from admin settings page (url-encoded)
#[derive(Debug, serde::Deserialize)]
pub struct AdminSettingsFormData {
    /// CSRF protection token.
    pub csrf_token: Option<String>,
    /// Application display name.
    pub app_name: Option<String>,
    /// Public domain of the application.
    pub app_domain: Option<String>,
    /// Short description of the application.
    pub app_description: Option<String>,
    /// Whether public self-registration is allowed (`"on"` / absent).
    pub allow_registration: Option<String>,
    /// Whether email validation is required on registration.
    pub validation_email_requise: Option<String>,
    /// Minimum password length.
    pub password_min_length: Option<String>,
    /// Whether passwords must contain at least one uppercase letter.
    pub password_require_uppercase: Option<String>,
    /// Whether passwords must contain at least one lowercase letter.
    pub password_require_lowercase: Option<String>,
    /// Whether passwords must contain at least one digit.
    pub password_require_digits: Option<String>,
    /// Whether passwords must contain at least one special character.
    pub password_require_special: Option<String>,
    /// Whether a password-change confirmation email is required.
    pub password_change_require_email: Option<String>,
    /// Whether periodic password expiration is enabled.
    pub password_expiration_enabled: Option<String>,
    /// Number of days before a password expires.
    pub password_expiration_days: Option<String>,
    /// Whether an alert email is sent before password expiry.
    pub password_expiration_alert_enabled: Option<String>,
    /// Number of days before expiry at which the alert is sent.
    pub password_expiration_alert_days: Option<String>,
    /// Minimum severity level for audit log entries.
    pub audit_log_level: Option<String>,
    /// Number of days to retain audit log entries.
    pub audit_log_retention_days: Option<String>,
    // OAuth
    /// Whether OAuth social login is globally enabled.
    pub oauth_enabled: Option<String>,
    /// Base URL used to build OAuth redirect URIs.
    pub oauth_redirect_base_url: Option<String>,
    /// Whether new users are registered automatically on OAuth login.
    pub oauth_auto_register: Option<String>,
    /// Whether OAuth login can be linked to an existing local account.
    pub oauth_link_existing_account: Option<String>,
    /// Whether Google OAuth provider is enabled.
    pub oauth_google_enabled: Option<String>,
    /// Google OAuth client ID.
    pub oauth_google_client_id: Option<String>,
    /// Google OAuth client secret.
    pub oauth_google_client_secret: Option<String>,
    /// Whether GitHub OAuth provider is enabled.
    pub oauth_github_enabled: Option<String>,
    /// GitHub OAuth client ID.
    pub oauth_github_client_id: Option<String>,
    /// GitHub OAuth client secret.
    pub oauth_github_client_secret: Option<String>,
    /// Whether Discord OAuth provider is enabled.
    pub oauth_discord_enabled: Option<String>,
    /// Discord OAuth client ID.
    pub oauth_discord_client_id: Option<String>,
    /// Discord OAuth client secret.
    pub oauth_discord_client_secret: Option<String>,
    /// Whether Microsoft OAuth provider is enabled.
    pub oauth_microsoft_enabled: Option<String>,
    /// Microsoft OAuth client ID.
    pub oauth_microsoft_client_id: Option<String>,
    /// Microsoft OAuth client secret.
    pub oauth_microsoft_client_secret: Option<String>,
    /// Microsoft Azure AD tenant ID or `"common"`.
    pub oauth_microsoft_tenant: Option<String>,
    // TOTP 2FA
    /// Whether TOTP two-factor authentication is enforced for all users.
    pub totp_force_enabled: Option<String>,
    // Audience measurement
    /// Whether the audience-measurement script is rendered.
    pub analytics_enabled: Option<String>,
    /// Site identifier handed to the analytics vendor.
    pub analytics_site_id: Option<String>,
    /// Extra `data-*` attributes, as a JSON object.
    pub analytics_attributes: Option<String>,
}

/// PUT /admin/settings - Update app settings
pub async fn update_app_settings(
    State(db): State<DatabaseConnection>,
    Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
    analytics: rustwarden_core::config::analytics::AnalyticsHandle,
    axum::Form(form): axum::Form<AdminSettingsFormData>,
) -> Result<impl IntoResponse, AppError> {
    let user_id = Some(claims.sub);

    let checkbox_val = |opt: &Option<String>| -> String {
        match opt {
            Some(v) if v == "true" || v == "on" => "true".to_string(),
            _ => "false".to_string(),
        }
    };

    // Audience measurement: validate before persisting. These values end up
    // inside a rendered <script> tag, so a value that would not survive
    // `AnalyticsConfig::validate` must never reach the database.
    let analytics_enabled = checkbox_val(&form.analytics_enabled);
    let analytics_site_id = form
        .analytics_site_id
        .as_deref()
        .unwrap_or_default()
        .trim()
        .to_string();
    let analytics_attributes = {
        let raw = form
            .analytics_attributes
            .as_deref()
            .unwrap_or_default()
            .trim();
        if raw.is_empty() { "{}" } else { raw }.to_string()
    };

    // Start from the environment configuration so the candidate is checked
    // against the real script URL and site-id attribute, not against defaults.
    let mut candidate = analytics.base_config().cloned().unwrap_or_default();
    candidate.enabled = analytics_enabled == "true";
    candidate.site_id = if analytics_site_id.is_empty() {
        None
    } else {
        Some(analytics_site_id.clone())
    };
    candidate = candidate
        .with_attributes_json(&analytics_attributes)
        .map_err(|e| AppError::Validation(e.to_string()))?;
    candidate
        .validate()
        .map_err(|e| AppError::Validation(e.to_string()))?;

    let settings_to_save: Vec<(&str, String)> = vec![
        ("app_name", form.app_name.unwrap_or_default()),
        ("app_domain", form.app_domain.unwrap_or_default()),
        ("app_description", form.app_description.unwrap_or_default()),
        ("allow_registration", checkbox_val(&form.allow_registration)),
        (
            "validation_email_required",
            checkbox_val(&form.validation_email_requise),
        ),
        (
            "password_min_length",
            form.password_min_length.unwrap_or_else(|| "8".to_string()),
        ),
        (
            "password_require_uppercase",
            checkbox_val(&form.password_require_uppercase),
        ),
        (
            "password_require_lowercase",
            checkbox_val(&form.password_require_lowercase),
        ),
        (
            "password_require_digits",
            checkbox_val(&form.password_require_digits),
        ),
        (
            "password_require_special",
            checkbox_val(&form.password_require_special),
        ),
        (
            "password_change_require_email",
            checkbox_val(&form.password_change_require_email),
        ),
        (
            "password_expiration_enabled",
            checkbox_val(&form.password_expiration_enabled),
        ),
        (
            "password_expiration_days",
            form.password_expiration_days
                .unwrap_or_else(|| "90".to_string()),
        ),
        (
            "password_expiration_alert_enabled",
            checkbox_val(&form.password_expiration_alert_enabled),
        ),
        (
            "password_expiration_alert_days_before",
            form.password_expiration_alert_days
                .unwrap_or_else(|| "7".to_string()),
        ),
        (
            "audit_log_level",
            form.audit_log_level.unwrap_or_else(|| "INFO".to_string()),
        ),
        (
            "audit_log_retention_days",
            form.audit_log_retention_days
                .unwrap_or_else(|| "365".to_string()),
        ),
        // OAuth toggles & non-secret fields
        ("oauth_enabled", checkbox_val(&form.oauth_enabled)),
        (
            "oauth_redirect_base_url",
            form.oauth_redirect_base_url.unwrap_or_default(),
        ),
        (
            "oauth_auto_register",
            checkbox_val(&form.oauth_auto_register),
        ),
        (
            "oauth_link_existing_account",
            checkbox_val(&form.oauth_link_existing_account),
        ),
        (
            "oauth_google_enabled",
            checkbox_val(&form.oauth_google_enabled),
        ),
        (
            "oauth_google_client_id",
            form.oauth_google_client_id.unwrap_or_default(),
        ),
        (
            "oauth_github_enabled",
            checkbox_val(&form.oauth_github_enabled),
        ),
        (
            "oauth_github_client_id",
            form.oauth_github_client_id.unwrap_or_default(),
        ),
        (
            "oauth_discord_enabled",
            checkbox_val(&form.oauth_discord_enabled),
        ),
        (
            "oauth_discord_client_id",
            form.oauth_discord_client_id.unwrap_or_default(),
        ),
        (
            "oauth_microsoft_enabled",
            checkbox_val(&form.oauth_microsoft_enabled),
        ),
        (
            "oauth_microsoft_client_id",
            form.oauth_microsoft_client_id.unwrap_or_default(),
        ),
        (
            "oauth_microsoft_tenant",
            form.oauth_microsoft_tenant.unwrap_or_default(),
        ),
        // TOTP 2FA
        ("totp_force_enabled", checkbox_val(&form.totp_force_enabled)),
        // Audience measurement (validated above)
        ("analytics_enabled", analytics_enabled),
        ("analytics_site_id", analytics_site_id),
        ("analytics_attributes", analytics_attributes),
    ];

    let mut updated_count = 0u32;
    for (key, value) in &settings_to_save {
        AppSettingsService::set_setting(&db, key.to_string(), value.clone(), user_id).await?;
        updated_count += 1;
    }

    // OAuth secrets: only update if a non-empty value was submitted (otherwise keep existing)
    use rustwarden_core::services::crypto_service::CryptoService;
    let secret_fields = [
        (
            "oauth_google_client_secret",
            &form.oauth_google_client_secret,
        ),
        (
            "oauth_github_client_secret",
            &form.oauth_github_client_secret,
        ),
        (
            "oauth_discord_client_secret",
            &form.oauth_discord_client_secret,
        ),
        (
            "oauth_microsoft_client_secret",
            &form.oauth_microsoft_client_secret,
        ),
    ];
    for (key, value) in &secret_fields {
        if let Some(v) = value {
            let trimmed = v.trim();
            if !trimmed.is_empty() {
                let encrypted = CryptoService::encrypt(trimmed)?;
                AppSettingsService::set_setting(&db, key.to_string(), encrypted, user_id).await?;
                updated_count += 1;
            }
        }
    }

    // Re-render the analytics snippet so the change takes effect immediately.
    // A failure here only means the pages keep serving the previous snippet
    // until the next restart — not a reason to report the save as failed.
    if let Err(e) = analytics.reload(&db).await {
        tracing::warn!(
            error = %e,
            "analytics settings saved but the in-memory snippet could not be refreshed"
        );
    }

    tracing::info!(
        "Admin {} updated {} app settings",
        claims.username,
        updated_count
    );

    Ok(Json(serde_json::json!({
        "message": "Settings saved successfully",
        "updated_count": updated_count,
    })))
}

// ============================================================================
// Admin Roles CRUD
// ============================================================================

/// Permission display info for role form checkboxes
#[derive(Debug, Clone)]
struct PermissionDisplay {
    id: String,
    name: String,
    description: String,
    is_selected: bool,
}

/// Grouped permissions by category for the form
#[derive(Debug, Clone)]
struct PermissionGroup {
    category: String,
    permissions: Vec<PermissionDisplay>,
}

/// Role info for the list table
#[derive(Debug, Clone)]
struct RoleInfoDisplay {
    id: String,
    name: String,
    description: String,
    user_count: u64,
    permission_count: usize,
    is_protected: bool,
}

/// Role list template
#[derive(Template)]
#[template(path = "admin/roles_list.html")]
#[allow(dead_code)]
struct RolesListTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    roles: Vec<RoleInfoDisplay>,
    user_menu_profile: String,
    user_menu_logout: String,

    breadcrumb_home: String,
    breadcrumb_admin: String,
    breadcrumb_roles: String,
    title: String,
    create_role: String,
    table_name: String,
    table_description: String,
    table_users: String,
    table_permissions: String,
    table_actions: String,
    edit: String,
    delete_label: String,
    no_roles: String,
    protected_hint: String,
    delete_confirm_title: String,
    delete_confirm_message: String,
    delete_button: String,
    cancel: String,
}

impl_template_response!(RolesListTemplate);

/// Role form template (create/edit)
#[derive(Template)]
#[template(path = "admin/role_form.html")]
#[allow(dead_code)]
struct RoleFormTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    mode: String,
    role_id: String,
    form_name: String,
    form_description: String,
    is_protected: bool,
    permission_groups: Vec<PermissionGroup>,
    user_menu_profile: String,
    user_menu_logout: String,

    breadcrumb_home: String,
    breadcrumb_admin: String,
    breadcrumb_roles: String,
    breadcrumb_mode: String,
    form_title: String,
    back_to_list: String,
    name_label: String,
    name_placeholder: String,
    description_label: String,
    description_placeholder: String,
    permissions_label: String,
    protected_name_hint: String,
    cancel: String,
    submit_label: String,
}

impl_template_response!(RoleFormTemplate);

/// GET /admin/roles - List all roles
pub async fn list_roles(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    use rustwarden_core::services::permission_service::PermissionService;
    use rustwarden_core::services::role_service::RoleService;

    let all_roles = RoleService::list_all_roles(&db).await?;
    let mut roles_display = Vec::new();

    for r in &all_roles {
        let user_count = RoleService::count_users_for_role(&db, r.id).await?;
        let perms = PermissionService::get_permissions_for_role(&db, r.id).await?;
        let is_protected = r.name == "admin" || r.name == "user";
        roles_display.push(RoleInfoDisplay {
            id: r.id.to_string(),
            name: r.name.clone(),
            description: r.description.clone().unwrap_or_default(),
            user_count,
            permission_count: perms.len(),
            is_protected,
        });
    }

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = RolesListTemplate {
        page_title: i18n_service
            .translate("admin.roles_list.page_title", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        roles: roles_display,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_roles: i18n_service
            .translate("admin.roles_list.breadcrumb_roles", locale_str)
            .await,
        title: i18n_service
            .translate("admin.roles_list.title", locale_str)
            .await,
        create_role: i18n_service
            .translate("admin.roles_list.create_role", locale_str)
            .await,
        table_name: i18n_service
            .translate("admin.roles_list.table_name", locale_str)
            .await,
        table_description: i18n_service
            .translate("admin.roles_list.table_description", locale_str)
            .await,
        table_users: i18n_service
            .translate("admin.roles_list.table_users", locale_str)
            .await,
        table_permissions: i18n_service
            .translate("admin.roles_list.table_permissions", locale_str)
            .await,
        table_actions: i18n_service
            .translate("admin.roles_list.table_actions", locale_str)
            .await,
        edit: i18n_service
            .translate("admin.roles_list.edit", locale_str)
            .await,
        delete_label: i18n_service
            .translate("admin.roles_list.delete", locale_str)
            .await,
        no_roles: i18n_service
            .translate("admin.roles_list.no_roles", locale_str)
            .await,
        protected_hint: i18n_service
            .translate("admin.roles_list.protected_hint", locale_str)
            .await,
        delete_confirm_title: i18n_service
            .translate("admin.roles_list.delete_confirm_title", locale_str)
            .await,
        delete_confirm_message: i18n_service
            .translate("admin.roles_list.delete_confirm_message", locale_str)
            .await,
        delete_button: i18n_service
            .translate("admin.roles_list.delete_button", locale_str)
            .await,
        cancel: i18n_service.translate("common.cancel", locale_str).await,
    };

    Ok(template)
}

/// Build permission groups from DB for the role form
async fn build_permission_groups(
    db: &DatabaseConnection,
    selected_ids: &[uuid::Uuid],
) -> Result<Vec<PermissionGroup>, AppError> {
    use rustwarden_core::services::permission_service::PermissionService;

    let all_perms = PermissionService::list_all(db).await?;
    let mut groups: std::collections::BTreeMap<String, Vec<PermissionDisplay>> =
        std::collections::BTreeMap::new();

    for p in all_perms {
        let display = PermissionDisplay {
            id: p.id.to_string(),
            name: p.name.clone(),
            description: p.description.clone().unwrap_or_default(),
            is_selected: selected_ids.contains(&p.id),
        };
        groups.entry(p.category.clone()).or_default().push(display);
    }

    Ok(groups
        .into_iter()
        .map(|(category, permissions)| PermissionGroup {
            category,
            permissions,
        })
        .collect())
}

/// GET /admin/roles/create - Role creation form
pub async fn create_role_form(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    let permission_groups = build_permission_groups(&db, &[]).await?;
    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = RoleFormTemplate {
        page_title: i18n_service
            .translate("admin.role_form.title_create", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        mode: "create".to_string(),
        role_id: String::new(),
        form_name: String::new(),
        form_description: String::new(),
        is_protected: false,
        permission_groups,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_roles: i18n_service
            .translate("admin.roles_list.breadcrumb_roles", locale_str)
            .await,
        breadcrumb_mode: i18n_service
            .translate("admin.role_form.breadcrumb_create", locale_str)
            .await,
        form_title: i18n_service
            .translate("admin.role_form.title_create", locale_str)
            .await,
        back_to_list: i18n_service
            .translate("admin.role_form.back_to_list", locale_str)
            .await,
        name_label: i18n_service
            .translate("admin.role_form.name_label", locale_str)
            .await,
        name_placeholder: i18n_service
            .translate("admin.role_form.name_placeholder", locale_str)
            .await,
        description_label: i18n_service
            .translate("admin.role_form.description_label", locale_str)
            .await,
        description_placeholder: i18n_service
            .translate("admin.role_form.description_placeholder", locale_str)
            .await,
        permissions_label: i18n_service
            .translate("admin.role_form.permissions_label", locale_str)
            .await,
        protected_name_hint: i18n_service
            .translate("admin.role_form.protected_name_hint", locale_str)
            .await,
        cancel: i18n_service
            .translate("admin.role_form.cancel", locale_str)
            .await,
        submit_label: i18n_service
            .translate("admin.role_form.submit_create", locale_str)
            .await,
    };

    Ok(template)
}

/// Form data for role create/update
#[derive(Debug, serde::Deserialize)]
pub struct RoleFormData {
    /// Unique role name (e.g. `"admin"`, `"user"`).
    pub name: String,
    /// Optional human-readable description of the role.
    pub description: Option<String>,
    /// List of permission keys granted by this role.
    #[serde(default)]
    pub permissions: Vec<String>,
}

/// POST /admin/roles - Create a new role
pub async fn create_role(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::Form(form_data): axum::Form<RoleFormData>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::permission_service::PermissionService;
    use rustwarden_core::services::role_service::RoleService;

    if form_data.name.is_empty() {
        return Ok(FormMessageTemplate {
            message_type: "error".to_string(),
            message: "Role name is required".to_string(),
        }
        .into_response());
    }

    if RoleService::get_role_by_name(&db, &form_data.name)
        .await?
        .is_some()
    {
        return Ok(FormMessageTemplate {
            message_type: "error".to_string(),
            message: format!("Role '{}' already exists", form_data.name),
        }
        .into_response());
    }

    let role = RoleService::create_role(&db, form_data.name, form_data.description).await?;

    let perm_ids: Vec<uuid::Uuid> = form_data
        .permissions
        .iter()
        .filter_map(|s| s.parse().ok())
        .collect();
    PermissionService::set_role_permissions(&db, role.id, perm_ids).await?;

    tracing::info!("Admin {} created role {}", claims.sub, role.name);

    let mut response = FormMessageTemplate {
        message_type: "success".to_string(),
        message: "Role created successfully.".to_string(),
    }
    .into_response();
    response.headers_mut().insert(
        axum::http::HeaderName::from_static("hx-redirect"),
        axum::http::HeaderValue::from_static("/admin/roles"),
    );
    Ok(response)
}

/// GET /admin/roles/:id - Edit role form
pub async fn edit_role_form(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    use rustwarden_core::services::permission_service::PermissionService;
    use rustwarden_core::services::role_service::RoleService;

    let role = RoleService::get_role_by_id(&db, role_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;

    let role_perms = PermissionService::get_permissions_for_role(&db, role_id).await?;
    let selected_ids: Vec<uuid::Uuid> = role_perms.iter().map(|p| p.id).collect();
    let permission_groups = build_permission_groups(&db, &selected_ids).await?;
    let is_protected = role.name == "admin" || role.name == "user";
    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = RoleFormTemplate {
        page_title: format!(
            "{} {}",
            i18n_service
                .translate("admin.role_form.title_edit", locale_str)
                .await,
            role.name
        ),
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        mode: "edit".to_string(),
        role_id: role_id.to_string(),
        form_name: role.name,
        form_description: role.description.unwrap_or_default(),
        is_protected,
        permission_groups,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_roles: i18n_service
            .translate("admin.roles_list.breadcrumb_roles", locale_str)
            .await,
        breadcrumb_mode: i18n_service
            .translate("admin.role_form.breadcrumb_edit", locale_str)
            .await,
        form_title: i18n_service
            .translate("admin.role_form.title_edit", locale_str)
            .await,
        back_to_list: i18n_service
            .translate("admin.role_form.back_to_list", locale_str)
            .await,
        name_label: i18n_service
            .translate("admin.role_form.name_label", locale_str)
            .await,
        name_placeholder: i18n_service
            .translate("admin.role_form.name_placeholder", locale_str)
            .await,
        description_label: i18n_service
            .translate("admin.role_form.description_label", locale_str)
            .await,
        description_placeholder: i18n_service
            .translate("admin.role_form.description_placeholder", locale_str)
            .await,
        permissions_label: i18n_service
            .translate("admin.role_form.permissions_label", locale_str)
            .await,
        protected_name_hint: i18n_service
            .translate("admin.role_form.protected_name_hint", locale_str)
            .await,
        cancel: i18n_service
            .translate("admin.role_form.cancel", locale_str)
            .await,
        submit_label: i18n_service
            .translate("admin.role_form.submit_edit", locale_str)
            .await,
    };

    Ok(template)
}

/// PUT /admin/roles/:id - Update a role
pub async fn update_role(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
    axum::Form(form_data): axum::Form<RoleFormData>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::permission_service::PermissionService;
    use rustwarden_core::services::role_service::RoleService;

    let existing = RoleService::get_role_by_id(&db, role_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;

    let is_protected = existing.name == "admin" || existing.name == "user";

    let name_to_set = if is_protected {
        None
    } else {
        Some(form_data.name)
    };

    RoleService::update_role(&db, role_id, name_to_set, form_data.description).await?;

    let perm_ids: Vec<uuid::Uuid> = form_data
        .permissions
        .iter()
        .filter_map(|s| s.parse().ok())
        .collect();
    PermissionService::set_role_permissions(&db, role_id, perm_ids).await?;

    tracing::info!("Admin {} updated role {}", claims.sub, existing.name);
    Ok(axum::response::Redirect::to("/admin/roles"))
}

/// DELETE /admin/roles/:id - Delete a role
pub async fn delete_role(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    axum::extract::Path(role_id): axum::extract::Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::role_service::RoleService;

    RoleService::delete_role(&db, role_id).await?;

    tracing::info!("Admin {} deleted role {}", claims.sub, role_id);
    Ok(axum::response::Redirect::to("/admin/roles"))
}

// ============================================================================
// Admin Errors Page
// ============================================================================

/// Error code information for admin errors page
#[derive(Debug, Clone)]
struct ErrorCodeInfo {
    code: String,
    description: String,
    category: String,
    middleware: String,
    context: String,
    example: String,
}

/// Admin errors page template
#[derive(Template)]
#[template(path = "admin/errors.html")]
#[allow(dead_code)]
struct AdminErrorsTemplate {
    page_title: String,
    csrf_token: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    username: String,
    email: String,
    initials: String,
    avatar_color: String,
    roles_text: String,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    error_codes: Vec<ErrorCodeInfo>,
    // User menu
    user_menu_profile: String,
    user_menu_logout: String,

    // Translated strings
    breadcrumb_home: String,
    breadcrumb_admin: String,
    breadcrumb_errors: String,
    title: String,
    description: String,
    table_code: String,
    table_description: String,
    table_category: String,
    table_middleware: String,
    table_context: String,
    table_example: String,
    legend_title: String,
    legend_code: String,
    legend_description: String,
    legend_category: String,
    legend_middleware: String,
    legend_context: String,
    legend_example: String,
}

impl_template_response!(AdminErrorsTemplate);

/// Handler for GET /admin/errors - Error codes documentation page
pub async fn admin_errors(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    session: Session,
    State(db): State<DatabaseConnection>,
) -> Result<impl IntoResponse, AppError> {
    let locale_str = locale.as_str();
    let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
    let (app_domain, app_description) = load_app_domain_and_description(&db).await?;

    use rustwarden_core::middleware::error_handler::ErrorCode;

    struct ErrorDef {
        code_enum: ErrorCode,
        desc_key: &'static str,
        cat_key: &'static str,
        middleware: &'static str,
        ctx_key: &'static str,
        ex_key: &'static str,
    }

    let error_defs = vec![
        ErrorDef {
            code_enum: ErrorCode::Auth001,
            desc_key: "admin.errors.error_auth001_desc",
            cat_key: "admin.errors.error_auth001_category",
            middleware: "auth_required",
            ctx_key: "admin.errors.error_auth001_context",
            ex_key: "admin.errors.error_auth001_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Auth002,
            desc_key: "admin.errors.error_auth002_desc",
            cat_key: "admin.errors.error_auth002_category",
            middleware: "auth_required",
            ctx_key: "admin.errors.error_auth002_context",
            ex_key: "admin.errors.error_auth002_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Auth003,
            desc_key: "admin.errors.error_auth003_desc",
            cat_key: "admin.errors.error_auth003_category",
            middleware: "auth_required",
            ctx_key: "admin.errors.error_auth003_context",
            ex_key: "admin.errors.error_auth003_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Csrf001,
            desc_key: "admin.errors.error_csrf001_desc",
            cat_key: "admin.errors.error_csrf001_category",
            middleware: "csrf",
            ctx_key: "admin.errors.error_csrf001_context",
            ex_key: "admin.errors.error_csrf001_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Csrf002,
            desc_key: "admin.errors.error_csrf002_desc",
            cat_key: "admin.errors.error_csrf002_category",
            middleware: "csrf",
            ctx_key: "admin.errors.error_csrf002_context",
            ex_key: "admin.errors.error_csrf002_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Rate001,
            desc_key: "admin.errors.error_rate001_desc",
            cat_key: "admin.errors.error_rate001_category",
            middleware: "rate_limit",
            ctx_key: "admin.errors.error_rate001_context",
            ex_key: "admin.errors.error_rate001_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Valid001,
            desc_key: "admin.errors.error_valid001_desc",
            cat_key: "admin.errors.error_valid001_category",
            middleware: "validation",
            ctx_key: "admin.errors.error_valid001_context",
            ex_key: "admin.errors.error_valid001_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Valid002,
            desc_key: "admin.errors.error_valid002_desc",
            cat_key: "admin.errors.error_valid002_category",
            middleware: "password_complexity",
            ctx_key: "admin.errors.error_valid002_context",
            ex_key: "admin.errors.error_valid002_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Rbac001,
            desc_key: "admin.errors.error_rbac001_desc",
            cat_key: "admin.errors.error_rbac001_category",
            middleware: "admin_required",
            ctx_key: "admin.errors.error_rbac001_context",
            ex_key: "admin.errors.error_rbac001_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Rbac002,
            desc_key: "admin.errors.error_rbac002_desc",
            cat_key: "admin.errors.error_rbac002_category",
            middleware: "admin_required",
            ctx_key: "admin.errors.error_rbac002_context",
            ex_key: "admin.errors.error_rbac002_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Db001,
            desc_key: "admin.errors.error_db001_desc",
            cat_key: "admin.errors.error_db001_category",
            middleware: "database",
            ctx_key: "admin.errors.error_db001_context",
            ex_key: "admin.errors.error_db001_example",
        },
        ErrorDef {
            code_enum: ErrorCode::Db002,
            desc_key: "admin.errors.error_db002_desc",
            cat_key: "admin.errors.error_db002_category",
            middleware: "database",
            ctx_key: "admin.errors.error_db002_context",
            ex_key: "admin.errors.error_db002_example",
        },
    ];

    let mut error_codes = Vec::new();
    for def in &error_defs {
        error_codes.push(ErrorCodeInfo {
            code: def.code_enum.as_str().to_string(),
            description: i18n_service.translate(def.desc_key, locale_str).await,
            category: i18n_service.translate(def.cat_key, locale_str).await,
            middleware: def.middleware.to_string(),
            context: i18n_service.translate(def.ctx_key, locale_str).await,
            example: i18n_service.translate(def.ex_key, locale_str).await,
        });
    }

    let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;

    let template = AdminErrorsTemplate {
        page_title: i18n_service
            .translate("admin.errors.title", locale_str)
            .await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: base_context.username,
        email: base_context.email,
        initials: base_context.initials,
        avatar_color: base_context.avatar_color,
        roles_text: base_context.roles.join(", "),
        nav_items,
        has_pending_password_change: false,
        error_codes,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await,
        breadcrumb_errors: i18n_service
            .translate("admin.errors.breadcrumb_errors", locale_str)
            .await,
        title: i18n_service
            .translate("admin.errors.title", locale_str)
            .await,
        description: i18n_service
            .translate("admin.errors.description", locale_str)
            .await,
        table_code: i18n_service
            .translate("admin.errors.table_code", locale_str)
            .await,
        table_description: i18n_service
            .translate("admin.errors.table_description", locale_str)
            .await,
        table_category: i18n_service
            .translate("admin.errors.table_category", locale_str)
            .await,
        table_middleware: i18n_service
            .translate("admin.errors.table_middleware", locale_str)
            .await,
        table_context: i18n_service
            .translate("admin.errors.table_context", locale_str)
            .await,
        table_example: i18n_service
            .translate("admin.errors.table_example", locale_str)
            .await,
        legend_title: i18n_service
            .translate("admin.errors.legend_title", locale_str)
            .await,
        legend_code: i18n_service
            .translate("admin.errors.legend_code", locale_str)
            .await,
        legend_description: i18n_service
            .translate("admin.errors.legend_description", locale_str)
            .await,
        legend_category: i18n_service
            .translate("admin.errors.legend_category", locale_str)
            .await,
        legend_middleware: i18n_service
            .translate("admin.errors.legend_middleware", locale_str)
            .await,
        legend_context: i18n_service
            .translate("admin.errors.legend_context", locale_str)
            .await,
        legend_example: i18n_service
            .translate("admin.errors.legend_example", locale_str)
            .await,
    };

    Ok(template)
}

/// POST /admin/users/{id}/reset-totp — Admin resets a user's 2FA
pub async fn reset_user_totp(
    Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
    State(db): State<sea_orm::DatabaseConnection>,
    axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>,
) -> Result<axum::response::Response, rustwarden_core::error::AppError> {
    use axum::response::IntoResponse;
    use rustwarden_core::services::totp_service::TotpService;

    // Verify the target user exists
    let _user = rustwarden_core::services::user_service::UserService::get_user_by_id(&db, user_id)
        .await?
        .ok_or_else(|| rustwarden_core::error::AppError::NotFound("User not found".to_string()))?;

    // Disable TOTP (delete the row)
    match TotpService::disable_totp(&db, user_id).await {
        Ok(()) => {}
        Err(rustwarden_core::error::AppError::TotpNotEnabled) => {
            // Already disabled, not an error
        }
        Err(e) => return Err(e),
    }

    tracing::info!(admin = %claims.username, target_user = %user_id, "TOTP_DISABLED_BY_ADMIN");

    Ok(axum::Json(serde_json::json!({ "success": true })).into_response())
}