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