diff --git a/biome.json b/biome.json index b4487259..5abc8fe5 100644 --- a/biome.json +++ b/biome.json @@ -1,4 +1,11 @@ { + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, "files": { "includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors/"] }, diff --git a/docs/plan/User-Image-support.md b/docs/plan/User-Image-support.md new file mode 100644 index 00000000..419261be --- /dev/null +++ b/docs/plan/User-Image-support.md @@ -0,0 +1,298 @@ +# Plan: User Avatar / Image Support + +## Context + +Users need to be able to set a profile photo (avatar). The image must: +- Be stored as a URL (`https://…`, `http://…`) or data URI (`data:image/(png|webp|jpeg);base64,…`) +- Match the CardDAV `PHOTO` format so the system address book exports it correctly +- Be editable **only** for local (username+password) accounts +- Be **synced automatically from OIDC** `picture` claim on every login for OIDC accounts +- Surface in `userVignette` components (owner column, ShareModal member rows) + +Currently: no `image` column on `auth.users`, no `picture` claim extraction in OIDC, profile page shows initials only, `user_to_contact()` hardcodes `photo_url: None`. + +--- + +## Execution order + +### 1. DB Migration +**New file:** `migrations/20260526000000_add_user_image.sql` +```sql +ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS image TEXT; +``` + +--- + +### 2. Domain Entity +**`src/domain/entities/user.rs`** +- Add `image: Option` field +- `User::new()` and `User::new_oidc()` — initialise to `None` +- Add getter `pub fn image(&self) -> Option<&str>` +- Add setter `pub fn set_image(&mut self, image: Option)` +- Add owned getter for persistence `pub fn image_owned(&self) -> Option` + +--- + +### 3. User Repository +**`src/infrastructure/repositories/pg/user_pg_repository.rs`** +- Add `image` to every `SELECT` that builds a `User` (row-mapper) +- Extend the `UPDATE` SQL in `update_user()` to include `image = $11` +- Add dedicated: `async fn update_image(&self, user_id: Uuid, image: Option) -> Result<(), DomainError>` + +--- + +### 4. OIDC: extract `picture` claim +**`src/application/ports/auth_ports.rs`** +- Add `pub picture: Option` to `OidcIdClaims` + +**`src/infrastructure/services/oidc_service.rs`** +- Add `picture: Option` to both `IdTokenClaims` and `UserInfoResponse` structs +- Pass `picture` into the returned `OidcIdClaims` + +**`src/application/services/auth_application_service.rs`** — in `oidc_callback()`: +- **Create path**: pass `claims.picture` to `User::new_oidc()` + (or call `user.set_image(claims.picture.clone())` before persisting) +- **Update path**: always call `user.set_image(claims.picture.clone())` then persist + (OIDC image is always authoritative — overwrite even if user had set one before) + +--- + +### 5. User DTO +**`src/application/dtos/user_dto.rs`** +Add two fields to `UserDto`: +```rust +pub image: Option, +pub can_edit_image: bool, // true iff !user.is_oidc_user() +``` +Populate in `UserDto::from(user)`. + +--- + +### 6. Validation helper (shared) +In the auth application service (or a small `validation.rs` module in `src/common/`): +```rust +fn validate_image_url(image: &str) -> bool { + image.starts_with("https://") + || image.starts_with("http://") + || image.starts_with("data:image/png;base64,") + || image.starts_with("data:image/webp;base64,") + || image.starts_with("data:image/jpeg;base64,") +} +``` +Max length for data URIs: **10 KB** (10 608 bytes) to prevent DB abuse — a 1à4×104 WebP at quality 0.85 is well under this; a raw PNG could exceed it so the client must resize/compress first. + +--- + +### 7. Auth Application Service — new method +**`src/application/services/auth_application_service.rs`** +```rust +pub async fn update_user_image( + &self, + caller_id: Uuid, + image: Option, +) -> Result<(), AppError> +``` +Logic: +1. Load user from repository +2. If `user.is_oidc_user()` → return `AppError::Forbidden` +3. If `image.is_some()` → validate format + length; return `AppError::Validation` if invalid +4. Call `user_repository.update_image(caller_id, image).await` + +--- + +### 8. Auth Handler + Route +**`src/interfaces/api/handlers/auth_handler.rs`** + +New DTO (inline or in a dto file): +```rust +#[derive(Deserialize)] +pub struct UpdateUserImageDto { + pub image: Option, // None = clear the image +} +``` + +New handler `update_user_image` — pattern mirrors `change_password`: +- Extract `CurrentUserId`, JSON body +- Call service method +- Map `AppError::Forbidden` → 403, `AppError::Validation` → 422, else 200 + +**`src/interfaces/api/routes.rs`** — in `auth_protected_routes()`: +```rust +.route("/me/image", put(update_user_image)) +``` + +--- + +### 9. System Address Book +**`src/interfaces/api/handlers/contacts_handler.rs`** — `user_to_contact()`: +```rust +photo_url: user.image.clone(), // was: None +``` + +--- + +### 10. Frontend — `systemUsers.js` +**`static/js/model/systemUsers.js`** +- Add `let _photoIndex = null;` (`Map`) +- In `_ensureIndex()`: build `_photoIndex` from `c.photo_url` alongside the name map +- Inject current user's photo from `localStorage.getItem('oxicloud_user')?.image` +- Add `async function getPhoto(userId): Promise` +- Export `{ prefetch, getDisplayName, getPhoto, isAvailable }` + +--- + +### 11. Frontend — `userVignette.js` +**`static/js/components/userVignette.js`** + +In `createUserVignette(userId, size)`: +- After async name resolves, also await `systemUsers.getPhoto(userId)` +- If photo URL is truthy: replace the initials text with `…` inside `user-vignette__avatar` +- Wire `onerror` on the img to fall back to initials (guard against broken URLs) + +CSS addition in `userVignette.css`: +```css +.user-vignette__avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; +} +``` + +--- + +### 12. Frontend — User Menu (top-right) +**`static/js/app/userMenu.js`** — `updateUserMenuData()`: +- Read `user.image` from the stored `oxicloud_user` in localStorage +- `#user-avatar` (38 px circle): if `user.image` is set, replace inner HTML with `…` instead of initials text; wire `onerror` fallback to initials +- `#user-menu-avatar` (48 px circle in dropdown): same treatment +- When `profile.js` saves a new image successfully, it must also refresh the stored `oxicloud_user` in localStorage (re-fetch `/api/auth/me` and update) then call `updateUserMenuData()` + +**`static/css/components/userMenu.css`** — add inside the file: +```css +.user-avatar img, +.user-menu-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; +} +``` + +--- + +### 13. Frontend — Image resize helper (new shared utility) +**`static/js/utils/imageResize.js`** — new file + +```js +/** + * Load a File/Blob as an Image, draw it on a Canvas, resize to fit within + * MAX_SIZE × MAX_SIZE, and return a data URI. + * + * @param {File} file + * @param {number} [maxSize=102] + * @returns {Promise} data:image/webp;base64,… (or jpeg fallback) + */ +export async function resizeImageToDataUrl(file, maxSize = 104) +``` + +Logic: +1. Read file with `FileReader` → data URL +2. Create `` element and wait for `onload` +3. Compute output dimensions: scale down proportionally if either dimension > `maxSize`; never scale up +4. Draw onto `OffscreenCanvas` (or regular ``) at the computed size +5. Export with `canvas.toBlob('image/webp', 0.85)` (fallback to `image/jpeg` if WebP not supported) +6. Convert Blob → base64 data URI via `FileReader` + +Accepts only MIME types: `image/png`, `image/webp`, `image/jpeg` — reject others with a thrown `Error`. + +--- + +### 14. Frontend — Profile Page +**`static/profile.html`** +- Make `#p-avatar` support both `` and initials text +- Add edit button (pencil icon) visible only when `user.can_edit_image === true` +- Add collapsible edit panel with **two input modes** (tabs or toggle): + - **URL tab**: `` with validation hint + - **Upload tab**: `` + live preview thumbnail +- Save / Cancel / Remove (clear) buttons + +**`static/js/views/profile/profile.js`** + +*Display:* +- If `user.image`: set `#p-avatar` to `` (with `onerror` → initials fallback) +- If `user.can_edit_image`: show edit pencil +- For OIDC users: show photo if `user.image` set; show "Managed by your identity provider" note; no edit controls + +*URL mode save:* +- Validate prefix client-side (`https://`, `http://`, `data:image/…;base64,`) +- `PUT /api/auth/me/image` with `{ image: url || null }` + +*Upload mode save:* +- On file selection: call `resizeImageToDataUrl(file, 104)` from the new utility +- Show preview in a `` (hidden until file chosen) +- On Save: send resulting data URI via `PUT /api/auth/me/image` with `{ image: dataUri }` +- Show progress indicator during resize + upload (data URIs for a 104×104 WebP are ~2-5 kB) + +*After successful save (both modes):* +- Re-fetch `/api/auth/me`, update `oxicloud_user` in localStorage +- Call `updateUserMenuData()` to refresh top-right avatar immediately +- Collapse the edit panel and update `#p-avatar` in-place + +--- + +## Files to modify / create + +| File | Action | +|---|---| +| `migrations/20260526000000_add_user_image.sql` | **CREATE** | +| `src/domain/entities/user.rs` | add `image` field + getter/setter | +| `src/infrastructure/repositories/pg/user_pg_repository.rs` | add to SELECT/UPDATE + `update_image()` | +| `src/application/ports/auth_ports.rs` | add `picture` to `OidcIdClaims` | +| `src/infrastructure/services/oidc_service.rs` | add `picture` to claims structs | +| `src/application/services/auth_application_service.rs` | OIDC sync + `update_user_image()` | +| `src/application/dtos/user_dto.rs` | add `image`, `can_edit_image` | +| `src/interfaces/api/handlers/auth_handler.rs` | `update_user_image` handler | +| `src/interfaces/api/routes.rs` | register `PUT /auth/me/image` | +| `src/interfaces/api/handlers/contacts_handler.rs` | `user_to_contact()` maps `image` → `photo_url` | +| `static/js/model/systemUsers.js` | add `_photoIndex`, `getPhoto()` | +| `static/js/components/userVignette.js` | render `` when photo available | +| `static/css/components/userVignette.css` | add `img` rule inside avatar | +| `static/js/utils/imageResize.js` | **CREATE** — Canvas resize → WebP/JPEG data URI | +| `static/profile.html` | avatar image + URL input + file upload + preview | +| `static/js/views/profile/profile.js` | photo display + URL/upload edit flow + post-save menu refresh | +| `static/js/app/userMenu.js` | render `` in both avatar circles when `user.image` present | +| `static/css/components/userMenu.css` | add `img` cover rule for `.user-avatar` and `.user-menu-avatar` | + +--- + +## Verification + +```bash +# Backend +cargo fmt --all +cargo clippy --all-features --all-targets -- -D warnings +cargo test + +# Frontend +biome lint static/js/ +tsc -p jsconfig.json --noEmit +stylelint static/css/ +``` + +**Smoke tests:** +1. Local user → profile page → edit image → paste `https://example.com/me.jpg` → Save → avatar shows photo +2. Local user → paste `data:image/png;base64,…` → Save → works +3. Local user → paste invalid string → Save → 422 error shown +4. Local user → clear image (empty) → Save → avatar reverts to initials +5. OIDC user → `picture` claim present → after login, `GET /api/auth/me` returns `image` → profile shows photo, no edit button +6. OIDC user → `picture` claim absent → `image` is null → profile shows initials +7. SharedWithMe owner column → users with photos show ``, others show initials +8. ShareModal People section → member avatars show photos where available +9. CardDAV client sync → system address book contact has `PHOTO` property set +10. After saving a photo on the profile page → top-right avatar button and dropdown header both update immediately without a page reload +11. Upload a large PNG (e.g. 2000×2000) → client resizes to 104×104 WebP, preview appears, Save sends data URI, backend accepts (< 10 KB) +12. Upload a 300×300 image → client does NOT upscale, stores at original dimensions +13. Upload a non-image file (PDF) → rejected client-side before any network call diff --git a/migrations/20260526000000_add_user_image.sql b/migrations/20260526000000_add_user_image.sql new file mode 100644 index 00000000..88ba03b4 --- /dev/null +++ b/migrations/20260526000000_add_user_image.sql @@ -0,0 +1 @@ +ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS image TEXT; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 3512e28a..3188b03b 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -17,6 +17,8 @@ pub struct UserDto { pub last_login_at: Option>, pub active: bool, pub auth_provider: String, + pub image: Option, + pub can_edit_image: bool, } impl From for UserDto { @@ -33,6 +35,8 @@ impl From 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(), } } } diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index e652d865..1415380e 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -151,6 +151,7 @@ pub struct OidcIdClaims { pub preferred_username: Option, pub name: Option, pub groups: Vec, + pub picture: Option, } /// Port for OIDC operations — implemented in infrastructure layer diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 01fd29da..bba3bea5 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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, + ) -> 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 { 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?; diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index f815b101..1d4572c9 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -35,6 +35,7 @@ pub struct User { active: bool, oidc_provider: Option, oidc_subject: Option, + image: Option, } 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, oidc_subject: Option, + image: Option, ) -> 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) { + 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() diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 8198350a..3a8436c4 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -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, + ) -> 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 { 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 { 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 { 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> { 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> { 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 { 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"), )) } diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 92503be1..19144e66 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -67,6 +67,7 @@ struct IdTokenClaims { name: Option, groups: Option>, nonce: Option, + picture: Option, // Standard JWT fields #[allow(dead_code)] iss: Option, @@ -90,6 +91,7 @@ struct UserInfoResponse { preferred_username: Option, name: Option, groups: Option>, + picture: Option, } // ============================================================================ @@ -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, }) } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 25a304a9..d5a90f50 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -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> { @@ -34,6 +36,7 @@ pub fn auth_public_routes() -> Router> { pub fn auth_protected_routes() -> Router> { 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, +} + async fn change_password( State(state): State>, CurrentUserId(user_id): CurrentUserId, @@ -338,6 +348,29 @@ async fn change_password( Ok(StatusCode::OK) } +pub async fn update_user_image( + State(state): State>, + CurrentUserId(user_id): CurrentUserId, + Json(dto): Json, +) -> 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>, CurrentUserId(user_id): CurrentUserId, diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs index 99604da7..479985d2 100644 --- a/src/interfaces/api/handlers/contacts_handler.rs +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -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, diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css index af314901..1b92d07f 100644 --- a/static/css/components/shareModal.css +++ b/static/css/components/shareModal.css @@ -132,30 +132,10 @@ background: var(--color-bg-hover); } -.smd-suggestion-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 700; - flex-shrink: 0; -} - -.smd-suggestion-name { - font-size: 14px; - color: var(--color-text-heading); +/* Vignette fills the row; name + email stack vertically inside .user-vignette__info. */ +.smd-suggestion-item .user-vignette { flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.smd-suggestion-email { - font-size: 12px; - color: var(--color-text-faint); + min-width: 0; } /* Role picker beside the search box */ @@ -201,18 +181,6 @@ color: var(--color-text-heading); } -.smd-chip-avatar { - width: 20px; - height: 20px; - border-radius: 50%; - font-size: 9px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - .smd-chip-remove { display: flex; align-items: center; @@ -280,25 +248,10 @@ padding: 7px 0; } -.smd-member-avatar { - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - flex-shrink: 0; -} - -.smd-member-name { +/* Vignette grows to fill the member row; role-select + action stay right-aligned. */ +.smd-member-row .user-vignette { flex: 1; - font-size: 14px; - color: var(--color-text-heading); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + min-width: 0; } .smd-member-role-select { diff --git a/static/css/components/userMenu.css b/static/css/components/userMenu.css index 29ec2e66..1a7ede6f 100644 --- a/static/css/components/userMenu.css +++ b/static/css/components/userMenu.css @@ -20,7 +20,7 @@ transform: scale(1.05); } -.user-avatar-btn:hover .user-avatar { +.user-avatar-btn:hover .user-vignette__avatar { box-shadow: 0 0 0 2px var(--color-accent-ring-strong); } @@ -28,21 +28,22 @@ border-color: var(--color-accent); } -.user-avatar { - width: 38px; - height: 38px; - background: var(--color-accent-gradient); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-danger-text); - font-weight: 700; - font-size: 14px; +/* ── Avatar vignette overrides ────────────────────────────────────────────── */ + +/* The toolbar button and dropdown header mount avatar-only userVignette + components. Sizing is owned by userVignette.css (--menu / --xl variants); + the rules below add the decoration that is specific to this context. */ + +.user-avatar-btn .user-vignette__avatar { letter-spacing: 0.5px; user-select: none; } +.user-menu-header .user-vignette__avatar { + letter-spacing: 0.5px; + box-shadow: 0 4px 12px var(--color-accent-shadow); +} + .user-menu { display: none; position: absolute; @@ -89,42 +90,22 @@ border-bottom: 1px solid var(--color-user-menu-header-border); } -.user-menu-avatar { - width: 48px; - height: 48px; - min-width: 48px; - background: var(--color-accent-gradient); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-danger-text); - font-weight: 700; - font-size: 17px; - letter-spacing: 0.5px; - box-shadow: 0 4px 12px var(--color-accent-shadow); +/* The header vignette (xl, name + email) fills the available width. */ +.user-menu-header .user-vignette { + flex: 1; + min-width: 0; } -.user-menu-info { - overflow: hidden; -} - -.user-menu-name { - font-weight: 600; +/* Increase name prominence relative to the base vignette style. */ +.user-menu-header .user-vignette__name { font-size: 15px; + font-weight: 600; color: var(--color-text-heading); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } -.user-menu-email { +.user-menu-header .user-vignette__email { font-size: 12.5px; - color: var(--color-text-faint); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-top: 2px; + margin-top: 1px; } .user-menu-storage { diff --git a/static/css/components/userVignette.css b/static/css/components/userVignette.css index af0e7f1c..7db2ef61 100644 --- a/static/css/components/userVignette.css +++ b/static/css/components/userVignette.css @@ -4,8 +4,10 @@ * name resolved asynchronously. Used in: * • Owner column (list view, SharedWithMe & Favorites sections) * • ShareModal member rows, chips, suggestion items + * • User-menu toolbar button and dropdown header (avatar-only mode) * - * Sizes: --xs (20 px) · --sm (24 px) · --md (32 px) · --lg (40 px) + * Sizes: --xs (20 px) · --sm (24 px) · --list (36 px) · --md (32 px) + * --lg (40 px) · --menu (38 px) · --xl (48 px) * Colours: .uv-color-0..4 (applied by JS via _colorIndex(userId) % 5) * * All colours use CSS custom properties — no raw hex / rgb / named values. @@ -40,6 +42,26 @@ color: var(--color-text-secondary); } +/* ── Email mode (showEmail: true) ─────────────────────────────────────────── */ + +/* Column wrapper used when both name and email are shown. */ +.user-vignette__info { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + overflow: hidden; + flex: 1; +} + +.user-vignette__email { + font-size: 12px; + color: var(--color-text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Size variants ─────────────────────────────────────────────────────────── */ .user-vignette--xs .user-vignette__avatar { @@ -54,6 +76,12 @@ font-size: 10px; } +.user-vignette--list .user-vignette__avatar { + width: 36px; + height: 36px; + font-size: 13px; +} + .user-vignette--md .user-vignette__avatar { width: 32px; height: 32px; @@ -66,6 +94,18 @@ font-size: 15px; } +.user-vignette--menu .user-vignette__avatar { + width: 38px; + height: 38px; + font-size: 14px; +} + +.user-vignette--xl .user-vignette__avatar { + width: 48px; + height: 48px; + font-size: 17px; +} + /* ── Colour palette ────────────────────────────────────────────────────────── */ /* Colours are shared with the ShareModal avatar palette. @@ -95,3 +135,15 @@ background: var(--color-badge-amber-bg); color: var(--color-badge-amber-text); } + +/* ── Photo rendering ───────────────────────────────────────────────────────── */ + +/* When a photo is available the JS replaces the initials text with an . + The avatar keeps its background color as a fallback while loading. */ +.user-vignette__avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; +} diff --git a/static/css/views/profile.css b/static/css/views/profile.css index fc549028..399d923d 100644 --- a/static/css/views/profile.css +++ b/static/css/views/profile.css @@ -776,3 +776,160 @@ body { margin: 12px 0 8px; line-height: 1.4; } + +/* ── Avatar photo ──────────────────────────────────────────────────────────── */ + +.avatar-large-wrap { + position: relative; + flex-shrink: 0; +} + +/* Fill circle with photo when JS injects an */ +.avatar-large img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; +} + +/* Pencil button that appears on hover (visible only for local accounts) */ +.avatar-edit-btn { + position: absolute; + bottom: 2px; + right: 2px; + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--color-bg-surface); + background: var(--color-accent); + color: var(--color-sidebar-text-active); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + cursor: pointer; + transition: + opacity 0.15s, + transform 0.15s; +} + +.avatar-edit-btn:hover { + transform: scale(1.1); +} + +/* OIDC note shown instead of edit button */ +.avatar-oidc-note { + font-size: 12px; + color: var(--color-text-faint); + margin-top: 6px; + font-style: italic; +} + +/* ── Photo edit panel ──────────────────────────────────────────────────────── */ + +.avatar-edit-panel { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--color-border); +} + +.avatar-edit-tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; +} + +.avatar-tab { + padding: 6px 16px; + border: 1px solid var(--color-border); + border-radius: 20px; + background: none; + color: var(--color-text-subtle); + font-size: 13px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; +} + +.avatar-tab.active { + background: var(--color-accent); + color: var(--color-sidebar-text-active); + border-color: var(--color-accent); +} + +.avatar-tab-pane { + display: flex; + flex-direction: column; + gap: 8px; +} + +.avatar-url-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-bg-alt); + color: var(--color-text-dark); + font-size: 14px; + box-sizing: border-box; +} + +.avatar-url-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-ring); +} + +.avatar-hint { + font-size: 12px; + color: var(--color-text-faint); +} + +/* Hidden file input — triggered via the label */ +.avatar-file-input { + display: none; +} + +.avatar-file-label { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px dashed var(--color-border); + border-radius: 10px; + background: var(--color-bg-alt); + color: var(--color-text-subtle); + font-size: 13px; + cursor: pointer; + transition: + border-color 0.15s, + color 0.15s; +} + +.avatar-file-label:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +/* Live preview thumbnail (square, small) */ +.avatar-preview { + width: 88px; + height: 88px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--color-border); +} + +.avatar-edit-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 16px; +} + +#p-avatar-status { + margin-top: 12px; +} diff --git a/static/index.html b/static/index.html index ac175e22..21faeed5 100644 --- a/static/index.html +++ b/static/index.html @@ -164,15 +164,11 @@
-
AD
-
-
User
-
user@oxicloud.app
-
+