feat(user-avatar): users can now edit there image (image is taken from OIDC picture)
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user