login_handler.rs 745 lignes · 24840 octets
/// Login Page Handler
/// Feature 004: UI Post-Auth
///
/// Handles the login page display (GET /login).
use askama::Template;
use axum::{
    extract::{Extension, State},
    http::HeaderMap,
    response::IntoResponse,
};
use axum_extra::extract::CookieJar;
use sea_orm::DatabaseConnection;
use std::sync::Arc;

use rustwarden_core::i18n::service::I18nService;
use rustwarden_core::templates::LayoutContext;

/// Login page template
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
    app_name: String,
    app_version: String,
    current_locale: String,
    supported_languages: Vec<(String, String, bool)>,
    page_title: String,
    page_description: String,
    subtitle: String,
    tab_login: String,
    tab_register: String,
    login_title: String,
    register_title: String,
    username_label: String,
    password_label: String,
    email_label: String,
    username_placeholder: String,
    password_placeholder: String,
    username_placeholder_register: String,
    username_pattern_register: String,
    password_placeholder_register: String,
    email_placeholder: String,
    submit_button: String,
    register_submit: String,
    remember_me: String,
    forgot_password: String,
    email_not_verified: String,
    resend_verification: String,
    already_have_account: String,
    sign_in_link: String,
    footer_text: String,
    success_message: String,
    error_message: String,
    email_unverified_message: String,
    resend_button: String,
    resend_success: String,
    resend_error: String,
    resend_prompt: String,
    register_success: String,
    register_error: String,
    pw_min_length: usize,
}

/// Resend verification template
#[derive(Template)]
#[template(path = "resend_verification.html")]
struct ResendVerificationTemplate {
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    page_title: String,
    #[allow(dead_code)] // utilise par base.html (template parent)
    csrf_token: String,
    title: String,
    subtitle: String,
    email_label: String,
    email_placeholder: String,
    submit: String,
    back_to_login: String,
    error_default: String,
    success_default: String,
    error_missing_email: String,
    error_cannot_send: String,
}

/// Reset password page template
#[derive(Template)]
#[template(path = "reset_password.html")]
struct ResetPasswordTemplate {
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    page_title: String,
    #[allow(dead_code)] // utilise par base.html (template parent)
    csrf_token: String,
    reset_password_title: String,
    subtitle: String,
    new_password_label: String,
    new_password_placeholder: String,
    confirm_password_label: String,
    confirm_password_placeholder: String,
    password_requirements: String,
    submit_button: String,
    back_to_login: String,
    success_message: String,
    error_message: String,
    missing_token_url: String,
    missing_token: String,
    missing_new_password: String,
    passwords_not_match: String,
    reset_success_message: String,
    error_link_invalid: String,
    error_generic: String,
}

rustwarden_core::impl_template_response!(LoginTemplate);
rustwarden_core::impl_template_response!(ResendVerificationTemplate);
rustwarden_core::impl_template_response!(ForgotPasswordTemplate);
rustwarden_core::impl_template_response!(ResetPasswordTemplate);

/// Handler for GET /login - Login page (PUBLIC)
pub async fn login_page(
    headers: HeaderMap,
    State(db): State<DatabaseConnection>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
    tracing::debug!("Rendering login page");

    let jar = CookieJar::from_headers(&headers);
    let current_locale = jar
        .get("locale")
        .and_then(|c| {
            let value = c.value();
            if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
                Some(value.to_string())
            } else {
                None
            }
        })
        .unwrap_or_else(|| "fr".to_string());

    let supported_languages: Vec<(String, String, bool)> = vec![
        (
            "fr".to_string(),
            "Français".to_string(),
            current_locale == "fr",
        ),
        (
            "en".to_string(),
            "English".to_string(),
            current_locale == "en",
        ),
        (
            "de".to_string(),
            "Deutsch".to_string(),
            current_locale == "de",
        ),
        (
            "es".to_string(),
            "Español".to_string(),
            current_locale == "es",
        ),
        (
            "it".to_string(),
            "Italiano".to_string(),
            current_locale == "it",
        ),
        (
            "pt".to_string(),
            "Português".to_string(),
            current_locale == "pt",
        ),
    ];

    let locale_str = &current_locale;
    let page_title = i18n_service.translate("login.page_title", locale_str).await;
    let page_description = i18n_service
        .translate("login.page_description", locale_str)
        .await;
    let subtitle = i18n_service.translate("login.subtitle", locale_str).await;
    let tab_login = i18n_service.translate("login.tab_login", locale_str).await;
    let tab_register = i18n_service
        .translate("login.tab_register", locale_str)
        .await;
    let login_title = i18n_service.translate("login.title", locale_str).await;
    let register_title = i18n_service
        .translate("login.register_title", locale_str)
        .await;
    let username_label = i18n_service.translate("login.username", locale_str).await;
    let password_label = i18n_service.translate("login.password", locale_str).await;
    let email_label = i18n_service
        .translate("login.email_label", locale_str)
        .await;
    let username_placeholder = i18n_service
        .translate("login.username_placeholder", locale_str)
        .await;
    let password_placeholder = i18n_service
        .translate("login.password_placeholder", locale_str)
        .await;
    let username_placeholder_register = i18n_service
        .translate("login.username_placeholder_register", locale_str)
        .await;
    let username_pattern_register = i18n_service
        .translate("login.username_pattern_register", locale_str)
        .await;
    let password_placeholder_register = i18n_service
        .translate("login.password_placeholder_register", locale_str)
        .await;
    let email_placeholder = i18n_service
        .translate("login.email_placeholder", locale_str)
        .await;
    let submit_button = i18n_service.translate("login.submit", locale_str).await;
    let register_submit = i18n_service
        .translate("login.register_submit", locale_str)
        .await;
    let remember_me = i18n_service
        .translate("login.remember_me", locale_str)
        .await;
    let forgot_password = i18n_service
        .translate("login.forgot_password", locale_str)
        .await;
    let email_not_verified = i18n_service
        .translate("login.email_not_verified", locale_str)
        .await;
    let resend_verification = i18n_service
        .translate("login.resend_verification", locale_str)
        .await;
    let already_have_account = i18n_service
        .translate("login.already_have_account", locale_str)
        .await;
    let sign_in_link = i18n_service
        .translate("login.sign_in_link", locale_str)
        .await;
    let footer_text = i18n_service
        .translate("login.footer_text", locale_str)
        .await;
    let success_message = i18n_service
        .translate("login.success_message", locale_str)
        .await;
    let error_message = i18n_service
        .translate("login.error_message", locale_str)
        .await;
    let email_unverified_message = i18n_service
        .translate("login.email_unverified_message", locale_str)
        .await;
    let resend_button = i18n_service
        .translate("login.resend_button", locale_str)
        .await;
    let resend_success = i18n_service
        .translate("login.resend_success", locale_str)
        .await;
    let resend_error = i18n_service
        .translate("login.resend_error", locale_str)
        .await;
    let resend_prompt = i18n_service
        .translate("login.resend_prompt", locale_str)
        .await;
    let register_success = i18n_service
        .translate("login.register_success", locale_str)
        .await;
    let register_error = i18n_service
        .translate("login.register_error", locale_str)
        .await;

    let layout = LayoutContext::new("", "");

    let template = LoginTemplate {
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        current_locale,
        supported_languages,
        page_title,
        page_description,
        subtitle,
        tab_login,
        tab_register,
        login_title,
        register_title,
        username_label,
        password_label,
        email_label,
        username_placeholder,
        password_placeholder,
        username_placeholder_register,
        username_pattern_register,
        password_placeholder_register,
        email_placeholder,
        submit_button,
        register_submit,
        remember_me,
        forgot_password,
        email_not_verified,
        resend_verification,
        already_have_account,
        sign_in_link,
        footer_text,
        success_message,
        error_message,
        email_unverified_message,
        resend_button,
        resend_success,
        resend_error,
        resend_prompt,
        register_success,
        register_error,
        pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(
            &db,
        )
        .await
        .min_length,
    };

    Ok(template)
}

/// Page asking for an email so the API can send a password-reset link.
#[derive(Template)]
#[template(path = "forgot_password.html")]
struct ForgotPasswordTemplate {
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    page_title: String,
    #[allow(dead_code)] // used by the parent base.html
    csrf_token: String,
    title: String,
    subtitle: String,
    email_label: String,
    email_placeholder: String,
    submit: String,
    back_to_login: String,
    error_default: String,
    success_default: String,
    error_missing_email: String,
    error_cannot_send: String,
}

/// Handler for GET /forgot-password (PUBLIC).
///
/// The form posts to `POST /api/v1/auth/forgot-password`, which always
/// answers 200 so the page does not reveal whether the address exists.
pub async fn forgot_password_page(
    headers: HeaderMap,
    State(db): State<DatabaseConnection>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
    let jar = CookieJar::from_headers(&headers);
    let current_locale = jar
        .get("locale")
        .and_then(|c| {
            let value = c.value();
            if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
                Some(value.to_string())
            } else {
                None
            }
        })
        .unwrap_or_else(|| "fr".to_string());
    let locale_str = current_locale.as_str();

    let layout = LayoutContext::new("", "");
    let app_domain =
        rustwarden_core::services::app_settings_service::AppSettingsService::get_setting(
            &db,
            "app_domain",
        )
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "localhost".to_string());
    let app_description =
        rustwarden_core::services::app_settings_service::AppSettingsService::get_setting(
            &db,
            "app_description",
        )
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "User accounts and authentication".to_string());

    Ok(ForgotPasswordTemplate {
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale_str,
        )
        .await,
        page_title: i18n_service
            .translate("forgot_password.page_title", locale_str)
            .await,
        csrf_token: String::new(),
        title: i18n_service
            .translate("forgot_password.title", locale_str)
            .await,
        subtitle: i18n_service
            .translate("forgot_password.subtitle", locale_str)
            .await,
        email_label: i18n_service
            .translate("forgot_password.email_label", locale_str)
            .await,
        email_placeholder: i18n_service
            .translate("forgot_password.email_placeholder", locale_str)
            .await,
        submit: i18n_service
            .translate("forgot_password.submit", locale_str)
            .await,
        back_to_login: i18n_service
            .translate("forgot_password.back_to_login", locale_str)
            .await,
        error_default: i18n_service
            .translate("forgot_password.error_default", locale_str)
            .await,
        success_default: i18n_service
            .translate("forgot_password.success_default", locale_str)
            .await,
        error_missing_email: i18n_service
            .translate("forgot_password.error_missing_email", locale_str)
            .await,
        error_cannot_send: i18n_service
            .translate("forgot_password.error_cannot_send", locale_str)
            .await,
    })
}

/// Handler for GET /resend-verification - Resend email verification page (PUBLIC)
pub async fn resend_verification_page(
    headers: HeaderMap,
    State(db): State<DatabaseConnection>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
    tracing::debug!("Rendering resend verification page");

    let jar = CookieJar::from_headers(&headers);
    let current_locale = jar
        .get("locale")
        .and_then(|c| {
            let value = c.value();
            if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
                Some(value.to_string())
            } else {
                None
            }
        })
        .unwrap_or_else(|| "fr".to_string());

    let locale_str = &current_locale;
    let page_title = i18n_service
        .translate("resend_verification.page_title", locale_str)
        .await;
    let title = i18n_service
        .translate("resend_verification.title", locale_str)
        .await;
    let subtitle = i18n_service
        .translate("resend_verification.subtitle", locale_str)
        .await;
    let email_label = i18n_service
        .translate("resend_verification.email_label", locale_str)
        .await;
    let email_placeholder = i18n_service
        .translate("resend_verification.email_placeholder", locale_str)
        .await;
    let submit = i18n_service
        .translate("resend_verification.submit", locale_str)
        .await;
    let back_to_login = i18n_service
        .translate("resend_verification.back_to_login", locale_str)
        .await;
    let error_default = i18n_service
        .translate("resend_verification.error_default", locale_str)
        .await;
    let success_default = i18n_service
        .translate("resend_verification.success_default", locale_str)
        .await;
    let error_missing_email = i18n_service
        .translate("resend_verification.error_missing_email", locale_str)
        .await;
    let error_cannot_send = i18n_service
        .translate("resend_verification.error_cannot_send", locale_str)
        .await;

    let layout = LayoutContext::new("", "");

    use rustwarden_core::services::app_settings_service::AppSettingsService;
    let app_domain = AppSettingsService::get_setting(&db, "app_domain")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "localhost".to_string());
    let app_description = AppSettingsService::get_setting(&db, "app_description")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "User accounts and authentication".to_string());

    let template = ResendVerificationTemplate {
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            current_locale.as_str(),
        )
        .await,
        page_title,
        csrf_token: String::new(),
        title,
        subtitle,
        email_label,
        email_placeholder,
        submit,
        back_to_login,
        error_default,
        success_default,
        error_missing_email,
        error_cannot_send,
    };

    Ok(template)
}

/// Query parameters for reset password page
#[derive(serde::Deserialize)]
pub struct ResetPasswordQuery {
    #[allow(dead_code)]
    token: Option<String>,
}

/// Handler for GET /reset-password?token={token} - Reset password page (PUBLIC)
pub async fn reset_password_page(
    headers: HeaderMap,
    State(db): State<DatabaseConnection>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    axum::extract::Query(_params): axum::extract::Query<ResetPasswordQuery>,
) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
    tracing::debug!("Rendering reset password page");

    let jar = CookieJar::from_headers(&headers);
    let current_locale = jar
        .get("locale")
        .and_then(|c| {
            let value = c.value();
            if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
                Some(value.to_string())
            } else {
                None
            }
        })
        .unwrap_or_else(|| "fr".to_string());

    let locale_str = &current_locale;
    let page_title = i18n_service
        .translate("password_reset.page_title", locale_str)
        .await;
    let reset_password_title = i18n_service
        .translate("password_reset.title", locale_str)
        .await;
    let subtitle = i18n_service
        .translate("password_reset.subtitle", locale_str)
        .await;
    let new_password_label = i18n_service
        .translate("password_reset.new_password_label", locale_str)
        .await;
    let new_password_placeholder = i18n_service
        .translate("password_reset.new_password_placeholder", locale_str)
        .await;
    let confirm_password_label = i18n_service
        .translate("password_reset.confirm_password_label", locale_str)
        .await;
    let confirm_password_placeholder = i18n_service
        .translate("password_reset.confirm_password_placeholder", locale_str)
        .await;
    let password_requirements = i18n_service
        .translate("password_reset.requirements", locale_str)
        .await;
    let submit_button = i18n_service.translate("login.submit", locale_str).await;
    let back_to_login = i18n_service
        .translate("login.sign_in_link", locale_str)
        .await;
    let success_message = i18n_service
        .translate("login.success_message", locale_str)
        .await;
    let error_message = i18n_service
        .translate("login.error_message", locale_str)
        .await;
    let missing_token_url = i18n_service
        .translate("password_reset.missing_token_url", locale_str)
        .await;
    let missing_token = i18n_service
        .translate("password_reset.missing_token", locale_str)
        .await;
    let missing_new_password = i18n_service
        .translate("password_reset.missing_new_password", locale_str)
        .await;
    let passwords_not_match = i18n_service
        .translate("password_reset.passwords_not_match", locale_str)
        .await;
    let reset_success_message = i18n_service
        .translate("password_reset.success_message", locale_str)
        .await;
    let error_link_invalid = i18n_service
        .translate("password_reset.error_link_invalid", locale_str)
        .await;
    let error_generic = i18n_service
        .translate("password_reset.error_generic", locale_str)
        .await;

    let layout = LayoutContext::new("", "");

    use rustwarden_core::services::app_settings_service::AppSettingsService;
    let app_domain = AppSettingsService::get_setting(&db, "app_domain")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "localhost".to_string());
    let app_description = AppSettingsService::get_setting(&db, "app_description")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "User accounts and authentication".to_string());

    let template = ResetPasswordTemplate {
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            current_locale.as_str(),
        )
        .await,
        page_title,
        csrf_token: String::new(),
        reset_password_title,
        subtitle,
        new_password_label,
        new_password_placeholder,
        confirm_password_label,
        confirm_password_placeholder,
        password_requirements,
        submit_button,
        back_to_login,
        success_message,
        error_message,
        missing_token_url,
        missing_token,
        missing_new_password,
        passwords_not_match,
        reset_success_message,
        error_link_invalid,
        error_generic,
    };

    Ok(template)
}

/// Login 2FA page template
#[derive(Template)]
#[template(path = "login_2fa.html")]
#[allow(dead_code)]
struct Login2faTemplate {
    app_name: String,
    app_version: String,
    page_title: String,
    title: String,
    subtitle: String,
    code_label: String,
    backup_code_label: String,
    verify_button: String,
    use_backup_code: String,
    use_totp_code: String,
    back_to_login: String,
    error_invalid_code: String,
    error_network: String,
}

rustwarden_core::impl_template_response!(Login2faTemplate);

/// GET /login/2fa — 2FA verification page
pub async fn login_2fa_page(
    headers: HeaderMap,
    Extension(i18n_service): Extension<Arc<I18nService>>,
) -> Result<impl IntoResponse, rustwarden_core::error::AppError> {
    let jar = CookieJar::from_headers(&headers);
    let current_locale = jar
        .get("locale")
        .and_then(|c| {
            let value = c.value();
            if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") {
                Some(value.to_string())
            } else {
                None
            }
        })
        .unwrap_or_else(|| "fr".to_string());

    let locale_str = &current_locale;
    let layout = LayoutContext::new("", "");

    let template = Login2faTemplate {
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        page_title: i18n_service
            .translate("login_2fa.page_title", locale_str)
            .await,
        title: i18n_service.translate("login_2fa.title", locale_str).await,
        subtitle: i18n_service
            .translate("login_2fa.subtitle", locale_str)
            .await,
        code_label: i18n_service
            .translate("login_2fa.code_label", locale_str)
            .await,
        backup_code_label: i18n_service
            .translate("login_2fa.backup_code_label", locale_str)
            .await,
        verify_button: i18n_service
            .translate("login_2fa.verify_button", locale_str)
            .await,
        use_backup_code: i18n_service
            .translate("login_2fa.use_backup_code", locale_str)
            .await,
        use_totp_code: i18n_service
            .translate("login_2fa.use_totp_code", locale_str)
            .await,
        back_to_login: i18n_service
            .translate("login_2fa.back_to_login", locale_str)
            .await,
        error_invalid_code: i18n_service
            .translate("login_2fa.error_invalid_code", locale_str)
            .await,
        error_network: i18n_service
            .translate("login_2fa.error_network", locale_str)
            .await,
    };

    Ok(template)
}