From 00e831ea83afa30d3de14b46f957164e05cadef5 Mon Sep 17 00:00:00 2001 From: Dessalines39394 <245616256+Dessalines39394@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:12:08 +0000 Subject: [PATCH 001/144] fix(docs): restore Star History chart with a working provider The Star History chart was broken because GitHub stargazer API restrictions disabled the previous service. Point the chart at a working alternative so the README graph renders again. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 096cdc00..0ff14ec5 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,11 @@ OxiCloud is a community-driven project, and we appreciate all contributions. Che ## Star History - + - - - Star History Chart + + + Star History Chart From 20e6e05bb454a4ae56ceba0049c0c5f9e5d89f6f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 19 Aug 2026 10:10:39 +0200 Subject: [PATCH 002/144] feat(sessions): identify online sessions (connected users) identify online session by writing the `last_seen_at` information is stored in a map and flush each 30s to prevent performance impact on pgsql --- Cargo.toml | 2 +- docs/plan/sessions.md | 401 ++++++++++++++++++ .../20261014000000_sessions_last_seen_at.sql | 28 ++ src/application/dtos/session_dto.rs | 180 +++++++- src/application/ports/auth_ports.rs | 9 + .../services/auth_application_service.rs | 74 ++-- .../services/device_auth_service.rs | 11 +- src/common/di.rs | 43 ++ src/domain/entities/session.rs | 18 + .../repositories/pg/session_pg_repository.rs | 12 +- src/infrastructure/services/jwt_service.rs | 73 +++- .../services/last_seen_tracker.rs | 239 +++++++++++ src/infrastructure/services/mod.rs | 2 + .../services/session_liveness_gauges.rs | 169 ++++++++ src/interfaces/middleware/auth.rs | 21 + src/main.rs | 72 ++++ 16 files changed, 1317 insertions(+), 37 deletions(-) create mode 100644 docs/plan/sessions.md create mode 100644 migrations/20261014000000_sessions_last_seen_at.sql create mode 100644 src/infrastructure/services/last_seen_tracker.rs create mode 100644 src/infrastructure/services/session_liveness_gauges.rs diff --git a/Cargo.toml b/Cargo.toml index 346ba04a..6f3d85ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ mimalloc = { version = "0.1.52", default-features = false } axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] } # "process" was previously enabled implicitly through aws-config's feature # unification; ffmpeg_video_frame_service needs it, so declare it ourselves. -tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process"] } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] } tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] } tokio-stream = { version = "0.1.18", features = ["fs", "sync"] } bytes = "1.11.1" diff --git a/docs/plan/sessions.md b/docs/plan/sessions.md new file mode 100644 index 00000000..1ce0c0cc --- /dev/null +++ b/docs/plan/sessions.md @@ -0,0 +1,401 @@ +# Session Liveness Tracking — `last_seen_at` + Prometheus + +Track per-session and per-user "currently active" signals cheaply, and +expose them as Prometheus gauges so an operator (demo instance, +production) can plot concurrency over time. Also unlocks future +features that need "when was this session last used" (idle-timeout +enforcement, per-user session-limit quotas, admin dashboard freshness). + +Companion doc for the design decisions covered here — narrative on the +overall session model lives in +[docs/architecture/auth-model.md](../architecture/auth-model.md). + +## Purpose — what we want to see + +Two distinct signals, deliberately separate: + +1. **Online sessions** (`oxicloud_sessions_online`) — count of + non-revoked `auth.sessions` rows that had a request within the last + N minutes. One user with three devices (browser + phone + Nextcloud + desktop) contributes **three** to this count. Useful for + provisioning ("how many concurrent connections do I need to + support?") and load-shape planning. + +2. **Online users** (`oxicloud_sessions_online_users`) — count of + DISTINCT `user_id` values behind those online sessions. Same + three-device user contributes **one** to this count. Useful for + billing shape ("how many humans are actually using the system?") + and for the demo landing page's "N users online right now" widget. + +The gap between the two IS the multi-device factor. A healthy system +where users routinely have web + desktop client should show `sessions +≈ 2 × users`. A sudden `sessions >> users × 3` is a signal — an app +that opens fresh sessions instead of reusing them, or a +credential-stuffing pattern. + +### Terminology — "online" vs "active" + +The admin sessions panel already has a lifecycle filter +`Active | Expired | Revoked` — where **active** means +`!revoked && !expired` (row is still usable). That's orthogonal to +"had a request lately", so both concepts fighting for the same word +was going to confuse admins reading the panel. + +**Decision (Ed, 2026-08-18)** — "online" is the *presence* signal +throughout the stack: + +- **UI**: green-dot badge next to each row when + `SessionSummaryDto::is_online == true`; grey dot + "last seen X ago" + otherwise. Lifecycle filter stays `Active | Expired | Revoked` + unchanged. +- **DTO**: `is_online: bool` on `SessionSummaryDto`, computed + server-side (avoids the SPA doing clock math and drifting from the + server view). Guaranteed `false` on revoked / expired rows so an + admin never sees "Online" on a row they just revoked. +- **Metrics**: `oxicloud_sessions_online` / `_online_users`. +- **Threshold**: single `pub const ONLINE_WINDOW` in + `application/dtos/session_dto.rs` — DTO derivation AND gauge query + read from the same constant so the per-row badge count and the + gauge aggregate stay consistent by construction. + +## Why the existing signals don't answer this — DECIDED + +`auth.sessions.created_at` is the closest existing proxy. But it +moves on **session rotation**, not per-request: + +- Sessions rotate on every silent refresh (`apiFetch`'s 401 → refresh + path). Rotation cadence = `access_token_expiry_secs` (default + 3600, i.e. 1 h). +- So `WHERE created_at > NOW() - INTERVAL '1 hour'` catches everyone + who refreshed in the last cycle — but a user who's actively clicking + around for 45 min hasn't rotated yet, so their `created_at` is 45 + min old. Threshold `< 30 min` false-negatives them. +- Resolution is capped at the access-token TTL. At the recommended + prod value of 15 min, `created_at` gives 15-min granularity. At the + test value of 60 s it's near-real-time — but no operator wants + to force 60 s token TTL just for observability. + +So `created_at` is an OK first-pass proxy but bad enough that we +should add a dedicated column that moves per-request. + +## Schema — `last_seen_at` + +Migration `_sessions_last_seen_at.sql`: + +```sql +ALTER TABLE auth.sessions + ADD COLUMN last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Partial index — the "active in the last N min" query is the only +-- reason to scan this column, and it always filters on revoked = FALSE. +-- Full index would double the write cost for zero read benefit. +CREATE INDEX idx_sessions_last_seen_at ON auth.sessions(last_seen_at) + WHERE revoked = FALSE; +``` + +Default `NOW()` — existing rows on migration land at "just seen" +which is slightly optimistic but the alternative (NULL / epoch) makes +every historic session count as long-idle for the first N min after +deploy. `NOW()` matches "assume everyone's active" and the metric +converges to reality within one N-min bucket. + +**Not in scope for this schema**: adding `last_seen_at` to +`auth.app_passwords`. Nextcloud-desktop clients authenticate through +that table; separate concern, separate PR if we want desktop-client +liveness. + +## Coalescing writes — DECIDED: in-process DashMap + periodic flush + +Naive shape (one `UPDATE` per authenticated request) would multiply +the DB write rate by every non-mutating request the SPA fires +(listing pages, thumbnails, delta-upload chunk PUTs). Untenable. + +The pattern that scales: + +- Middleware stamps `(session_id, Instant::now())` into a shared + `DashMap>`. **O(1)** per request, no I/O. +- Background task drains the map every N seconds (default 30) and + emits ONE batched `UPDATE ... FROM UNNEST(...)` covering every + distinct session seen in the window. +- The map data structure IS the dedup: same `session_id` → same key + → last write wins. A session seen 100× in the window contributes + ONE row to the batched update, with the latest timestamp. + +The `UPDATE` uses `greatest()` to be idempotent under any +retry/race/clock-skew: + +```sql +UPDATE auth.sessions AS s +SET last_seen_at = greatest(s.last_seen_at, t.seen_at) +FROM UNNEST($1::uuid[], $2::timestamptz[]) AS t(id, seen_at) +WHERE s.id = t.id; +``` + +**Restart durability**: up to one flush interval of activity lost on +hard crash. On graceful shutdown (SIGTERM) run one final flush +synchronously before exit — zero loss on planned rolling restarts. + +**Failure durability**: if the batched UPDATE fails (PG blip), +DON'T clear the DashMap; next tick retries with the accumulated set +overlaid on any new activity. + +### Why not `Arc>` + +Every authenticated request writes. Under any concurrency (delta +uploads, thumbnail bursts, folder listing paginations firing in +parallel) a single mutex becomes the bottleneck. `DashMap`'s +per-shard locking (16-32 shards by default) parallelises writes +across distinct keys — different session_ids don't contend. + +### Why not `Arc>` + +The workload is write-heavy (every auth'd request writes, reads only +fire in the flusher). RwLock would still serialize the writes for no +benefit. + +### Why not PG `NOTIFY` / `LISTEN` + +Considered. Trade-offs: + +- **Pro**: cross-instance coalescing — multiple OxiCloud processes + behind a load balancer push to one channel, single flusher owns + writes. +- **Con**: every request pays a PG round-trip (`SELECT + pg_notify(...)`) — ~1 ms per request vs ~50 ns for DashMap insert. + On hot endpoints (delta chunk PUTs, thumbnails) this is + measurable. +- **Con**: adds a persistent LISTEN connection to the pool. +- **Con**: OxiCloud is single-instance today. The multi-instance win + doesn't apply. + +**Deferred**: if OxiCloud ever grows a multi-instance deployment +story (Kubernetes, active-active behind a load balancer), migrate +the flusher to `NOTIFY`-based ingest — schema stays identical, only +the tracker implementation swaps. Document the migration path in +[Future — multi-instance](#future--multi-instance) below. + +## Metric surface — Prometheus + +Exposed via the existing `/metrics` endpoint (see +`src/interfaces/metrics.rs`; gated on `OXICLOUD_METRICS_LISTEN`). + +### Gauges (polled every 30 s from a background task) + +``` +# HELP oxicloud_sessions_online Non-revoked sessions seen in the last N min. +# TYPE oxicloud_sessions_online gauge +oxicloud_sessions_online + +# HELP oxicloud_sessions_online_users Distinct users behind online sessions. +# TYPE oxicloud_sessions_online_users gauge +oxicloud_sessions_online_users + +# HELP oxicloud_sessions_total_non_revoked Total non-revoked sessions +# regardless of activity — the long tail (mobile clients still holding +# refresh tokens they haven't used in weeks). +# TYPE oxicloud_sessions_total_non_revoked gauge +oxicloud_sessions_total_non_revoked +``` + +Queries powering each: + +```sql +-- oxicloud_sessions_online +SELECT COUNT(*) FROM auth.sessions +WHERE revoked = FALSE + AND last_seen_at > NOW() - $1::interval; -- $1 = ONLINE_WINDOW + +-- oxicloud_sessions_online_users +SELECT COUNT(DISTINCT user_id) FROM auth.sessions +WHERE revoked = FALSE + AND last_seen_at > NOW() - $1::interval; + +-- oxicloud_sessions_total_non_revoked +SELECT COUNT(*) FROM auth.sessions WHERE revoked = FALSE; +``` + +All three run on the maintenance pool (background polling shouldn't +compete with request-serving connections). Three lightweight +`COUNT(*)` reads every 30 s; measured cost negligible even on +tens-of-thousands-of-rows tables thanks to the partial index. + +### Counters (already in-place shape) + +`oxicloud_sessions_created_total` and +`oxicloud_sessions_revoked_total{reason}` — extend the existing +counter surface in the auth service (`session.created` audit line +sites) to also `metrics::counter!(...)`. Not strictly needed for the +"how many active" question but useful sanity signal on the +dashboard: rate of creation vs rate of revocation should be +approximately balanced at steady state. + +## Config surface + +**No new env var.** Ed's call (2026-08-18): tuning the online window +is a deployment-shape question we haven't had to answer in practice, +and adding an env knob invites premature customization. The three +knobs stay hardcoded: + +- **Online window** — 5 min. Feels responsive for a demo + landing page without over-fluctuating with tab-open-then-close + blips. Lives at `pub const ONLINE_WINDOW` in + `src/application/dtos/session_dto.rs`; the gauges module in + `src/infrastructure/services/session_liveness_gauges.rs` reads + from that constant so the DTO badge and the gauge aggregate + can't drift. +- **Flush interval** — 30 s. Balances DB write load against gauge + freshness (typical Prometheus scrape at 15 s sees the value + refreshed after at most two scrapes). Lives at `FLUSH_INTERVAL` + in `src/infrastructure/services/last_seen_tracker.rs`. +- **DashMap shard count** — crate default (16). Only worth + surfacing when profiling shows shard contention. + +## Middleware wiring + +Auth extractor (`CurrentUserId`) already loads the session by +refresh-token cookie / bearer-token subject. Extend the post-load +path: + +```rust +// After successful session lookup + auth checks: +state.last_seen_tracker.stamp(session.id); +``` + +`LastSeenTracker` shape: + +```rust +pub struct LastSeenTracker { + seen: Arc>>, + pool: Arc, +} + +impl LastSeenTracker { + pub fn new(pool: Arc) -> Arc { + let seen = Arc::new(DashMap::new()); + let this = Arc::new(Self { seen: seen.clone(), pool: pool.clone() }); + tokio::spawn(this.clone().flush_loop()); + this + } + + /// Called from the auth middleware on every authenticated request. + /// O(1); no I/O; no round-trip. + pub fn stamp(&self, session_id: Uuid) { + self.seen.insert(session_id, Utc::now()); + } + + /// Called from the graceful-shutdown handler. + pub async fn flush_now(&self) -> Result<(), sqlx::Error> { + /* drain + one batched UPDATE, same as the loop body */ + } + + async fn flush_loop(self: Arc) { + let mut ticker = tokio::time::interval(Duration::from_secs(30)); + loop { + ticker.tick().await; + let _ = self.flush_now().await; // errors logged, not propagated + } + } +} +``` + +Wired in `common/di.rs`; injected into `AppState` and referenced by +the auth extractor. + +## Graceful shutdown + +Hook into the existing SIGTERM handler in `main.rs` to call +`tracker.flush_now().await` before the runtime exits. Ensures rolling +restarts / container replacements don't lose the last 30 s of +liveness data. + +## Admin dashboard integration (deferred, sibling PR) + +Once `last_seen_at` exists, the admin sessions panel can render a +"last seen X min ago" column instead of only "created X ago". Small +follow-up — not part of this plan's scope, but the schema addition +unlocks it. + +## Testing + +Two hermetic units (no DB): + +1. `DashMap` dedup test — insert same key 10× with different + timestamps, drain, assert one entry with the latest timestamp. +2. `flush_now()` UPDATE-shape test — mock a `PgExecutor`, assert the + batched UNNEST binds match the drained set. Uses `sqlx-mock` or + equivalent. + +One integration (real PG, gated on `integration_tests` cfg): + +3. End-to-end — insert a session row, `stamp()` it, call `flush_now`, + assert `last_seen_at > created_at`. Covers the whole write path + including the `greatest()` guard. + +## Phasing + +1. **Migration** — add column + partial index. Ships alone; zero + application-layer impact. Reversible via `DROP COLUMN`. ✅ **2026-08-18** + (`migrations/20261014000000_sessions_last_seen_at.sql`). +2. **`LastSeenTracker` service** — DashMap + flusher task. Wire into + `AppState`. Middleware calls `stamp()`. Now `last_seen_at` moves + in real time. ✅ **2026-08-18** (`src/infrastructure/services/last_seen_tracker.rs`). +3. **JWT `sid` claim** — token minters carry the fresh session's + id; auth middleware reads it and stamps with no DB round trip. + New tokens carry it, old tokens still validate (Option → no-op). + ✅ **2026-08-18** (extends `TokenClaims::sid` on + `application/ports/auth_ports.rs`). +4. **Prometheus gauges** — background poller updates the three + gauges every 30 s using the queries above. Gated on + `OXICLOUD_METRICS_LISTEN` (no recorder → no periodic PG hits). + ✅ **2026-08-18** (`src/infrastructure/services/session_liveness_gauges.rs`). +5. **Graceful-shutdown flush** — hook into SIGTERM handler. + ✅ **2026-08-18** (added `shutdown_signal` + + `with_graceful_shutdown` in `main.rs`). +6. **Session DTO exposes `last_seen_at` + `is_online`** — + `GET /api/admin/sessions` returns both so the admin table + external + monitors can read them. `is_online` is the server-side derivation + against `ONLINE_WINDOW` (see terminology decision above). ✅ + **2026-08-18** (`application/dtos/session_dto.rs`). +7. **Admin dashboard "Online" column** (deferred to a sibling PR) — + render `is_online` as a green/grey dot next to each row plus + "last seen X ago" from `last_seen_at`. + +## Future — multi-instance + +If OxiCloud grows a multi-instance deployment story (K8s replicaset, +active-active behind a load balancer), the in-process DashMap becomes +insufficient — each process holds its own map, N flushers race +UPDATEs, coalescing across instances doesn't happen. + +Migration path when that becomes real: + +1. Keep the schema (`last_seen_at` column + partial index). +2. Replace the `LastSeenTracker::stamp()` in-process insert with a + `SELECT pg_notify('oxicloud_session_seen', $session_id)` call. +3. Move the flusher into a **single elected worker** (leader election + via advisory lock in PG). That worker `LISTEN`s the channel, + accumulates into an in-process HashMap, flushes on the same 30 s + ticker. +4. Every OxiCloud instance publishes; one instance consumes. + +Trade-off: NOTIFY costs a PG round-trip per request (~1 ms) vs the +current ~50 ns DashMap insert. Only pay that when multi-instance +coalescing actually matters. Schema and gauge queries stay identical; +only the tracker implementation swaps. + +## Open questions + +1. ~~Definition of "active"~~ — DECIDED 2026-08-18: hardcoded to + 5 min, no env var. See [Config surface](#config-surface). +2. **Should the gauge query drop long-inactive sessions?** — a + Nextcloud desktop client checking in every 6 h qualifies as + "active" if the window is 6 h. Probably want two separate metrics + (web-active < 5 min AND dav-active < 6 h) but scope creep for the + initial ship. +3. **Multi-tab dedup on the UI side** — three browser tabs of the + same user share ONE session (same cookies, same refresh token, + same row). Naturally deduped at the DB layer — no action needed. +4. **App-password rows** — Nextcloud desktop / mobile clients use + `auth.app_passwords` on top of the session model. Whether they + should get their own `last_seen_at` (and a companion metric) is + deferred; separate concern, separate PR. diff --git a/migrations/20261014000000_sessions_last_seen_at.sql b/migrations/20261014000000_sessions_last_seen_at.sql new file mode 100644 index 00000000..40b36c1d --- /dev/null +++ b/migrations/20261014000000_sessions_last_seen_at.sql @@ -0,0 +1,28 @@ +-- Session liveness tracking — per-request `last_seen_at` stamp on +-- `auth.sessions`, moved by the in-process `LastSeenTracker` +-- (see `src/application/services/last_seen_tracker.rs`) via a +-- batched UPDATE every 30 s. +-- +-- Distinct from `created_at`: that column moves on session ROTATION +-- (every silent refresh), so its resolution is capped at the +-- access-token TTL (default 3600 s). `last_seen_at` moves on every +-- authenticated request, so the "active in the last N min" query +-- underlying `oxicloud_sessions_active` / `_active_users` gauges is +-- accurate to the flusher's 30 s cadence regardless of token TTL. +-- +-- See `docs/plan/sessions.md` for the full design (why DashMap + +-- periodic flush, why partial index, why `NOW()` default). + +ALTER TABLE auth.sessions + ADD COLUMN last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Partial index — the only reads on this column are the gauge +-- queries in `session_liveness_gauges.rs`, and they always filter +-- `revoked = FALSE`. Indexing only unrevoked rows keeps the write +-- cost of the 30 s batched UPDATE flat: rotated / revoked rows +-- fall out of the index automatically when `revoked` flips to TRUE +-- (partial-index maintenance drops them, no re-scan). A full +-- b-tree on the column would double index size for zero read +-- benefit — every gauge query would skip the revoked half anyway. +CREATE INDEX idx_sessions_last_seen_at ON auth.sessions(last_seen_at) + WHERE revoked = FALSE; diff --git a/src/application/dtos/session_dto.rs b/src/application/dtos/session_dto.rs index 87fe23ba..170457ee 100644 --- a/src/application/dtos/session_dto.rs +++ b/src/application/dtos/session_dto.rs @@ -14,6 +14,8 @@ //! separate batch fetch (extra round-trip). Frontend cross-references //! `user_id` against its cached user list. +use std::time::Duration; + use chrono::{DateTime, Utc}; use serde::Serialize; use utoipa::ToSchema; @@ -21,6 +23,20 @@ use uuid::Uuid; use crate::domain::entities::session::{Session, SessionOrigin}; +/// The "recently seen" threshold that turns a session's +/// `last_seen_at` into a green-dot "Online" badge on the admin +/// sessions panel — AND the same window that drives the +/// `oxicloud_sessions_online[_users]` Prometheus gauges (see +/// `src/infrastructure/services/session_liveness_gauges.rs`). +/// The two MUST agree so the dashboard's per-row badge count +/// matches the gauge's aggregate — one source of truth here. +/// +/// 5 min feels responsive without over-fluctuating with +/// tab-open-then-close blips. Deliberately hardcoded, not an +/// env var — see `docs/plan/sessions.md` §"Config surface" for +/// the reasoning. +pub const ONLINE_WINDOW: Duration = Duration::from_secs(5 * 60); + /// Authenticated-caller context — the caller's identity + session- /// bound signals a service method might key off. Constructed at the /// handler boundary from `AuthUser` and passed through unchanged; @@ -53,6 +69,17 @@ pub struct SessionSummaryDto { pub user_id: Uuid, pub created_at: DateTime, pub expires_at: DateTime, + /// Wall-clock time this session was last observed serving an + /// authenticated request. Moved forward per request by the + /// in-process [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker) + /// via a batched UPDATE every 30 s — so this value trails the + /// true "last seen" by at most one flush interval on a running + /// server. On DB read it always converges after a graceful + /// shutdown flush. Distinct from `created_at`: that only moves + /// on session rotation (silent refresh), so its resolution is + /// capped at the access-token TTL. The admin table renders a + /// "last seen X ago" column off this field. + pub last_seen_at: DateTime, pub ip_address: Option, pub user_agent: Option, /// `true` iff the session is DPoP-bound. Rendered as a lock icon @@ -68,7 +95,24 @@ pub struct SessionSummaryDto { pub is_revoked: bool, /// Whether this row is currently usable — `!revoked && expires_at > now()`. /// Kept server-side so the SPA doesn't drift if the browser clock is off. + /// **Distinct from [`is_online`](Self::is_online)** — this is a + /// *lifecycle* signal (row still has authority), that one is a + /// *presence* signal (a request landed on it lately). pub is_active: bool, + /// Whether the session was actually observed serving a request in the + /// last [`ONLINE_WINDOW`] (5 min). Presence signal, orthogonal to + /// [`is_active`](Self::is_active): a session may be active-and-online + /// (green dot in the admin table), active-and-idle (no dot, "last + /// seen 12 min ago"), or non-active-and-offline (expired / revoked + /// rows are never online). Derived server-side against + /// [`ONLINE_WINDOW`] so the row-level badge stays consistent with + /// the `oxicloud_sessions_online[_users]` Prometheus aggregates. + /// + /// Guaranteed `false` for revoked / expired rows — those short- + /// circuit before the recency check so a revoked row that happened + /// to receive a request in its final second before revocation + /// doesn't confusingly render "Online" post-revocation. + pub is_online: bool, // NOTE: no `oidc_sid` / `oidc_sid_prefix` field. The IdP-emitted // sid identifies the row's upstream session and stays server-side // (used by Back-Channel Logout matching). Exposing even a prefix @@ -104,23 +148,40 @@ impl SessionSummaryDto { pub fn from_session(s: Session, caller_jkt: Option<&str>) -> Self { let is_revoked = s.is_revoked(); let is_expired = s.is_expired(); + let is_active = !is_revoked && !is_expired; let jkt = s.dpop_jkt().map(|s| s.to_owned()); let dpop_jkt_prefix = jkt.as_ref().map(|t| t.chars().take(8).collect::()); let is_current = match (jkt.as_deref(), caller_jkt) { (Some(row), Some(caller)) => row == caller, _ => false, }; + // Presence check gated on lifecycle — a revoked or expired + // row's `last_seen_at` may still be fresh (the last request + // that arrived just before revocation), but calling it + // "Online" post-revocation would confuse an admin reading + // the panel. Short-circuit on !is_active. + let online_cutoff = match chrono::Duration::from_std(ONLINE_WINDOW) { + Ok(d) => Utc::now() - d, + // Cast can only fail on a Duration too large for i64 + // milliseconds; not reachable with our 5 min constant. + // Fall back to "never online" rather than panic — a + // wrong badge is fixable, a request-path panic is not. + Err(_) => DateTime::::MAX_UTC, + }; + let is_online = is_active && s.last_seen_at() > online_cutoff; Self { id: s.id(), user_id: s.user_id(), created_at: s.created_at(), expires_at: s.expires_at(), + last_seen_at: s.last_seen_at(), ip_address: s.ip_address().map(str::to_owned), user_agent: s.user_agent().map(str::to_owned), is_bound: jkt.is_some(), dpop_jkt_prefix, is_revoked, - is_active: !is_revoked && !is_expired, + is_active, + is_online, origin: s.origin(), is_current, } @@ -166,6 +227,7 @@ mod tests { Some(sid.to_string()), None, crate::domain::entities::session::SessionOrigin::Oidc, + Utc::now(), ) } @@ -241,6 +303,121 @@ mod tests { assert!(dto.is_active); } + #[test] + fn dto_exposes_last_seen_at() { + // Regression: the admin table renders "last seen X ago" + // straight off this field, and clients that build + // dashboards off the session API rely on it too. Guards + // against a struct field being removed / renamed silently. + let s = base(false, None); + let expected = s.last_seen_at(); + let dto = SessionSummaryDto::from(s); + assert_eq!(dto.last_seen_at, expected); + let json = serde_json::to_string(&dto).unwrap(); + assert!( + json.contains("\"last_seen_at\""), + "wire shape must include `last_seen_at`: {json}" + ); + } + + /// A freshly-minted, unbound, unrevoked session ships with + /// `last_seen_at = Utc::now()` from `Session::new`, so it + /// MUST render as online. This is the green-dot happy path + /// the admin panel keys off — regression here means the + /// dashboard misses every currently-active session. + #[test] + fn dto_is_online_when_last_seen_is_fresh() { + let dto = SessionSummaryDto::from(base(false, None)); + assert!(dto.is_online, "fresh session must be online: {dto:?}"); + assert!(dto.is_active); + } + + /// A session whose `last_seen_at` is older than the + /// [`ONLINE_WINDOW`] MUST render as offline even when the + /// row is otherwise Active — that's the whole point of the + /// presence vs lifecycle split. Constructed via `from_raw` + /// so we can stamp a stale timestamp deterministically. + #[test] + fn dto_is_not_online_when_last_seen_is_stale() { + let stale = Utc::now() - chrono::Duration::hours(1); + let s = Session::from_raw( + Uuid::new_v4(), + Uuid::new_v4(), + "rt".to_string(), + Utc::now() + Duration::days(30), + None, + None, + stale, + false, + Uuid::new_v4(), + None, + None, + None, + SessionOrigin::Password, + stale, + ); + let dto = SessionSummaryDto::from(s); + assert!(!dto.is_online, "1h-idle session must not be online"); + assert!(dto.is_active, "stale-but-alive session stays active"); + } + + /// Anti-confusion guard: a revoked row whose `last_seen_at` + /// happens to be fresh (the last request that landed just + /// before revocation) must NOT surface as "Online" — an admin + /// reading the panel post-revocation expects the green dot + /// gone. `is_online` short-circuits on `!is_active`. + #[test] + fn dto_is_not_online_when_revoked_even_if_fresh() { + let dto = SessionSummaryDto::from(base(true, None)); + assert!(dto.is_revoked); + assert!(!dto.is_active); + assert!( + !dto.is_online, + "revoked-but-fresh row must never render as online", + ); + } + + /// Same anti-confusion guard for expiry: a session that's + /// past `expires_at` but whose last request landed in the + /// last 5 min must not surface as online. + #[test] + fn dto_is_not_online_when_expired_even_if_fresh() { + let past = Utc::now() - Duration::days(1); + let s = Session::from_raw( + Uuid::new_v4(), + Uuid::new_v4(), + "rt".to_string(), + past, // expires_at in the past + None, + None, + past, + false, + Uuid::new_v4(), + None, + None, + None, + SessionOrigin::Password, + Utc::now(), // last_seen_at fresh + ); + let dto = SessionSummaryDto::from(s); + assert!(!dto.is_active, "expired session is not active"); + assert!( + !dto.is_online, + "expired-but-fresh row must never render as online", + ); + } + + #[test] + fn fresh_session_has_last_seen_equal_to_created_at() { + // The DB default is `NOW()` and `Session::new` mirrors + // that with `Utc::now()` for BOTH columns — so a + // freshly-minted session immediately counts as "recently + // active" for the liveness gauges rather than showing up + // as long-idle for the first flush interval. + let s = base(false, None); + assert_eq!(s.created_at(), s.last_seen_at()); + } + #[test] fn from_raw_expired_session_is_not_active() { let past = Utc::now() - Duration::days(1); @@ -258,6 +435,7 @@ mod tests { None, None, crate::domain::entities::session::SessionOrigin::Unknown, + past, ); let dto = SessionSummaryDto::from(s); assert!(!dto.is_active); diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index cb8564fa..e7f2deff 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -63,6 +63,14 @@ pub struct TokenClaims { /// The DPoP middleware reads it from the already-validated token /// (no DB round trip) to enforce "bound session → proof required". pub dpop_jkt: Option, + /// Session identifier — the `auth.sessions.id` this access token + /// was minted for. Read by the auth middleware to stamp + /// per-session liveness via [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker) + /// with no DB round trip. `None` for tokens minted by builds + /// that predate the `sid` claim (backward compat during rollout; + /// harmless — the missing sid just means no stamp fires, and + /// the token still authenticates normally). + pub sid: Option, } /// Port for JWT token operations. @@ -81,6 +89,7 @@ pub trait TokenServicePort: Send + Sync + 'static { fn generate_access_token( &self, user: &User, + session_id: Option, dpop_jkt: Option<&str>, ) -> Result; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 6bd62e07..e042dcee 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1269,21 +1269,17 @@ impl AuthApplicationService { None => None, }; - // Generate tokens using the injected token service. The - // access token carries the `cnf.jkt` binding when present, - // so the DPoP middleware can enforce "bound → proof required" - // straight from the already-validated JWT — no session-row - // lookup on the hot path. - let access_token = self - .token_service - .generate_access_token(&user, validated_jkt.as_deref())?; - - let refresh_token = self.token_service.generate_refresh_token(); - + // Construct the session FIRST so its id is available to the + // token mint below — the `sid` claim lets the auth middleware + // stamp per-session liveness with no DB round trip. Order + // was reversed as part of the `last_seen_at` wiring + // (`docs/plan/sessions.md`). + // // Save session — new login starts a new token family. DPoP // binding is set at INSERT time and immutable thereafter (see // `docs/plan/dpop.md` — a mutable bind would let an attacker // downgrade a bound session by re-binding to their own key). + let refresh_token = self.token_service.generate_refresh_token(); let mut session = Session::new( user.id(), refresh_token.clone(), @@ -1293,6 +1289,18 @@ impl AuthApplicationService { Uuid::new_v4(), origin, ); + + // Generate tokens using the injected token service. The + // access token carries the `cnf.jkt` binding when present, + // so the DPoP middleware can enforce "bound → proof required" + // straight from the already-validated JWT — no session-row + // lookup on the hot path. `sid` correlates the token to the + // session row constructed just above. + let access_token = self.token_service.generate_access_token( + &user, + Some(session.id()), + validated_jkt.as_deref(), + )?; if let Some(jkt) = validated_jkt { // Success-path audit — records the bind so operators can // correlate a session_id in the panel with the exact moment @@ -1562,8 +1570,9 @@ impl AuthApplicationService { // thread `dpop_jkt` into a GET body. Session is minted // unbound; the SPA calls `POST /api/auth/dpop/bind` // post-redirect to bind it (see Gate 3). Token accordingly - // ships without `cnf.jkt`. - let access_token = self.token_service.generate_access_token(&user, None)?; + // ships without `cnf.jkt`. Session constructed first so + // its id can feed the token's `sid` claim — see the login + // path above for the rationale. let refresh_token = self.token_service.generate_refresh_token(); let session = Session::new( user.id(), @@ -1574,6 +1583,9 @@ impl AuthApplicationService { Uuid::new_v4(), crate::domain::entities::session::SessionOrigin::MagicLink, ); + let access_token = + self.token_service + .generate_access_token(&user, Some(session.id()), None)?; self.session_storage.create_session(session).await?; tracing::info!( @@ -1715,16 +1727,12 @@ impl AuthApplicationService { )); } - // Generate new tokens. Inherit the DPoP binding from the - // parent session so the refreshed access token carries the - // same `cnf.jkt` — otherwise every refresh would silently - // downgrade to unbound and the next request would 401 under - // Gate 9 enforcement (see Gate 7). - let access_token = self - .token_service - .generate_access_token(&user, session.dpop_jkt())?; - let new_refresh_token = self.token_service.generate_refresh_token(); - + // Rotate the session first so the new row's id is available + // to the token mint below — the `sid` claim tracks the + // freshly-inserted row, not the revoked parent. Order + // reversed as part of the `last_seen_at` wiring + // (`docs/plan/sessions.md`). + // // New session inherits the family_id so reuse of any ancestor triggers // full-family revocation. Revoking the old session and inserting the // new one happen in ONE transaction (`rotate_session`) — this path @@ -1738,6 +1746,7 @@ impl AuthApplicationService { // refresh silently downgrade the session to unbound, and every // subsequent request would fail DPoP verification once required // mode enforces per-session binding. + let new_refresh_token = self.token_service.generate_refresh_token(); let mut new_session = Session::new( user.id(), new_refresh_token.clone(), @@ -1770,6 +1779,16 @@ impl AuthApplicationService { new_session = new_session.with_oidc_sid(sid.to_string()); } + // Mint the access token AFTER the new session is fully + // configured — the `sid` claim points at the new row's id, + // and `cnf.jkt` inherits from the parent so DPoP proof + // enforcement (Gate 9) still holds across the rotation. + let access_token = self.token_service.generate_access_token( + &user, + Some(new_session.id()), + session.dpop_jkt(), + )?; + self.session_storage .rotate_session(session.id(), new_session) .await?; @@ -4608,8 +4627,8 @@ impl AuthApplicationService { // through the browser's redirect chain. Session is minted // unbound; the SPA calls `POST /api/auth/dpop/bind` post- // redirect to bind it (see Gate 3). Token accordingly ships - // without `cnf.jkt`. - let access_token = self.token_service.generate_access_token(&user, None)?; + // without `cnf.jkt`. Session constructed first so its id + // feeds the token's `sid` claim. let refresh_token = self.token_service.generate_refresh_token(); let mut session = Session::new( @@ -4630,6 +4649,11 @@ impl AuthApplicationService { if let Some(sid) = claims.sid.as_ref() { session = session.with_oidc_sid(sid.clone()); } + // Mint AFTER session is fully configured so `sid` claim + // aligns with the row about to be inserted. + let access_token = + self.token_service + .generate_access_token(&user, Some(session.id()), None)?; self.session_storage.create_session(session).await?; let force_password_change = self.read_force_password_change(user.id()).await; diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index 8e0a455e..8e10751e 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -180,10 +180,12 @@ impl DeviceAuthService { // clients that don't run WebCrypto — always unbound (`None` // for the `dpop_jkt` param), which the DPoP middleware exempts // from proof requirements. See `docs/plan/dpop.md` Gate 9. - let access_token = self.token_service.generate_access_token(&user, None)?; + // + // Session constructed first so its id feeds the token's + // `sid` claim — the auth middleware uses this to stamp + // per-session liveness without a DB round trip + // (`docs/plan/sessions.md`). let refresh_token = self.token_service.generate_refresh_token(); - - // Persist refresh token as a session let session = Session::new( user_id, refresh_token.clone(), @@ -193,6 +195,9 @@ impl DeviceAuthService { Uuid::new_v4(), crate::domain::entities::session::SessionOrigin::Device, ); + let access_token = + self.token_service + .generate_access_token(&user, Some(session.id()), None)?; self.session_storage.create_session(session).await?; // Store tokens on the device code entity diff --git a/src/common/di.rs b/src/common/di.rs index 5a6fb9f8..9f3fc5b7 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2126,6 +2126,38 @@ impl AppServiceFactory { let mut core = core; core.zip_service = Some(zip_service); + // Session liveness tracker — spawns its own 30 s flush + // loop at construction. Only built when auth (and thus + // sessions) exist; when auth is off this is `None` and + // the middleware never calls it. Uses the maintenance + // pool so background flushes don't compete with + // request-serving connections. See + // [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker). + let last_seen_tracker = if auth_services.is_some() { + Some( + crate::infrastructure::services::last_seen_tracker::LastSeenTracker::start( + maintenance_pool.clone(), + ), + ) + } else { + None + }; + + // Session-liveness Prometheus poller — three COUNT(*) reads + // every 30 s, publishing `oxicloud_sessions_active`, + // `_active_users`, `_total_non_revoked`. Only spawned when + // the Prometheus recorder is installed (i.e., + // `OXICLOUD_METRICS_LISTEN` is set) — without the recorder + // the `metrics::gauge!(...)` calls are no-ops and the + // periodic PG hits would be pure waste. Requires auth for + // the same reason as `last_seen_tracker`: no sessions to + // count without it. + if auth_services.is_some() && self.config.metrics_listen.is_some() { + crate::infrastructure::services::session_liveness_gauges::spawn( + maintenance_pool.clone(), + ); + } + // 9. Assemble final AppState let mut app_state = AppState { core, @@ -2158,6 +2190,7 @@ impl AppServiceFactory { people_service, storage_usage_service, grant_cleanup_service, + last_seen_tracker, calendar_service: None, calendar_use_case: None, addressbook_use_case: None, @@ -2952,6 +2985,16 @@ pub struct AppState { pub grant_cleanup_service: Option< Arc, >, + /// Per-session liveness tracker — the auth middleware calls + /// `stamp(session_id)` after every successful token validation, + /// and a background loop flushes the DashMap to `auth.sessions. + /// last_seen_at` every 30 s (batched UNNEST UPDATE). `None` + /// when auth is disabled — nothing to track. See + /// [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker) + /// for the contract and `docs/plan/sessions.md` for the design. + pub last_seen_tracker: Option< + Arc, + >, pub calendar_service: Option>, pub calendar_use_case: Option>, pub addressbook_use_case: Option>, diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index 7e30653a..cf2b10aa 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -97,6 +97,16 @@ pub struct Session { /// construction so a callsite can't forget to record it (the /// admin sessions panel filters on this). origin: SessionOrigin, + /// Wall-clock time the session was last observed serving an + /// authenticated request. Set to `created_at` at construction so + /// a freshly-minted session immediately counts as "recently + /// active" for the liveness gauges; moved forward in batches by + /// [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker) + /// via a per-30 s UNNEST-based UPDATE, so per-request writes + /// stay in-process. Distinct from `created_at` — that only moves + /// on session rotation (silent refresh), so its resolution is + /// capped at the access-token TTL. See `docs/plan/sessions.md`. + last_seen_at: DateTime, } impl Session { @@ -129,6 +139,7 @@ impl Session { oidc_sid: None, dpop_jkt: None, origin, + last_seen_at: now, } } @@ -180,6 +191,7 @@ impl Session { oidc_sid: Option, dpop_jkt: Option, origin: SessionOrigin, + last_seen_at: DateTime, ) -> Self { Self { id, @@ -195,6 +207,7 @@ impl Session { oidc_sid, dpop_jkt, origin, + last_seen_at, } } @@ -258,6 +271,10 @@ impl Session { pub fn origin(&self) -> SessionOrigin { self.origin } + + pub fn last_seen_at(&self) -> DateTime { + self.last_seen_at + } } #[cfg(test)] @@ -311,6 +328,7 @@ mod tests { None, Some("thumbprint-xyz".to_string()), SessionOrigin::Unknown, + Utc::now(), ); assert_eq!(s.dpop_jkt(), Some("thumbprint-xyz")); } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index b4c638be..336b8e80 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -117,7 +117,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid, dpop_jkt, origin + oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at FROM auth.sessions WHERE id = $1 "#, @@ -141,6 +141,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_sid"), row.get("dpop_jkt"), crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), + row.get("last_seen_at"), )) } @@ -155,7 +156,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid, dpop_jkt, origin + oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at FROM auth.sessions WHERE refresh_token = $1 "#, @@ -179,6 +180,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_sid"), row.get("dpop_jkt"), crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), + row.get("last_seen_at"), )) } @@ -192,7 +194,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid, dpop_jkt, origin + oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -220,6 +222,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_sid"), row.get("dpop_jkt"), crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), + row.get("last_seen_at"), ) }) .collect(); @@ -248,7 +251,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid, dpop_jkt, origin + oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at FROM auth.sessions WHERE ($1::uuid IS NULL OR user_id = $1) AND ($2 OR (revoked = false AND expires_at > NOW())) @@ -281,6 +284,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_sid"), row.get("dpop_jkt"), crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), + row.get("last_seen_at"), ) }) .collect(); diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index 9f8385a2..939a37f5 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -55,6 +55,17 @@ struct JwtClaims { /// Serialised as `{"cnf": {"jkt": "..."}}` to match RFC 9449. #[serde(skip_serializing_if = "Option::is_none")] pub cnf: Option, + /// OIDC-style `sid` claim (RFC 8417 §4.1) — carries the + /// `auth.sessions.id` this access token was minted for so the + /// auth middleware can stamp per-session liveness without a DB + /// round trip. `None` on tokens minted by pre-`sid` builds so + /// deserialisation stays backward-compatible during rollout. + /// Kept as `String` on the wire (Uuid parses at the port + /// boundary) so a malformed value fails at token-decode time + /// with a clear parse error instead of poisoning the field + /// silently. + #[serde(skip_serializing_if = "Option::is_none")] + pub sid: Option, } /// RFC 9449 §5 confirmation-key wrapper. Only the `jkt` member is @@ -72,6 +83,17 @@ impl From for TokenClaims { // signed always carries a UUID `sub`; nil is a safe sentinel the // middleware rejects. See benches/ROUND14.md §A3. let sub_id = uuid::Uuid::parse_str(&claims.sub).unwrap_or_else(|_| uuid::Uuid::nil()); + // Parse `sid` at the boundary — same amortization rationale + // as `sub_id` above, and gives us a clean `Option` in + // `TokenClaims`. A parse failure (mint-time bug or hand- + // crafted claim) drops the sid to `None`; the middleware + // then simply skips the stamp — token still authenticates. + // Legitimate tokens minted by this codebase always carry a + // valid Uuid, so this only masks external drift. + let sid = claims + .sid + .as_deref() + .and_then(|s| uuid::Uuid::parse_str(s).ok()); TokenClaims { sub_id, sub: claims.sub, @@ -82,6 +104,7 @@ impl From for TokenClaims { email: claims.email, role: claims.role, dpop_jkt: claims.cnf.map(|c| c.jkt), + sid, } } } @@ -196,6 +219,7 @@ impl TokenServicePort for JwtTokenService { fn generate_access_token( &self, user: &User, + session_id: Option, dpop_jkt: Option<&str>, ) -> Result { let now = Utc::now().timestamp(); @@ -219,6 +243,7 @@ impl TokenServicePort for JwtTokenService { cnf: dpop_jkt.map(|jkt| CnfClaim { jkt: jkt.to_string(), }), + sid: session_id.map(|id| id.to_string()), }; // Log JWT claims for debugging @@ -331,7 +356,7 @@ mod tests { let user = create_test_user(); let token = service - .generate_access_token(&user, None) + .generate_access_token(&user, Some(Uuid::new_v4()), None) .expect("Should generate token"); let claims = service @@ -370,7 +395,7 @@ mod tests { let user = create_test_user(); let token = service - .generate_access_token(&user, None) + .generate_access_token(&user, Some(Uuid::new_v4()), None) .expect("Should generate token"); // First call: cache miss — performs full HMAC verification @@ -397,7 +422,7 @@ mod tests { 86400, ); let token = service - .generate_access_token(&create_test_user(), None) + .generate_access_token(&create_test_user(), Some(Uuid::new_v4()), None) .expect("Should generate token"); // Miss populates the cache; hit must hand back the very same @@ -425,4 +450,46 @@ mod tests { let (hits, _misses) = service.cache_stats(); assert_eq!(hits, 0, "Invalid tokens should never produce cache hits"); } + + /// Regression for the `sid` claim wiring — the auth middleware + /// stamps per-session liveness by reading this exact field. If + /// the mint stops setting the claim or the port stops parsing + /// it, every `LastSeenTracker::stamp` call goes silent and the + /// Prometheus gauges freeze at zero. + #[test] + fn access_token_round_trips_session_id_as_sid_claim() { + let service = JwtTokenService::new( + "test_secret_key_at_least_32_bytes_long".to_string(), + 3600, + 86400, + ); + let user = create_test_user(); + let session_id = Uuid::new_v4(); + let token = service + .generate_access_token(&user, Some(session_id), None) + .expect("Should generate token"); + let claims = service.validate_token(&token).expect("Should validate"); + assert_eq!(claims.sid, Some(session_id)); + } + + /// Backward-compatibility guard: a mint call with `None` + /// omits the `sid` claim entirely (matches the pre-`sid` + /// on-wire shape), and the validated claims surface `None` + /// on the port. The middleware's `if let (Some(sid), ...)` + /// then simply skips the stamp — critical during rollout + /// where old tokens are still in flight. + #[test] + fn access_token_without_session_id_omits_sid_claim() { + let service = JwtTokenService::new( + "test_secret_key_at_least_32_bytes_long".to_string(), + 3600, + 86400, + ); + let user = create_test_user(); + let token = service + .generate_access_token(&user, None, None) + .expect("Should generate token"); + let claims = service.validate_token(&token).expect("Should validate"); + assert_eq!(claims.sid, None); + } } diff --git a/src/infrastructure/services/last_seen_tracker.rs b/src/infrastructure/services/last_seen_tracker.rs new file mode 100644 index 00000000..577b8a05 --- /dev/null +++ b/src/infrastructure/services/last_seen_tracker.rs @@ -0,0 +1,239 @@ +//! Per-session liveness tracker — the hot path of the "how many +//! sessions are active right now?" observation loop. +//! +//! **Contract.** Every authenticated request calls +//! [`LastSeenTracker::stamp`] with the session id it resolved. The +//! call is O(1) — a DashMap upsert of `(session_id → Utc::now())` — +//! and hits no I/O. The map data structure IS the dedup: 100 +//! requests against the same session in a flush window contribute +//! ONE row to the batched UPDATE with the latest timestamp. +//! +//! A background task ([`flush_loop`](Self::flush_loop), spawned at +//! construction) drains the map every 30 s and issues one +//! `UPDATE ... FROM UNNEST($1::uuid[], $2::timestamptz[])` covering +//! every distinct session_id observed in the window. The +//! `greatest(s.last_seen_at, t.seen_at)` guard makes the write +//! idempotent under any retry / race / clock skew — replaying the +//! same batch never moves the column backward. +//! +//! **Failure model.** A flush that hits a transient PG error does +//! NOT drop the accumulated set — the map is not cleared until the +//! UPDATE succeeds. Next tick overlays new activity on the retry +//! set and the whole thing gets flushed together. Bounded loss +//! window under a hard crash is one flush interval; graceful +//! shutdown calls [`flush_now`](Self::flush_now) synchronously (see +//! `main.rs`) so rolling restarts drop nothing. +//! +//! **Non-goals.** No per-session locking, no ordering guarantees +//! across sessions, no back-pressure on the flusher (the loop +//! swallows errors and keeps ticking). The workload is +//! observation-only — losing a stamp under contention is a +//! correctness no-op, the next request re-stamps. +//! +//! See `docs/plan/sessions.md` for the full design (why DashMap +//! over Mutex, why not NOTIFY/LISTEN today, migration +//! path to a multi-instance cluster). + +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use sqlx::PgPool; +use uuid::Uuid; + +/// Cadence of the batched UPDATE. Hardcoded — 30 s balances DB +/// write load against gauge freshness (the Prometheus scrape +/// interval is typically 15 s, so at worst two scrapes see the +/// same value before the next flush). Deliberately NOT exposed as +/// an env var — tuning it is a deployment-shape question we've +/// never had to answer in practice. +const FLUSH_INTERVAL: Duration = Duration::from_secs(30); + +/// In-process session-liveness tracker. See [module docs](self) for +/// the full contract; the two entry points are: +/// +/// - [`stamp`](Self::stamp) — called from the auth middleware on +/// every authenticated request. +/// - [`flush_now`](Self::flush_now) — called from the graceful- +/// shutdown handler. +/// +/// The periodic flush task is spawned on the tokio runtime by +/// [`start`](Self::start) at construction. The struct keeps no +/// handle to it — the task holds the `Arc` and observes the +/// runtime shutting down naturally. +pub struct LastSeenTracker { + /// (session_id → last observed time). DashMap's sharded locking + /// parallelises writes across distinct session_ids — different + /// users' requests never contend. + seen: DashMap>, + /// Maintenance pool — the tracker is a background writer and + /// must not compete with request-serving connections. + pool: Arc, +} + +impl LastSeenTracker { + /// Construct + spawn the flush loop. Returns the shared + /// handle; callers store it on `AppState` and pass it to the + /// auth middleware. + /// + /// The background task lives for the runtime's lifetime — no + /// cancellation handle is exposed because there is no + /// mid-process reason to stop tracking (a stopped flusher is + /// indistinguishable from a wedged one, and both are bugs). + /// Graceful shutdown calls [`flush_now`](Self::flush_now) + /// separately BEFORE the runtime tears down. + pub fn start(pool: Arc) -> Arc { + let this = Arc::new(Self { + seen: DashMap::new(), + pool, + }); + tokio::spawn(this.clone().flush_loop()); + this + } + + /// Record that `session_id` was observed serving a request + /// right now. Overwrites any prior stamp for the same session + /// in the current window — the flusher uses the latest value. + /// + /// O(1) DashMap upsert. No I/O. Never fails. + pub fn stamp(&self, session_id: Uuid) { + self.seen.insert(session_id, Utc::now()); + } + + /// Drain the accumulated stamps and write them in one batched + /// UPDATE. Idempotent — the `greatest(...)` guard means + /// replaying the same batch (or overlapping batches from a + /// retry) never moves the column backward. + /// + /// Errors are surfaced to the caller so `flush_loop`'s + /// warn-and-continue policy is a deliberate choice made in one + /// place, and the shutdown flusher in `main.rs` can decide + /// whether to log or panic. + /// + /// On PG error the accumulated set is NOT cleared — the next + /// tick retries with fresh activity overlaid. + pub async fn flush_now(&self) -> Result { + if self.seen.is_empty() { + return Ok(0); + } + + // Drain into two parallel vectors — one UNNEST arg each. + // `retain(|_,_| false)` clears every shard in-place; the + // pull-and-drop order doesn't matter (we upserted the + // latest wins per key already). + let mut ids: Vec = Vec::with_capacity(self.seen.len()); + let mut seen_at: Vec> = Vec::with_capacity(self.seen.len()); + for entry in self.seen.iter() { + ids.push(*entry.key()); + seen_at.push(*entry.value()); + } + + let result = sqlx::query( + r#" + UPDATE auth.sessions AS s + SET last_seen_at = greatest(s.last_seen_at, t.seen_at) + FROM UNNEST($1::uuid[], $2::timestamptz[]) AS t(id, seen_at) + WHERE s.id = t.id + "#, + ) + .bind(&ids) + .bind(&seen_at) + .execute(&*self.pool) + .await?; + + // Only clear the drained keys on success. A key inserted + // BETWEEN our copy above and the clear below survives + // (retain drops only those whose value we already flushed, + // by timestamp equality). Same-key re-stamp with a newer + // timestamp gets kept for the next flush. + let flushed: std::collections::HashMap> = + ids.iter().copied().zip(seen_at.iter().copied()).collect(); + self.seen + .retain(|k, v| flushed.get(k).is_none_or(|ts| ts != v)); + + let updated = result.rows_affected() as usize; + tracing::debug!( + target: "oxicloud::sessions", + batched = ids.len(), + updated, + "last_seen flush", + ); + Ok(updated) + } + + /// The periodic drain loop. Runs forever; every failed flush + /// is logged at WARN and the accumulated set is preserved for + /// the next tick. + async fn flush_loop(self: Arc) { + let mut ticker = tokio::time::interval(FLUSH_INTERVAL); + // Skip the "first tick fires immediately" behaviour — the + // map is empty at spawn time, so a same-tick flush is + // wasted work. + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + ticker.tick().await; + + loop { + ticker.tick().await; + if let Err(err) = self.flush_now().await { + tracing::warn!( + target: "oxicloud::sessions", + error = %err, + "last_seen flush failed; will retry next tick", + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Ten stamps of the same session_id must collapse to ONE + /// entry with the newest timestamp — the whole point of the + /// DashMap-as-dedup pattern. Guards against a future refactor + /// that swaps to an append-only channel and doubles the DB + /// write rate. + #[test] + fn stamps_dedup_by_session_id() { + // No pool needed — we're only exercising the map. Build + // the tracker directly without spawning the loop. + let seen = DashMap::new(); + let session = Uuid::new_v4(); + + for _ in 0..10 { + seen.insert(session, Utc::now()); + } + + assert_eq!(seen.len(), 1); + } + + /// Latest-wins semantics: two stamps for the same session + /// leave the newer timestamp in place, matching the flusher's + /// `greatest(...)` guard so a request that beats the flush + /// keeps its more recent stamp. + #[test] + fn stamp_keeps_latest_timestamp() { + let seen: DashMap> = DashMap::new(); + let session = Uuid::new_v4(); + + let t1 = Utc::now(); + seen.insert(session, t1); + let t2 = t1 + chrono::Duration::seconds(5); + seen.insert(session, t2); + + assert_eq!(*seen.get(&session).unwrap(), t2); + } + + /// Distinct sessions never collide — sharded map, no dedup + /// across keys. + #[test] + fn different_sessions_are_independent() { + let seen: DashMap> = DashMap::new(); + for _ in 0..100 { + seen.insert(Uuid::new_v4(), Utc::now()); + } + assert_eq!(seen.len(), 100); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index b86e7560..7eeab9c5 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -27,6 +27,7 @@ pub mod folders_consistency_service; pub mod grant_cleanup_service; pub mod image_transcode_service; pub mod jwt_service; +pub mod last_seen_tracker; pub mod local_blob_backend; pub mod local_fs_mount_provider; pub mod login_lockout_service; @@ -51,6 +52,7 @@ pub mod retry_blob_backend; pub mod s3_blob_backend; pub mod search_index; pub mod session_cleanup_service; +pub mod session_liveness_gauges; pub mod share_unlock_cookie; pub mod smtp_email_sender; pub mod swappable_blob_backend; diff --git a/src/infrastructure/services/session_liveness_gauges.rs b/src/infrastructure/services/session_liveness_gauges.rs new file mode 100644 index 00000000..cc6f0cfe --- /dev/null +++ b/src/infrastructure/services/session_liveness_gauges.rs @@ -0,0 +1,169 @@ +//! Prometheus session-liveness gauges — periodic polling of +//! `auth.sessions` to publish three gauges the `/metrics` scraper +//! reads: +//! +//! - `oxicloud_sessions_online` — non-revoked rows observed in the +//! last [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW). +//! **Per-session count**, not per-user — one user with three +//! devices contributes three. +//! - `oxicloud_sessions_online_users` — DISTINCT `user_id` behind +//! those online sessions. The multi-device factor is exactly +//! `sessions_online / sessions_online_users`. +//! - `oxicloud_sessions_total_non_revoked` — long-tail total, +//! including mobile clients still holding a refresh token they +//! haven't used in weeks. Useful sanity signal on the dashboard. +//! +//! **Naming — "online" vs "active".** The word "active" is already +//! spoken for by the session *lifecycle* (Active | Expired | +//! Revoked in the admin panel). Presence (recently-seen) is +//! orthogonal and uses "online" throughout the UI, DTO +//! (`SessionSummaryDto::is_online`), and these gauges — so a +//! dashboard graph and a per-row green-dot badge have the same +//! label root. Terminology decided 2026-08-18; see +//! `docs/plan/sessions.md`. +//! +//! **Cadence.** Poller ticks every [`POLL_INTERVAL`] (30 s). Three +//! `COUNT(*)` reads on the maintenance pool per tick — negligible +//! load on tens-of-thousands-of-rows tables thanks to the partial +//! index `idx_sessions_last_seen_at` (partial on `revoked = FALSE`, +//! which every query below filters on). +//! +//! **When it runs.** Spawned from DI only when auth is enabled AND +//! `OXICLOUD_METRICS_LISTEN` is set (recorder installed). Without +//! the recorder, `metrics::gauge!(...)` is a no-op — spawning +//! anyway would still hit PG every 30 s for values nobody reads. +//! +//! See `docs/plan/sessions.md` for the full design. + +use std::sync::Arc; +use std::time::Duration; + +use sqlx::PgPool; + +use crate::application::dtos::session_dto::ONLINE_WINDOW; + +/// Poll cadence. Matches the [`LastSeenTracker`](super::last_seen_tracker) +/// flush cadence so the gauges converge one tick after the tracker +/// flushes — no need to sync the two. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Spawn the session-liveness poller. Detached — the task lives +/// for the runtime's lifetime; there's no mid-process reason to +/// stop reporting gauges. +/// +/// Emits an initial poll on spawn so the very first `/metrics` +/// scrape after boot returns real values instead of the recorder's +/// zero-initialised default. +pub fn spawn(maintenance_pool: Arc) { + tokio::spawn(async move { + // Immediate first tick — a scraper hitting `/metrics` in + // the first 30 s otherwise sees `oxicloud_sessions_online + // 0` even on a busy server. Warmup query is cheap. + if let Err(err) = poll_once(&maintenance_pool).await { + tracing::warn!( + target: "oxicloud::sessions", + error = %err, + "initial session-liveness poll failed", + ); + } + + let mut ticker = tokio::time::interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // Consume the first tick — `interval` fires immediately on + // creation and we've already done the warmup above. + ticker.tick().await; + + loop { + ticker.tick().await; + if let Err(err) = poll_once(&maintenance_pool).await { + tracing::warn!( + target: "oxicloud::sessions", + error = %err, + "session-liveness poll failed; keeping last-known gauge values", + ); + } + } + }); + tracing::info!( + target: "oxicloud::sessions", + poll_interval_secs = POLL_INTERVAL.as_secs(), + online_window_secs = ONLINE_WINDOW.as_secs(), + "📊 session-liveness gauges spawned", + ); +} + +/// One poll cycle. Three lightweight `COUNT` reads → three gauge +/// updates. Errors propagate to the caller (loop logs + retries +/// next tick; gauges keep their last-known value in the interim, +/// which is the honest thing to publish — a temporary PG blip is +/// not a "sessions dropped to zero" event). +async fn poll_once(pool: &PgPool) -> Result<(), sqlx::Error> { + // NOTE: `ONLINE_WINDOW` is a Duration; PG expects the interval + // in seconds via `make_interval` (portable across sqlx driver + // versions). Casting once at bind time is cheaper than an + // `INTERVAL '$1 seconds'` string interp and keeps the query + // parameterised. + let online_secs: f64 = ONLINE_WINDOW.as_secs_f64(); + + let online: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM auth.sessions + WHERE revoked = FALSE + AND last_seen_at > NOW() - make_interval(secs => $1) + "#, + ) + .bind(online_secs) + .fetch_one(pool) + .await?; + + let online_users: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT user_id) FROM auth.sessions + WHERE revoked = FALSE + AND last_seen_at > NOW() - make_interval(secs => $1) + "#, + ) + .bind(online_secs) + .fetch_one(pool) + .await?; + + let total_non_revoked: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM auth.sessions WHERE revoked = FALSE + "#, + ) + .fetch_one(pool) + .await?; + + // metrics-exporter-prometheus takes f64 gauges; the raw COUNT + // fits into f64 precisely up to 2^53, well past any realistic + // session-row count. `describe_gauge!` is called once at first + // emission and cached in the recorder — the second/third tick + // just updates the value. + metrics::describe_gauge!( + "oxicloud_sessions_online", + "Non-revoked sessions observed in the last ONLINE_WINDOW." + ); + metrics::gauge!("oxicloud_sessions_online").set(online as f64); + + metrics::describe_gauge!( + "oxicloud_sessions_online_users", + "Distinct users behind sessions observed in the last ONLINE_WINDOW." + ); + metrics::gauge!("oxicloud_sessions_online_users").set(online_users as f64); + + metrics::describe_gauge!( + "oxicloud_sessions_total_non_revoked", + "Total non-revoked sessions regardless of last-seen recency." + ); + metrics::gauge!("oxicloud_sessions_total_non_revoked").set(total_non_revoked as f64); + + tracing::debug!( + target: "oxicloud::sessions", + online, + online_users, + total_non_revoked, + "session-liveness gauges updated", + ); + Ok(()) +} diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 7f2a70ec..a2f2f6a1 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -229,6 +229,18 @@ pub async fn auth_middleware( request.extensions_mut().insert(current_user); tracing::Span::current() .record("user_id", tracing::field::display(user_id)); + // Bump per-session liveness for the + // Prometheus gauges. O(1) DashMap upsert + // — no I/O on this hot path. The `sid` + // claim is `None` on tokens minted by + // pre-`sid` builds, in which case the + // stamp is skipped entirely — no + // fallback lookup, no round-trip. + if let (Some(sid), Some(tracker)) = + (claims.sid, state.last_seen_tracker.as_ref()) + { + tracker.stamp(sid); + } return Ok(next.run(request).await); } Err(e) => { @@ -353,6 +365,15 @@ pub async fn auth_middleware( request.extensions_mut().insert(CookieAuthenticated); tracing::Span::current() .record("user_id", tracing::field::display(user_id)); + // Cookie-auth branch stamps the same + // way as the Bearer branch above — + // see that site for the O(1) / + // no-DB rationale. + if let (Some(sid), Some(tracker)) = + (claims.sid, state.last_seen_tracker.as_ref()) + { + tracker.stamp(sid); + } return Ok(next.run(request).await); } LiveRole::Revoked => { diff --git a/src/main.rs b/src/main.rs index c25fcc0c..7d936460 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1409,17 +1409,89 @@ async fn run() -> Result<(), Box> { let listener = tokio::net::TcpListener::from_std(socket.into())?; + // Grab shutdown-hook handles BEFORE the router consumes + // app_state below. Currently: only the session `LastSeenTracker` + // needs a final synchronous flush on graceful shutdown so + // rolling restarts don't lose the last flush interval of + // liveness stamps. Future services with shutdown obligations + // add their handles here and chain their flushes into + // [`shutdown_signal`] alongside this one. + let last_seen_tracker = app_state.last_seen_tracker.clone(); + // Provide the fully-built state to the router let app = app.with_state(app_state); // TCP_NODELAY is inherited from the listening socket on Linux, // so every accepted connection already has Nagle disabled. + // + // `with_graceful_shutdown` waits for SIGTERM / SIGINT, then + // stops accepting new connections, drains in-flight requests, + // and runs the async block below. The session tracker flush + // fires AFTER draining so it captures any last-second requests + // that landed while shutdown propagates. axum::serve( listener, app.into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(async move { + shutdown_signal().await; + if let Some(tracker) = last_seen_tracker { + match tracker.flush_now().await { + Ok(n) => tracing::info!( + target: "oxicloud::sessions", + flushed = n, + "last-seen final flush before shutdown", + ), + Err(err) => tracing::warn!( + target: "oxicloud::sessions", + error = %err, + "last-seen final flush failed; up to one flush interval of \ + liveness data may have been lost", + ), + } + } + }) .await?; tracing::info!("Server shutdown completed"); Ok(()) } + +/// Block until the process receives SIGINT (Ctrl-C) or SIGTERM +/// (systemd / docker stop / K8s pod eviction). Returns once EITHER +/// arrives — no distinction between them at the caller: a signal +/// is a signal, drain and exit. +/// +/// On non-Unix (Windows), the `terminate` arm is a never-resolving +/// future so only Ctrl-C works — which matches how Windows expects +/// service shutdown to be signalled anyway. +async fn shutdown_signal() { + let ctrl_c = async { + if let Err(err) = tokio::signal::ctrl_c().await { + tracing::warn!("failed to install Ctrl-C handler: {err}"); + } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut s) => { + s.recv().await; + } + Err(err) => { + tracing::warn!("failed to install SIGTERM handler: {err}"); + // Fall through to a pending future so tokio::select! doesn't + // spin — Ctrl-C is still armed. + std::future::pending::<()>().await; + } + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => tracing::info!("SIGINT received, initiating graceful shutdown"), + _ = terminate => tracing::info!("SIGTERM received, initiating graceful shutdown"), + } +} From 20495355162dc2a9046e583357e7809f40443e68 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 19 Aug 2026 10:11:33 +0200 Subject: [PATCH 003/144] feat(session): admin UI showing online sessions --- frontend/src/lib/api/types.ts | 15 ++++ .../src/routes/admin/[[tab]]/+page.svelte | 70 +++++++++++++++++++ frontend/static/locales/en.json | 4 ++ frontend/static/locales/fr.json | 4 ++ 4 files changed, 93 insertions(+) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 253ec029..c99f2909 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -761,12 +761,27 @@ export interface SessionSummary { user_id: string; created_at: string; expires_at: string; + /** Wall-clock (RFC 3339) of the last authenticated request the + * server observed on this session. Trails the true value by at + * most the tracker's flush interval (30 s) on a running server; + * converges after graceful shutdown. Populates the "last seen X + * ago" tooltip on the presence dot. */ + last_seen_at: string; ip_address: string | null; user_agent: string | null; is_bound: boolean; dpop_jkt_prefix: string | null; is_revoked: boolean; is_active: boolean; + /** Presence signal — `true` when the server observed a request on + * this session within the last 5 minutes AND the row is `is_active` + * (never `true` on revoked / expired rows). Renders as a filled + * green dot in the Status column; `false` on an otherwise-active + * row renders as an outlined idle dot with a "last seen X ago" + * tooltip. Distinct from `is_active`: that's a lifecycle signal, + * this is a presence signal. Derived server-side against the + * same window that drives `oxicloud_sessions_online[_users]`. */ + is_online: boolean; /** How this session was minted. `unknown` covers pre-migration * rows and any origin the SPA doesn't yet render. Server enum * is populated at INSERT (see `Session::new`) and copied on diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 037e4636..532f0dcc 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -3082,6 +3082,36 @@ {t('admin.sessions.expired', 'expired')} {:else} + + {t('admin.sessions.active', 'active')} @@ -4572,6 +4602,46 @@ text-transform: uppercase; } + /* Presence dot in the sessions-table Status column — filled green + when the row is `is_online` (a request landed in the last 5 min), + outlined grey when the row is active-but-idle. Only rendered on + active rows: a revoked-but-recently-seen row must never flash + green post-revocation (see the markup guard `{#if s.is_active}`). + The dot sits BEFORE the `active` badge with a small gap, so the + Status cell reads left-to-right as `● active` when online and + `○ active` when idle. + + Tokens: `--color-success-alt` / `--color-success-border` for the + filled fill is the same green used by `.badge--active`, keeping + the presence signal visually consistent with the lifecycle one + without stealing the badge's own colour treatment. Grey border + for the idle state uses the neutral `--color-border` token so + both themes (light + dark, driven by `light-dark(...)`) get a + readable contrast. Fixed 8px / 8px sizing — the dot is a signal, + not a click target, so relative units would over-scale on + larger UI densities. */ + .presence-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: var(--space-1); + vertical-align: middle; + /* No border on the filled state, so both variants render at + the same 8×8 footprint (the outlined variant's 1px border + is inset via box-sizing: border-box below). */ + box-sizing: border-box; + } + + .presence-dot--online { + background: var(--color-success-alt); + } + + .presence-dot--idle { + background: transparent; + border: 1px solid var(--color-border); + } + /* External / grant-only account marker. Sibling of `.badge--user` in the same cell so the two stack horizontally; the accent colour reuses `--color-warning-*` because "external" is the diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 304e0b2e..88557131 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -1136,6 +1136,10 @@ "revoked": "revoked", "expired": "expired", "active": "active", + "online": "online", + "idle": "idle", + "presence_online_tooltip": "Online — last seen {{ago}}", + "presence_idle_tooltip": "Idle — last seen {{ago}}", "revoke": "Revoke", "empty": "No sessions match the current filter.", "revoke_self_confirm": "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index 165f0ad2..998c1e4a 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -1269,6 +1269,10 @@ "revoked": "révoquée", "expired": "expirée", "active": "actif", + "online": "en ligne", + "idle": "inactif", + "presence_online_tooltip": "En ligne — vue {{ago}}", + "presence_idle_tooltip": "Inactif — vue {{ago}}", "revoke": "Révoquer", "empty": "Aucune session ne correspond au filtre actuel.", "revoke_self_confirm": "⚠️ Il s'agit de VOTRE session actuelle. La révoquer vous déconnectera immédiatement et vous devrez vous reconnecter. Continuer ?", From 543a1a88eb5881992dd6a35057cf979bed148657 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 19 Aug 2026 22:58:09 +0200 Subject: [PATCH 004/144] feat(admin > users): UI: correct oidc badge --- examples/bench_round10_micro.rs | 1 + frontend/src/routes/admin/[[tab]]/+page.svelte | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/bench_round10_micro.rs b/examples/bench_round10_micro.rs index 8b884d8a..a346d777 100644 --- a/examples/bench_round10_micro.rs +++ b/examples/bench_round10_micro.rs @@ -159,6 +159,7 @@ fn section_identity(iters: u64) { email: Arc::from("alice.longname@example.com"), role: "user".to_string(), dpop_jkt: None, + sid: None, }); let (bn, ba) = measure("BEFORE String clones + role to_string", iters, || { diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 532f0dcc..63243dfe 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -2758,8 +2758,8 @@ --> {#if isOidcUser(u)} - - {u.federation_issuer} + + oidc {/if} {#if u.has_password} From 8cd25d7e0fe4015796bdd6c4bc88c0d459921ac7 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 13:49:43 +0200 Subject: [PATCH 005/144] security(RUSTSEC-2026-0258): update h2 crate and ignore alert for aws dependency, risk of DoS is null with AWS/S3) --- .cargo/audit.toml | 24 ++++++++++++++++++++++++ Cargo.lock | 34 +++++++++++++++++----------------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 7a63df25..f27f13b1 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -23,6 +23,30 @@ ignore = [ "RUSTSEC-2026-0098", "RUSTSEC-2026-0099", + # h2 0.3.27 — RUSTSEC-2026-0258 "unbounded empty DATA frames" + # (GHSA-q83h-524g-xf6h). Transitive via aws-smithy-http-client 1.1.12 + # → hyper 0.14.32 → h2 0.3.27. The patched line is 0.4.16+, but hyper + # 0.14's `h2 = "0.3"` requirement pins us to the 0.3.x branch which + # will not receive a backport — real fix requires aws-smithy-http-client + # to migrate to hyper 1.x (which our other h2 copy — 0.4.16, already + # bumped — is on). The 0.4.x copy is fixed via `cargo update`; this + # ignore covers only the 0.3.x chain. + # + # Severity: low (advisory's own classification). Attack is empty-DATA- + # frame flooding by a malicious HTTP/2 peer → memory pressure or panic. + # In this codebase h2 0.3.x runs strictly on the CLIENT side of AWS + # SDK requests to S3 endpoints. Exploitation requires either + # compromising AWS S3 (out-of-scope) or MitM with a valid TLS cert + # for the configured S3 host (bigger problem than the DoS). No + # data-integrity or auth impact; panic path contained by + # request-level unwind. + # + # Un-ignore trigger: aws-smithy-http-client releases a version that + # switches to hyper 1.x (checkable with `cargo tree -i h2@0.3` — the + # command returns no rows once the chain is gone). Track upstream at + # https://github.com/smithy-lang/smithy-rs/issues (search "hyper 1"). + "RUSTSEC-2026-0258", + # instant unmaintained — transitive via azure_core 0.21.0 (latest available). # No direct security impact; no upgrade path exists. "RUSTSEC-2024-0384", diff --git a/Cargo.lock b/Cargo.lock index 2ee0cc0f..1156a1ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,7 +160,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -601,7 +601,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.13", + "h2 0.4.16", "http 0.2.12", "http 1.4.0", "http-body 0.4.6", @@ -2267,7 +2267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2916,9 +2916,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -3197,7 +3197,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2 0.4.16", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -3285,7 +3285,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3603,7 +3603,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4318,7 +4318,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5140,7 +5140,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5177,7 +5177,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5651,7 +5651,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6174,7 +6174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6686,7 +6686,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7016,7 +7016,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2 0.4.13", + "h2 0.4.16", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -8154,7 +8154,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From cc3be1ec38702150af4cc33f842598060c8c1579 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 14:27:42 +0200 Subject: [PATCH 006/144] refactor: apply clippy recos for rustc 1.98.0 --- clippy.toml | 28 +++++++++++++++++++ examples/bench_round14_queries.rs | 8 ++++-- .../repositories/pg/face_pg_repository.rs | 13 +++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 clippy.toml diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..a59d2fd9 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,28 @@ +# Clippy configuration overrides. Kept minimal — each entry documents +# what it's for and when it should be revisited. + +# `clippy::result_large_err` — raise the "big Err variant" ceiling to +# 512 bytes. +# +# Rationale: axum handler signatures shaped as +# `Result` (or `AppError` variants +# that wrap `axum::response::Response`) naturally exceed the default +# 128-byte threshold. `Response` carries a `HeaderMap` (~256 B inline) +# + status + body + extensions; a handful of handlers land in that +# range without doing anything wrong. Fighting the lint per-handler +# with `#[allow]` on every one is churn for zero runtime benefit — +# these Results are constructed on the stack once per request and +# never nested in a hot inner loop. +# +# 512 B keeps the lint's protective value: it still fires on genuinely +# oversized Err variants (embedded `Vec` blobs, avatar payloads, +# large enum aggregates) that WOULD be worth boxing. +# +# Revisit if: +# * A future refactor slims axum Response OR extracts a small error +# enum with an IntoResponse impl across the handler layer — then +# drop this override back to the default 128. +# * A specific handler exceeds 512 B and clippy re-fires — deal with +# that handler individually (boxed error / small enum) rather than +# raising the ceiling further. +large-error-threshold = 512 diff --git a/examples/bench_round14_queries.rs b/examples/bench_round14_queries.rs index c71e58cb..4fd21a62 100644 --- a/examples/bench_round14_queries.rs +++ b/examples/bench_round14_queries.rs @@ -43,9 +43,13 @@ fn stats(mut s: Vec) -> (f64, f64, f64) { /// Mirror of `face_pg_repository::bytes_to_embedding` — the per-face /// `Vec` decode the BEFORE path pays for a column it never reads. +/// Kept byte-for-byte identical to the shipped path so the benchmark +/// measures apples-to-apples; `as_chunks` swap matches the source. fn bytes_to_embedding(b: &[u8]) -> Vec { - b.chunks_exact(4) - .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + b.as_chunks::<4>() + .0 + .iter() + .map(|c| f32::from_le_bytes(*c)) .collect() } diff --git a/src/infrastructure/repositories/pg/face_pg_repository.rs b/src/infrastructure/repositories/pg/face_pg_repository.rs index a9a4e12b..260c82df 100644 --- a/src/infrastructure/repositories/pg/face_pg_repository.rs +++ b/src/infrastructure/repositories/pg/face_pg_repository.rs @@ -47,8 +47,17 @@ fn embedding_to_bytes(e: &[f32]) -> Vec { } fn bytes_to_embedding(b: &[u8]) -> Vec { - b.chunks_exact(4) - .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + // `as_chunks::<4>` (stable since Rust 1.88) hands back `&[[u8; 4]]` + // typed at the array level, so the closure gets a `&[u8; 4]` and + // the `[c[0], c[1], c[2], c[3]]` array-copy dance from the old + // `chunks_exact(4)` shape collapses to a plain deref. Any trailing + // bytes that aren't a multiple of 4 land in `.1` and are dropped + // — same semantics as `chunks_exact` which iterated only the + // aligned prefix. + b.as_chunks::<4>() + .0 + .iter() + .map(|c| f32::from_le_bytes(*c)) .collect() } From ec70b21c6e7483368613baeddb2218b69c924b46 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 14:06:54 +0200 Subject: [PATCH 007/144] refactor(User): clear separation PublicUserDto, FullUserDto, SelfUserDto --- docs/plan/userdto-refactor.md | 416 ++++++++++++++++++ src/application/dtos/user_dto.rs | 256 +++++++++++ src/domain/repositories/user_repository.rs | 45 ++ .../repositories/pg/user_pg_repository.rs | 31 +- 4 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 docs/plan/userdto-refactor.md diff --git a/docs/plan/userdto-refactor.md b/docs/plan/userdto-refactor.md new file mode 100644 index 00000000..17fe98aa --- /dev/null +++ b/docs/plan/userdto-refactor.md @@ -0,0 +1,416 @@ +# UserDto Refactor — Three-Layer Split (Public / Full / Self) + +Establish three DTO shapes for representing a user on the wire, each +with a single unambiguous audience, composed hierarchically so the +overlap between audiences is defined ONCE: + +- **`PublicUserDto`** — public identity. What any authenticated caller + may see about *another* user. Returned by `/api/users/{id}`, share + responses, group members, magic-link invitees, recipient enrichment. +- **`FullUserDto`** = `PublicUserDto` + all fields that BOTH an admin + (viewing another user) AND the subject themselves (viewing + themselves) may see. Returned as `Vec` by + `/api/admin/users`. Closest DTO to the underlying `auth.users` row. +- **`SelfUserDto`** = `FullUserDto` + self-only preferences, + session-scoped flags, and caller-scoped permissions. Returned by + `/api/auth/me` and by the login / refresh / OIDC / magic-link auth + response. + +Composition (`FullUserDto.user: PublicUserDto`, +`SelfUserDto.full: FullUserDto`) means the public-identity contract +has ONE definition; the overlap between admin's view and self's view +is another single definition. Adding a new field naturally finds its +level: + +- Useful to any authenticated caller? → `PublicUserDto`. +- Useful only to admin (about another user) and the subject + themselves? → `FullUserDto`. +- Meaningful only to the caller viewing themselves? → `SelfUserDto`. + +Companion of `docs/plan/sessions.md` (which introduced `is_online` +and motivated widening the DTO for presence). Same principle: pick +the audience first, structure the DTO around it, don't let the same +field mean different things on different endpoints. + +## Why now — the problems this fixes + +Today's single `UserDto` conflates three audiences. Symptoms: + +1. **The "quiet lie"**. `UserDto::has_password` is populated only by + `/api/auth/me`; every other emitter (`From`) leaves it + `false`. A share-recipient DTO on the wire says + `has_password: false` unconditionally, which an attacker + scraping share responses could misread as "this user is + passwordless" when the truth is "we didn't fill this field in + for you". Same pattern for `force_password_change` and + `is_dpop_bound`. See `src/application/dtos/user_dto.rs:134-138` + for the explicit disclaimer — the convention exists precisely + because the field placement is wrong. + +2. **Private signals leak by default**. `last_login_at`, + `notify_on_share`, `ui_preferences`, `preferred_locale`, + `federation_kind`, `email_verified_at`, and + `storage_used_bytes` all ride on `UserDto` and are returned to + any authenticated caller who can see a given user. Group + members can see when their peers last logged in, which IdP they + federate with, and how full their disks are. None of this is + information a share picker or a member listing needs. + +3. **`AdminUserSummaryDto` duplicates a chunk of `UserDto` verbatim** + (id / username / email / role / quotas / last_login_at / active / + federation_* / is_external), then adds three admin-only fields + (has_password / opaque_registered / opaque_migrated). The two + shapes drift naturally as new fields are added — no compile-time + guarantee they stay in sync. + +4. **N+1 in the admin panel**. Because `AdminUserSummaryDto` doesn't + include `image`, the admin users table fires `/api/users/{id}` + per row so `UserVignette` can render the avatar. Composition + (`FullUserDto.user.image`) lets the admin listing seed the SPA's + per-user cache from the list rows directly. + +## Target shapes + +### `PublicUserDto` — public identity (9 fields) + +Applied rule: "would a share picker / group member listing / +recipient enrichment need this? if no, it doesn't belong here." + +```rust +pub struct PublicUserDto { + pub id: String, + pub username: Option, + pub email: String, + pub role: String, // sharee UI renders admin badge + pub image: Option, // avatar + pub is_external: bool, // external badge + pub given_name: Option, // social identity + pub family_name: Option, // social identity + pub is_online: bool, // presence — social signal +} +``` + +Every existing UserDto emitter site (`From`, share responses, +group members, magic-link invitees, sharee-vignette lookup) +returns this slim shape. All private signals below vanish from +those wire paths. + +### `FullUserDto` — admin's view of anyone + self's view of self (13 extras) + +The fields the SUBJECT themselves may know about themselves that +an ADMIN may also know about the subject. Composed on top of +`PublicUserDto`. This is the DTO closest to the underlying +`auth.users` row. + +```rust +pub struct FullUserDto { + /// Public identity — same set any authenticated caller can see. + pub user: PublicUserDto, + /// IdP linkage. Which SSO provider a peer uses is a soft + /// org-affiliation leak; not needed by share pickers. + pub federation_kind: Option, + pub federation_issuer: Option, + /// Subject's own locale preference. Only THEY or an admin + /// managing them needs this — other callers use their own. + pub preferred_locale: Option, + /// Email-verification stamp. Trust signal — meaningful to admin + /// (auditing verification status) and to self (own record), but + /// not to a share picker rendering a vignette. + pub email_verified_at: Option>, + /// Row bookkeeping — not rendered on any non-admin surface today. + pub created_at: DateTime, + pub updated_at: DateTime, + /// Activity signal — private. + pub last_login_at: Option>, + /// Account-active flag — private (a deactivated user couldn't + /// reach `/me` anyway, but admin needs to see it). + pub active: bool, + /// Storage quotas — personal financials. Admin manages others'; + /// self sees own. + pub storage_quota_bytes: i64, + pub storage_used_bytes: i64, + /// Auth capability set — has_password / OPAQUE flags. Kept off + /// public identity because per-user auth adoption leaks through + /// directory endpoints. + pub has_password: bool, + pub opaque_registered: bool, + pub opaque_migrated: bool, +} +``` + +### `SelfUserDto` — /api/auth/me (5 extras) + +Everything the caller may see about themselves that no other +caller (not even an admin) needs to see: pure self-scoped state. + +```rust +pub struct SelfUserDto { + /// Full profile. Every field an admin would see about you is + /// here — same shape as one row of /api/admin/users. + pub full: FullUserDto, + /// Opaque UI preferences bag — my own UI state. Cross-device + /// via PATCH /api/auth/me/profile. + pub ui_preferences: serde_json::Value, + /// Whether I want share-notification emails. + pub notify_on_share: bool, + /// Session-scoped: my current session carries a DPoP thumbprint. + /// SPA skips a redundant /api/auth/dpop/bind on load when true. + pub is_dpop_bound: bool, + /// Admin-set temp-password gate — SPA nav guard blocks everything + /// but /change-password until this flips back. + pub force_password_change: bool, + /// Caller-scoped permission: can I edit my own avatar? False for + /// OIDC users whose avatar comes from the IdP. Only meaningful + /// when caller == subject; nonsense on any other DTO. + pub can_edit_image: bool, +} +``` + +## Endpoint mapping + +| Endpoint | Old shape | New shape | +|---|---|---| +| `/api/auth/me` | `UserDto` (fat) | `SelfUserDto` | +| `/api/auth/login` / `/refresh` / OIDC callback / magic-link redemption | `AuthResponseDto { user: UserDto }` | `AuthResponseDto { user: SelfUserDto }` | +| `/api/admin/users` | `Vec` | `Vec` | +| `/api/users/{id}` | `UserDto` (fat) | `PublicUserDto` (9 fields) | +| Share responses / group members / magic-link invitees / recipient enrichment | `UserDto` (fat) | `PublicUserDto` | + +**Login response ships `SelfUserDto`, not `PublicUserDto`.** The SPA +needs `has_password`, `ui_preferences`, `is_dpop_bound`, and +`force_password_change` immediately post-login to avoid a UI race +with the first `/me` fetch. Same rationale for refresh and +OIDC/magic-link callback: the SPA's post-auth state must be +complete in one round trip. + +## Backend callsite inventory + +**DTO layer** (`src/application/dtos/user_dto.rs`): + +- Replace `UserDto` with `PublicUserDto` (renamed AND slimmed — + same rename forces every consumer to consciously pick the new + shape rather than silently losing fields). +- Add `FullUserDto` and `SelfUserDto`. +- Delete `AdminUserSummaryDto` (superseded by `FullUserDto`). +- `impl From for PublicUserDto` — the entry point. Maps 1:1 + to `User`'s public identity accessors. +- `FullUserDto::build(user: User, flags: UserDerivedFlags)` — + wraps a `PublicUserDto` plus the DB-computed booleans not on + `User` (`has_password`, `opaque_registered`, `opaque_migrated`, + `is_online`). Every other FullUserDto field comes from `User` + directly. Not a `From` impl because the second argument is + needed and Rust's `From` is single-arg. +- `SelfUserDto::build(full: FullUserDto, session_ctx: SessionContext)` + — helper taking a FullUserDto plus caller context (session's + DPoP-bound flag, admin-set force-password-change flag). Same + reason as FullUserDto's builder — not a `From` impl. + +**Repository layer** +(`src/infrastructure/repositories/pg/user_pg_repository.rs`): + +- **Delete `UserListEntry`** — the narrow projection was a perf + optimization; Path B decision supersedes it. +- `list_users` returns `Vec<(User, UserDerivedFlags)>` where + `UserDerivedFlags` is a small named struct in + `domain/repositories/user_repository.rs`: + + ```rust + /// DB-computed booleans about a user that aren't fields on the + /// `User` entity itself — either derived from column presence + /// (`password_hash IS NOT NULL`) or from a cross-table lookup + /// (`auth.sessions.last_seen_at` for `is_online`). Companion + /// to `User` on the list projection: the repo computes both, + /// the application layer packs them into `FullUserDto`. + pub struct UserDerivedFlags { + pub has_password: bool, + pub opaque_registered: bool, + pub opaque_migrated: bool, + pub is_online: bool, + } + ``` + + Not "admin-only" — every field ends up on `FullUserDto`, which + both admin AND self read. The name reflects "derived from the + DB row, not intrinsic to the User entity". +- SELECT widens to include `image` (previously narrowed away + per ROUND12 §Q1) + the `EXISTS(...)` scalar for `is_online`, + bound with `ONLINE_WINDOW.as_secs_f64()` via + `make_interval(secs => $N)` — same pattern as + `session_liveness_gauges.rs:104-114`, single source of truth, + no SQL literal. + +**Handler / service layer**: + +- `/api/auth/me` handler — builds `SelfUserDto` from + `(User, UserDerivedFlags, session_context)`. The + `UserDerivedFlags` for /me comes from a reuse of the + list-repo path scoped to `WHERE id = $me` or a new small + dedicated query (implementer's call — either works). +- `AuthResponseDto` shape follows — `user: SelfUserDto` field. +- Every login/refresh/OIDC/magic-link path that mints an + `AuthResponseDto` computes the same SelfUserDto. +- Admin service `list_users_admin` — returns `Vec`. +- `/api/users/{id}` handler — returns `PublicUserDto`. Every + other public consumer stays on `PublicUserDto`. + +## Frontend callsite inventory + +**Type changes** (`frontend/src/lib/api/types.ts`): + +- Rename `User` interface → `PublicUser` and slim to match new + DTO (9 fields). +- Add `FullUser` interface — `{ user: PublicUser, federation_kind, ... }`. +- Add `SelfUser` interface — `{ full: FullUser, ui_preferences, ... }`. +- Delete `AdminUser` (replaced by `FullUser`). + +**Store changes**: + +- `lib/stores/session.svelte.ts` — reads /me, must handle SelfUser + shape. Recommendation: keep a derived `session.me: SelfUser` for + the full record and shorthand accessors: + `session.user: PublicUser` = `session.me.full.user`, + `session.full: FullUser` = `session.me.full`. + Existing `session.user.username` calls keep working via the + shorthand; new self-only reads go through `session.me.foo` or + `session.full.foo`. + +**Component changes**: + +- Profile / change-password / DPoP-bind pages — reads + `session.me.has_password`, `session.me.is_dpop_bound`, + `session.me.can_edit_image`, `session.full.preferred_locale`, + etc. +- `routes/admin/[[tab]]/+page.svelte` users table — every + `u.username` → `u.user.username`, every `u.last_login_at` / + `u.active` / `u.has_password` stays top-level (FullUserDto + fields). Also **seed `resolveUser` cache with `u.user`** in the + load path — kills the N+1 that motivated widening the query. +- Admin sessions table's `UserVignette` — no change, `user_id` + passed through unchanged; the users-table cache seed above + satisfies the vignette lookup on cross-table navigation. +- `lib/composables/useOwnerCache.ts` / + `lib/api/endpoints/users.ts` — `resolveUser` returns + `PublicUser`. No signature change; the return shape only gets + smaller. Add a `seedUser(u: PublicUser)` export so the admin + table can prime the cache. + +## Phasing + +Each step compiles standalone; each is a reasonable review chunk. + +1. **Introduce the new DTOs** — add `PublicUserDto`, `FullUserDto`, + and `SelfUserDto` alongside the existing `UserDto`. Don't + change `UserDto` yet. Compiles; no behaviour change. +2. **Widen repo projection** — add `is_online` (via EXISTS + subquery) and `image` back to `list_users` SELECT. Introduce + `AdminExtras` struct. `UserListEntry` still exists but is now + redundant (fields also available on `User`). +3. **Migrate the emitter sites** — `/api/auth/me`, + login/refresh/OIDC/magic-link, admin service. Each now builds + the new nested shape. Old `UserDto` still ships every field. +4. **Rename `UserDto` → `PublicUserDto` and slim** — remove the + moved fields. The Rust compiler flags every remaining consumer + that reads a removed field; those either move to `.full.foo` / + `.user.foo` (embedded) or promote themselves to a Self/Full + DTO. +5. **Delete `UserListEntry` + `AdminUserSummaryDto`** — dead after + the cutover. +6. **Frontend** — rename types (`User` → `PublicUser`), add + `FullUser` / `SelfUser`, update session store, all consumers. + Seed `resolveUser` cache from admin table. +7. **Regenerate OpenAPI** — `cargo run --bin generate-openapi` + picks up the new schemas; the shrunken `PublicUserDto` schema + documents the new contract. +8. **Delete obsolete doc comments** — `has_password` / + `is_dpop_bound` / `force_password_change` comments on the old + UserDto explaining "populated only by `/me`" become obsolete + (the field structurally can't exist on non-self emitters). + +## Wire-shape breaking changes + +All in-repo consumers (backend + SPA) migrate in the same commit. +External consumers: none today — `/api/admin/users` is +admin-panel-only, `/me` is SPA-only, share/group endpoints are +SPA-only. Ship as one clean break; skip a `?shape=v2` deprecation +window. + +Every removed field from a public UserDto path (share responses, +group members, magic-link invitees, `/api/users/{id}`) is a +deliberate leak reduction, not a regression. Any FE consumer that +was reading e.g. `sharee.has_password` was reading a "quiet lie" +anyway (always `false`). + +## Testing + +**Backend**: + +- Round-trip tests for each new DTO type (already have for + UserDto; extend to PublicUserDto + FullUserDto + SelfUserDto). +- **Structural quarantine tests** — + `self_user_dto_does_not_leak_ui_preferences_via_public_paths`: + serialize a `SelfUserDto`, assert `ui_preferences` appears + ONLY at top level, not inside `.full.user` or `.full`. Same for + `FullUserDto` — `has_password` at top level of `FullUserDto`, + not inside `.user`. +- Update every service test that constructs `UserDto` fixtures. + +**Frontend**: + +- The TS type system catches every consumer that reads a removed + field. `npm run check` surfaces the whole blast radius on the + first pass — no new test infrastructure needed. +- Add one Vitest integration on the admin users table asserting + the presence dot renders AND `/api/users/{id}` is NOT called + per row (checks `apiFetch` mock call count). + +**Wire-shape guard**: + +- Hurl test hitting `/api/users/{id}` as a non-admin caller, + asserting the response does NOT contain moved fields + (`has_password`, `last_login_at`, `notify_on_share`, + `ui_preferences`, `storage_used_bytes`, `federation_kind`, + `preferred_locale`, `email_verified_at`, `created_at`, etc.). + Anti-regression guard for the whole point of this refactor. + +## Non-goals + +- **Reworking the `User` domain entity** — this refactor is + DTO-shape only. The entity keeps all its fields. +- **Visibility-rule changes on `/api/users/{id}`** — who can see + whom stays as-is; only the field set narrows. +- **Splitting other DTOs** — `SessionSummaryDto`, `FileDto`, etc. + Same principle would apply, but each is a separate design call. +- **Moving avatars out of the row** — planned separately. This + refactor keeps `image` on `PublicUserDto` so the admin-panel + N+1 fix survives. +- **Flattening the nested shape via `#[serde(flatten)]`** — the + three-level wire shape (`me.full.user.username`) is slightly + deeper than a flat DTO would be, but the structural quarantine + is worth the cost. Reconsider if consumer readability suffers. + +## Open questions + +1. **Wire nesting depth on `/me`**: `me.full.user.username` is 3 + levels. Acceptable? Alternative: `#[serde(flatten)]` on + FullUserDto and SelfUserDto so the wire is flat + (`me.username`, `me.has_password`, `me.ui_preferences` all + at top level), while keeping structural quarantine at compile + time only. Simpler for FE consumers, loses runtime + introspectability (a receiver can't tell which fields are + public vs full vs self from the shape). Recommendation: ship + nested; revisit if FE readability suffers. +2. **`created_at` / `updated_at` on FullUserDto** — not rendered + anywhere currently. Keep for compat unless there's a + compelling reason to drop. + +## Memory notes to update on landing + +- Extend `project_sessions_last_seen_at_shipped` with a "led to" + pointer at this refactor. +- New note `project_userdto_three_layer_split` — captures the + PublicUserDto / FullUserDto / SelfUserDto pattern + the + decision rule ("would any authenticated caller need this? + PublicUserDto. would self+admin? FullUserDto. self only? + SelfUserDto."). +- Delete the `AdminUserSummaryDto` and `UserListEntry` + references in earlier memory notes. diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 9147fafa..3d88b344 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -288,6 +288,262 @@ impl From for UserDto { } } +// ──────────────────────────────────────────────────────────────────────── +// Three-layer user DTO family — see docs/plan/userdto-refactor.md. +// +// `PublicUserDto` — public identity. Every authenticated caller may see it. +// Returned by /api/users/{id}, share responses, group +// members, magic-link invitees, recipient enrichment. +// `FullUserDto` — `{ user: PublicUserDto, ...admin+self extras }`. +// Returned as `Vec` by /api/admin/users; +// embedded in `SelfUserDto`. Closest DTO to the +// `auth.users` row. +// `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned +// by /api/auth/me and by every AuthResponseDto path. +// +// The fat `UserDto` above is being phased out — the three types will replace +// it and its emitter sites migrate one at a time. Kept temporarily so this +// PR compiles at every checkpoint; deleted at the end of the refactor. +// ──────────────────────────────────────────────────────────────────────── + +/// Public identity — what any authenticated caller may see about ANOTHER +/// user. Returned by `/api/users/{id}` and everywhere a user is +/// referenced by another surface (share responses, group members, +/// magic-link invitees, recipient enrichment). +/// +/// This is the audience-narrowest DTO: adding a field here means every +/// authenticated caller can see it about every visible user. Fields that +/// are meaningful only to the subject themselves (preferences, session +/// state) or only to an admin (auth adoption signals) belong on +/// [`SelfUserDto`] or [`FullUserDto`] respectively. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PublicUserDto { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + pub email: String, + /// Role string ("admin" | "user"). Kept public because the sharee / + /// group-member vignette renders an admin badge. + pub role: String, + /// Avatar payload (base64 data-URI up to 512 KiB). Public so a share + /// picker can render the recipient's face directly. Will move to a + /// dedicated avatar endpoint in a future refactor — this shape is + /// transitional. + pub image: Option, + /// `true` for grant-only external recipients (magic-link, OIDC-only, + /// future OCM federated). Renders the "external" badge on the vignette. + pub is_external: bool, + /// Optional first/given name. Social identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub given_name: Option, + /// Optional last/family name. Social identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub family_name: Option, + /// Presence signal — TRUE when the server observed a request on any + /// of this user's non-revoked sessions within the last + /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW) + /// (5 min). Sourced from an EXISTS subquery when the DTO is built + /// from a list-projection path; single-user endpoints that don't + /// enrich presence ship `false`. + #[serde(default)] + pub is_online: bool, +} + +/// Full user record — public identity + all fields BOTH an admin +/// (viewing another user) AND the subject themselves may see. Returned +/// as `Vec` by `/api/admin/users`; embedded in +/// [`SelfUserDto`] for `/api/auth/me`. +/// +/// This is the DTO closest to the underlying `auth.users` row. Adding a +/// field here means an admin looking at any user can see it, and the +/// subject themselves can see it in their `/me` response — but the field +/// stays off the public [`PublicUserDto`] surface. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FullUserDto { + /// Public identity — same set every authenticated caller sees. + pub user: PublicUserDto, + /// Which trust chain minted this user's federation identity — + /// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local users. + /// Kept off `PublicUserDto` because a peer's federation kind is a + /// soft org-affiliation leak; only self + admin need it. + #[serde(skip_serializing_if = "Option::is_none")] + pub federation_kind: Option, + /// The authority that minted this user's `federation_subject` — + /// issuer URL for OIDC (id_token `iss` claim), peer domain for OCM, + /// `None` for local users. Same rationale as `federation_kind`. + #[serde(skip_serializing_if = "Option::is_none")] + pub federation_issuer: Option, + /// Subject's own locale preference. Only THEY or an admin managing + /// them needs this — other callers use their own locale. + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_locale: Option, + /// When the user first demonstrated control of their email. Trust + /// signal — meaningful to admin (auditing verification status) and + /// to self (own record), but not to a share picker rendering a + /// vignette. + #[serde(skip_serializing_if = "Option::is_none")] + pub email_verified_at: Option>, + /// Row bookkeeping. + pub created_at: DateTime, + pub updated_at: DateTime, + /// Activity signal — private to the subject; admin sees it too. + pub last_login_at: Option>, + /// Account-active flag — a deactivated user couldn't reach `/me` + /// anyway, but admin needs to see it. + pub active: bool, + /// Storage quotas — personal financials. Admin manages others'; + /// self sees own. + pub storage_quota_bytes: i64, + pub storage_used_bytes: i64, + /// TRUE when the account has a server-verifiable password + /// (`password_hash IS NOT NULL`). Kept off `PublicUserDto` because + /// per-user auth adoption leaks through directory endpoints. + pub has_password: bool, + /// TRUE when the user has an OPAQUE envelope on file. + pub opaque_registered: bool, + /// TRUE when the user has completed ≥1 successful OPAQUE login. + /// Distinct from `opaque_registered`: an admin can invalidate the + /// envelope leaving the user registered=false but with historical + /// migrated=true. + pub opaque_migrated: bool, +} + +/// Self view — everything the caller may see about themselves. +/// Returned by `/api/auth/me` and by every `AuthResponseDto` path +/// (login / refresh / OIDC callback / magic-link redemption). +/// +/// Composed on top of [`FullUserDto`] so `/me` and `/admin/users` share +/// the SAME "full profile" contract for the fields both need — new +/// self+admin-visible fields go on `FullUserDto` and both endpoints get +/// them together. Fields here are pure self-scoped state: preferences, +/// session-scoped flags, and caller-scoped permissions. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SelfUserDto { + /// Full profile — same shape as one row of `/api/admin/users`. + pub full: FullUserDto, + /// Opaque UI preferences bag — my own UI state. Cross-device store + /// for pure UI toggles (view mode, sidebar collapse, hide dotfiles, + /// …). The server never inspects the contents. Always present on + /// the wire; empty bag is `{}`, never `null`. + pub ui_preferences: serde_json::Value, + /// Whether I want share-notification emails. + pub notify_on_share: bool, + /// Session-scoped: my current session carries a DPoP thumbprint. + /// SPA reads this on `session.load()` to skip a redundant + /// `POST /api/auth/dpop/bind` when the session is already bound. + pub is_dpop_bound: bool, + /// Admin-set temp-password gate — SPA nav guard blocks everything + /// but `/change-password` until this flips back. Cleared by a + /// successful `POST /api/auth/change-password`. + pub force_password_change: bool, + /// Caller-scoped permission: can I edit my own avatar? `false` for + /// OIDC users whose avatar comes from the IdP. Only meaningful when + /// caller == subject; nonsense on any other DTO. + pub can_edit_image: bool, +} + +impl From for PublicUserDto { + fn from(user: User) -> Self { + let role = format!("{}", user.role()); + let p = user.into_parts(); + Self { + id: p.id.to_string(), + username: p.username, + email: p.email, + role, + image: p.image, + is_external: p.is_external, + given_name: p.given_name, + family_name: p.family_name, + // Single-user paths that don't enrich presence ship `false`. + // List projections (admin users, sharees enriched with + // presence) build via FullUserDto::build below, which + // overrides this from UserDerivedFlags. + is_online: false, + } + } +} + +impl FullUserDto { + /// Construct a `FullUserDto` from a `User` entity plus the DB-derived + /// flags the entity doesn't carry (`has_password`, OPAQUE flags, + /// `is_online`). Both are typically produced together by the users + /// list repo projection. + /// + /// Not a `From` impl because it takes two arguments; not a `From + /// <(User, UserDerivedFlags)>` because that reads awkwardly at + /// callsites — `FullUserDto::build(user, flags)` is clearer. + pub fn build( + user: User, + flags: crate::domain::repositories::user_repository::UserDerivedFlags, + ) -> Self { + let role = format!("{}", user.role()); + let p = user.into_parts(); + Self { + user: PublicUserDto { + id: p.id.to_string(), + username: p.username, + email: p.email, + role, + image: p.image, + is_external: p.is_external, + given_name: p.given_name, + family_name: p.family_name, + is_online: flags.is_online, + }, + federation_kind: p.federation_kind.map(|k| k.as_str().to_string()), + federation_issuer: p.federation_issuer, + preferred_locale: p.preferred_locale, + email_verified_at: p.email_verified_at, + created_at: p.created_at, + updated_at: p.updated_at, + last_login_at: p.last_login_at, + active: p.active, + storage_quota_bytes: p.storage_quota_bytes, + storage_used_bytes: p.storage_used_bytes, + has_password: flags.has_password, + opaque_registered: flags.opaque_registered, + opaque_migrated: flags.opaque_migrated, + } + } +} + +impl SelfUserDto { + /// Assemble the `/me` response from a `FullUserDto` plus the two + /// session-scoped booleans that can't be derived from `User` alone: + /// the caller's DPoP-binding state (from the JWT `cnf.jkt` claim) + /// and the admin-set force-password-change flag (from the auth + /// service's cache). + /// + /// The other self-only fields (`ui_preferences`, `notify_on_share`, + /// `can_edit_image`) come from `User` and are read off the entity + /// before it's moved into the FullUserDto; this method takes those + /// as explicit parameters so the caller can decide when to read + /// them (typically at the same point they read the DPoP-binding + /// state). + pub fn build( + full: FullUserDto, + ui_preferences: serde_json::Value, + notify_on_share: bool, + is_dpop_bound: bool, + force_password_change: bool, + can_edit_image: bool, + ) -> Self { + Self { + full, + ui_preferences, + notify_on_share, + is_dpop_bound, + force_password_change, + can_edit_image, + } + } +} + +// ──────────────────────────────────────────────────────────────────────── +// End of three-layer user DTO family. +// ──────────────────────────────────────────────────────────────────────── + #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct LoginDto { /// Identifier the user typed. Accepts BOTH a username (no `@`) and diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 7bcb4148..3adaddc4 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -69,6 +69,51 @@ pub struct UserListEntry { /// file without having actually logged in via OPAQUE yet (e.g. /// admin cleared the envelope, silent-migration hasn't re-run). pub opaque_migrated: bool, + /// Optional avatar payload (base64, up to 512 KiB per row). Included + /// on the admin list projection so the SPA can seed its per-user + /// `resolveUser` cache from the list row and skip the follow-up + /// `/api/users/{id}` fetch UserVignette would otherwise trigger. + /// The narrow-projection concern that motivated omitting this + /// column originally is retired by that cache-seeding path — the + /// bytes now do useful work per page load instead of being + /// discarded. Deferred: moving avatar storage out of the row + /// entirely (planned refactor); this shape is transitional. + pub image: Option, + /// Presence signal — TRUE when the server observed a request on + /// any of this user's non-revoked sessions within the last + /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW) + /// (5 min). Populated via an `EXISTS(...)` subquery on + /// `auth.sessions` in the list projection — the partial index + /// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the + /// scan, so per-row cost is ~μs. Surfaces to the FE via + /// `UserDto::is_online` so both `/api/users/{id}` and the admin + /// listing carry it, and the admin table renders a green/grey + /// presence dot next to each vignette. + pub is_online: bool, +} + +/// DB-computed booleans about a user that aren't fields on the +/// [`User`](crate::domain::entities::user::User) entity itself — +/// either derived from column presence (`password_hash IS NOT NULL`) +/// or from a cross-table lookup (`auth.sessions.last_seen_at` for +/// `is_online`). Companion to `User` on the list projection: the +/// repo computes both, the application layer packs them into +/// [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto). +/// +/// Not "admin-only" — every field ends up on `FullUserDto`, which +/// both admin AND self read. The name reflects "derived from the DB +/// row, not intrinsic to the User entity". +/// +/// See `docs/plan/userdto-refactor.md` for the phasing that +/// introduces this type; it will replace [`UserListEntry`] once the +/// list repo is switched from narrow projection to +/// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor). +#[derive(Debug, Clone, Copy)] +pub struct UserDerivedFlags { + pub has_password: bool, + pub opaque_registered: bool, + pub opaque_migrated: bool, + pub is_online: bool, } // Conversion from UserRepositoryError to DomainError diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 6feb033e..06b0fd47 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -858,6 +858,8 @@ impl UserRepository for UserPgRepository { bool, bool, bool, + Option, + bool, ), >( // Auth-credential columns projected as booleans via `IS NOT @@ -871,6 +873,21 @@ impl UserRepository for UserPgRepository { // federation_kind / federation_issuer, the SPA derives the // full "capability set" per user (password / OPAQUE / SSO / // passwordless). + // + // `image` is projected too — the previous narrow projection + // (ROUND12 §Q1 / ROUND13 §Q1) discarded up to 512 KiB per + // row because the admin table never rendered it. That's now + // reversed: the SPA seeds its per-user `resolveUser` cache + // from these rows to kill the N+1 `/api/users/{id}` fetches + // UserVignette would otherwise trigger. + // + // `is_online` uses an EXISTS scalar subquery against + // `auth.sessions` — the partial index + // `idx_sessions_last_seen_at WHERE revoked = FALSE` covers + // the lookup, so per-row cost is ~μs. The window comes from + // `application::dtos::session_dto::ONLINE_WINDOW` (bound as + // `$4` seconds), same single-source-of-truth pattern the + // `session_liveness_gauges` module uses. r#" SELECT id, username, email, role::text, @@ -879,7 +896,14 @@ impl UserRepository for UserPgRepository { federation_kind, federation_issuer, is_external, (password_hash IS NOT NULL) AS has_password, (opaque_envelope IS NOT NULL) AS opaque_registered, - (opaque_migrated_at IS NOT NULL) AS opaque_migrated + (opaque_migrated_at IS NOT NULL) AS opaque_migrated, + image, + EXISTS ( + SELECT 1 FROM auth.sessions s + WHERE s.user_id = auth.users.id + AND s.revoked = FALSE + AND s.last_seen_at > NOW() - make_interval(secs => $4) + ) AS is_online FROM auth.users WHERE ($3 OR is_external = FALSE) ORDER BY created_at DESC, id DESC @@ -889,6 +913,7 @@ impl UserRepository for UserPgRepository { .bind(limit) .bind(offset) .bind(include_external) + .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64()) .fetch_all(self.pool.as_ref()) .await .map_err(Self::map_sqlx_error)?; @@ -911,6 +936,8 @@ impl UserRepository for UserPgRepository { has_password, opaque_registered, opaque_migrated, + image, + is_online, )| UserListEntry { id, username, @@ -930,6 +957,8 @@ impl UserRepository for UserPgRepository { has_password, opaque_registered, opaque_migrated, + image, + is_online, }, ) .collect()) From d17b3b6bd3f42a204a048a1868a87599fee47414 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 15:42:22 +0200 Subject: [PATCH 008/144] refactor(User): wire /api/auth/me to SelfUserDto and /api/admin/users to FullUserDto --- src/application/dtos/user_dto.rs | 7 +- src/application/ports/auth_ports.rs | 30 +++ .../services/auth_application_service.rs | 130 +++++++++-- src/domain/repositories/user_repository.rs | 37 +++ .../repositories/pg/user_pg_repository.rs | 220 ++++++++++++++++++ src/interfaces/api/handlers/admin_handler.rs | 13 +- src/interfaces/api/handlers/auth_handler.rs | 67 ++++-- .../api/handlers/magic_link_handler.rs | 2 +- 8 files changed, 461 insertions(+), 45 deletions(-) diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 3d88b344..7c8feaa6 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -681,7 +681,12 @@ impl UpdateProfileDto { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AuthResponseDto { - pub user: UserDto, + /// Full self view — identical shape to `/api/auth/me`. Every + /// login / refresh / OIDC-callback / magic-link redemption ships + /// this so the SPA's post-auth state matches its post-`/me` state + /// (no UI race between `AuthResponseDto` and the first `/me` + /// fetch). See `docs/plan/userdto-refactor.md` § Endpoint mapping. + pub user: SelfUserDto, pub access_token: String, pub refresh_token: String, pub token_type: String, diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index e7f2deff..dc75e315 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -123,6 +123,36 @@ pub trait UserStoragePort: Send + Sync + 'static { /// Gets a user by ID async fn get_user_by_id(&self, id: Uuid) -> Result; + /// Fetch the full `User` + [`UserDerivedFlags`] in one query. See + /// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags) + /// for the contract and the rationale for the single-query shape. + async fn get_user_with_derived_flags( + &self, + id: Uuid, + ) -> Result< + ( + User, + crate::domain::repositories::user_repository::UserDerivedFlags, + ), + DomainError, + >; + + /// Paginated admin user listing with derived flags. See + /// [`UserRepository::list_users_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::list_users_with_derived_flags) + /// for the contract and rationale. + async fn list_users_with_derived_flags( + &self, + limit: i64, + offset: i64, + include_external: bool, + ) -> Result< + Vec<( + User, + crate::domain::repositories::user_repository::UserDerivedFlags, + )>, + DomainError, + >; + /// Batch-loads users by id. Order is unspecified; missing ids are /// silently dropped. Used by group-recipient expansion in /// `RecipientNotificationService` to avoid N+1 lookups when notifying diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index e042dcee..7c8a70aa 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,6 +1,6 @@ use crate::application::dtos::user_dto::{ - AdminUserSummaryDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, - RegisterDto, UpgradeToInternalDto, UserDto, + AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, RefreshTokenDto, RegisterDto, + SelfUserDto, UpgradeToInternalDto, UserDto, }; use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, @@ -1320,12 +1320,16 @@ impl AuthApplicationService { session = session.with_dpop_jkt(jkt); } + // Build the SelfUserDto BEFORE `session` moves into + // `create_session` — the builder reads `session.dpop_jkt()`. + let user_id = user.id(); + let user_dto = self.build_self_user_dto(user_id, &session).await?; self.session_storage.create_session(session).await?; // Authentication response - let force_password_change = self.read_force_password_change(user.id()).await; + let force_password_change = self.read_force_password_change(user_id).await; Ok(AuthResponseDto { - user: UserDto::from(user), + user: user_dto, access_token, refresh_token, token_type: "Bearer".to_string(), @@ -1334,6 +1338,45 @@ impl AuthApplicationService { }) } + /// Assemble a `SelfUserDto` for the given user + the session that + /// mints them. Called by every `AuthResponseDto` path + /// (login / refresh / OIDC / magic-link) so the wire shape stays + /// consistent across login flavours and matches what `/api/auth/me` + /// would return. + /// + /// Costs one wide SELECT (`get_user_with_derived_flags`) even when + /// the caller already has a `User` in hand — acceptable because + /// `/login`, `/refresh`, and the OIDC/magic-link callbacks are + /// not hot inner loops. In exchange the composition stays uniform + /// across all four callsites and OPAQUE / `is_online` flags land + /// on the wire without a second lookup at each site. + /// + /// `is_dpop_bound` is derived from the session's own DPoP + /// thumbprint — the session was just constructed, so this reads + /// exactly the binding that will govern subsequent requests. + async fn build_self_user_dto( + &self, + user_id: Uuid, + session: &crate::domain::entities::session::Session, + ) -> Result { + let (user, flags) = + UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?; + let can_edit_image = !user.is_oidc_user(); + let ui_preferences = user.ui_preferences().clone(); + let notify_on_share = user.notify_on_share(); + let force_password_change = self.read_force_password_change(user_id).await; + let is_dpop_bound = session.dpop_jkt().is_some(); + let full = FullUserDto::build(user, flags); + Ok(SelfUserDto::build( + full, + ui_preferences, + notify_on_share, + is_dpop_bound, + force_password_change, + can_edit_image, + )) + } + /// Read `force_password_change_at_next_login` for the given user, /// with fail-open semantics on repo error (returns `false` and /// logs a warn). Every callsite that builds an `AuthResponseDto` @@ -1586,22 +1629,29 @@ impl AuthApplicationService { let access_token = self.token_service .generate_access_token(&user, Some(session.id()), None)?; + // Snapshot fields still needed for logging + DTO before + // `session` and `user` are consumed by the storage call and + // the DTO builder below. + let user_id = user.id(); + let user_display = user.display_for_audit().to_string(); + let is_external = user.is_external(); + let user_dto = self.build_self_user_dto(user_id, &session).await?; self.session_storage.create_session(session).await?; tracing::info!( target: "audit", event = "magic_link.redeemed", - user_id = %user.id(), - username = %user.display_for_audit(), - is_external = user.is_external(), + user_id = %user_id, + username = %user_display, + is_external = is_external, resource_kind = ?mlt.resource_kind(), resource_id = ?mlt.resource_id(), cross_browser_confirmed = cross_browser_confirmed, ); - let force_password_change = self.read_force_password_change(user.id()).await; + let force_password_change = self.read_force_password_change(user_id).await; let auth = AuthResponseDto { - user: UserDto::from(user), + user: user_dto, access_token, refresh_token, token_type: "Bearer".to_string(), @@ -1789,6 +1839,12 @@ impl AuthApplicationService { session.dpop_jkt(), )?; + // Build the SelfUserDto before `new_session` is consumed by + // the rotate call — the builder reads `session.dpop_jkt()` + // to compute `is_dpop_bound`. + let user_id = user.id(); + let user_dto = self.build_self_user_dto(user_id, &new_session).await?; + self.session_storage .rotate_session(session.id(), new_session) .await?; @@ -1798,9 +1854,9 @@ impl AuthApplicationService { // initial login. The SPA's post-refresh flow (silent, on // its own timer) can then route the user to change-password // without waiting for an explicit re-login. - let force_password_change = self.read_force_password_change(user.id()).await; + let force_password_change = self.read_force_password_change(user_id).await; Ok(AuthResponseDto { - user: UserDto::from(user), + user: user_dto, access_token, refresh_token: new_refresh_token, token_type: "Bearer".to_string(), @@ -2871,6 +2927,26 @@ impl AuthApplicationService { UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await } + /// Load the full `User` entity + `UserDerivedFlags` for the given + /// id in ONE query. See + /// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags) + /// for the shape and the SELECT that drives it. Used by + /// `/api/auth/me` to build a `SelfUserDto` and by future admin + /// single-user views to build a `FullUserDto` without paying two + /// round-trips. + pub async fn get_user_with_derived_flags( + &self, + user_id: Uuid, + ) -> Result< + ( + crate::domain::entities::user::User, + crate::domain::repositories::user_repository::UserDerivedFlags, + ), + DomainError, + > { + UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await + } + /// Login-style identifier lookup: dispatches on `@` in the input /// (email path when present, username path when not), identical /// to `login()`'s dispatch. Exposed so the OPAQUE login handler @@ -3145,22 +3221,30 @@ impl AuthApplicationService { Ok(users.into_iter().map(UserDto::from).collect()) } - /// Admin-only compact listing. The detail endpoint retains the complete - /// [`UserDto`]; this path projects only what the management table renders so - /// PostgreSQL never detoasts or transfers avatars/preferences for a page. + /// Admin-only user listing. Returns `Vec` — same + /// `FullUserDto` shape [`SelfUserDto`] embeds, so the FE reads + /// admin table rows and `/me` responses through identical field + /// paths. Includes the avatar (`user.image`) and presence + /// (`user.is_online`) so the admin table renders the vignette + + /// green dot without per-row follow-up fetches to + /// `/api/users/{id}` (the N+1 that motivated the widening — see + /// `docs/plan/userdto-refactor.md` § N+1). pub async fn list_user_summaries_including_external_with_perms( &self, authorization: &A, caller_id: Uuid, limit: i64, offset: i64, - ) -> Result, DomainError> { + ) -> Result, DomainError> { self.require_admin_caller(authorization, caller_id).await?; - let users = self + let rows = self .user_storage - .list_user_summaries(limit, offset, true) + .list_users_with_derived_flags(limit, offset, true) .await?; - Ok(users.into_iter().map(AdminUserSummaryDto::from).collect()) + Ok(rows + .into_iter() + .map(|(user, flags)| FullUserDto::build(user, flags)) + .collect()) } /// Service-layer gate for administrator-scoped user-directory operations. @@ -4654,11 +4738,17 @@ impl AuthApplicationService { let access_token = self.token_service .generate_access_token(&user, Some(session.id()), None)?; + // Build the SelfUserDto before `session` is consumed by the + // storage call — the builder reads `session.dpop_jkt()` + // (None here since OIDC callbacks land unbound and the SPA + // finishes binding post-redirect). + let user_id = user.id(); + let user_dto = self.build_self_user_dto(user_id, &session).await?; self.session_storage.create_session(session).await?; - let force_password_change = self.read_force_password_change(user.id()).await; + let force_password_change = self.read_force_password_change(user_id).await; let auth_response = AuthResponseDto { - user: UserDto::from(user), + user: user_dto, access_token, refresh_token, token_type: "Bearer".to_string(), diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 3adaddc4..9bc6bec0 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -139,6 +139,20 @@ pub trait UserRepository: Send + Sync + 'static { /// Gets a user by ID async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult; + /// Fetch the full `User` entity + the [`UserDerivedFlags`] in a + /// single query. Used by `/api/auth/me` and future admin single-user + /// views — anywhere the caller needs both the row itself AND the + /// derived booleans (`has_password`, OPAQUE flags, `is_online`) to + /// build a [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto) + /// or [`SelfUserDto`](crate::application::dtos::user_dto::SelfUserDto). + /// Single query is cheaper than `get_user_by_id` + separate lookups + /// for OPAQUE state + `is_online`; the EXISTS subquery is cheap + /// thanks to the partial index `idx_sessions_last_seen_at`. + async fn get_user_with_derived_flags( + &self, + id: Uuid, + ) -> UserRepositoryResult<(User, UserDerivedFlags)>; + /// Batch-loads a set of users by id, preserving no particular order /// and silently skipping ids that don't match any row. Caller is /// responsible for de-duplicating the input vec. Returns an empty @@ -200,6 +214,12 @@ pub trait UserRepository: Send + Sync + 'static { /// Lists the columns needed by compact user-management tables. Unlike /// [`Self::list_users`], this never fetches password hashes, OIDC subjects, /// avatars, names, locale state, or UI preferences. + /// + /// **Deprecated** — [`Self::list_users_with_derived_flags`] supersedes + /// this: it returns the full `User` entity + [`UserDerivedFlags`] so + /// the application layer can build [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto) + /// directly. Kept only until P6 of `docs/plan/userdto-refactor.md` + /// removes `UserListEntry` + the last remaining caller. async fn list_user_summaries( &self, limit: i64, @@ -207,6 +227,23 @@ pub trait UserRepository: Send + Sync + 'static { include_external: bool, ) -> UserRepositoryResult>; + /// Paginated admin user listing — full `User` entity + the derived + /// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide + /// SELECT. Called by the admin service to build + /// `Vec` for `/api/admin/users` without paying two + /// round-trips per row (once for User, once for derived flags). + /// + /// Same `include_external` semantics as [`Self::list_users`]: + /// admin management UI passes `true`; every other caller passes + /// `false` so external / grant-only users stay off internal-user + /// surfaces. + async fn list_users_with_derived_flags( + &self, + limit: i64, + offset: i64, + include_external: bool, + ) -> UserRepositoryResult>; + /// Searches users by username or email (SQL ILIKE) with a limit. /// See [`list_users`] for the meaning of `include_external`. async fn search_users( diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 06b0fd47..f8d50fda 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -388,6 +388,90 @@ impl UserRepository for UserPgRepository { )) } + async fn get_user_with_derived_flags( + &self, + id: Uuid, + ) -> UserRepositoryResult<( + User, + crate::domain::repositories::user_repository::UserDerivedFlags, + )> { + // Same column set as `get_user_by_id` plus the three IS-NOT-NULL + // derivations for auth-capability flags AND the EXISTS scalar + // for `is_online`. The `interval` argument is bound as `$2` + // (seconds, `ONLINE_WINDOW.as_secs_f64()`) via + // `make_interval(secs => $2)` — same pattern as + // `session_liveness_gauges.rs`. Partial index + // `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the + // EXISTS scan, so per-row cost is ~μs. + let row = sqlx::query( + r#" + 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, + federation_kind, federation_issuer, federation_subject, image, is_external, + given_name, family_name, email_verified_at, preferred_locale, notify_on_share, + ui_preferences, + (password_hash IS NOT NULL) AS has_password, + (opaque_envelope IS NOT NULL) AS opaque_registered, + (opaque_migrated_at IS NOT NULL) AS opaque_migrated, + EXISTS ( + SELECT 1 FROM auth.sessions s + WHERE s.user_id = auth.users.id + AND s.revoked = FALSE + AND s.last_seen_at > NOW() - make_interval(secs => $2) + ) AS is_online + FROM auth.users + WHERE id = $1 + "#, + ) + .bind(id) + .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64()) + .fetch_one(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let role_str: Option = row.try_get("role_text").unwrap_or(None); + let role = match role_str.as_deref() { + Some("admin") => UserRole::Admin, + _ => UserRole::User, + }; + + let user = User::from_data_full( + row.get("id"), + row.get("username"), + row.get("email"), + row.get("password_hash"), + role, + row.get("storage_quota_bytes"), + row.get("storage_used_bytes"), + row.get("created_at"), + row.get("updated_at"), + row.get("last_login_at"), + row.get("active"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), + row.get("image"), + row.get("is_external"), + row.get("given_name"), + row.get("family_name"), + row.get("email_verified_at"), + row.get("preferred_locale"), + row.get("notify_on_share"), + row.get::("ui_preferences"), + ); + let flags = crate::domain::repositories::user_repository::UserDerivedFlags { + has_password: row.get("has_password"), + opaque_registered: row.get("opaque_registered"), + opaque_migrated: row.get("opaque_migrated"), + is_online: row.get("is_online"), + }; + Ok((user, flags)) + } + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult { let row = sqlx::query( @@ -964,6 +1048,110 @@ impl UserRepository for UserPgRepository { .collect()) } + async fn list_users_with_derived_flags( + &self, + limit: i64, + offset: i64, + include_external: bool, + ) -> UserRepositoryResult< + Vec<( + User, + crate::domain::repositories::user_repository::UserDerivedFlags, + )>, + > { + // Full `User` column set (matches `get_user_by_id`) + the four + // derived booleans (IS-NOT-NULL for auth capability, EXISTS for + // `is_online`) in one SELECT. Same rationale as the single-user + // `get_user_with_derived_flags` variant. Widened over the older + // `list_user_summaries` projection because the FE now consumes + // the full user profile from these rows (killing the per-row + // `/api/users/{id}` fetch the admin table used to fire for + // avatars — see docs/plan/userdto-refactor.md § N+1). + // + // `interval` bound as `$4` seconds + // (`ONLINE_WINDOW.as_secs_f64()`), same pattern as + // `session_liveness_gauges.rs` and `get_user_with_derived_flags`. + let rows = sqlx::query( + r#" + 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, + federation_kind, federation_issuer, federation_subject, image, is_external, + given_name, family_name, email_verified_at, preferred_locale, notify_on_share, + ui_preferences, + (password_hash IS NOT NULL) AS has_password_flag, + (opaque_envelope IS NOT NULL) AS opaque_registered, + (opaque_migrated_at IS NOT NULL) AS opaque_migrated, + EXISTS ( + SELECT 1 FROM auth.sessions s + WHERE s.user_id = auth.users.id + AND s.revoked = FALSE + AND s.last_seen_at > NOW() - make_interval(secs => $4) + ) AS is_online + FROM auth.users + WHERE ($3 OR is_external = FALSE) + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .bind(include_external) + .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64()) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + // Note: the `has_password_flag` alias avoids colliding with the + // `password_hash` column selected above (the tuple destructure + // in `list_user_summaries` uses a shorter projection so it + // could reuse the raw `has_password` alias; here we keep both). + Ok(rows + .into_iter() + .map(|row| { + let role_str: Option = row.try_get("role_text").unwrap_or(None); + let role = match role_str.as_deref() { + Some("admin") => UserRole::Admin, + _ => UserRole::User, + }; + let user = User::from_data_full( + row.get("id"), + row.get("username"), + row.get("email"), + row.get("password_hash"), + role, + row.get("storage_quota_bytes"), + row.get("storage_used_bytes"), + row.get("created_at"), + row.get("updated_at"), + row.get("last_login_at"), + row.get("active"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), + row.get("image"), + row.get("is_external"), + row.get("given_name"), + row.get("family_name"), + row.get("email_verified_at"), + row.get("preferred_locale"), + row.get("notify_on_share"), + row.get::("ui_preferences"), + ); + let flags = crate::domain::repositories::user_repository::UserDerivedFlags { + has_password: row.get("has_password_flag"), + opaque_registered: row.get("opaque_registered"), + opaque_migrated: row.get("opaque_migrated"), + is_online: row.get("is_online"), + }; + (user, flags) + }) + .collect()) + } + async fn search_users( &self, query: &str, @@ -1331,6 +1519,21 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } + async fn get_user_with_derived_flags( + &self, + id: Uuid, + ) -> Result< + ( + User, + crate::domain::repositories::user_repository::UserDerivedFlags, + ), + DomainError, + > { + UserRepository::get_user_with_derived_flags(self, id) + .await + .map_err(DomainError::from) + } + async fn get_users_by_ids(&self, ids: Vec) -> Result, DomainError> { UserRepository::get_users_by_ids(self, ids) .await @@ -1396,6 +1599,23 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } + async fn list_users_with_derived_flags( + &self, + limit: i64, + offset: i64, + include_external: bool, + ) -> Result< + Vec<( + User, + crate::domain::repositories::user_repository::UserDerivedFlags, + )>, + DomainError, + > { + UserRepository::list_users_with_derived_flags(self, limit, offset, include_external) + .await + .map_err(DomainError::from) + } + async fn search_users( &self, query: &str, diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 0cb66c26..bb5e12d2 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -22,7 +22,7 @@ use crate::application::dtos::settings_dto::{ TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, }; -use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto}; +use crate::application::dtos::user_dto::{FullUserDto, UserDto}; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; // JobStoreProvider is used only by the storage-migration shims below, @@ -42,8 +42,17 @@ use uuid::Uuid; #[derive(serde::Serialize)] #[serde(untagged)] enum AdminUsersPayload { + /// Fat-`UserDto` per row. Emitted when `?summary=false` — legacy + /// path retained until the FE drops the `summary=false` query + /// (rare; the SPA uses `summary=true` for the paginated table). Full(Vec), - Summary(Vec), + /// `FullUserDto` per row — same shape one row of the /me + /// response's embedded `full` carries. Emitted when + /// `?summary=true`. The FE seeds `resolveUser` cache from + /// `row.user` here (kills the per-row `/api/users/{id}` fetch). + /// The old `AdminUserSummaryDto` returned here has been replaced + /// by `FullUserDto`; see `docs/plan/userdto-refactor.md`. + Summary(Vec), } #[derive(serde::Serialize)] diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 2f9bad63..44cc0171 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -11,9 +11,9 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, - OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto, - UserDto, + AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto, + OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto, + UpgradeToInternalDto, UserDto, }; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::common::di::AppState; @@ -626,7 +626,7 @@ pub async fn refresh_token( get, path = "/api/auth/me", responses( - (status = 200, description = "Current user profile", body = UserDto), + (status = 200, description = "Current user profile", body = SelfUserDto), (status = 401, description = "Not authenticated"), ), security(("bearerAuth" = [])), @@ -654,35 +654,58 @@ pub async fn get_current_user( // never count against this envelope — collaborating in a team drive // costs no personal bytes. The matching cap is // `storage_quota_bytes` (admin-only mutation). - let mut user = auth_service + // + // Single-query fetch: `get_user_with_derived_flags` returns the full + // `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags / + // is_online) in one round-trip. That collapses what used to be a + // `get_user_by_id` + separate credential lookups into one wire trip, + // AND populates the OPAQUE flags on `/me` which the fat-UserDto path + // never did (it left them at false — the "quiet lie" that motivated + // this refactor, see `docs/plan/userdto-refactor.md`). + let (user, flags) = auth_service .auth_application_service - .get_user_by_id(user_id) + .get_user_with_derived_flags(user_id) .await?; + // Read the fields we need before moving `user` into FullUserDto below. + // Ordering matters: `can_edit_image` and the self-only bag fields + // must be captured while `user` is still borrowable; the + // `FullUserDto::build` call downstream consumes the entity. + let can_edit_image = !user.is_oidc_user(); + let ui_preferences = user.ui_preferences().clone(); + let notify_on_share = user.notify_on_share(); + // Overlay the cached `force_password_change` flag (see UserFlags). - // `From` defaults to false; the SPA reads this field on - // startup to decide whether to enter mandatory change-password - // mode. Using the cached path (`get_user_flags` → `user_flags_cache`) + // Using the cached path (`get_user_flags` → `user_flags_cache`) // avoids a second DB round-trip on this hot endpoint. - if let Ok(flags) = auth_service + let force_password_change = auth_service .auth_application_service .get_user_flags(user_id) .await - { - user.force_password_change = flags.force_password_change; - } + .map(|f| f.force_password_change) + .unwrap_or(false); // Session-binding state — read from the JWT `cnf.jkt` claim // (surfaced by the auth middleware into `CurrentUser.dpop_jkt`). // Present ⇒ the session that minted this JWT was bound; absent ⇒ - // the session is unbound and the SPA should call `/dpop/bind` - // to attach the browser's keypair (OIDC / magic-link redirect - // flow). Skips an otherwise-redundant `POST /dpop/bind` on every - // page load which would return 409 `already_bound` and litter - // the audit stream. - user.is_dpop_bound = auth_user.dpop_jkt.is_some(); + // the session is unbound and the SPA should call `/dpop/bind` to + // attach the browser's keypair (OIDC / magic-link redirect flow). + // Skips an otherwise-redundant `POST /dpop/bind` on every page load + // which would return 409 `already_bound` and litter the audit + // stream. + let is_dpop_bound = auth_user.dpop_jkt.is_some(); - Ok((StatusCode::OK, Json(user))) + let full = FullUserDto::build(user, flags); + let self_dto = SelfUserDto::build( + full, + ui_preferences, + notify_on_share, + is_dpop_bound, + force_password_change, + can_edit_image, + ); + + Ok((StatusCode::OK, Json(self_dto))) } /// DTO for updating the user's profile image. @@ -1820,10 +1843,12 @@ pub async fn oidc_exchange( tracing::info!( "OIDC token exchange successful for user: {}", auth_response + .user + .full .user .username .as_deref() - .unwrap_or(&auth_response.user.email) + .unwrap_or(&auth_response.user.full.user.email) ); // Set HttpOnly cookies for the browser diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index aa2c07bf..8a376387 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -625,7 +625,7 @@ fn redirect_target(redemption: &MagicLinkRedemption) -> String { (Some(MagicLinkResourceKind::Folder), Some(folder_id)) => { format!("/files/{}", folder_id) } - _ if redemption.auth.user.is_external => "/shared-with-me".to_string(), + _ if redemption.auth.user.full.user.is_external => "/shared-with-me".to_string(), _ => "/files".to_string(), } } From a11ae679cf7da2e8173974d0a157e2c69bbe3f91 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 16:09:10 +0200 Subject: [PATCH 009/144] refactor(User): move UserDto to PublicUserDto --- src/application/dtos/user_dto.rs | 291 +----------------- src/application/ports/auth_ports.rs | 10 - .../services/auth_application_service.rs | 78 ++--- src/domain/entities/user.rs | 2 +- src/domain/repositories/user_repository.rs | 89 +----- .../repositories/pg/user_pg_repository.rs | 173 +---------- src/interfaces/api/handlers/admin_handler.rs | 8 +- .../api/handlers/app_password_handler.rs | 2 +- src/interfaces/api/handlers/auth_handler.rs | 20 +- .../api/handlers/contacts_handler.rs | 21 +- src/interfaces/api/handlers/users_handler.rs | 2 +- src/interfaces/api/mod.rs | 6 +- src/interfaces/nextcloud/ocs_handler.rs | 40 ++- 13 files changed, 136 insertions(+), 606 deletions(-) diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 7c8feaa6..b3c225c1 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -1,5 +1,4 @@ use crate::domain::entities::user::User; -use crate::domain::repositories::user_repository::UserListEntry; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use smol_str::SmolStr; @@ -7,287 +6,6 @@ use std::sync::Arc; use utoipa::ToSchema; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UserDto { - pub id: String, - /// Optional handle. `None` for users who have not claimed one - /// (externals, fresh email-only signups). Frontend display callers - /// should walk `username → given/family → email` as their fallback - /// chain. Omitted from JSON when None (consistent with the existing - /// given_name / family_name fields). - #[serde(skip_serializing_if = "Option::is_none")] - pub username: Option, - pub email: String, - pub role: String, - pub storage_quota_bytes: i64, - pub storage_used_bytes: i64, - pub created_at: DateTime, - pub updated_at: DateTime, - pub last_login_at: Option>, - pub active: bool, - /// Which trust chain minted this user's federation identity — - /// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local - /// users. Load-bearing for "is this user OIDC?"-shape predicates: - /// use `federation_kind == "oidc"` rather than string-scraping - /// `federation_issuer`. Serialized only when populated. - /// - /// Mirrors `auth.users.federation_kind` verbatim — same name at - /// DB, entity, and wire layers so there's no translation to reason - /// about. See docs/plan/ocm.md § Identity & auth model. - #[serde(skip_serializing_if = "Option::is_none")] - pub federation_kind: Option, - /// The authority that mints this user's `federation_subject` — - /// issuer URL for OIDC (id_token `iss` claim), peer domain for - /// OCM, `null` for local users (password / OPAQUE only). - /// - /// Renamed from `auth_provider` (which was a `String` with the - /// sentinel `"local"` for non-federated users, and a human-readable - /// label like `"MockSSO"` before Phase B). This shape mirrors the - /// `auth.users.federation_issuer` column directly: nullable when - /// there's no federation involved. FE predicates for "is this user - /// federated?" should read `federation_kind`, not - /// string-compare this value. - /// - /// When populated, FE code that wants a friendly display label - /// looks this value up against `OidcProviderInfoDto.issuer → - /// provider_name` to render the deployment's configured display - /// name; falls back to the raw issuer for foreign IdPs / legacy - /// rows still holding a pre-Phase-B label. - #[serde(skip_serializing_if = "Option::is_none")] - pub federation_issuer: Option, - pub image: Option, - pub can_edit_image: bool, - /// `true` for grant-only external recipients (magic-link, OIDC-only, - /// future OCM federated). External users have no home folder and - /// can't own storage; their quota is always 0. Internal users - /// default to `false`. - pub is_external: bool, - /// Optional first/given name. Populated from the OIDC `given_name` - /// claim at JIT provisioning, or via a profile-edit endpoint. - /// `None` until explicitly set — `skip_serializing_if = "Option::is_none"` - /// keeps the wire format compact for the common case. - #[serde(skip_serializing_if = "Option::is_none")] - pub given_name: Option, - /// Optional last/family name. Same provenance + serde rules as - /// `given_name`. - #[serde(skip_serializing_if = "Option::is_none")] - pub family_name: Option, - /// When the user first demonstrated control of their email (PR 23). - /// `None` = unverified (omitted from JSON). Stamped on the first - /// successful magic-link redemption or OIDC JIT with verified - /// claim. Idempotent — the original timestamp is preserved on - /// subsequent verifications. - #[serde(skip_serializing_if = "Option::is_none")] - pub email_verified_at: Option>, - /// User-chosen locale for server-rendered surfaces (emails, - /// future authenticated HTML). `None` = no preference (the server - /// resolves to `OXICLOUD_DEFAULT_LOCALE` when rendering). Round-trips - /// through `/api/auth/me` and `PATCH /api/auth/me/profile`. - #[serde(skip_serializing_if = "Option::is_none")] - pub preferred_locale: Option, - /// Whether the user wants an email when someone shares a resource - /// with them. `true` (default) = receive share-notification mails; - /// `false` = grants are still created but no email is sent. Honored - /// only on the plain-notification path — magic-link first-invitations - /// to brand-new external users always send, otherwise the recipient - /// could never claim the share. Round-trips through `/api/auth/me` - /// and `PATCH /api/auth/me/profile`. - pub notify_on_share: bool, - /// Opaque UI preferences bag. Cross-device store for pure UI - /// toggles (hide dotfiles, view mode, sidebar collapse, …). The - /// server never inspects the contents — this DTO field just echoes - /// what was PATCHed via `PATCH /api/auth/me/profile`. Shape is a - /// JSON object; the frontend defines the keys it cares about (see - /// `frontend/src/lib/stores/preferences.svelte.ts`). Always present - /// on the wire; empty bag is `{}`, never `null`. - pub ui_preferences: serde_json::Value, - /// Mirrors `auth.users.force_password_change_at_next_login`. Set - /// TRUE by the admin password-reset flow (see - /// `AuthApplicationService::admin_reset_password`) and cleared by - /// a successful self-service `POST /api/auth/change-password`. - /// - /// Populated only by the `/api/auth/me` handler and the login - /// response minter (via a distinct code path). `From` — used - /// by admin listings, share-recipient responses, group-member DTOs, - /// etc. — leaves it at `false`. The flag is a per-session-account - /// concern (does *this* user need to change their password before - /// they can proceed?), not a general user attribute worth - /// surfacing on every list row. - /// - /// The load-bearing consumer is the SPA's session store: on - /// startup and after every refresh, `/me` returns the current - /// flag value and the SPA's nav-guard blocks navigation to - /// anything but the change-password surface until it flips - /// back to false. Backend enforcement is separate (see the - /// `require_no_password_change_pending` middleware) — this DTO - /// field is what the SPA reads to render the mandatory-mode UI. - #[serde(default)] - pub force_password_change: bool, - /// TRUE when the account has a local Argon2id `password_hash` on - /// file. Distinct from `federation_kind`: an OIDC-linked account - /// (`federation_kind == "oidc"`) can ALSO carry a local password if - /// it was set at signup or later — a hybrid posture. The SPA - /// gates the profile page's change-password card on this flag, - /// so hybrid users can rotate their local password even though - /// they normally sign in via SSO. - /// - /// Populated only by the `/api/auth/me` handler. `From` in - /// this file leaves it `false` — other UserDto emitters (admin - /// listings, share-recipient responses, group members) do not - /// need to surface per-user credential state. - #[serde(default)] - pub has_password: bool, - /// TRUE when the caller's current session carries a DPoP JWK - /// thumbprint (`session.dpop_jkt IS NOT NULL`). Sourced from the - /// caller's JWT `cnf.jkt` claim — `is_some()` means the session - /// was bound at token-mint time. - /// - /// Populated only by the `/api/auth/me` handler; other UserDto - /// emitters leave it `false`. The SPA reads this on `session.load()` - /// to skip a redundant `POST /api/auth/dpop/bind` call when the - /// session is already bound (which would 409 and log noisily under - /// the audit stream — see the `already_bound` reject). Only the - /// OIDC / magic-link redirect flows land here as `false` on first - /// visit; password login binds at session-mint time so the very - /// first `/me` after login already reports `true`. - #[serde(default)] - pub is_dpop_bound: bool, -} - -/// Compact row returned by the paginated admin user table. -/// -/// Account-detail fields deliberately do not appear here. In particular, -/// omitting `image` and `ui_preferences` prevents a 100-row page from turning -/// into tens of MiB when users have uploaded avatars. `GET /api/admin/users/:id` -/// remains the full-detail endpoint. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct AdminUserSummaryDto { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub username: Option, - pub email: String, - pub role: String, - pub storage_quota_bytes: i64, - pub storage_used_bytes: i64, - pub last_login_at: Option>, - pub active: bool, - /// See `UserDto::federation_kind` — same semantics, same wire spelling. - #[serde(skip_serializing_if = "Option::is_none")] - pub federation_kind: Option, - /// See `UserDto::federation_issuer` — same semantics, same wire spelling. - #[serde(skip_serializing_if = "Option::is_none")] - pub federation_issuer: Option, - pub is_external: bool, - /// TRUE when the user has a server-verifiable password on file - /// (`password_hash IS NOT NULL`). The admin table uses this - /// alongside `federation_issuer` and `opaque_registered` to render - /// the user's full capability set: a `password` chip lights up - /// here, an OIDC provider name renders the SSO badge, an - /// envelope-on-file flips the OPAQUE chip. A user with none of - /// the three is passwordless (magic-link only — the SPA renders - /// a distinct `passwordless` chip in that case). Admin-only - /// exposure — see the DTO doc for why this isn't on `UserDto`. - #[serde(default)] - pub has_password: bool, - /// Mirrors `UserListEntry::opaque_registered` — TRUE when the user - /// has an OPAQUE envelope on file. Surfaced on the admin table so - /// operators can see per-user rollout progress during the - /// migration window. **Admin-only exposure**: this field is NOT - /// on `UserDto` — putting it there would leak adoption status - /// through every user-directory-adjacent endpoint (share targets, - /// group members, invite listings). `#[serde(default)]` keeps - /// older SPA builds tolerant of the added field. - #[serde(default)] - pub opaque_registered: bool, - /// Mirrors `UserListEntry::opaque_migrated` — TRUE when the user - /// has completed at least one successful OPAQUE login. Distinct - /// from `opaque_registered`: an admin can invalidate the envelope - /// (`clear_registration`) leaving the user registered=false but - /// with a historical migrated=true; the SPA's admin table shows - /// both so this operational nuance is visible. - #[serde(default)] - pub opaque_migrated: bool, -} - -impl From for AdminUserSummaryDto { - fn from(entry: UserListEntry) -> Self { - Self { - id: entry.id.to_string(), - username: entry.username, - email: entry.email, - role: entry.role.to_string(), - storage_quota_bytes: entry.storage_quota_bytes, - storage_used_bytes: entry.storage_used_bytes, - last_login_at: entry.last_login_at, - active: entry.active, - federation_kind: entry.federation_kind, - federation_issuer: entry.federation_issuer, - is_external: entry.is_external, - has_password: entry.has_password, - opaque_registered: entry.opaque_registered, - opaque_migrated: entry.opaque_migrated, - } - } -} - -impl From for UserDto { - fn from(user: User) -> Self { - // `user` is owned and dropped here, so every owned field is MOVED out - // via `into_parts` rather than cloned through the borrowing accessors — - // the accessor form deep-cloned `image` (a data URI up to 512 KiB) and - // the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin - // user listing (benches/ROUND20.md §A2). The two derived values read the - // entity before the move. - let role = format!("{}", user.role()); - let can_edit_image = !user.is_oidc_user(); - // has_password is derivable from the entity — read before the - // move. Cheap (bool from Option::is_some), no extra DB round- - // trip, so From can populate it uniformly rather than - // leaving it false and requiring per-call-site backfill. - let has_password = user.has_password(); - let p = user.into_parts(); - Self { - id: p.id.to_string(), - username: p.username, - email: p.email, - role, - storage_quota_bytes: p.storage_quota_bytes, - storage_used_bytes: p.storage_used_bytes, - created_at: p.created_at, - updated_at: p.updated_at, - last_login_at: p.last_login_at, - active: p.active, - // NULL on both fields for local users (no federation wired). - // FE predicates use `!!federation_kind` for "is federated?" — - // no "local" sentinel string; the null tells the whole story. - federation_kind: p.federation_kind.map(|k| k.as_str().to_string()), - federation_issuer: p.federation_issuer, - image: p.image, - can_edit_image, - is_external: p.is_external, - given_name: p.given_name, - family_name: p.family_name, - email_verified_at: p.email_verified_at, - preferred_locale: p.preferred_locale, - notify_on_share: p.notify_on_share, - ui_preferences: p.ui_preferences, - // Defaults to false. The `/me` handler + the login-response - // minter populate this via a distinct code path (a - // repo read that goes through the auth service's cache); - // admin listings and other UserDto consumers deliberately - // leave it false — the flag is per-session-account state, - // not a general user attribute. - force_password_change: false, - has_password, - // Populated only by `/api/auth/me` — the handler overlays - // the caller's session's actual DPoP binding state after - // this `From` runs. Other UserDto emitters leave - // this at `false` (they lack session context). - is_dpop_bound: false, - } - } -} - // ──────────────────────────────────────────────────────────────────────── // Three-layer user DTO family — see docs/plan/userdto-refactor.md. // @@ -301,9 +19,10 @@ impl From for UserDto { // `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned // by /api/auth/me and by every AuthResponseDto path. // -// The fat `UserDto` above is being phased out — the three types will replace -// it and its emitter sites migrate one at a time. Kept temporarily so this -// PR compiles at every checkpoint; deleted at the end of the refactor. +// Adding a field? Decide by audience: +// * Any authenticated caller may see it about another user → `PublicUserDto`. +// * Only admin (about another user) AND self (about self) → `FullUserDto`. +// * Only self about themselves → `SelfUserDto`. // ──────────────────────────────────────────────────────────────────────── /// Public identity — what any authenticated caller may see about ANOTHER @@ -823,7 +542,7 @@ pub struct OidcProviderInfoDto { /// users JIT-provisioned via this IdP. /// /// Populated so the frontend can resolve display: when - /// `UserDto.federation_issuer` equals this `issuer`, render + /// `PublicUserDto.federation_issuer` equals this `issuer`, render /// `provider_name` as the human-friendly label (avoids showing raw /// issuer URLs like `https://sso.example.com/realms/main` in the /// admin badge / profile view). Falls back to the raw issuer when diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index dc75e315..5a7bd955 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -3,7 +3,6 @@ use crate::domain::entities::app_password::AppPassword; use crate::domain::entities::device_code::DeviceCode; use crate::domain::entities::session::Session; use crate::domain::entities::user::User; -use crate::domain::repositories::user_repository::UserListEntry; use std::sync::Arc; use uuid::Uuid; @@ -194,15 +193,6 @@ pub trait UserStoragePort: Send + Sync + 'static { include_external: bool, ) -> Result, DomainError>; - /// Narrow user-list projection for management tables. Keeps heavyweight - /// account-detail fields off the database and JSON hot path. - async fn list_user_summaries( - &self, - limit: i64, - offset: i64, - include_external: bool, - ) -> Result, DomainError>; - /// Searches users by username or email (SQL ILIKE) with a limit. /// See [`list_users`] for the meaning of `include_external`. async fn search_users( diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 7c8a70aa..fc28c146 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,6 +1,6 @@ use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, RefreshTokenDto, RegisterDto, - SelfUserDto, UpgradeToInternalDto, UserDto, + AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, PublicUserDto, RefreshTokenDto, + RegisterDto, SelfUserDto, UpgradeToInternalDto, }; use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, @@ -319,11 +319,11 @@ pub enum OidcCallbackResult { #[derive(Debug, Clone)] pub enum RegisterResult { /// Boxed to avoid the `large_enum_variant` clippy warning — - /// `UserDto` is ~250 bytes, the other variants are zero-sized, + /// `PublicUserDto` is ~250 bytes, the other variants are zero-sized, /// so a heap-pointer indirection keeps the enum's stack size /// small. `register` is called once per request; the /// allocation cost is negligible. - Created(Box), + Created(Box), UsernameTaken, EmailTaken, } @@ -876,7 +876,7 @@ impl AuthApplicationService { is_external = false, "🛂 user registered", ); - Ok(RegisterResult::Created(Box::new(UserDto::from( + Ok(RegisterResult::Created(Box::new(PublicUserDto::from( created_user, )))) } @@ -894,7 +894,7 @@ impl AuthApplicationService { username: String, email: String, password: String, - ) -> Result { + ) -> Result { // Validate username if username.len() < 3 || username.len() > 254 { return Err(DomainError::new( @@ -981,7 +981,7 @@ impl AuthApplicationService { username, created_user.id() ); - Ok(UserDto::from(created_user)) + Ok(PublicUserDto::from(created_user)) } pub async fn login( @@ -2081,7 +2081,7 @@ impl AuthApplicationService { &self, caller_id: Uuid, dto: UpgradeToInternalDto, - ) -> Result { + ) -> Result { let mut user = self.user_storage.get_user_by_id(caller_id).await?; // Precondition: caller is currently external. Fast-path 409 so @@ -2182,7 +2182,7 @@ impl AuthApplicationService { lc.dispatch_upgraded_to_internal(&updated).await; } - Ok(UserDto::from(updated)) + Ok(PublicUserDto::from(updated)) } /// Admin-driven external → internal promotion. @@ -2211,7 +2211,7 @@ impl AuthApplicationService { &self, admin_id: Uuid, target_id: Uuid, - ) -> Result { + ) -> Result { let mut user = self.user_storage.get_user_by_id(target_id).await?; if !user.is_external() { @@ -2293,7 +2293,7 @@ impl AuthApplicationService { "👮🏻‍♂️ external user promoted to internal by admin", ); - Ok(UserDto::from(updated)) + Ok(PublicUserDto::from(updated)) } /// `keep_session_id` — when `Some`, revoke every OTHER session for @@ -2548,9 +2548,9 @@ impl AuthApplicationService { Ok(()) } - pub async fn get_user(&self, user_id: Uuid) -> Result { + pub async fn get_user(&self, user_id: Uuid) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; - Ok(UserDto::from(user)) + Ok(PublicUserDto::from(user)) } /// Cached, image-free lookup of the caller's authorization flags @@ -2699,7 +2699,7 @@ impl AuthApplicationService { caller_id: Uuid, dto: crate::application::dtos::user_dto::UpdateProfileDto, locale_registry: &crate::common::locale::LocaleRegistry, - ) -> Result { + ) -> Result { let mut user = self.user_storage.get_user_by_id(caller_id).await?; // For OIDC-managed users, refuse the patch ONLY when it touches @@ -2875,7 +2875,7 @@ impl AuthApplicationService { if changed.is_empty() && ui_prefs_patch.is_none() { // No-op — return the current user without a DB write. - return Ok(UserDto::from(user)); + return Ok(PublicUserDto::from(user)); } // Persist the typed-field changes first (if any). Skip the @@ -2906,11 +2906,11 @@ impl AuthApplicationService { // Refetch so the returned DTO reflects the merged JSONB bag // (the in-memory `user` above holds the pre-merge value). let refreshed = self.user_storage.get_user_by_id(caller_id).await?; - Ok(UserDto::from(refreshed)) + Ok(PublicUserDto::from(refreshed)) } // Alias for consistency with handler method - pub async fn get_user_by_id(&self, user_id: Uuid) -> Result { + pub async fn get_user_by_id(&self, user_id: Uuid) -> Result { self.get_user(user_id).await } @@ -3003,12 +3003,12 @@ impl AuthApplicationService { target_id: Uuid, expose_system_users: bool, pool: &sqlx::PgPool, - ) -> Result { + ) -> Result { // (1) Self — a single fetch suffices (the check compares the input // UUIDs, so the target read is never needed on this path). if caller_id == target_id { let caller = self.user_storage.get_user_by_id(caller_id).await?; - return Ok(UserDto::from(caller)); + return Ok(PublicUserDto::from(caller)); } // Caller and target are independent point reads (the self-case already @@ -3069,7 +3069,7 @@ impl AuthApplicationService { })?; if related.is_some() { - return Ok(UserDto::from(target)); + return Ok(PublicUserDto::from(target)); } // (3) External callers stop here — no directory enumeration. @@ -3097,12 +3097,12 @@ impl AuthApplicationService { // (4) Internal target + system-address-book exposed: already public. if !target.is_external() && expose_system_users { - return Ok(UserDto::from(target)); + return Ok(PublicUserDto::from(target)); } // (5) Admin caller: always visible. if caller.role() == UserRole::Admin { - return Ok(UserDto::from(target)); + return Ok(PublicUserDto::from(target)); } // (6) No relationship — anti-enumeration NotFound. @@ -3155,7 +3155,7 @@ impl AuthApplicationService { username: &str, expose_system_users: bool, pool: &sqlx::PgPool, - ) -> Result { + ) -> Result { let target = match self.user_storage.get_user_by_username(username).await { Ok(u) => u, Err(e) if e.kind == ErrorKind::NotFound => { @@ -3182,9 +3182,9 @@ impl AuthApplicationService { } // New method to get user by username - needed for admin user handling - pub async fn get_user_by_username(&self, username: &str) -> Result { + pub async fn get_user_by_username(&self, username: &str) -> Result { let user = self.user_storage.get_user_by_username(username).await?; - Ok(UserDto::from(user)) + Ok(PublicUserDto::from(user)) } // Method to count how many admin users exist in the system @@ -3202,9 +3202,13 @@ impl AuthApplicationService { /// sharee search, etc. — never expose external identities. Admin /// surfaces that need the full list should call /// [`list_users_including_external_with_perms`] instead. - pub async fn list_users(&self, limit: i64, offset: i64) -> Result, DomainError> { + pub async fn list_users( + &self, + limit: i64, + offset: i64, + ) -> Result, DomainError> { let users = self.user_storage.list_users(limit, offset, false).await?; - Ok(users.into_iter().map(UserDto::from).collect()) + Ok(users.into_iter().map(PublicUserDto::from).collect()) } /// Admin-only: lists users including external (grant-only) recipients. @@ -3215,10 +3219,10 @@ impl AuthApplicationService { caller_id: Uuid, limit: i64, offset: i64, - ) -> Result, DomainError> { + ) -> Result, DomainError> { self.require_admin_caller(authorization, caller_id).await?; let users = self.user_storage.list_users(limit, offset, true).await?; - Ok(users.into_iter().map(UserDto::from).collect()) + Ok(users.into_iter().map(PublicUserDto::from).collect()) } /// Admin-only user listing. Returns `Vec` — same @@ -3267,9 +3271,13 @@ impl AuthApplicationService { } /// Searches internal users only. See [`list_users`] for the rationale. - pub async fn search_users(&self, query: &str, limit: i64) -> Result, DomainError> { + pub async fn search_users( + &self, + query: &str, + limit: i64, + ) -> Result, DomainError> { let users = self.user_storage.search_users(query, limit, false).await?; - Ok(users.into_iter().map(UserDto::from).collect()) + Ok(users.into_iter().map(PublicUserDto::from).collect()) } /// Username-only search for the NC sharee autocomplete: identical @@ -3375,7 +3383,7 @@ impl AuthApplicationService { pub async fn admin_create_user( &self, dto: crate::application::dtos::settings_dto::AdminCreateUserDto, - ) -> Result { + ) -> Result { // Validate username length if dto.username.len() < 3 || dto.username.len() > 254 { return Err(DomainError::new( @@ -3533,7 +3541,7 @@ impl AuthApplicationService { created.id(), created.is_external() ); - Ok(UserDto::from(created)) + Ok(PublicUserDto::from(created)) } /// Admin-only: reset a user's password. @@ -3630,9 +3638,9 @@ impl AuthApplicationService { } /// Get a single user by ID (for admin panel) - pub async fn get_user_admin(&self, user_id: Uuid) -> Result { + pub async fn get_user_admin(&self, user_id: Uuid) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; - Ok(UserDto::from(user)) + Ok(PublicUserDto::from(user)) } /// Delete a user by ID (admin only). diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index 37a0e3ed..5e2c8692 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -207,7 +207,7 @@ pub struct User { /// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` / /// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning /// them through the borrowing accessors — notably `image` (a data URI up to -/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from` +/// 512 KiB) and `ui_preferences` (a JSON tree). See `PublicUserDto::from` /// (benches/ROUND20.md §A2). pub struct UserParts { pub id: Uuid, diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 9bc6bec0..5080a51b 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -1,6 +1,5 @@ use crate::common::errors::DomainError; use crate::domain::entities::user::{User, UserRole}; -use chrono::{DateTime, Utc}; use uuid::Uuid; #[derive(Debug, thiserror::Error)] @@ -26,72 +25,6 @@ pub enum UserRepositoryError { pub type UserRepositoryResult = Result; -/// Narrow projection for user-directory tables that do not need secrets, -/// profile pictures, or the cross-device UI-preferences document. -/// -/// The full [`User`] row intentionally carries all of those fields for account -/// detail and the system address book. Reusing it for the paginated admin -/// table made PostgreSQL detoast and transfer an avatar of up to 512 KiB per -/// row, only for the handler to serialize it back to the browser where the -/// table never reads it. Keeping the projection explicit prevents a future -/// full-row field from silently returning to that hot path. -#[derive(Debug, Clone)] -pub struct UserListEntry { - pub id: Uuid, - pub username: Option, - pub email: String, - pub role: UserRole, - pub storage_quota_bytes: i64, - pub storage_used_bytes: i64, - pub last_login_at: Option>, - pub active: bool, - pub federation_kind: Option, - pub federation_issuer: Option, - pub is_external: bool, - /// TRUE when `auth.users.password_hash IS NOT NULL` — user has a - /// server-verifiable password on file (legacy or admin-set). - /// Distinct from `opaque_registered` (which is the zero-knowledge - /// envelope): a fully-migrated user carries BOTH — password for - /// the fallback / operator flows, envelope for the actual login. - /// A user with `has_password = false AND !opaque_registered AND - /// federation_issuer IS NULL` is passwordless — the only path in is - /// via magic-link (or, for externals, whatever grant they hold). - pub has_password: bool, - /// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user - /// has completed OPAQUE registration (typically via the Phase 2 - /// silent-migration hook after a successful legacy login). Surfaced - /// on the admin user table so operators can see rollout progress - /// per-user. Admin-only exposure — see `AdminUserSummaryDto`. - pub opaque_registered: bool, - /// TRUE when `auth.users.opaque_migrated_at IS NOT NULL` — the - /// user has completed at least one successful OPAQUE login. Distinct - /// from `opaque_registered` because a user can have an envelope on - /// file without having actually logged in via OPAQUE yet (e.g. - /// admin cleared the envelope, silent-migration hasn't re-run). - pub opaque_migrated: bool, - /// Optional avatar payload (base64, up to 512 KiB per row). Included - /// on the admin list projection so the SPA can seed its per-user - /// `resolveUser` cache from the list row and skip the follow-up - /// `/api/users/{id}` fetch UserVignette would otherwise trigger. - /// The narrow-projection concern that motivated omitting this - /// column originally is retired by that cache-seeding path — the - /// bytes now do useful work per page load instead of being - /// discarded. Deferred: moving avatar storage out of the row - /// entirely (planned refactor); this shape is transitional. - pub image: Option, - /// Presence signal — TRUE when the server observed a request on - /// any of this user's non-revoked sessions within the last - /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW) - /// (5 min). Populated via an `EXISTS(...)` subquery on - /// `auth.sessions` in the list projection — the partial index - /// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the - /// scan, so per-row cost is ~μs. Surfaces to the FE via - /// `UserDto::is_online` so both `/api/users/{id}` and the admin - /// listing carry it, and the admin table renders a green/grey - /// presence dot next to each vignette. - pub is_online: bool, -} - /// DB-computed booleans about a user that aren't fields on the /// [`User`](crate::domain::entities::user::User) entity itself — /// either derived from column presence (`password_hash IS NOT NULL`) @@ -104,10 +37,8 @@ pub struct UserListEntry { /// both admin AND self read. The name reflects "derived from the DB /// row, not intrinsic to the User entity". /// -/// See `docs/plan/userdto-refactor.md` for the phasing that -/// introduces this type; it will replace [`UserListEntry`] once the -/// list repo is switched from narrow projection to -/// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor). +/// See `docs/plan/userdto-refactor.md` for the design; this type +/// replaced the earlier `UserListEntry` narrow projection as of P6. #[derive(Debug, Clone, Copy)] pub struct UserDerivedFlags { pub has_password: bool, @@ -211,22 +142,6 @@ pub trait UserRepository: Send + Sync + 'static { include_external: bool, ) -> UserRepositoryResult>; - /// Lists the columns needed by compact user-management tables. Unlike - /// [`Self::list_users`], this never fetches password hashes, OIDC subjects, - /// avatars, names, locale state, or UI preferences. - /// - /// **Deprecated** — [`Self::list_users_with_derived_flags`] supersedes - /// this: it returns the full `User` entity + [`UserDerivedFlags`] so - /// the application layer can build [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto) - /// directly. Kept only until P6 of `docs/plan/userdto-refactor.md` - /// removes `UserListEntry` + the last remaining caller. - async fn list_user_summaries( - &self, - limit: i64, - offset: i64, - include_external: bool, - ) -> UserRepositoryResult>; - /// Paginated admin user listing — full `User` entity + the derived /// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide /// SELECT. Called by the admin service to build diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index f8d50fda..7dd3bae0 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::UserStoragePort; use crate::common::errors::DomainError; use crate::domain::entities::user::{User, UserFlags, UserRole}; use crate::domain::repositories::user_repository::{ - StorageStats, UserListEntry, UserRepository, UserRepositoryError, UserRepositoryResult, + StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult, }; use crate::infrastructure::repositories::pg::transaction_utils::with_transaction; @@ -919,135 +919,6 @@ impl UserRepository for UserPgRepository { Ok(users) } - async fn list_user_summaries( - &self, - limit: i64, - offset: i64, - include_external: bool, - ) -> UserRepositoryResult> { - let rows = sqlx::query_as::< - _, - ( - Uuid, - Option, - String, - String, - i64, - i64, - Option>, - bool, - Option, - Option, - bool, - bool, - bool, - bool, - Option, - bool, - ), - >( - // Auth-credential columns projected as booleans via `IS NOT - // NULL` rather than as timestamps / hashes so the row-mapping - // tuple stays small and the wire shape is exactly what the - // admin table needs. Per-row scalar tests — no cost beyond - // the full-table sequential scan the LIMIT/OFFSET already - // pays. `has_password` on the password_hash column tells - // the admin table whether a server-verifiable password is - // on file; combined with the two OPAQUE flags and - // federation_kind / federation_issuer, the SPA derives the - // full "capability set" per user (password / OPAQUE / SSO / - // passwordless). - // - // `image` is projected too — the previous narrow projection - // (ROUND12 §Q1 / ROUND13 §Q1) discarded up to 512 KiB per - // row because the admin table never rendered it. That's now - // reversed: the SPA seeds its per-user `resolveUser` cache - // from these rows to kill the N+1 `/api/users/{id}` fetches - // UserVignette would otherwise trigger. - // - // `is_online` uses an EXISTS scalar subquery against - // `auth.sessions` — the partial index - // `idx_sessions_last_seen_at WHERE revoked = FALSE` covers - // the lookup, so per-row cost is ~μs. The window comes from - // `application::dtos::session_dto::ONLINE_WINDOW` (bound as - // `$4` seconds), same single-source-of-truth pattern the - // `session_liveness_gauges` module uses. - r#" - SELECT - id, username, email, role::text, - storage_quota_bytes, storage_used_bytes, - last_login_at, active, - federation_kind, federation_issuer, is_external, - (password_hash IS NOT NULL) AS has_password, - (opaque_envelope IS NOT NULL) AS opaque_registered, - (opaque_migrated_at IS NOT NULL) AS opaque_migrated, - image, - EXISTS ( - SELECT 1 FROM auth.sessions s - WHERE s.user_id = auth.users.id - AND s.revoked = FALSE - AND s.last_seen_at > NOW() - make_interval(secs => $4) - ) AS is_online - FROM auth.users - WHERE ($3 OR is_external = FALSE) - ORDER BY created_at DESC, id DESC - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit) - .bind(offset) - .bind(include_external) - .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64()) - .fetch_all(self.pool.as_ref()) - .await - .map_err(Self::map_sqlx_error)?; - - Ok(rows - .into_iter() - .map( - |( - id, - username, - email, - role, - storage_quota_bytes, - storage_used_bytes, - last_login_at, - active, - federation_kind, - federation_issuer, - is_external, - has_password, - opaque_registered, - opaque_migrated, - image, - is_online, - )| UserListEntry { - id, - username, - email, - role: if role == "admin" { - UserRole::Admin - } else { - UserRole::User - }, - storage_quota_bytes, - storage_used_bytes, - last_login_at, - active, - federation_kind, - federation_issuer, - is_external, - has_password, - opaque_registered, - opaque_migrated, - image, - is_online, - }, - ) - .collect()) - } - async fn list_users_with_derived_flags( &self, limit: i64, @@ -1588,17 +1459,6 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } - async fn list_user_summaries( - &self, - limit: i64, - offset: i64, - include_external: bool, - ) -> Result, DomainError> { - UserRepository::list_user_summaries(self, limit, offset, include_external) - .await - .map_err(DomainError::from) - } - async fn list_users_with_derived_flags( &self, limit: i64, @@ -1992,26 +1852,27 @@ mod integration_tests { ) .await; - let page = UserRepository::list_user_summaries(&repo, 3, 0, true) + // Migrated from the (now-deleted) `list_user_summaries` + + // `UserListEntry` to `list_users_with_derived_flags`, which + // returns `Vec<(User, UserDerivedFlags)>`. Field checks read + // through the `User` accessors instead of struct-field access. + let page = UserRepository::list_users_with_derived_flags(&repo, 3, 0, true) .await .expect("compact projection query must decode"); - assert_eq!(page.iter().map(|entry| entry.id).collect::>(), ids); - assert_eq!(page[0].username.as_deref(), Some(username_a.as_str())); - assert_eq!(page[0].role, UserRole::Admin); - assert_eq!(page[0].storage_quota_bytes, 10_737_418_240); - assert_eq!(page[1].username, None); - assert!(page[1].is_external); - assert_eq!( - page[1].federation_issuer.as_deref(), - Some("integration-idp") - ); + assert_eq!(page.iter().map(|(u, _)| u.id()).collect::>(), ids); + assert_eq!(page[0].0.username(), Some(username_a.as_str())); + assert_eq!(page[0].0.role(), UserRole::Admin); + assert_eq!(page[0].0.storage_quota_bytes(), 10_737_418_240); + assert_eq!(page[1].0.username(), None); + assert!(page[1].0.is_external()); + assert_eq!(page[1].0.federation_issuer(), Some("integration-idp")); - let internal = UserRepository::list_user_summaries(&repo, 10, 0, false) + let internal = UserRepository::list_users_with_derived_flags(&repo, 10, 0, false) .await .expect("internal compact projection query must decode"); - assert!(internal.iter().any(|entry| entry.id == ids[0])); - assert!(internal.iter().any(|entry| entry.id == ids[2])); - assert!(!internal.iter().any(|entry| entry.id == ids[1])); + assert!(internal.iter().any(|(u, _)| u.id() == ids[0])); + assert!(internal.iter().any(|(u, _)| u.id() == ids[2])); + assert!(!internal.iter().any(|(u, _)| u.id() == ids[1])); sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)") .bind(ids.as_slice()) diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index bb5e12d2..b727b5a1 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -22,7 +22,7 @@ use crate::application::dtos::settings_dto::{ TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, }; -use crate::application::dtos::user_dto::{FullUserDto, UserDto}; +use crate::application::dtos::user_dto::{FullUserDto, PublicUserDto}; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; // JobStoreProvider is used only by the storage-migration shims below, @@ -42,10 +42,10 @@ use uuid::Uuid; #[derive(serde::Serialize)] #[serde(untagged)] enum AdminUsersPayload { - /// Fat-`UserDto` per row. Emitted when `?summary=false` — legacy + /// Fat-`PublicUserDto` per row. Emitted when `?summary=false` — legacy /// path retained until the FE drops the `summary=false` query /// (rare; the SPA uses `summary=true` for the paginated table). - Full(Vec), + Full(Vec), /// `FullUserDto` per row — same shape one row of the /me /// response's embedded `full` carries. Emitted when /// `?summary=true`. The FE seeds `resolveUser` cache from @@ -1571,7 +1571,7 @@ pub async fn reset_user_password( path = "/api/admin/users/{id}/promote-to-internal", params(("id" = String, Path, description = "Target user id")), responses( - (status = 200, description = "User promoted", body = UserDto), + (status = 200, description = "User promoted", body = PublicUserDto), (status = 400, description = "Magic-link login is disabled on this deployment"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required (or target is OIDC-linked)"), diff --git a/src/interfaces/api/handlers/app_password_handler.rs b/src/interfaces/api/handlers/app_password_handler.rs index f2b36b14..ed8a9023 100644 --- a/src/interfaces/api/handlers/app_password_handler.rs +++ b/src/interfaces/api/handlers/app_password_handler.rs @@ -61,7 +61,7 @@ async fn create_app_password( } // Require a claimed username. NextCloud Basic Auth resolves users by - // username; an app password is unusable without one. UserDto carries + // username; an app password is unusable without one. PublicUserDto carries // an empty string when the underlying `users.username` is NULL — the // entity rejects empty strings on construction, so empty here is an // unambiguous signal that the column is NULL. diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 44cc0171..18d4f129 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -12,8 +12,8 @@ use uuid::Uuid; use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto, - OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto, - UpgradeToInternalDto, UserDto, + OidcExchangeDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, + SetupAdminDto, UpgradeToInternalDto, }; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::common::di::AppState; @@ -89,7 +89,7 @@ pub fn setup_route() -> Router> { /// the `audit` channel as `auth.register` with `reason` one of /// `created`, `email_taken`, `username_taken`. /// - **SMTP not configured**: there is no welcome-mail cover story, so -/// the classic `201 + UserDto` on success and `409` on collision +/// the classic `201 + PublicUserDto` on success and `409` on collision /// apply. Anti-enumeration would just be misleading UX (telling the /// user to check an email that will never arrive). Email-only /// signup is **503** in this mode because the user would otherwise @@ -106,7 +106,7 @@ pub fn setup_route() -> Router> { request_body = RegisterDto, responses( (status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"), - (status = 201, description = "User registered successfully (SMTP not configured)", body = UserDto), + (status = 201, description = "User registered successfully (SMTP not configured)", body = PublicUserDto), (status = 400, description = "Validation error (malformed request body)"), (status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"), (status = 409, description = "Username or email already taken (SMTP not configured)"), @@ -280,7 +280,7 @@ pub async fn register( } Ok(resp) } else { - // Classic mode: clear 201 + UserDto so the frontend can + // Classic mode: clear 201 + PublicUserDto so the frontend can // log the user in directly with the password they just // submitted. Unbox the DTO for the JSON serialisation. Ok((StatusCode::CREATED, Json(*user)).into_response()) @@ -659,7 +659,7 @@ pub async fn get_current_user( // `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags / // is_online) in one round-trip. That collapses what used to be a // `get_user_by_id` + separate credential lookups into one wire trip, - // AND populates the OPAQUE flags on `/me` which the fat-UserDto path + // AND populates the OPAQUE flags on `/me` which the fat-PublicUserDto path // never did (it left them at false — the "quiet lie" that motivated // this refactor, see `docs/plan/userdto-refactor.md`). let (user, flags) = auth_service @@ -860,14 +860,14 @@ pub async fn change_password( /// self-registration policy. Refused with 403 /// `error_type = "RegistrationDomainNotAllowed"`. /// -/// Response: the updated `UserDto` (post-upgrade view — `is_external` +/// Response: the updated `PublicUserDto` (post-upgrade view — `is_external` /// is false, `storage_quota_bytes` is set). #[utoipa::path( post, path = "/api/auth/upgrade-to-internal", request_body = UpgradeToInternalDto, responses( - (status = 200, description = "Upgrade succeeded", body = UserDto), + (status = 200, description = "Upgrade succeeded", body = PublicUserDto), (status = 400, description = "Password missing / too short"), (status = 401, description = "Not authenticated"), (status = 403, description = "OIDC user, or domain not in allowlist"), @@ -965,7 +965,7 @@ pub async fn upgrade_to_internal( path = "/api/auth/me/profile", request_body = crate::application::dtos::user_dto::UpdateProfileDto, responses( - (status = 200, description = "Updated profile (UserDto)", body = UserDto), + (status = 200, description = "Updated profile (PublicUserDto)", body = PublicUserDto), (status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"), (status = 401, description = "Not authenticated"), (status = 403, description = "OIDC-managed profile — edit at the IdP"), @@ -1249,7 +1249,7 @@ pub struct BackchannelLogoutForm { path = "/api/setup", request_body = SetupAdminDto, responses( - (status = 201, description = "First admin created and system initialized", body = UserDto), + (status = 201, description = "First admin created and system initialized", body = PublicUserDto), (status = 403, description = "System already initialized"), (status = 503, description = "Auth service not configured"), ), diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs index b1b495f9..22fb9f22 100644 --- a/src/interfaces/api/handlers/contacts_handler.rs +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -16,7 +16,7 @@ use crate::application::dtos::contact_dto::{ AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto, GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto, }; -use crate::application::dtos::user_dto::UserDto; +use crate::application::dtos::user_dto::PublicUserDto; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::contact_service::ContactService; @@ -185,14 +185,14 @@ fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool { } } -/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts +/// Map a `PublicUserDto` to a `ContactDto` so OxiCloud users appear as contacts /// inside the virtual system address book. /// /// `given_name`/`family_name` come from OIDC standard claims at JIT /// provisioning (or NULL for password-only or pre-OIDC users). When /// they're present, prefer a "First Last" full name; otherwise fall /// back to the username (which is always present). -fn user_to_contact(user: UserDto) -> ContactDto { +fn user_to_contact(user: PublicUserDto) -> ContactDto { // Display fallback chain: given+family name → username → email. // Username is `Option` post PR 16; externals start with None. let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) { @@ -222,8 +222,19 @@ fn user_to_contact(user: UserDto) -> ContactDto { photo_url: user.image.clone(), birthday: None, anniversary: None, - created_at: user.created_at, - updated_at: user.updated_at, + // System-book contacts are VIRTUAL projections of the user + // directory — they have no independent creation history. Stamp + // both timestamps with `Utc::now()` so the ContactDto shape is + // satisfied; CardDAV clients ETag on the vCard content (see + // `etag` below, keyed on the stable user id), not on these + // wrapper timestamps. + // + // Previously read `user.created_at` / `user.updated_at` from the + // fat `UserDto`; those fields moved to `FullUserDto` under the + // three-layer refactor (docs/plan/userdto-refactor.md) and are + // not exposed on the slim `PublicUserDto` this function receives. + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), etag: user.id, } } diff --git a/src/interfaces/api/handlers/users_handler.rs b/src/interfaces/api/handlers/users_handler.rs index b2d0d943..f6aee02e 100644 --- a/src/interfaces/api/handlers/users_handler.rs +++ b/src/interfaces/api/handlers/users_handler.rs @@ -1,6 +1,6 @@ //! User-profile lookup for the frontend. //! -//! `GET /api/users/{id}` returns a [`UserDto`] for the target user iff +//! `GET /api/users/{id}` returns a [`PublicUserDto`] for the target user iff //! the authenticated caller has a legitimate relationship with them. //! The visibility rule lives in //! [`AuthApplicationService::get_user_profile`] — handlers never embed diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 03d7008e..9bc4cfcc 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -46,7 +46,7 @@ use crate::application::dtos::trash_dto::{ }; use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto, - RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, + PublicUserDto, RefreshTokenDto, RegisterDto, SetupAdminDto, }; use crate::application::ports::chunked_upload_ports::{ ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto, @@ -367,7 +367,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; PaginationDto, PaginationRequestDto, // User / Auth schemas - UserDto, + PublicUserDto, LoginDto, RegisterDto, SetupAdminDto, @@ -583,7 +583,7 @@ mod tests { "FolderDto", "ShareDto", "TrashedItemDto", - "UserDto", + "PublicUserDto", ] { assert!(schemas.contains_key(name), "missing schema: {name}"); } diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index da1c19b1..39e832d8 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -191,7 +191,15 @@ async fn user_provisioning_response( return Json(ocs_err(997, "Database pool not available")).into_response(); }; - let user_dto = match auth_service + // Two-step lookup: (1) `get_user_profile_by_username_with_perms` + // gates access via the same visibility engine the REST endpoint + // uses; (2) if visibility passes, `get_user_with_derived_flags` + // hydrates the OCS-specific fields (federation_kind / last_login_at + // / active) that live on `FullUserDto` but not on the slim + // `PublicUserDto` returned by the visibility gate. Second call is + // ~1 DB round-trip on the maintenance pool; NC OCS provisioning is + // not on any hot inner loop. + let public = match auth_service .get_user_profile_by_username_with_perms( user.id, &userid, @@ -205,9 +213,27 @@ async fn user_provisioning_response( return Json(ocs_err(404, "User not found")).into_response(); } }; + let target_id = match uuid::Uuid::parse_str(&public.id) { + Ok(u) => u, + Err(_) => { + // Should be unreachable — PublicUserDto.id is always the + // serialised form of a Uuid. Fail closed if this invariant + // is ever violated. + return Json(ocs_err(500, "Malformed user id")).into_response(); + } + }; + let user_dto = match auth_service.get_user_with_derived_flags(target_id).await { + Ok((user, flags)) => crate::application::dtos::user_dto::FullUserDto::build(user, flags), + Err(_) => { + // Visibility already passed above; a miss here would mean + // the user was deleted between the two round-trips. Fall + // back to the 404 shape (anti-enum invariant still holds). + return Json(ocs_err(404, "User not found")).into_response(); + } + }; // Determine groups based on role - let groups = if user_dto.role == "admin" { + let groups = if user_dto.user.role == "admin" { vec!["admin", "users"] } else { vec!["users"] @@ -235,7 +261,7 @@ async fn user_provisioning_response( // Fetch quota from storage usage service let quota: (i64, i64) = match state.storage_usage_service.as_ref() { Some(service) => match service - .get_user_storage_info(uuid::Uuid::parse_str(&user_dto.id).unwrap_or_default()) + .get_user_storage_info(uuid::Uuid::parse_str(&user_dto.user.id).unwrap_or_default()) .await { Ok((used, total)) => (used, total), @@ -256,10 +282,10 @@ async fn user_provisioning_response( "meta": { "status": "ok", "statuscode": statuscode, "message": "OK" }, "data": { "enabled": user_dto.active, - "id": user_dto.username, - "display-name": user_dto.username, - "displayname": user_dto.username, - "email": user_dto.email, + "id": user_dto.user.username, + "display-name": user_dto.user.username, + "displayname": user_dto.user.username, + "email": user_dto.user.email, "phone": "", "address": "", "website": "", From ec9b5087f3ced7a7dbe055917edf62c455cf22ca Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 16:49:32 +0200 Subject: [PATCH 010/144] refactor(User): apply changes on frontend --- docs/plan/userdto-refactor.md | 11 + frontend/src/lib/api/endpoints/auth.ts | 14 +- frontend/src/lib/api/endpoints/profile.ts | 6 +- frontend/src/lib/api/endpoints/users.ts | 32 +++ frontend/src/lib/api/types.ts | 220 ++++++++---------- frontend/src/lib/components/AppShell.svelte | 26 +-- frontend/src/lib/components/AppShell.test.ts | 31 ++- frontend/src/lib/stores/preferences.svelte.ts | 20 +- .../src/lib/stores/session.svelte.test.ts | 17 +- frontend/src/lib/stores/session.svelte.ts | 56 +++-- .../src/routes/admin/[[tab]]/+page.svelte | 113 +++++---- .../src/routes/admin/[[tab]]/page.test.ts | 20 +- frontend/src/routes/profile/+page.svelte | 86 ++++--- frontend/src/routes/profile/page.test.ts | 67 ++++-- src/application/dtos/user_dto.rs | 139 +++++++++++ 15 files changed, 558 insertions(+), 300 deletions(-) diff --git a/docs/plan/userdto-refactor.md b/docs/plan/userdto-refactor.md index 17fe98aa..fdf8de8e 100644 --- a/docs/plan/userdto-refactor.md +++ b/docs/plan/userdto-refactor.md @@ -1,5 +1,16 @@ # UserDto Refactor — Three-Layer Split (Public / Full / Self) +> **Status — SHIPPED 2026-08-21.** All eight phases landed and all gates +> pass: `cargo clippy --all-targets --all-features -D warnings` clean, +> `cargo fmt --check` clean, `cargo test three_layer_quarantine` (2/2 +> structural-quarantine tests pass), `npm run check` (593 files, 0 +> errors, 0 warnings), `npm run test:unit` (414 pass / 1 skipped / 0 +> failed), OpenAPI regenerated at `resources/gen/openapi.json`. See the +> [Phasing](#phasing) section below for the per-step outcome. The doc +> is retained as the reference for anyone extending the three-layer +> shape (new field → decide by audience per the rule in the opening +> section). + Establish three DTO shapes for representing a user on the wire, each with a single unambiguous audience, composed hierarchically so the overlap between audiences is defined ONCE: diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 1e8fff43..b6685dd6 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -5,7 +5,7 @@ */ import { ApiError, apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { AuthResponse, User } from '$lib/api/types'; +import type { AuthResponse, SelfUser } from '$lib/api/types'; /** * Best-effort parse of the backend `ErrorResponse` shape @@ -45,7 +45,7 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' }; * Failure to build a proof (no keypair, missing WebCrypto) falls back to a * headerless request — the server still accepts it for unbound sessions. */ -export async function fetchMe(): Promise { +export async function fetchMe(): Promise { // Build + sign a DPoP proof, send with the header, harvest any // `DPoP-Nonce` off the response into the shared client cache // (so the NEXT apiFetch call reuses it — no wasted round trip). @@ -80,7 +80,7 @@ export async function fetchMe(): Promise { if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); if (res.status === 401) return null; if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); - return (await res.json()) as User; + return (await res.json()) as SelfUser; } /** @@ -408,7 +408,7 @@ export async function setupAdmin(email: string, password: string): Promise * failure, not an expired access token. Returns the user on success, null on * any failure so the caller can fall through to the normal login UI. */ -export async function exchangeOidcCode(code: string): Promise { +export async function exchangeOidcCode(code: string): Promise { try { const res = await fetch('/api/auth/oidc/exchange', { method: 'POST', @@ -417,7 +417,7 @@ export async function exchangeOidcCode(code: string): Promise { body: JSON.stringify({ code }) }); if (!res.ok) return null; - const data = (await res.json()) as { user?: User }; + const data = (await res.json()) as { user?: SelfUser }; return data.user ?? null; } catch { return null; @@ -463,7 +463,7 @@ export async function register(email: string, password?: string, username?: stri * authenticated; a 401 here IS a genuine "session expired" and the * refresh interceptor is the right response. */ -export async function upgradeToInternal(password?: string): Promise { +export async function upgradeToInternal(password?: string): Promise { const body: Record = {}; if (password) body.password = password; const res = await apiFetch('/api/auth/upgrade-to-internal', { @@ -482,7 +482,7 @@ export async function upgradeToInternal(password?: string): Promise { message ); } - return (await res.json()) as User; + return (await res.json()) as SelfUser; } export type MagicLinkResult = 'sent' | 'unavailable'; diff --git a/frontend/src/lib/api/endpoints/profile.ts b/frontend/src/lib/api/endpoints/profile.ts index a172cc3f..9b1e003d 100644 --- a/frontend/src/lib/api/endpoints/profile.ts +++ b/frontend/src/lib/api/endpoints/profile.ts @@ -1,7 +1,7 @@ /** Profile / account endpoints — ported from views/profile/profile.js. */ import { apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { User } from '$lib/api/types'; +import type { PublicUser } from '$lib/api/types'; import { t } from '$lib/i18n/index.svelte'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -24,7 +24,7 @@ export interface ProfilePatch { ui_preferences?: Record; } -export async function updateProfile(patch: ProfilePatch): Promise { +export async function updateProfile(patch: ProfilePatch): Promise { const res = await apiFetch('/api/auth/me/profile', { method: 'PATCH', credentials: 'same-origin', @@ -57,7 +57,7 @@ export async function updateProfile(patch: ProfilePatch): Promise { } throw new Error(err.message || err.error || `profile update failed: ${res.status}`); } - return (await res.json()) as User; + return (await res.json()) as PublicUser; } export async function changePassword(currentPw: string, newPw: string): Promise { diff --git a/frontend/src/lib/api/endpoints/users.ts b/frontend/src/lib/api/endpoints/users.ts index ff9b9be7..b82727e0 100644 --- a/frontend/src/lib/api/endpoints/users.ts +++ b/frontend/src/lib/api/endpoints/users.ts @@ -57,3 +57,35 @@ export function resolveUser(id: string): Promise { cache.set(id, pending); return pending; } + +/** + * Prime the resolver cache from data the caller already has in hand. + * When a list endpoint (e.g. `/api/admin/users`) ships full + * `PublicUser` rows, the admin page seeds this cache in its load path + * so every subsequent `resolveUser(id)` call (from `UserVignette` + * mounted per-row) hits the cache synchronously — no per-row + * `/api/users/{id}` follow-up fetch. Kills the N+1 that motivated + * widening `/api/admin/users` to include the avatar (see + * `docs/plan/userdto-refactor.md` § N+1). + * + * No-op when the id is already cached (in-flight or resolved). This + * makes seeding safe to call unconditionally — never clobbers an + * authoritative in-flight lookup with a stale seed. + */ +export function seedUser(u: { + id: string; + username?: string | null; + email: string; + image?: string | null; + is_external: boolean; +}): void { + if (cache.has(u.id)) return; + const resolved: ResolvedUser = { + id: u.id, + name: u.username?.trim() || u.email || u.id, + email: u.email, + image: u.image ?? null, + isExternal: u.is_external + }; + cache.set(u.id, Promise.resolve(resolved)); +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index c99f2909..9c967495 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -179,148 +179,116 @@ export interface TrashResourcesResponse { export type Role = 'user' | 'admin'; -/** Wire shape of `UserDto` (backend: src/application/dtos/user_dto.rs). */ -export interface User { +// ───────────────────────────────────────────────────────────────────────── +// Three-layer user family — mirrors src/application/dtos/user_dto.rs. +// See docs/plan/userdto-refactor.md. +// +// `PublicUser` — public identity. Every authenticated caller may see it. +// Returned by /api/users/{id}, share responses, group +// members, magic-link invitees, recipient enrichment. +// `FullUser` — `{ user: PublicUser, ...admin+self extras }`. Returned +// as Vec by /api/admin/users; embedded in `SelfUser`. +// `SelfUser` — `{ full: FullUser, ...self-only extras }`. Returned by +// /api/auth/me and by every auth response. +// +// Adding a field? Decide by audience: +// * Any authenticated caller may see it about another user → PublicUser. +// * Only admin (about another user) AND self (about self) → FullUser. +// * Only self about themselves → SelfUser. +// ───────────────────────────────────────────────────────────────────────── + +/** Public identity — 9 fields visible to any authenticated caller. */ +export interface PublicUser { id: string; username?: string; email: string; role: string; - storage_quota_bytes: number; - storage_used_bytes: number; + image?: string | null; + is_external: boolean; + given_name?: string; + family_name?: string; + /** Presence — TRUE when the server observed a request on any of this + * user's non-revoked sessions within the last 5 min. Populated on + * list endpoints; single-user public paths default to `false`. + * Backwards-compat: missing on older backend builds → `false`. */ + is_online?: boolean; +} + +/** Full user record — public identity + all fields BOTH an admin (viewing + * another user) AND the subject themselves may see. Returned as `Vec` by + * `/api/admin/users`; embedded in `SelfUser` for `/api/auth/me`. */ +export interface FullUser { + user: PublicUser; + /** IdP linkage. Load-bearing "is federated?" predicate: + * `full.federation_kind === 'oidc'`. */ + federation_kind?: 'oidc' | 'ocm' | 'magic_link'; + /** Authority that minted the OIDC/OCM identity — issuer URL for OIDC, + * peer domain for OCM. FE that wants a friendly label maps this + * against `OidcProviders.issuer → provider_name`. */ + federation_issuer?: string; + preferred_locale?: string; + email_verified_at?: string; created_at: string; updated_at: string; last_login_at?: string | null; active: boolean; - /** - * Which trust chain minted this user's federation identity. `null` - * (omitted from wire) for local users (password / OPAQUE only). - * `"oidc" | "ocm" | "magic_link"` for federated users. Predicate: - * `!user.federation_kind` = local; `user.federation_kind === 'oidc'` - * = OIDC user. Mirrors `auth.users.federation_kind` verbatim. - */ - federation_kind?: 'oidc' | 'ocm' | 'magic_link'; - /** - * Authority that minted this user's OIDC/OCM identity — issuer URL - * for OIDC (id_token `iss`), peer domain for OCM. `null` (omitted) - * for local users. FE that wants a friendly display label maps this - * against `OidcProviders.issuer → provider_name` when they match; - * shows the raw value otherwise. Renamed from the historical - * `auth_provider` (which held a display label pre-Phase-B and a - * `"local"` sentinel for non-federated users — both are gone). - */ - federation_issuer?: string; - image?: string | null; - can_edit_image: boolean; - is_external: boolean; - given_name?: string; - family_name?: string; - email_verified_at?: string; - preferred_locale?: string; - notify_on_share: boolean; - /** - * Opaque UI preferences bag. Server-side JSONB column that persists - * pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …) - * across devices. The server never inspects the contents — the SPA - * defines the keys (see `lib/stores/preferences.svelte.ts` for the - * typed view). Always an object on the wire (empty bag is `{}`, - * never `null` or missing). - * - * When PATCHing back to the server via - * `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the - * server SHALLOW-merges — only the keys present in the patch are - * touched, so partial writes from one device don't clobber - * preferences set on another. Set a key to `null` in the patch to - * delete it from the bag. - */ - ui_preferences: Record; - /** - * Mirrors `auth.users.force_password_change_at_next_login`. Only - * populated by `GET /api/auth/me` (see the backend UserDto doc for - * why other UserDto call-sites default to false). When true, the - * SPA MUST lock navigation to the password-change surface — the - * root layout's guard + the backend's `require_no_password_change_pending` - * middleware together enforce this. Optional on the wire because - * older backend builds omit it and `#[serde(default)]` maps - * missing → `false`. - */ - force_password_change?: boolean; - /** - * TRUE when the account has a local Argon2id `password_hash` on - * file. Distinct from `federation_kind`: an OIDC-linked account - * (`federation_kind === 'oidc'`) can ALSO carry a local password - * (hybrid posture — SSO for daily login, local password as - * fallback). The profile page's change-password card gates on this - * flag rather than on the federation shape so hybrid users can - * rotate their local credential. Optional on the wire for older- - * backend compatibility; missing → `false` (safe default: hide the - * card). - */ - has_password?: boolean; - /** - * TRUE when the caller's current session is DPoP-bound (row's - * `dpop_jkt IS NOT NULL`). Populated only by `/api/auth/me`; other - * User-emitting endpoints leave it unset. - * - * The session store reads this to skip a redundant - * `POST /api/auth/dpop/bind` call — the endpoint returns 409 - * `already_bound` on repeated attempts (anti-downgrade invariant) - * and each rejection logs at audit INFO, so a naive "bind on - * every load" pattern was cluttering the audit stream. We only - * fire bind now when there's actual work to do (fresh OIDC / - * magic-link session that landed unbound). - */ - is_dpop_bound?: boolean; + storage_quota_bytes: number; + storage_used_bytes: number; + /** TRUE when the account has a local Argon2id `password_hash` on file. + * Distinct from `federation_kind`: an OIDC-linked account can ALSO + * carry a local password (hybrid). */ + has_password: boolean; + /** TRUE when the user has an OPAQUE envelope on file. Admin-visible + * rollout signal — kept off `PublicUser` so directory endpoints don't + * leak OPAQUE adoption. */ + opaque_registered: boolean; + /** TRUE when the user has completed ≥1 OPAQUE login. Distinct from + * `opaque_registered` — envelope-on-file vs successful-login. */ + opaque_migrated: boolean; } -/** Fields rendered by the paginated admin table. Full account details remain - * available from the detail endpoint; this shape keeps avatars and preference - * documents off every listing page. - * - * The two OPAQUE flags below are ADMIN-ONLY signals: they surface per-user - * OPAQUE rollout progress in the admin table. The backend deliberately keeps - * them off `UserDto` (`/api/auth/me`, share-recipient DTOs, group members) - * so a non-admin can't enumerate the adoption set through third-party - * endpoints. Both optional on the wire — older backend builds omit them and - * `#[serde(default)]` maps missing → `false`. */ -export type AdminUserSummary = Pick< - User, - | 'id' - | 'username' - | 'email' - | 'role' - | 'storage_quota_bytes' - | 'storage_used_bytes' - | 'last_login_at' - | 'active' - | 'federation_kind' - | 'federation_issuer' - | 'is_external' -> & { - /** TRUE = user has a server-verifiable password on file (legacy or - * admin-set). Combined with `opaque_registered` and `federation_kind`, - * the admin table derives the full auth capability set — a user with - * `has_password=false`, `opaque_registered=false` AND - * `federation_kind === undefined` (no federation) is passwordless - * (magic-link only, which is the default for externals). */ - has_password?: boolean; - /** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration - * succeeded, or the user completed a manual re-registration). */ - opaque_registered?: boolean; - /** TRUE = user has completed at least one successful OPAQUE login. - * Distinct from `opaque_registered` — the envelope may have been - * cleared by an admin reset while a stale migrated=true remains as - * historical signal (backend clears both atomically today, but the - * two-flag shape keeps the option open for a future policy split). */ - opaque_migrated?: boolean; -}; +/** Self view — everything the caller may see about themselves. + * Returned by `/api/auth/me` and every `AuthResponse` (login / refresh / + * OIDC callback / magic-link redemption ships this so the SPA's post-auth + * state matches its post-`/me` state with no UI race). */ +export interface SelfUser { + full: FullUser; + /** Opaque UI-preferences bag. Cross-device store for pure UI toggles + * (view mode, sidebar collapse, hide-dotfiles, …). Server never + * inspects contents; the SPA defines the keys (see + * `lib/stores/preferences.svelte.ts`). Always an object on the wire + * — empty bag is `{}`, never `null`. PATCH via `/api/auth/me/profile` + * shallow-merges; setting a key to `null` removes it. */ + ui_preferences: Record; + /** Whether the user wants share-notification emails. */ + notify_on_share: boolean; + /** Session-scoped: my current session is DPoP-bound. SPA reads this + * on `session.load()` to skip a redundant `/api/auth/dpop/bind` call + * (409 `already_bound` otherwise, noisy in the audit stream). */ + is_dpop_bound: boolean; + /** Admin-set temp-password gate — SPA nav guard blocks everything + * but /change-password until this flips back. Cleared by a successful + * `POST /api/auth/change-password`. */ + force_password_change: boolean; + /** Caller-scoped: can I edit my own avatar? `false` for OIDC users + * whose avatar comes from the IdP. Only meaningful when caller == + * subject; nonsense on any other DTO. */ + can_edit_image: boolean; +} + +/** Backwards-compat alias while migrating call-sites. Prefer `PublicUser` + * for public-identity contexts (sharee, group member, invitee) or + * `SelfUser` when reading `/api/auth/me`. Delete once no consumers reference + * the bare `User` name. */ +export type User = PublicUser; export interface AdminUsersPage { total: number; - users: AdminUserSummary[]; + users: FullUser[]; } export interface AuthResponse { - user: User; + user: SelfUser; access_token: string; refresh_token: string; token_type: string; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 595b5637..74f21ad1 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -437,11 +437,11 @@ { mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') } ]; - const storagePct = $derived( - session.user && session.user.storage_quota_bytes > 0 - ? Math.min(100, (session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100) - : 0 - ); + const storagePct = $derived.by(() => { + const full = session.me?.full; + if (!full || full.storage_quota_bytes <= 0) return 0; + return Math.min(100, (full.storage_used_bytes / full.storage_quota_bytes) * 100); + }); const initials = $derived(userInitials(session.user?.username || session.user?.email)); @@ -655,12 +655,12 @@
- {#if session.user.storage_quota_bytes > 0} - {Math.round(storagePct)}% · {formatBytes(session.user.storage_used_bytes)} / {formatBytes( - session.user.storage_quota_bytes + {#if (session.me?.full.storage_quota_bytes ?? 0) > 0} + {Math.round(storagePct)}% · {formatBytes(session.me?.full.storage_used_bytes ?? 0)} / {formatBytes( + session.me?.full.storage_quota_bytes ?? 0 )} {:else} - {formatBytes(session.user.storage_used_bytes)} + {formatBytes(session.me?.full.storage_used_bytes ?? 0)} {/if}
@@ -903,18 +903,18 @@
- {#if session.user.storage_quota_bytes > 0} + {#if (session.me?.full.storage_quota_bytes ?? 0) > 0} {t( 'storage.used', { percentage: Math.round(storagePct), - used: formatBytes(session.user.storage_used_bytes), - total: formatBytes(session.user.storage_quota_bytes) + used: formatBytes(session.me?.full.storage_used_bytes ?? 0), + total: formatBytes(session.me?.full.storage_quota_bytes ?? 0) }, '{{percentage}}% used ({{used}} / {{total}})' )} {:else} - {formatBytes(session.user.storage_used_bytes)} + {formatBytes(session.me?.full.storage_used_bytes ?? 0)} {/if}
diff --git a/frontend/src/lib/components/AppShell.test.ts b/frontend/src/lib/components/AppShell.test.ts index 1439ecff..04089694 100644 --- a/frontend/src/lib/components/AppShell.test.ts +++ b/frontend/src/lib/components/AppShell.test.ts @@ -26,16 +26,27 @@ const children = createRawSnippet(() => ({ beforeEach(() => { vi.clearAllMocks(); pageState.url = new URL('http://localhost/files'); - session.user = { - id: '1', - username: 'admin', - email: 'a@x.test', - given_name: 'A', - family_name: 'B', - role: 'admin', - storage_used_bytes: 10, - storage_quota_bytes: 100, - is_external: false + // Post the three-layer UserDto refactor, `session.user` is a + // derived accessor over `session.me.full.user`; only `session.me` + // is settable. Fixture composes the nested shape — public identity + // (username/email/name) on `.full.user`, admin+self extras + // (storage_*, has_password) on `.full`, self-only bag (ui_prefs, + // dpop_bound, force_password_change, can_edit_image) at the top. + // See docs/plan/userdto-refactor.md. + session.me = { + full: { + user: { + id: '1', + username: 'admin', + email: 'a@x.test', + given_name: 'A', + family_name: 'B', + role: 'admin', + is_external: false + }, + storage_used_bytes: 10, + storage_quota_bytes: 100 + } } as never; }); diff --git a/frontend/src/lib/stores/preferences.svelte.ts b/frontend/src/lib/stores/preferences.svelte.ts index e9e141d0..610b0979 100644 --- a/frontend/src/lib/stores/preferences.svelte.ts +++ b/frontend/src/lib/stores/preferences.svelte.ts @@ -69,12 +69,15 @@ const PATCH_DEBOUNCE_MS = 500; class PreferencesStore { /** - * The typed view of the bag. Derived from `session.user?.ui_preferences` - * so signing in / out / refresh flips it in lockstep with the session. + * The typed view of the bag. Derived from `session.me?.ui_preferences` + * (moved from public `User.ui_preferences` to `SelfUser.ui_preferences` + * as part of the three-layer UserDto refactor — the bag is self-only + * state, not something other authenticated callers should see). + * Signing in / out / refresh flips it in lockstep with the session. * Reads pass through DEFAULTS for any missing key. */ private bag = $derived>( - (session.user?.ui_preferences as Record | undefined) ?? {} + (session.me?.ui_preferences as Record | undefined) ?? {} ); // ── Typed accessors ────────────────────────────────────────── @@ -100,11 +103,14 @@ class PreferencesStore { * `jsonb_strip_nulls` after the merge). */ set(patch: Partial>): void { - if (!session.user) return; + if (!session.me) return; - // Optimistic local write — mutate the reactive user shallowly. + // Optimistic local write — mutate the reactive me shallowly. + // `ui_preferences` lives on `SelfUser` (self-only), not on the + // public `User` slice, so the mutation stays at the SelfUser + // level. The nested `full` / `full.user` blocks are untouched. const nextBag = { - ...((session.user.ui_preferences as Record | undefined) ?? {}), + ...((session.me.ui_preferences as Record | undefined) ?? {}), ...patch }; // Strip any explicit-null locally so the derived getters see the @@ -114,7 +120,7 @@ class PreferencesStore { for (const [k, v] of Object.entries(patch)) { if (v === null) delete (nextBag as Record)[k]; } - session.user = { ...session.user, ui_preferences: nextBag }; + session.me = { ...session.me, ui_preferences: nextBag }; // Accumulate keys so successive `set` calls before the debounce // fires collapse into a single PATCH body — matters for diff --git a/frontend/src/lib/stores/session.svelte.test.ts b/frontend/src/lib/stores/session.svelte.test.ts index 20325726..85450069 100644 --- a/frontend/src/lib/stores/session.svelte.test.ts +++ b/frontend/src/lib/stores/session.svelte.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { User } from '$lib/api/types'; +import type { SelfUser } from '$lib/api/types'; // `vi.mock` is hoisted above imports, so the spy it references must be created // with `vi.hoisted` (a plain top-level const isn't initialised yet when the @@ -14,7 +14,14 @@ vi.mock('$lib/api/endpoints/auth', () => ({ import { session } from './session.svelte'; -const userWithUsage = (used: number) => ({ storage_used_bytes: used }) as unknown as User; +// `storage_used_bytes` moved to `FullUser` (embedded inside `SelfUser`) +// as part of the three-layer UserDto refactor +// (`docs/plan/userdto-refactor.md`). Build a minimal SelfUser shape that +// satisfies the type checker without hand-populating every field the +// production shape carries — the test only cares about the usage read +// path (`session.me.full.storage_used_bytes`). +const userWithUsage = (used: number) => + ({ full: { storage_used_bytes: used } }) as unknown as SelfUser; describe('session.refresh', () => { beforeEach(() => { @@ -25,7 +32,7 @@ describe('session.refresh', () => { it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => { fetchMeMock.mockResolvedValue(userWithUsage(2048)); await session.refresh(); - expect(session.user?.storage_used_bytes).toBe(2048); + expect(session.me?.full.storage_used_bytes).toBe(2048); }); it('leaves the current user intact when the probe returns null', async () => { @@ -33,7 +40,7 @@ describe('session.refresh', () => { await session.refresh(); fetchMeMock.mockResolvedValue(null); await session.refresh(); - expect(session.user?.storage_used_bytes).toBe(2048); + expect(session.me?.full.storage_used_bytes).toBe(2048); }); it('leaves the current user intact when the probe throws', async () => { @@ -41,6 +48,6 @@ describe('session.refresh', () => { await session.refresh(); fetchMeMock.mockRejectedValue(new Error('network')); await session.refresh(); - expect(session.user?.storage_used_bytes).toBe(2048); + expect(session.me?.full.storage_used_bytes).toBe(2048); }); }); diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index bc2022c1..a46e1e4d 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -11,17 +11,39 @@ import { setLogoutInProgress } from '$lib/api/client'; import { hasSessionHint } from '$lib/api/csrf'; import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; import { drives } from '$lib/stores/drives.svelte'; -import type { User } from '$lib/api/types'; +import type { PublicUser, SelfUser } from '$lib/api/types'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; +/** + * Session store — the authenticated user and derived flags. + * + * Post the three-layer UserDto refactor (`docs/plan/userdto-refactor.md`), + * `/api/auth/me` returns `SelfUser` (composed: + * `SelfUser.full.user: PublicUser`). Two shorthand accessors keep every + * existing consumer readable: + * + * - `session.user` → `PublicUser` (via `me.full.user`). Every callsite + * that read `session.user.username / email / id / role / image / + * is_external / given_name / family_name / is_online` keeps working. + * - `session.me` → full `SelfUser`. New code that needs self-only or + * admin-visible fields (`has_password`, `is_dpop_bound`, `active`, + * `ui_preferences`, `federation_kind`, `last_login_at`, quotas, …) + * reads through `session.me.full.foo` or `session.me.foo`. + */ class SessionStore { - user = $state(null); + /** Full `/api/auth/me` payload. Null when unauthenticated. */ + me = $state(null); loaded = $state(false); homeFolderId = $state(null); homeFolderName = $state(null); - isExternalUser = $derived(this.user?.is_external ?? false); - isAuthenticated = $derived(this.user !== null); + /** Public-identity shorthand — same fields any authenticated caller + * can see. Every legacy `session.user.foo` read (username, email, id, + * role, image, is_external, given_name, family_name, is_online) still + * works via this derived accessor. */ + user = $derived(this.me?.full.user ?? null); + isExternalUser = $derived(this.me?.full.user.is_external ?? false); + isAuthenticated = $derived(this.me !== null); /** * TRUE when the backend has set `force_password_change_at_next_login` * on this account — an admin picked a temporary password and the @@ -33,7 +55,7 @@ class SessionStore { * flag (or a malformed `/me` response) doesn't accidentally * quarantine every user. */ - mustChangePassword = $derived(this.user?.force_password_change === true); + mustChangePassword = $derived(this.me?.force_password_change === true); /** * Resolve the session once. Probes /api/auth/me; on 401 it makes a single @@ -41,15 +63,15 @@ class SessionStore { * what to do with an unauthenticated result. Idempotent: subsequent calls * return the cached result (so client-side navigation doesn't re-probe). */ - async load(): Promise { - if (this.loaded) return this.user; + async load(): Promise { + if (this.loaded) return this.me; // No JS-visible session hint ⇒ nothing to probe. The server sets // `oxicloud_csrf` alongside the HttpOnly session cookies and clears // it on logout, so a missing hint means no session. Skips the // doomed 2× /me + /refresh burst that would otherwise fire on // every first landing / post-logout re-mount with no cookies. if (!hasSessionHint()) { - this.user = null; + this.me = null; this.loaded = true; return null; } @@ -71,25 +93,25 @@ class SessionStore { // otherwise clutter the audit stream. Fire-and-forget // so a slow IndexedDB open doesn't stall app boot. if (me.is_dpop_bound === false) void bindDpopIfPossible(); - } else this.user = null; + } else this.me = null; } catch { - this.user = null; + this.me = null; } this.loaded = true; - return this.user; + return this.me; } /** * Set the authenticated user AND run per-user localStorage cleanup * (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct - * `session.user = …` assignments skip the cleanup — always call + * `session.me = …` assignments skip the cleanup — always call * `setUser` on login-flow entry points (form login, OIDC exchange, * existing-session probe) so a switch-account flow inside the same * tab observes the wipe. */ - setUser(user: User): void { - this.user = user; - ensureActiveUser(user.id); + setUser(me: SelfUser): void { + this.me = me; + ensureActiveUser(me.full.user.id); // Any successful login clears the session-teardown gate. Without // this, a logout → login within the same SPA session leaves the // gate stuck at `true` — the login POST is exempted via @@ -115,7 +137,7 @@ class SessionStore { async refresh(): Promise { try { const me = await fetchMe(); - if (me) this.user = me; + if (me) this.me = me; } catch { /* keep the existing user on a transient /api/auth/me failure */ } @@ -142,7 +164,7 @@ class SessionStore { } reset(): void { - this.user = null; + this.me = null; this.homeFolderId = null; this.homeFolderName = null; // Mark the store as `loaded` so any subsequent `session.load()` — diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 63243dfe..715bfd2c 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -63,6 +63,7 @@ type StorageTestResult } from '$lib/api/endpoints/admin'; import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives'; + import { seedUser } from '$lib/api/endpoints/users'; import { ensureResolvers, resolveRecipient, @@ -70,7 +71,7 @@ type Recipient } from '$lib/api/endpoints/recipients'; import type { - AdminUserSummary, + FullUser, Drive, DriveMember, DrivePolicies, @@ -156,11 +157,11 @@ deleteUserModal !== null && deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase() ); - function openDeleteUser(u: AdminUserSummary) { + function openDeleteUser(u: FullUser) { deleteUserModal = { - userId: u.id, - username: u.username || u.email, - email: u.email + userId: u.user.id, + username: u.user.username || u.user.email, + email: u.user.email }; deleteUserEmailInput = ''; } @@ -817,7 +818,7 @@ } // Users - let users = $state([]); + let users = $state([]); let total = $state(0); let pageIndex = $state(0); let usersError = $state(null); @@ -923,6 +924,12 @@ const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE); users = page.users; total = page.total; + // Seed the per-user resolver cache with the row's `PublicUser` + // slice so every `UserVignette` mounted per row hits the cache + // synchronously — no per-row `/api/users/{id}` follow-up. + // Kills the N+1 that motivated widening `/api/admin/users` to + // carry the avatar (docs/plan/userdto-refactor.md § N+1). + for (const row of page.users) seedUser(row.user); } catch (e) { usersError = errorMessage(e); } @@ -1003,49 +1010,49 @@ } /** True for the signed-in admin's own row — guards self-destructive actions. */ - function isSelf(u: AdminUserSummary): boolean { - return u.id === currentAdminId; + function isSelf(u: FullUser): boolean { + return u.user.id === currentAdminId; } /** OIDC/SSO-provisioned account (no local password to reset). */ - function isOidcUser(u: AdminUserSummary): boolean { + function isOidcUser(u: FullUser): boolean { return u.federation_kind === 'oidc'; } /** Used-quota percentage (0 when unlimited) for the per-user progress bar. */ - function quotaPct(u: AdminUserSummary): number { + function quotaPct(u: FullUser): number { return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0; } - async function toggleRole(u: AdminUserSummary) { + async function toggleRole(u: FullUser) { if (isSelf(u)) return; - const role = u.role === 'admin' ? 'user' : 'admin'; + const role = u.user.role === 'admin' ? 'user' : 'admin'; if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return; try { - await setUserRole(u.id, role); + await setUserRole(u.user.id, role); await loadUsers(); } catch (e) { reportError(e); } } - async function toggleActive(u: AdminUserSummary) { + async function toggleActive(u: FullUser) { if (isSelf(u) && u.active) return; const msg = u.active ? t('admin.confirm_deactivate', 'Deactivate this user?') : t('admin.confirm_activate', 'Activate this user?'); if (!(await showConfirm(msg))) return; try { - await setUserActive(u.id, !u.active); + await setUserActive(u.user.id, !u.active); await loadUsers(); } catch (e) { reportError(e); } } - function openQuota(u: AdminUserSummary) { + function openQuota(u: FullUser) { quotaModalError = null; quotaModal = { - userId: u.id, - username: u.username || u.email, + userId: u.user.id, + username: u.user.username || u.user.email, initialBytes: u.storage_quota_bytes }; } @@ -1069,8 +1076,8 @@ } } - function openReset(u: AdminUserSummary) { - resetModal = { userId: u.id, username: u.username || u.email }; + function openReset(u: FullUser) { + resetModal = { userId: u.user.id, username: u.user.username || u.user.email }; resetPassword = ''; resetError = null; } @@ -1094,7 +1101,7 @@ } } - function removeUser(u: AdminUserSummary) { + function removeUser(u: FullUser) { if (isSelf(u)) return; openDeleteUser(u); } @@ -1103,20 +1110,20 @@ // provisions a home drive + flips the is_external flag; irreversible // via the admin UI (there's no demote endpoint on purpose). Backend // refuses when magic-link login is disabled — surfaced as a toast. - async function promoteExternal(u: AdminUserSummary) { - if (!u.is_external) return; + async function promoteExternal(u: FullUser) { + if (!u.user.is_external) return; if ( !(await showConfirm( t( 'admin.confirm_promote_user', - { name: u.username || u.email }, + { name: u.user.username || u.user.email }, 'Promote {{name}} to an internal user? This provisions a home drive and gives the account a normal storage envelope. The account keeps its identity; magic-link login stays the way in unless a password is set later.' ) )) ) return; try { - await promoteUserToInternal(u.id); + await promoteUserToInternal(u.user.id); await loadUsers(); } catch (e) { reportError(e); @@ -2680,15 +2687,15 @@ - {#each users as u (u.id)} + {#each users as u (u.user.id)} {@const pct = quotaPct(u)}
{#if isSelf(u)} {t('admin.you_badge', 'you')} @@ -2703,11 +2710,11 @@ badge is `white-space: nowrap` so the badge label itself never wraps mid-word either. -->
- - {#if u.role === 'admin'}{/if} - {u.role} + + {#if u.user.role === 'admin'}{/if} + {u.user.role} - {#if u.is_external} + {#if u.user.is_external}
- {#if u.is_external} + {#if u.user.is_external} {:else} @@ -2926,7 +2933,7 @@
-
{timeAgo(session.user.last_login_at)}
+
{timeAgo(session.me?.full.last_login_at)}
@@ -752,20 +762,20 @@

{t('profile.storage', 'Storage')}

-
{formatBytes(session.user.storage_used_bytes)}
+
{formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{t('profile.used', 'Used')}
- {session.user.storage_quota_bytes > 0 - ? formatBytes(session.user.storage_quota_bytes) + {(session.me?.full.storage_quota_bytes ?? 0) > 0 + ? formatBytes(session.me?.full.storage_quota_bytes ?? 0) : '∞'}
{t('profile.quota', 'Quota')}
- {session.user.storage_quota_bytes > 0 ? `${storagePct}%` : '—'} + {(session.me?.full.storage_quota_bytes ?? 0) > 0 ? `${storagePct}%` : '—'}
{t('profile.usage', 'Usage')}
diff --git a/frontend/src/routes/profile/page.test.ts b/frontend/src/routes/profile/page.test.ts index 204a7744..10a2cf9b 100644 --- a/frontend/src/routes/profile/page.test.ts +++ b/frontend/src/routes/profile/page.test.ts @@ -1,10 +1,15 @@ import { it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -const { session, ui } = vi.hoisted(() => ({ - session: { - loaded: true, - load: vi.fn(), +// Test-double session store. Post the three-layer UserDto refactor +// (docs/plan/userdto-refactor.md), production `session.user` is a +// derived accessor over `session.me.full.user`. The stub here mirrors +// that shape: `me` carries the whole SelfUser tree, and `user` mirrors +// `me.full.user` so any legacy `session.user.foo` read on the tested +// page keeps working through the mock without reproducing the derived +// mechanism. +const buildSelfMe = () => ({ + full: { user: { id: '1', username: 'admin', @@ -12,14 +17,41 @@ const { session, ui } = vi.hoisted(() => ({ given_name: 'A', family_name: 'B', role: 'admin', + is_external: false + }, + storage_used_bytes: 100, + storage_quota_bytes: 1000, + has_password: true + } +}); + +const { session, ui } = vi.hoisted(() => { + const me = { + full: { + user: { + id: '1', + username: 'admin', + email: 'a@x.test', + given_name: 'A', + family_name: 'B', + role: 'admin', + is_external: false + }, storage_used_bytes: 100, storage_quota_bytes: 1000, - is_external: false, has_password: true } - }, - ui: { notify: vi.fn() } -})); + }; + return { + session: { + loaded: true, + load: vi.fn(), + me, + user: me.full.user + }, + ui: { notify: vi.fn() } + }; +}); vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/ui.svelte', () => ({ ui })); vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() })); @@ -44,20 +76,13 @@ const m = (fn: unknown) => fn as ReturnType; beforeEach(() => { vi.clearAllMocks(); - // Reset the shared session each test (handlers may mutate session.user). + // Reset the shared session each test (handlers may mutate session.me + // on save / refresh). `me` is the SelfUser tree; `user` mirrors + // `me.full.user` for legacy `session.user.foo` reads. session.loaded = true; - session.user = { - id: '1', - username: 'admin', - email: 'a@x.test', - given_name: 'A', - family_name: 'B', - role: 'admin', - storage_used_bytes: 100, - storage_quota_bytes: 1000, - is_external: false, - has_password: true - }; + const me = buildSelfMe(); + session.me = me; + session.user = me.full.user; m(profile.listAppPasswords).mockResolvedValue([]); m(profile.updateProfile).mockResolvedValue(undefined); m(getOidcProviders).mockResolvedValue({ password_login_enabled: true }); diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index b3c225c1..8742b1de 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -586,3 +586,142 @@ pub struct OidcUserInfoDto { pub name: Option, pub groups: Vec, } + +#[cfg(test)] +mod three_layer_quarantine { + use super::*; + use serde_json::Value; + + /// Structural-quarantine guard for `SelfUserDto`. The self-only + /// bag (`ui_preferences`, `notify_on_share`, `is_dpop_bound`, + /// `force_password_change`, `can_edit_image`) MUST live at the + /// top level, NOT nested inside `.full` or `.full.user`. If a + /// future refactor accidentally moves one of them down, the + /// wire shape leaks it through every `PublicUserDto` / + /// `FullUserDto` emitter (share responses, group members, + /// `/api/admin/users`, magic-link invitees) — exactly what the + /// three-layer split exists to prevent. Fails loudly here. + #[test] + fn self_only_fields_stay_at_top_level_of_self_user_dto() { + let self_dto = SelfUserDto { + full: FullUserDto { + user: PublicUserDto { + id: "00000000-0000-0000-0000-000000000001".into(), + username: None, + email: "self@example.invalid".into(), + role: "user".into(), + image: None, + is_external: false, + given_name: None, + family_name: None, + is_online: false, + }, + federation_kind: None, + federation_issuer: None, + preferred_locale: None, + email_verified_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + last_login_at: None, + active: true, + storage_quota_bytes: 0, + storage_used_bytes: 0, + has_password: true, + opaque_registered: false, + opaque_migrated: false, + }, + ui_preferences: serde_json::json!({}), + notify_on_share: true, + is_dpop_bound: false, + force_password_change: false, + can_edit_image: true, + }; + let json: Value = serde_json::to_value(&self_dto).expect("SelfUserDto serialises"); + assert!( + json.get("ui_preferences").is_some(), + "top-level ui_preferences" + ); + assert!( + json.get("full") + .expect("full block") + .get("ui_preferences") + .is_none(), + "ui_preferences must NOT appear inside `.full`" + ); + assert!( + json.pointer("/full/user/ui_preferences").is_none(), + "ui_preferences must NOT appear inside `.full.user`" + ); + // Same guard for the other self-only fields. + for k in [ + "notify_on_share", + "is_dpop_bound", + "force_password_change", + "can_edit_image", + ] { + assert!(json.get(k).is_some(), "{k} at top level"); + assert!( + json.pointer(&format!("/full/{k}")).is_none(), + "{k} must NOT nest in .full" + ); + assert!( + json.pointer(&format!("/full/user/{k}")).is_none(), + "{k} must NOT nest in .full.user" + ); + } + } + + /// Structural-quarantine guard for `FullUserDto`. Admin-visible + /// extras (`has_password`, OPAQUE flags, `federation_*`, + /// `last_login_at`, `active`, quotas, `preferred_locale`, + /// `email_verified_at`) MUST live at the top level of + /// `FullUserDto`, NOT inside `.user`. If a future refactor + /// accidentally lifts one of them onto `PublicUserDto` (the + /// embedded `user` field), it leaks through `/api/users/{id}` + /// and every other public directory endpoint. + #[test] + fn admin_only_fields_stay_at_top_level_of_full_user_dto() { + let full = FullUserDto { + user: PublicUserDto { + id: "00000000-0000-0000-0000-000000000002".into(), + username: Some("bob".into()), + email: "bob@example.invalid".into(), + role: "user".into(), + image: None, + is_external: false, + given_name: None, + family_name: None, + is_online: false, + }, + federation_kind: None, + federation_issuer: None, + preferred_locale: None, + email_verified_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + last_login_at: None, + active: true, + storage_quota_bytes: 10_737_418_240, + storage_used_bytes: 0, + has_password: true, + opaque_registered: false, + opaque_migrated: false, + }; + let json: Value = serde_json::to_value(&full).expect("FullUserDto serialises"); + for k in [ + "has_password", + "opaque_registered", + "opaque_migrated", + "last_login_at", + "active", + "storage_quota_bytes", + "storage_used_bytes", + ] { + assert!(json.get(k).is_some(), "{k} at top level of FullUserDto"); + assert!( + json.pointer(&format!("/user/{k}")).is_none(), + "{k} must NOT nest in .user" + ); + } + } +} From 117815ef4db6adc3967a7d10567d6556d670a103 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 19:34:11 +0200 Subject: [PATCH 011/144] feat(user): show if user is online --- frontend/src/lib/api/endpoints/users.ts | 21 +++++-- .../src/lib/components/UserVignette.svelte | 38 +++++++++++ src/application/dtos/user_dto.rs | 34 +++++++--- .../services/auth_application_service.rs | 63 ++++++++++++------- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/frontend/src/lib/api/endpoints/users.ts b/frontend/src/lib/api/endpoints/users.ts index b82727e0..06e71b28 100644 --- a/frontend/src/lib/api/endpoints/users.ts +++ b/frontend/src/lib/api/endpoints/users.ts @@ -16,15 +16,23 @@ export interface ResolvedUser { email: string; image: string | null; isExternal: boolean; + /** Presence — TRUE when the server observed a request on any of this + * user's non-revoked sessions within the last 5 min (backend + * `PublicUserDto.is_online`). Drives the presence dot overlay on + * `` / ``. `false` when the caller's + * source didn't compute presence (a bare `resolveUser(id)` from + * pre-3-layer callers, an older backend build) — dot stays dark. */ + isOnline: boolean; } -/** Subset of the backend `UserDto` we consume here. */ -interface UserDtoShape { +/** Subset of the backend `PublicUserDto` we consume here. */ +interface PublicUserShape { id: string; username?: string | null; email?: string | null; image?: string | null; is_external: boolean; + is_online?: boolean; } // id → in-flight/resolved lookup (the Promise is cached so concurrent callers @@ -41,13 +49,14 @@ export function resolveUser(id: string): Promise { credentials: 'same-origin' }); if (!res.ok) return null; - const u = (await res.json()) as UserDtoShape; + const u = (await res.json()) as PublicUserShape; return { id: u.id, name: u.username?.trim() || u.email || u.id, email: u.email ?? '', image: u.image ?? null, - isExternal: u.is_external + isExternal: u.is_external, + isOnline: u.is_online ?? false }; } catch { return null; @@ -78,6 +87,7 @@ export function seedUser(u: { email: string; image?: string | null; is_external: boolean; + is_online?: boolean; }): void { if (cache.has(u.id)) return; const resolved: ResolvedUser = { @@ -85,7 +95,8 @@ export function seedUser(u: { name: u.username?.trim() || u.email || u.id, email: u.email, image: u.image ?? null, - isExternal: u.is_external + isExternal: u.is_external, + isOnline: u.is_online ?? false }; cache.set(u.id, Promise.resolve(resolved)); } diff --git a/frontend/src/lib/components/UserVignette.svelte b/frontend/src/lib/components/UserVignette.svelte index a1c0fe12..57cd999e 100644 --- a/frontend/src/lib/components/UserVignette.svelte +++ b/frontend/src/lib/components/UserVignette.svelte @@ -33,6 +33,7 @@ const label = $derived(resolved?.name ?? fallbackLabel ?? userId); const email = $derived(resolved?.email || fallbackSublabel || ''); const isExternal = $derived(resolved?.isExternal ?? false); + const isOnline = $derived(resolved?.isOnline ?? false); const image = $derived(resolved?.image ?? null); const colorIndex = $derived(avatarColorIndex(userId)); const initials = $derived(userInitials(label)); @@ -50,6 +51,22 @@ {/if} + {#if isOnline} + + + {/if} {label} @@ -128,6 +145,27 @@ font-size: 9px; } + /* Presence dot — top-right, symmetric with `.uv__badge` at + bottom-right so the two corners don't collide. Slightly smaller + (10x10 vs the badge's 16x16) because it's a pure signal — no + icon, no text. The 2px `--color-bg-surface` border creates a + visual gap between dot and avatar so the green pops out cleanly + regardless of avatar palette (photo, dark initials, light + initials). `box-sizing: border-box` keeps the inner circle's + green footprint at 6x6 — same visual weight the sessions-table + dot has. See `docs/plan/sessions.md` § UI. */ + .uv__presence { + position: absolute; + right: -2px; + top: -2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-success-alt); + border: 2px solid var(--color-bg-surface); + box-sizing: border-box; + } + .uv__text { display: flex; flex-direction: column; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 8742b1de..6822535d 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -161,8 +161,32 @@ pub struct SelfUserDto { pub can_edit_image: bool, } -impl From for PublicUserDto { - fn from(user: User) -> Self { +impl PublicUserDto { + /// Construct a `PublicUserDto` from a `User` entity + an explicit + /// `is_online` signal. + /// + /// **Why not `From`?** The `User` entity models a row in + /// `auth.users`; `is_online` is a cross-table lookup on + /// `auth.sessions` (see the EXISTS subquery in + /// `list_users_with_derived_flags` and `get_user_with_derived_flags` + /// on the user repo). A `From` impl couldn't compute it + /// honestly — it would have to ship a `false` default that lies to + /// the FE presence dot on every emitter that didn't remember to + /// override. Making presence a required constructor argument + /// removes that footgun: every callsite has to declare its intent. + /// + /// Two shapes at the callsite: + /// + /// - Presence matters (single-user `/api/users/{id}`, list + /// projections, self-view): pair with + /// `user_storage.get_user_with_derived_flags(id)` and pass + /// `flags.is_online`. + /// - Presence is out of scope (register / update-profile response, + /// post-mutation echo where the FE ignores the field): pass + /// `false` with a short comment explaining why. The receiver's + /// presence read is a no-op — no dot lights up on the stale + /// value. + pub fn new(user: User, is_online: bool) -> Self { let role = format!("{}", user.role()); let p = user.into_parts(); Self { @@ -174,11 +198,7 @@ impl From for PublicUserDto { is_external: p.is_external, given_name: p.given_name, family_name: p.family_name, - // Single-user paths that don't enrich presence ship `false`. - // List projections (admin users, sharees enriched with - // presence) build via FullUserDto::build below, which - // overrides this from UserDerivedFlags. - is_online: false, + is_online, } } } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index fc28c146..5b2cd930 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -876,9 +876,7 @@ impl AuthApplicationService { is_external = false, "🛂 user registered", ); - Ok(RegisterResult::Created(Box::new(PublicUserDto::from( - created_user, - )))) + Ok(RegisterResult::Created(Box::new(PublicUserDto::new(created_user, false)))) } /// Create the first admin user during initial system setup. @@ -981,7 +979,7 @@ impl AuthApplicationService { username, created_user.id() ); - Ok(PublicUserDto::from(created_user)) + Ok(PublicUserDto::new(created_user, false)) } pub async fn login( @@ -2182,7 +2180,7 @@ impl AuthApplicationService { lc.dispatch_upgraded_to_internal(&updated).await; } - Ok(PublicUserDto::from(updated)) + Ok(PublicUserDto::new(updated, false)) } /// Admin-driven external → internal promotion. @@ -2293,7 +2291,7 @@ impl AuthApplicationService { "👮🏻‍♂️ external user promoted to internal by admin", ); - Ok(PublicUserDto::from(updated)) + Ok(PublicUserDto::new(updated, false)) } /// `keep_session_id` — when `Some`, revoke every OTHER session for @@ -2550,7 +2548,7 @@ impl AuthApplicationService { pub async fn get_user(&self, user_id: Uuid) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; - Ok(PublicUserDto::from(user)) + Ok(PublicUserDto::new(user, false)) } /// Cached, image-free lookup of the caller's authorization flags @@ -2875,7 +2873,7 @@ impl AuthApplicationService { if changed.is_empty() && ui_prefs_patch.is_none() { // No-op — return the current user without a DB write. - return Ok(PublicUserDto::from(user)); + return Ok(PublicUserDto::new(user, false)); } // Persist the typed-field changes first (if any). Skip the @@ -2906,7 +2904,7 @@ impl AuthApplicationService { // Refetch so the returned DTO reflects the merged JSONB bag // (the in-memory `user` above holds the pre-merge value). let refreshed = self.user_storage.get_user_by_id(caller_id).await?; - Ok(PublicUserDto::from(refreshed)) + Ok(PublicUserDto::new(refreshed, false)) } // Alias for consistency with handler method @@ -3006,9 +3004,20 @@ impl AuthApplicationService { ) -> Result { // (1) Self — a single fetch suffices (the check compares the input // UUIDs, so the target read is never needed on this path). + // + // Both branches use `get_user_with_derived_flags` (not the narrow + // `get_user_by_id`) so `PublicUserDto.is_online` on the wire + // reflects the same EXISTS subquery the admin list uses. Without + // this the FE presence dot would only light up on list-derived + // paths (admin seed); single fetches from share pickers / group + // members would show every user as offline regardless of real + // state. See `docs/plan/userdto-refactor.md`. if caller_id == target_id { - let caller = self.user_storage.get_user_by_id(caller_id).await?; - return Ok(PublicUserDto::from(caller)); + let (caller, flags) = self + .user_storage + .get_user_with_derived_flags(caller_id) + .await?; + return Ok(PublicUserDto::new(caller, flags.is_online)); } // Caller and target are independent point reads (the self-case already @@ -3016,16 +3025,26 @@ impl AuthApplicationService { // overlap them with `join!` instead of two serial round-trips. // `caller_res?` first preserves the caller-error precedence of the old // sequential form. (benches/ROUND23.md §P1) + // + // `caller` uses the narrow `get_user_by_id` because we only read + // `is_external()` off it for the visibility gate; nothing about + // the caller ships on the wire. Only `target` needs the wider + // projection. let (caller_res, target_res) = tokio::join!( self.user_storage.get_user_by_id(caller_id), - self.user_storage.get_user_by_id(target_id) + self.user_storage.get_user_with_derived_flags(target_id) ); let caller = caller_res?; // Anti-enumeration: NotFound for everything that doesn't pass. // Convert a real NotFound on `target` to the same anonymous 404, // so existence isn't leaked through differential responses. - let target = match target_res { + // + // Destructure the (User, UserDerivedFlags) tuple immediately so + // `target` keeps its historical `User` shape (accessors still + // work below); the flags come along as `target_flags` for the + // `is_online` propagation into the returned `PublicUserDto`. + let (target, target_flags) = match target_res { Ok(u) => u, Err(e) if e.kind == ErrorKind::NotFound => { tracing::info!( @@ -3069,7 +3088,7 @@ impl AuthApplicationService { })?; if related.is_some() { - return Ok(PublicUserDto::from(target)); + return Ok(PublicUserDto::new(target, target_flags.is_online)); } // (3) External callers stop here — no directory enumeration. @@ -3097,12 +3116,12 @@ impl AuthApplicationService { // (4) Internal target + system-address-book exposed: already public. if !target.is_external() && expose_system_users { - return Ok(PublicUserDto::from(target)); + return Ok(PublicUserDto::new(target, target_flags.is_online)); } // (5) Admin caller: always visible. if caller.role() == UserRole::Admin { - return Ok(PublicUserDto::from(target)); + return Ok(PublicUserDto::new(target, target_flags.is_online)); } // (6) No relationship — anti-enumeration NotFound. @@ -3184,7 +3203,7 @@ impl AuthApplicationService { // New method to get user by username - needed for admin user handling pub async fn get_user_by_username(&self, username: &str) -> Result { let user = self.user_storage.get_user_by_username(username).await?; - Ok(PublicUserDto::from(user)) + Ok(PublicUserDto::new(user, false)) } // Method to count how many admin users exist in the system @@ -3208,7 +3227,7 @@ impl AuthApplicationService { offset: i64, ) -> Result, DomainError> { let users = self.user_storage.list_users(limit, offset, false).await?; - Ok(users.into_iter().map(PublicUserDto::from).collect()) + Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) } /// Admin-only: lists users including external (grant-only) recipients. @@ -3222,7 +3241,7 @@ impl AuthApplicationService { ) -> Result, DomainError> { self.require_admin_caller(authorization, caller_id).await?; let users = self.user_storage.list_users(limit, offset, true).await?; - Ok(users.into_iter().map(PublicUserDto::from).collect()) + Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) } /// Admin-only user listing. Returns `Vec` — same @@ -3277,7 +3296,7 @@ impl AuthApplicationService { limit: i64, ) -> Result, DomainError> { let users = self.user_storage.search_users(query, limit, false).await?; - Ok(users.into_iter().map(PublicUserDto::from).collect()) + Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) } /// Username-only search for the NC sharee autocomplete: identical @@ -3541,7 +3560,7 @@ impl AuthApplicationService { created.id(), created.is_external() ); - Ok(PublicUserDto::from(created)) + Ok(PublicUserDto::new(created, false)) } /// Admin-only: reset a user's password. @@ -3640,7 +3659,7 @@ impl AuthApplicationService { /// Get a single user by ID (for admin panel) pub async fn get_user_admin(&self, user_id: Uuid) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; - Ok(PublicUserDto::from(user)) + Ok(PublicUserDto::new(user, false)) } /// Delete a user by ID (admin only). From 6a11036d9641443ba32829cf6e5fe2c45552e90c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 22:42:57 +0200 Subject: [PATCH 012/144] feat(admin): show active session/users on dashboard --- frontend/src/lib/api/endpoints/admin.ts | 21 +++ .../src/routes/admin/[[tab]]/+page.svelte | 130 +++++++++++++++++- frontend/static/locales/en.json | 11 ++ frontend/static/locales/fr.json | 11 ++ src/application/dtos/settings_dto.rs | 31 ++++- .../services/auth_application_service.rs | 20 ++- src/interfaces/api/handlers/admin_handler.rs | 48 +++++++ 7 files changed, 263 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 2cf9ee48..c00b8538 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -387,9 +387,30 @@ export interface DriveKindUsage { } export interface AdminDashboard { + // ── User accounts (static breakdown of auth.users) ── + // All four are counts of the same table under different + // predicates. Rendered as one grouped section on the dashboard. total_users: number; active_users: number; admin_users: number; + /** Grant-only accounts (magic-link / OIDC-only / OCM recipients). + * Filtered out of `total_users` / `active_users` — those count + * operational seats. Surfaced here as its own metric because + * external-heavy deployments (public-share collab, invited-only + * shops) need the invited population at a glance. */ + external_users: number; + // ── Live activity (projection over auth.sessions) ── + // Both change minute-to-minute — a whole different cadence from + // the account counts above. Rendered as a separate section on + // the dashboard with the presence-dot visual cue. + /** Distinct users behind non-revoked sessions active in the last + * 5 min. Same 5-min window as the `oxicloud_sessions_online_users` + * Prometheus gauge; single source of truth on the backend. */ + online_users: number; + /** Non-revoked sessions active in the last 5 min. Ratio + * `online_sessions / online_users` is the multi-device factor + * (browser + desktop + phone). */ + online_sessions: number; server_version: string; drive_usage: DriveKindUsage[]; auth_enabled: boolean; diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 715bfd2c..9d66fbde 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -1730,6 +1730,16 @@ {:else if !dashboard}

{t('common.loading', 'Loading…')}

{:else} + + + +

{t('admin.section_accounts', 'User accounts')}

{dashboard.total_users}{t('admin.total_users', 'Total users')} @@ -1740,11 +1750,66 @@
{dashboard.admin_users}{t('admin.admin_users', 'Admins')}
-
- v{dashboard.server_version}{t('admin.version', 'Version')} +
+ {dashboard.external_users}{t( + 'admin.external_users', + 'External' + )}
+ +

+ {t('admin.section_activity', 'Live activity')} + + {t('admin.live', 'live')} + +

+
+
+ + + {dashboard.online_users} + + {t('admin.online_users', 'Online users')} +
+
+ + + {dashboard.online_sessions} + + {t('admin.online_sessions', 'Online sessions')} +
+
+ + +

{t('admin.section_system', 'System')}

@@ -1768,6 +1833,9 @@ {t('admin.quotas', 'Quotas')}
+
+ v{dashboard.server_version}{t('admin.version', 'Version')} +
{#if dashboard.users_over_quota > 0} @@ -4340,6 +4408,40 @@ margin-bottom: var(--space-4); } + /* Section title bar above each dashboard grid — labels the + nature of the cards below (accounts vs live activity vs + system). Small, muted, so it structures the page without + competing with the numbers. `text-transform: uppercase` + + `letter-spacing` matches the small-caps section-header pattern + used elsewhere in the admin surface. */ + .ds-section-title { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin: var(--space-4) 0 var(--space-2) 0; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + /* "live" pill next to the "Live activity" section header — + subtle visual hint that the values in this grid change on + their own cadence. Matches the presence-dot's success token + so the whole live-activity block reads as one visual family. */ + .ds-section-live { + display: inline-block; + padding: 0 var(--space-2); + border-radius: var(--radius-full); + background: var(--color-success-bg); + color: var(--color-success-text); + font-size: 0.65rem; + font-weight: var(--weight-bold); + letter-spacing: 0.08em; + vertical-align: middle; + } + .ds-card { display: flex; flex-direction: column; @@ -4358,6 +4460,18 @@ color: var(--color-text-heading); } + /* Live-count variant — same font size as `.ds-num`, plus a + flex container so the leading presence dot aligns with the + number baseline instead of the top of the digit. Reuses the + `.presence-dot--online` class from the sessions-panel work + so the visual signal for presence is identical across the + admin surface. */ + .ds-num.ds-num--live { + display: inline-flex; + align-items: center; + gap: var(--space-2); + } + .ds-bar { height: 8px; background: var(--color-bg-muted); @@ -5230,9 +5344,17 @@ } .admin { - max-width: 64rem; + /* Raised from 64rem to 80rem so the data-dense tables (sessions + row with 9+ cells, users table with vignette + role + auth + chips + quota bar) have more horizontal room. At viewports + above 80rem `margin: 0 auto` still centers with the leftover + whitespace — DevTools shows that whitespace as horizontal + margin (not padding) and is what "content looks squeezed" + really means on wide displays. Vertical rhythm and the small + horizontal padding are unchanged. */ + max-width: 80rem; margin: 0 auto; - padding: 1.5rem 1rem; + padding: 1.5rem var(--space-2); display: flex; flex-direction: column; gap: 1rem; diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 88557131..4058be85 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -836,6 +836,17 @@ "total_users": "Total Users", "active_users": "Active Users", "admins": "Admins", + "external_users": "External", + "external_users_tooltip": "Grant-only accounts — magic-link, OIDC-only, OCM recipients", + "online_users": "Online users", + "online_users_tooltip": "Distinct users with a session active in the last 5 minutes", + "online_sessions": "Online sessions", + "online_sessions_tooltip": "Non-revoked sessions active in the last 5 minutes — multi-device users contribute more than one", + "section_accounts": "User accounts", + "section_activity": "Live activity", + "section_system": "System", + "live": "live", + "live_tooltip": "Reflects sessions active in the last 5 minutes", "version": "Version", "storage_overview": "Storage Overview", "used": "Used", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index 998c1e4a..d3b6713f 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -801,6 +801,17 @@ "total_users": "Utilisateurs totaux", "active_users": "Utilisateurs actifs", "admins": "Admins", + "external_users": "Externes", + "external_users_tooltip": "Comptes invités — magic-link, OIDC seulement, destinataires OCM", + "online_users": "Utilisateurs en ligne", + "online_users_tooltip": "Utilisateurs distincts ayant une session active dans les 5 dernières minutes", + "online_sessions": "Sessions en ligne", + "online_sessions_tooltip": "Sessions non révoquées actives dans les 5 dernières minutes — les utilisateurs multi-appareils en contribuent plusieurs", + "section_accounts": "Comptes utilisateurs", + "section_activity": "Activité en direct", + "section_system": "Système", + "live": "en direct", + "live_tooltip": "Reflète les sessions actives dans les 5 dernières minutes", "version": "Version", "storage_overview": "Aperçu du stockage", "used": "Utilisé", diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 0d2cc9da..835f4719 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -163,10 +163,39 @@ pub struct DashboardStatsDto { pub auth_enabled: bool, pub oidc_configured: bool, pub quotas_enabled: bool, - // User stats + // ── User accounts (static breakdown of auth.users) ── + // All four are counts of the SAME table under different + // predicates. `active`, `admin`, `external` are all subsets of + // `total`. `external` is disjoint from `admin` by DB constraint + // (`users_external_not_admin`). The dashboard renders these as + // one grouped section separate from the live-activity section + // below, so admins don't confuse "as-of-now row count" with + // "who's here right now". pub total_users: i64, pub active_users: i64, pub admin_users: i64, + /// Grant-only accounts (magic-link / OIDC-only / OCM recipients). + /// Filtered out of `total_users` / `active_users` since those + /// columns count operational seats (see the SELECT comment). Here + /// as its own metric because operators of external-heavy + /// deployments (public shares, invited-collab shops) need to see + /// the invited population at a glance. + pub external_users: i64, + // ── Live activity (projection over auth.sessions) ── + // Both fields change minute-to-minute, unlike the user counts + // above which only move on register/deactivate/role-toggle. + // Same 5-min window as the Prometheus gauges + // (`oxicloud_sessions_online[_users]` in + // `session_liveness_gauges.rs`), computed via the shared + // `ONLINE_WINDOW` constant so per-user badges + aggregate + // counts + this dashboard number stay consistent by construction. + /// Distinct users behind non-revoked sessions active in the last + /// 5 min. Answers "how many humans are here right now?". + pub online_users: i64, + /// Non-revoked sessions active in the last 5 min. Answers "how + /// many concurrent connections must I serve?". Ratio + /// `online_sessions / online_users` is the multi-device factor. + pub online_sessions: i64, // ── Per-drive-kind quota accounting ── // One row per drive kind (personal, shared). Pre-dedup, logical // file sizes summed from `drives.used_bytes` (personal rolls up diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 5b2cd930..36c8090f 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -876,7 +876,10 @@ impl AuthApplicationService { is_external = false, "🛂 user registered", ); - Ok(RegisterResult::Created(Box::new(PublicUserDto::new(created_user, false)))) + Ok(RegisterResult::Created(Box::new(PublicUserDto::new( + created_user, + false, + )))) } /// Create the first admin user during initial system setup. @@ -3227,7 +3230,10 @@ impl AuthApplicationService { offset: i64, ) -> Result, DomainError> { let users = self.user_storage.list_users(limit, offset, false).await?; - Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) + Ok(users + .into_iter() + .map(|u| PublicUserDto::new(u, false)) + .collect()) } /// Admin-only: lists users including external (grant-only) recipients. @@ -3241,7 +3247,10 @@ impl AuthApplicationService { ) -> Result, DomainError> { self.require_admin_caller(authorization, caller_id).await?; let users = self.user_storage.list_users(limit, offset, true).await?; - Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) + Ok(users + .into_iter() + .map(|u| PublicUserDto::new(u, false)) + .collect()) } /// Admin-only user listing. Returns `Vec` — same @@ -3296,7 +3305,10 @@ impl AuthApplicationService { limit: i64, ) -> Result, DomainError> { let users = self.user_storage.search_users(query, limit, false).await?; - Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) + Ok(users + .into_iter() + .map(|u| PublicUserDto::new(u, false)) + .collect()) } /// Username-only search for the NC sharee autocomplete: identical diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index b727b5a1..4856f0c6 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -940,6 +940,51 @@ pub async fn get_dashboard_stats( .await .map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?; + // External account count — distinct query (not FILTERed into + // `stats_row` above) because `stats_row` scopes to + // `is_external = false` for the operational-seat counts. + // Externals form their own population; the dashboard renders them + // as a separate stat card in the "User accounts" section. + let external_users: i64 = + sqlx::query_scalar(r#"SELECT COUNT(*)::INT8 FROM auth.users WHERE is_external = true"#) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("External user count failed: {}", e)))?; + + // Live-activity counts — projection over auth.sessions, same + // `ONLINE_WINDOW` (5 min) the Prometheus gauges use so the + // dashboard number, admin-table green dot, and + // `oxicloud_sessions_online` scrape all agree by construction. + // Bound as `$1 = window_secs` via `make_interval(secs => $1)` + // to keep the single-source-of-truth pattern (no SQL literal + // for the window). Both queries hit the partial index + // `idx_sessions_last_seen_at WHERE revoked = FALSE` so per-run + // cost is ~μs even at tens of thousands of session rows. + let online_window_secs: f64 = + crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64(); + let online_sessions: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*)::INT8 FROM auth.sessions + WHERE revoked = FALSE + AND last_seen_at > NOW() - make_interval(secs => $1) + "#, + ) + .bind(online_window_secs) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("Online session count failed: {}", e)))?; + let online_users: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT user_id)::INT8 FROM auth.sessions + WHERE revoked = FALSE + AND last_seen_at > NOW() - make_interval(secs => $1) + "#, + ) + .bind(online_window_secs) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("Online user count failed: {}", e)))?; + use sqlx::Row; // Per-drive-kind quota panel: @@ -1024,6 +1069,9 @@ pub async fn get_dashboard_stats( total_users: stats_row.get("total_users"), active_users: stats_row.get("active_users"), admin_users: stats_row.get("admin_users"), + external_users, + online_users, + online_sessions, drive_usage, users_over_80_percent: stats_row.get("users_over_80"), users_over_quota: stats_row.get("users_over_quota"), From c583b26355e6c50448c090eead35c9e05b0fbd20 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 23:18:23 +0200 Subject: [PATCH 013/144] refactor(user): apply change on update entries --- frontend/src/lib/api/endpoints/profile.ts | 6 +++--- frontend/src/routes/profile/+page.svelte | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/api/endpoints/profile.ts b/frontend/src/lib/api/endpoints/profile.ts index 9b1e003d..6305b215 100644 --- a/frontend/src/lib/api/endpoints/profile.ts +++ b/frontend/src/lib/api/endpoints/profile.ts @@ -1,7 +1,7 @@ /** Profile / account endpoints — ported from views/profile/profile.js. */ import { apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { PublicUser } from '$lib/api/types'; +import type { SelfUser } from '$lib/api/types'; import { t } from '$lib/i18n/index.svelte'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -24,7 +24,7 @@ export interface ProfilePatch { ui_preferences?: Record; } -export async function updateProfile(patch: ProfilePatch): Promise { +export async function updateProfile(patch: ProfilePatch): Promise { const res = await apiFetch('/api/auth/me/profile', { method: 'PATCH', credentials: 'same-origin', @@ -57,7 +57,7 @@ export async function updateProfile(patch: ProfilePatch): Promise { } throw new Error(err.message || err.error || `profile update failed: ${res.status}`); } - return (await res.json()) as PublicUser; + return (await res.json()) as SelfUser; } export async function changePassword(currentPw: string, newPw: string): Promise { diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index d0b7539b..217e091e 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -210,13 +210,13 @@ savingProfile = true; try { - await updateProfile(patch); - // Server returns the truncated `PublicUser` shape; re-fetch - // `/me` so `session.me` picks up self-only edits (locale, - // notify_on_share, ui_preferences bag) as well as the public - // identity changes. `session.user` is a derived accessor over - // `session.me.full.user`, so it updates in lockstep. - await session.refresh(); + // PATCH /me/profile echoes SelfUser (same shape as GET /me) + // so the SPA absorbs the just-written state in one round + // trip — no follow-up refresh needed. `session.user` is a + // derived accessor over `session.me.full.user`, so it + // updates in lockstep with the me assignment. + const updated = await updateProfile(patch); + session.me = updated; if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale); ui.notify(t('profile.saved', 'Profile saved'), 'success'); } catch (err) { From a8fa281a02594ae1c1f1e0826c13bd42b16cf743 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 23:19:00 +0200 Subject: [PATCH 014/144] refactor(user): apply chanoges to hurl tests --- frontend/src/lib/api/endpoints/admin.ts | 19 +-- .../services/auth_application_service.rs | 57 +++++++-- src/interfaces/api/handlers/auth_handler.rs | 115 ++++++++---------- tests/api/admin_user_ops.hurl | 6 +- tests/api/auth_login.hurl | 4 +- tests/api/auth_magic_link_login.hurl | 6 +- tests/api/auth_session_lifecycle.hurl | 2 +- tests/api/auth_upgrade_to_internal.hurl | 18 +-- tests/api/calendar.hurl | 4 +- tests/api/contacts.hurl | 4 +- tests/api/cross_drive_copy.hurl | 2 +- tests/api/cross_drive_move.hurl | 2 +- tests/api/dav_error_mapping.hurl | 2 +- tests/api/default_caldav_carddav.hurl | 2 +- tests/api/drive_policies.hurl | 4 +- tests/api/drive_quota.hurl | 2 +- tests/api/drive_read_only.hurl | 4 +- tests/api/drives_foundation.hurl | 6 +- tests/api/drives_membership.hurl | 10 +- tests/api/external_users.hurl | 46 +++++-- tests/api/files-folders.hurl | 4 +- tests/api/grant_cleanup.hurl | 4 +- tests/api/grants.hurl | 10 +- tests/api/grants_nested_groups.hurl | 2 +- tests/api/groups_effective_members.hurl | 2 +- tests/api/nc_login_flow_v2_drive_picker.hurl | 2 +- tests/api/nc_multidrive_move_regression.hurl | 2 +- tests/api/nc_second_user_setup.hurl | 4 +- tests/api/nc_webdav_patch_consistency.hurl | 10 +- tests/api/nc_webdav_put_gaps.hurl | 8 +- tests/api/nc_webdav_quota_properties.hurl | 2 +- tests/api/playlists.hurl | 4 +- tests/api/registration.hurl | 36 +++--- .../regression_595_unlimited_user_quota.hurl | 2 +- tests/api/role_grants.hurl | 6 +- tests/api/storage_cleanup_check.sh | 5 +- tests/api/subject_groups.hurl | 4 +- tests/api/trash_per_drive.hurl | 4 +- tests/api/user_envelope_quota.hurl | 12 +- tests/api/webdav_patch_consistency.hurl | 2 +- tests/api/webdav_permissions.hurl | 4 +- tests/api/webdav_quota_properties.hurl | 2 +- tests/api/wopi_authz.hurl | 4 +- tests/api/wopi_shared_drive.hurl | 2 +- tests/oidc/link_unlink.hurl | 52 ++++---- tests/oidc/oidc.hurl | 38 +++--- tests/oidc/sso-only.hurl | 6 +- .../test_nextcloud_chunked_upload_cap.sh | 6 +- 48 files changed, 310 insertions(+), 244 deletions(-) diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index c00b8538..586b0b4b 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -12,7 +12,7 @@ import type { DriveMember, DriveMemberSubject, DriveRole, - User + FullUser } from '$lib/api/types'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -287,9 +287,12 @@ export function listUsers(limit: number, offset: number): Promise>(); +const adminUserCache = new Map>(); -export function getUserAdmin(id: string): Promise { +export function getUserAdmin(id: string): Promise { const hit = adminUserCache.get(id); if (hit) return hit; - const pending = (async (): Promise => { + const pending = (async (): Promise => { try { - return await apiJson(`/api/admin/users/${encodeURIComponent(id)}`, { + return await apiJson(`/api/admin/users/${encodeURIComponent(id)}`, { credentials: 'same-origin' }); } catch { diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 36c8090f..d34ff312 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1359,6 +1359,31 @@ impl AuthApplicationService { &self, user_id: Uuid, session: &crate::domain::entities::session::Session, + ) -> Result { + // Session-context flavour — delegates to the shared builder + // with the DPoP-bound flag derived from the session row's + // thumbprint. See [`build_self_user_dto_for_id`] for the + // handler-context flavour. + self.build_self_user_dto_for_id(user_id, session.dpop_jkt().is_some()) + .await + } + + /// Handler-context variant of [`build_self_user_dto`]. Called by + /// every endpoint that returns a `SelfUserDto` from a REST handler + /// (`GET /me`, `PATCH /me/profile`, `POST /upgrade-to-internal`) + /// so the wire shape is byte-for-byte identical across them — + /// avoids a "quiet lie" where a client PATCHes one shape and + /// reads another on the very next `/me`. + /// + /// `is_dpop_bound` is passed in by the handler because the JWT + /// `cnf.jkt` claim is where handler-scope code learns the caller's + /// binding state (via `auth_user.dpop_jkt.is_some()`). Session- + /// mint paths use [`build_self_user_dto`] and derive the flag from + /// the freshly-created `Session` row instead. + pub async fn build_self_user_dto_for_id( + &self, + user_id: Uuid, + is_dpop_bound: bool, ) -> Result { let (user, flags) = UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?; @@ -1366,7 +1391,6 @@ impl AuthApplicationService { let ui_preferences = user.ui_preferences().clone(); let notify_on_share = user.notify_on_share(); let force_password_change = self.read_force_password_change(user_id).await; - let is_dpop_bound = session.dpop_jkt().is_some(); let full = FullUserDto::build(user, flags); Ok(SelfUserDto::build( full, @@ -3414,7 +3438,7 @@ impl AuthApplicationService { pub async fn admin_create_user( &self, dto: crate::application::dtos::settings_dto::AdminCreateUserDto, - ) -> Result { + ) -> Result { // Validate username length if dto.username.len() < 3 || dto.username.len() > 254 { return Err(DomainError::new( @@ -3572,7 +3596,16 @@ impl AuthApplicationService { created.id(), created.is_external() ); - Ok(PublicUserDto::new(created, false)) + // Return `FullUserDto` — same shape as `GET /api/admin/users/{id}` + // and one row of the admin list. Admin surfaces uniformly return + // FullUserDto so the SPA / test asserts don't need to know which + // admin endpoint they came from. Fresh user has no session yet + // (`is_online = false`) and no OPAQUE registration; `has_password` + // reflects whatever the admin passed in the DTO. + let created_id = created.id(); + let (user, flags) = + UserStoragePort::get_user_with_derived_flags(&*self.user_storage, created_id).await?; + Ok(FullUserDto::build(user, flags)) } /// Admin-only: reset a user's password. @@ -3668,10 +3701,20 @@ impl AuthApplicationService { Ok(()) } - /// Get a single user by ID (for admin panel) - pub async fn get_user_admin(&self, user_id: Uuid) -> Result { - let user = self.user_storage.get_user_by_id(user_id).await?; - Ok(PublicUserDto::new(user, false)) + /// Get a single user by ID (for admin panel). + /// + /// Returns `FullUserDto` — same shape as one row of + /// `/api/admin/users` — so admin single-user views (detail modal, + /// per-user edit page) render the same fields the list surfaces. + /// The single-row admin view is the canonical observation surface + /// for admin-visible signals like `email_verified_at` / + /// `has_password` / `opaque_registered` / `last_login_at` — none + /// of which live on the peer-view `PublicUserDto`. See + /// `docs/plan/userdto-refactor.md`. + pub async fn get_user_admin(&self, user_id: Uuid) -> Result { + let (user, flags) = + UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?; + Ok(FullUserDto::build(user, flags)) } /// Delete a user by ID (admin only). diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 18d4f129..236721b4 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -11,9 +11,9 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto, - OidcExchangeDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, - SetupAdminDto, UpgradeToInternalDto, + AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, + OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto, + UpgradeToInternalDto, }; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::common::di::AppState; @@ -652,59 +652,20 @@ pub async fn get_current_user( // Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM // of `used_bytes` across the user's personal drives only. Shared drives // never count against this envelope — collaborating in a team drive - // costs no personal bytes. The matching cap is - // `storage_quota_bytes` (admin-only mutation). + // costs no personal bytes. // - // Single-query fetch: `get_user_with_derived_flags` returns the full - // `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags / - // is_online) in one round-trip. That collapses what used to be a - // `get_user_by_id` + separate credential lookups into one wire trip, - // AND populates the OPAQUE flags on `/me` which the fat-PublicUserDto path - // never did (it left them at false — the "quiet lie" that motivated - // this refactor, see `docs/plan/userdto-refactor.md`). - let (user, flags) = auth_service + // Delegate to the shared `build_self_user_dto_for_id` — same code + // path `PATCH /me/profile` and `POST /upgrade-to-internal` use so + // all three self endpoints ship byte-for-byte identical shapes. + // The DPoP-bound signal comes from the JWT `cnf.jkt` claim + // (surfaced by the auth middleware into `AuthUser.dpop_jkt`); + // when present the session that minted this JWT is bound and + // the SPA can skip a redundant `/dpop/bind` call. + let self_dto = auth_service .auth_application_service - .get_user_with_derived_flags(user_id) + .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some()) .await?; - // Read the fields we need before moving `user` into FullUserDto below. - // Ordering matters: `can_edit_image` and the self-only bag fields - // must be captured while `user` is still borrowable; the - // `FullUserDto::build` call downstream consumes the entity. - let can_edit_image = !user.is_oidc_user(); - let ui_preferences = user.ui_preferences().clone(); - let notify_on_share = user.notify_on_share(); - - // Overlay the cached `force_password_change` flag (see UserFlags). - // Using the cached path (`get_user_flags` → `user_flags_cache`) - // avoids a second DB round-trip on this hot endpoint. - let force_password_change = auth_service - .auth_application_service - .get_user_flags(user_id) - .await - .map(|f| f.force_password_change) - .unwrap_or(false); - - // Session-binding state — read from the JWT `cnf.jkt` claim - // (surfaced by the auth middleware into `CurrentUser.dpop_jkt`). - // Present ⇒ the session that minted this JWT was bound; absent ⇒ - // the session is unbound and the SPA should call `/dpop/bind` to - // attach the browser's keypair (OIDC / magic-link redirect flow). - // Skips an otherwise-redundant `POST /dpop/bind` on every page load - // which would return 409 `already_bound` and litter the audit - // stream. - let is_dpop_bound = auth_user.dpop_jkt.is_some(); - - let full = FullUserDto::build(user, flags); - let self_dto = SelfUserDto::build( - full, - ui_preferences, - notify_on_share, - is_dpop_bound, - force_password_change, - can_edit_image, - ); - Ok((StatusCode::OK, Json(self_dto))) } @@ -860,14 +821,16 @@ pub async fn change_password( /// self-registration policy. Refused with 403 /// `error_type = "RegistrationDomainNotAllowed"`. /// -/// Response: the updated `PublicUserDto` (post-upgrade view — `is_external` -/// is false, `storage_quota_bytes` is set). +/// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA +/// absorbs the post-upgrade state — new `storage_quota_bytes`, +/// `is_external = false`, updated OPAQUE / auth capability flags — in one +/// round trip without a follow-up `/me` fetch. #[utoipa::path( post, path = "/api/auth/upgrade-to-internal", request_body = UpgradeToInternalDto, responses( - (status = 200, description = "Upgrade succeeded", body = PublicUserDto), + (status = 200, description = "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)", body = SelfUserDto), (status = 400, description = "Password missing / too short"), (status = 401, description = "Not authenticated"), (status = 403, description = "OIDC user, or domain not in allowlist"), @@ -878,9 +841,10 @@ pub async fn change_password( )] pub async fn upgrade_to_internal( State(state): State>, - CurrentUserId(user_id): CurrentUserId, + auth_user: AuthUser, Json(dto): Json, ) -> Result { + let user_id = auth_user.id; let auth_service = state .auth_service .as_ref() @@ -928,7 +892,11 @@ pub async fn upgrade_to_internal( } } - let updated = auth_service + // Apply the upgrade. Service returns the updated `PublicUserDto`; + // we discard it and rebuild the full self view via the shared + // `build_self_user_dto_for_id` helper so the wire shape matches + // `GET /me` and `PATCH /me/profile` byte-for-byte. + let _ = auth_service .auth_application_service .upgrade_to_internal(user_id, dto) .await @@ -947,7 +915,11 @@ pub async fn upgrade_to_internal( _ => AppError::from(err), })?; - Ok((StatusCode::OK, Json(updated))) + let self_dto = auth_service + .auth_application_service + .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some()) + .await?; + Ok((StatusCode::OK, Json(self_dto))) } /// Update the caller's profile (PR 24). @@ -965,7 +937,7 @@ pub async fn upgrade_to_internal( path = "/api/auth/me/profile", request_body = crate::application::dtos::user_dto::UpdateProfileDto, responses( - (status = 200, description = "Updated profile (PublicUserDto)", body = PublicUserDto), + (status = 200, description = "Updated profile (SelfUserDto) — same shape as GET /me so the SPA sees the just-written state without a follow-up fetch", body = SelfUserDto), (status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"), (status = 401, description = "Not authenticated"), (status = 403, description = "OIDC-managed profile — edit at the IdP"), @@ -976,20 +948,39 @@ pub async fn upgrade_to_internal( )] pub async fn update_profile( State(state): State>, - CurrentUserId(user_id): CurrentUserId, + auth_user: AuthUser, Json(dto): Json, ) -> Result { + let user_id = auth_user.id; let auth_service = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - let updated = auth_service + // Apply the patch. The service returns the updated `PublicUserDto` + // internally; we discard it and re-fetch the full self view below + // so the response matches `GET /me`'s `SelfUserDto` shape. + // + // Why SelfUserDto instead of PublicUserDto: a self-write endpoint + // whose response mirrors GET /me lets the SPA update its session + // store in one round trip. Returning a slim PublicUserDto would + // force the SPA to follow up with GET /me anyway to observe the + // just-written `ui_preferences` / `notify_on_share` / etc — those + // fields live on SelfUserDto only, not on the public identity + // slice. Same shape for both endpoints avoids "quiet lie" reads + // where a client PATCHes and then reads a stale local value. + let _ = auth_service .auth_application_service .update_profile_with_perms(user_id, dto, &state.locale_registry) .await?; - Ok((StatusCode::OK, Json(updated))) + // Rebuild via the shared helper so the wire shape matches + // `GET /me` and `POST /upgrade-to-internal` byte-for-byte. + let self_dto = auth_service + .auth_application_service + .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some()) + .await?; + Ok((StatusCode::OK, Json(self_dto))) } // TODO: add utoipa diff --git a/tests/api/admin_user_ops.hurl b/tests/api/admin_user_ops.hurl index 0abbe1bf..2f779695 100644 --- a/tests/api/admin_user_ops.hurl +++ b/tests/api/admin_user_ops.hurl @@ -56,7 +56,7 @@ Content-Type: application/json HTTP 201 [Captures] -charlie_id: jsonpath "$.id" +charlie_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -89,7 +89,7 @@ Authorization: Bearer {{charlie_token_v1}} HTTP 200 [Asserts] -jsonpath "$.storage_quota_bytes" == 209715200 +jsonpath "$.full.storage_quota_bytes" == 209715200 # ───────────────────────────────────────────────────────────── @@ -108,7 +108,7 @@ Authorization: Bearer {{charlie_token_v1}} HTTP 200 [Asserts] -jsonpath "$.role" == "admin" +jsonpath "$.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_login.hurl b/tests/api/auth_login.hurl index c553613a..86f14651 100644 --- a/tests/api/auth_login.hurl +++ b/tests/api/auth_login.hurl @@ -19,7 +19,7 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$.access_token" exists -jsonpath "$.user.email" == "{{email}}" +jsonpath "$.user.full.user.email" == "{{email}}" # ───────────────────────────────────────────────────────────── @@ -34,7 +34,7 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$.access_token" exists -jsonpath "$.user.email" == "{{email}}" +jsonpath "$.user.full.user.email" == "{{email}}" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_magic_link_login.hurl b/tests/api/auth_magic_link_login.hurl index da3e7197..e927c37d 100644 --- a/tests/api/auth_magic_link_login.hurl +++ b/tests/api/auth_magic_link_login.hurl @@ -142,10 +142,10 @@ Authorization: Bearer {{alice_magic_access_token}} HTTP 200 [Asserts] -jsonpath "$.email" == "{{email}}" -jsonpath "$.username" == "{{username}}" +jsonpath "$.full.user.email" == "{{email}}" +jsonpath "$.full.user.username" == "{{username}}" [Captures] -admin_user_id: jsonpath "$.id" +admin_user_id: jsonpath "$.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_session_lifecycle.hurl b/tests/api/auth_session_lifecycle.hurl index df61b796..621afe88 100644 --- a/tests/api/auth_session_lifecycle.hurl +++ b/tests/api/auth_session_lifecycle.hurl @@ -78,7 +78,7 @@ Authorization: Bearer {{access_v2}} HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" +jsonpath "$.full.user.username" == "{{username}}" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_upgrade_to_internal.hurl b/tests/api/auth_upgrade_to_internal.hurl index ff429ec8..2e59aaa7 100644 --- a/tests/api/auth_upgrade_to_internal.hurl +++ b/tests/api/auth_upgrade_to_internal.hurl @@ -52,7 +52,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -73,8 +73,8 @@ Authorization: Bearer {{bob_token}} HTTP 200 [Asserts] -jsonpath "$.is_external" == true -jsonpath "$.storage_quota_bytes" == 0 +jsonpath "$.full.user.is_external" == true +jsonpath "$.full.storage_quota_bytes" == 0 # ───────────────────────────────────────────────────────────── @@ -88,8 +88,8 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.is_external" == false -jsonpath "$.storage_quota_bytes" > 0 +jsonpath "$.full.user.is_external" == false +jsonpath "$.full.storage_quota_bytes" > 0 # ───────────────────────────────────────────────────────────── @@ -100,8 +100,8 @@ Authorization: Bearer {{bob_token}} HTTP 200 [Asserts] -jsonpath "$.is_external" == false -jsonpath "$.storage_quota_bytes" > 0 +jsonpath "$.full.user.is_external" == false +jsonpath "$.full.storage_quota_bytes" > 0 # ───────────────────────────────────────────────────────────── @@ -179,7 +179,7 @@ Content-Type: application/json HTTP 201 [Captures] -carol_user_id: jsonpath "$.id" +carol_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -204,7 +204,7 @@ Authorization: Bearer {{carol_token}} HTTP 200 [Asserts] -jsonpath "$.is_external" == true +jsonpath "$.full.user.is_external" == true # ───────────────────────────────────────────────────────────── diff --git a/tests/api/calendar.hurl b/tests/api/calendar.hurl index e53c192e..108330e4 100644 --- a/tests/api/calendar.hurl +++ b/tests/api/calendar.hurl @@ -41,7 +41,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -120,7 +120,7 @@ Content-Type: application/json HTTP 200 [Captures] bob_token: jsonpath "$.access_token" -bob_user_id: jsonpath "$.user.id" +bob_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/contacts.hurl b/tests/api/contacts.hurl index 7057b488..a02021d5 100644 --- a/tests/api/contacts.hurl +++ b/tests/api/contacts.hurl @@ -24,7 +24,7 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" @@ -344,7 +344,7 @@ Content-Type: application/json HTTP 200 [Captures] bob_token: jsonpath "$.access_token" -bob_user_id: jsonpath "$.user.id" +bob_user_id: jsonpath "$.user.full.user.id" # Step 17 — Bob's book listing does NOT include Alice's book. diff --git a/tests/api/cross_drive_copy.hurl b/tests/api/cross_drive_copy.hurl index b317705a..a804dd9e 100644 --- a/tests/api/cross_drive_copy.hurl +++ b/tests/api/cross_drive_copy.hurl @@ -71,7 +71,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/cross_drive_move.hurl b/tests/api/cross_drive_move.hurl index c23b4eca..40b0bdd6 100644 --- a/tests/api/cross_drive_move.hurl +++ b/tests/api/cross_drive_move.hurl @@ -67,7 +67,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/dav_error_mapping.hurl b/tests/api/dav_error_mapping.hurl index 1815397f..49d7ef20 100644 --- a/tests/api/dav_error_mapping.hurl +++ b/tests/api/dav_error_mapping.hurl @@ -249,7 +249,7 @@ Content-Type: application/json HTTP * [Captures] -alice_id: jsonpath "$.id" +alice_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/default_caldav_carddav.hurl b/tests/api/default_caldav_carddav.hurl index f67cb071..d849f6f4 100644 --- a/tests/api/default_caldav_carddav.hurl +++ b/tests/api/default_caldav_carddav.hurl @@ -103,7 +103,7 @@ Content-Type: application/json HTTP * [Captures] -fresh_user_id: jsonpath "$.id" +fresh_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drive_policies.hurl b/tests/api/drive_policies.hurl index 98f388eb..2aabcf0f 100644 --- a/tests/api/drive_policies.hurl +++ b/tests/api/drive_policies.hurl @@ -65,7 +65,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # Provision `dp_intruder` — a second internal user used only to @@ -91,7 +91,7 @@ Content-Type: application/json HTTP 200 [Captures] intruder_token: jsonpath "$.access_token" -intruder_user_id: jsonpath "$.user.id" +intruder_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drive_quota.hurl b/tests/api/drive_quota.hurl index e15e5ee1..c1358cfd 100644 --- a/tests/api/drive_quota.hurl +++ b/tests/api/drive_quota.hurl @@ -60,7 +60,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drive_read_only.hurl b/tests/api/drive_read_only.hurl index 6f94540f..4045fc2a 100644 --- a/tests/api/drive_read_only.hurl +++ b/tests/api/drive_read_only.hurl @@ -82,7 +82,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -107,7 +107,7 @@ Content-Type: application/json HTTP 200 [Captures] -target_user_id: jsonpath "$.user.id" +target_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drives_foundation.hurl b/tests/api/drives_foundation.hurl index 55202224..2ba30b40 100644 --- a/tests/api/drives_foundation.hurl +++ b/tests/api/drives_foundation.hurl @@ -31,7 +31,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -70,7 +70,7 @@ Content-Type: application/json HTTP 201 [Captures] -alice_user_id: jsonpath "$.id" +alice_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_token}} @@ -79,7 +79,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" # Alice's first login fires `PersonalDriveLifecycleHook::on_user_login` diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl index 5d454a5c..05cc3f8b 100644 --- a/tests/api/drives_membership.hurl +++ b/tests/api/drives_membership.hurl @@ -64,7 +64,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -113,7 +113,7 @@ Content-Type: application/json HTTP 201 [Captures] -alice_user_id: jsonpath "$.id" +alice_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -470,7 +470,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -657,7 +657,7 @@ Content-Type: application/json HTTP 201 [Captures] -carol_user_id: jsonpath "$.id" +carol_user_id: jsonpath "$.user.id" # 24a — Owner grants Carol Owner role (Owner-creates-Owner). @@ -836,7 +836,7 @@ Content-Type: application/json HTTP 201 [Captures] -dave_user_id: jsonpath "$.id" +dave_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index ab10d5b2..51fde096 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -23,7 +23,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -226,13 +226,16 @@ Authorization: Bearer {{bob_access_token}} HTTP 200 [Asserts] +# `/api/users/{id}` returns the slim `PublicUserDto` (9 fields) — +# id / email / username / role / image / is_external / given_name / +# family_name / is_online. Admin-visible fields like +# `email_verified_at` moved to `FullUserDto` under the three-layer +# refactor (docs/plan/userdto-refactor.md) and are checked below +# via `/api/admin/users`. jsonpath "$.id" == "{{bob_user_id}}" jsonpath "$.is_external" == true jsonpath "$.email" == "bob@externalcompany.com" jsonpath "$.username" not exists -# PR 23 — bob redeemed his invitation magic-link in Step 8, so his -# email_verified_at was stamped at that time and stays set. -jsonpath "$.email_verified_at" exists # 11d — bob CAN look up Alice (his granter) — shared-grant relationship # lets the external recipient resolve the sharer's display name + @@ -244,14 +247,37 @@ HTTP 200 [Asserts] jsonpath "$.id" == "{{alice_user_id}}" jsonpath "$.is_external" == false -# Setup admin is auto-verified at creation. `setup_create_admin` stamps + +# 11c/d/verify — admin (alice) observes email_verified_at on both +# users via `GET /api/admin/users/{id}` — returns `FullUserDto` +# (public identity in `.user` + admin-visible extras at top level). +# +# `email_verified_at` lives on `FullUserDto` (admin+self-visible), +# not on `PublicUserDto` — peer views via `/api/users/{id}` never +# expose it. The admin single-user endpoint is the correct +# observation surface. See `docs/plan/userdto-refactor.md` for the +# three-layer split. +# +# Setup admin auto-verified rationale: `setup_create_admin` stamps # `email_verified_at = NOW()` — admin fiat counts as verification, -# matching the OIDC-JIT convention. Rationale: an operator running the -# first-run wizard is authoritative by construction (they set the -# password at the console on a fresh install). Without this, flipping +# matching the OIDC-JIT convention. An operator running the first-run +# wizard is authoritative by construction. Without this, flipping # `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment -# would lock the sole admin out of their own instance. The admin login -# exemption is a second layer of defense; this stamp is the primary. +# would lock the sole admin out of their own instance. +GET {{base_url}}/api/admin/users/{{bob_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.user.id" == "{{bob_user_id}}" +jsonpath "$.email_verified_at" exists + +GET {{base_url}}/api/admin/users/{{alice_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.user.id" == "{{alice_user_id}}" jsonpath "$.email_verified_at" exists # 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404 diff --git a/tests/api/files-folders.hurl b/tests/api/files-folders.hurl index 13505130..d1dcaf26 100644 --- a/tests/api/files-folders.hurl +++ b/tests/api/files-folders.hurl @@ -19,11 +19,11 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" -jsonpath "$.user.id" isString +jsonpath "$.user.full.user.id" isString # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grant_cleanup.hurl b/tests/api/grant_cleanup.hurl index 01d211e2..4353bd1a 100644 --- a/tests/api/grant_cleanup.hurl +++ b/tests/api/grant_cleanup.hurl @@ -29,7 +29,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders @@ -57,7 +57,7 @@ Content-Type: application/json HTTP 201 [Captures] -mallory_user_id: jsonpath "$.id" +mallory_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index b02d4d58..6cb3ddcc 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -23,7 +23,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 201 [Captures] -dave_user_id: jsonpath "$.id" +dave_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{alice_token}} @@ -54,7 +54,7 @@ Content-Type: application/json HTTP 201 [Captures] -eve_user_id: jsonpath "$.id" +eve_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -371,7 +371,7 @@ Content-Type: application/json HTTP 201 [Captures] -adam_user_id: jsonpath "$.id" +adam_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -1044,7 +1044,7 @@ Content-Type: application/json HTTP 201 [Captures] -frank_user_id: jsonpath "$.id" +frank_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/grants_nested_groups.hurl b/tests/api/grants_nested_groups.hurl index 480edbb3..579d234d 100644 --- a/tests/api/grants_nested_groups.hurl +++ b/tests/api/grants_nested_groups.hurl @@ -44,7 +44,7 @@ Content-Type: application/json HTTP 201 [Captures] -henry_user_id: jsonpath "$.id" +henry_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/groups_effective_members.hurl b/tests/api/groups_effective_members.hurl index 21f3dcb1..078f1a97 100644 --- a/tests/api/groups_effective_members.hurl +++ b/tests/api/groups_effective_members.hurl @@ -76,7 +76,7 @@ Content-Type: application/json HTTP 201 [Captures] -dora_id: jsonpath "$.id" +dora_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/nc_login_flow_v2_drive_picker.hurl b/tests/api/nc_login_flow_v2_drive_picker.hurl index a3e6bc94..0fcd7d1f 100644 --- a/tests/api/nc_login_flow_v2_drive_picker.hurl +++ b/tests/api/nc_login_flow_v2_drive_picker.hurl @@ -48,7 +48,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/nc_multidrive_move_regression.hurl b/tests/api/nc_multidrive_move_regression.hurl index 9c826370..3868ff91 100644 --- a/tests/api/nc_multidrive_move_regression.hurl +++ b/tests/api/nc_multidrive_move_regression.hurl @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 200 [Captures] jwt: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/nc_second_user_setup.hurl b/tests/api/nc_second_user_setup.hurl index 05fd69f6..d1f5a88e 100644 --- a/tests/api/nc_second_user_setup.hurl +++ b/tests/api/nc_second_user_setup.hurl @@ -50,5 +50,5 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$.access_token" exists -jsonpath "$.user.username" == "bob" -jsonpath "$.user.email" == "bob@example.com" +jsonpath "$.user.full.user.username" == "bob" +jsonpath "$.user.full.user.email" == "bob@example.com" diff --git a/tests/api/nc_webdav_patch_consistency.hurl b/tests/api/nc_webdav_patch_consistency.hurl index e23f1d78..cc108e67 100644 --- a/tests/api/nc_webdav_patch_consistency.hurl +++ b/tests/api/nc_webdav_patch_consistency.hurl @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_jwt: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ═════════════════════════════════════════════════════════════ @@ -71,7 +71,7 @@ Content-Type: application/json HTTP 201 [Captures] -editor_user_id: jsonpath "$.id" +editor_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_jwt}} @@ -85,7 +85,7 @@ Content-Type: application/json HTTP 201 [Captures] -viewer_user_id: jsonpath "$.id" +viewer_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_jwt}} @@ -99,7 +99,7 @@ Content-Type: application/json HTTP 201 [Captures] -outsider_user_id: jsonpath "$.id" +outsider_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -434,7 +434,7 @@ Content-Type: application/json HTTP 201 [Captures] -quota_owner_id: jsonpath "$.id" +quota_owner_id: jsonpath "$.user.id" PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota diff --git a/tests/api/nc_webdav_put_gaps.hurl b/tests/api/nc_webdav_put_gaps.hurl index d6d0657c..f8829b30 100644 --- a/tests/api/nc_webdav_put_gaps.hurl +++ b/tests/api/nc_webdav_put_gaps.hurl @@ -40,7 +40,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_jwt: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ═════════════════════════════════════════════════════════════ @@ -65,7 +65,7 @@ Content-Type: application/json HTTP 201 [Captures] -editor_user_id: jsonpath "$.id" +editor_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_jwt}} @@ -79,7 +79,7 @@ Content-Type: application/json HTTP 201 [Captures] -viewer_user_id: jsonpath "$.id" +viewer_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -351,7 +351,7 @@ Content-Type: application/json HTTP 201 [Captures] -quota_owner_id: jsonpath "$.id" +quota_owner_id: jsonpath "$.user.id" PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota diff --git a/tests/api/nc_webdav_quota_properties.hurl b/tests/api/nc_webdav_quota_properties.hurl index 99775a07..6db91de5 100644 --- a/tests/api/nc_webdav_quota_properties.hurl +++ b/tests/api/nc_webdav_quota_properties.hurl @@ -85,7 +85,7 @@ Content-Type: application/json HTTP 200 [Captures] ncq_owner_jwt: jsonpath "$.access_token" -ncq_owner_id: jsonpath "$.user.id" +ncq_owner_id: jsonpath "$.user.full.user.id" POST {{base_url}}/api/auth/app-passwords Authorization: Bearer {{ncq_owner_jwt}} diff --git a/tests/api/playlists.hurl b/tests/api/playlists.hurl index d8956818..664f9345 100644 --- a/tests/api/playlists.hurl +++ b/tests/api/playlists.hurl @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -126,7 +126,7 @@ Content-Type: application/json HTTP 200 [Captures] bob_token: jsonpath "$.access_token" -bob_user_id: jsonpath "$.user.id" +bob_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/registration.hurl b/tests/api/registration.hurl index c50a2c37..1a78984a 100644 --- a/tests/api/registration.hurl +++ b/tests/api/registration.hurl @@ -55,7 +55,7 @@ Content-Type: application/json HTTP 200 [Captures] charlie_token: jsonpath "$.access_token" -charlie_user_id: jsonpath "$.user.id" +charlie_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -139,16 +139,16 @@ Authorization: Bearer {{pr18_access_token}} HTTP 200 [Asserts] -jsonpath "$.email" == "pr18-emailonly@example.com" -jsonpath "$.is_external" == false -jsonpath "$.username" not exists +jsonpath "$.full.user.email" == "pr18-emailonly@example.com" +jsonpath "$.full.user.is_external" == false +jsonpath "$.full.user.username" not exists # PR 23 — the user redeemed the welcome magic-link in Step 5b, so # email_verified_at is stamped (the click IS the proof of inbox # control, regardless of whether the redemption went through the # direct or cross-browser-confirm path). -jsonpath "$.email_verified_at" exists +jsonpath "$.full.email_verified_at" exists [Captures] -pr18_user_id: jsonpath "$.id" +pr18_user_id: jsonpath "$.full.user.id" # ───────────────────────────────────────────────────────────── @@ -162,9 +162,9 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.id" == "{{pr18_user_id}}" -jsonpath "$.username" not exists -jsonpath "$.given_name" not exists +jsonpath "$.full.user.id" == "{{pr18_user_id}}" +jsonpath "$.full.user.username" not exists +jsonpath "$.full.user.given_name" not exists # ───────────────────────────────────────────────────────────── @@ -178,9 +178,9 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.given_name" == "Pee Are" -jsonpath "$.family_name" == "Eighteen" -jsonpath "$.username" not exists +jsonpath "$.full.user.given_name" == "Pee Are" +jsonpath "$.full.user.family_name" == "Eighteen" +jsonpath "$.full.user.username" not exists # ───────────────────────────────────────────────────────────── @@ -220,7 +220,7 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.username" == "pr18handle" +jsonpath "$.full.user.username" == "pr18handle" # ───────────────────────────────────────────────────────────── @@ -263,7 +263,7 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.given_name" == "Pr18@Handle" +jsonpath "$.full.user.given_name" == "Pr18@Handle" # ───────────────────────────────────────────────────────────── @@ -276,10 +276,10 @@ Authorization: Bearer {{pr18_access_token}} HTTP 200 [Asserts] -jsonpath "$.username" == "pr18handle" -jsonpath "$.given_name" == "Pr18@Handle" -jsonpath "$.family_name" == "Eighteen" -jsonpath "$.email_verified_at" exists +jsonpath "$.full.user.username" == "pr18handle" +jsonpath "$.full.user.given_name" == "Pr18@Handle" +jsonpath "$.full.user.family_name" == "Eighteen" +jsonpath "$.full.email_verified_at" exists # ───────────────────────────────────────────────────────────── diff --git a/tests/api/regression_595_unlimited_user_quota.hurl b/tests/api/regression_595_unlimited_user_quota.hurl index 928f75f1..f7a7fb35 100644 --- a/tests/api/regression_595_unlimited_user_quota.hurl +++ b/tests/api/regression_595_unlimited_user_quota.hurl @@ -80,7 +80,7 @@ Content-Type: application/json HTTP 200 [Captures] user_token: jsonpath "$.access_token" -user_user_id: jsonpath "$.user.id" +user_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/role_grants.hurl b/tests/api/role_grants.hurl index e5a218e1..b7742d8f 100644 --- a/tests/api/role_grants.hurl +++ b/tests/api/role_grants.hurl @@ -34,7 +34,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{admin_token}} @@ -56,7 +56,7 @@ Content-Type: application/json HTTP 201 [Captures] -renee_user_id: jsonpath "$.id" +renee_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users @@ -66,7 +66,7 @@ Content-Type: application/json HTTP 201 [Captures] -sam_user_id: jsonpath "$.id" +sam_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 36f55219..378fdf60 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -75,7 +75,10 @@ log "Probe blob and thumbnail confirmed present on disk." # subsequent trash-empty triggers garbage_collect() to remove the # now-orphaned blob files from disk. -# /api/admin/users returns { users: [...], total, limit, offset } +# /api/admin/users returns { users: [PublicUserDto…], total, limit, offset } +# under the default `?summary=false` path — flat public-identity rows. The +# `?summary=true` path emits nested FullUserDto rows instead (used by the +# admin table); see `docs/plan/userdto-refactor.md`. USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500") ADMIN_USER_ID=$(echo "$USERS_JSON" \ diff --git a/tests/api/subject_groups.hurl b/tests/api/subject_groups.hurl index b0c2ca90..70b3dc68 100644 --- a/tests/api/subject_groups.hurl +++ b/tests/api/subject_groups.hurl @@ -35,7 +35,7 @@ Content-Type: application/json HTTP 201 [Captures] -grace_user_id: jsonpath "$.id" +grace_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -268,7 +268,7 @@ Content-Type: application/json HTTP 201 [Captures] -helper_user_id: jsonpath "$.id" +helper_user_id: jsonpath "$.user.id" POST {{base_url}}/api/groups/{{engineers_id}}/members diff --git a/tests/api/trash_per_drive.hurl b/tests/api/trash_per_drive.hurl index 4b0fbdeb..5011ed6c 100644 --- a/tests/api/trash_per_drive.hurl +++ b/tests/api/trash_per_drive.hurl @@ -51,7 +51,7 @@ Content-Type: application/json HTTP 201 [Captures] -owner_user_id: jsonpath "$.id" +owner_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -209,7 +209,7 @@ Content-Type: application/json HTTP 201 [Captures] -viewer_user_id: jsonpath "$.id" +viewer_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/user_envelope_quota.hurl b/tests/api/user_envelope_quota.hurl index ea141db0..7ab4bc4b 100644 --- a/tests/api/user_envelope_quota.hurl +++ b/tests/api/user_envelope_quota.hurl @@ -64,7 +64,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 0 +jsonpath "$.full.storage_used_bytes" == 0 # ───────────────────────────────────────────────────────────── @@ -173,7 +173,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 0 +jsonpath "$.full.storage_used_bytes" == 0 # ───────────────────────────────────────────────────────────── @@ -202,7 +202,7 @@ retry-interval: 200ms HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 32 +jsonpath "$.full.storage_used_bytes" == 32 # Confirm the sweep agrees with the delta — both code paths must @@ -217,7 +217,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 32 +jsonpath "$.full.storage_used_bytes" == 32 # ───────────────────────────────────────────────────────────── @@ -247,7 +247,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 0 +jsonpath "$.full.storage_used_bytes" == 0 # ───────────────────────────────────────────────────────────── diff --git a/tests/api/webdav_patch_consistency.hurl b/tests/api/webdav_patch_consistency.hurl index e3842c87..399b5819 100644 --- a/tests/api/webdav_patch_consistency.hurl +++ b/tests/api/webdav_patch_consistency.hurl @@ -174,7 +174,7 @@ Content-Type: application/json HTTP 201 [Captures] -quota_owner_id: jsonpath "$.id" +quota_owner_id: jsonpath "$.user.id" PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota diff --git a/tests/api/webdav_permissions.hurl b/tests/api/webdav_permissions.hurl index 57cf811e..558ca16a 100644 --- a/tests/api/webdav_permissions.hurl +++ b/tests/api/webdav_permissions.hurl @@ -35,7 +35,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -54,7 +54,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/webdav_quota_properties.hurl b/tests/api/webdav_quota_properties.hurl index d092578f..1b3381f1 100644 --- a/tests/api/webdav_quota_properties.hurl +++ b/tests/api/webdav_quota_properties.hurl @@ -151,7 +151,7 @@ Content-Type: application/json HTTP 200 [Captures] wq_owner_token: jsonpath "$.access_token" -wq_owner_id: jsonpath "$.user.id" +wq_owner_id: jsonpath "$.user.full.user.id" POST {{base_url}}/api/drives diff --git a/tests/api/wopi_authz.hurl b/tests/api/wopi_authz.hurl index 144e0df7..cf01f5e5 100644 --- a/tests/api/wopi_authz.hurl +++ b/tests/api/wopi_authz.hurl @@ -39,7 +39,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders @@ -65,7 +65,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/wopi_shared_drive.hurl b/tests/api/wopi_shared_drive.hurl index 11c7ab36..695e256d 100644 --- a/tests/api/wopi_shared_drive.hurl +++ b/tests/api/wopi_shared_drive.hurl @@ -47,7 +47,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/oidc/link_unlink.hurl b/tests/oidc/link_unlink.hurl index b8e8b3e6..958b7efe 100644 --- a/tests/oidc/link_unlink.hurl +++ b/tests/oidc/link_unlink.hurl @@ -98,11 +98,11 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" +jsonpath "$.full.user.username" == "{{username}}" # federation_kind is skip_serializing_if=Option::is_none, so a # local user's response OMITS the field entirely. -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ───────────────────────────────────────────────────────────── @@ -202,8 +202,8 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ═════════════════════════════════════════════════════════════ @@ -252,8 +252,8 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ═════════════════════════════════════════════════════════════ @@ -307,9 +307,9 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.user.username" == "{{username}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" # Scenario 9 — unlink success (admin has a password, so the @@ -326,8 +326,8 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ═════════════════════════════════════════════════════════════ @@ -380,7 +380,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" == "oidc" +jsonpath "$.full.federation_kind" == "oidc" # Unlink to reset state before the auto-link scenarios. @@ -447,9 +447,9 @@ HTTP 200 [Asserts] # Auto-link resolved to the pre-existing admin, NOT a fresh # JIT-provisioned user. The load-bearing assertion. -jsonpath "$.user.username" == "{{username}}" -jsonpath "$.user.federation_kind" == "oidc" -jsonpath "$.user.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.user.full.user.username" == "{{username}}" +jsonpath "$.user.full.federation_kind" == "oidc" +jsonpath "$.user.full.federation_issuer" == "{{oidc_issuer}}" [Captures] # Fresh cookies replace the password session's; capture the # new CSRF for the unlink below. @@ -463,9 +463,9 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.user.username" == "{{username}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" # Reset admin state before the next scenario (auto-link would @@ -535,7 +535,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists +jsonpath "$.full.federation_kind" not exists # Reset fake IdP state (email_verified back to true, sub back @@ -599,7 +599,7 @@ X-CSRF-Token: {{autolink_csrf_token}} HTTP 201 [Captures] -alias_user_id: jsonpath "$.id" +alias_user_id: jsonpath "$.user.id" # Point the fake IdP at a fresh sub with admin's email. Both @@ -636,7 +636,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists +jsonpath "$.full.federation_kind" not exists # Cleanup — delete the collider so later scenarios see the same @@ -701,8 +701,8 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.user.username" == "oidc_user" -jsonpath "$.user.federation_kind" == "oidc" +jsonpath "$.user.full.user.username" == "oidc_user" +jsonpath "$.user.full.federation_kind" == "oidc" [Captures] # Fresh CSRF from the OIDC session cookies — the admin CSRFs # won't validate against these new cookies. @@ -730,5 +730,5 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index 026523fb..2aa1802d 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -172,7 +172,7 @@ Content-Type: application/json HTTP 200 [Captures] -oidc_session_user: jsonpath "$.user.username" +oidc_session_user: jsonpath "$.user.full.user.username" # Snapshotted so Step 7's refresh can prove the tokens rotated # rather than being re-issued unchanged. The refresh handler in # auth_handler.rs always rotates all three cookies (access JWT, @@ -184,8 +184,8 @@ initial_access_token: jsonpath "$.access_token" initial_refresh_token: jsonpath "$.refresh_token" initial_csrf_token: cookie "oxicloud_csrf" [Asserts] -jsonpath "$.user.username" == "oidc_user" -jsonpath "$.user.email" == "oidc@example.com" +jsonpath "$.user.full.user.username" == "oidc_user" +jsonpath "$.user.full.user.email" == "oidc@example.com" jsonpath "$.access_token" isString # Multiple Set-Cookie headers come back as a list of values, so # `contains` only matches whole-element strings. Each cookie shows up @@ -211,10 +211,10 @@ HTTP 200 # Stash the user id for the re-login check in Step 10 below — a # second OIDC flow with the same `sub` must resolve back to this # exact user, not silently create a duplicate. -oidc_user_id: jsonpath "$.id" +oidc_user_id: jsonpath "$.full.user.id" [Asserts] -jsonpath "$.username" == "oidc_user" -jsonpath "$.email" == "oidc@example.com" +jsonpath "$.full.user.username" == "oidc_user" +jsonpath "$.full.user.email" == "oidc@example.com" # Post the federation-identity rename (docs/plan/ocm.md § Schema # rename) UserDto exposes federation_kind + federation_issuer as # separate nullable fields. Local users have both null; OIDC users @@ -222,24 +222,24 @@ jsonpath "$.email" == "oidc@example.com" # the fake IdP (tests/oidc/fake_idp/server.js) that URL is the # issuer published in its discovery document, which matches # `oidc_issuer` from test.env. -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" # Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js) # pins these values and OxiCloud must persist each one verbatim during # JIT provisioning (see auth_application_service.rs around line 2257). # A regression that drops, swaps, or truncates a claim trips here. # Note the field name flip on the API side: OIDC `picture` becomes # UserDto.image (a URL or data URI). -jsonpath "$.given_name" == "OIDC" -jsonpath "$.family_name" == "Test" -jsonpath "$.image" == "https://example.com/oidc-test-user.png" +jsonpath "$.full.user.given_name" == "OIDC" +jsonpath "$.full.user.family_name" == "Test" +jsonpath "$.full.user.image" == "https://example.com/oidc-test-user.png" # Group-to-role mapping. server-with-oidc.env sets # OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include # `groups: ["admin-users"]`. The JIT path intersects the claim against # the env and promotes the new user from `user` to `admin`. A # regression here would silently strip (or wrongly grant) admin rights # for every SSO deployment that uses group-based role mapping. -jsonpath "$.role" == "admin" +jsonpath "$.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── @@ -274,7 +274,7 @@ refreshed_refresh_token: jsonpath "$.refresh_token" # the (freshly-rotated) `oxicloud_csrf` cookie on the browser. refreshed_csrf_token: cookie "oxicloud_csrf" [Asserts] -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.username" == "oidc_user" jsonpath "$.access_token" isString jsonpath "$.refresh_token" isString # All three cookies must rotate. If any value were re-used, a @@ -296,7 +296,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "oidc_user" +jsonpath "$.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── @@ -412,8 +412,8 @@ HTTP 200 [Asserts] # Same local id — proves the existing-user resolver matched on `sub` # (or `oidc_provider + oidc_subject`) instead of minting a new row. -jsonpath "$.user.id" == "{{oidc_user_id}}" -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.id" == "{{oidc_user_id}}" +jsonpath "$.user.full.user.username" == "oidc_user" # Role from the prior JIT-provisioned admin survives the re-login. # Two regressions this catches: (a) the existing-user branch wiping # the role to a default `user`; (b) the existing-user branch @@ -421,7 +421,7 @@ jsonpath "$.user.username" == "oidc_user" # IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS # still resolves to "admin"). Either way, the role should remain # `admin` — otherwise we have a silent admin demotion on every login. -jsonpath "$.user.role" == "admin" +jsonpath "$.user.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── @@ -876,7 +876,7 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── @@ -889,7 +889,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "oidc_user" +jsonpath "$.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── diff --git a/tests/oidc/sso-only.hurl b/tests/oidc/sso-only.hurl index 87a33c0b..4f0b2230 100644 --- a/tests/oidc/sso-only.hurl +++ b/tests/oidc/sso-only.hurl @@ -123,10 +123,10 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.username" == "oidc_user" # Group-to-role mapping worked — this is now the admin (and the only # user). -jsonpath "$.user.role" == "admin" +jsonpath "$.user.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── @@ -138,7 +138,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "oidc_user" +jsonpath "$.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── diff --git a/tests/webdav/test_nextcloud_chunked_upload_cap.sh b/tests/webdav/test_nextcloud_chunked_upload_cap.sh index a2e4dd96..0ed426ef 100755 --- a/tests/webdav/test_nextcloud_chunked_upload_cap.sh +++ b/tests/webdav/test_nextcloud_chunked_upload_cap.sh @@ -132,9 +132,9 @@ echo " app password minted (id=$APP_PASSWORD_ID)" # failure path. `storage_quota_bytes == 0` is the unlimited # sentinel (see `check_storage_quota`); we read it back here in # case a prior test set a real value. -ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.id') +ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.full.user.id') [[ -n "$ADMIN_ID" && "$ADMIN_ID" != "null" ]] || fail "Failed to read admin user id" -ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.storage_quota_bytes // 0') +ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.full.storage_quota_bytes // 0') # Single cleanup on exit: # - restore admin's original storage envelope (in case Case 3 @@ -247,7 +247,7 @@ echo "[3/3] QUOTA REJECTION — envelope tightened to (used + 100 B), PUT 200 B # `current + 100` — leaves enough headroom that MKCOL passes # (`used + 0 = used < used + 100`) while a 200 B chunk PUT # overflows by exactly 100 (`used + 0 + 200 > used + 100`). -CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.storage_used_bytes') +CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.full.storage_used_bytes') [[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes" TIGHT_QUOTA=$(( CURRENT_USED + 100 )) From 537e7f15efbe7c7ee248f24e4624297db2fc8ff3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 23:59:40 +0200 Subject: [PATCH 015/144] fix(users): /api/admin/users always returns a FullUserDto[] --- frontend/src/lib/api/endpoints/admin.test.ts | 5 +- frontend/src/lib/api/endpoints/admin.ts | 8 ++- frontend/src/lib/stores/preferences.svelte.ts | 10 ++- .../src/routes/admin/[[tab]]/+page.svelte | 9 ++- src/application/dtos/settings_dto.rs | 9 +-- .../services/auth_application_service.rs | 28 ++------ src/interfaces/api/handlers/admin_handler.rs | 67 +++++++------------ tests/api/storage_cleanup_check.sh | 12 ++-- 8 files changed, 62 insertions(+), 86 deletions(-) diff --git a/frontend/src/lib/api/endpoints/admin.test.ts b/frontend/src/lib/api/endpoints/admin.test.ts index 5c4a6977..f234f36e 100644 --- a/frontend/src/lib/api/endpoints/admin.test.ts +++ b/frontend/src/lib/api/endpoints/admin.test.ts @@ -64,10 +64,7 @@ describe('admin mutate-based endpoints', () => { describe('admin read endpoints', () => { it('call apiJson for the listing/settings reads', async () => { await admin.listUsers(25, 0); - expect(jsonMock).toHaveBeenCalledWith( - '/api/admin/users?limit=25&offset=0&summary=true', - expect.anything() - ); + expect(jsonMock).toHaveBeenCalledWith('/api/admin/users?limit=25&offset=0', expect.anything()); await admin.getDashboard(); await admin.getSmtpInfo(); await admin.getOidcSettings(); diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 586b0b4b..9d1eae3d 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -277,10 +277,12 @@ export function revokeAdminSession(sessionId: string): Promise { // ── Users ─────────────────────────────────────────────────────────────── -/** List the compact rows rendered by the management table; full account - * details remain available through {@link getUserAdmin}. */ +/** List admin users — always returns `FullUser` rows. The former + * `?summary` toggle is retired; a single canonical shape carries + * the vignette + admin-visible extras the table needs. Single-user + * details still available via {@link getUserAdmin}. */ export function listUsers(limit: number, offset: number): Promise { - return apiJson(`/api/admin/users?limit=${limit}&offset=${offset}&summary=true`, { + return apiJson(`/api/admin/users?limit=${limit}&offset=${offset}`, { credentials: 'same-origin' }); } diff --git a/frontend/src/lib/stores/preferences.svelte.ts b/frontend/src/lib/stores/preferences.svelte.ts index 610b0979..a905da65 100644 --- a/frontend/src/lib/stores/preferences.svelte.ts +++ b/frontend/src/lib/stores/preferences.svelte.ts @@ -137,16 +137,20 @@ class PreferencesStore { this.pendingPatch = {}; if (Object.keys(patch).length === 0) return; - const previousUser = session.user; + // `session.user` is a derived read-through on `session.me.full.user` + // — the source of truth is `session.me: SelfUser`. Snapshot + assign + // there so the optimistic update / rollback matches the store shape + // (see `docs/plan/userdto-refactor.md` for the layering). + const previousMe = session.me; try { const updated = await updateProfile({ ui_preferences: patch }); - session.user = updated; + session.me = updated; } catch { // Roll back to whatever the server last confirmed. The // optimistic local mutation is discarded and the derived // `hideDotfiles` / other getters snap back on the next // reactivity tick. - session.user = previousUser; + session.me = previousMe; ui.notify( t('preferences.save_failed', "Couldn't save your preference. Please try again."), 'error' diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 9d66fbde..3a80c6e0 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -1247,8 +1247,13 @@ .map(async (d) => { const ownerMember = nextMembers[d.id]?.find((m) => m.subject.type === 'user'); if (!ownerMember) return; - const user = await getUserAdmin(ownerMember.subject.id); - if (user) nextOwners[d.id] = user; + // `getUserAdmin` returns `FullUser` (admin-visible extras + // + nested `.user: PublicUser`). The drive row only reads + // public-identity fields (username, email, image) so keep + // the map typed as `PublicUser` and unwrap the embedded + // public block on insert. See docs/plan/userdto-refactor.md. + const full = await getUserAdmin(ownerMember.subject.id); + if (full) nextOwners[d.id] = full.user; }) ); personalDriveOwners = nextOwners; diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 835f4719..7917d519 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -108,14 +108,15 @@ pub struct AdminResetPasswordDto { pub new_password: String, } -/// Query parameters for listing users +/// Query parameters for listing users. `/api/admin/users` used to +/// bifurcate on `?summary=` (flat `PublicUserDto` vs nested +/// `FullUserDto`); that split was retired — the endpoint now always +/// returns `FullUserDto`. Unknown query params are ignored, so +/// existing callers still passing `?summary=true` keep working. #[derive(Debug, Serialize, Deserialize)] pub struct ListUsersQueryDto { pub limit: Option, pub offset: Option, - /// Return only the fields rendered by the paginated management table. - /// Defaults to `false` so existing API clients keep the full user shape. - pub summary: Option, } /// Query parameters for the admin sessions listing. diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index d34ff312..618fdd22 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -3247,7 +3247,7 @@ impl AuthApplicationService { /// out so that internal-user surfaces — system address book, OCS /// sharee search, etc. — never expose external identities. Admin /// surfaces that need the full list should call - /// [`list_users_including_external_with_perms`] instead. + /// [`list_user_summaries_including_external_with_perms`] instead. pub async fn list_users( &self, limit: i64, @@ -3260,23 +3260,6 @@ impl AuthApplicationService { .collect()) } - /// Admin-only: lists users including external (grant-only) recipients. - /// Used by the admin user-management UI. - pub async fn list_users_including_external_with_perms( - &self, - authorization: &A, - caller_id: Uuid, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - self.require_admin_caller(authorization, caller_id).await?; - let users = self.user_storage.list_users(limit, offset, true).await?; - Ok(users - .into_iter() - .map(|u| PublicUserDto::new(u, false)) - .collect()) - } - /// Admin-only user listing. Returns `Vec` — same /// `FullUserDto` shape [`SelfUserDto`] embeds, so the FE reads /// admin table rows and `/me` responses through identical field @@ -3284,7 +3267,10 @@ impl AuthApplicationService { /// (`user.is_online`) so the admin table renders the vignette + /// green dot without per-row follow-up fetches to /// `/api/users/{id}` (the N+1 that motivated the widening — see - /// `docs/plan/userdto-refactor.md` § N+1). + /// `docs/plan/userdto-refactor.md` § N+1). This is the sole + /// admin-visible listing path; the former flat + /// `list_users_including_external_with_perms` variant was + /// retired when `?summary` was dropped. pub async fn list_user_summaries_including_external_with_perms( &self, authorization: &A, @@ -3362,8 +3348,8 @@ impl AuthApplicationService { // `interfaces/api/routes.rs::admin_router`) — but every admin // method here still calls `require_admin_caller` as a // defense-in-depth check, matching the pattern - // `list_users_including_external_with_perms` established. If a - // handler is ever wired outside the /admin subtree, the AuthZ + // `list_user_summaries_including_external_with_perms` established. + // If a handler is ever wired outside the /admin subtree, the AuthZ // still holds. /// List sessions for the admin panel. `user_id_filter = Some(uuid)` diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 4856f0c6..e1985f77 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -39,25 +39,14 @@ use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; use uuid::Uuid; -#[derive(serde::Serialize)] -#[serde(untagged)] -enum AdminUsersPayload { - /// Fat-`PublicUserDto` per row. Emitted when `?summary=false` — legacy - /// path retained until the FE drops the `summary=false` query - /// (rare; the SPA uses `summary=true` for the paginated table). - Full(Vec), - /// `FullUserDto` per row — same shape one row of the /me - /// response's embedded `full` carries. Emitted when - /// `?summary=true`. The FE seeds `resolveUser` cache from - /// `row.user` here (kills the per-row `/api/users/{id}` fetch). - /// The old `AdminUserSummaryDto` returned here has been replaced - /// by `FullUserDto`; see `docs/plan/userdto-refactor.md`. - Summary(Vec), -} - +/// Response envelope for `GET /api/admin/users`. `users` is always +/// `Vec` — same shape one row of `/me`'s embedded +/// `full` block carries; the FE seeds `resolveUser` cache from +/// `row.user` (kills the per-row `/api/users/{id}` fetch). See +/// `docs/plan/userdto-refactor.md`. #[derive(serde::Serialize)] struct AdminUsersPageResponse { - users: AdminUsersPayload, + users: Vec, total: i64, limit: i64, offset: i64, @@ -1094,13 +1083,20 @@ pub async fn get_dashboard_stats( // ============================================================================ /// GET /api/admin/users?limit=50&offset=0 — list all users +/// +/// Always returns `Vec` — the shape one row of the +/// `/me` response's embedded `full` block carries. The former +/// `?summary` toggle (flat `PublicUserDto` vs nested `FullUserDto`) +/// has been retired: admin listing is low-volume and the FE always +/// asked for the nested shape anyway, so the two-shape split served +/// no caller and only invited jq-path bugs. See +/// `docs/plan/userdto-refactor.md`. #[utoipa::path( get, path = "/api/admin/users", params( ("limit" = Option, Query, description = "Max users to return (default 100, max 500)"), - ("offset" = Option, Query, description = "Pagination offset"), - ("summary" = Option, Query, description = "Return the compact management-table projection") + ("offset" = Option, Query, description = "Pagination offset") ), responses( (status = 200, description = "List of users"), @@ -1128,31 +1124,16 @@ pub async fn list_users( // internal-only variant is used by system address book / sharee // search, where surfacing externals would leak identities. See // `auth_application_service::list_users` doc for the split. - let users = if query.summary.unwrap_or(false) { - AdminUsersPayload::Summary( - auth.auth_application_service - .list_user_summaries_including_external_with_perms( - state.authorization.as_ref(), - auth_user.id, - limit, - offset, - ) - .await - .map_err(AppError::from)?, + let users = auth + .auth_application_service + .list_user_summaries_including_external_with_perms( + state.authorization.as_ref(), + auth_user.id, + limit, + offset, ) - } else { - AdminUsersPayload::Full( - auth.auth_application_service - .list_users_including_external_with_perms( - state.authorization.as_ref(), - auth_user.id, - limit, - offset, - ) - .await - .map_err(AppError::from)?, - ) - }; + .await + .map_err(AppError::from)?; let total = auth .auth_application_service diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 378fdf60..ec416797 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -75,18 +75,18 @@ log "Probe blob and thumbnail confirmed present on disk." # subsequent trash-empty triggers garbage_collect() to remove the # now-orphaned blob files from disk. -# /api/admin/users returns { users: [PublicUserDto…], total, limit, offset } -# under the default `?summary=false` path — flat public-identity rows. The -# `?summary=true` path emits nested FullUserDto rows instead (used by the -# admin table); see `docs/plan/userdto-refactor.md`. +# /api/admin/users returns { users: [FullUserDto…], total, limit, offset } +# — public identity nests under `.user`; admin-visible extras +# (`storage_used_bytes`, `last_login_at`, …) sit at the top level of +# each row. See `docs/plan/userdto-refactor.md`. USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500") ADMIN_USER_ID=$(echo "$USERS_JSON" \ - | jq -r --arg u "$username" '.users[] | select(.username == $u) | .id') + | jq -r --arg u "$username" '.users[] | select(.user.username == $u) | .user.id') [[ -z "$ADMIN_USER_ID" || "$ADMIN_USER_ID" == "null" ]] && fail "could not resolve admin user id" OTHER_USER_IDS=$(echo "$USERS_JSON" \ - | jq -r --arg admin_id "$ADMIN_USER_ID" '.users[] | select(.id != $admin_id) | .id') + | jq -r --arg admin_id "$ADMIN_USER_ID" '.users[] | select(.user.id != $admin_id) | .user.id') OTHER_USER_COUNT=0 while IFS= read -r uid; do From af9d9badb425180416a9a44e726521b675d7a67b Mon Sep 17 00:00:00 2001 From: yzxcj797 <1784931579@qq.com> Date: Sat, 22 Aug 2026 07:28:21 +0800 Subject: [PATCH 016/144] fix(caldav): accept floating-time DTSTART/DTEND values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_ical_datetime rejected any datetime without the trailing 'Z', so events created without a timezone in calendar apps — which DAVx5 syncs as floating time per RFC 5545 3.3.5 form 2 — failed with 'Invalid DTSTART: Invalid datetime format: expected YYYYMMDDTHHMMSSZ' and HTTP 400, breaking the whole event upload. Accept the 15-char floating form and interpret the wall-clock time as UTC. TZID-anchored forms remain unsupported until VTIMEZONE handling lands. Fixes #682 --- src/domain/entities/calendar_event.rs | 42 +++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/domain/entities/calendar_event.rs b/src/domain/entities/calendar_event.rs index 2fb6c4e9..fbbc0b83 100644 --- a/src/domain/entities/calendar_event.rs +++ b/src/domain/entities/calendar_event.rs @@ -1105,9 +1105,15 @@ impl CalendarEvent { } // Standard UTC form: YYYYMMDDTHHMMSSZ, 16 chars, trailing 'Z'. - // Floating-time (no 'Z') and TZID-anchored forms aren't yet - // supported — future work when we tackle VTIMEZONE properly. - if value.len() < 15 || !value.ends_with('Z') { + // Floating-time (no 'Z', RFC 5545 §3.3.5) is what calendar apps emit + // for events without a timezone — DAVx5 sends it from Fossify + // Calendar, and rejecting it failed the whole event sync with a 400 + // (#682). Accept it and interpret the wall-clock time as UTC. + // TZID-anchored forms remain unsupported — future work when we + // tackle VTIMEZONE properly. + let has_utc_suffix = value.len() == 16 && value.ends_with('Z'); + let is_floating = value.len() == 15; + if !has_utc_suffix && !is_floating { return Err(format!( "Invalid datetime format: expected YYYYMMDDTHHMMSSZ, got {:?}", value @@ -1355,6 +1361,24 @@ SUMMARY:Weekly all-day — rescheduled\r RECURRENCE-ID;VALUE=DATE:20260112\r END:VEVENT\r END:VCALENDAR\r +"; + + /// Floating-time VEVENT — DTSTART/DTEND without the UTC 'Z' suffix + /// (RFC 5545 §3.3.5 "form #2": local time, no timezone reference). + /// This is what DAVx5 syncs from calendar apps for events created + /// without a timezone (e.g. Fossify Calendar); rejecting it failed + /// the entire event upload with a 400 (#682). + const FLOATING_TIME_EVENT: &str = "BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//OxiCloud test//EN +BEGIN:VEVENT +UID:floating-1@oxicloud.test +DTSTAMP:20260101T100000Z +DTSTART:20260831T154000 +DTEND:20260831T164000 +SUMMARY:Floating time event +END:VEVENT +END:VCALENDAR "; fn parse_ok(body: &str) -> CalendarEvent { @@ -1369,6 +1393,18 @@ END:VCALENDAR\r assert!(!ev.all_day()); } + #[test] + fn floating_time_event_parses_as_utc_wall_clock() { + // Regression (#682): '20260831T154000' used to be rejected with + // "Invalid datetime format: expected YYYYMMDDTHHMMSSZ" and the + // whole DAVx5 sync failed with HTTP 400. + let ev = parse_ok(FLOATING_TIME_EVENT); + assert_eq!(ev.summary(), "Floating time event"); + assert!(!ev.all_day()); + assert_eq!(ev.start_time().to_rfc3339(), "2026-08-31T15:40:00+00:00"); + assert_eq!(ev.end_time().to_rfc3339(), "2026-08-31T16:40:00+00:00"); + } + #[test] fn all_day_event_parses_and_flags_as_all_day() { // Regression: DTSTART;VALUE=DATE:20260201 used to fail From 06e4df5318650b49cc9af8c3fbc6018ed8ce5099 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 22 Aug 2026 08:09:21 +0200 Subject: [PATCH 017/144] fix(upload): fix race condition in front-end --- frontend/src/lib/components/EmptyState.svelte | 14 +++++++- .../src/routes/admin/[[tab]]/+page.svelte | 19 +++++++++- .../src/routes/files/[...path]/+page.svelte | 36 +++++++++++++++++++ frontend/src/routes/files/page.test.ts | 6 ++++ frontend/static/locales/en.json | 1 + frontend/static/locales/fr.json | 1 + tests/e2e/spa/files.spec.ts | 11 +++++- 7 files changed, 85 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/EmptyState.svelte b/frontend/src/lib/components/EmptyState.svelte index 4403103f..1e0c606b 100644 --- a/frontend/src/lib/components/EmptyState.svelte +++ b/frontend/src/lib/components/EmptyState.svelte @@ -18,7 +18,19 @@ let { icon, title, hint, error = false, children }: Props = $props(); -
+ +
{#if icon}{/if} {#if title}

{title}

{/if} {#if hint}

{hint}

{/if} diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 3a80c6e0..919a3770 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -1882,13 +1882,17 @@ row.kind === 'personal' ? t('admin.quota_personal', 'Personal drives') : t('admin.quota_shared', 'Shared drives')} + {@const total = row.unlimited_count + row.capped_count} {@const pct = row.capped_quota_bytes && row.capped_quota_bytes > 0 ? (row.used_bytes / row.capped_quota_bytes) * 100 : null} {#if row.capped_count > 0 || row.unlimited_count > 0} - {label} + + {total} + {label} + {#if row.capped_quota_bytes !== null && pct !== null} {formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)} @@ -4569,6 +4573,19 @@ white-space: nowrap; } + /* Prepended drive count: tabular-nums so single/double/triple digits align + vertically across rows; right-aligned inside a fixed-width box so the + ones-digits line up across "personal" and "shared" rows regardless of + how many digits each count has. */ + .quota-table__count { + display: inline-block; + min-width: 1.5em; + margin-right: 0.25em; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text-heading); + } + .quota-table__num { font-variant-numeric: tabular-nums; white-space: nowrap; diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index fc8ca3f4..e4d9056f 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -713,8 +713,43 @@ * Upload a batch of files into the current folder, reporting aggregate * progress through a single bell notification with a progress bar. */ + /** + * Cold-navigation upload guard. + * + * `currentId` starts `null` and is only populated inside `load()` AFTER + * `session.loadHomeFolder()` resolves (see the `$effect` at the bottom of + * this file that drives `load()`, and the assignment at `currentId = + * folderId` inside `load()`). The hidden `` is unconditional in the template, so it's + * in the DOM the moment the page shell mounts — before `load()` has + * awaited its first HTTP round-trip. + * + * On a slow network / cold page / Playwright cold `page.goto` immediately + * followed by `setInputFiles`, `onchange` can fire while `currentId` is + * still `null`. Without this guard, `uploadBatch` / `uploadTree` post + * with `folderId: null` and the file silently lands in the caller's + * home root instead of the intended folder — a real user hitting Ctrl+U + * or dropping a file within ~100 ms of navigation hits the same window. + * + * The e2e reproduction: `tests/e2e/spa/files.spec.ts::"upload a file via + * the hidden file input"` flakes on CI where the mount→load round-trip + * outruns Playwright's file-input dispatch. + * + * Returns `true` when it's safe to proceed; `false` + a user-visible + * toast when the folder isn't ready. + */ + function guardUploadFolderReady(): boolean { + if (currentId !== null) return true; + ui.notify( + t('files.upload_folder_not_ready', 'Folder is still loading — please try again in a moment.'), + 'warning' + ); + return false; + } + async function uploadBatch(files: File[]) { if (files.length === 0) return; + if (!guardUploadFolderReady()) return; uploading = true; // Arm the reload-guard + persist a "batch in flight" marker so a // page refresh mid-upload (a) prompts the browser's "Leave site?" @@ -1557,6 +1592,7 @@ */ async function uploadTree(entries: { file: File; relativePath: string }[]) { if (entries.length === 0) return; + if (!guardUploadFolderReady()) return; uploading = true; // Same reload-guard + interrupted-uploads breadcrumb as uploadBatch — // the browser prompts on refresh, and if the user reloads anyway diff --git a/frontend/src/routes/files/page.test.ts b/frontend/src/routes/files/page.test.ts index b2c78a27..059dc70d 100644 --- a/frontend/src/routes/files/page.test.ts +++ b/frontend/src/routes/files/page.test.ts @@ -158,6 +158,12 @@ it('keeps aggregate upload progress exact when one file restarts', async () => { ); render(FilesPage); const input = await screen.findByTestId('files-upload-file-input'); + // The cold-navigation upload guard (`guardUploadFolderReady` in + // `+page.svelte`) refuses uploads while `currentId` is null — which is + // the initial state before `load()` runs. `load()` sets `currentId = + // folderId` BEFORE it calls `fetchFolderPage`, so waiting on the fetch + // mock is a stable "load() has progressed past the assignment" signal. + await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); const uploads = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]; Object.defineProperty(input, 'files', { configurable: true, value: uploads }); diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 4058be85..fc20b4d3 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -546,6 +546,7 @@ "empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.", "show_hidden": "Show hidden files", "upload_dotfile_hidden": "{{n}} file(s) uploaded but hidden by your dotfile preference.", + "upload_folder_not_ready": "Folder is still loading — please try again in a moment.", "rename_dotfile_hidden": "Renamed to '{{name}}' — now hidden by your preference.", "new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.", "dotfiles_hidden_toast": "Dotfiles hidden", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index d3b6713f..a2ef3ce1 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -459,6 +459,7 @@ "empty_hidden_hint": "Les fichiers dont le nom commence par '.' sont masqués. Modifiez le réglage pour les afficher.", "show_hidden": "Afficher les fichiers masqués", "upload_dotfile_hidden": "{{n}} fichier(s) téléversé(s) mais masqué(s) par votre préférence.", + "upload_folder_not_ready": "Le dossier est encore en cours de chargement — merci de réessayer dans un instant.", "rename_dotfile_hidden": "Renommé en \"{{name}}\" — désormais masqué par votre préférence.", "new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.", "dotfiles_hidden_toast": "Fichiers masqués", diff --git a/tests/e2e/spa/files.spec.ts b/tests/e2e/spa/files.spec.ts index ef7e88f5..467a8140 100644 --- a/tests/e2e/spa/files.spec.ts +++ b/tests/e2e/spa/files.spec.ts @@ -74,7 +74,16 @@ test('upload a file via the hidden file input', async ({ page }) => { // Navigate straight into the (empty) folder by ID (the route keys on folder // id, not name) — avoids the crowded root listing and click ambiguity. await page.goto(`/files/${created.id}`); - await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 }); + // The hidden file input renders unconditionally on mount, so waiting on + // `toBeAttached` fires BEFORE the page's `load()` populates `currentId` + // from the URL. Firing `setInputFiles` in that window used to race + // `load()` and post the upload with `folderId: null`, silently landing + // the file in the caller's home root — the guard in + // `guardUploadFolderReady` now refuses that upload with a toast. Wait + // for the empty-state hook instead — `ResourceList` only renders + // `EmptyState` once `load()` has definitively completed with zero + // items, so it doubles as a "folder is ready to accept uploads" signal. + await expect(page.getByTestId('empty-state')).toBeVisible({ timeout: 15_000 }); // Now inside the folder; upload a text file by setting the hidden input. const f = SAMPLE_FILES.text(); await page.getByTestId('files-upload-file-input').setInputFiles({ From d57400f7d33a91259927755c0658777f7f2f00da Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 23 Aug 2026 13:23:24 +0200 Subject: [PATCH 018/144] fix(i18n): fix too literal translation with jobs --- frontend/static/locales/ar.json | 6 +++--- frontend/static/locales/de.json | 8 ++++---- frontend/static/locales/es.json | 14 +++++++------- frontend/static/locales/fa.json | 6 +++--- frontend/static/locales/fr.json | 30 +++++++++++++++--------------- frontend/static/locales/hi.json | 12 ++++++------ frontend/static/locales/it.json | 10 +++++----- frontend/static/locales/ja.json | 10 +++++----- frontend/static/locales/ko.json | 12 ++++++------ frontend/static/locales/nl.json | 6 +++--- frontend/static/locales/pl.json | 12 ++++++------ frontend/static/locales/pt.json | 10 +++++----- frontend/static/locales/ru.json | 16 ++++++++-------- frontend/static/locales/zh-TW.json | 8 ++++---- frontend/static/locales/zh.json | 8 ++++---- 15 files changed, 84 insertions(+), 84 deletions(-) diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index ec5d8a6f..a6ec4f3c 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -1201,11 +1201,11 @@ "progress_scanned_only_tooltip": "لا يوجد إجمالي متاح لهذا التشغيل (نشر شريط التقدم المسبق أو عدم قيام المستأجر بالإبلاغ عن موضوع قابل للعد).", "findings_present_tooltip": "قم بتوسيع هذا التشغيل لرؤية تفاصيل كل نتيجة.", "col_error": "خطأ", - "col_kind": "عطوف", + "col_kind": "نوع", "col_severity": "خطورة", "col_resource": "الموارد", "col_detail": "التفاصيل", - "run": "يجري", + "run": "تشغيل", "cancel": "يلغي", "refresh": "ينعش", "runs_title": "أشواط الأخيرة", @@ -1218,7 +1218,7 @@ "every_min": "كل دقيقة", "every_sec": "كل ق", "outcome_ok": "نعم", - "outcome_err": "يخطئ", + "outcome_err": "خطأ", "outcome_issues": "مشاكل", "outcome_notices": "إشعارات", "n_findings": "‹النتائج", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 38bd8b1a..2d5f1fd6 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -1182,8 +1182,8 @@ "gen_key": "Schlüssel generieren", "gen_key_warning": "Bewahren Sie diesen Schlüssel sicher auf. Bei Verlust sind die verschlüsselten Daten unwiederbringlich verloren.", "jobs": { - "run_all_consistency": "Führen Sie alle Konsistenzprüfungen durch", - "run_deep": "Lauf tief", + "run_all_consistency": "Alle Konsistenzprüfungen ausführen", + "run_deep": "Tiefenprüfung", "run_deep_hint": "Läuft auch langsame Varianten (Blob-Re-Hash, Bitrot-Erkennung).", "col_name": "Name", "col_cadence": "Kadenz", @@ -1205,7 +1205,7 @@ "col_severity": "Schwere", "col_resource": "Ressource", "col_detail": "Detail", - "run": "Laufen", + "run": "Ausführen", "cancel": "Stornieren", "refresh": "Aktualisieren", "runs_title": "Aktuelle Läufe", @@ -1218,7 +1218,7 @@ "every_min": "alle {{n}} Min", "every_sec": "alle {{n}} s", "outcome_ok": "OK", - "outcome_err": "ähm", + "outcome_err": "err", "outcome_issues": "Probleme", "outcome_notices": "Hinweise", "n_findings": "{{n}} Erkenntnisse", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index 5f157a0b..881ba985 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -1183,15 +1183,15 @@ "time_just_now": "En este momento", "unchanged": "Déjelo en blanco para mantenerse actualizado", "jobs": { - "run_all_consistency": "Ejecute todas las comprobaciones de coherencia", - "run_deep": "Corre profundo", + "run_all_consistency": "Ejecutar todas las comprobaciones de coherencia", + "run_deep": "Análisis en profundidad", "run_deep_hint": "También ejecuta variantes lentas (repetición de blobs, detección de bitrot).", "col_name": "Nombre", "col_cadence": "Cadencia", "col_last_run": "última ejecución", "col_outcome": "Resultado", "col_state": "Estado", - "col_actions": "Comportamiento", + "col_actions": "Acciones", "col_started_at": "Comenzó", "col_status": "Estado", "col_duration": "Duración", @@ -1202,24 +1202,24 @@ "progress_scanned_only_tooltip": "No hay un total disponible para esta ejecución (implementación previa a la barra de progreso o el inquilino no informa un asunto contable).", "findings_present_tooltip": "Amplíe esta ejecución para ver detalles por hallazgo.", "col_error": "Error", - "col_kind": "Amable", + "col_kind": "Tipo", "col_severity": "Gravedad", "col_resource": "Recurso", "col_detail": "Detalle", - "run": "Correr", + "run": "Ejecutar", "cancel": "Cancelar", "refresh": "Refrescar", "runs_title": "Ejecuciones recientes", "run_json": "Resumen de ejecución (JSON)", "findings_title": "Recomendaciones", - "no_runs": "Aún no hay carreras.", + "no_runs": "Aún no hay ejecuciones.", "no_findings": "No hay resultados: ejecución limpia.", "on_demand": "Bajo demanda", "every_h": "cada {{n}} horas", "every_min": "cada {{n}} minutos", "every_sec": "cada {{n}} s", "outcome_ok": "OK", - "outcome_err": "errar", + "outcome_err": "err", "outcome_issues": "asuntos", "outcome_notices": "avisos", "n_findings": "{{n}} hallazgos", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index 03a60e3f..73f1e92a 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -1165,7 +1165,7 @@ "gen_key_warning": "این کلید را به صورت ایمن ذخیره کنید. اگر از بین برود، داده های رمزگذاری شده به طور غیرقابل جبرانی از بین می روند.", "jobs": { "run_all_consistency": "تمام بررسی های سازگاری را اجرا کنید", - "run_deep": "عمیق بدو", + "run_deep": "بررسی عمیق", "run_deep_hint": "همچنین انواع آهسته را اجرا می کند (هش مجدد حباب، تشخیص بیتوت).", "col_name": "نام", "col_cadence": "آهنگ", @@ -1182,7 +1182,7 @@ "progress_scanned_only_tooltip": "مجموع برای این اجرا موجود نیست (پیش از پیشرفت نوار مستقر شده یا مستاجر موضوع قابل شمارش را گزارش نمی کند).", "findings_present_tooltip": "این اجرا را گسترش دهید تا جزئیات هر یافته را ببینید.", "col_error": "خطا", - "col_kind": "مهربان", + "col_kind": "نوع", "col_severity": "شدت", "col_resource": "منبع", "col_detail": "جزئیات", @@ -1199,7 +1199,7 @@ "every_min": "هر {{n}} دقیقه", "every_sec": "هر {{n}} ثانیه", "outcome_ok": "باشه", - "outcome_err": "اشتباه کن", + "outcome_err": "خطا", "outcome_issues": "مسائل", "outcome_notices": "اطلاعیه ها", "n_findings": "{{n}} یافته ها", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index a2ef3ce1..7b51a642 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -1194,57 +1194,57 @@ "gen_key": "Générer une clé", "gen_key_warning": "Conservez cette clé en toute sécurité. En cas de perte, les données cryptées sont irrémédiablement perdues.", "jobs": { - "run_all_consistency": "Exécutez tous les contrôles de cohérence", - "run_deep": "Courir en profondeur", + "run_all_consistency": "Exécuter tous les contrôles de cohérence", + "run_deep": "Analyse approfondie", "run_deep_hint": "Exécute également des variantes lentes (re-hachage de blob, détection bitrot).", "col_name": "Nom", "col_cadence": "Cadence", - "col_last_run": "Dernière course", + "col_last_run": "Dernière exécution", "col_outcome": "Résultat", "col_state": "État", - "col_actions": "Actes", + "col_actions": "Actions", "col_started_at": "Commencé", "col_status": "Statut", "col_duration": "Durée", - "col_scanned": "Numérisé", + "col_scanned": "Analysé", "col_progress": "Progrès", "col_findings": "Résultats", "progress_scanned_only": "{{n}} scanné", "progress_scanned_only_tooltip": "Aucun total disponible pour cette exécution (déploiement préalable de la barre de progression ou le locataire ne signale pas de sujet dénombrable).", "findings_present_tooltip": "Développez cette analyse pour voir les détails par résultat.", "col_error": "Erreur", - "col_kind": "Gentil", + "col_kind": "Type", "col_severity": "Gravité", "col_resource": "Ressource", "col_detail": "Détail", - "run": "Courir", + "run": "Exécuter", "cancel": "Annuler", - "refresh": "Rafraîchir", - "runs_title": "Courses récentes", + "refresh": "Actualiser", + "runs_title": "Exécutions récentes", "run_json": "Résumé de l'exécution (JSON)", "findings_title": "Résultats", - "no_runs": "Aucune course pour l'instant.", + "no_runs": "Aucune exécution pour l'instant.", "no_findings": "Aucun résultat – exécution propre.", "on_demand": "sur demande", "every_h": "toutes les {{n}} h", "every_min": "toutes les {{n}} minutes", "every_sec": "toutes les {{n}} s", - "outcome_ok": "d'accord", - "outcome_err": "se tromper", + "outcome_ok": "ok", + "outcome_err": "err", "outcome_issues": "problèmes", "outcome_notices": "avis", - "n_findings": "{{n}} conclusions", + "n_findings": "{{n}} résultats", "n_notices": "{{n}} remarques", "notices_present_tooltip": "Résultats informatifs – aucune action requise. Développez pour plus de détails.", "purge": "Purger les anciennes exécutions", - "purge_hint": "Supprimez l’historique des exécutions terminées et échouées plus ancienne que la fenêtre de conservation choisie. Les résultats tombent avec leurs exécutions parentes. Les parcours non terminaux sont toujours préservés.", + "purge_hint": "Supprimer l’historique des exécutions terminées et échouées plus ancien que la fenêtre de conservation choisie. Les résultats sont supprimés avec leurs exécutions parentes. Les exécutions non terminales sont toujours préservées.", "purge_body": "Supprimez l’historique des exécutions terminées et échouées plus ancienne que le nombre de jours choisi. Les résultats tombent avec leurs exécutions parentes. Les exécutions non terminales (en cours d’exécution, en pause, demandées en annulation) sont toujours conservées.", "purge_days_label": "Rétention (jours)", "purge_confirm": "Purger", "purge_done": "{{n}} anciennes exécutions purgées (rétention {{days}} jours)", "state_running": "en cours d'exécution", "never": "jamais", - "just_now": "tout à l' heure", + "just_now": "à l'instant", "n_min_ago": "il y a {{n}} min", "n_h_ago": "il y a {{n}} h", "n_d_ago": "il y a {{n}} j", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index 3cd503a1..63dd5076 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -1183,11 +1183,11 @@ "gen_key_warning": "इस कुंजी को सुरक्षित रूप से संग्रहित करें. यदि यह खो जाता है, तो एन्क्रिप्टेड डेटा अपरिवर्तनीय रूप से खो जाता है।", "jobs": { "run_all_consistency": "सभी संगतता जांचें चलाएँ", - "run_deep": "गहरा रिश्ता", + "run_deep": "गहन जाँच", "run_deep_hint": "धीमे वेरिएंट (ब्लॉब री-हैश, बिट्रोट डिटेक्शन) भी चलाता है।", "col_name": "नाम", "col_cadence": "ताल", - "col_last_run": "आखरी बार", + "col_last_run": "अंतिम रन", "col_outcome": "नतीजा", "col_state": "राज्य", "col_actions": "कार्रवाई", @@ -1201,11 +1201,11 @@ "progress_scanned_only_tooltip": "इस रन के लिए कोई कुल उपलब्ध नहीं है (पूर्व-प्रगति-बार परिनियोजन या किरायेदार एक गणनीय विषय की रिपोर्ट नहीं करता है)।", "findings_present_tooltip": "प्रति-खोज विवरण देखने के लिए इस रन का विस्तार करें।", "col_error": "गलती", - "col_kind": "दयालु", + "col_kind": "प्रकार", "col_severity": "गंभीरता", "col_resource": "संसाधन", "col_detail": "विवरण", - "run": "दौड़ना", + "run": "चलाएँ", "cancel": "रद्द करना", "refresh": "ताज़ा करना", "runs_title": "हालिया रन", @@ -1218,10 +1218,10 @@ "every_min": "हर {{n}} मिनट", "every_sec": "हर {{n}} एस", "outcome_ok": "ठीक है", - "outcome_err": "ग़लती होना", + "outcome_err": "त्रुटि", "outcome_issues": "समस्याएँ", "outcome_notices": "नोटिस", - "n_findings": "{{n}}निष्कर्ष", + "n_findings": "{{n}} निष्कर्ष", "n_notices": "{{n}}नोटिस", "notices_present_tooltip": "सूचनात्मक निष्कर्ष - किसी कार्रवाई की आवश्यकता नहीं। विवरण के लिए विस्तार करें.", "purge": "पुराने रन शुद्ध करें", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index a6e6dbee..b35bf833 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -1183,11 +1183,11 @@ "gen_key_warning": "Conserva questa chiave in modo sicuro. In caso di smarrimento, i dati crittografati andranno persi irrimediabilmente.", "jobs": { "run_all_consistency": "Esegui tutti i controlli di coerenza", - "run_deep": "Corri in profondità", + "run_deep": "Analisi approfondita", "run_deep_hint": "Esegue anche varianti lente (re-hash blob, rilevamento bitrot).", "col_name": "Nome", "col_cadence": "Cadenza", - "col_last_run": "Ultima corsa", + "col_last_run": "Ultima esecuzione", "col_outcome": "Risultato", "col_state": "Stato", "col_actions": "Azioni", @@ -1205,20 +1205,20 @@ "col_severity": "Gravità", "col_resource": "Risorsa", "col_detail": "Dettaglio", - "run": "Correre", + "run": "Esegui", "cancel": "Cancellare", "refresh": "Aggiorna", "runs_title": "Esecuzioni recenti", "run_json": "Riepilogo esecuzione (JSON)", "findings_title": "Risultati", - "no_runs": "Nessuna corsa ancora.", + "no_runs": "Nessuna esecuzione ancora.", "no_findings": "Nessun risultato: analisi pulita.", "on_demand": "su richiesta", "every_h": "ogni {{n}} h", "every_min": "ogni {{n}} min", "every_sec": "ogni {{n}} s", "outcome_ok": "OK", - "outcome_err": "errare", + "outcome_err": "err", "outcome_issues": "problemi", "outcome_notices": "avvisi", "n_findings": "{{n}} risultati", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index 44ab8042..73908eb3 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -1183,7 +1183,7 @@ "gen_key_warning": "このキーは安全に保管してください。紛失すると、暗号化されたデータは回復不能に失われます。", "jobs": { "run_all_consistency": "すべての整合性チェックを実行する", - "run_deep": "深く走る", + "run_deep": "詳細スキャン", "run_deep_hint": "低速な亜種 (BLOB 再ハッシュ、ビットロット検出) も実行します。", "col_name": "名前", "col_cadence": "ケイデンス", @@ -1201,14 +1201,14 @@ "progress_scanned_only_tooltip": "この実行で利用できる合計はありません (進行状況バーのデプロイ前、またはテナントがカウント可能な件名を報告しない)。", "findings_present_tooltip": "この実行を展開すると、結果ごとの詳細が表示されます。", "col_error": "エラー", - "col_kind": "親切", + "col_kind": "種類", "col_severity": "重大度", "col_resource": "リソース", "col_detail": "詳細", - "run": "走る", + "run": "実行", "cancel": "キャンセル", "refresh": "リフレッシュ", - "runs_title": "最近のランニング", + "runs_title": "最近の実行", "run_json": "実行概要(JSON)", "findings_title": "調査結果", "no_runs": "まだ実行はありません。", @@ -1217,7 +1217,7 @@ "every_h": "{{n}} 時間ごと", "every_min": "{{n}} 分ごと", "every_sec": "{{n}} 秒ごと", - "outcome_ok": "わかりました", + "outcome_ok": "OK", "outcome_err": "エラー", "outcome_issues": "問題", "outcome_notices": "通知", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index da61354e..042a4a71 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -1218,14 +1218,14 @@ "storage_backend_audit": "백엔드 일관성", "jobs": { "run_all_consistency": "모든 일관성 검사 실행", - "run_deep": "깊이 달리다", + "run_deep": "심층 스캔", "run_deep_hint": "또한 느린 변형(블롭 재해시, 비트롯 감지)을 실행합니다.", "col_name": "이름", "col_cadence": "운율", "col_last_run": "마지막 실행", "col_outcome": "결과", "col_state": "상태", - "col_actions": "행위", + "col_actions": "작업", "col_started_at": "시작됨", "col_status": "상태", "col_duration": "지속", @@ -1235,11 +1235,11 @@ "progress_scanned_only_tooltip": "이 실행에 사용할 수 있는 총계가 없습니다(사전 진행률 표시줄 배포 또는 테넌트가 셀 수 있는 주제를 보고하지 않음).", "findings_present_tooltip": "이 실행을 확장하면 발견 항목별 세부 정보를 볼 수 있습니다.", "col_error": "오류", - "col_kind": "친절한", + "col_kind": "종류", "col_severity": "심각성", "col_resource": "의지", "col_detail": "세부 사항", - "run": "달리다", + "run": "실행", "cancel": "취소", "refresh": "새로 고치다", "runs_title": "최근 실행", @@ -1251,8 +1251,8 @@ "every_h": "매 {{n}}시간마다", "every_min": "{{n}}분마다", "every_sec": "{{n}}초마다", - "outcome_ok": "좋아요", - "outcome_err": "실수", + "outcome_ok": "OK", + "outcome_err": "오류", "outcome_issues": "문제", "outcome_notices": "공지사항", "n_findings": "{{n}} 조사 결과", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 6bda3779..ec9311d7 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -1183,7 +1183,7 @@ "gen_key_warning": "Bewaar deze sleutel veilig. Als het verloren gaat, zijn de gecodeerde gegevens onherstelbaar verloren.", "jobs": { "run_all_consistency": "Voer alle consistentiecontroles uit", - "run_deep": "Ren diep", + "run_deep": "Diepe scan", "run_deep_hint": "Voert ook langzame varianten uit (blob re-hash, bitrot-detectie).", "col_name": "Naam", "col_cadence": "Cadans", @@ -1201,11 +1201,11 @@ "progress_scanned_only_tooltip": "Er is geen totaal beschikbaar voor deze run (implementatie vóór de voortgangsbalk of de tenant rapporteert geen telbaar onderwerp).", "findings_present_tooltip": "Vouw deze run uit om de details per vondst te bekijken.", "col_error": "Fout", - "col_kind": "Vriendelijk", + "col_kind": "Soort", "col_severity": "Ernst", "col_resource": "Bron", "col_detail": "Detail", - "run": "Loop", + "run": "Uitvoeren", "cancel": "Annuleren", "refresh": "Vernieuwen", "runs_title": "Recente runs", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index e1ffff9e..034f30d3 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -1183,11 +1183,11 @@ "gen_key_warning": "Przechowuj ten klucz w bezpiecznym miejscu. W przypadku jego utraty zaszyfrowane dane zostaną utracone bezpowrotnie.", "jobs": { "run_all_consistency": "Uruchom wszystkie kontrole spójności", - "run_deep": "Biegnij głęboko", + "run_deep": "Głęboka analiza", "run_deep_hint": "Uruchamia również powolne warianty (ponowne mieszanie obiektów blob, wykrywanie bitrot).", "col_name": "Nazwa", "col_cadence": "Rytm", - "col_last_run": "Ostatni bieg", + "col_last_run": "Ostatnie uruchomienie", "col_outcome": "Wynik", "col_state": "Państwo", "col_actions": "Działania", @@ -1201,24 +1201,24 @@ "progress_scanned_only_tooltip": "Brak sumy dostępnej dla tego przebiegu (wdrożenie przed paskiem postępu lub dzierżawca nie zgłasza przedmiotu, który można policzyć).", "findings_present_tooltip": "Rozwiń ten przebieg, aby zobaczyć szczegóły dotyczące każdego znaleziska.", "col_error": "Błąd", - "col_kind": "Uprzejmy", + "col_kind": "Rodzaj", "col_severity": "Powaga", "col_resource": "Ratunek", "col_detail": "Szczegół", "run": "Uruchomić", "cancel": "Anulować", "refresh": "Odświeżać", - "runs_title": "Ostatnie biegi", + "runs_title": "Ostatnie uruchomienia", "run_json": "Podsumowanie uruchomienia (JSON)", "findings_title": "Ustalenia", - "no_runs": "Nie ma jeszcze żadnych biegów.", + "no_runs": "Nie ma jeszcze żadnych uruchomień.", "no_findings": "Brak wyników – czysty przebieg.", "on_demand": "na żądanie", "every_h": "co {{n}} godz", "every_min": "co {{n}} min", "every_sec": "co {{n}} s", "outcome_ok": "OK", - "outcome_err": "błądzić", + "outcome_err": "błąd", "outcome_issues": "kwestie", "outcome_notices": "uwagi", "n_findings": "{{n}} ustalenia", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 2b2dc27a..923da744 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -1182,8 +1182,8 @@ "gen_key": "Gerar chave", "gen_key_warning": "Armazene esta chave com segurança. Se for perdido, os dados criptografados serão perdidos irrecuperavelmente.", "jobs": { - "run_all_consistency": "Execute todas as verificações de consistência", - "run_deep": "Corra fundo", + "run_all_consistency": "Executar todas as verificações de consistência", + "run_deep": "Análise profunda", "run_deep_hint": "Também executa variantes lentas (re-hash de blob, detecção de bitrot).", "col_name": "Nome", "col_cadence": "Cadência", @@ -1205,20 +1205,20 @@ "col_severity": "Gravidade", "col_resource": "Recurso", "col_detail": "Detalhe", - "run": "Correr", + "run": "Executar", "cancel": "Cancelar", "refresh": "Atualizar", "runs_title": "Execuções recentes", "run_json": "Resumo da execução (JSON)", "findings_title": "Descobertas", - "no_runs": "Ainda não há corridas.", + "no_runs": "Ainda não há execuções.", "no_findings": "Nenhuma descoberta – execução limpa.", "on_demand": "Sob demanda", "every_h": "a cada {{n}} h", "every_min": "a cada {{n}}min", "every_sec": "cada {{n}} s", "outcome_ok": "OK", - "outcome_err": "errar", + "outcome_err": "err", "outcome_issues": "problemas", "outcome_notices": "avisos", "n_findings": "{{n}} descobertas", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 20bb682d..382597fa 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -1182,8 +1182,8 @@ "gen_key": "Сгенерировать ключ", "gen_key_warning": "Храните этот ключ в надежном месте. Если он утерян, зашифрованные данные теряются безвозвратно.", "jobs": { - "run_all_consistency": "Запустите все проверки согласованности", - "run_deep": "Беги глубоко", + "run_all_consistency": "Запустить все проверки согласованности", + "run_deep": "Глубокая проверка", "run_deep_hint": "Также выполняются медленные варианты (повторное хэширование больших двоичных объектов, обнаружение битротов).", "col_name": "Имя", "col_cadence": "Каденс", @@ -1201,27 +1201,27 @@ "progress_scanned_only_tooltip": "Для этого запуска общая сумма недоступна (развертывание до индикатора выполнения или клиент не сообщает об подсчитываемой теме).", "findings_present_tooltip": "Разверните этот прогон, чтобы просмотреть детали каждого результата.", "col_error": "Ошибка", - "col_kind": "Добрый", + "col_kind": "Тип", "col_severity": "Серьезность", "col_resource": "Ресурс", "col_detail": "Деталь", - "run": "Бегать", + "run": "Запустить", "cancel": "Отмена", "refresh": "Обновить", "runs_title": "Недавние запуски", "run_json": "Сводка выполнения (JSON)", "findings_title": "Выводы", - "no_runs": "Пробегов пока нет.", + "no_runs": "Запусков пока нет.", "no_findings": "Никаких результатов — чистый пробег.", "on_demand": "по требованию", "every_h": "каждые {{n}} ч", "every_min": "каждые {{n}} мин.", "every_sec": "каждые {{n}} с", - "outcome_ok": "хорошо", - "outcome_err": "ошибаться", + "outcome_ok": "ок", + "outcome_err": "ош", "outcome_issues": "проблемы", "outcome_notices": "уведомления", - "n_findings": "{{n}} выводы", + "n_findings": "{{n}} результатов", "n_notices": "{{n}} уведомления", "notices_present_tooltip": "Информационные выводы — никаких действий не требуется. Разверните для подробностей.", "purge": "Очистка старых пробегов", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index da23ac86..6ac8f2e8 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -1165,7 +1165,7 @@ "gen_key_warning": "安全地保存此密鑰。如果遺失,加密資料將無法恢復。", "jobs": { "run_all_consistency": "執行所有一致性檢查", - "run_deep": "深入運行", + "run_deep": "深度掃描", "run_deep_hint": "也運行緩慢的變體(blob 重新哈希、bitrot 檢測)。", "col_name": "姓名", "col_cadence": "節奏", @@ -1187,10 +1187,10 @@ "col_severity": "嚴重性", "col_resource": "資源", "col_detail": "細節", - "run": "跑步", + "run": "運行", "cancel": "取消", "refresh": "重新整理", - "runs_title": "最近的跑步", + "runs_title": "最近的運行", "run_json": "運行摘要 (JSON)", "findings_title": "發現", "no_runs": "還沒有運行。", @@ -1200,7 +1200,7 @@ "every_min": "每 {{n}} 分鐘", "every_sec": "每{{n}}秒", "outcome_ok": "好的", - "outcome_err": "犯錯", + "outcome_err": "錯誤", "outcome_issues": "問題", "outcome_notices": "通知", "n_findings": "{{n}} 研究結果", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 4287f062..d6ff8319 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -1165,7 +1165,7 @@ "gen_key_warning": "安全地保存此密钥。如果丢失,加密数据将无法恢复。", "jobs": { "run_all_consistency": "运行所有一致性检查", - "run_deep": "深入运行", + "run_deep": "深度扫描", "run_deep_hint": "还运行缓慢的变体(blob 重新哈希、bitrot 检测)。", "col_name": "姓名", "col_cadence": "节奏", @@ -1187,10 +1187,10 @@ "col_severity": "严重性", "col_resource": "资源", "col_detail": "细节", - "run": "跑步", + "run": "运行", "cancel": "取消", "refresh": "刷新", - "runs_title": "最近的跑步", + "runs_title": "最近的运行", "run_json": "运行摘要 (JSON)", "findings_title": "发现", "no_runs": "还没有运行。", @@ -1200,7 +1200,7 @@ "every_min": "每 {{n}} 分钟", "every_sec": "每{{n}}秒", "outcome_ok": "好的", - "outcome_err": "犯错", + "outcome_err": "错误", "outcome_issues": "问题", "outcome_notices": "通知", "n_findings": "{{n}} 研究结果", From 8b8cec0ba1675f530e24bb9742f1cd873ebe538a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 12 Aug 2026 00:58:41 +0200 Subject: [PATCH 019/144] docs(plan): revise derived-blobs design Enrich OxiCloud to maximise the use of `dedup` Engine 2 cases will be covered: - blobs issues from other blobs (thumbnail automatic generation from blob) - by filename (ex: thumbnail uploaded from users) A local cache will be added when blobs are remote (S3 or similar) --- docs/plan/derived-blobs.md | 1162 +++++++++++++++++++++++++++++++++--- 1 file changed, 1091 insertions(+), 71 deletions(-) diff --git a/docs/plan/derived-blobs.md b/docs/plan/derived-blobs.md index 52462057..2cb6c2cc 100644 --- a/docs/plan/derived-blobs.md +++ b/docs/plan/derived-blobs.md @@ -1,12 +1,20 @@ # Plan — Derived content as blobs (tier-2 refactor) -**Status:** design captured 2026-08-02, not implemented. Follow-up to -`fix/services-use-blob-abstraction` — that PR normalised the -**read-side** (services consume blobs through `BlobStorageBackend` -uniformly). This plan tackles the **write-side**: services that -today write derived artifacts (thumbnails, transcodes) to a local -sidecar directory and would benefit from writing them through the -backend abstraction instead. +**Status:** design captured 2026-08-02, revised 2026-08-16 — keying +rule, CDC reuse, backend-dispatch rule, the +`content_derived_blobs` / `file_attached_blobs` pair, copy/version +semantics, a consistency coverage matrix with **three** hard +prerequisites (one of them a `dedup_gc` predicate that would delete +the entire derived tier), migration of the existing sidecar content, +and a schema trim down to the columns that carry information nothing +else owns. Not implemented. + +Follow-up to `fix/services-use-blob-abstraction` — that +PR normalised the **read-side** (services consume blobs through +`BlobStorageBackend` uniformly). This plan tackles the **write-side**: +services that today write derived artifacts (thumbnails, transcodes) +to a local sidecar directory and would benefit from writing them +through the backend abstraction instead. ## Context — the three-tier storage taxonomy @@ -27,6 +35,58 @@ thumbnails for every photo) but not data loss; conflating them means backup policies can't distinguish "must preserve" from "can rebuild". +## The relation map (after this refactor) + +Solid arrows **hold a reference** (bump a `ref_count`); dashed arrows +are **dependents** — they must be cleaned up when their target dies but +they keep nothing alive. + +```mermaid +flowchart TB + subgraph RES["RESOURCE LAYER · keyed by UUID"] + FILES["storage.files
id UUID PK
blob_hash VARCHAR(64)
name · folder_id · mime_type"] + FAB["storage.file_attached_blobs
(file_id, kind, variant) PK
blob_hash · uploaded_by
user-supplied · never shared"] + FMD["storage.file_metadata (EXIF)
file_id PK
⚠ content-derived, file-keyed"] + end + + subgraph CON["CONTENT LAYER · keyed by BLAKE3 of source bytes"] + CDB["storage.content_derived_blobs
(source_hash, kind, variant) PK
blob_hash
pure f(content) · dedupes"] + BET["storage.blob_extracted_text
blob_hash PK"] + FACES["faces.faces
blob_hash"] + end + + BLOB["BLOB — the content of a file
BLAKE3 of plaintext
storage.chunk_manifests
file_hash PK · chunk_hashes[]
ref_count"] + CHUNK["CHUNK — physical payload
BLAKE3 of the fragment
storage.blobs
hash PK · ref_count · orphaned_at"] + BACKEND[("BlobStorageBackend
Local .blobs/ · S3 · Azure
+encryption +retry +cache")] + + FILES -->|"FK file_id · CASCADE"| FAB + FILES -->|"FK file_id · CASCADE"| FMD + FILES -->|"blob_hash"| BLOB + FILES -.->|"legacy pre-CDC · no manifest"| CHUNK + CDB -.->|"source_hash · dependent"| BLOB + CDB -->|"blob_hash"| BLOB + FAB -->|"blob_hash"| BLOB + BET -.->|"dependent cache"| BLOB + FACES -.->|"dependent cache"| BLOB + BLOB -->|"chunk_hashes[] · 1..N ordered"| CHUNK + CHUNK -->|bytes| BACKEND +``` + +Three things to read off it: + +1. **`content_derived_blobs` touches the Blob layer twice with + opposite meanings** — `source_hash` is a dependent (it keeps + nothing alive; the file does), `blob_hash` is a reference holder. + Conflating them is how you get either a leak or a premature reap. +2. **Every new solid arrow into the Blob layer feeds + `chunk_manifests.ref_count`** — the counter nothing reconciles + today. See the prerequisites below. +3. **The two new tables meet the rest of the graph only at the Blob + layer.** `content_derived_blobs` has no edge to `storage.files` at + all: it reaches a file only by sharing that file's `blob_hash`. + That is exactly what makes it dedupe across files — and exactly why + it must never hold user-chosen bytes. + ## Multi-instance driver Single-instance: tier-2-as-local-cache works fine. Rebuild after @@ -61,36 +121,606 @@ Reusing it for derived artifacts means no second abstraction to build and maintain, and all the operational surface (audit, migration, key rotation) applies to derived content by default. -### Keying +### Keying — content-address only pure functions of the content -Content-addressable via BLAKE3, same as source blobs. For -server-derived content the hash is over the produced bytes (not -the source), so: +**The rule:** an artifact may be keyed by its source's content hash +**iff** it is a deterministic pure function of the source bytes. +Anything influenced by user choice must be keyed by the resource it +was attached to, never by content. -- Two files with **identical thumbnails** (e.g. same 256px WebP - crop of the same underlying image → identical bytes → identical - hash) share the physical blob. Dedup wins for free. -- Two files with **identical originals** but **different variant - specs** (256px vs 512px thumb) produce different blobs. Also - correct. +| Artifact | Function of | Content-keyable? | +|---|---|---| +| server thumbnail | `f(blob bytes, variant)` | ✅ any user uploading identical bytes derives identical output — nothing to poison | +| transcode | `f(blob bytes, target)` | ✅ | +| extracted text | `f(blob bytes)` | ✅ — `storage.blob_extracted_text` | +| face vectors | `f(blob bytes)` | ✅ — `faces.faces` | +| client-uploaded preview | `f(user's choice)` | ❌ **must be file-keyed** — `storage.file_attached_blobs`, see below | -The variant spec (what was rendered) lives in the referring DB row -alongside the blob hash — not in the storage key. Storage stays -one keyspace; ownership stays per-service. +This isn't a new pattern: `storage.blob_extracted_text` already +chose content-keying for the same reason, and the migration says so +(`migrations/20260701000000_content_search_index.sql:22-28`) — +"extraction is keyed by `blob_hash`, not by file: N copies of the +same PDF cost ONE extraction, and rename/move/copy never +re-extract." `faces.faces` is keyed on `blob_hash` too. Thumbnails +are the same class of artifact, and file-keying them would make +them the odd one out among three sibling features while costing: -### Client-uploaded thumbnails +- **the dedup fast path** — `ThumbnailRefreshHook::on_file_created` + returns early when `!is_new_blob`, so 100 users uploading the same + photo cost one render. File-keying means either N renders or a + join back through `files.blob_hash` (content-keying + through the back door, slower and with more code). +- **free copies and free versions** — `on_file_copied` is a no-op + today precisely because the key is content, and future versioning + inherits the same property. See the copy/version axes below. + +For the derived side the hash is over the **produced** bytes, so: + +- Two files with identical thumbnails (same variant of the same + source → identical bytes → identical hash) share the physical + blob. Dedup wins for free. +- Two variants of one source (256px vs 512px) produce different + blobs. Also correct. + +The variant spec lives in the referring DB row, not in the storage +key. Storage stays one keyspace; ownership stays per-service. + +### Corollary — point at a file, never at a blob + +Both tables in this plan exist because their content is *not* a file: +a thumbnail has no name, no folder and no place in a user's tree. When +a binary **can** be a file, make it one and point at it with a +`*_file_id` FK — `storage.files` is already a `BlobReferenceSource`, +already covered by every consistency edge, already GC-integrated, so a +file pointer costs **zero** new reference sources and zero new +consistency checks. + +That is the rule that stops the next person adding a fourth +blob-referencing table. It is what `docs/plan/hidden-system.md` +applies to user avatars, backgrounds and signatures, and it extends to +owners that are not users at all — +`carddav.contacts.photo_file_id` would retire the inlined +`photo_url TEXT` on the same terms. + +### Schema + +```sql +CREATE TABLE storage.content_derived_blobs ( + source_hash VARCHAR(64) NOT NULL, -- source Blob (no FK — see below) + kind TEXT NOT NULL, -- 'thumbnail' | 'transcode' + variant TEXT NOT NULL, -- 'icon' | 'preview' | 'large' | '720p' + blob_hash VARCHAR(64) NOT NULL, -- the DERIVED Blob + content_type TEXT NOT NULL, -- served directly; no byte-sniffing + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (source_hash, kind, variant) +); +CREATE INDEX ON storage.content_derived_blobs(blob_hash); +``` + +**`variant` is opaque text. New axes go inside it, never into new +columns.** This is the rule that keeps the table from growing, and it +disposes of three columns earlier drafts proposed: + +- **No `format` column.** WebP vs JPEG looks like a second axis, but + only the canonical rendering is persisted (below), so there is one + row per variant. If a format migration ever happens — AVIF is the + plausible one — it is `variant = 'preview-avif'` beside + `'preview'`. Data change, not a PK migration. +- **No `codec` column.** Transcoding here is a *playability + fallback*, not bandwidth optimisation: one widely-compatible + rendition (H.264/AAC in MP4), no negotiation, nothing to + distinguish. `
- {#if hasBatch} + {#if batchJob} + {@const batch = batchJob} - - + + {#if batch.repair_description} + + {/if} {/if} {/if} - {#if supportsRepair(job.name)} + {#if supportsRepair(job)}