This commit is contained in:
Priec
2026-06-25 19:24:50 +02:00
parent 0c0cae2355
commit aea4782e68
15 changed files with 161 additions and 4 deletions

View File

@@ -7,6 +7,7 @@
//! 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::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
@@ -14,7 +15,11 @@ use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
controllers::{
admin_form::{read_multipart_form, store_image},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
models::{
customer_profiles::{self, ProfileFields},
order_items, orders, users,
@@ -128,6 +133,8 @@ fn profile_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"avatar_id": user.avatar_id,
"saved": saved,
"error": error,
"name": user.name,
@@ -202,6 +209,64 @@ async fn save_profile(
profile_view(&v, &jar, &user, &fields, true, false)
}
/// Persist `avatar_id` (a stored image filename, or `None` to clear) on the
/// signed-in customer and re-render the profile page with the success banner.
async fn set_avatar(
v: &TeraView,
jar: &CookieJar,
ctx: &AppContext,
user: users::Model,
avatar_id: Option<String>,
) -> Result<Response> {
let mut active = user.clone().into_active_model();
active.avatar_id = ActiveValue::set(avatar_id.clone());
let user = active.update(&ctx.db).await?;
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
profile_view(v, jar, &user, &fields_of(profile.as_ref()), true, false)
}
/// Upload (or replace) the signed-in customer's avatar picture. The single
/// `image` file part is validated and stored through the shared image storage,
/// then its generated filename is saved as the user's `avatar_id`.
#[debug_handler]
async fn upload_avatar(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> 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 form = read_multipart_form(multipart).await?;
let Some(image) = form.single_image() else {
// No file chosen — nothing to do, just re-show the profile.
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
return profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false);
};
let filename = store_image(&ctx, image).await?;
set_avatar(&v, &jar, &ctx, user, Some(filename)).await
}
/// Remove the signed-in customer's avatar, reverting to the initials fallback.
#[debug_handler]
async fn remove_avatar(
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");
}
set_avatar(&v, &jar, &ctx, user, None).await
}
/// Lists the signed-in customer's orders, split into still-active and past.
#[debug_handler]
async fn orders_page(
@@ -236,6 +301,7 @@ async fn orders_page(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"active_orders": shape(active),
"past_orders": shape(past),
"lang": current_lang(&jar),
@@ -278,6 +344,7 @@ async fn order_detail_page(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"order": order_view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
@@ -312,6 +379,7 @@ fn password_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"changed": changed,
"error": error,
"lang": current_lang(jar),
@@ -406,6 +474,7 @@ fn security_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"totp_enabled": user.totp_enabled(),
"enrolling": enrolling,
"qr": qr,
@@ -538,6 +607,11 @@ pub fn routes() -> Routes {
Routes::new()
.add("/account/profile", get(profile_page))
.add("/account/profile", post(save_profile))
.add(
"/account/profile/avatar",
post(upload_avatar).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/account/profile/avatar/remove", post(remove_avatar))
.add("/account/orders", get(orders_page))
.add("/account/orders/{order_number}", get(order_detail_page))
.add("/account/password", get(change_password_page))

View File

@@ -264,6 +264,7 @@ async fn show(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"lang": current_lang(&jar),
}),
)?;

View File

@@ -132,6 +132,7 @@ async fn checkout_page(
// logged_in_customer is true); None for admins/guests.
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
"profile_filled": profile_filled,
// A logged-in customer's account type is fixed; only guests pick it
// and may opt to create an account from the order.
@@ -375,6 +376,7 @@ async fn order_confirmation(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"account_created": account_created,
"lang": current_lang(&jar),
}),

View File

@@ -28,6 +28,7 @@ async fn index(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
// The header search bar only appears on the landing page.

View File

@@ -25,6 +25,7 @@ async fn render(v: &TeraView, jar: &CookieJar, ctx: &AppContext, page: &str) ->
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(jar),
}),

View File

@@ -350,6 +350,7 @@ fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str)
map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
map.insert("customer_name".into(), json!(c.customer_name));
map.insert("customer_account_type".into(), json!(c.customer_account_type));
map.insert("customer_avatar".into(), json!(c.customer_avatar));
map.insert("lang".into(), json!(lang));
}
}
@@ -482,6 +483,7 @@ async fn show(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),

View File

@@ -31,6 +31,7 @@ pub struct Model {
pub totp_enabled_at: Option<DateTimeWithTimeZone>,
#[sea_orm(column_type = "Text", nullable)]
pub totp_backup_codes: Option<String>,
pub avatar_id: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -56,6 +56,9 @@ pub struct Chrome {
pub logged_in_customer: bool,
pub customer_name: Option<String>,
pub customer_account_type: Option<String>,
/// Stored avatar image filename (served via `/images/{filename}`), set only
/// for a logged-in customer who uploaded one. `None` -> initials fallback.
pub customer_avatar: Option<String>,
}
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome {
@@ -74,6 +77,7 @@ pub fn chrome_from(ctx: &AppContext, user: Option<&users::Model>) -> Chrome {
logged_in_customer: true,
customer_name: Some(user.name.clone()),
customer_account_type: Some(user.account_type.clone()),
customer_avatar: user.avatar_id.clone(),
..Default::default()
},
None => Chrome::default(),