Merge pull request #394 from EdouardVanbelle/feat/user-image-avatar
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors/"]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -17,6 +17,8 @@ pub struct UserDto {
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
pub active: bool,
|
||||
pub auth_provider: String,
|
||||
pub image: Option<String>,
|
||||
pub can_edit_image: bool,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
@@ -33,6 +35,8 @@ impl From<User> for UserDto {
|
||||
last_login_at: user.last_login_at(),
|
||||
active: user.is_active(),
|
||||
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
||||
image: user.image().map(|s| s.to_string()),
|
||||
can_edit_image: !user.is_oidc_user(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ pub struct OidcIdClaims {
|
||||
pub preferred_username: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
pub picture: Option<String>,
|
||||
}
|
||||
|
||||
/// Port for OIDC operations — implemented in infrastructure layer
|
||||
|
||||
@@ -647,6 +647,53 @@ impl AuthApplicationService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the profile image for a non-OIDC user.
|
||||
pub async fn update_user_image(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
image: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
let user = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
|
||||
if user.is_oidc_user() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"Avatar is managed by your identity provider and cannot be changed here",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref img) = image {
|
||||
const MAX_BYTES: usize = 524_288; // 512 KiB
|
||||
if img.len() > MAX_BYTES {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Image exceeds maximum allowed size (512 KiB)",
|
||||
));
|
||||
}
|
||||
let valid = img.starts_with("https://")
|
||||
|| img.starts_with("http://")
|
||||
|| img.starts_with("data:image/png;base64,")
|
||||
|| img.starts_with("data:image/webp;base64,")
|
||||
|| img.starts_with("data:image/jpeg;base64,");
|
||||
if !valid {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Image must be an https/http URL or a data URI (png, webp, jpeg)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.user_storage
|
||||
.update_image(caller_id, image)
|
||||
.await
|
||||
.map_err(DomainError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
|
||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
Ok(UserDto::from(user))
|
||||
@@ -1128,8 +1175,9 @@ impl AuthApplicationService {
|
||||
.await
|
||||
{
|
||||
Ok(mut existing_user) => {
|
||||
// User exists — update last login
|
||||
// User exists — update last login and sync avatar from IdP
|
||||
existing_user.register_login();
|
||||
existing_user.set_image(claims.picture.clone());
|
||||
self.user_storage.update_user(existing_user.clone()).await?;
|
||||
existing_user
|
||||
}
|
||||
@@ -1206,7 +1254,7 @@ impl AuthApplicationService {
|
||||
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
|
||||
}
|
||||
|
||||
let new_user = User::new_oidc(
|
||||
let mut new_user = User::new_oidc(
|
||||
username.clone(),
|
||||
oidc_email,
|
||||
role,
|
||||
@@ -1221,6 +1269,7 @@ impl AuthApplicationService {
|
||||
format!("Failed to create OIDC user: {}", e),
|
||||
)
|
||||
})?;
|
||||
new_user.set_image(claims.picture.clone());
|
||||
|
||||
let created_user = self.user_storage.create_user(new_user).await?;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct User {
|
||||
active: bool,
|
||||
oidc_provider: Option<String>,
|
||||
oidc_subject: Option<String>,
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
@@ -83,6 +84,7 @@ impl User {
|
||||
active: true,
|
||||
oidc_provider: None,
|
||||
oidc_subject: None,
|
||||
image: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,6 +114,7 @@ impl User {
|
||||
active: true,
|
||||
oidc_provider: Some(oidc_provider),
|
||||
oidc_subject: Some(oidc_subject),
|
||||
image: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,6 +146,7 @@ impl User {
|
||||
active,
|
||||
oidc_provider: None,
|
||||
oidc_subject: None,
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +165,7 @@ impl User {
|
||||
active: bool,
|
||||
oidc_provider: Option<String>,
|
||||
oidc_subject: Option<String>,
|
||||
image: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -176,6 +181,7 @@ impl User {
|
||||
active,
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +238,15 @@ impl User {
|
||||
self.oidc_subject.as_deref()
|
||||
}
|
||||
|
||||
pub fn image(&self) -> Option<&str> {
|
||||
self.image.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_image(&mut self, image: Option<String>) {
|
||||
self.image = image;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Returns true if this is an OIDC-only user (no password)
|
||||
pub fn is_oidc_user(&self) -> bool {
|
||||
self.oidc_provider.is_some()
|
||||
|
||||
@@ -42,6 +42,28 @@ impl UserPgRepository {
|
||||
_ => UserRepositoryError::DatabaseError(format!("Database error: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a user's profile image (URL or data URI). Not part of the
|
||||
/// `UserRepository` trait — called directly from `AuthApplicationService`.
|
||||
pub async fn update_image(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
image: Option<String>,
|
||||
) -> UserRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET image = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&image)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRepository for UserPgRepository {
|
||||
@@ -105,11 +127,11 @@ impl UserRepository for UserPgRepository {
|
||||
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -140,6 +162,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -147,11 +170,11 @@ impl UserRepository for UserPgRepository {
|
||||
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -182,6 +205,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -189,11 +213,11 @@ impl UserRepository for UserPgRepository {
|
||||
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
@@ -224,6 +248,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -238,7 +263,7 @@ impl UserRepository for UserPgRepository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET
|
||||
SET
|
||||
username = $2,
|
||||
email = $3,
|
||||
password_hash = $4,
|
||||
@@ -247,7 +272,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_used_bytes = $7,
|
||||
updated_at = $8,
|
||||
last_login_at = $9,
|
||||
active = $10
|
||||
active = $10,
|
||||
image = $11
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -261,6 +287,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.updated_at())
|
||||
.bind(user_clone.last_login_at())
|
||||
.bind(user_clone.is_active())
|
||||
.bind(user_clone.image())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -323,11 +350,11 @@ impl UserRepository for UserPgRepository {
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
@@ -363,6 +390,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -378,7 +406,7 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
WHERE username ILIKE $1 OR email ILIKE $1
|
||||
ORDER BY username
|
||||
@@ -414,6 +442,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -496,11 +525,11 @@ impl UserRepository for UserPgRepository {
|
||||
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
WHERE role::text = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -535,6 +564,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -566,11 +596,11 @@ impl UserRepository for UserPgRepository {
|
||||
) -> UserRepositoryResult<User> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, image
|
||||
FROM auth.users
|
||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||
"#,
|
||||
@@ -601,6 +631,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ struct IdTokenClaims {
|
||||
name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
nonce: Option<String>,
|
||||
picture: Option<String>,
|
||||
// Standard JWT fields
|
||||
#[allow(dead_code)]
|
||||
iss: Option<String>,
|
||||
@@ -90,6 +91,7 @@ struct UserInfoResponse {
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -460,6 +462,7 @@ impl OidcServicePort for OidcService {
|
||||
preferred_username: claims.preferred_username,
|
||||
name: claims.name,
|
||||
groups: claims.groups.unwrap_or_default(),
|
||||
picture: claims.picture,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -511,6 +514,7 @@ impl OidcServicePort for OidcService {
|
||||
preferred_username: info.preferred_username,
|
||||
name: info.name,
|
||||
groups: info.groups.unwrap_or_default(),
|
||||
picture: info.picture,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::common::di::AppState;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUserId;
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Public auth routes — no authentication required.
|
||||
pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
||||
@@ -34,6 +36,7 @@ pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
||||
pub fn auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/me/image", put(update_user_image))
|
||||
.route("/change-password", put(change_password))
|
||||
.route("/logout", post(logout))
|
||||
}
|
||||
@@ -320,6 +323,13 @@ async fn get_current_user(
|
||||
Ok((StatusCode::OK, Json(user)))
|
||||
}
|
||||
|
||||
/// DTO for updating the user's profile image.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateUserImageDto {
|
||||
/// 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,
|
||||
|
||||
@@ -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:; \
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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": "جارٍ الرفع...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "فایل تغییر نام داد",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "अपलोड हो रहा है...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "アップロード中...",
|
||||
|
||||
@@ -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": "업로드 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Загрузка...",
|
||||
|
||||
@@ -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": "檔案已重新命名",
|
||||
|
||||
@@ -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": "文件已重命名",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
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);
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 59 KiB |
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||