Merge pull request #394 from EdouardVanbelle/feat/user-image-avatar

This commit is contained in:
Dionisio Pozo
2026-05-27 21:33:45 +02:00
committed by GitHub
70 changed files with 1625 additions and 286 deletions
+5 -5
View File
@@ -81,7 +81,7 @@ jobs:
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
@@ -93,7 +93,7 @@ jobs:
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -120,7 +120,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
@@ -140,7 +140,7 @@ jobs:
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -150,7 +150,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --release
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test"
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
# Build the exact tag behind the published release or manual dispatch.
ref: ${{ github.event.inputs.version || github.event.release.tag_name || github.ref }}
@@ -61,7 +61,7 @@ jobs:
needs: test
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
# Build the exact tag behind the published release or manual dispatch.
ref: ${{ github.event.inputs.version || github.event.release.tag_name || github.ref }}
@@ -26,11 +26,17 @@ jobs:
- name: Install Node dependencies
run: npm ci
- name: Build OxiCloud (release)
working-directory: .
run: cargo build --release
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Update snapshots
run: npm test -- --update-snapshots
run: npm test -- --update-snapshots=all
env:
BUILD_TARGET: release
- name: Commit updated snapshots
uses: stefanzweifel/git-auto-commit-action@v5
-1
View File
@@ -94,7 +94,6 @@ charts/*/charts/*
# Playwright
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
tests/e2e/blob-report/
tests/e2e/playwright/.cache/
tests/e2e/playwright/.auth/
+7
View File
@@ -1,4 +1,11 @@
{
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"files": {
"includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors/"]
},
+298
View File
@@ -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<String>` 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<String>)`
- Add owned getter for persistence `pub fn image_owned(&self) -> Option<String>`
---
### 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<String>) -> Result<(), DomainError>`
---
### 4. OIDC: extract `picture` claim
**`src/application/ports/auth_ports.rs`**
- Add `pub picture: Option<String>` to `OidcIdClaims`
**`src/infrastructure/services/oidc_service.rs`**
- Add `picture: Option<String>` 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<String>,
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<String>,
) -> 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<String>, // 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<string, string|null>`)
- 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<string|null>`
- 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 `<img src="…" alt="…">` 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 `<img src="…" alt="…">` 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<string>} data:image/webp;base64,… (or jpeg fallback)
*/
export async function resizeImageToDataUrl(file, maxSize = 104)
```
Logic:
1. Read file with `FileReader` → data URL
2. Create `<img>` 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 `<canvas>`) 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 `<img>` 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**: `<input type="url" id="p-image-url" placeholder="https://…">` with validation hint
- **Upload tab**: `<input type="file" id="p-image-file" accept="image/png,image/jpeg,image/webp">` + live preview thumbnail
- Save / Cancel / Remove (clear) buttons
**`static/js/views/profile/profile.js`**
*Display:*
- If `user.image`: set `#p-avatar` to `<img src="…">` (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 `<img id="p-image-preview">` (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 `<img>` 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 `<img>` 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 `<img>`, 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
+1 -1
View File
@@ -77,7 +77,7 @@ front-test:
# update images snapshots
front-test-update-snapshot:
cd tests/e2e && npm test -- --update-snapshots
cd tests/e2e && npm test -- --update-snapshots=all
# Hurl API functional tests (starts postgres + server, tears down after)
api-test:
@@ -0,0 +1 @@
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS image TEXT;
+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 {
/// Image URL (https/http) or data URI (data:image/png|webp|jpeg;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,
+1 -1
View File
@@ -525,7 +525,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
script-src 'self'; \
worker-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; \
img-src 'self' data: blob: https:; \
media-src 'self' blob:; \
connect-src 'self'; \
font-src 'self' data:; \
+6 -53
View File
@@ -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 {
+22 -41
View File
@@ -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 {
+53 -1
View File
@@ -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 <img>.
The avatar <span> 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;
}
+157
View File
@@ -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 <img> */
.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;
}
+2 -6
View File
@@ -164,15 +164,11 @@
<div class="user-menu-wrapper" id="user-menu-wrapper">
<button class="user-avatar-btn" id="user-avatar-btn">
<div class="user-avatar" id="user-avatar">AD</div>
<!-- avatar-only userVignette mounted here by updateUserMenuData() -->
</button>
<div class="user-menu" id="user-menu">
<div class="user-menu-header">
<div class="user-menu-avatar" id="user-menu-avatar">AD</div>
<div class="user-menu-info">
<div class="user-menu-name" id="user-menu-name">User</div>
<div class="user-menu-email" id="user-menu-email">user@oxicloud.app</div>
</div>
<!-- userVignette (avatar + name + email) mounted here by updateUserMenuData() -->
</div>
<div class="user-menu-role-badge hidden" id="user-menu-role-badge">
<span class="role-badge role-badge-admin"><i class="fas fa-shield-alt"></i> Admin</span>
+3 -12
View File
@@ -7,6 +7,7 @@ import { loadFiles } from './filesView.js';
import { updateStorageUsageDisplay } from './main.js';
import { app } from './state.js';
import { ui } from './ui.js';
import { updateUserMenuData } from './userMenu.js';
/**
* @import {User} from '../core/types.js'
@@ -96,14 +97,7 @@ async function checkAuthentication() {
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
// We have cached user data — render immediately, refresh in background
const userInitials = userData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach((el) => {
el.textContent = userInitials;
});
const menuName = document.getElementById('user-menu-name');
const menuEmail = document.getElementById('user-menu-email');
if (menuName) menuName.textContent = userData.username;
if (menuEmail) menuEmail.textContent = userData.email || '';
updateUserMenuData();
updateStorageUsageDisplay(userData);
@@ -145,10 +139,7 @@ async function checkAuthentication() {
try {
const freshData = await refreshUserData();
if (freshData?.username) {
const userInitials = freshData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach((el) => {
el.textContent = userInitials;
});
updateUserMenuData();
updateStorageUsageDisplay(freshData);
resolveHomeFolder().then(() => loadFiles());
} else {
+1 -1
View File
@@ -481,7 +481,7 @@ const ui = {
const id = cell.dataset.ownerId;
cell.dataset.ownerResolved = '1';
if (!id) continue;
cell.replaceChildren(createUserVignette(id, 'sm'));
cell.replaceChildren(createUserVignette(id, 'list'));
}
},
+33 -8
View File
@@ -2,6 +2,7 @@
* User menu, profile modal and logout logic
*/
import { createUserVignette } from '../components/userVignette.js';
import { getCsrfHeaders } from '../core/csrf.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
@@ -21,6 +22,9 @@ function setupUserMenu() {
if (!wrapper || !avatarBtn || !menu) return;
// Populate avatar and name immediately from localStorage on every page load.
updateUserMenuData();
avatarBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = wrapper.classList.contains('open');
@@ -156,20 +160,41 @@ function setupUserMenu() {
fetchAppVersion();
}
/**
* Mount avatar-only vignettes for the toolbar button and the dropdown header.
* Called whenever user data in localStorage changes (login, photo save, etc.).
*
* The toolbar button (#user-avatar-btn) and the menu header (.user-menu-header)
* are the stable mount points. Both receive a fresh vignette each call so
* the photo / initials are always in sync with the current localStorage state.
*
* @param {string} userId
*/
function _mountAvatarVignettes(userId) {
const avatarBtn = document.getElementById('user-avatar-btn');
if (avatarBtn) {
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
}
const menuHeader = document.querySelector('.user-menu-header');
if (menuHeader) {
menuHeader.replaceChildren(createUserVignette(userId, 'xl', { showName: true, showEmail: true }));
}
}
/**
* @returns {void}
*/
function updateUserMenuData() {
const USER_DATA_KEY = 'oxicloud_user';
/** @type {import('../core/types.js').User} */
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const nameEl = document.getElementById('user-menu-name');
const emailEl = document.getElementById('user-menu-email');
const avatarEl = document.getElementById('user-menu-avatar');
const storageFill = document.getElementById('user-menu-storage-fill');
const storageText = document.getElementById('user-menu-storage-text');
if (userData.username) {
if (nameEl) nameEl.textContent = userData.username;
if (emailEl) emailEl.textContent = userData.email || '';
if (avatarEl) avatarEl.textContent = userData.username.substring(0, 2).toUpperCase();
if (userData.username && userData.id) {
_mountAvatarVignettes(userData.id);
}
const usedBytes = userData.storage_used_bytes || 0;
@@ -287,4 +312,4 @@ async function logout() {
window.location.href = '/login';
}
export { logout, setupUserMenu, showUserProfileModal };
export { logout, setupUserMenu, showUserProfileModal, updateUserMenuData };
+6 -45
View File
@@ -21,7 +21,7 @@ import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
import { Modal } from './modal.js';
import { _colorIndex, _initials } from './userVignette.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
@@ -337,27 +337,7 @@ const shareModal = {
item.className = 'smd-suggestion-item';
item.tabIndex = 0;
const avatar = document.createElement('div');
avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`;
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
avatar.textContent = _initials(displayName);
const nameEl = document.createElement('span');
nameEl.className = 'smd-suggestion-name';
nameEl.textContent = displayName;
const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
if (primaryEmail) {
const emailEl = document.createElement('span');
emailEl.className = 'smd-suggestion-email';
emailEl.textContent = primaryEmail;
item.appendChild(avatar);
item.appendChild(nameEl);
item.appendChild(emailEl);
} else {
item.appendChild(avatar);
item.appendChild(nameEl);
}
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
const select = () => onSelect(c);
item.addEventListener('click', select);
@@ -415,13 +395,7 @@ const shareModal = {
const chip = document.createElement('div');
chip.className = 'smd-chip';
const avatar = document.createElement('div');
avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`;
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
avatar.textContent = _initials(displayName);
const nameEl = document.createElement('span');
nameEl.textContent = displayName;
const vignette = createUserVignette(c.id, 'xs');
const rm = document.createElement('button');
rm.className = 'smd-chip-remove';
@@ -434,8 +408,7 @@ const shareModal = {
if (addBtn) addBtn.disabled = this._stagedUsers.length === 0;
});
chip.appendChild(avatar);
chip.appendChild(nameEl);
chip.appendChild(vignette);
chip.appendChild(rm);
container.appendChild(chip);
});
@@ -526,18 +499,7 @@ const shareModal = {
const row = document.createElement('div');
row.className = 'smd-member-row';
const avatar = document.createElement('div');
avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`;
// Resolve display name async
systemUsers.getDisplayName(entry.grant.subject.id).then((name) => {
avatar.textContent = _initials(name);
nameEl.textContent = name;
});
const nameEl = document.createElement('span');
nameEl.className = 'smd-member-name';
nameEl.textContent = `${entry.grant.subject.id.slice(0, 8)}…`;
const vignette = createUserVignette(entry.grant.subject.id, 'md');
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';
@@ -568,8 +530,7 @@ const shareModal = {
this._refreshMemberGroups();
});
row.appendChild(avatar);
row.appendChild(nameEl);
row.appendChild(vignette);
row.appendChild(roleSelect);
row.appendChild(removeBtn);
return row;
+91 -23
View File
@@ -1,16 +1,22 @@
// @ts-check
/**
* UserVignette — reusable user avatar + name inline component.
* UserVignette — reusable user avatar component, two display modes.
*
* Renders a coloured circle with initials (or photo when available) alongside
* an asynchronously-resolved display name. Used in:
* • Owner column (list view) via `ui.resolveOwnerCells()`
* • ShareModal member rows / chips / suggestion items
* Mode 1 — avatar + name (default):
* A coloured circle with initials (or photo) alongside an async-resolved
* display name. Used in the owner column, ShareModal rows / chips / items.
*
* Mode 2 — avatar only ({ showName: false }):
* The circle alone, no name span. Used in the user-menu toolbar button
* and the dropdown header where the name is rendered separately.
*
* Usage:
* import { createUserVignette } from './userVignette.js';
* // with name
* cell.replaceChildren(createUserVignette(userId, 'sm'));
* // avatar only
* btn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
*/
import { systemUsers } from '../model/systemUsers.js';
@@ -42,22 +48,60 @@ export function _colorIndex(userId) {
return Math.abs(hash) % 5;
}
/**
* Render a photo inside an avatar element, falling back to initials on error.
* @param {HTMLElement} avatar The `.user-vignette__avatar` element.
* @param {string} photoUrl Non-empty photo URL or data URI.
* @param {string} name Display name for the alt attribute / fallback.
*/
function _applyPhoto(avatar, photoUrl, name) {
const img = document.createElement('img');
img.alt = name;
img.src = photoUrl;
img.onerror = () => {
// Photo failed to load — fall back to initials
avatar.replaceChildren();
avatar.textContent = _initials(name);
};
avatar.replaceChildren(img);
}
// ── Component ──────────────────────────────────────────────────────────────────
/**
* @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize
* Available sizes. Each maps to a `.user-vignette--{size}` CSS modifier:
* xs → 20 px (chip avatar, small inline contexts)
* sm → 24 px (default; ShareModal suggestions, compact rows)
* list → 36 px (owner column in list view)
* md → 32 px (ShareModal member rows)
* lg → 40 px (profile page, larger lists)
* menu → 38 px (user-menu toolbar button)
* xl → 48 px (user-menu dropdown header)
*
* @typedef {'xs'|'sm'|'list'|'md'|'lg'|'menu'|'xl'} VignetteSize
*/
/**
* Create a user vignette element: a coloured initials circle + async-resolved
* display name span. The element is returned immediately with a short-UUID
* placeholder; the name resolves in the background via `systemUsers`.
* @typedef {Object} VignetteOptions
* @property {boolean} [showName=true]
* When false, only the avatar circle is rendered — no name span.
* Use this when the name is displayed separately (e.g. the user-menu header).
* @property {boolean} [showEmail=false]
* When true (and showName is true), the primary email address is shown below
* the name in a lighter style. Name and email are wrapped in a
* `.user-vignette__info` column. Has no effect when showName is false.
*/
/**
* Create a user vignette element. Returns immediately with a placeholder;
* the display name, email, and photo resolve asynchronously via `systemUsers`.
*
* @param {string} userId UUID of the user
* @param {VignetteSize} [size='sm']
* @param {string} userId UUID of the user
* @param {VignetteSize} [size='sm']
* @param {VignetteOptions} [options]
* @returns {HTMLElement}
*/
export function createUserVignette(userId, size = 'sm') {
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false } = {}) {
const colorIdx = _colorIndex(userId);
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
@@ -67,19 +111,43 @@ export function createUserVignette(userId, size = 'sm') {
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
// Temporary placeholder: first two chars of UUID
avatar.textContent = userId.slice(0, 2).toUpperCase();
const nameEl = document.createElement('span');
nameEl.className = 'user-vignette__name';
nameEl.textContent = `${userId.slice(0, 8)}…`;
wrapper.appendChild(avatar);
wrapper.appendChild(nameEl);
// Resolve full name asynchronously and update both avatar initials and name
systemUsers.getDisplayName(userId).then((name) => {
avatar.textContent = _initials(name);
nameEl.textContent = name;
});
/** @type {HTMLElement | null} */
const nameEl = showName ? document.createElement('span') : null;
/** @type {HTMLElement | null} */
const emailEl = showName && showEmail ? document.createElement('span') : null;
if (nameEl) {
nameEl.className = 'user-vignette__name';
nameEl.textContent = `${userId.slice(0, 8)}…`;
if (emailEl) {
// Wrap name + email in a column so they stack vertically.
emailEl.className = 'user-vignette__email';
const info = document.createElement('span');
info.className = 'user-vignette__info';
info.appendChild(nameEl);
info.appendChild(emailEl);
wrapper.appendChild(info);
} else {
wrapper.appendChild(nameEl);
}
}
// Resolve name, photo, and (when requested) email asynchronously.
Promise.all([systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null)]).then(
([name, photo, email]) => {
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email ?? '';
if (photo) {
_applyPhoto(avatar, photo, name);
} else {
avatar.textContent = _initials(name);
}
}
);
return wrapper;
}
+17 -3
View File
@@ -88,11 +88,25 @@ function installFetchInterceptor() {
return response;
}
// Auth endpoints must bypass retry: a 401 on /api/auth/* means the
// credentials themselves are invalid; retrying would cause a loop.
// True auth primitives must bypass retry — they would either loop
// (/refresh), or a 401 there genuinely means bad credentials (login,
// register, oidc, device flows). User-data endpoints that happen to
// live under /api/auth/ (me, me/image, change-password, app-passwords)
// ARE retried so that an expired access token is transparently refreshed.
// Public share endpoints (/api/s/) use 401 to mean "password required",
// not "session expired" — intercepting them would wrongly redirect to login.
if (urlStr.includes('/api/auth/') || urlStr.includes('/api/s/')) return response;
const AUTH_PRIMITIVES = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/',
'/api/auth/device/'
];
if (AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/')) {
return response;
}
const refreshed = await _refresh();
if (!refreshed) {
+8
View File
@@ -234,6 +234,10 @@ const OxiIcons = {
576,
'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z'
],
link: [
576,
'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'
],
list: [
512,
'M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z'
@@ -429,6 +433,10 @@ const OxiIcons = {
'volume-up': [
640,
'M533.6 32.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C557.5 113.8 592 180.8 592 256s-34.5 142.2-88.7 186.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C598.5 426.7 640 346.2 640 256S598.5 85.2 533.6 32.5zM473.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C475.3 170.7 496 210.9 496 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C393.1 227.6 400 241 400 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C434.1 312.9 448 286.1 448 256s-13.9-56.9-35.4-74.5zM80 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L128 160 80 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48z'
],
world: [
512,
'M351.9 280l-190.9 0c2.9 64.5 17.2 123.9 37.5 167.4 11.4 24.5 23.7 41.8 35.1 52.4 11.2 10.5 18.9 12.2 22.9 12.2s11.7-1.7 22.9-12.2c11.4-10.6 23.7-28 35.1-52.4 20.3-43.5 34.6-102.9 37.5-167.4zM160.9 232l190.9 0C349 167.5 334.7 108.1 314.4 64.6 303 40.2 290.7 22.8 279.3 12.2 268.1 1.7 260.4 0 256.4 0s-11.7 1.7-22.9 12.2c-11.4 10.6-23.7 28-35.1 52.4-20.3 43.5-34.6 102.9-37.5 167.4zm-48 0C116.4 146.4 138.5 66.9 170.8 14.7 78.7 47.3 10.9 131.2 1.5 232l111.4 0zM1.5 280c9.4 100.8 77.2 184.7 169.3 217.3-32.3-52.2-54.4-131.7-57.9-217.3L1.5 280zm398.4 0c-3.5 85.6-25.6 165.1-57.9 217.3 92.1-32.7 159.9-116.5 169.3-217.3l-111.4 0zm111.4-48C501.9 131.2 434.1 47.3 342 14.7 374.3 66.9 396.4 146.4 399.9 232l111.4 0z'
]
};
+74 -9
View File
@@ -4,9 +4,11 @@
* System-users convenience layer.
*
* Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that
* provides a userId → display-name index. Used wherever a grant's
* `granted_by` UUID needs to be shown as a human-readable name
* (owner tooltips, share dialogs, etc.).
* provides a userId → display-name index, a userId → photo-url index,
* and a userId → primary-email index.
* Used wherever a grant's `granted_by` UUID needs to be shown as a
* human-readable name (owner tooltips, share dialogs, etc.), avatar
* image (userVignette, user menu), or email (suggestion dropdowns).
*
* Falls back gracefully when the system address book is disabled
* server-side (`OXICLOUD_EXPOSE_SYSTEM_USERS` not set): `isAvailable()`
@@ -20,6 +22,12 @@ import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js';
/** @type {Map<string, string> | null} userId → display name, built lazily */
let _index = null;
/** @type {Map<string, string | null> | null} userId → photo URL (or null), built lazily */
let _photoIndex = null;
/** @type {Map<string, string | null> | null} userId → primary email (or null), built lazily */
let _emailIndex = null;
/**
* Derive the best human-readable name from a contact.
* Priority: "First Last" → full_name → primary email → shortened id.
@@ -36,7 +44,7 @@ function _nameFor(c) {
}
/**
* Ensure the index is built (idempotent).
* Ensure both indexes are built (idempotent).
* After loading contacts from the system address book, the current user
* (from localStorage) is injected so owner cells resolve correctly even
* when the server-side address book does not include the logged-in user.
@@ -46,15 +54,30 @@ async function _ensureIndex() {
if (_index !== null) return;
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
_index = new Map(contacts.map((c) => [c.id, _nameFor(c)]));
_photoIndex = new Map(contacts.map((c) => [c.id, c.photo_url ?? null]));
_emailIndex = new Map(
contacts.map((c) => {
const primary = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? null;
return [c.id, primary];
})
);
// Inject the current user if they are not already in the index
try {
const raw = localStorage.getItem('oxicloud_user');
if (raw) {
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw));
if (u?.id && !_index.has(u.id)) {
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
_index.set(u.id, name);
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string, image?:string|null}} */ (JSON.parse(raw));
if (u?.id) {
if (!_index.has(u.id)) {
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
_index.set(u.id, name);
}
if (!_photoIndex.has(u.id)) {
_photoIndex.set(u.id, u.image ?? null);
}
if (!_emailIndex.has(u.id)) {
_emailIndex.set(u.id, u.email ?? null);
}
}
}
} catch {
@@ -85,6 +108,48 @@ async function getDisplayName(userId) {
return _index?.get(userId) ?? `${userId.slice(0, 8)}…`;
}
/**
* Resolve a user UUID to a photo URL (or null if none set).
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
*
* @param {string} userId
* @returns {Promise<string | null>}
*/
async function getPhoto(userId) {
await _ensureIndex();
return _photoIndex?.get(userId) ?? null;
}
/**
* Resolve a user UUID to their primary email address (or null if unknown).
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
*
* @param {string} userId
* @returns {Promise<string | null>}
*/
async function getEmail(userId) {
await _ensureIndex();
return _emailIndex?.get(userId) ?? null;
}
/**
* Force-refresh the current user's photo entry in the index from localStorage.
* Call this after saving a new avatar on the profile page so that existing
* vignettes can re-render without a full page reload.
*/
function refreshCurrentUserPhoto() {
try {
const raw = localStorage.getItem('oxicloud_user');
if (!raw || !_photoIndex) return;
const u = /** @type {{id?:string, image?:string|null}} */ (JSON.parse(raw));
if (u?.id) {
_photoIndex.set(u.id, u.image ?? null);
}
} catch {
// ignore
}
}
/**
* Returns `false` only after a confirmed 404 from the server (feature
* disabled). Returns `true` when status is unknown or the book loaded OK.
@@ -94,4 +159,4 @@ function isAvailable() {
return addressBook.isSystemAvailable();
}
export const systemUsers = { prefetch, getDisplayName, isAvailable };
export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
+96
View File
@@ -0,0 +1,96 @@
// @ts-check
/**
* Client-side image resize utility.
*
* Accepts a File/Blob, resizes it to fit within maxSize × maxSize pixels
* (never upscales), and returns a data URI (WebP preferred, JPEG fallback).
*
* Used by the profile page before uploading an avatar image so that
* data URIs stay well within the 512 KiB backend limit.
*/
/** Accepted MIME types for avatar uploads. */
const ACCEPTED_TYPES = new Set(['image/png', 'image/webp', 'image/jpeg']);
/**
* Load an image File/Blob as a data URL via FileReader.
* @param {File | Blob} file
* @returns {Promise<string>}
*/
function _readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(/** @type {string} */ (reader.result));
reader.onerror = () => reject(new Error('FileReader failed'));
reader.readAsDataURL(file);
});
}
/**
* Load a data URL into an HTMLImageElement (waits for `onload`).
* @param {string} src
* @returns {Promise<HTMLImageElement>}
*/
function _loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image failed to load'));
img.src = src;
});
}
/**
* Convert a canvas to a data URI, preferring WebP at quality 0.85.
* Falls back to JPEG if the browser does not support WebP encoding.
* @param {HTMLCanvasElement} canvas
* @returns {string}
*/
function _canvasToDataUri(canvas) {
const webp = canvas.toDataURL('image/webp', 0.85);
// toDataURL returns a PNG if the MIME type is not supported — detect by prefix
if (webp.startsWith('data:image/webp')) return webp;
return canvas.toDataURL('image/jpeg', 0.85);
}
/**
* Resize an image File to fit within maxSize × maxSize, then return
* a data URI (WebP at quality 0.85, or JPEG as fallback).
*
* - Images already within maxSize × maxSize are not upscaled.
* - Only `image/png`, `image/webp`, and `image/jpeg` are accepted;
* all other MIME types throw an Error.
*
* @param {File} file Image file to resize
* @param {number} [maxSize=512] Maximum width and height in pixels
* @returns {Promise<string>} data URI of the (possibly resized) image
*/
export async function resizeImageToDataUrl(file, maxSize = 104) {
if (!ACCEPTED_TYPES.has(file.type)) {
throw new Error(`Unsupported image type: ${file.type}. Accepted: PNG, WebP, JPEG.`);
}
const dataUrl = await _readAsDataUrl(file);
const img = await _loadImage(dataUrl);
const { naturalWidth: w, naturalHeight: h } = img;
// Compute output dimensions — scale down proportionally if needed, never upscale
let outW = w;
let outH = h;
if (w > maxSize || h > maxSize) {
const ratio = Math.min(maxSize / w, maxSize / h);
outW = Math.round(w * ratio);
outH = Math.round(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not get 2D canvas context');
ctx.drawImage(img, 0, 0, outW, outH);
return _canvasToDataUri(canvas);
}
+255 -1
View File
@@ -1,6 +1,12 @@
import { getCsrfHeaders } from '../../core/csrf.js';
import { installFetchInterceptor } from '../../core/fetchWrapper.js';
import { i18n } from '../../core/i18n.js';
import { oxiIconsInit } from '../../core/icons.js';
import { resizeImageToDataUrl } from '../../utils/imageResize.js';
// Install the fetch interceptor so expired access tokens are refreshed
// automatically on this standalone page (it is not loaded by main.js here).
installFetchInterceptor();
const API = '/api';
@@ -36,6 +42,240 @@ function timeAgo(dateStr) {
return d.toLocaleDateString();
}
// ── Avatar helpers ─────────────────────────────────────────────────────────────
/**
* Render the large profile avatar (#p-avatar) — photo or initials.
* @param {string | null | undefined} photo
* @param {string} initials
*/
function _renderAvatar(photo, initials) {
const avatarEl = document.getElementById('p-avatar');
if (!avatarEl) return;
if (photo) {
const img = document.createElement('img');
img.alt = initials;
img.src = photo;
img.onerror = () => {
avatarEl.replaceChildren();
avatarEl.textContent = initials;
};
avatarEl.replaceChildren(img);
} else {
avatarEl.replaceChildren();
avatarEl.textContent = initials;
}
}
/**
* Persist user data to localStorage and refresh the top-right avatar.
* Calls GET /api/auth/me to get the fresh user object.
* @returns {Promise<void>}
*/
async function _refreshUserCache() {
try {
const resp = await fetch(`${API}/auth/me`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) return;
const user = await resp.json();
localStorage.setItem('oxicloud_user', JSON.stringify(user));
// Refresh top-right avatars if userMenu module is loaded on this page
// (profile.html is a standalone page, userMenu is only in index.html)
// — so we update #user-avatar / #user-menu-avatar directly if present
const initials = (user.username || '?').substring(0, 2).toUpperCase();
const topEl = /** @type {HTMLElement|null} */ (document.getElementById('user-avatar'));
const dropEl = /** @type {HTMLElement|null} */ (document.getElementById('user-menu-avatar'));
if (topEl || dropEl) {
/** @param {HTMLElement|null} el */
function applyPhoto(el) {
if (!el) return;
if (user.image) {
const img = document.createElement('img');
img.alt = initials;
img.src = user.image;
img.onerror = () => {
el.replaceChildren();
el.textContent = initials;
};
el.replaceChildren(img);
} else {
el.replaceChildren();
el.textContent = initials;
}
}
applyPhoto(topEl);
applyPhoto(dropEl);
}
} catch (_) {
// Best-effort
}
}
// ── Photo edit panel ────────────────────────────────────────────────────────────
/** @type {string|null} Pending data URI from file upload (upload mode) */
let _uploadedDataUri = null;
/**
* Switch the visible edit tab.
* @param {'url'|'upload'} tab
*/
function _switchTab(tab) {
const urlPane = document.getElementById('p-pane-url');
const uploadPane = document.getElementById('p-pane-upload');
const urlBtn = document.getElementById('p-tab-url');
const uploadBtn = document.getElementById('p-tab-upload');
if (tab === 'url') {
urlPane?.classList.remove('hidden');
uploadPane?.classList.add('hidden');
urlBtn?.classList.add('active');
uploadBtn?.classList.remove('active');
} else {
urlPane?.classList.add('hidden');
uploadPane?.classList.remove('hidden');
urlBtn?.classList.remove('active');
uploadBtn?.classList.add('active');
}
}
function _openEditPanel() {
document.getElementById('p-avatar-edit-panel')?.classList.remove('hidden');
_switchTab('url');
_uploadedDataUri = null;
const preview = /** @type {HTMLImageElement|null} */ (document.getElementById('p-image-preview'));
if (preview) {
preview.src = '';
preview.classList.add('hidden');
}
const urlInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-url'));
if (urlInput) urlInput.value = '';
const status = document.getElementById('p-avatar-status');
if (status) status.innerHTML = '';
}
function _closeEditPanel() {
document.getElementById('p-avatar-edit-panel')?.classList.add('hidden');
_uploadedDataUri = null;
}
/**
* Send PUT /api/auth/me/image and update UI on success.
* @param {string | null} image
*/
async function _saveImage(image) {
const statusEl = document.getElementById('p-avatar-status');
const saveBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById('p-avatar-save'));
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i>`;
}
if (statusEl) statusEl.innerHTML = '';
try {
const resp = await fetch(`${API}/auth/me/image`, {
method: 'PUT',
headers: headers(),
credentials: 'same-origin',
body: JSON.stringify({ image })
});
if (resp.ok) {
await _refreshUserCache();
// Update large avatar immediately
const raw = localStorage.getItem('oxicloud_user');
const user = raw ? JSON.parse(raw) : null;
const initials = (user?.username || '?').substring(0, 2).toUpperCase();
_renderAvatar(user?.image, initials);
_closeEditPanel();
} else {
const err = await resp.json().catch(() => ({}));
if (statusEl) {
statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(err.message || err.error || i18n.t('profile.photo_save_failed')) +
'</div>';
}
}
} catch (err) {
if (statusEl) {
statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message })) +
'</div>';
}
} finally {
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('profile.photo_save'))}`;
}
}
}
function _setupPhotoEdit() {
const editBtn = document.getElementById('p-avatar-edit-btn');
const cancelBtn = document.getElementById('p-avatar-cancel');
const saveBtn = document.getElementById('p-avatar-save');
const removeBtn = document.getElementById('p-avatar-remove');
const tabUrl = document.getElementById('p-tab-url');
const tabUpload = document.getElementById('p-tab-upload');
const fileInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-file'));
editBtn?.addEventListener('click', _openEditPanel);
cancelBtn?.addEventListener('click', _closeEditPanel);
tabUrl?.addEventListener('click', () => {
_switchTab('url');
});
tabUpload?.addEventListener('click', () => {
_switchTab('upload');
});
saveBtn?.addEventListener('click', async () => {
const activePane = document.getElementById('p-pane-url')?.classList.contains('hidden') ? 'upload' : 'url';
if (activePane === 'url') {
const urlInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-url'));
const val = urlInput?.value.trim() || null;
await _saveImage(val || null);
} else {
if (!_uploadedDataUri) {
const status = document.getElementById('p-avatar-status');
if (status)
status.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.photo_no_file'))}</div>`;
return;
}
await _saveImage(_uploadedDataUri);
}
});
removeBtn?.addEventListener('click', async () => {
await _saveImage(null);
});
fileInput?.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
const status = document.getElementById('p-avatar-status');
if (status) status.innerHTML = '';
try {
const dataUri = await resizeImageToDataUrl(file, 104);
_uploadedDataUri = dataUri;
const preview = /** @type {HTMLImageElement|null} */ (document.getElementById('p-image-preview'));
if (preview) {
preview.src = dataUri;
preview.classList.remove('hidden');
}
} catch (err) {
_uploadedDataUri = null;
if (status) {
status.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(/** @type {Error} */ (err).message)}</div>`;
}
}
});
}
async function init() {
try {
oxiIconsInit();
@@ -50,7 +290,7 @@ async function init() {
const user = await resp.json();
const initials = (user.username || '?').substring(0, 2).toUpperCase();
document.getElementById('p-avatar').textContent = initials;
_renderAvatar(user.image, initials);
document.getElementById('p-username').textContent = user.username;
document.getElementById('p-email').textContent = user.email || '';
@@ -63,6 +303,17 @@ async function init() {
badge.innerHTML = `<i class="fas fa-user"></i> ${i18n.t('profile.role_user')}`;
}
// Photo edit controls
const isLocal = !user.auth_provider || user.auth_provider === 'local';
const editBtn = document.getElementById('p-avatar-edit-btn');
const oidcNote = document.getElementById('p-avatar-oidc-note');
if (user.can_edit_image && isLocal) {
editBtn?.classList.remove('hidden');
} else if (!isLocal && user.image) {
// OIDC user with a photo: show note, no edit button
oidcNote?.classList.remove('hidden');
}
document.getElementById('p-detail-username').textContent = user.username;
document.getElementById('p-detail-email').textContent = user.email || '—';
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? i18n.t('profile.role_admin') : i18n.t('profile.role_user');
@@ -361,6 +612,9 @@ document.getElementById('app-pw-generate').addEventListener('click', createAppPa
document.getElementById('app-pw-copy-btn').addEventListener('click', copyAppPassword);
document.getElementById('app-pw-auto-toggle').addEventListener('click', toggleAutoPasswords);
/* Photo-edit panel — wired once at module load, not per init() call */
_setupPhotoEdit();
/* Re-render when language changes */
window.addEventListener('translationsLoaded', () => {
init();
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "أدخل تسمية",
"error_create_pw": "فشل إنشاء كلمة المرور",
"confirm_revoke": "إلغاء كلمة المرور \"{{label}}\"؟ ستتوقف العملاء عن العمل.",
"error_revoke": "فشل الإلغاء"
"error_revoke": "فشل الإلغاء",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "جارٍ الرفع...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Bitte Bezeichnung eingeben",
"error_create_pw": "App-Passwort erstellen fehlgeschlagen",
"confirm_revoke": "App-Passwort \"{{label}}\" widerrufen? Clients werden nicht mehr funktionieren.",
"error_revoke": "Widerrufen fehlgeschlagen"
"error_revoke": "Widerrufen fehlgeschlagen",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Wird hochgeladen...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Please enter a label",
"error_create_pw": "Failed to create app password",
"confirm_revoke": "Revoke app password \"{{label}}\"? Clients using this password will stop working.",
"error_revoke": "Failed to revoke app password"
"error_revoke": "Failed to revoke app password",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Uploading...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Introduce una etiqueta",
"error_create_pw": "Error al crear contraseña de aplicación",
"confirm_revoke": "¿Revocar contraseña \"{{label}}\"? Los clientes que la usen dejarán de funcionar.",
"error_revoke": "Error al revocar contraseña"
"error_revoke": "Error al revocar contraseña",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Subiendo...",
+14 -1
View File
@@ -660,7 +660,20 @@
"error_label_required": "لطفاً برچسب وارد کنید",
"error_create_pw": "ایجاد رمز ناموفق بود",
"confirm_revoke": "رمز «{{label}}» ابطال شود؟ کلاینت‌ها از کار می‌افتند.",
"error_revoke": "ابطال ناموفق بود"
"error_revoke": "ابطال ناموفق بود",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"notifications": {
"file_renamed": "فایل تغییر نام داد",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Veuillez entrer un libellé",
"error_create_pw": "Impossible de créer le mot de passe",
"confirm_revoke": "Révoquer le mot de passe \"{{label}}\" ? Les clients l'utilisant ne fonctionneront plus.",
"error_revoke": "Échec de la révocation"
"error_revoke": "Échec de la révocation",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Téléchargement en cours...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "कृपया एक लेबल दर्ज करें",
"error_create_pw": "ऐप पासवर्ड बनाने में विफल",
"confirm_revoke": "ऐप पासवर्ड \"{{label}}\" रद्द करें? इसका उपयोग करने वाले क्लाइंट काम करना बंद कर देंगे।",
"error_revoke": "रद्द करने में विफल"
"error_revoke": "रद्द करने में विफल",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "अपलोड हो रहा है...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Inserisci un'etichetta",
"error_create_pw": "Impossibile creare la password",
"confirm_revoke": "Revocare la password \"{{label}}\"? I client che la usano smetteranno di funzionare.",
"error_revoke": "Revoca fallita"
"error_revoke": "Revoca fallita",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Caricamento in corso...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "ラベルを入力してください",
"error_create_pw": "アプリパスワードの作成に失敗しました",
"confirm_revoke": "アプリパスワード「{{label}}」を失効させますか?使用中のクライアントは動作しなくなります。",
"error_revoke": "失効に失敗しました"
"error_revoke": "失効に失敗しました",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "アップロード中...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "라벨을 입력하세요",
"error_create_pw": "앱 비밀번호 생성 실패",
"confirm_revoke": "앱 비밀번호 \"{{label}}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 클라이언트가 작동하지 않게 됩니다.",
"error_revoke": "취소 실패"
"error_revoke": "취소 실패",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "업로드 중...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Voer een label in",
"error_create_pw": "App-wachtwoord aanmaken mislukt",
"confirm_revoke": "App-wachtwoord \"{{label}}\" intrekken? Clients die dit wachtwoord gebruiken zullen stoppen.",
"error_revoke": "Intrekken mislukt"
"error_revoke": "Intrekken mislukt",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Bezig met uploaden...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Wprowadź etykietę",
"error_create_pw": "Nie udało się utworzyć hasła aplikacji",
"confirm_revoke": "Unieważnić hasło aplikacji \"{{label}}\"? Klienci używający tego hasła przestaną działać.",
"error_revoke": "Nie udało się unieważnić hasła aplikacji"
"error_revoke": "Nie udało się unieważnić hasła aplikacji",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Przesyłanie...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Digite um rótulo",
"error_create_pw": "Falha ao criar senha de aplicativo",
"confirm_revoke": "Revogar senha \"{{label}}\"? Clientes que usam esta senha deixarão de funcionar.",
"error_revoke": "Falha ao revogar"
"error_revoke": "Falha ao revogar",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "A carregar...",
+14 -1
View File
@@ -677,7 +677,20 @@
"error_label_required": "Введите метку",
"error_create_pw": "Не удалось создать пароль",
"confirm_revoke": "Отозвать пароль «{{label}}»? Клиенты перестанут работать.",
"error_revoke": "Не удалось отозвать"
"error_revoke": "Не удалось отозвать",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Загрузка...",
+14 -1
View File
@@ -660,7 +660,20 @@
"error_label_required": "請輸入標籤",
"error_create_pw": "建立應用密碼失敗",
"confirm_revoke": "撤銷應用密碼\"{{label}}\"?使用此密碼的客戶端將停止工作。",
"error_revoke": "撤銷失敗"
"error_revoke": "撤銷失敗",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"notifications": {
"file_renamed": "檔案已重新命名",
+14 -1
View File
@@ -660,7 +660,20 @@
"error_label_required": "请输入标签",
"error_create_pw": "创建应用密码失败",
"confirm_revoke": "撤销应用密码\"{{label}}\"?使用此密码的客户端将停止工作。",
"error_revoke": "撤销失败"
"error_revoke": "撤销失败",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"notifications": {
"file_renamed": "文件已重命名",
+42 -1
View File
@@ -40,13 +40,54 @@
<div id="main-content" class="hidden">
<div class="profile-card">
<div class="avatar-section">
<div class="avatar-large" id="p-avatar">—</div>
<div class="avatar-large-wrap">
<div class="avatar-large" id="p-avatar">—</div>
<button class="avatar-edit-btn hidden" id="p-avatar-edit-btn" data-i18n-title="profile.edit_photo" title="Edit photo">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="avatar-info">
<h1 id="p-username">—</h1>
<div class="email" id="p-email">—</div>
<span class="role-badge" id="p-role-badge">—</span>
<p class="avatar-oidc-note hidden" id="p-avatar-oidc-note" data-i18n="profile.photo_managed_by_oidc">Photo managed by your identity provider.</p>
</div>
</div>
<!-- Photo edit panel (local accounts only) -->
<div class="avatar-edit-panel hidden" id="p-avatar-edit-panel">
<div class="avatar-edit-tabs">
<button class="avatar-tab active" id="p-tab-url" data-i18n="profile.photo_tab_url">URL</button>
<button class="avatar-tab" id="p-tab-upload" data-i18n="profile.photo_tab_upload">Upload</button>
</div>
<!-- URL input mode -->
<div class="avatar-tab-pane" id="p-pane-url">
<input type="url" id="p-image-url" class="avatar-url-input"
data-i18n-placeholder="profile.photo_url_placeholder"
placeholder="https://example.com/photo.jpg">
<small class="avatar-hint" data-i18n="profile.photo_url_hint">https://, http://, or data:image/…;base64,… accepted</small>
</div>
<!-- Upload mode -->
<div class="avatar-tab-pane hidden" id="p-pane-upload">
<label class="avatar-file-label" for="p-image-file">
<i class="fas fa-cloud-upload-alt"></i>
<span data-i18n="profile.photo_choose_file">Choose a photo (PNG, JPEG, WebP)</span>
</label>
<input type="file" id="p-image-file" class="avatar-file-input"
accept="image/png,image/jpeg,image/webp">
<img class="avatar-preview hidden" id="p-image-preview" alt="Preview" src="">
<small class="avatar-hint" data-i18n="profile.photo_resize_note">Images larger than 512 × 512 px are automatically resized.</small>
</div>
<div class="avatar-edit-actions">
<button class="btn btn-primary" id="p-avatar-save" data-i18n="profile.photo_save">Save</button>
<button class="btn btn-danger-sm" id="p-avatar-remove" data-i18n="profile.photo_remove">Remove photo</button>
<button class="btn btn-secondary" id="p-avatar-cancel" data-i18n="profile.photo_cancel">Cancel</button>
</div>
<div id="p-avatar-status"></div>
</div>
</div>
<div class="profile-card">
+8 -1
View File
@@ -1,3 +1,10 @@
end to end tests via playwright
Eend to end tests via playwright
to update playwright and chromium:
```
npm install @playwright/test@latest
npx playwright install chromium
```
+12 -12
View File
@@ -9,18 +9,18 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.59.1",
"@playwright/test": "^1.60.0",
"@types/node": "^25.6.0"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -55,13 +55,13 @@
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -74,9 +74,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+2 -2
View File
@@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"pretest": "bash ../common/spawn-db.sh",
"test": "npx playwright test --reporter=line,github --trace=on-first-retry scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
"test": "FORCE_COLOR=true npx playwright test scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
"posttest": "bash ../common/stop-db.sh"
},
"keywords": [],
@@ -13,7 +13,7 @@
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.59.1",
"@playwright/test": "^1.60.0",
"@types/node": "^25.6.0"
}
}
+6 -3
View File
@@ -27,9 +27,9 @@ export default defineConfig({
testDir: './scenarios',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
retries: 0,
workers: 1,
reporter: process.env.CI ? [['github'], ['html']] : 'html',
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [ ['list'], ['html']],
globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
@@ -37,10 +37,13 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:8087',
trace: 'on-first-retry',
headless: true,
// take a screenshot on failure
screenshot: 'only-on-failure',
},
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.02 },
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
},
projects: [
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

@@ -73,7 +73,7 @@ test.describe('Folder management', () => {
});
test('folder creation', async ({ page }) => {
const name = `Test folder ${Date.now()}`;
const name = `Test folder creation`;
await submitNewFolder(page, name);
@@ -92,7 +92,7 @@ test.describe('Folder management', () => {
});
test('folder reject if already exists', async ({ page }) => {
const name = `Test folder ${Date.now()}`;
const name = `Test existing folder`;
// Prerequisite: create the folder once successfully.
await submitNewFolder(page, name);
@@ -122,9 +122,8 @@ test.describe('Folder management', () => {
});
test('folder rename', async ({ page }) => {
const ts = Date.now();
const original = `Test folder ${ts}`;
const renamed = `Renamed folder ${ts}`;
const original = `Test folder`;
const renamed = `Renamed folders`;
// Prerequisite: create the folder to rename.
await submitNewFolder(page, original);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+13 -1
View File
@@ -7,7 +7,17 @@ export const TEST_ADMIN = {
};
/**
* Log in as the test admin and wait until the main app is ready.
* Log in as the test admin and wait until the main app is fully initialized.
*
* We wait for two things after the login redirect:
* 1. `#sidebar` — confirms the main HTML has loaded.
* 2. `#user-avatar-btn .user-vignette` — confirms that `setupUserMenu()` has
* run and mounted the avatar vignette. This is the earliest reliable
* signal that the click-handler on the avatar button is attached, so any
* subsequent test that opens the user menu will not race against JS startup.
*
* Without (2), CI (Ubuntu + Xvfb) occasionally clicks the button before the
* event listener is registered because the JS runtime is slower than on macOS.
*/
export async function loginAsAdmin(page: Page) {
await goToLoginPage(page);
@@ -15,6 +25,8 @@ export async function loginAsAdmin(page: Page) {
await page.locator('#login-password').fill(TEST_ADMIN.password);
await page.locator('#login-panel button[type="submit"]').click();
await expect(page.locator('#sidebar')).toBeVisible({ timeout: 15_000 });
// Wait for the JS app to initialise: avatar vignette present ⟹ click handler attached.
await expect(page.locator('#user-avatar-btn .user-vignette')).toBeAttached({ timeout: 10_000 });
}
/**