user_auth.rs 1046 lignes · 40552 octets
//! User-facing handlers for the Fediverse auth flow (RFC 0023, Slice B).
//!
//! Iter. 1 ships only the error landing page
//! (`/login/fediverse/error?reason=…`) — the four other endpoints
//! (login form, waiting page, SSE, consent) land in subsequent iters.
//!
//! All routes here are **public** (no `Claims` extension required) and
//! gated by `experimental-fediverse`. The mode-dégradé (Plan UI §3 P9)
//! returns `404` when `fediverse_enabled = false`.

use std::convert::Infallible;
use std::sync::Arc;
use std::time::Duration;

use askama::Template;
use axum::extract::{Extension, Form, Path, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Redirect};
use futures::stream::Stream;
use rustwarden_core::middleware::csrf;
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use tower_sessions::Session;
use uuid::Uuid;

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_fediverse::FediverseConfig;
use rustwarden_fediverse::models::fediverse_pending_invite;
use rustwarden_fediverse::models::remote_identity;
use rustwarden_fediverse::services::fediverse_auth_service::{
    ChallengeDecision, ChallengeStatus, FediverseAuthService, InitiateRequest,
    build_auth_decision_payload,
};
use rustwarden_fediverse::services::fediverse_invite_service::FediverseInviteService;
use rustwarden_fediverse::services::fediverse_outbound_service::{
    EnqueueRequest, FediverseOutboundService,
};
use rustwarden_fediverse::services::fediverse_trust_service::FediverseTrustService;

use rustwarden_fediverse::models::fediverse_auth_challenge;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};

/// Default challenge TTL for the AP-native auth flow (RFC 0006 §6).
/// Operator override via `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` is honoured
/// by the service layer separately; here we only provide a UI default
/// to hand into [`FediverseAuthService::initiate`].
const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60);

/// Whitelist of accepted `?reason=` values. Any other input falls back
/// to [`ErrorReason::Unknown`] which renders a generic message — keeps
/// the URL safe to share without leaking internal status names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorReason {
    /// `Accept{AuthRequest}` not received within the challenge TTL.
    Timeout,
    /// IdP explicitly rejected the auth request.
    Rejected,
    /// Domain not in the allowlist (or in the blocklist).
    Untrusted,
    /// HTTP signature on the IdP response did not verify.
    InvalidSignature,
    /// Anything else (renders the generic catch-all message).
    Unknown,
}

impl ErrorReason {
    /// Parse the `?reason=` query value into an enum. Trims, lowercases.
    pub fn parse(raw: &str) -> Self {
        match raw.trim().to_ascii_lowercase().as_str() {
            "timeout" => Self::Timeout,
            "rejected" => Self::Rejected,
            "untrusted" => Self::Untrusted,
            "invalid_signature" | "invalid_sig" => Self::InvalidSignature,
            _ => Self::Unknown,
        }
    }

    /// i18n key suffix for the heading + body copy.
    pub fn i18n_key(self) -> &'static str {
        match self {
            Self::Timeout => "timeout",
            Self::Rejected => "rejected",
            Self::Untrusted => "untrusted",
            Self::InvalidSignature => "invalid_signature",
            Self::Unknown => "unknown",
        }
    }

    /// Stable wire form (echoed back into the template for icon styling).
    pub fn as_str(self) -> &'static str {
        self.i18n_key()
    }
}

/// `?reason=` query parameter for `GET /login/fediverse/error`.
#[derive(Debug, Deserialize)]
pub struct ErrorQuery {
    /// One of `timeout`, `rejected`, `untrusted`, `invalid_signature`.
    /// Anything else renders the generic message.
    pub reason: Option<String>,
}

/// Askama template for `GET /login/fediverse/error`.
#[derive(Template)]
#[template(path = "error_unauthorized.html")]
#[allow(dead_code)]
pub struct FediverseAuthErrorTemplate {
    /// IETF locale tag (`fr`, `en`, …) for `<html lang>`.
    pub current_locale: String,
    /// Browser tab title.
    pub page_title: String,
    /// Meta-description copy.
    pub subtitle: String,
    /// Wire form of the error reason — used by the template to pick an
    /// icon (timeout / invalid_signature / generic alert).
    pub reason: String,
    /// i18n-translated H1.
    pub heading: String,
    /// i18n-translated body paragraph.
    pub body: String,
    /// Primary CTA label.
    pub back_to_login: String,
}

rustwarden_core::impl_template_response!(FediverseAuthErrorTemplate);

/// `GET /login/fediverse/error?reason=…` — landing page after a failed
/// auth attempt.
///
/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so
/// the page is invisible while the feature is off.
pub async fn error(
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    State(db): State<DatabaseConnection>,
    Query(query): Query<ErrorQuery>,
) -> 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 reason = ErrorReason::parse(query.reason.as_deref().unwrap_or(""));
    let locale_str = locale.as_str();
    let key = reason.i18n_key();

    let heading_key = format!("fediverse.auth.error.{key}.title");
    let body_key = format!("fediverse.auth.error.{key}.body");

    let template = FediverseAuthErrorTemplate {
        current_locale: locale_str.to_string(),
        page_title: i18n_service
            .translate("fediverse.auth.error.page_title", locale_str)
            .await,
        subtitle: i18n_service
            .translate("fediverse.auth.error.subtitle", locale_str)
            .await,
        reason: reason.as_str().to_string(),
        heading: i18n_service.translate(&heading_key, locale_str).await,
        body: i18n_service.translate(&body_key, locale_str).await,
        back_to_login: i18n_service
            .translate("fediverse.auth.error.back_to_login", locale_str)
            .await,
    };

    Ok(template.into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /login/fediverse — login form + initiate (RFC 0023 Slice B iter. 2)
// ─────────────────────────────────────────────────────────────────────

/// Form payload for `POST /login/fediverse`.
#[derive(Debug, Deserialize)]
pub struct LoginInitiateForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// `@user@domain` handle the user typed.
    pub handle: String,
}

/// Askama template for `GET /login/fediverse`.
#[derive(Template)]
#[template(path = "login_form.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseLoginFormTemplate {
    pub current_locale: String,
    pub page_title: String,
    pub subtitle: String,
    pub csrf_token: String,
    pub heading: String,
    pub field_label: String,
    pub field_placeholder: String,
    pub field_help: String,
    pub submit_button: String,
    pub back_to_login: String,
}

rustwarden_core::impl_template_response!(FediverseLoginFormTemplate);

/// `GET /login/fediverse` — render the AP-native login form.
///
/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`.
pub async fn login_form(
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    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 csrf_token = csrf::get_or_create_csrf_token(&session).await?;

    let template = FediverseLoginFormTemplate {
        current_locale: locale_str.to_string(),
        page_title: i18n_service
            .translate("fediverse.auth.login.page_title", locale_str)
            .await,
        subtitle: i18n_service
            .translate("fediverse.auth.login.subtitle", locale_str)
            .await,
        csrf_token,
        heading: i18n_service
            .translate("fediverse.auth.login.heading", locale_str)
            .await,
        field_label: i18n_service
            .translate("fediverse.auth.login.field_label", locale_str)
            .await,
        field_placeholder: i18n_service
            .translate("fediverse.auth.login.field_placeholder", locale_str)
            .await,
        field_help: i18n_service
            .translate("fediverse.auth.login.field_help", locale_str)
            .await,
        submit_button: i18n_service
            .translate("fediverse.auth.login.submit", locale_str)
            .await,
        back_to_login: i18n_service
            .translate("fediverse.auth.login.back_to_login", locale_str)
            .await,
    };

    Ok(template.into_response())
}

/// Extract the `domain` part from a `@user@domain` (or `user@domain`)
/// handle. Returns `None` on malformed input. Lowercased.
pub fn extract_handle_domain(handle: &str) -> Option<String> {
    let trimmed = handle.trim().trim_start_matches('@');
    let (_, domain) = trimmed.split_once('@')?;
    let domain = domain.trim();
    if domain.is_empty() {
        return None;
    }
    Some(domain.to_ascii_lowercase())
}

/// `POST /login/fediverse` — start an AP-native login.
///
/// Pipeline:
/// 1. Validate CSRF.
/// 2. Reject if `fediverse_enabled = false` (404).
/// 3. Parse handle ⇒ domain ; reject malformed via `error?reason=unknown`.
/// 4. Check `FediverseTrustService::is_domain_allowed(domain)` ;
///    redirect to `error?reason=untrusted` if denied.
/// 5. Call `FediverseAuthService::initiate` ; on `Ok` redirect to
///    `/login/fediverse/waiting/{id}` (page delivered iter. 3).
/// 6. On any other error, redirect to `error?reason=unknown`.
pub async fn login_initiate(
    State(db): State<DatabaseConnection>,
    session: Session,
    Form(form): Form<LoginInitiateForm>,
) -> 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 Some(domain) = extract_handle_domain(&form.handle) else {
        return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response());
    };

    if !trust.is_domain_allowed(&domain).await? {
        return Ok(Redirect::to("/login/fediverse/error?reason=untrusted").into_response());
    }

    let req = InitiateRequest {
        handle: form.handle.trim().to_string(),
        redirect_url: None,
    };
    match FediverseAuthService::initiate(&db, req, CHALLENGE_TTL).await {
        Ok(challenge) => {
            Ok(Redirect::to(&format!("/login/fediverse/waiting/{}", challenge.id)).into_response())
        }
        Err(AppError::Validation(_)) => {
            Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response())
        }
        Err(err) => {
            tracing::error!("FediverseAuthService::initiate failed: {err}");
            Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response())
        }
    }
}

// ─────────────────────────────────────────────────────────────────────
// /login/fediverse/waiting/{id} — page d'attente + SSE (iter. 3)
// ─────────────────────────────────────────────────────────────────────

/// Askama template for `GET /login/fediverse/waiting/{challenge_id}`.
#[derive(Template)]
#[template(path = "login_waiting.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseLoginWaitingTemplate {
    pub current_locale: String,
    pub page_title: String,
    pub subtitle: String,
    pub heading: String,
    pub explainer: String,
    pub cancel_button: String,
    /// Challenge UUID — embedded in the JS so the EventSource opens the
    /// correct SSE stream URL.
    pub challenge_id: String,
}

rustwarden_core::impl_template_response!(FediverseLoginWaitingTemplate);

/// `GET /login/fediverse/waiting/{challenge_id}` — render the waiting
/// page that opens an `EventSource` against the SSE endpoint below.
pub async fn waiting(
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    State(db): State<DatabaseConnection>,
    Path(challenge_id): Path<Uuid>,
) -> 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 the challenge exists; otherwise show the error page rather
    // than a stale spinner.
    if FediverseAuthService::status(&db, challenge_id)
        .await
        .is_err()
    {
        return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response());
    }

    let locale_str = locale.as_str();
    let template = FediverseLoginWaitingTemplate {
        current_locale: locale_str.to_string(),
        page_title: i18n_service
            .translate("fediverse.auth.waiting.page_title", locale_str)
            .await,
        subtitle: i18n_service
            .translate("fediverse.auth.waiting.subtitle", locale_str)
            .await,
        heading: i18n_service
            .translate("fediverse.auth.waiting.heading", locale_str)
            .await,
        explainer: i18n_service
            .translate("fediverse.auth.waiting.explainer", locale_str)
            .await,
        cancel_button: i18n_service
            .translate("fediverse.auth.waiting.cancel", locale_str)
            .await,
        challenge_id: challenge_id.to_string(),
    };

    Ok(template.into_response())
}

/// Polling cadence between two DB reads in [`sse_status`]. Two seconds
/// is the master plan §10 default — small enough that the user
/// perceives "live", large enough to keep the DB load negligible.
const SSE_POLL_INTERVAL: Duration = Duration::from_secs(2);

/// Maximum number of polls before the SSE stream gives up and closes.
/// `5 min / 2 s = 150` matches the challenge TTL exactly so a never-
/// answered challenge does not keep the connection alive forever.
const SSE_MAX_POLLS: u32 = 150;

/// `GET /login/fediverse/sse/{challenge_id}` — SSE polling endpoint.
///
/// Emits one `event: status` per poll with payload
/// `{"state":"pending|approved|rejected|expired|consumed"}`. Stops after
/// the first terminal status or `SSE_MAX_POLLS` iterations, whichever
/// comes first. The endpoint is **public** — the challenge UUID is
/// itself the unguessable bearer (256 bits of entropy, RFC 0006).
pub async fn sse_status(
    State(db): State<DatabaseConnection>,
    Path(challenge_id): Path<Uuid>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let stream = async_status_stream(db, challenge_id);
    Sse::new(stream).keep_alive(KeepAlive::default())
}

fn async_status_stream(
    db: DatabaseConnection,
    challenge_id: Uuid,
) -> impl Stream<Item = Result<Event, Infallible>> {
    use futures::stream;

    stream::unfold(
        StreamState {
            db,
            challenge_id,
            polls: 0,
            done: false,
        },
        |mut s| async move {
            if s.done || s.polls >= SSE_MAX_POLLS {
                return None;
            }
            if s.polls > 0 {
                tokio::time::sleep(SSE_POLL_INTERVAL).await;
            }
            s.polls += 1;

            let state_str = match FediverseAuthService::status(&s.db, s.challenge_id).await {
                Ok(model) => ChallengeStatus::parse(&model.status).as_str(),
                Err(_) => "expired",
            };
            let event = Event::default()
                .event("status")
                .data(format!(r#"{{"state":"{state_str}"}}"#));

            // Mark stream as terminal when status is non-pending so the
            // next iteration short-circuits.
            if state_str != "pending" {
                s.done = true;
            }
            Some((Ok::<_, Infallible>(event), s))
        },
    )
}

struct StreamState {
    db: DatabaseConnection,
    challenge_id: Uuid,
    polls: u32,
    done: bool,
}

// ─────────────────────────────────────────────────────────────────────
// /fediverse/consent/{id} — IdP-side consent screen (iter. 4)
// ─────────────────────────────────────────────────────────────────────

/// Form payload for `POST /fediverse/consent/{id}`. The hidden field
/// `decision` is `"approve"` or `"reject"` ; anything else is treated
/// as a rejection (defensive default).
#[derive(Debug, Deserialize)]
pub struct ConsentForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// `"approve"` or `"reject"`.
    pub decision: String,
}

/// Askama template for `GET /fediverse/consent/{id}`.
#[derive(Template)]
#[template(path = "consent_screen.html")]
#[allow(dead_code, missing_docs)]
pub struct FediverseConsentTemplate {
    pub current_locale: String,
    pub page_title: String,
    pub csrf_token: String,
    pub heading: String,
    pub explainer: String,
    /// Domain of the requesting instance (`b.tld`).
    pub requester_domain: String,
    /// Handle the request claims to authenticate (`@alice@a.tld`).
    pub handle: String,
    pub field_label_requester: String,
    pub field_label_handle: String,
    pub approve_button: String,
    pub reject_button: String,
    /// `true` once the challenge has reached a terminal state — flips
    /// the action area to a status banner instead of the form.
    pub already_decided: bool,
    pub already_decided_text: String,
    /// Challenge UUID — embedded in the form action URL.
    pub challenge_id: String,
}

rustwarden_core::impl_template_response!(FediverseConsentTemplate);

/// `GET /fediverse/consent/{challenge_id}` — render the consent screen
/// for a logged-in local user.
///
/// The current implementation **renders** the screen but only fully
/// supports the *Reject* path. *Approve* requires the inbound auth
/// challenge to carry a `requested_remote_id`, which the inbox handler
/// will set in a follow-up to RFC 0006 (the existing inbox `auth_inbox`
/// in `rustwarden_fediverse::auth_routes` documents this TODO at the
/// service-layer level). When `requested_remote_id` is absent the POST
/// handler refuses Approve with a clear i18n error.
pub async fn consent_form(
    Extension(_claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Path(challenge_id): Path<Uuid>,
) -> 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 model = match FediverseAuthService::status(&db, challenge_id).await {
        Ok(m) => m,
        Err(_) => return Ok(axum::http::StatusCode::NOT_FOUND.into_response()),
    };

    let already_decided = ChallengeStatus::parse(&model.status) != ChallengeStatus::Pending;

    let locale_str = locale.as_str();
    let csrf_token = csrf::get_or_create_csrf_token(&session).await?;

    let already_decided_text = if already_decided {
        i18n_service
            .translate("fediverse.auth.consent.already_decided", locale_str)
            .await
    } else {
        String::new()
    };

    let template = FediverseConsentTemplate {
        current_locale: locale_str.to_string(),
        page_title: i18n_service
            .translate("fediverse.auth.consent.page_title", locale_str)
            .await,
        csrf_token,
        heading: i18n_service
            .translate("fediverse.auth.consent.heading", locale_str)
            .await,
        explainer: i18n_service
            .translate("fediverse.auth.consent.explainer", locale_str)
            .await,
        requester_domain: model.domain,
        handle: model.handle,
        field_label_requester: i18n_service
            .translate("fediverse.auth.consent.label_requester", locale_str)
            .await,
        field_label_handle: i18n_service
            .translate("fediverse.auth.consent.label_handle", locale_str)
            .await,
        approve_button: i18n_service
            .translate("fediverse.auth.consent.approve", locale_str)
            .await,
        reject_button: i18n_service
            .translate("fediverse.auth.consent.reject", locale_str)
            .await,
        already_decided,
        already_decided_text,
        challenge_id: challenge_id.to_string(),
    };

    Ok(template.into_response())
}

/// `POST /fediverse/consent/{challenge_id}` — record the user's
/// decision and redirect to `/home`.
///
/// *Reject* always succeeds. *Approve* requires the challenge row to
/// have a `requested_remote_id` (set by the inbox handler when the
/// `AuthRequest` arrives). Without it we surface a Validation error
/// rather than mint an unbound approval, which would let an attacker
/// inject any `remote_identity_id` from the form. The follow-up RFC
/// 0006 Phase 2 will close the binding.
pub async fn consent_submit(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    Path(challenge_id): Path<Uuid>,
    Form(form): Form<ConsentForm>,
) -> 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());
    }

    // Pre-fetch challenge: we need `nonce` + `requested_remote_id` both
    // to feed `record_decision` and to enqueue the outbound back-channel
    // (RFC 0025). Doing this once avoids a second roundtrip on approve.
    let challenge = FediverseAuthService::status(&db, challenge_id).await?;
    let remote_id = challenge.requested_remote_id.ok_or_else(|| {
        AppError::Validation(
            "auth challenge has no remote_identity_id binding — inbox \
             enrichment (RFC 0006 Phase 2) not yet wired"
                .to_string(),
        )
    })?;

    let approve_flag = form.decision.as_str() == "approve";
    let decision = if approve_flag {
        ChallengeDecision::Approve {
            remote_identity_id: remote_id,
        }
    } else {
        ChallengeDecision::Reject
    };

    FediverseAuthService::record_decision(&db, challenge_id, decision).await?;

    // RFC 0025 — push the decision to B's inbox as Accept(AuthRequest)
    // / Reject(AuthRequest). Failure to enqueue must not roll back the
    // local decision (the row already moved past Pending), but we
    // surface it so the operator sees the dead-letter / retry need.
    if let Err(e) = enqueue_auth_decision(&db, &claims, &challenge, approve_flag).await {
        tracing::error!(
            challenge_id = %challenge_id,
            decision = if approve_flag { "approve" } else { "reject" },
            error = %e,
            "consent_submit: failed to enqueue back-channel activity",
        );
    }

    Ok(Redirect::to("/home").into_response())
}

/// Build and enqueue the `Accept(AuthRequest)` or `Reject(AuthRequest)`
/// activity (RFC 0025) addressed to the requester's inbox.
///
/// Resolves the requester via `challenge.requested_remote_id`. The
/// approver actor URL is derived from the IdP's `FediverseConfig` base
/// + the approver's username (in `Claims`).
async fn enqueue_auth_decision(
    db: &DatabaseConnection,
    approver: &Claims,
    challenge: &fediverse_auth_challenge::Model,
    approve: bool,
) -> Result<(), AppError> {
    let remote_id = challenge.requested_remote_id.ok_or_else(|| {
        AppError::Internal("challenge approved without requested_remote_id".into())
    })?;

    let requester = remote_identity::Entity::find_by_id(remote_id)
        .one(db)
        .await?
        .ok_or_else(|| {
            AppError::Internal(format!(
                "remote_identity {remote_id} disappeared between checks"
            ))
        })?;

    let config = FediverseConfig::from_env()
        .map_err(|e| AppError::Config(format!("FediverseConfig::from_env: {e}")))?;
    let approver_actor_url = format!("{}users/{}", config.base_url(), approver.username);

    let payload = build_auth_decision_payload(
        config.base_url(),
        &approver_actor_url,
        &requester.inbox_url,
        &challenge.nonce,
        approve,
    );
    let activity_id = payload["id"].as_str().unwrap_or("").to_string();

    FediverseOutboundService::enqueue(
        db,
        EnqueueRequest {
            activity_id,
            payload_json: serde_json::to_string(&payload)
                .map_err(|e| AppError::Internal(format!("payload serialize: {e}")))?,
            target_inbox: requester.inbox_url.clone(),
            target_domain: requester.domain.clone(),
            actor_user_id: Some(approver.sub),
        },
    )
    .await?;
    Ok(())
}

// ─────────────────────────────────────────────────────────────────────
// /profile/connected_apps (RFC 0024 Slice C iter. 5)
// ─────────────────────────────────────────────────────────────────────

/// One row rendered into the connected-apps table — a remote instance
/// where the current user has an active federated session (= a
/// challenge in `approved` or `consumed` status).
#[derive(Debug, Clone)]
pub struct ConnectedAppRowCtx {
    /// Domain of the requesting instance (`b.tld`).
    pub domain: String,
    /// Number of distinct sessions / approvals for that domain.
    pub session_count: u64,
    /// Most recent `created_at` for this domain, formatted
    /// `YYYY-MM-DD HH:MM`.
    pub last_seen: String,
}

/// Askama template for `GET /profile/connected_apps`.
#[derive(Template)]
#[template(path = "connected_apps.html")]
#[allow(dead_code, missing_docs)]
pub struct ConnectedAppsTemplate {
    pub current_locale: String,
    pub page_title: String,
    pub heading: String,
    pub subtitle: String,
    pub rows: Vec<ConnectedAppRowCtx>,
    pub has_rows: bool,
    pub table_domain: String,
    pub table_count: String,
    pub table_last_seen: String,
    pub empty_title: String,
    pub empty_body: String,
    pub revoke_note: String,
    pub back_to_profile: String,
}

rustwarden_core::impl_template_response!(ConnectedAppsTemplate);

/// `GET /profile/connected_apps` — section profil affichant la liste
/// des instances fédérées où l'utilisateur a actuellement une session.
///
/// Mode dégradé silencieux (Plan UI §3 P9) : `404` quand
/// `fediverse_enabled = false`. Lecture seule en iter. 5 ; le bouton
/// « Révoquer » est différé à RFC 0006 Phase 2 (qui ajoutera la méthode
/// `FediverseAuthService::revoke_remote_session(challenge_id)` capable
/// de blacklister le JWT remote correspondant).
pub async fn connected_apps(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    State(db): State<DatabaseConnection>,
) -> 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());
    }

    // Build the local handle of the current user.
    let domain_self = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "localhost".to_string());
    let local_handle = format!("@{}@{}", claims.username, domain_self);

    // Pull all `approved` / `consumed` challenges for that handle.
    let challenges = fediverse_auth_challenge::Entity::find()
        .filter(fediverse_auth_challenge::Column::Handle.eq(local_handle))
        .filter(fediverse_auth_challenge::Column::Status.is_in(["approved", "consumed"]))
        .order_by_desc(fediverse_auth_challenge::Column::CreatedAt)
        .all(&db)
        .await?;

    // Group by domain: count + most recent created_at.
    use std::collections::BTreeMap;
    let mut grouped: BTreeMap<String, (u64, chrono::DateTime<chrono::Utc>)> = BTreeMap::new();
    for c in challenges {
        let entry = grouped.entry(c.domain.clone()).or_insert((0, c.created_at));
        entry.0 += 1;
        if c.created_at > entry.1 {
            entry.1 = c.created_at;
        }
    }
    let mut rows: Vec<ConnectedAppRowCtx> = grouped
        .into_iter()
        .map(|(domain, (count, last))| ConnectedAppRowCtx {
            domain,
            session_count: count,
            last_seen: last.format("%Y-%m-%d %H:%M").to_string(),
        })
        .collect();
    // Most recently active first.
    rows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
    let has_rows = !rows.is_empty();

    let locale_str = locale.as_str();
    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 = ConnectedAppsTemplate {
        current_locale: locale_str.to_string(),
        page_title: t("fediverse_connected_apps.page_title").await,
        heading: t("fediverse_connected_apps.heading").await,
        subtitle: t("fediverse_connected_apps.subtitle").await,
        rows,
        has_rows,
        table_domain: t("fediverse_connected_apps.table_domain").await,
        table_count: t("fediverse_connected_apps.table_count").await,
        table_last_seen: t("fediverse_connected_apps.table_last_seen").await,
        empty_title: t("fediverse_connected_apps.empty_title").await,
        empty_body: t("fediverse_connected_apps.empty_body").await,
        revoke_note: t("fediverse_connected_apps.revoke_note").await,
        back_to_profile: t("fediverse_connected_apps.back_to_profile").await,
    };

    Ok(template.into_response())
}

// ─────────────────────────────────────────────────────────────────────
// /fediverse/invite/{id} (RFC 0024 Slice C iter. 6)
// ─────────────────────────────────────────────────────────────────────

/// Form payload for `POST /fediverse/invite/{id}`.
#[derive(Debug, Deserialize)]
pub struct InviteLandingForm {
    /// CSRF token from the hidden form field.
    pub csrf_token: String,
    /// `"accept"` or `"reject"`.
    pub decision: String,
}

/// Askama template for `GET /fediverse/invite/{id}`.
#[derive(Template)]
#[template(path = "invite_landing.html")]
#[allow(dead_code, missing_docs)]
pub struct InviteLandingTemplate {
    pub current_locale: String,
    pub page_title: String,
    pub csrf_token: String,
    pub heading: String,
    pub explainer: String,
    pub team_label: String,
    pub team_name: String,
    pub role_label: String,
    pub role_value: String,
    pub status_label: String,
    pub status_value: String,
    pub accept_button: String,
    pub reject_button: String,
    /// `true` when the invite is in a terminal state (Accepted / Rejected
    /// / Revoked / Expired) — flips the UI to a status banner.
    pub already_decided: bool,
    pub already_decided_text: String,
    /// Challenge UUID for form action.
    pub invite_id: String,
    /// `true` when the current user can accept (= remote identity bound).
    pub can_accept: bool,
    pub cannot_accept_note: String,
}

rustwarden_core::impl_template_response!(InviteLandingTemplate);

/// `GET /fediverse/invite/{invite_id}` — landing page for a federated
/// team invitation.
///
/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` or
/// the invite does not exist. The current user (`Claims`) must be a
/// **Remote** identity to be able to accept — local users only see
/// the page in read-only mode (the form refuses to call `accept`
/// because `record_decision` requires `remote_identity_id`).
pub async fn invite_landing_form(
    Extension(claims): Extension<Claims>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    Extension(locale): Extension<Locale>,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(invite_id): axum::extract::Path<Uuid>,
) -> 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 Some(invite) = fediverse_pending_invite::Entity::find_by_id(invite_id)
        .one(&db)
        .await?
    else {
        return Ok((axum::http::StatusCode::NOT_FOUND, "Invite not found").into_response());
    };

    // Load team name for display.
    let team_name = rustwarden_core::models::team::Entity::find_by_id(invite.team_id)
        .one(&db)
        .await?
        .map(|t| t.name)
        .unwrap_or_else(|| invite.team_id.to_string());

    // Status terminal?
    use rustwarden_fediverse::services::fediverse_invite_service::InviteStatus;
    let status_parsed = InviteStatus::parse(&invite.status);
    let already_decided = status_parsed != InviteStatus::Pending;

    // Can accept = current user has a remote_identity_id (RFC 0007).
    let can_accept = claims.is_remote() && claims.remote_identity_id().is_some();

    let locale_str = locale.as_str();
    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 template = InviteLandingTemplate {
        current_locale: locale_str.to_string(),
        page_title: t("fediverse_invite_landing.page_title").await,
        csrf_token,
        heading: t("fediverse_invite_landing.heading").await,
        explainer: t("fediverse_invite_landing.explainer").await,
        team_label: t("fediverse_invite_landing.team_label").await,
        team_name,
        role_label: t("fediverse_invite_landing.role_label").await,
        role_value: invite.role.clone(),
        status_label: t("fediverse_invite_landing.status_label").await,
        status_value: invite.status.clone(),
        accept_button: t("fediverse_invite_landing.accept_button").await,
        reject_button: t("fediverse_invite_landing.reject_button").await,
        already_decided,
        already_decided_text: if already_decided {
            t("fediverse_invite_landing.already_decided").await
        } else {
            String::new()
        },
        invite_id: invite_id.to_string(),
        can_accept,
        cannot_accept_note: if can_accept {
            String::new()
        } else {
            t("fediverse_invite_landing.cannot_accept_note").await
        },
    };

    Ok(template.into_response())
}

/// `POST /fediverse/invite/{invite_id}` — record the invitee's decision.
///
/// *Reject* always succeeds. *Accept* requires the current user to be a
/// **Remote** identity carrying a `remote_identity_id` claim — the
/// service inserts the `team_members` row with that FK.
pub async fn invite_landing_submit(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    session: Session,
    axum::extract::Path(invite_id): axum::extract::Path<Uuid>,
    Form(form): Form<InviteLandingForm>,
) -> 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());
    }

    match form.decision.as_str() {
        "accept" => {
            let remote_id = claims.remote_identity_id().ok_or_else(|| {
                AppError::Validation(
                    "Only remote identities can accept federated invites; \
                     local users must reject or ignore."
                        .into(),
                )
            })?;
            FediverseInviteService::accept(&db, invite_id, remote_id).await?;
        }
        // Anything other than "accept" is a reject (defensive default).
        _ => {
            FediverseInviteService::reject(&db, invite_id).await?;
        }
    }

    Ok(Redirect::to("/home").into_response())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn error_reason_parses_known_values() {
        assert_eq!(ErrorReason::parse("timeout"), ErrorReason::Timeout);
        assert_eq!(ErrorReason::parse("rejected"), ErrorReason::Rejected);
        assert_eq!(ErrorReason::parse("untrusted"), ErrorReason::Untrusted);
        assert_eq!(
            ErrorReason::parse("invalid_signature"),
            ErrorReason::InvalidSignature
        );
        assert_eq!(
            ErrorReason::parse("invalid_sig"),
            ErrorReason::InvalidSignature
        );
    }

    #[test]
    fn error_reason_falls_back_to_unknown() {
        assert_eq!(ErrorReason::parse(""), ErrorReason::Unknown);
        assert_eq!(ErrorReason::parse("garbage"), ErrorReason::Unknown);
        assert_eq!(ErrorReason::parse("500"), ErrorReason::Unknown);
    }

    #[test]
    fn error_reason_trims_and_lowercases() {
        assert_eq!(ErrorReason::parse("  TIMEOUT  "), ErrorReason::Timeout);
        assert_eq!(
            ErrorReason::parse("Invalid_Signature"),
            ErrorReason::InvalidSignature
        );
    }

    #[test]
    fn extract_handle_domain_accepts_canonical() {
        assert_eq!(
            extract_handle_domain("@alice@example.org"),
            Some("example.org".to_string())
        );
        assert_eq!(
            extract_handle_domain("alice@example.org"),
            Some("example.org".to_string())
        );
        assert_eq!(
            extract_handle_domain("  @bob@MASTODON.SOCIAL  "),
            Some("mastodon.social".to_string())
        );
    }

    #[test]
    fn extract_handle_domain_rejects_malformed() {
        assert_eq!(extract_handle_domain(""), None);
        assert_eq!(extract_handle_domain("alice"), None);
        assert_eq!(extract_handle_domain("alice@"), None);
        assert_eq!(extract_handle_domain("@@example.org"), None);
    }

    #[test]
    fn error_reason_round_trips_through_str() {
        for r in [
            ErrorReason::Timeout,
            ErrorReason::Rejected,
            ErrorReason::Untrusted,
            ErrorReason::InvalidSignature,
            ErrorReason::Unknown,
        ] {
            assert_eq!(ErrorReason::parse(r.as_str()), r);
        }
    }
}