//! Customer account area. Currently just the shipping/contact profile, whose //! fields prefill the checkout form. Gated to authenticated non-admin users: //! anonymous visitors are bounced to `/login`. Admins have their own area and //! are sent to the dashboard. //! //! The account *type* (personal vs company) is fixed at registration and lives //! on the user — it is shown here read-only and can never be changed. The //! profile only edits the type-specific details (company identity + address). use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::QueryOrder; use serde::Deserialize; use serde_json::json; use crate::{ controllers::i18n::current_lang, models::{ customer_profiles::{self, ProfileFields}, order_items, orders, users, }, shared::{guard, settings}, views::checkout as order_view, }; /// Active (still-being-fulfilled) order statuses. Anything else /// (`delivered`, `cancelled`) is considered closed/past. const ACTIVE_STATUSES: [&str; 3] = ["pending", "paid", "shipped"]; #[derive(Debug, Deserialize)] struct ProfileForm { first_name: Option, last_name: Option, company_name: Option, company_id: Option, tax_id: Option, vat_id: Option, phone_prefix: Option, phone: Option, address: Option, city: Option, zip: Option, country: Option, } fn trimmed(value: Option<&str>) -> Option { value.map(str::trim).filter(|v| !v.is_empty()).map(String::from) } /// Split a stored full name into (first name, surname). The surname is /// everything after the first whitespace, so multi-word surnames round-trip. fn split_name(name: &str) -> (String, String) { match name.trim().split_once(char::is_whitespace) { Some((first, rest)) => (first.to_string(), rest.trim().to_string()), None => (name.trim().to_string(), String::new()), } } /// Recombine the two name fields into the single stored `name`. Returns `None` /// when the result is too short to be a valid name (the user can't blank it out). fn full_name_from_form(form: &ProfileForm) -> Option { let first = form.first_name.as_deref().unwrap_or("").trim(); let last = form.last_name.as_deref().unwrap_or("").trim(); let full = format!("{first} {last}").trim().to_string(); (full.chars().count() >= 2).then_some(full) } /// Build the persisted fields from the submitted form. Company identifiers are /// only kept for company accounts (a personal account can never carry them). fn fields_from_form(form: &ProfileForm, is_company: bool) -> ProfileFields { let company = |v: Option<&str>| if is_company { trimmed(v) } else { None }; ProfileFields { company_name: company(form.company_name.as_deref()), company_id: company(form.company_id.as_deref()), tax_id: company(form.tax_id.as_deref()), vat_id: company(form.vat_id.as_deref()), phone_prefix: trimmed(form.phone_prefix.as_deref()), phone: trimmed(form.phone.as_deref()), address: trimmed(form.address.as_deref()), city: trimmed(form.city.as_deref()), zip: trimmed(form.zip.as_deref()), country: trimmed(form.country.as_deref()), } } /// The profile fields held by a saved profile, for re-prefilling the form. fn fields_of(profile: Option<&customer_profiles::Model>) -> ProfileFields { match profile { Some(p) => ProfileFields { company_name: p.company_name.clone(), company_id: p.company_id.clone(), tax_id: p.tax_id.clone(), vat_id: p.vat_id.clone(), phone_prefix: p.phone_prefix.clone(), phone: p.phone.clone(), address: p.address.clone(), city: p.city.clone(), zip: p.zip.clone(), country: p.country.clone(), }, None => ProfileFields::default(), } } /// A company account must carry its invoicing identity (company name + IČO + /// DIČ; IČ DPH stays optional). Personal accounts have no such requirement. fn company_fields_missing(fields: &ProfileFields) -> bool { fields.company_name.is_none() || fields.company_id.is_none() || fields.tax_id.is_none() } /// Render the profile form for `user`, prefilled from `fields`. `saved` shows /// the success banner; `error` shows the company-required validation message. fn profile_view( v: &TeraView, jar: &CookieJar, user: &users::Model, fields: &ProfileFields, saved: bool, error: bool, ) -> Result { let (first_name, last_name) = split_name(&user.name); format::view( v, "account/profile.html", json!({ "logged_in_admin": false, "logged_in_customer": true, "account_nav": true, "customer_name": user.name, "customer_account_type": user.account_type, "saved": saved, "error": error, "name": user.name, "first_name": first_name, "last_name": last_name, "email": user.email, "account_type": user.account_type, "company_name": fields.company_name, "company_id": fields.company_id, "tax_id": fields.tax_id, "vat_id": fields.vat_id, "phone_prefix": fields.phone_prefix, "phone": fields.phone, "address": fields.address, "city": fields.city, "zip": fields.zip, "country": fields.country, "lang": current_lang(jar), }), ) } #[debug_handler] async fn profile_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?; profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false) } #[debug_handler] async fn save_profile( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } // Apply the edited name to a working copy so it's reflected on both the // success and re-rendered-error views. A blank/too-short name is ignored — // the field can't be cleared. let mut user = user; let new_name = full_name_from_form(&form).filter(|n| *n != user.name); if let Some(name) = new_name.clone() { user.name = name; } let fields = fields_from_form(&form, user.is_company()); // A company account's profile is rejected (and re-shown with the entered // values) until it carries its required identifiers. if user.is_company() && company_fields_missing(&fields) { return profile_view(&v, &jar, &user, &fields, false, true); } if let Some(name) = new_name { let mut active = user.clone().into_active_model(); active.name = ActiveValue::set(name); active.update(&ctx.db).await?; } customer_profiles::Model::upsert(&ctx.db, user.id, fields.clone()).await?; profile_view(&v, &jar, &user, &fields, true, false) } /// Lists the signed-in customer's orders, split into still-active and past. #[debug_handler] async fn orders_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } let rows = orders::Entity::find() .filter(orders::Column::UserId.eq(user.id)) .order_by_desc(orders::Column::CreatedAt) .all(&ctx.db) .await?; let (active, past): (Vec<_>, Vec<_>) = rows .iter() .partition(|o| ACTIVE_STATUSES.contains(&o.status.as_str())); let shape = |list: Vec<&orders::Model>| -> Vec<_> { list.into_iter().map(order_view::summary).collect() }; format::view( &v, "account/orders.html", json!({ "logged_in_admin": false, "logged_in_customer": true, "account_nav": true, "customer_name": user.name, "customer_account_type": user.account_type, "active_orders": shape(active), "past_orders": shape(past), "lang": current_lang(&jar), }), ) } /// Shows a single order belonging to the signed-in customer. Orders owned by /// someone else (or guest orders) are not found here. #[debug_handler] async fn order_detail_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Path(order_number): Path, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } let order = orders::Entity::find() .filter(orders::Column::OrderNumber.eq(order_number)) .one(&ctx.db) .await? .filter(|o| o.user_id == Some(user.id)) .ok_or_else(|| Error::NotFound)?; let items = order_items::Entity::find() .filter(order_items::Column::OrderId.eq(order.id)) .all(&ctx.db) .await?; format::view( &v, "account/order_detail.html", json!({ "logged_in_admin": false, "logged_in_customer": true, "account_nav": true, "customer_name": user.name, "customer_account_type": user.account_type, "order": order_view::detail( &order, settings::get(&ctx, "bank_iban").unwrap_or(""), settings::get(&ctx, "bank_account_name").unwrap_or(""), ), "items": order_view::items(&items), "lang": current_lang(&jar), }), ) } #[derive(Debug, Deserialize)] struct ChangePasswordForm { current_password: String, password: String, password_confirm: String, } fn password_view( v: &TeraView, jar: &CookieJar, user: &users::Model, changed: bool, error: Option<&str>, ) -> Result { format::view( v, "account/password.html", json!({ "logged_in_admin": false, "logged_in_customer": true, "account_nav": true, "customer_name": user.name, "customer_account_type": user.account_type, "changed": changed, "error": error, "lang": current_lang(jar), }), ) } #[debug_handler] async fn change_password_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } password_view(&v, &jar, &user, false, None) } #[debug_handler] async fn change_password( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } if !user.verify_password(&form.current_password) { return password_view(&v, &jar, &user, false, Some("current")); } if form.password != form.password_confirm { return password_view(&v, &jar, &user, false, Some("mismatch")); } if form.password.len() < 8 { return password_view(&v, &jar, &user, false, Some("weak")); } let user = user .into_active_model() .reset_password(&ctx.db, &form.password) .await?; password_view(&v, &jar, &user, true, None) } // ---- Two-factor authentication (TOTP / Google Authenticator) ------------- // // Entirely opt-in. The security page has three shapes, all rendered from // `security.html`: // * disabled -> an "enable" button, // * enrolling -> the QR + a confirm-code field (secret staged, not yet on), // * enabled -> status, remaining backup codes, disable/regenerate forms. // Both turning 2FA off and regenerating backup codes require re-entering the // account password, so a walk-up attacker on an open session can't weaken it. #[derive(Debug, Deserialize)] struct ConfirmTotpForm { code: String, } #[derive(Debug, Deserialize)] struct PasswordConfirmForm { current_password: String, } /// Render the security page. Exactly one of (`enrolling`, plain status) applies; /// `backup_codes` is non-empty only on the one render right after enabling or /// regenerating, where the plaintext codes are shown once. #[allow(clippy::too_many_arguments)] fn security_view( v: &TeraView, jar: &CookieJar, user: &users::Model, enrolling: bool, qr: Option<&str>, secret: Option<&str>, backup_codes: &[String], error: Option<&str>, ) -> Result { format::view( v, "account/security.html", json!({ "logged_in_admin": false, "logged_in_customer": true, "account_nav": true, "customer_name": user.name, "customer_account_type": user.account_type, "totp_enabled": user.totp_enabled(), "enrolling": enrolling, "qr": qr, "secret": secret, "backup_codes": backup_codes, "backup_remaining": user.backup_codes_remaining(), "error": error, "lang": current_lang(jar), }), ) } /// Common guard for every security handler: a signed-in, non-admin customer. async fn require_customer(ctx: &AppContext, jar: &CookieJar) -> Result { match guard::current_user(ctx, jar).await { Some(user) if guard::is_admin(ctx, &user) => Err(Error::string("admin")), Some(user) => Ok(user), None => Err(Error::Unauthorized("login required".into())), } } #[debug_handler] async fn security_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let Some(user) = guard::current_user(&ctx, &jar).await else { return format::redirect("/login"); }; if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } security_view(&v, &jar, &user, false, None, None, &[], None) } /// Stage a fresh secret and show the QR + confirm-code field. #[debug_handler] async fn enable_totp( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let Ok(user) = require_customer(&ctx, &jar).await else { return format::redirect("/login"); }; // Already on — nothing to enroll. if user.totp_enabled() { return security_view(&v, &jar, &user, false, None, None, &[], None); } let user = user.into_active_model().begin_totp_enrollment(&ctx.db).await?; let Some((qr, secret)) = user.totp_provisioning() else { return security_view(&v, &jar, &user, false, None, None, &[], Some("enroll")); }; security_view(&v, &jar, &user, true, Some(&qr), Some(&secret), &[], None) } /// Verify the first code against the staged secret; on success flip 2FA on and /// show the one-time backup codes. On a wrong code, re-show the QR to retry. #[debug_handler] async fn confirm_totp( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { let Ok(user) = require_customer(&ctx, &jar).await else { return format::redirect("/login"); }; if user.totp_enabled() { return security_view(&v, &jar, &user, false, None, None, &[], None); } if !user.verify_totp_code(&form.code) { let qr = user.totp_provisioning(); let (qr, secret) = match &qr { Some((q, s)) => (Some(q.as_str()), Some(s.as_str())), None => (None, None), }; return security_view(&v, &jar, &user, true, qr, secret, &[], Some("code")); } let (user, backup_codes) = user.into_active_model().enable_totp(&ctx.db).await?; security_view(&v, &jar, &user, false, None, None, &backup_codes, None) } /// Turn 2FA off — requires the account password as confirmation. #[debug_handler] async fn disable_totp( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { let Ok(user) = require_customer(&ctx, &jar).await else { return format::redirect("/login"); }; if !user.totp_enabled() { return security_view(&v, &jar, &user, false, None, None, &[], None); } if !user.verify_password(&form.current_password) { return security_view(&v, &jar, &user, false, None, None, &[], Some("password")); } let user = user.into_active_model().disable_totp(&ctx.db).await?; security_view(&v, &jar, &user, false, None, None, &[], None) } /// Issue a fresh set of backup codes (invalidating the old ones) — also gated by /// the account password. #[debug_handler] async fn regenerate_backup_codes( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { let Ok(user) = require_customer(&ctx, &jar).await else { return format::redirect("/login"); }; if !user.totp_enabled() { return security_view(&v, &jar, &user, false, None, None, &[], None); } if !user.verify_password(&form.current_password) { return security_view(&v, &jar, &user, false, None, None, &[], Some("password")); } let (user, backup_codes) = user.into_active_model().regenerate_backup_codes(&ctx.db).await?; security_view(&v, &jar, &user, false, None, None, &backup_codes, None) } pub fn routes() -> Routes { Routes::new() .add("/account/profile", get(profile_page)) .add("/account/profile", post(save_profile)) .add("/account/orders", get(orders_page)) .add("/account/orders/{order_number}", get(order_detail_page)) .add("/account/password", get(change_password_page)) .add("/account/password", post(change_password)) .add("/account/security", get(security_page)) .add("/account/security/enable", post(enable_totp)) .add("/account/security/confirm", post(confirm_totp)) .add("/account/security/disable", post(disable_totp)) .add("/account/security/backup-codes", post(regenerate_backup_codes)) }