admin.rs 2152 lignes · 84810 octets
//! Admin handlers for the Fediverse UI foundations slice (RFC 0021).
//!
//! Ships two endpoints behind the standard `admin_required` middleware:
//!
//! - `GET  /admin/fediverse/settings` renders a form pre-filled with the
//!   four runtime toggles persisted in `app_settings`:
//!   `fediverse_enabled`, `fediverse_trust_mode`,
//!   `fediverse_interop_scope`, `fediverse_authorized_fetch`.
//! - `POST /admin/fediverse/settings` applies the form (CSRF-checked)
//!   and redirects back to the GET handler with a flash-style toast
//!   parameter.
//!
//! All labels are pulled from `fediverse.*` keys in `locales/*.yml`.
//! The trust-list screen is delivered in a follow-up patch within the
//! same RFC 0021 slice.

use std::sync::Arc;

use askama::Template;
use axum::extract::{Extension, Form, State};
use axum::response::{IntoResponse, Redirect};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use tower_sessions::Session;

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::middleware::csrf;
use rustwarden_core::services::app_settings_service::AppSettingsService;
use rustwarden_fediverse::services::fediverse_trust_service::{
    BlockSeverity, FediverseTrustService, TrustMode,
};

use rustwarden_core::models::team;
use rustwarden_core::models::user as user_model;
use rustwarden_core::services::team_service::TeamRole;
use rustwarden_fediverse::models::fediverse_outbound_queue;
use rustwarden_fediverse::models::fediverse_pending_invite;
use rustwarden_fediverse::models::remote_identity;
use rustwarden_fediverse::services::fediverse_invite_service::{
    FediverseInviteService, InviteStatus,
};
use rustwarden_fediverse::services::fediverse_outbound_service::OutboundStatus;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};

use rustwarden_ui::dto::NavigationDTO;
use rustwarden_ui::template_helpers::build_base_context_with_claims;

use crate::dto::{AdminTabDTO, admin_tabs};

/// Navbar entry consumed by `components/navigation.html` (admin pages).
///
/// Mirrors the private struct used in `admin_handler` so this file
/// stays standalone — duplication is intentional until a shared
/// `AdminLayoutContext` lands (deferred to a generic refactor).
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct NavItem {
    /// Stable identifier; matched against the active page id.
    pub id: String,
    /// Visible label (already i18n-translated).
    pub label: String,
    /// CSS / heroicon name.
    pub icon: String,
    /// Absolute URL the entry points to.
    pub url: String,
    /// `true` when this entry corresponds to the current page.
    pub active: bool,
    /// `true` when the entry is clickable.
    pub enabled: bool,
    /// Badge text (empty string for none).
    pub badge: String,
    /// Whether to render the badge.
    pub has_badge: bool,
    /// Tooltip shown when `enabled` is false.
    pub disabled_message: String,
    /// Whether to render the disabled-tooltip.
    pub has_disabled_message: bool,
}

async fn build_fediverse_admin_nav(
    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()
}

/// Form payload for `POST /admin/fediverse/settings`.
///
/// Checkboxes are absent from the body when unchecked, hence the
/// `Option<String>` pattern + presence test rather than `bool`.
#[derive(Debug, Deserialize)]
pub struct UpdateFediverseSettingsForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// Present only when the toggle is checked.
    #[serde(default)]
    pub fediverse_enabled: Option<String>,
    /// Selector value: `allowlist` or `open_with_blocklist`.
    pub fediverse_trust_mode: String,
    /// Selector value: `rustwarden_only`, `rustwarden_plus_universal_read`,
    /// or `open`.
    pub fediverse_interop_scope: String,
    /// Present only when the toggle is checked.
    #[serde(default)]
    pub fediverse_authorized_fetch: Option<String>,
}

/// Subset of `app_settings` rendered into the settings template.
#[derive(Debug, Clone)]
pub struct FediverseSettingsCtx {
    /// `"true"` when ActivityPub federation is active runtime-wide.
    pub fediverse_enabled: String,
    /// `allowlist` (default) or `open_with_blocklist`.
    pub fediverse_trust_mode: String,
    /// Interop scope selector value.
    pub fediverse_interop_scope: String,
    /// `"true"` when authorized-fetch (RFC 0017) is required.
    pub fediverse_authorized_fetch: String,
}

/// Askama template for `GET /admin/fediverse/settings`.
#[derive(Template)]
#[template(path = "admin/settings.html")]
#[allow(dead_code)]
pub struct FediverseSettingsTemplate {
    /// Browser tab + page title.
    pub page_title: String,
    /// CSRF token rendered as hidden input.
    pub csrf_token: String,
    /// Application display name.
    pub app_name: String,
    /// Application version (footer).
    pub app_version: String,
    /// Application domain (header).
    pub app_domain: String,
    /// Short app description (header).
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    /// Username for the navbar avatar.
    pub username: String,
    /// Email for the navbar tooltip.
    pub email: String,
    /// Two-letter avatar initials.
    pub initials: String,
    /// CSS class for avatar background.
    pub avatar_color: String,
    /// Comma-joined roles for the navbar tooltip.
    pub roles_text: String,
    /// Whether the user has a pending password change banner.
    pub has_pending_password_change: bool,
    /// Navigation items for the navbar (built from `NavigationDTO`).
    pub nav_items: Vec<NavItem>,
    /// User menu — profile entry label.
    pub user_menu_profile: String,
    /// User menu — logout entry label.
    pub user_menu_logout: String,
    /// Banner displayed when `?saved=1` is in the URL.
    pub saved_flash: bool,
    /// Pre-filled form values.
    pub settings: FediverseSettingsCtx,

    /// Heading "Réglages fédération".
    pub heading: String,
    /// Subtitle explaining the page.
    pub subtitle: String,
    /// Card title "Activation".
    pub card_activation: String,
    /// Card title "Confiance".
    pub card_trust: String,
    /// Card title "Interopérabilité".
    pub card_interop: String,
    /// Card title "Sécurité avancée".
    pub card_security: String,
    /// Toggle label.
    pub enabled_label: String,
    /// Toggle help text.
    pub enabled_desc: String,
    /// trust_mode label.
    pub trust_mode_label: String,
    /// trust_mode option: allowlist.
    pub trust_mode_allowlist: String,
    /// trust_mode option: open_with_blocklist.
    pub trust_mode_open: String,
    /// interop_scope label.
    pub interop_scope_label: String,
    /// interop_scope option: rustwarden_only.
    pub interop_scope_rustwarden_only: String,
    /// interop_scope option: rustwarden_plus_universal_read.
    pub interop_scope_rustwarden_plus: String,
    /// interop_scope option: open.
    pub interop_scope_open: String,
    /// authorized_fetch label.
    pub authorized_fetch_label: String,
    /// authorized_fetch help text.
    pub authorized_fetch_desc: String,
    /// Submit button label.
    pub save_button: String,
    /// Toast text shown after a successful save.
    pub saved_toast: String,
    /// Breadcrumb segment: home.
    pub breadcrumb_home: String,
    /// Breadcrumb segment: admin parent.
    pub breadcrumb_admin: String,
    /// Breadcrumb segment: federation hub (parent of every admin tab).
    pub breadcrumb_federation: String,
    /// Breadcrumb segment: current page (Federation).
    pub breadcrumb_self: String,
    /// Federation admin tab bar entries.
    pub tabs: Vec<AdminTabDTO>,
    /// Slug of the currently displayed tab; matches `tabs[i].slug`.
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseSettingsTemplate);

/// Allowed values for `fediverse_trust_mode` (RFC 0004).
const TRUST_MODES: &[&str] = &["allowlist", "open_with_blocklist"];
/// Allowed values for `fediverse_interop_scope` (master plan §2.1).
const INTEROP_SCOPES: &[&str] = &["rustwarden_only", "rustwarden_plus_universal_read", "open"];

/// Render `GET /admin/fediverse/settings`. Pre-fills the form with the
/// current `app_settings` rows and shows a one-shot toast when the
/// referrer included `?saved=1`.
pub async fn get_settings(
    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::Query(query): axum::extract::Query<SavedQuery>,
) -> 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 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 settings_ctx = FediverseSettingsCtx {
        fediverse_enabled: get_val("fediverse_enabled", "false"),
        fediverse_trust_mode: get_val("fediverse_trust_mode", "allowlist"),
        fediverse_interop_scope: get_val("fediverse_interop_scope", "rustwarden_only"),
        fediverse_authorized_fetch: get_val("fediverse_authorized_fetch", "false"),
    };

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let template = FediverseSettingsTemplate {
        page_title: t("fediverse.settings.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,
        saved_flash: query.saved.unwrap_or(0) == 1,
        settings: settings_ctx,
        heading: t("fediverse.settings.heading").await,
        subtitle: t("fediverse.settings.subtitle").await,
        card_activation: t("fediverse.settings.card_activation").await,
        card_trust: t("fediverse.settings.card_trust").await,
        card_interop: t("fediverse.settings.card_interop").await,
        card_security: t("fediverse.settings.card_security").await,
        enabled_label: t("fediverse.settings.enabled_label").await,
        enabled_desc: t("fediverse.settings.enabled_desc").await,
        trust_mode_label: t("fediverse.settings.trust_mode_label").await,
        trust_mode_allowlist: t("fediverse.settings.trust_mode_allowlist").await,
        trust_mode_open: t("fediverse.settings.trust_mode_open").await,
        interop_scope_label: t("fediverse.settings.interop_scope_label").await,
        interop_scope_rustwarden_only: t("fediverse.settings.interop_scope_rustwarden_only").await,
        interop_scope_rustwarden_plus: t("fediverse.settings.interop_scope_rustwarden_plus").await,
        interop_scope_open: t("fediverse.settings.interop_scope_open").await,
        authorized_fetch_label: t("fediverse.settings.authorized_fetch_label").await,
        authorized_fetch_desc: t("fediverse.settings.authorized_fetch_desc").await,
        save_button: t("fediverse.settings.save_button").await,
        saved_toast: t("fediverse.settings.saved_toast").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse.settings.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "settings".to_string(),
    };

    Ok(template)
}

/// Optional `?saved=1` query parameter.
#[derive(Debug, Deserialize)]
pub struct SavedQuery {
    /// `1` after a successful POST → triggers the success toast.
    pub saved: Option<u8>,
}

/// Apply `POST /admin/fediverse/settings`. Validates CSRF + selector
/// values, persists 4 keys via [`AppSettingsService`], and redirects
/// back to the GET handler with `?saved=1`.
pub async fn update_settings(
    Extension(claims): Extension<Claims>,
    session: Session,
    State(db): State<DatabaseConnection>,
    Form(form): Form<UpdateFediverseSettingsForm>,
) -> Result<impl IntoResponse, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;

    if !TRUST_MODES.contains(&form.fediverse_trust_mode.as_str()) {
        return Err(AppError::Validation(format!(
            "invalid trust_mode: {}",
            form.fediverse_trust_mode
        )));
    }
    if !INTEROP_SCOPES.contains(&form.fediverse_interop_scope.as_str()) {
        return Err(AppError::Validation(format!(
            "invalid interop_scope: {}",
            form.fediverse_interop_scope
        )));
    }

    let bool_str = |opt: &Option<String>| {
        if opt.is_some() {
            "true".to_string()
        } else {
            "false".to_string()
        }
    };

    let updated_by = Some(claims.sub);

    AppSettingsService::set_setting(
        &db,
        "fediverse_enabled".to_string(),
        bool_str(&form.fediverse_enabled),
        updated_by,
    )
    .await?;
    AppSettingsService::set_setting(
        &db,
        "fediverse_trust_mode".to_string(),
        form.fediverse_trust_mode,
        updated_by,
    )
    .await?;
    AppSettingsService::set_setting(
        &db,
        "fediverse_interop_scope".to_string(),
        form.fediverse_interop_scope,
        updated_by,
    )
    .await?;
    AppSettingsService::set_setting(
        &db,
        "fediverse_authorized_fetch".to_string(),
        bool_str(&form.fediverse_authorized_fetch),
        updated_by,
    )
    .await?;

    Ok(Redirect::to("/admin/fediverse/settings?saved=1"))
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse/trust_list (RFC 0021 Slice A iter. 2)
// ─────────────────────────────────────────────────────────────────────

/// One row rendered into the trust-list table. Whether it represents a
/// trusted or a blocked domain is conveyed by the surrounding template
/// state (`is_blocklist`).
#[derive(Debug, Clone)]
pub struct TrustEntryCtx {
    /// Normalised domain.
    pub domain: String,
    /// Optional admin note (allowlist) or blocking reason (blocklist).
    pub note_or_reason: String,
    /// `"true"` when [`Self::note_or_reason`] is non-empty (Askama lacks
    /// a clean `Option` ergonomic for templates).
    pub has_note_or_reason: bool,
    /// Severity (blocklist only): `suspend` / `silence` / `reject_auth`.
    /// Empty string for trusted entries.
    pub severity: String,
    /// `created_at` formatted as `YYYY-MM-DD`.
    pub created_at: String,
}

/// Form payload for `POST /admin/fediverse/trust_list/add`.
#[derive(Debug, Deserialize)]
pub struct AddTrustEntryForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// Domain submitted by the admin (free-form, normalised on insert).
    pub domain: String,
    /// Optional note (allowlist) or reason (blocklist).
    #[serde(default)]
    pub note_or_reason: String,
    /// Blocklist severity: `suspend` / `silence` / `reject_auth`. Ignored
    /// when the active mode is `allowlist`.
    #[serde(default)]
    pub severity: String,
}

/// Form payload for `POST /admin/fediverse/trust_list/delete`.
#[derive(Debug, Deserialize)]
pub struct DeleteTrustEntryForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// Domain to remove from the active list.
    pub domain: String,
}

/// Askama template for `GET /admin/fediverse/trust_list`. The fields
/// below carry pre-translated UI strings whose intent is conveyed by
/// their names; verbose per-field rustdoc would be noise on a 38-key
/// table.
#[derive(Template)]
#[template(path = "admin/trust_list.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseTrustListTemplate {
    /// Browser tab + page title.
    pub page_title: String,
    /// CSRF token for the add + delete forms.
    pub csrf_token: String,
    /// App display fields shared with `settings.html`.
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,
    /// Toast banner shown when `?saved=1`.
    pub saved_flash: bool,
    /// `true` when the active mode is `open_with_blocklist` — drives
    /// table headers and form fields (severity selector vs note).
    pub is_blocklist: bool,
    /// Pre-loaded entries.
    pub entries: Vec<TrustEntryCtx>,
    /// Whether [`Self::entries`] is empty (Askama lacks `is_empty()`
    /// in stable expressions across versions).
    pub has_entries: bool,

    // i18n
    pub heading: String,
    pub subtitle: String,
    pub mode_indicator: String,
    pub add_form_legend: String,
    pub domain_label: String,
    pub domain_placeholder: String,
    pub note_label: String,
    pub reason_label: String,
    pub severity_label: String,
    pub severity_suspend: String,
    pub severity_silence: String,
    pub severity_reject_auth: String,
    pub add_button: String,
    pub table_domain: String,
    pub table_note_or_reason: String,
    pub table_severity: String,
    pub table_created_at: String,
    pub table_actions: String,
    pub delete_button: String,
    pub delete_confirm_title: String,
    pub delete_confirm_body: String,
    pub modal_cancel: String,
    pub modal_confirm: String,
    pub empty_title: String,
    pub empty_body: String,
    pub saved_toast: String,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_federation: String,
    pub breadcrumb_self: String,
    pub tabs: Vec<AdminTabDTO>,
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseTrustListTemplate);

/// Allowed `severity` form values (RFC 0004).
const SEVERITIES: &[&str] = &["suspend", "silence", "reject_auth"];

/// Render `GET /admin/fediverse/trust_list`.
///
/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so the
/// page is invisible while the feature is off. The active list (allow
/// vs block) is selected from `app_settings.fediverse_trust_mode`.
pub async fn get_trust_list(
    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::Query(query): axum::extract::Query<SavedQuery>,
) -> Result<axum::response::Response, AppError> {
    let trust = FediverseTrustService::new(db.clone());

    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    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 mode = trust.trust_mode().await?;
    let is_blocklist = matches!(mode, TrustMode::OpenWithBlocklist);

    let entries: Vec<TrustEntryCtx> = if is_blocklist {
        trust
            .list_blocked_domains()
            .await?
            .into_iter()
            .map(|m| TrustEntryCtx {
                domain: m.domain,
                has_note_or_reason: m.reason.as_deref().is_some_and(|s| !s.is_empty()),
                note_or_reason: m.reason.unwrap_or_default(),
                severity: m.severity,
                created_at: m.created_at.format("%Y-%m-%d").to_string(),
            })
            .collect()
    } else {
        trust
            .list_trusted_domains()
            .await?
            .into_iter()
            .map(|m| TrustEntryCtx {
                domain: m.domain,
                has_note_or_reason: m.note.as_deref().is_some_and(|s| !s.is_empty()),
                note_or_reason: m.note.unwrap_or_default(),
                severity: String::new(),
                created_at: m.created_at.format("%Y-%m-%d").to_string(),
            })
            .collect()
    };
    let has_entries = !entries.is_empty();

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let mode_indicator = if is_blocklist {
        t("fediverse.trust.mode_blocklist").await
    } else {
        t("fediverse.trust.mode_allowlist").await
    };

    let template = FediverseTrustListTemplate {
        page_title: t("fediverse.trust.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,
        saved_flash: query.saved.unwrap_or(0) == 1,
        is_blocklist,
        entries,
        has_entries,
        heading: t("fediverse.trust.heading").await,
        subtitle: t("fediverse.trust.subtitle").await,
        mode_indicator,
        add_form_legend: t("fediverse.trust.add_legend").await,
        domain_label: t("fediverse.trust.domain_label").await,
        domain_placeholder: t("fediverse.trust.domain_placeholder").await,
        note_label: t("fediverse.trust.note_label").await,
        reason_label: t("fediverse.trust.reason_label").await,
        severity_label: t("fediverse.trust.severity_label").await,
        severity_suspend: t("fediverse.trust.severity_suspend").await,
        severity_silence: t("fediverse.trust.severity_silence").await,
        severity_reject_auth: t("fediverse.trust.severity_reject_auth").await,
        add_button: t("fediverse.trust.add_button").await,
        table_domain: t("fediverse.trust.table_domain").await,
        table_note_or_reason: t("fediverse.trust.table_note_or_reason").await,
        table_severity: t("fediverse.trust.table_severity").await,
        table_created_at: t("fediverse.trust.table_created_at").await,
        table_actions: t("fediverse.trust.table_actions").await,
        delete_button: t("fediverse.trust.delete_button").await,
        delete_confirm_title: t("fediverse.trust.delete_confirm_title").await,
        delete_confirm_body: t("fediverse.trust.delete_confirm_body").await,
        modal_cancel: t("fediverse.trust.modal_cancel").await,
        modal_confirm: t("fediverse.trust.modal_confirm").await,
        empty_title: t("fediverse.trust.empty_title").await,
        empty_body: t("fediverse.trust.empty_body").await,
        saved_toast: t("fediverse.trust.saved_toast").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse.trust.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "trust_list".to_string(),
    };

    Ok(template.into_response())
}

/// Apply `POST /admin/fediverse/trust_list/add`. Adds an entry to the
/// allowlist or the blocklist depending on the current `trust_mode`.
pub async fn add_trust_entry(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Form(form): Form<AddTrustEntryForm>,
) -> Result<impl IntoResponse, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;

    let domain = form.domain.trim();
    if domain.is_empty() {
        return Err(AppError::Validation("domain must not be empty".into()));
    }

    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok(axum::http::StatusCode::NOT_FOUND.into_response());
    }
    let mode = trust.trust_mode().await?;
    let added_by = Some(claims.sub);
    let note_opt = if form.note_or_reason.trim().is_empty() {
        None
    } else {
        Some(form.note_or_reason.trim())
    };

    match mode {
        TrustMode::Allowlist => {
            trust.add_trusted_domain(domain, note_opt, added_by).await?;
        }
        TrustMode::OpenWithBlocklist => {
            if !SEVERITIES.contains(&form.severity.as_str()) {
                return Err(AppError::Validation(format!(
                    "invalid severity: {}",
                    form.severity
                )));
            }
            let severity = BlockSeverity::parse(&form.severity);
            trust
                .add_blocked_domain(domain, severity, note_opt, added_by)
                .await?;
            // F9 (iter 4 / S4) — auto-kill: blocking a domain must
            // immediately disable any active `remote_identities` for
            // that domain. Without this, the admin has to perform two
            // separate actions for a single intent (« cut this hostile
            // instance »). The middleware F10 check then rejects all
            // future protected requests carrying a JWT bound to the
            // disabled identity.
            use rustwarden_fediverse::services::fediverse_trust_service::normalize_domain;
            use sea_orm::sea_query::Expr;
            let now = chrono::Utc::now();
            let normalized = normalize_domain(domain);
            remote_identity::Entity::update_many()
                .col_expr(remote_identity::Column::DisabledAt, Expr::value(now))
                .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now))
                .filter(remote_identity::Column::Domain.eq(normalized))
                .filter(remote_identity::Column::DisabledAt.is_null())
                .exec(&db)
                .await?;
        }
    }

    Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response())
}

/// Apply `POST /admin/fediverse/trust_list/delete`. Removes an entry by
/// canonical domain (URL-safe form input) from the active list.
pub async fn delete_trust_entry(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Form(form): Form<DeleteTrustEntryForm>,
) -> Result<impl IntoResponse, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;
    // ANSSI FUI-AUTH-1 — audit trail for destructive trust mutation.
    tracing::info!(
        admin_id = %claims.sub,
        domain = %form.domain,
        action = "delete_trust_entry",
        "admin destructive fediverse action",
    );

    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok(axum::http::StatusCode::NOT_FOUND.into_response());
    }
    let mode = trust.trust_mode().await?;

    match mode {
        TrustMode::Allowlist => {
            trust.remove_trusted_domain(&form.domain).await?;
        }
        TrustMode::OpenWithBlocklist => {
            trust.remove_blocked_domain(&form.domain).await?;
        }
    }

    Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse/queue (RFC 0024 Slice C iter. 1)
// ─────────────────────────────────────────────────────────────────────

/// One row rendered into the outbound-queue table.
#[derive(Debug, Clone)]
pub struct OutboundQueueRowCtx {
    /// Activity ID (truncated for display).
    pub activity_id: String,
    /// Target inbox URL.
    pub target_inbox: String,
    /// Target domain extracted by `FediverseOutboundService::enqueue`.
    pub target_domain: String,
    /// `pending` / `delivered` / `failed` / `dead`.
    pub status: String,
    /// Number of attempts already performed.
    pub attempts: i32,
    /// Maximum attempts before terminal `dead`.
    pub max_attempts: i32,
    /// Last delivery error truncated.
    pub last_error: String,
    /// `true` when [`Self::last_error`] is non-empty.
    pub has_last_error: bool,
    /// `next_retry_at` formatted RFC3339, empty when `None`.
    pub next_retry_at: String,
    /// Insertion timestamp formatted `YYYY-MM-DD HH:MM:SS`.
    pub created_at: String,
}

/// Askama template for `GET /admin/fediverse/queue`.
#[derive(Template)]
#[template(path = "admin/queue.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseQueueTemplate {
    pub page_title: String,
    pub csrf_token: String,
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,

    /// Pre-loaded last 100 rows ordered by `created_at desc`.
    pub rows: Vec<OutboundQueueRowCtx>,
    /// `true` when [`Self::rows`] is empty.
    pub has_rows: bool,
    /// Per-status counts (sums all rows, not just the 100 visible).
    pub count_pending: u64,
    pub count_delivered: u64,
    pub count_failed: u64,
    pub count_dead: u64,

    pub heading: String,
    pub subtitle: String,
    pub stats_pending: String,
    pub stats_delivered: String,
    pub stats_failed: String,
    pub stats_dead: String,
    pub table_activity: String,
    pub table_target: String,
    pub table_status: String,
    pub table_attempts: String,
    pub table_last_error: String,
    pub table_next_retry: String,
    pub table_created_at: String,
    pub empty_title: String,
    pub empty_body: String,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_federation: String,
    pub breadcrumb_self: String,
    pub tabs: Vec<AdminTabDTO>,
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseQueueTemplate);

/// Truncate a string to `max` chars, appending `…` when cut.
fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let head: String = s.chars().take(max.saturating_sub(1)).collect();
        format!("{head}…")
    }
}

/// Render `GET /admin/fediverse/queue` — operator dashboard for the
/// outbound delivery queue (RFC 0011 + RFC 0019). Shows the last 100
/// rows + per-status counts so an admin can spot a backlog at a glance.
///
/// Returns `404` when `fediverse_enabled = false` (Plan UI §3 P9).
pub async fn get_queue(
    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<axum::response::Response, AppError> {
    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    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 count_pending = fediverse_outbound_queue::Entity::find()
        .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str()))
        .count(&db)
        .await
        .unwrap_or(0);
    let count_delivered = fediverse_outbound_queue::Entity::find()
        .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Delivered.as_str()))
        .count(&db)
        .await
        .unwrap_or(0);
    let count_failed = fediverse_outbound_queue::Entity::find()
        .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Failed.as_str()))
        .count(&db)
        .await
        .unwrap_or(0);
    let count_dead = fediverse_outbound_queue::Entity::find()
        .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Dead.as_str()))
        .count(&db)
        .await
        .unwrap_or(0);

    let rows_raw = fediverse_outbound_queue::Entity::find()
        .order_by_desc(fediverse_outbound_queue::Column::CreatedAt)
        .limit(100)
        .all(&db)
        .await?;

    let rows: Vec<OutboundQueueRowCtx> = rows_raw
        .into_iter()
        .map(|m| OutboundQueueRowCtx {
            activity_id: truncate(&m.activity_id, 60),
            target_inbox: truncate(&m.target_inbox, 60),
            target_domain: m.target_domain,
            status: m.status,
            attempts: m.attempts,
            max_attempts: m.max_attempts,
            has_last_error: m.last_error.as_deref().is_some_and(|s| !s.is_empty()),
            last_error: m.last_error.map(|e| truncate(&e, 80)).unwrap_or_default(),
            next_retry_at: m.next_retry_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
            created_at: m.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
        })
        .collect();
    let has_rows = !rows.is_empty();

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let template = FediverseQueueTemplate {
        page_title: t("fediverse.queue.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,

        rows,
        has_rows,
        count_pending,
        count_delivered,
        count_failed,
        count_dead,

        heading: t("fediverse.queue.heading").await,
        subtitle: t("fediverse.queue.subtitle").await,
        stats_pending: t("fediverse.queue.stats_pending").await,
        stats_delivered: t("fediverse.queue.stats_delivered").await,
        stats_failed: t("fediverse.queue.stats_failed").await,
        stats_dead: t("fediverse.queue.stats_dead").await,
        table_activity: t("fediverse.queue.table_activity").await,
        table_target: t("fediverse.queue.table_target").await,
        table_status: t("fediverse.queue.table_status").await,
        table_attempts: t("fediverse.queue.table_attempts").await,
        table_last_error: t("fediverse.queue.table_last_error").await,
        table_next_retry: t("fediverse.queue.table_next_retry").await,
        table_created_at: t("fediverse.queue.table_created_at").await,
        empty_title: t("fediverse.queue.empty_title").await,
        empty_body: t("fediverse.queue.empty_body").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse.queue.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "queue".to_string(),
    };

    Ok(template.into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse/sessions_remote (RFC 0024 Slice C iter. 2)
// ─────────────────────────────────────────────────────────────────────

/// One row rendered into the sessions-remote table.
#[derive(Debug, Clone)]
pub struct RemoteSessionRowCtx {
    /// PK of the remote_identities row.
    pub id: String,
    /// Canonical AP id (`https://b.tld/users/alice`), truncated.
    pub ap_id: String,
    /// `@user@domain` recomposed for human display.
    pub handle: String,
    /// Display name advertised by the actor (or empty).
    pub display_name: String,
    /// Whether [`Self::display_name`] is non-empty.
    pub has_display_name: bool,
    /// Domain of the federated instance.
    pub domain: String,
    /// `last_fetched_at` formatted `YYYY-MM-DD HH:MM`.
    pub last_seen: String,
}

/// Form payload for `POST /admin/fediverse/sessions_remote/revoke`.
#[derive(Debug, Deserialize)]
pub struct RevokeRemoteForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// PK of the remote_identities row to disable.
    pub id: String,
}

/// Form payload for `POST /admin/fediverse/sessions_remote/kill_domain`.
#[derive(Debug, Deserialize)]
pub struct KillDomainForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// Domain whose remote_identities rows should be disabled in bulk.
    pub domain: String,
}

/// Askama template for `GET /admin/fediverse/sessions_remote`.
#[derive(Template)]
#[template(path = "admin/sessions_remote.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseSessionsRemoteTemplate {
    pub page_title: String,
    pub csrf_token: String,
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,

    pub saved_flash: bool,
    pub rows: Vec<RemoteSessionRowCtx>,
    pub has_rows: bool,
    /// Distinct domains present in [`Self::rows`] for the kill-switch
    /// `<select>`.
    pub distinct_domains: Vec<String>,

    pub heading: String,
    pub subtitle: String,
    pub kill_switch_legend: String,
    pub kill_switch_label: String,
    pub kill_switch_button: String,
    pub kill_switch_confirm_title: String,
    pub kill_switch_confirm_body: String,
    pub table_handle: String,
    pub table_display_name: String,
    pub table_domain: String,
    pub table_last_seen: String,
    pub table_actions: String,
    pub revoke_button: String,
    pub revoke_confirm_title: String,
    pub revoke_confirm_body: String,
    pub modal_cancel: String,
    pub modal_confirm: String,
    pub empty_title: String,
    pub empty_body: String,
    pub saved_toast: String,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_federation: String,
    pub breadcrumb_self: String,
    pub tabs: Vec<AdminTabDTO>,
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseSessionsRemoteTemplate);

/// Render `GET /admin/fediverse/sessions_remote` — list active federated
/// identities (rows with `disabled_at IS NULL`).
pub async fn get_sessions_remote(
    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::Query(query): axum::extract::Query<SavedQuery>,
) -> Result<axum::response::Response, AppError> {
    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    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 active_rows = remote_identity::Entity::find()
        .filter(remote_identity::Column::DisabledAt.is_null())
        .order_by_desc(remote_identity::Column::LastFetchedAt)
        .limit(200)
        .all(&db)
        .await?;

    let rows: Vec<RemoteSessionRowCtx> = active_rows
        .iter()
        .map(|m| RemoteSessionRowCtx {
            id: m.id.to_string(),
            ap_id: truncate(&m.ap_id, 60),
            handle: format!("@{}@{}", m.username, m.domain),
            has_display_name: m.display_name.as_deref().is_some_and(|s| !s.is_empty()),
            display_name: m.display_name.clone().unwrap_or_default(),
            domain: m.domain.clone(),
            last_seen: m.last_fetched_at.format("%Y-%m-%d %H:%M").to_string(),
        })
        .collect();
    let has_rows = !rows.is_empty();

    let mut distinct_domains: Vec<String> = active_rows
        .iter()
        .map(|m| m.domain.clone())
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect();
    distinct_domains.sort();

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let template = FediverseSessionsRemoteTemplate {
        page_title: t("fediverse.sessions_remote.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,

        saved_flash: query.saved.unwrap_or(0) == 1,
        rows,
        has_rows,
        distinct_domains,

        heading: t("fediverse.sessions_remote.heading").await,
        subtitle: t("fediverse.sessions_remote.subtitle").await,
        kill_switch_legend: t("fediverse.sessions_remote.kill_switch_legend").await,
        kill_switch_label: t("fediverse.sessions_remote.kill_switch_label").await,
        kill_switch_button: t("fediverse.sessions_remote.kill_switch_button").await,
        kill_switch_confirm_title: t("fediverse.sessions_remote.kill_switch_confirm_title").await,
        kill_switch_confirm_body: t("fediverse.sessions_remote.kill_switch_confirm_body").await,
        table_handle: t("fediverse.sessions_remote.table_handle").await,
        table_display_name: t("fediverse.sessions_remote.table_display_name").await,
        table_domain: t("fediverse.sessions_remote.table_domain").await,
        table_last_seen: t("fediverse.sessions_remote.table_last_seen").await,
        table_actions: t("fediverse.sessions_remote.table_actions").await,
        revoke_button: t("fediverse.sessions_remote.revoke_button").await,
        revoke_confirm_title: t("fediverse.sessions_remote.revoke_confirm_title").await,
        revoke_confirm_body: t("fediverse.sessions_remote.revoke_confirm_body").await,
        modal_cancel: t("fediverse.sessions_remote.modal_cancel").await,
        modal_confirm: t("fediverse.sessions_remote.modal_confirm").await,
        empty_title: t("fediverse.sessions_remote.empty_title").await,
        empty_body: t("fediverse.sessions_remote.empty_body").await,
        saved_toast: t("fediverse.sessions_remote.saved_toast").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse.sessions_remote.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "sessions_remote".to_string(),
    };

    Ok(template.into_response())
}

/// `POST /admin/fediverse/sessions_remote/revoke` — set `disabled_at`
/// on a single remote_identities row.
pub async fn revoke_remote_session(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Form(form): Form<RevokeRemoteForm>,
) -> Result<axum::response::Response, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;
    // ANSSI FUI-AUTH-1 — audit trail for remote session revocation.
    tracing::info!(
        admin_id = %claims.sub,
        remote_identity_id = %form.id,
        action = "revoke_remote_session",
        "admin destructive fediverse action",
    );

    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    let id = uuid::Uuid::parse_str(&form.id)
        .map_err(|_| AppError::Validation("invalid remote identity id".into()))?;
    let now = chrono::Utc::now();

    use sea_orm::sea_query::Expr;
    remote_identity::Entity::update_many()
        .col_expr(remote_identity::Column::DisabledAt, Expr::value(now))
        .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now))
        .filter(remote_identity::Column::Id.eq(id))
        .exec(&db)
        .await?;

    Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response())
}

/// `POST /admin/fediverse/sessions_remote/kill_domain` — bulk disable
/// every remote_identities row from a given domain. Used as a panic
/// button when an entire federated instance is compromised.
pub async fn kill_domain(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Form(form): Form<KillDomainForm>,
) -> Result<axum::response::Response, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;
    // ANSSI FUI-AUTH-1 — audit trail for whole-domain disablement.
    tracing::info!(
        admin_id = %claims.sub,
        domain = %form.domain,
        action = "kill_domain",
        "admin destructive fediverse action",
    );

    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    let domain = form.domain.trim().to_ascii_lowercase();
    if domain.is_empty() {
        return Err(AppError::Validation("domain must not be empty".into()));
    }
    let now = chrono::Utc::now();

    use sea_orm::sea_query::Expr;
    remote_identity::Entity::update_many()
        .col_expr(remote_identity::Column::DisabledAt, Expr::value(now))
        .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now))
        .filter(remote_identity::Column::Domain.eq(domain))
        .filter(remote_identity::Column::DisabledAt.is_null())
        .exec(&db)
        .await?;

    Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse/nodeinfo (RFC 0024 Slice C iter. 3)
// ─────────────────────────────────────────────────────────────────────

/// Askama template for `GET /admin/fediverse/nodeinfo`.
#[derive(Template)]
#[template(path = "admin/nodeinfo.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseNodeInfoTemplate {
    pub page_title: String,
    pub csrf_token: String,
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,

    // Software
    pub software_name: String,
    pub software_version: String,
    pub fediverse_domain: String,
    // Federation runtime config
    pub fediverse_enabled_label: String,
    pub trust_mode: String,
    pub interop_scope: String,
    pub authorized_fetch: bool,
    // User counts
    pub users_total: u64,
    pub users_active_month: u64,
    pub users_active_halfyear: u64,
    pub remote_identities_active: u64,
    pub remote_distinct_domains: u64,

    // i18n
    pub heading: String,
    pub subtitle: String,
    pub card_software: String,
    pub card_runtime: String,
    pub card_users: String,
    pub card_remotes: String,
    pub label_name: String,
    pub label_version: String,
    pub label_domain: String,
    pub label_enabled: String,
    pub label_trust_mode: String,
    pub label_interop_scope: String,
    pub label_authorized_fetch: String,
    pub label_users_total: String,
    pub label_users_active_month: String,
    pub label_users_active_halfyear: String,
    pub label_remote_identities_active: String,
    pub label_remote_distinct_domains: String,
    pub yes_label: String,
    pub no_label: String,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_federation: String,
    pub breadcrumb_self: String,
    pub tabs: Vec<AdminTabDTO>,
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseNodeInfoTemplate);

/// Render `GET /admin/fediverse/nodeinfo` — humanised stats panel.
///
/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`.
pub async fn get_nodeinfo(
    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<axum::response::Response, AppError> {
    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    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?;

    // Federation runtime config (read straight from app_settings).
    let trust_mode = match trust.trust_mode().await? {
        TrustMode::Allowlist => "allowlist".to_string(),
        TrustMode::OpenWithBlocklist => "open_with_blocklist".to_string(),
    };
    let interop_scope = AppSettingsService::get_setting(&db, "fediverse_interop_scope")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "rustwarden_only".to_string());
    let authorized_fetch =
        AppSettingsService::get_bool(&db, "fediverse_authorized_fetch", false).await?;

    // User counts (local users).
    let users_total = user_model::Entity::find().count(&db).await.unwrap_or(0);
    let now = chrono::Utc::now();
    let users_active_month = user_model::Entity::find()
        .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(30)))
        .count(&db)
        .await
        .unwrap_or(0);
    let users_active_halfyear = user_model::Entity::find()
        .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(180)))
        .count(&db)
        .await
        .unwrap_or(0);

    // Remote identities (active = disabled_at IS NULL).
    let remote_identities_active = remote_identity::Entity::find()
        .filter(remote_identity::Column::DisabledAt.is_null())
        .count(&db)
        .await
        .unwrap_or(0);

    let remote_rows = remote_identity::Entity::find()
        .filter(remote_identity::Column::DisabledAt.is_null())
        .all(&db)
        .await
        .unwrap_or_default();
    let remote_distinct_domains = remote_rows
        .iter()
        .map(|m| m.domain.clone())
        .collect::<std::collections::BTreeSet<_>>()
        .len() as u64;

    let fediverse_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "—".to_string());

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let template = FediverseNodeInfoTemplate {
        page_title: t("fediverse_nodeinfo.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,

        software_name: "rustwarden".to_string(),
        software_version: env!("CARGO_PKG_VERSION").to_string(),
        fediverse_domain,
        fediverse_enabled_label: t("common.yes").await,
        trust_mode,
        interop_scope,
        authorized_fetch,
        users_total,
        users_active_month,
        users_active_halfyear,
        remote_identities_active,
        remote_distinct_domains,

        heading: t("fediverse_nodeinfo.heading").await,
        subtitle: t("fediverse_nodeinfo.subtitle").await,
        card_software: t("fediverse_nodeinfo.card_software").await,
        card_runtime: t("fediverse_nodeinfo.card_runtime").await,
        card_users: t("fediverse_nodeinfo.card_users").await,
        card_remotes: t("fediverse_nodeinfo.card_remotes").await,
        label_name: t("fediverse_nodeinfo.label_name").await,
        label_version: t("fediverse_nodeinfo.label_version").await,
        label_domain: t("fediverse_nodeinfo.label_domain").await,
        label_enabled: t("fediverse_nodeinfo.label_enabled").await,
        label_trust_mode: t("fediverse_nodeinfo.label_trust_mode").await,
        label_interop_scope: t("fediverse_nodeinfo.label_interop_scope").await,
        label_authorized_fetch: t("fediverse_nodeinfo.label_authorized_fetch").await,
        label_users_total: t("fediverse_nodeinfo.label_users_total").await,
        label_users_active_month: t("fediverse_nodeinfo.label_users_active_month").await,
        label_users_active_halfyear: t("fediverse_nodeinfo.label_users_active_halfyear").await,
        label_remote_identities_active: t("fediverse_nodeinfo.label_remote_identities_active")
            .await,
        label_remote_distinct_domains: t("fediverse_nodeinfo.label_remote_distinct_domains").await,
        yes_label: t("common.yes").await,
        no_label: t("common.no").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse_nodeinfo.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "nodeinfo".to_string(),
    };

    Ok(template.into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse/invites/{team_id} (RFC 0024 Slice C iter. 4)
// ─────────────────────────────────────────────────────────────────────

/// One row rendered into the team invites table.
#[derive(Debug, Clone)]
pub struct TeamInviteRowCtx {
    /// PK of the invite row.
    pub id: String,
    /// `@user@domain` handle.
    pub invited_handle: String,
    /// Wire form: `owner` / `admin` / `member`.
    pub role: String,
    /// Wire form: `pending` / `accepted` / `rejected` / `revoked` / `expired`.
    pub status: String,
    /// `created_at` formatted `YYYY-MM-DD HH:MM`.
    pub created_at: String,
}

/// Form payload for `POST /admin/fediverse/invites/{team_id}/add`.
#[derive(Debug, Deserialize)]
pub struct CreateTeamInviteForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// `@user@domain` handle to invite.
    pub handle: String,
    /// `owner` / `admin` / `member`.
    pub role: String,
}

/// Form payload for `POST /admin/fediverse/invites/{team_id}/revoke`.
#[derive(Debug, Deserialize)]
pub struct RevokeInviteForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// PK of the invite row to revoke.
    pub id: String,
}

/// Askama template for `GET /admin/fediverse/invites/{team_id}`.
#[derive(Template)]
#[template(path = "admin/team_invites.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseTeamInvitesTemplate {
    pub page_title: String,
    pub csrf_token: String,
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,

    pub saved_flash: bool,
    /// Team name for the page heading.
    pub team_name: String,
    /// Team UUID (for form actions).
    pub team_id: String,
    pub rows: Vec<TeamInviteRowCtx>,
    pub has_rows: bool,

    pub heading: String,
    pub subtitle: String,
    pub add_form_legend: String,
    pub handle_label: String,
    pub handle_placeholder: String,
    pub role_label: String,
    pub role_member: String,
    pub role_admin: String,
    pub role_owner: String,
    pub add_button: String,
    pub table_handle: String,
    pub table_role: String,
    pub table_status: String,
    pub table_created_at: String,
    pub table_actions: String,
    pub revoke_button: String,
    pub revoke_confirm_title: String,
    pub revoke_confirm_body: String,
    pub modal_cancel: String,
    pub modal_confirm: String,
    pub empty_title: String,
    pub empty_body: String,
    pub saved_toast: String,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_federation: String,
    pub breadcrumb_self: String,
    pub tabs: Vec<AdminTabDTO>,
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseTeamInvitesTemplate);

/// Default TTL for team invitations (RFC 0009 §6).
const INVITE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);

/// `GET /admin/fediverse/invites/{team_id}` — list pending invites.
#[allow(clippy::too_many_arguments)]
pub async fn get_team_invites(
    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(team_id): axum::extract::Path<uuid::Uuid>,
    axum::extract::Query(query): axum::extract::Query<SavedQuery>,
) -> Result<axum::response::Response, AppError> {
    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    // Verify team exists; otherwise 404.
    let Some(team_row) = team::Entity::find_by_id(team_id).one(&db).await? else {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Team not found").into_response());
    };

    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?;

    // Show all statuses for this team (not just pending) so the admin
    // sees the historical decisions. Order by created_at desc.
    let invite_rows = fediverse_pending_invite::Entity::find()
        .filter(fediverse_pending_invite::Column::TeamId.eq(team_id))
        .order_by_desc(fediverse_pending_invite::Column::CreatedAt)
        .limit(100)
        .all(&db)
        .await?;

    let rows: Vec<TeamInviteRowCtx> = invite_rows
        .into_iter()
        .map(|m| TeamInviteRowCtx {
            id: m.id.to_string(),
            invited_handle: m.invited_handle,
            role: m.role,
            status: m.status,
            created_at: m.created_at.format("%Y-%m-%d %H:%M").to_string(),
        })
        .collect();
    let has_rows = !rows.is_empty();

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let template = FediverseTeamInvitesTemplate {
        page_title: t("fediverse_invites.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,

        saved_flash: query.saved.unwrap_or(0) == 1,
        team_name: team_row.name,
        team_id: team_id.to_string(),
        rows,
        has_rows,

        heading: t("fediverse_invites.heading").await,
        subtitle: t("fediverse_invites.subtitle").await,
        add_form_legend: t("fediverse_invites.add_legend").await,
        handle_label: t("fediverse_invites.handle_label").await,
        handle_placeholder: t("fediverse_invites.handle_placeholder").await,
        role_label: t("fediverse_invites.role_label").await,
        role_member: t("fediverse_invites.role_member").await,
        role_admin: t("fediverse_invites.role_admin").await,
        role_owner: t("fediverse_invites.role_owner").await,
        add_button: t("fediverse_invites.add_button").await,
        table_handle: t("fediverse_invites.table_handle").await,
        table_role: t("fediverse_invites.table_role").await,
        table_status: t("fediverse_invites.table_status").await,
        table_created_at: t("fediverse_invites.table_created_at").await,
        table_actions: t("fediverse_invites.table_actions").await,
        revoke_button: t("fediverse_invites.revoke_button").await,
        revoke_confirm_title: t("fediverse_invites.revoke_confirm_title").await,
        revoke_confirm_body: t("fediverse_invites.revoke_confirm_body").await,
        modal_cancel: t("fediverse_invites.modal_cancel").await,
        modal_confirm: t("fediverse_invites.modal_confirm").await,
        empty_title: t("fediverse_invites.empty_title").await,
        empty_body: t("fediverse_invites.empty_body").await,
        saved_toast: t("fediverse_invites.saved_toast").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse_invites.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "invites".to_string(),
    };

    Ok(template.into_response())
}

/// `POST /admin/fediverse/invites/{team_id}/add` — create a pending invite.
pub async fn create_team_invite(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
    Form(form): Form<CreateTeamInviteForm>,
) -> Result<axum::response::Response, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;

    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    let handle = form.handle.trim();
    if handle.is_empty() || !handle.contains('@') {
        return Err(AppError::Validation(
            "handle must be of form @user@domain".into(),
        ));
    }
    let role = TeamRole::parse(&form.role)
        .ok_or_else(|| AppError::Validation(format!("invalid role: {}", form.role)))?;

    FediverseInviteService::invite_remote(&db, team_id, handle, role, claims.sub, INVITE_TTL)
        .await?;

    Ok(Redirect::to(&format!("/admin/fediverse/invites/{team_id}?saved=1")).into_response())
}

/// `POST /admin/fediverse/invites/{team_id}/revoke` — revoke an invite.
pub async fn revoke_team_invite(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
    Form(form): Form<RevokeInviteForm>,
) -> Result<axum::response::Response, AppError> {
    csrf::validate_csrf_token(&session, &form.csrf_token).await?;
    // ANSSI FUI-AUTH-1 — audit trail for team invite revocation.
    tracing::info!(
        admin_id = %claims.sub,
        team_id = %team_id,
        invite_id = %form.id,
        action = "revoke_team_invite",
        "admin destructive fediverse action",
    );

    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    let invite_id = uuid::Uuid::parse_str(&form.id)
        .map_err(|_| AppError::Validation("invalid invite id".into()))?;

    // Touch unused-import guard for InviteStatus (kept exported).
    let _ = InviteStatus::Pending;

    FediverseInviteService::revoke(&db, invite_id).await?;

    Ok(Redirect::to(&format!("/admin/fediverse/invites/{team_id}?saved=1")).into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse — federation admin hub
// ─────────────────────────────────────────────────────────────────────

/// One card rendered into the federation admin hub.
#[derive(Debug, Clone)]
pub struct HubCardCtx {
    /// Translated card title.
    pub title: String,
    /// Translated short description.
    pub desc: String,
    /// Absolute URL the card links to.
    pub href: String,
    /// Unicode glyph rendered as a leading icon.
    pub icon: String,
}

/// Askama template for `GET /admin/fediverse` (the federation hub).
#[derive(Template)]
#[template(path = "admin/hub.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseHubTemplate {
    pub page_title: String,
    pub csrf_token: String,
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,

    pub heading: String,
    pub subtitle: String,
    pub cards: Vec<HubCardCtx>,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_self: String,
}

rustwarden_core::impl_template_response!(FediverseHubTemplate);

/// Render `GET /admin/fediverse` — entry point for every admin
/// sub-section. Lists the six federation cards (settings, trust list,
/// queue, remote sessions, nodeinfo, invites) so the admin can pick
/// where to land instead of memorising URLs.
///
/// Unlike the rest of `/admin/fediverse/*`, the hub is reachable even
/// when `fediverse_enabled = false` so the admin can navigate to the
/// settings page and turn the feature on.
pub async fn get_hub(
    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 base_context = build_base_context_with_claims(&session, &db, &claims).await?;
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let card_specs: &[(&str, &str, &str, &str)] = &[
        (
            "settings",
            "/admin/fediverse/settings",
            "fediverse.admin.hub.card.settings.title",
            "fediverse.admin.hub.card.settings.desc",
        ),
        (
            "trust_list",
            "/admin/fediverse/trust_list",
            "fediverse.admin.hub.card.trust_list.title",
            "fediverse.admin.hub.card.trust_list.desc",
        ),
        (
            "queue",
            "/admin/fediverse/queue",
            "fediverse.admin.hub.card.queue.title",
            "fediverse.admin.hub.card.queue.desc",
        ),
        (
            "sessions_remote",
            "/admin/fediverse/sessions_remote",
            "fediverse.admin.hub.card.sessions_remote.title",
            "fediverse.admin.hub.card.sessions_remote.desc",
        ),
        (
            "nodeinfo",
            "/admin/fediverse/nodeinfo",
            "fediverse.admin.hub.card.nodeinfo.title",
            "fediverse.admin.hub.card.nodeinfo.desc",
        ),
        (
            "invites",
            "/admin/fediverse/teams",
            "fediverse.admin.hub.card.invites.title",
            "fediverse.admin.hub.card.invites.desc",
        ),
    ];

    let icons: &[&str] = &["⚙️", "🛡️", "📤", "🌐", "📊", "✉️"];
    let mut cards = Vec::with_capacity(card_specs.len());
    for ((_, href, tk, dk), icon) in card_specs.iter().zip(icons.iter()) {
        cards.push(HubCardCtx {
            title: t(tk).await,
            desc: t(dk).await,
            href: (*href).to_string(),
            icon: (*icon).to_string(),
        });
    }

    let template = FediverseHubTemplate {
        page_title: t("fediverse.admin.hub.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,

        heading: t("fediverse.admin.hub.title").await,
        subtitle: t("fediverse.admin.hub.subtitle").await,
        cards,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_self: t("fediverse.admin.hub.title").await,
    };

    Ok(template)
}

// ─────────────────────────────────────────────────────────────────────
// /admin/fediverse/teams — picker for the per-team invites screen
// ─────────────────────────────────────────────────────────────────────

/// One row rendered into the team picker for invites.
#[derive(Debug, Clone)]
pub struct TeamRowCtx {
    /// Team UUID used in the per-team invites URL.
    pub id: String,
    /// Team display name.
    pub name: String,
    /// Team URL slug (rendered for disambiguation).
    pub slug: String,
}

/// Askama template for `GET /admin/fediverse/teams`.
#[derive(Template)]
#[template(path = "admin/teams_for_invites.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseTeamsForInvitesTemplate {
    pub page_title: String,
    pub csrf_token: String,
    pub app_name: String,
    pub app_version: String,
    pub app_domain: String,
    pub app_description: String,
    /// Audience-measurement opt-out. Empty renders nothing.
    pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext,
    pub username: String,
    pub email: String,
    pub initials: String,
    pub avatar_color: String,
    pub roles_text: String,
    pub has_pending_password_change: bool,
    pub nav_items: Vec<NavItem>,
    pub user_menu_profile: String,
    pub user_menu_logout: String,

    pub heading: String,
    pub subtitle: String,
    pub teams: Vec<TeamRowCtx>,
    pub has_teams: bool,
    pub empty_title: String,
    pub empty_body: String,
    pub breadcrumb_home: String,
    pub breadcrumb_admin: String,
    pub breadcrumb_federation: String,
    pub breadcrumb_self: String,
    pub tabs: Vec<AdminTabDTO>,
    pub active_tab: String,
}

rustwarden_core::impl_template_response!(FediverseTeamsForInvitesTemplate);

/// Render `GET /admin/fediverse/teams` — list every team so the admin
/// can pick one before reaching `/admin/fediverse/invites/{team_id}`.
/// Lives at the federation hub level because no global "all invites"
/// view exists today.
pub async fn get_teams_for_invites(
    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<axum::response::Response, AppError> {
    let trust = FediverseTrustService::new(db.clone());
    if !trust.is_enabled().await? {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
    }

    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 team_rows = team::Entity::find()
        .order_by_asc(team::Column::Name)
        .all(&db)
        .await?;

    let teams: Vec<TeamRowCtx> = team_rows
        .into_iter()
        .map(|t| TeamRowCtx {
            id: t.id.to_string(),
            name: t.name,
            slug: t.slug,
        })
        .collect();
    let has_teams = !teams.is_empty();

    let t = |k: &str| {
        let i18n = i18n_service.clone();
        let key = k.to_string();
        let loc = locale_str.to_string();
        async move { i18n.translate(&key, &loc).await }
    };

    let template = FediverseTeamsForInvitesTemplate {
        page_title: t("fediverse.admin.teams_for_invites.title").await,
        csrf_token,
        app_name: base_context.app_name,
        app_version: base_context.app_version,
        app_domain: base_context.app_domain,
        app_description: base_context.app_description,
        analytics_opt_out: rustwarden_ui::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(", "),
        has_pending_password_change: false,
        nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
            .await,
        user_menu_profile: t("user_menu.profile").await,
        user_menu_logout: t("user_menu.logout").await,

        heading: t("fediverse.admin.teams_for_invites.heading").await,
        subtitle: t("fediverse.admin.teams_for_invites.subtitle").await,
        teams,
        has_teams,
        empty_title: t("fediverse.admin.teams_for_invites.empty_title").await,
        empty_body: t("fediverse.admin.teams_for_invites.empty_body").await,
        breadcrumb_home: t("breadcrumb.home").await,
        breadcrumb_admin: t("breadcrumb.admin").await,
        breadcrumb_federation: t("fediverse.admin.hub.title").await,
        breadcrumb_self: t("fediverse.admin.teams_for_invites.breadcrumb").await,
        tabs: admin_tabs(&i18n_service, locale_str).await,
        active_tab: "invites".to_string(),
    };

    Ok(template.into_response())
}

// ─── ANSSI FUI-TEST-1 — scaffold ────────────────────────────────────
//
// Initial coverage focused on the pure-Rust helpers exercised by the
// admin handlers (truncation, allow-listed config values). The full
// integration matrix (CSRF rejection × auth absence × happy path for
// the 8 POST handlers) is a follow-up that requires a
// `tower-sessions::MemoryStore` + `SeaORM MockDatabase` harness and
// is tracked in docs/ANSSI_FEDIVERSE_COMPLIANCE.md → FUI-TEST-1.
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn truncate_returns_original_when_shorter_than_max() {
        assert_eq!(truncate("hello", 10), "hello");
    }

    #[test]
    fn truncate_returns_original_when_equal_to_max() {
        assert_eq!(truncate("hello", 5), "hello");
    }

    #[test]
    fn truncate_appends_ellipsis_when_cut() {
        let out = truncate("hello world", 6);
        assert!(out.ends_with('…'), "expected ellipsis suffix, got {out:?}");
        assert_eq!(out.chars().count(), 6);
    }

    #[test]
    fn truncate_handles_multibyte_chars_without_panic() {
        // Each emoji is several bytes — `truncate` must count chars, not bytes.
        let out = truncate("🚀🚀🚀🚀🚀", 3);
        assert_eq!(out.chars().count(), 3);
        assert!(out.ends_with('…'));
    }

    #[test]
    fn trust_modes_contains_canonical_values() {
        assert!(TRUST_MODES.contains(&"allowlist"));
        assert!(TRUST_MODES.contains(&"open_with_blocklist"));
    }

    #[test]
    fn severities_covers_three_documented_levels() {
        assert!(SEVERITIES.contains(&"suspend"));
        assert!(SEVERITIES.contains(&"silence"));
        assert!(SEVERITIES.contains(&"reject_auth"));
        assert_eq!(SEVERITIES.len(), 3);
    }

    #[test]
    fn invite_ttl_default_is_seven_days() {
        assert_eq!(INVITE_TTL.as_secs(), 7 * 24 * 60 * 60);
    }
}