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