feat(user-avatar): users can now edit there image (image is taken from OIDC picture)

This commit is contained in:
Edouard Vanbelle
2026-05-26 00:50:38 +02:00
parent 8b6c8e345b
commit b0c5e7827e
43 changed files with 1563 additions and 250 deletions
+4
View File
@@ -17,6 +17,8 @@ pub struct UserDto {
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub auth_provider: String,
pub image: Option<String>,
pub can_edit_image: bool,
}
impl From<User> for UserDto {
@@ -33,6 +35,8 @@ impl From<User> for UserDto {
last_login_at: user.last_login_at(),
active: user.is_active(),
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
image: user.image().map(|s| s.to_string()),
can_edit_image: !user.is_oidc_user(),
}
}
}
+1
View File
@@ -151,6 +151,7 @@ pub struct OidcIdClaims {
pub preferred_username: Option<String>,
pub name: Option<String>,
pub groups: Vec<String>,
pub picture: Option<String>,
}
/// Port for OIDC operations — implemented in infrastructure layer
@@ -647,6 +647,53 @@ impl AuthApplicationService {
Ok(())
}
/// Update the profile image for a non-OIDC user.
pub async fn update_user_image(
&self,
caller_id: Uuid,
image: Option<String>,
) -> Result<(), DomainError> {
let user = self.user_storage.get_user_by_id(caller_id).await?;
if user.is_oidc_user() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"User",
"Avatar is managed by your identity provider and cannot be changed here",
));
}
if let Some(ref img) = image {
const MAX_BYTES: usize = 524_288; // 512 KiB
if img.len() > MAX_BYTES {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Image exceeds maximum allowed size (512 KiB)",
));
}
let valid = img.starts_with("https://")
|| img.starts_with("http://")
|| img.starts_with("data:image/png;base64,")
|| img.starts_with("data:image/webp;base64,")
|| img.starts_with("data:image/jpeg;base64,");
if !valid {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Image must be an https/http URL or a data URI (png, webp, jpeg)",
));
}
}
self.user_storage
.update_image(caller_id, image)
.await
.map_err(DomainError::from)?;
Ok(())
}
pub async fn get_user(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user))
@@ -1128,8 +1175,9 @@ impl AuthApplicationService {
.await
{
Ok(mut existing_user) => {
// User exists — update last login
// User exists — update last login and sync avatar from IdP
existing_user.register_login();
existing_user.set_image(claims.picture.clone());
self.user_storage.update_user(existing_user.clone()).await?;
existing_user
}
@@ -1206,7 +1254,7 @@ impl AuthApplicationService {
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
}
let new_user = User::new_oidc(
let mut new_user = User::new_oidc(
username.clone(),
oidc_email,
role,
@@ -1221,6 +1269,7 @@ impl AuthApplicationService {
format!("Failed to create OIDC user: {}", e),
)
})?;
new_user.set_image(claims.picture.clone());
let created_user = self.user_storage.create_user(new_user).await?;
+15
View File
@@ -35,6 +35,7 @@ pub struct User {
active: bool,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
image: Option<String>,
}
impl User {
@@ -83,6 +84,7 @@ impl User {
active: true,
oidc_provider: None,
oidc_subject: None,
image: None,
})
}
@@ -112,6 +114,7 @@ impl User {
active: true,
oidc_provider: Some(oidc_provider),
oidc_subject: Some(oidc_subject),
image: None,
})
}
@@ -143,6 +146,7 @@ impl User {
active,
oidc_provider: None,
oidc_subject: None,
image: None,
}
}
@@ -161,6 +165,7 @@ impl User {
active: bool,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
image: Option<String>,
) -> Self {
Self {
id,
@@ -176,6 +181,7 @@ impl User {
active,
oidc_provider,
oidc_subject,
image,
}
}
@@ -232,6 +238,15 @@ impl User {
self.oidc_subject.as_deref()
}
pub fn image(&self) -> Option<&str> {
self.image.as_deref()
}
pub fn set_image(&mut self, image: Option<String>) {
self.image = image;
self.updated_at = Utc::now();
}
/// Returns true if this is an OIDC-only user (no password)
pub fn is_oidc_user(&self) -> bool {
self.oidc_provider.is_some()
@@ -42,6 +42,28 @@ impl UserPgRepository {
_ => UserRepositoryError::DatabaseError(format!("Database error: {}", err)),
}
}
/// Updates a user's profile image (URL or data URI). Not part of the
/// `UserRepository` trait — called directly from `AuthApplicationService`.
pub async fn update_image(
&self,
user_id: Uuid,
image: Option<String>,
) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET image = $2, updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(&image)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
}
impl UserRepository for UserPgRepository {
@@ -105,11 +127,11 @@ impl UserRepository for UserPgRepository {
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
WHERE id = $1
"#,
@@ -140,6 +162,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
))
}
@@ -147,11 +170,11 @@ impl UserRepository for UserPgRepository {
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
WHERE username = $1
"#,
@@ -182,6 +205,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
))
}
@@ -189,11 +213,11 @@ impl UserRepository for UserPgRepository {
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
WHERE email = $1
"#,
@@ -224,6 +248,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
))
}
@@ -238,7 +263,7 @@ impl UserRepository for UserPgRepository {
sqlx::query(
r#"
UPDATE auth.users
SET
SET
username = $2,
email = $3,
password_hash = $4,
@@ -247,7 +272,8 @@ impl UserRepository for UserPgRepository {
storage_used_bytes = $7,
updated_at = $8,
last_login_at = $9,
active = $10
active = $10,
image = $11
WHERE id = $1
"#,
)
@@ -261,6 +287,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.updated_at())
.bind(user_clone.last_login_at())
.bind(user_clone.is_active())
.bind(user_clone.image())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -323,11 +350,11 @@ impl UserRepository for UserPgRepository {
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>> {
let rows = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
@@ -363,6 +390,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
)
})
.collect();
@@ -378,7 +406,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
WHERE username ILIKE $1 OR email ILIKE $1
ORDER BY username
@@ -414,6 +442,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
)
})
.collect();
@@ -496,11 +525,11 @@ impl UserRepository for UserPgRepository {
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>> {
let rows = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -535,6 +564,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
)
})
.collect();
@@ -566,11 +596,11 @@ impl UserRepository for UserPgRepository {
) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
oidc_provider, oidc_subject, image
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#,
@@ -601,6 +631,7 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
))
}
@@ -67,6 +67,7 @@ struct IdTokenClaims {
name: Option<String>,
groups: Option<Vec<String>>,
nonce: Option<String>,
picture: Option<String>,
// Standard JWT fields
#[allow(dead_code)]
iss: Option<String>,
@@ -90,6 +91,7 @@ struct UserInfoResponse {
preferred_username: Option<String>,
name: Option<String>,
groups: Option<Vec<String>>,
picture: Option<String>,
}
// ============================================================================
@@ -460,6 +462,7 @@ impl OidcServicePort for OidcService {
preferred_username: claims.preferred_username,
name: claims.name,
groups: claims.groups.unwrap_or_default(),
picture: claims.picture,
})
}
@@ -511,6 +514,7 @@ impl OidcServicePort for OidcService {
preferred_username: info.preferred_username,
name: info.name,
groups: info.groups.unwrap_or_default(),
picture: info.picture,
})
}
@@ -17,6 +17,8 @@ use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUserId;
use serde::Deserialize;
use utoipa::ToSchema;
/// Public auth routes — no authentication required.
pub fn auth_public_routes() -> Router<Arc<AppState>> {
@@ -34,6 +36,7 @@ pub fn auth_public_routes() -> Router<Arc<AppState>> {
pub fn auth_protected_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/me", get(get_current_user))
.route("/me/image", put(update_user_image))
.route("/change-password", put(change_password))
.route("/logout", post(logout))
}
@@ -320,6 +323,13 @@ async fn get_current_user(
Ok((StatusCode::OK, Json(user)))
}
/// DTO for updating the user's profile image.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateUserImageDto {
/// New image URL (https/http) or data URI (data:image/…;base64,…). Null to clear.
pub image: Option<String>,
}
async fn change_password(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
@@ -338,6 +348,29 @@ async fn change_password(
Ok(StatusCode::OK)
}
pub async fn update_user_image(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
Json(dto): Json<UpdateUserImageDto>,
) -> impl IntoResponse {
let auth_service = match state.auth_service.as_ref() {
Some(svc) => svc,
None => {
return AppError::internal_error("Authentication service not configured")
.into_response();
}
};
match auth_service
.auth_application_service
.update_user_image(user_id, dto.image)
.await
{
Ok(_) => StatusCode::OK.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
async fn logout(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
@@ -201,7 +201,7 @@ fn user_to_contact(user: UserDto) -> ContactDto {
organization: Some("OxiCloud".to_string()),
title: None,
notes: None,
photo_url: None,
photo_url: user.image.clone(),
birthday: None,
anniversary: None,
created_at: user.created_at,