profile.rs 305 lignes · 11964 octets
//! Settings - Profile handlers (GET /settings/profile, PUT /settings/profile).

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

use super::{NavItem, build_nav_items_from_dto};
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::user_service::UserService;

/// Inline DTO: Update profile request
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
    /// New username, if the user wants to change it.
    pub username: Option<String>,
    /// Ignored for writes. A different address is refused by `update_profile`
    /// (RFC 0041). The profile page starts a change with
    /// `POST /api/v1/auth/email-change` instead.
    pub email: Option<String>,
}
/// PUT /settings/profile — Update the authenticated user's profile.
pub async fn update_profile(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    _session: Session,
    Json(request): Json<UpdateProfileRequest>,
) -> Result<impl IntoResponse, AppError> {
    let old_user = UserService::get_user_by_id(&db, claims.sub)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

    // RFC 0041: `update_profile` no longer writes `users.email`. It accepts an
    // echo of the current address (the profile form submits every field) and
    // refuses an actual change with a `Validation` error pointing at
    // `EmailChangeService` — the two-step flow lives on the machine channel
    // (`POST /api/v1/auth/email-change`). Nothing to invalidate here either:
    // pending password-change requests are dropped by the core service when
    // the new address is confirmed, not when it is merely requested.
    let updated_user =
        UserService::update_profile(&db, claims.sub, request.username, request.email).await?;

    tracing::info!("Profile updated for user {}", updated_user.username);

    // The UI layer only notifies an actual username rename: the profile form
    // always submits every field, so an echoed (unchanged) username must not
    // trigger an alert.
    if updated_user.username != old_user.username {
        if let Ok(email_config) = EmailConfig::from_env() {
            let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
            let app_name = email_config
                .smtp_from_name
                .clone()
                .unwrap_or_else(rustwarden_core::config::app::default_app_name);

            let changes = vec![rustwarden_core::templates::email_templates::AccountChange {
                field: "Username".to_string(),
                old_value: Some(old_user.username.clone()),
                new_value: updated_user.username.clone(),
            }];

            let template =
                rustwarden_core::templates::email_templates::AccountModifiedTemplate::new(
                    Some("fr".to_string()),
                    updated_user.username.clone(),
                    modified_at,
                    changes,
                    Some(app_name.clone()),
                );

            if let (Ok(body_html), body_text) = (template.render_html(), template.render_text()) {
                let subject = "Compte modifié".to_string();
                use rustwarden_core::services::email_queue_service::EmailQueueService;

                if let Err(e) = EmailQueueService::enqueue(
                    &db,
                    updated_user.email.clone(),
                    Some(updated_user.username.clone()),
                    email_config.smtp_from.clone(),
                    email_config.smtp_from_name.clone(),
                    subject,
                    body_html,
                    body_text,
                    "account_modified".to_string(),
                )
                .await
                {
                    tracing::warn!("Failed to enqueue account modified notification: {}", e);
                }
            }
        }
    }

    Ok(Json(serde_json::json!({
        "message": "Profile updated successfully",
        "username": updated_user.username,
        "email": updated_user.email,
    })))
}
#[derive(Template)]
#[template(path = "profile.html")]
#[allow(dead_code)]
struct ProfileTemplate {
    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,
    user_id: String,
    created_at: String,
    roles: String,
    roles_text: String,
    initials: String,
    avatar_color: String,
    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,
    account_info: String,
    username_label: String,
    username_public: String,
    username_placeholder: String,
    username_pattern: String,
    username_help: String,
    email_label: String,
    email_used_for: String,
    email_warning: String,
    email_placeholder: String,
    email_change_info: String,
    email_change_title: String,
    new_email_label: String,
    current_password_label: String,
    request_button: String,
    email_change_error: String,
    profile_saved: String,
    profile_error: String,
    network_error: String,
    saving: String,
    save_button: String,
    cancel: String,
    additional_info: String,
    account_created: String,
    roles_label: String,
    breadcrumb_home: String,
    breadcrumb_settings: String,
    breadcrumb_profile: String,
}

rustwarden_core::impl_template_response!(ProfileTemplate);

/// GET /settings/profile — Display the user profile settings page.
pub async fn get_profile_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 layout = rustwarden_core::templates::LayoutContext::new("Profil", "");

    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 template = ProfileTemplate {
        page_title: layout.page_title.clone(),
        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(),
        user_id: user.id.to_string(),
        created_at: user.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
        roles: user.roles.join(", "),
        roles_text: user.roles.join(", "),
        initials: user.initials.clone(),
        avatar_color: user.avatar_color.clone(),
        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("profile.title", locale_str).await,
        subtitle: i18n_service.translate("profile.subtitle", locale_str).await,
        account_info: i18n_service
            .translate("profile.account_info", locale_str)
            .await,
        username_label: i18n_service.translate("profile.username", locale_str).await,
        username_public: i18n_service
            .translate("profile.username_public", locale_str)
            .await,
        username_placeholder: i18n_service
            .translate("profile.username_placeholder", locale_str)
            .await,
        username_pattern: i18n_service
            .translate("profile.username_pattern", locale_str)
            .await,
        username_help: i18n_service
            .translate("profile.username_help", locale_str)
            .await,
        email_label: i18n_service
            .translate("profile.email_label", locale_str)
            .await,
        email_used_for: i18n_service
            .translate("profile.email_used_for", locale_str)
            .await,
        email_warning: i18n_service
            .translate("profile.email_warning", locale_str)
            .await,
        email_placeholder: i18n_service
            .translate("profile.email_placeholder", locale_str)
            .await,
        email_change_info: i18n_service
            .translate("profile.email_change_info", locale_str)
            .await,
        email_change_title: i18n_service
            .translate("profile.email_change_title", locale_str)
            .await,
        new_email_label: i18n_service
            .translate("profile.new_email_label", locale_str)
            .await,
        current_password_label: i18n_service
            .translate("profile.current_password_label", locale_str)
            .await,
        request_button: i18n_service
            .translate("profile.request_button", locale_str)
            .await,
        email_change_error: i18n_service
            .translate("profile.email_change_error", locale_str)
            .await,
        profile_saved: i18n_service.translate("profile.saved", locale_str).await,
        profile_error: i18n_service.translate("profile.error", locale_str).await,
        network_error: i18n_service
            .translate("profile.network_error", locale_str)
            .await,
        saving: i18n_service.translate("profile.saving", locale_str).await,
        save_button: i18n_service.translate("profile.save", locale_str).await,
        cancel: i18n_service.translate("common.cancel", locale_str).await,
        additional_info: i18n_service
            .translate("profile.additional_info", locale_str)
            .await,
        account_created: i18n_service
            .translate("profile.account_created", locale_str)
            .await,
        roles_label: i18n_service.translate("profile.roles", locale_str).await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_settings: i18n_service
            .translate("breadcrumb.settings", locale_str)
            .await,
        breadcrumb_profile: i18n_service
            .translate("breadcrumb.profile", locale_str)
            .await,
    };

    Ok(template)
}