security.rs 887 lignes · 31350 octets
//! Settings - Security handlers (password change, active sessions).

use askama::Template;
use axum::{
    Extension, Json,
    extract::{Query, State},
    response::IntoResponse,
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use std::sync::Arc;
use tower_sessions::Session;

use super::{NavItem, build_nav_items_from_dto, filters};
use crate::dto::navigation_dto::NavigationDTO;
use crate::dto::user_profile_dto::UserProfileDTO;
use rustwarden_core::auth::jwt::Claims;
use rustwarden_core::config::email::EmailConfig;
use rustwarden_core::error::AppError;
use rustwarden_core::i18n::extractor::Locale;
use rustwarden_core::i18n::service::I18nService;
use rustwarden_core::services::app_settings_service::AppSettingsService;
use rustwarden_core::services::refresh_token_service::RefreshTokenService;
use rustwarden_core::services::user_service::UserService;

/// Inline DTO: Change password request
#[derive(Debug, Deserialize)]
pub struct ChangePasswordRequest {
    /// Current password for verification.
    pub old_password: String,
    /// Desired new password.
    pub new_password: String,
    /// Confirmation of the new password (must match `new_password`).
    pub confirm_password: String,
}

/// Inline DTO: Change password response
#[derive(Debug, serde::Serialize)]
pub struct ChangePasswordResponse {
    /// Human-readable confirmation message.
    pub message: String,
}

/// Inline DTO: Active sessions response
#[derive(Debug, serde::Serialize)]
pub struct ActiveSessionsResponse {
    /// List of currently active refresh-token sessions for the user.
    pub sessions: Vec<rustwarden_core::services::refresh_token_service::ActiveSession>,
}

/// Query parameters for password page
#[derive(Deserialize)]
pub struct PasswordPageQuery {
    must_change: Option<String>,
}

/// POST /settings/security/password — Change the authenticated user's password.
pub async fn change_password(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Json(request): Json<ChangePasswordRequest>,
) -> Result<impl IntoResponse, AppError> {
    if request.new_password != request.confirm_password {
        return Err(AppError::Validation(
            "New password and confirmation do not match".to_string(),
        ));
    }

    let require_email =
        AppSettingsService::get_bool(&db, "password_change_require_email", true).await?;

    if require_email {
        let current_token_id: Option<uuid::Uuid> =
            session.get("refresh_token_id").await.ok().flatten();

        let (_request_id, token) = UserService::change_password(
            &db,
            claims.sub,
            &request.old_password,
            &request.new_password,
            current_token_id,
        )
        .await?;

        tracing::info!(
            "Created pending password change request for user {}",
            claims.sub
        );

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

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

        let confirmation_url = format!(
            "{}/confirm-password-change?token={}",
            email_config.email_base_url, token
        );

        let template =
            rustwarden_core::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new(
                Some("fr".to_string()),
                user.username.clone(),
                confirmation_url,
                Some(
                    email_config
                        .smtp_from_name
                        .clone()
                        .unwrap_or_else(rustwarden_core::config::app::default_app_name),
                ),
            );

        let body_html = template
            .render_html()
            .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
        let body_text = template.render_text();

        let subject = "Confirmation de changement de mot de passe".to_string();

        let email_svc = rustwarden_core::services::email_service::EmailService::new(email_config)
            .map_err(|e| {
            AppError::Internal(format!("Failed to create email service: {}", e))
        })?;
        email_svc
            .send_email(
                &user.email,
                Some(&user.username),
                &subject,
                &body_html,
                &body_text,
            )
            .await
            .map_err(|e| {
                tracing::error!(
                    "Failed to send password change confirmation email for user {}: {}",
                    user.username,
                    e
                );
                AppError::Internal(
                    "Failed to send confirmation email. Please try again later.".to_string(),
                )
            })?;

        tracing::info!(
            "Password change confirmation email sent for user {}",
            user.username
        );

        Ok(Json(ChangePasswordResponse {
            message: "email_confirmation_sent".to_string(),
        }))
    } else {
        use rustwarden_core::services::password_service::PasswordService;

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

        if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? {
            return Err(AppError::Validation(
                "Old password is incorrect".to_string(),
            ));
        }

        rustwarden_core::middleware::password_complexity::validate_password_complexity(
            &db,
            &request.new_password,
        )
        .await?;

        let new_hash = PasswordService::hash_password(&request.new_password).await?;
        UserService::update_password_hash(&db, claims.sub, &new_hash).await?;
        RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?;

        tracing::info!(
            "Password changed directly (no email confirmation) for user {}",
            claims.sub
        );

        Ok(Json(ChangePasswordResponse {
            message: "password_changed_directly".to_string(),
        }))
    }
}

/// Query parameters for password change confirmation
#[derive(Deserialize)]
pub struct ConfirmPasswordChangeQuery {
    token: String,
}

/// Handler for GET /confirm-password-change?token={token} - Confirm password change (PUBLIC)
pub async fn confirm_password_change(
    State(db): State<DatabaseConnection>,
    Query(query): Query<ConfirmPasswordChangeQuery>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::password_change_service::PasswordChangeService;

    let password_change_service = PasswordChangeService::new();

    let _request = password_change_service
        .find_by_token(&db, &query.token)
        .await?;

    let user_id = password_change_service
        .confirm_request(&db, &query.token)
        .await?;

    RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?;

    tracing::info!(
        "Password change confirmed and applied for user {}. All sessions invalidated.",
        user_id
    );

    Ok(Json(serde_json::json!({
        "success": true,
        "message": "Password change confirmed successfully. Please log in with your new password."
    })))
}

/// Handler for DELETE /settings/password/pending - Cancel pending password change request (PROTECTED)
pub async fn cancel_pending_password_change(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
) -> Result<impl IntoResponse, AppError> {
    use rustwarden_core::services::password_change_service::PasswordChangeService;

    let password_change_service = PasswordChangeService::new();

    password_change_service
        .cancel_pending_request(&db, claims.sub)
        .await?;

    tracing::info!(
        "Canceled pending password change request for user {}",
        claims.sub
    );

    Ok(Json(serde_json::json!({
        "success": true,
        "message": "Pending password change request canceled successfully."
    })))
}

/// Handler for GET /settings/sessions - List active sessions (PROTECTED)
pub async fn list_sessions(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();

    let mut sessions = RefreshTokenService::list_user_sessions(&db, claims.sub).await?;

    if let Some(current_id) = current_token_id {
        for session in &mut sessions {
            if session.id == current_id {
                session.is_current = true;
                break;
            }
        }
    }

    Ok(Json(ActiveSessionsResponse { sessions }))
}

/// Handler for DELETE /settings/sessions/:id - Revoke session (PROTECTED)
pub async fn revoke_session(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();

    if Some(session_id) == current_token_id {
        return Err(AppError::Validation(
            "Cannot revoke current session. Please log out instead.".to_string(),
        ));
    }

    use rustwarden_core::models::refresh_token;
    use sea_orm::EntityTrait;

    let token = refresh_token::Entity::find_by_id(session_id)
        .one(&db)
        .await?
        .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?;

    if token.user_id != claims.sub {
        return Err(AppError::Validation(
            "You can only revoke your own sessions".to_string(),
        ));
    }

    RefreshTokenService::revoke_session(&db, session_id).await?;

    tracing::info!("Session {} revoked by user {}", session_id, claims.sub);

    Ok(Json(serde_json::json!({
        "message": "Session revoked successfully",
    })))
}
#[derive(Template)]
#[template(path = "settings/password.html")]
#[allow(dead_code)]
struct PasswordTemplate {
    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,
    roles_text: String,
    initials: String,
    avatar_color: String,
    nav_items: Vec<NavItem>,
    user_menu_profile: String,
    user_menu_logout: String,

    must_change: bool,
    must_change_message: String,
    has_pending_request: bool,
    pending_request_message: String,
    pending_request_info: String,
    pending_request_cancel: String,
    // Translated strings (hardcoded FR)
    title: String,
    subtitle: String,
    new_password_label: String,
    current_password_label: String,
    current_password_placeholder: String,
    new_password_placeholder: String,
    required: String,
    requirements: String,
    strength_label: String,
    strength_weak: String,
    strength_medium: String,
    strength_strong: String,
    strength_too_short: String,
    strength_missing_lowercase: String,
    strength_missing_uppercase: String,
    strength_missing_digit: String,
    strength_missing_special: String,
    confirm_password_label: String,
    confirm_password_placeholder: String,
    passwords_match: String,
    passwords_not_match: String,
    change_button: String,
    changing: String,
    logout_warning_title: String,
    logout_warning_desc: String,
    saved: String,
    error_all_fields_required: String,
    error_empty_data: String,
    error_invalid_data: String,
    error_incorrect_current: String,
    error_reading_response: String,
    error_415: String,
    network_error: String,
    security_tips_title: String,
    security_tip_unique: String,
    security_tip_share: String,
    security_tip_regular: String,
    security_tip_2fa: String,
    security_tip_manager: String,
    breadcrumb_home: String,
    breadcrumb_settings: String,
    breadcrumb_password: String,
    cancel: String,
    logout_label: String,
    has_pending_password_change: bool,
    // Password complexity rules (from app_settings)
    pw_min_length: usize,
    pw_require_uppercase: bool,
    pw_require_lowercase: bool,
    pw_require_digits: bool,
    pw_require_special: bool,
    password_change_require_email: bool,
    subtitle_no_email: String,
    saved_direct: String,
}

rustwarden_core::impl_template_response!(PasswordTemplate);

/// Session item for template
#[derive(Clone, Debug)]
#[allow(dead_code)]
struct SessionItem {
    id: String,
    device_info: String,
    ip_address: String,
    last_activity: String,
    created_at: String,
    is_current: bool,
}

/// Sessions page template
#[derive(Template)]
#[template(path = "settings/sessions.html")]
#[allow(dead_code)]
struct SessionsTemplate {
    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,
    roles_text: String,
    initials: String,
    avatar_color: String,
    sessions: Vec<SessionItem>,
    nav_items: Vec<NavItem>,
    has_pending_password_change: bool,
    user_menu_profile: String,
    user_menu_logout: String,

    // Translated strings (hardcoded FR)
    title: String,
    subtitle: String,
    current_session: String,
    current_badge: String,
    unknown_device: String,
    ip_address: String,
    created_at: String,
    last_activity: String,
    session_id: String,
    revoke: String,
    revoke_confirm_template: String,
    revoke_success: String,
    revoke_error: String,
    no_sessions: String,
    no_sessions_desc: String,
    danger_zone: String,
    danger_zone_desc: String,
    revoke_all: String,
    revoke_all_confirm: String,
    revoke_all_success: String,
    revoke_all_error: String,
    about_title: String,
    about_desc: String,
    back_to_settings: String,
    breadcrumb_home: String,
    breadcrumb_settings: String,
    breadcrumb_sessions: String,
}

rustwarden_core::impl_template_response!(SessionsTemplate);

/// Preferences page template
pub async fn get_password_page(
    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(query): Query<PasswordPageQuery>,
) -> Result<impl IntoResponse, AppError> {
    let user_model = UserService::get_user_by_username(&db, &claims.username)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

    let roles = UserService::get_user_roles(&db, user_model.id).await?;
    let user = UserProfileDTO::new(user_model.clone(), roles.clone());

    let locale_str = locale.as_str();

    let must_change = query.must_change.as_deref() == Some("true");
    let must_change_message = if must_change {
        i18n_service
            .translate("password.must_change_message", locale_str)
            .await
    } else {
        String::new()
    };

    let pw_rules =
        rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db).await;

    use rustwarden_core::services::password_change_service::PasswordChangeService;
    let password_change_service = PasswordChangeService::new();
    let pending_request = password_change_service
        .find_pending_by_user_id(&db, user_model.id)
        .await?;
    let has_pending_request = pending_request.is_some();
    let has_pending_password_change = has_pending_request;

    let pending_request_message = if has_pending_request {
        i18n_service
            .translate("password.pending_request_message", locale_str)
            .await
    } else {
        String::new()
    };
    let pending_request_info = if has_pending_request {
        i18n_service
            .translate("password.pending_request_info", locale_str)
            .await
    } else {
        String::new()
    };
    let pending_request_cancel = i18n_service
        .translate("password.pending_request_cancel", locale_str)
        .await;

    let layout = rustwarden_core::templates::LayoutContext::new("Mot de passe", "");
    use crate::template_helpers::csrf_token_helper;
    let csrf_token = csrf_token_helper(&session).await;

    let mut navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
    navigation.set_pending_password_change(has_pending_password_change);
    let nav_items = build_nav_items_from_dto(&navigation, "settings");

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

    let page_title = i18n_service
        .translate("password.page_title", locale_str)
        .await;

    let template = PasswordTemplate {
        page_title,
        csrf_token,
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: user.username.clone(),
        email: user.email.clone(),
        roles_text: user.roles.join(", "),
        initials: user.initials.clone(),
        avatar_color: user.avatar_color.clone(),
        nav_items,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,

        must_change,
        must_change_message,
        has_pending_request,
        pending_request_message,
        pending_request_info,
        pending_request_cancel,
        title: i18n_service.translate("password.title", locale_str).await,
        subtitle: i18n_service
            .translate("password.subtitle", locale_str)
            .await,
        new_password_label: i18n_service
            .translate("password.new_password", locale_str)
            .await,
        current_password_label: i18n_service
            .translate("password.current_password", locale_str)
            .await,
        current_password_placeholder: i18n_service
            .translate("password.current_password_placeholder", locale_str)
            .await,
        new_password_placeholder: i18n_service
            .translate("password.new_password_placeholder", locale_str)
            .await,
        required: i18n_service
            .translate("password.required", locale_str)
            .await,
        requirements: i18n_service
            .translate("password.requirements", locale_str)
            .await,
        strength_label: i18n_service
            .translate("password.strength", locale_str)
            .await,
        strength_weak: i18n_service
            .translate("password.strength_weak", locale_str)
            .await,
        strength_medium: i18n_service
            .translate("password.strength_medium", locale_str)
            .await,
        strength_strong: i18n_service
            .translate("password.strength_strong", locale_str)
            .await,
        strength_too_short: i18n_service
            .translate("password.strength_too_short", locale_str)
            .await,
        strength_missing_lowercase: i18n_service
            .translate("password.strength_missing_lowercase", locale_str)
            .await,
        strength_missing_uppercase: i18n_service
            .translate("password.strength_missing_uppercase", locale_str)
            .await,
        strength_missing_digit: i18n_service
            .translate("password.strength_missing_digit", locale_str)
            .await,
        strength_missing_special: i18n_service
            .translate("password.strength_missing_special", locale_str)
            .await,
        confirm_password_label: i18n_service
            .translate("password.confirm_password", locale_str)
            .await,
        confirm_password_placeholder: i18n_service
            .translate("password.confirm_password_placeholder", locale_str)
            .await,
        passwords_match: i18n_service
            .translate("password.passwords_match", locale_str)
            .await,
        passwords_not_match: i18n_service
            .translate("password.passwords_not_match", locale_str)
            .await,
        change_button: i18n_service
            .translate("password.change_button", locale_str)
            .await,
        changing: i18n_service
            .translate("password.changing", locale_str)
            .await,
        logout_warning_title: i18n_service
            .translate("password.logout_warning_title", locale_str)
            .await,
        logout_warning_desc: i18n_service
            .translate("password.logout_warning_desc", locale_str)
            .await,
        saved: i18n_service.translate("password.saved", locale_str).await,
        error_all_fields_required: i18n_service
            .translate("password.error_all_fields_required", locale_str)
            .await,
        error_empty_data: i18n_service
            .translate("password.error_empty_data", locale_str)
            .await,
        error_invalid_data: i18n_service
            .translate("password.error_invalid_data", locale_str)
            .await,
        error_incorrect_current: i18n_service
            .translate("password.error_incorrect_current", locale_str)
            .await,
        error_reading_response: i18n_service
            .translate("password.error_reading_response", locale_str)
            .await,
        error_415: i18n_service
            .translate("password.error_415", locale_str)
            .await,
        network_error: i18n_service
            .translate("password.network_error", locale_str)
            .await,
        security_tips_title: i18n_service
            .translate("password.security_tips_title", locale_str)
            .await,
        security_tip_unique: i18n_service
            .translate("password.security_tip_unique", locale_str)
            .await,
        security_tip_share: i18n_service
            .translate("password.security_tip_share", locale_str)
            .await,
        security_tip_regular: i18n_service
            .translate("password.security_tip_regular", locale_str)
            .await,
        security_tip_2fa: i18n_service
            .translate("password.security_tip_2fa", locale_str)
            .await,
        security_tip_manager: i18n_service
            .translate("password.security_tip_manager", locale_str)
            .await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_settings: i18n_service
            .translate("breadcrumb.settings", locale_str)
            .await,
        breadcrumb_password: i18n_service
            .translate("breadcrumb.password", locale_str)
            .await,
        cancel: i18n_service.translate("common.cancel", locale_str).await,
        logout_label: i18n_service.translate("user_menu.logout", locale_str).await,
        has_pending_password_change,
        pw_min_length: pw_rules.min_length,
        pw_require_uppercase: pw_rules.require_uppercase,
        pw_require_lowercase: pw_rules.require_lowercase,
        pw_require_digits: pw_rules.require_digits,
        pw_require_special: pw_rules.require_special,
        password_change_require_email: AppSettingsService::get_bool(
            &db,
            "password_change_require_email",
            true,
        )
        .await
        .unwrap_or(true),
        subtitle_no_email: i18n_service
            .translate("password.subtitle_no_email", locale_str)
            .await,
        saved_direct: i18n_service
            .translate("password.saved_direct", locale_str)
            .await,
    };

    Ok(template)
}

/// Handler for GET /settings/sessions - Display sessions page (PROTECTED)
pub async fn get_sessions_page(
    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 user_model = UserService::get_user_by_username(&db, &claims.username)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

    let roles = UserService::get_user_roles(&db, user_model.id).await?;
    let user = UserProfileDTO::new(user_model, roles.clone());

    let locale_str = locale.as_str();

    let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten();

    let mut sessions_data = RefreshTokenService::list_user_sessions(&db, claims.sub).await?;

    if let Some(current_id) = current_token_id {
        for session_data in &mut sessions_data {
            if session_data.id == current_id {
                session_data.is_current = true;
                break;
            }
        }
    }

    let ip_fallback = i18n_service
        .translate("session.ip_unavailable", locale_str)
        .await;
    let sessions: Vec<SessionItem> = sessions_data
        .iter()
        .map(|s| SessionItem {
            id: s.id.to_string(),
            device_info: s
                .device_info
                .clone()
                .unwrap_or_else(|| "Unknown".to_string()),
            ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()),
            last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(),
            created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
            is_current: s.is_current,
        })
        .collect();

    let layout = rustwarden_core::templates::LayoutContext::new("Sessions", "");
    use crate::template_helpers::csrf_token_helper;
    let csrf_token = csrf_token_helper(&session).await;

    let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
    let nav_items = build_nav_items_from_dto(&navigation, "settings");

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

    let page_title = i18n_service
        .translate("sessions.page_title", locale_str)
        .await;

    let template = SessionsTemplate {
        page_title,
        csrf_token,
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        username: user.username.clone(),
        email: user.email.clone(),
        roles_text: user.roles.join(", "),
        initials: user.initials.clone(),
        avatar_color: user.avatar_color.clone(),
        sessions,
        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,

        title: i18n_service.translate("sessions.title", locale_str).await,
        subtitle: i18n_service
            .translate("sessions.subtitle", locale_str)
            .await,
        current_session: i18n_service
            .translate("sessions.current_session", locale_str)
            .await,
        current_badge: i18n_service
            .translate("sessions.current_badge", locale_str)
            .await,
        unknown_device: i18n_service
            .translate("sessions.unknown_device", locale_str)
            .await,
        ip_address: i18n_service
            .translate("sessions.ip_address", locale_str)
            .await,
        created_at: i18n_service
            .translate("sessions.created_at", locale_str)
            .await,
        last_activity: i18n_service
            .translate("sessions.last_activity", locale_str)
            .await,
        session_id: i18n_service
            .translate("sessions.session_id", locale_str)
            .await,
        revoke: i18n_service.translate("sessions.revoke", locale_str).await,
        revoke_confirm_template: i18n_service
            .translate("sessions.revoke_confirm", locale_str)
            .await,
        revoke_success: i18n_service
            .translate("sessions.revoke_success", locale_str)
            .await,
        revoke_error: i18n_service
            .translate("sessions.revoke_error", locale_str)
            .await,
        no_sessions: i18n_service
            .translate("sessions.no_sessions", locale_str)
            .await,
        no_sessions_desc: i18n_service
            .translate("sessions.no_sessions_desc", locale_str)
            .await,
        danger_zone: i18n_service
            .translate("sessions.danger_zone", locale_str)
            .await,
        danger_zone_desc: i18n_service
            .translate("sessions.danger_zone_desc", locale_str)
            .await,
        revoke_all: i18n_service
            .translate("sessions.revoke_all", locale_str)
            .await,
        revoke_all_confirm: i18n_service
            .translate("sessions.revoke_all_confirm", locale_str)
            .await,
        revoke_all_success: i18n_service
            .translate("sessions.revoke_all_success", locale_str)
            .await,
        revoke_all_error: i18n_service
            .translate("sessions.revoke_all_error", locale_str)
            .await,
        about_title: i18n_service
            .translate("sessions.about_title", locale_str)
            .await,
        about_desc: i18n_service
            .translate("sessions.about_desc", locale_str)
            .await,
        back_to_settings: i18n_service
            .translate("sessions.back_to_settings", locale_str)
            .await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_settings: i18n_service
            .translate("breadcrumb.settings", locale_str)
            .await,
        breadcrumb_sessions: i18n_service
            .translate("breadcrumb.sessions", locale_str)
            .await,
    };

    Ok(template)
}