551 lines
19 KiB
Rust
551 lines
19 KiB
Rust
//! 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<String>,
|
|
last_name: Option<String>,
|
|
company_name: Option<String>,
|
|
company_id: Option<String>,
|
|
tax_id: Option<String>,
|
|
vat_id: Option<String>,
|
|
phone_prefix: Option<String>,
|
|
phone: Option<String>,
|
|
address: Option<String>,
|
|
city: Option<String>,
|
|
zip: Option<String>,
|
|
country: Option<String>,
|
|
}
|
|
|
|
fn trimmed(value: Option<&str>) -> Option<String> {
|
|
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<String> {
|
|
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<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<ProfileForm>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Path(order_number): Path<String>,
|
|
) -> Result<Response> {
|
|
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<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<ChangePasswordForm>,
|
|
) -> Result<Response> {
|
|
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<Response> {
|
|
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<users::Model> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<ConfirmTotpForm>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<PasswordConfirmForm>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<PasswordConfirmForm>,
|
|
) -> Result<Response> {
|
|
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))
|
|
}
|