Merge pull request #683 from EdouardVanbelle/refactor/userdto

This commit is contained in:
Dionisio Pozo
2026-08-22 23:31:25 +02:00
committed by GitHub
83 changed files with 2484 additions and 1068 deletions
+427
View File
@@ -0,0 +1,427 @@
# 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:
- **`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<FullUserDto>` 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<User>`) 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<String>,
pub email: String,
pub role: String, // sharee UI renders admin badge
pub image: Option<String>, // avatar
pub is_external: bool, // external badge
pub given_name: Option<String>, // social identity
pub family_name: Option<String>, // social identity
pub is_online: bool, // presence — social signal
}
```
Every existing UserDto emitter site (`From<User>`, 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<String>,
pub federation_issuer: Option<String>,
/// Subject's own locale preference. Only THEY or an admin
/// managing them needs this — other callers use their own.
pub preferred_locale: Option<String>,
/// 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<DateTime<Utc>>,
/// Row bookkeeping — not rendered on any non-admin surface today.
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Activity signal — private.
pub last_login_at: Option<DateTime<Utc>>,
/// 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<AdminUserSummaryDto>` | `Vec<FullUserDto>` |
| `/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<User> 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<FullUserDto>`.
- `/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.
+1 -4
View File
@@ -64,10 +64,7 @@ describe('admin mutate-based endpoints', () => {
describe('admin read endpoints', () => { describe('admin read endpoints', () => {
it('call apiJson for the listing/settings reads', async () => { it('call apiJson for the listing/settings reads', async () => {
await admin.listUsers(25, 0); await admin.listUsers(25, 0);
expect(jsonMock).toHaveBeenCalledWith( expect(jsonMock).toHaveBeenCalledWith('/api/admin/users?limit=25&offset=0', expect.anything());
'/api/admin/users?limit=25&offset=0&summary=true',
expect.anything()
);
await admin.getDashboard(); await admin.getDashboard();
await admin.getSmtpInfo(); await admin.getSmtpInfo();
await admin.getOidcSettings(); await admin.getOidcSettings();
+37 -11
View File
@@ -12,7 +12,7 @@ import type {
DriveMember, DriveMember,
DriveMemberSubject, DriveMemberSubject,
DriveRole, DriveRole,
User FullUser
} from '$lib/api/types'; } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' }; const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -277,19 +277,24 @@ export function revokeAdminSession(sessionId: string): Promise<void> {
// ── Users ─────────────────────────────────────────────────────────────── // ── Users ───────────────────────────────────────────────────────────────
/** List the compact rows rendered by the management table; full account /** List admin users — always returns `FullUser` rows. The former
* details remain available through {@link getUserAdmin}. */ * `?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<AdminUsersPage> { export function listUsers(limit: number, offset: number): Promise<AdminUsersPage> {
return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}&summary=true`, { return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}`, {
credentials: 'same-origin' credentials: 'same-origin'
}); });
} }
/** /**
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`. * Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
* Returns the full `User` DTO including `storage_quota_bytes` + * Returns the full `FullUser` DTO (public identity in `.user` +
* `storage_used_bytes` which the non-admin `/api/users/{id}` * admin-visible extras like `email_verified_at` / `has_password` /
* response omits for privacy. * `opaque_registered` / `last_login_at` / quotas at top level) —
* same shape as one row of `/api/admin/users` list. The peer-view
* `/api/users/{id}` returns the slim `PublicUser` which omits those
* admin-only signals.
* *
* Result promises are cached per id at module scope so multiple * Result promises are cached per id at module scope so multiple
* callers for the same user (e.g. the admin drives table with N * callers for the same user (e.g. the admin drives table with N
@@ -301,14 +306,14 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
* still sees the cached value. Callers that need to refresh (e.g. * still sees the cached value. Callers that need to refresh (e.g.
* after `setUserQuota`) should call `invalidateAdminUserCache`. * after `setUserQuota`) should call `invalidateAdminUserCache`.
*/ */
const adminUserCache = new Map<string, Promise<User | null>>(); const adminUserCache = new Map<string, Promise<FullUser | null>>();
export function getUserAdmin(id: string): Promise<User | null> { export function getUserAdmin(id: string): Promise<FullUser | null> {
const hit = adminUserCache.get(id); const hit = adminUserCache.get(id);
if (hit) return hit; if (hit) return hit;
const pending = (async (): Promise<User | null> => { const pending = (async (): Promise<FullUser | null> => {
try { try {
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, { return await apiJson<FullUser>(`/api/admin/users/${encodeURIComponent(id)}`, {
credentials: 'same-origin' credentials: 'same-origin'
}); });
} catch { } catch {
@@ -387,9 +392,30 @@ export interface DriveKindUsage {
} }
export interface AdminDashboard { 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; total_users: number;
active_users: number; active_users: number;
admin_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; server_version: string;
drive_usage: DriveKindUsage[]; drive_usage: DriveKindUsage[];
auth_enabled: boolean; auth_enabled: boolean;
+7 -7
View File
@@ -5,7 +5,7 @@
*/ */
import { ApiError, apiFetch } from '$lib/api/client'; import { ApiError, apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; 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 * 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 * Failure to build a proof (no keypair, missing WebCrypto) falls back to a
* headerless request — the server still accepts it for unbound sessions. * headerless request — the server still accepts it for unbound sessions.
*/ */
export async function fetchMe(): Promise<User | null> { export async function fetchMe(): Promise<SelfUser | null> {
// Build + sign a DPoP proof, send with the header, harvest any // Build + sign a DPoP proof, send with the header, harvest any
// `DPoP-Nonce` off the response into the shared client cache // `DPoP-Nonce` off the response into the shared client cache
// (so the NEXT apiFetch call reuses it — no wasted round trip). // (so the NEXT apiFetch call reuses it — no wasted round trip).
@@ -80,7 +80,7 @@ export async function fetchMe(): Promise<User | null> {
if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send();
if (res.status === 401) return null; if (res.status === 401) return null;
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); 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<void>
* failure, not an expired access token. Returns the user on success, null on * 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. * any failure so the caller can fall through to the normal login UI.
*/ */
export async function exchangeOidcCode(code: string): Promise<User | null> { export async function exchangeOidcCode(code: string): Promise<SelfUser | null> {
try { try {
const res = await fetch('/api/auth/oidc/exchange', { const res = await fetch('/api/auth/oidc/exchange', {
method: 'POST', method: 'POST',
@@ -417,7 +417,7 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
body: JSON.stringify({ code }) body: JSON.stringify({ code })
}); });
if (!res.ok) return null; 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; return data.user ?? null;
} catch { } catch {
return null; 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 * authenticated; a 401 here IS a genuine "session expired" and the
* refresh interceptor is the right response. * refresh interceptor is the right response.
*/ */
export async function upgradeToInternal(password?: string): Promise<User> { export async function upgradeToInternal(password?: string): Promise<SelfUser> {
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
if (password) body.password = password; if (password) body.password = password;
const res = await apiFetch('/api/auth/upgrade-to-internal', { const res = await apiFetch('/api/auth/upgrade-to-internal', {
@@ -482,7 +482,7 @@ export async function upgradeToInternal(password?: string): Promise<User> {
message message
); );
} }
return (await res.json()) as User; return (await res.json()) as SelfUser;
} }
export type MagicLinkResult = 'sent' | 'unavailable'; export type MagicLinkResult = 'sent' | 'unavailable';
+3 -3
View File
@@ -1,7 +1,7 @@
/** Profile / account endpoints — ported from views/profile/profile.js. */ /** Profile / account endpoints — ported from views/profile/profile.js. */
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; import { getCsrfHeaders } from '$lib/api/csrf';
import type { User } from '$lib/api/types'; import type { SelfUser } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
const JSON_HEADERS = { 'Content-Type': 'application/json' }; const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -24,7 +24,7 @@ export interface ProfilePatch {
ui_preferences?: Record<string, unknown>; ui_preferences?: Record<string, unknown>;
} }
export async function updateProfile(patch: ProfilePatch): Promise<User> { export async function updateProfile(patch: ProfilePatch): Promise<SelfUser> {
const res = await apiFetch('/api/auth/me/profile', { const res = await apiFetch('/api/auth/me/profile', {
method: 'PATCH', method: 'PATCH',
credentials: 'same-origin', credentials: 'same-origin',
@@ -57,7 +57,7 @@ export async function updateProfile(patch: ProfilePatch): Promise<User> {
} }
throw new Error(err.message || err.error || `profile update failed: ${res.status}`); throw new Error(err.message || err.error || `profile update failed: ${res.status}`);
} }
return (await res.json()) as User; return (await res.json()) as SelfUser;
} }
export async function changePassword(currentPw: string, newPw: string): Promise<void> { export async function changePassword(currentPw: string, newPw: string): Promise<void> {
+47 -4
View File
@@ -16,15 +16,23 @@ export interface ResolvedUser {
email: string; email: string;
image: string | null; image: string | null;
isExternal: boolean; 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
* `<UserAvatar>` / `<UserVignette>`. `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. */ /** Subset of the backend `PublicUserDto` we consume here. */
interface UserDtoShape { interface PublicUserShape {
id: string; id: string;
username?: string | null; username?: string | null;
email?: string | null; email?: string | null;
image?: string | null; image?: string | null;
is_external: boolean; is_external: boolean;
is_online?: boolean;
} }
// id → in-flight/resolved lookup (the Promise is cached so concurrent callers // id → in-flight/resolved lookup (the Promise is cached so concurrent callers
@@ -41,13 +49,14 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
credentials: 'same-origin' credentials: 'same-origin'
}); });
if (!res.ok) return null; if (!res.ok) return null;
const u = (await res.json()) as UserDtoShape; const u = (await res.json()) as PublicUserShape;
return { return {
id: u.id, id: u.id,
name: u.username?.trim() || u.email || u.id, name: u.username?.trim() || u.email || u.id,
email: u.email ?? '', email: u.email ?? '',
image: u.image ?? null, image: u.image ?? null,
isExternal: u.is_external isExternal: u.is_external,
isOnline: u.is_online ?? false
}; };
} catch { } catch {
return null; return null;
@@ -57,3 +66,37 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
cache.set(id, pending); cache.set(id, pending);
return 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;
is_online?: 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,
isOnline: u.is_online ?? false
};
cache.set(u.id, Promise.resolve(resolved));
}
+94 -126
View File
@@ -179,148 +179,116 @@ export interface TrashResourcesResponse {
export type Role = 'user' | 'admin'; 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; id: string;
username?: string; username?: string;
email: string; email: string;
role: string; role: string;
storage_quota_bytes: number; image?: string | null;
storage_used_bytes: number; 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; created_at: string;
updated_at: string; updated_at: string;
last_login_at?: string | null; last_login_at?: string | null;
active: boolean; active: boolean;
/** storage_quota_bytes: number;
* Which trust chain minted this user's federation identity. `null` storage_used_bytes: number;
* (omitted from wire) for local users (password / OPAQUE only). /** TRUE when the account has a local Argon2id `password_hash` on file.
* `"oidc" | "ocm" | "magic_link"` for federated users. Predicate: * Distinct from `federation_kind`: an OIDC-linked account can ALSO
* `!user.federation_kind` = local; `user.federation_kind === 'oidc'` * carry a local password (hybrid). */
* = OIDC user. Mirrors `auth.users.federation_kind` verbatim. has_password: boolean;
*/ /** TRUE when the user has an OPAQUE envelope on file. Admin-visible
federation_kind?: 'oidc' | 'ocm' | 'magic_link'; * rollout signal — kept off `PublicUser` so directory endpoints don't
/** * leak OPAQUE adoption. */
* Authority that minted this user's OIDC/OCM identity — issuer URL opaque_registered: boolean;
* for OIDC (id_token `iss`), peer domain for OCM. `null` (omitted) /** TRUE when the user has completed ≥1 OPAQUE login. Distinct from
* for local users. FE that wants a friendly display label maps this * `opaque_registered` — envelope-on-file vs successful-login. */
* against `OidcProviders.issuer → provider_name` when they match; opaque_migrated: boolean;
* 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<string, unknown>;
/**
* 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;
} }
/** Fields rendered by the paginated admin table. Full account details remain /** Self view — everything the caller may see about themselves.
* available from the detail endpoint; this shape keeps avatars and preference * Returned by `/api/auth/me` and every `AuthResponse` (login / refresh /
* documents off every listing page. * OIDC callback / magic-link redemption ships this so the SPA's post-auth
* * state matches its post-`/me` state with no UI race). */
* The two OPAQUE flags below are ADMIN-ONLY signals: they surface per-user export interface SelfUser {
* OPAQUE rollout progress in the admin table. The backend deliberately keeps full: FullUser;
* them off `UserDto` (`/api/auth/me`, share-recipient DTOs, group members) /** Opaque UI-preferences bag. Cross-device store for pure UI toggles
* so a non-admin can't enumerate the adoption set through third-party * (view mode, sidebar collapse, hide-dotfiles, …). Server never
* endpoints. Both optional on the wire — older backend builds omit them and * inspects contents; the SPA defines the keys (see
* `#[serde(default)]` maps missing → `false`. */ * `lib/stores/preferences.svelte.ts`). Always an object on the wire
export type AdminUserSummary = Pick< * — empty bag is `{}`, never `null`. PATCH via `/api/auth/me/profile`
User, * shallow-merges; setting a key to `null` removes it. */
| 'id' ui_preferences: Record<string, unknown>;
| 'username' /** Whether the user wants share-notification emails. */
| 'email' notify_on_share: boolean;
| 'role' /** Session-scoped: my current session is DPoP-bound. SPA reads this
| 'storage_quota_bytes' * on `session.load()` to skip a redundant `/api/auth/dpop/bind` call
| 'storage_used_bytes' * (409 `already_bound` otherwise, noisy in the audit stream). */
| 'last_login_at' is_dpop_bound: boolean;
| 'active' /** Admin-set temp-password gate — SPA nav guard blocks everything
| 'federation_kind' * but /change-password until this flips back. Cleared by a successful
| 'federation_issuer' * `POST /api/auth/change-password`. */
| 'is_external' force_password_change: boolean;
> & { /** Caller-scoped: can I edit my own avatar? `false` for OIDC users
/** TRUE = user has a server-verifiable password on file (legacy or * whose avatar comes from the IdP. Only meaningful when caller ==
* admin-set). Combined with `opaque_registered` and `federation_kind`, * subject; nonsense on any other DTO. */
* the admin table derives the full auth capability set — a user with can_edit_image: boolean;
* `has_password=false`, `opaque_registered=false` AND }
* `federation_kind === undefined` (no federation) is passwordless
* (magic-link only, which is the default for externals). */ /** Backwards-compat alias while migrating call-sites. Prefer `PublicUser`
has_password?: boolean; * for public-identity contexts (sharee, group member, invitee) or
/** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration * `SelfUser` when reading `/api/auth/me`. Delete once no consumers reference
* succeeded, or the user completed a manual re-registration). */ * the bare `User` name. */
opaque_registered?: boolean; export type User = PublicUser;
/** 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;
};
export interface AdminUsersPage { export interface AdminUsersPage {
total: number; total: number;
users: AdminUserSummary[]; users: FullUser[];
} }
export interface AuthResponse { export interface AuthResponse {
user: User; user: SelfUser;
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
token_type: string; token_type: string;
+13 -13
View File
@@ -437,11 +437,11 @@
{ mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') } { mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') }
]; ];
const storagePct = $derived( const storagePct = $derived.by(() => {
session.user && session.user.storage_quota_bytes > 0 const full = session.me?.full;
? Math.min(100, (session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100) if (!full || full.storage_quota_bytes <= 0) return 0;
: 0 return Math.min(100, (full.storage_used_bytes / full.storage_quota_bytes) * 100);
); });
const initials = $derived(userInitials(session.user?.username || session.user?.email)); const initials = $derived(userInitials(session.user?.username || session.user?.email));
@@ -655,12 +655,12 @@
<div class="storage-fill" style:width="{storagePct}%"></div> <div class="storage-fill" style:width="{storagePct}%"></div>
</div> </div>
<div class="storage-info"> <div class="storage-info">
{#if session.user.storage_quota_bytes > 0} {#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
{Math.round(storagePct)}% · {formatBytes(session.user.storage_used_bytes)} / {formatBytes( {Math.round(storagePct)}% · {formatBytes(session.me?.full.storage_used_bytes ?? 0)} / {formatBytes(
session.user.storage_quota_bytes session.me?.full.storage_quota_bytes ?? 0
)} )}
{:else} {:else}
{formatBytes(session.user.storage_used_bytes)} {formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{/if} {/if}
</div> </div>
</div> </div>
@@ -903,18 +903,18 @@
<div class="user-menu-storage-fill" style:width="{storagePct}%"></div> <div class="user-menu-storage-fill" style:width="{storagePct}%"></div>
</div> </div>
<div class="user-menu-storage-text"> <div class="user-menu-storage-text">
{#if session.user.storage_quota_bytes > 0} {#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
{t( {t(
'storage.used', 'storage.used',
{ {
percentage: Math.round(storagePct), percentage: Math.round(storagePct),
used: formatBytes(session.user.storage_used_bytes), used: formatBytes(session.me?.full.storage_used_bytes ?? 0),
total: formatBytes(session.user.storage_quota_bytes) total: formatBytes(session.me?.full.storage_quota_bytes ?? 0)
}, },
'{{percentage}}% used ({{used}} / {{total}})' '{{percentage}}% used ({{used}} / {{total}})'
)} )}
{:else} {:else}
{formatBytes(session.user.storage_used_bytes)} {formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{/if} {/if}
</div> </div>
</div> </div>
+14 -3
View File
@@ -26,16 +26,27 @@ const children = createRawSnippet(() => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
pageState.url = new URL('http://localhost/files'); pageState.url = new URL('http://localhost/files');
session.user = { // 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', id: '1',
username: 'admin', username: 'admin',
email: 'a@x.test', email: 'a@x.test',
given_name: 'A', given_name: 'A',
family_name: 'B', family_name: 'B',
role: 'admin', role: 'admin',
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false is_external: false
},
storage_used_bytes: 10,
storage_quota_bytes: 100
}
} as never; } as never;
}); });
+13 -1
View File
@@ -18,7 +18,19 @@
let { icon, title, hint, error = false, children }: Props = $props(); let { icon, title, hint, error = false, children }: Props = $props();
</script> </script>
<div class="empty-state" class:empty-state--error={error} role={error ? 'alert' : undefined}> <!-- `data-testid="empty-state"` is a stable Playwright hook: consumers
(ResourceList, ShareList, TrashList, …) only render this component
once the underlying load resolved with no items — so waiting on
this testid = "the listing definitively finished loading and is
empty". Used by `tests/e2e/spa/files.spec.ts` to gate a cold-
navigation upload behind the folder-loaded state (see the guard
in `routes/files/[...path]/+page.svelte::guardUploadFolderReady`). -->
<div
class="empty-state"
class:empty-state--error={error}
role={error ? 'alert' : undefined}
data-testid="empty-state"
>
{#if icon}<Icon name={icon} class="empty-state__icon" />{/if} {#if icon}<Icon name={icon} class="empty-state__icon" />{/if}
{#if title}<p class="empty-state__title">{title}</p>{/if} {#if title}<p class="empty-state__title">{title}</p>{/if}
{#if hint}<p class="empty-state__hint">{hint}</p>{/if} {#if hint}<p class="empty-state__hint">{hint}</p>{/if}
@@ -33,6 +33,7 @@
const label = $derived(resolved?.name ?? fallbackLabel ?? userId); const label = $derived(resolved?.name ?? fallbackLabel ?? userId);
const email = $derived(resolved?.email || fallbackSublabel || ''); const email = $derived(resolved?.email || fallbackSublabel || '');
const isExternal = $derived(resolved?.isExternal ?? false); const isExternal = $derived(resolved?.isExternal ?? false);
const isOnline = $derived(resolved?.isOnline ?? false);
const image = $derived(resolved?.image ?? null); const image = $derived(resolved?.image ?? null);
const colorIndex = $derived(avatarColorIndex(userId)); const colorIndex = $derived(avatarColorIndex(userId));
const initials = $derived(userInitials(label)); const initials = $derived(userInitials(label));
@@ -50,6 +51,22 @@
<Icon name="building-circle-xmark" /> <Icon name="building-circle-xmark" />
</span> </span>
{/if} {/if}
{#if isOnline}
<!-- Presence dot — top-right corner so it doesn't collide with the
external badge at bottom-right. Only rendered when true (absent
= offline reads cleanly on an avatar; no grey placeholder). Uses
`--color-success-alt` (same green as `.badge--active` in the
admin sessions panel + `.presence-dot--online` in the sessions
status column) so the presence signal reads consistently across
every surface. The 2px surface-coloured border visually detaches
the dot from the avatar's own background — matches the pattern
Slack/Teams/Discord use. -->
<span
class="uv__presence"
title={t('share.userOnline', 'Online')}
aria-label={t('share.userOnline', 'Online')}
></span>
{/if}
</span> </span>
<span class="uv__text"> <span class="uv__text">
<span class="uv__name">{label}</span> <span class="uv__name">{label}</span>
@@ -128,6 +145,27 @@
font-size: 9px; 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 { .uv__text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+20 -10
View File
@@ -69,12 +69,15 @@ const PATCH_DEBOUNCE_MS = 500;
class PreferencesStore { class PreferencesStore {
/** /**
* The typed view of the bag. Derived from `session.user?.ui_preferences` * The typed view of the bag. Derived from `session.me?.ui_preferences`
* so signing in / out / refresh flips it in lockstep with the session. * (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. * Reads pass through DEFAULTS for any missing key.
*/ */
private bag = $derived<Record<string, unknown>>( private bag = $derived<Record<string, unknown>>(
(session.user?.ui_preferences as Record<string, unknown> | undefined) ?? {} (session.me?.ui_preferences as Record<string, unknown> | undefined) ?? {}
); );
// ── Typed accessors ────────────────────────────────────────── // ── Typed accessors ──────────────────────────────────────────
@@ -100,11 +103,14 @@ class PreferencesStore {
* `jsonb_strip_nulls` after the merge). * `jsonb_strip_nulls` after the merge).
*/ */
set(patch: Partial<Record<keyof UiPreferences, unknown>>): void { set(patch: Partial<Record<keyof UiPreferences, unknown>>): 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 = { const nextBag = {
...((session.user.ui_preferences as Record<string, unknown> | undefined) ?? {}), ...((session.me.ui_preferences as Record<string, unknown> | undefined) ?? {}),
...patch ...patch
}; };
// Strip any explicit-null locally so the derived getters see the // 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)) { for (const [k, v] of Object.entries(patch)) {
if (v === null) delete (nextBag as Record<string, unknown>)[k]; if (v === null) delete (nextBag as Record<string, unknown>)[k];
} }
session.user = { ...session.user, ui_preferences: nextBag }; session.me = { ...session.me, ui_preferences: nextBag };
// Accumulate keys so successive `set` calls before the debounce // Accumulate keys so successive `set` calls before the debounce
// fires collapse into a single PATCH body — matters for // fires collapse into a single PATCH body — matters for
@@ -131,16 +137,20 @@ class PreferencesStore {
this.pendingPatch = {}; this.pendingPatch = {};
if (Object.keys(patch).length === 0) return; 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 { try {
const updated = await updateProfile({ ui_preferences: patch }); const updated = await updateProfile({ ui_preferences: patch });
session.user = updated; session.me = updated;
} catch { } catch {
// Roll back to whatever the server last confirmed. The // Roll back to whatever the server last confirmed. The
// optimistic local mutation is discarded and the derived // optimistic local mutation is discarded and the derived
// `hideDotfiles` / other getters snap back on the next // `hideDotfiles` / other getters snap back on the next
// reactivity tick. // reactivity tick.
session.user = previousUser; session.me = previousMe;
ui.notify( ui.notify(
t('preferences.save_failed', "Couldn't save your preference. Please try again."), t('preferences.save_failed', "Couldn't save your preference. Please try again."),
'error' 'error'
+12 -5
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 // `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 // 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'; 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', () => { describe('session.refresh', () => {
beforeEach(() => { beforeEach(() => {
@@ -25,7 +32,7 @@ describe('session.refresh', () => {
it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => { it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => {
fetchMeMock.mockResolvedValue(userWithUsage(2048)); fetchMeMock.mockResolvedValue(userWithUsage(2048));
await session.refresh(); 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 () => { it('leaves the current user intact when the probe returns null', async () => {
@@ -33,7 +40,7 @@ describe('session.refresh', () => {
await session.refresh(); await session.refresh();
fetchMeMock.mockResolvedValue(null); fetchMeMock.mockResolvedValue(null);
await session.refresh(); 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 () => { it('leaves the current user intact when the probe throws', async () => {
@@ -41,6 +48,6 @@ describe('session.refresh', () => {
await session.refresh(); await session.refresh();
fetchMeMock.mockRejectedValue(new Error('network')); fetchMeMock.mockRejectedValue(new Error('network'));
await session.refresh(); await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048); expect(session.me?.full.storage_used_bytes).toBe(2048);
}); });
}); });
+39 -17
View File
@@ -11,17 +11,39 @@ import { setLogoutInProgress } from '$lib/api/client';
import { hasSessionHint } from '$lib/api/csrf'; import { hasSessionHint } from '$lib/api/csrf';
import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; import { seedNonceFromCookie } from '$lib/auth/dpop-proof';
import { drives } from '$lib/stores/drives.svelte'; 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'; 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 { class SessionStore {
user = $state<User | null>(null); /** Full `/api/auth/me` payload. Null when unauthenticated. */
me = $state<SelfUser | null>(null);
loaded = $state(false); loaded = $state(false);
homeFolderId = $state<string | null>(null); homeFolderId = $state<string | null>(null);
homeFolderName = $state<string | null>(null); homeFolderName = $state<string | null>(null);
isExternalUser = $derived(this.user?.is_external ?? false); /** Public-identity shorthand — same fields any authenticated caller
isAuthenticated = $derived(this.user !== null); * 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<PublicUser | null>(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` * TRUE when the backend has set `force_password_change_at_next_login`
* on this account — an admin picked a temporary password and the * 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 * flag (or a malformed `/me` response) doesn't accidentally
* quarantine every user. * 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 * 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 * what to do with an unauthenticated result. Idempotent: subsequent calls
* return the cached result (so client-side navigation doesn't re-probe). * return the cached result (so client-side navigation doesn't re-probe).
*/ */
async load(): Promise<User | null> { async load(): Promise<SelfUser | null> {
if (this.loaded) return this.user; if (this.loaded) return this.me;
// No JS-visible session hint ⇒ nothing to probe. The server sets // No JS-visible session hint ⇒ nothing to probe. The server sets
// `oxicloud_csrf` alongside the HttpOnly session cookies and clears // `oxicloud_csrf` alongside the HttpOnly session cookies and clears
// it on logout, so a missing hint means no session. Skips the // it on logout, so a missing hint means no session. Skips the
// doomed 2× /me + /refresh burst that would otherwise fire on // doomed 2× /me + /refresh burst that would otherwise fire on
// every first landing / post-logout re-mount with no cookies. // every first landing / post-logout re-mount with no cookies.
if (!hasSessionHint()) { if (!hasSessionHint()) {
this.user = null; this.me = null;
this.loaded = true; this.loaded = true;
return null; return null;
} }
@@ -71,25 +93,25 @@ class SessionStore {
// otherwise clutter the audit stream. Fire-and-forget // otherwise clutter the audit stream. Fire-and-forget
// so a slow IndexedDB open doesn't stall app boot. // so a slow IndexedDB open doesn't stall app boot.
if (me.is_dpop_bound === false) void bindDpopIfPossible(); if (me.is_dpop_bound === false) void bindDpopIfPossible();
} else this.user = null; } else this.me = null;
} catch { } catch {
this.user = null; this.me = null;
} }
this.loaded = true; this.loaded = true;
return this.user; return this.me;
} }
/** /**
* Set the authenticated user AND run per-user localStorage cleanup * Set the authenticated user AND run per-user localStorage cleanup
* (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct * (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, * `setUser` on login-flow entry points (form login, OIDC exchange,
* existing-session probe) so a switch-account flow inside the same * existing-session probe) so a switch-account flow inside the same
* tab observes the wipe. * tab observes the wipe.
*/ */
setUser(user: User): void { setUser(me: SelfUser): void {
this.user = user; this.me = me;
ensureActiveUser(user.id); ensureActiveUser(me.full.user.id);
// Any successful login clears the session-teardown gate. Without // Any successful login clears the session-teardown gate. Without
// this, a logout → login within the same SPA session leaves the // this, a logout → login within the same SPA session leaves the
// gate stuck at `true` — the login POST is exempted via // gate stuck at `true` — the login POST is exempted via
@@ -115,7 +137,7 @@ class SessionStore {
async refresh(): Promise<void> { async refresh(): Promise<void> {
try { try {
const me = await fetchMe(); const me = await fetchMe();
if (me) this.user = me; if (me) this.me = me;
} catch { } catch {
/* keep the existing user on a transient /api/auth/me failure */ /* keep the existing user on a transient /api/auth/me failure */
} }
@@ -142,7 +164,7 @@ class SessionStore {
} }
reset(): void { reset(): void {
this.user = null; this.me = null;
this.homeFolderId = null; this.homeFolderId = null;
this.homeFolderName = null; this.homeFolderName = null;
// Mark the store as `loaded` so any subsequent `session.load()` — // Mark the store as `loaded` so any subsequent `session.load()` —
+216 -55
View File
@@ -63,6 +63,7 @@
type StorageTestResult type StorageTestResult
} from '$lib/api/endpoints/admin'; } from '$lib/api/endpoints/admin';
import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives'; import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives';
import { seedUser } from '$lib/api/endpoints/users';
import { import {
ensureResolvers, ensureResolvers,
resolveRecipient, resolveRecipient,
@@ -70,7 +71,7 @@
type Recipient type Recipient
} from '$lib/api/endpoints/recipients'; } from '$lib/api/endpoints/recipients';
import type { import type {
AdminUserSummary, FullUser,
Drive, Drive,
DriveMember, DriveMember,
DrivePolicies, DrivePolicies,
@@ -156,11 +157,11 @@
deleteUserModal !== null && deleteUserModal !== null &&
deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase() deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase()
); );
function openDeleteUser(u: AdminUserSummary) { function openDeleteUser(u: FullUser) {
deleteUserModal = { deleteUserModal = {
userId: u.id, userId: u.user.id,
username: u.username || u.email, username: u.user.username || u.user.email,
email: u.email email: u.user.email
}; };
deleteUserEmailInput = ''; deleteUserEmailInput = '';
} }
@@ -817,7 +818,7 @@
} }
// Users // Users
let users = $state<AdminUserSummary[]>([]); let users = $state<FullUser[]>([]);
let total = $state(0); let total = $state(0);
let pageIndex = $state(0); let pageIndex = $state(0);
let usersError = $state<string | null>(null); let usersError = $state<string | null>(null);
@@ -923,6 +924,12 @@
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE); const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
users = page.users; users = page.users;
total = page.total; 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) { } catch (e) {
usersError = errorMessage(e); usersError = errorMessage(e);
} }
@@ -1003,49 +1010,49 @@
} }
/** True for the signed-in admin's own row — guards self-destructive actions. */ /** True for the signed-in admin's own row — guards self-destructive actions. */
function isSelf(u: AdminUserSummary): boolean { function isSelf(u: FullUser): boolean {
return u.id === currentAdminId; return u.user.id === currentAdminId;
} }
/** OIDC/SSO-provisioned account (no local password to reset). */ /** OIDC/SSO-provisioned account (no local password to reset). */
function isOidcUser(u: AdminUserSummary): boolean { function isOidcUser(u: FullUser): boolean {
return u.federation_kind === 'oidc'; return u.federation_kind === 'oidc';
} }
/** Used-quota percentage (0 when unlimited) for the per-user progress bar. */ /** 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; 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; 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; if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return;
try { try {
await setUserRole(u.id, role); await setUserRole(u.user.id, role);
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
reportError(e); reportError(e);
} }
} }
async function toggleActive(u: AdminUserSummary) { async function toggleActive(u: FullUser) {
if (isSelf(u) && u.active) return; if (isSelf(u) && u.active) return;
const msg = u.active const msg = u.active
? t('admin.confirm_deactivate', 'Deactivate this user?') ? t('admin.confirm_deactivate', 'Deactivate this user?')
: t('admin.confirm_activate', 'Activate this user?'); : t('admin.confirm_activate', 'Activate this user?');
if (!(await showConfirm(msg))) return; if (!(await showConfirm(msg))) return;
try { try {
await setUserActive(u.id, !u.active); await setUserActive(u.user.id, !u.active);
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
reportError(e); reportError(e);
} }
} }
function openQuota(u: AdminUserSummary) { function openQuota(u: FullUser) {
quotaModalError = null; quotaModalError = null;
quotaModal = { quotaModal = {
userId: u.id, userId: u.user.id,
username: u.username || u.email, username: u.user.username || u.user.email,
initialBytes: u.storage_quota_bytes initialBytes: u.storage_quota_bytes
}; };
} }
@@ -1069,8 +1076,8 @@
} }
} }
function openReset(u: AdminUserSummary) { function openReset(u: FullUser) {
resetModal = { userId: u.id, username: u.username || u.email }; resetModal = { userId: u.user.id, username: u.user.username || u.user.email };
resetPassword = ''; resetPassword = '';
resetError = null; resetError = null;
} }
@@ -1094,7 +1101,7 @@
} }
} }
function removeUser(u: AdminUserSummary) { function removeUser(u: FullUser) {
if (isSelf(u)) return; if (isSelf(u)) return;
openDeleteUser(u); openDeleteUser(u);
} }
@@ -1103,20 +1110,20 @@
// provisions a home drive + flips the is_external flag; irreversible // provisions a home drive + flips the is_external flag; irreversible
// via the admin UI (there's no demote endpoint on purpose). Backend // via the admin UI (there's no demote endpoint on purpose). Backend
// refuses when magic-link login is disabled — surfaced as a toast. // refuses when magic-link login is disabled — surfaced as a toast.
async function promoteExternal(u: AdminUserSummary) { async function promoteExternal(u: FullUser) {
if (!u.is_external) return; if (!u.user.is_external) return;
if ( if (
!(await showConfirm( !(await showConfirm(
t( t(
'admin.confirm_promote_user', '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.' '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; return;
try { try {
await promoteUserToInternal(u.id); await promoteUserToInternal(u.user.id);
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
reportError(e); reportError(e);
@@ -1240,8 +1247,13 @@
.map(async (d) => { .map(async (d) => {
const ownerMember = nextMembers[d.id]?.find((m) => m.subject.type === 'user'); const ownerMember = nextMembers[d.id]?.find((m) => m.subject.type === 'user');
if (!ownerMember) return; if (!ownerMember) return;
const user = await getUserAdmin(ownerMember.subject.id); // `getUserAdmin` returns `FullUser` (admin-visible extras
if (user) nextOwners[d.id] = user; // + 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; personalDriveOwners = nextOwners;
@@ -1723,6 +1735,16 @@
{:else if !dashboard} {:else if !dashboard}
<p class="status">{t('common.loading', 'Loading…')}</p> <p class="status">{t('common.loading', 'Loading…')}</p>
{:else} {:else}
<!-- Three grouped sections — one per data-nature axis. Static
row counts on top (change on register/deactivate/role toggle),
live-presence signals in the middle (change minute-to-minute,
visually distinguished with the presence dot), system flags at
the bottom (deployment posture, changes rarely). Splitting
what used to be a single 4-card row prevents admins from
misreading "as-of-now count" as "who's here right now". -->
<!-- Section 1: User accounts — static breakdown of auth.users -->
<h2 class="ds-section-title">{t('admin.section_accounts', 'User accounts')}</h2>
<div class="ds-grid"> <div class="ds-grid">
<div class="ds-card"> <div class="ds-card">
<span class="ds-num">{dashboard.total_users}</span>{t('admin.total_users', 'Total users')} <span class="ds-num">{dashboard.total_users}</span>{t('admin.total_users', 'Total users')}
@@ -1733,11 +1755,66 @@
<div class="ds-card"> <div class="ds-card">
<span class="ds-num">{dashboard.admin_users}</span>{t('admin.admin_users', 'Admins')} <span class="ds-num">{dashboard.admin_users}</span>{t('admin.admin_users', 'Admins')}
</div> </div>
<div class="ds-card"> <div
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')} class="ds-card"
title={t(
'admin.external_users_tooltip',
'Grant-only accounts — magic-link, OIDC-only, OCM recipients'
)}
>
<span class="ds-num">{dashboard.external_users}</span>{t(
'admin.external_users',
'External'
)}
</div> </div>
</div> </div>
<!-- Section 2: Live activity — projection over auth.sessions.
Same 5-min window as the Prometheus `oxicloud_sessions_online`
gauges. The presence dot before each number signals "this
value changes minute-to-minute" — same green as the
admin > sessions row indicator so admins read one consistent
visual for presence across the panel. -->
<h2 class="ds-section-title">
{t('admin.section_activity', 'Live activity')}
<span
class="ds-section-live"
title={t('admin.live_tooltip', 'Reflects sessions active in the last 5 minutes')}
>
{t('admin.live', 'live')}
</span>
</h2>
<div class="ds-grid">
<div
class="ds-card"
title={t(
'admin.online_users_tooltip',
'Distinct users with a session active in the last 5 minutes'
)}
>
<span class="ds-num ds-num--live">
<span class="presence-dot presence-dot--online" aria-hidden="true"></span>
{dashboard.online_users}
</span>
{t('admin.online_users', 'Online users')}
</div>
<div
class="ds-card"
title={t(
'admin.online_sessions_tooltip',
'Non-revoked sessions active in the last 5 minutes — multi-device users contribute more than one'
)}
>
<span class="ds-num ds-num--live">
<span class="presence-dot presence-dot--online" aria-hidden="true"></span>
{dashboard.online_sessions}
</span>
{t('admin.online_sessions', 'Online sessions')}
</div>
</div>
<!-- Section 3: System — deployment flags + version. -->
<h2 class="ds-section-title">{t('admin.section_system', 'System')}</h2>
<div class="ds-grid"> <div class="ds-grid">
<div class="ds-card"> <div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}> <span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
@@ -1761,6 +1838,9 @@
</span> </span>
{t('admin.quotas', 'Quotas')} {t('admin.quotas', 'Quotas')}
</div> </div>
<div class="ds-card">
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
</div>
</div> </div>
{#if dashboard.users_over_quota > 0} {#if dashboard.users_over_quota > 0}
@@ -1802,13 +1882,17 @@
row.kind === 'personal' row.kind === 'personal'
? t('admin.quota_personal', 'Personal drives') ? t('admin.quota_personal', 'Personal drives')
: t('admin.quota_shared', 'Shared drives')} : t('admin.quota_shared', 'Shared drives')}
{@const total = row.unlimited_count + row.capped_count}
{@const pct = {@const pct =
row.capped_quota_bytes && row.capped_quota_bytes > 0 row.capped_quota_bytes && row.capped_quota_bytes > 0
? (row.used_bytes / row.capped_quota_bytes) * 100 ? (row.used_bytes / row.capped_quota_bytes) * 100
: null} : null}
{#if row.capped_count > 0 || row.unlimited_count > 0} {#if row.capped_count > 0 || row.unlimited_count > 0}
<tr> <tr>
<th scope="row">{label}</th> <th scope="row">
<span class="quota-table__count">{total}</span>
{label}
</th>
<td class="quota-table__num"> <td class="quota-table__num">
{#if row.capped_quota_bytes !== null && pct !== null} {#if row.capped_quota_bytes !== null && pct !== null}
{formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)} {formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)}
@@ -2680,15 +2764,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each users as u (u.id)} {#each users as u (u.user.id)}
{@const pct = quotaPct(u)} {@const pct = quotaPct(u)}
<tr> <tr>
<td> <td>
<div class="user-vignette-cell"> <div class="user-vignette-cell">
<UserVignette <UserVignette
userId={u.id} userId={u.user.id}
fallbackLabel={u.username || u.email} fallbackLabel={u.user.username || u.user.email}
fallbackSublabel={u.email} fallbackSublabel={u.user.email}
/> />
{#if isSelf(u)} {#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span> <span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
@@ -2703,11 +2787,11 @@
badge is `white-space: nowrap` so the badge label badge is `white-space: nowrap` so the badge label
itself never wraps mid-word either. --> itself never wraps mid-word either. -->
<div class="role-badges"> <div class="role-badges">
<span class="badge badge--{u.role === 'admin' ? 'admin' : 'user'}"> <span class="badge badge--{u.user.role === 'admin' ? 'admin' : 'user'}">
{#if u.role === 'admin'}<Icon name="shield-alt" />{/if} {#if u.user.role === 'admin'}<Icon name="shield-alt" />{/if}
{u.role} {u.user.role}
</span> </span>
{#if u.is_external} {#if u.user.is_external}
<!-- Origin flag, orthogonal to `role`. Grant-only <!-- Origin flag, orthogonal to `role`. Grant-only
accounts (magic-link / OCM) can never be admin accounts (magic-link / OCM) can never be admin
(DB CHECK `users_external_not_admin`) so the two (DB CHECK `users_external_not_admin`) so the two
@@ -2738,7 +2822,7 @@
<td class="auth-cell"> <td class="auth-cell">
<!-- <!--
Auth-capability chip set — ADMIN-ONLY (fields Auth-capability chip set — ADMIN-ONLY (fields
scoped to `AdminUserSummaryDto`; never on scoped to `FullUserDto`; never on
`UserDto`). Any user carries ZERO OR MORE of: `UserDto`). Any user carries ZERO OR MORE of:
* SSO/OIDC — `federation_kind === 'oidc'`, * SSO/OIDC — `federation_kind === 'oidc'`,
identity delegated to the IdP; label is identity delegated to the IdP; label is
@@ -2825,7 +2909,7 @@
</span> </span>
</td> </td>
<td> <td>
{#if u.is_external} {#if u.user.is_external}
<!-- External accounts have no storage envelope by <!-- External accounts have no storage envelope by
design (DB CHECK `users_external_no_storage` design (DB CHECK `users_external_no_storage`
enforces storage_quota_bytes = 0). Rendering the enforces storage_quota_bytes = 0). Rendering the
@@ -2867,10 +2951,10 @@
actions render as invisible placeholders. --> actions render as invisible placeholders. -->
<div class="actions actions--user"> <div class="actions actions--user">
<!-- Slot 1: quota (internal) OR promote (external). --> <!-- Slot 1: quota (internal) OR promote (external). -->
{#if u.is_external} {#if u.user.is_external}
<button <button
class="icon-btn icon-btn--success" class="icon-btn icon-btn--success"
data-testid={`admin-user-promote-${u.id}`} data-testid={`admin-user-promote-${u.user.id}`}
title={t('admin.promote_to_internal_title', 'Promote to internal user')} title={t('admin.promote_to_internal_title', 'Promote to internal user')}
aria-label={t('admin.promote_to_internal_title', 'Promote to internal user')} aria-label={t('admin.promote_to_internal_title', 'Promote to internal user')}
onclick={() => promoteExternal(u)} onclick={() => promoteExternal(u)}
@@ -2880,7 +2964,7 @@
{:else} {:else}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-quota-${u.id}`} data-testid={`admin-user-quota-${u.user.id}`}
title={t('admin.edit_quota_title', 'Edit quota')} title={t('admin.edit_quota_title', 'Edit quota')}
aria-label={t('admin.edit_quota_title', 'Edit quota')} aria-label={t('admin.edit_quota_title', 'Edit quota')}
onclick={() => openQuota(u)} onclick={() => openQuota(u)}
@@ -2891,10 +2975,10 @@
<!-- Slot 2: reset password (local internal only — <!-- Slot 2: reset password (local internal only —
OIDC and external accounts have no password OIDC and external accounts have no password
to reset). Placeholder otherwise. --> to reset). Placeholder otherwise. -->
{#if !isOidcUser(u) && !u.is_external} {#if !isOidcUser(u) && !u.user.is_external}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-reset-password-${u.id}`} data-testid={`admin-user-reset-password-${u.user.id}`}
title={t('admin.reset_password_title', 'Reset password')} title={t('admin.reset_password_title', 'Reset password')}
aria-label={t('admin.reset_password_title', 'Reset password')} aria-label={t('admin.reset_password_title', 'Reset password')}
onclick={() => openReset(u)} onclick={() => openReset(u)}
@@ -2909,16 +2993,16 @@
`change_user_role` + DB CHECK `change_user_role` + DB CHECK
`users_external_not_admin`). Promotion to `users_external_not_admin`). Promotion to
internal is offered separately in slot 1. --> internal is offered separately in slot 1. -->
{#if !u.is_external} {#if !u.user.is_external}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-toggle-role-${u.id}`} data-testid={`admin-user-toggle-role-${u.user.id}`}
title={t('admin.toggle_role_title', 'Toggle admin role')} title={t('admin.toggle_role_title', 'Toggle admin role')}
aria-label={t('admin.toggle_role_title', 'Toggle admin role')} aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
disabled={isSelf(u)} disabled={isSelf(u)}
onclick={() => toggleRole(u)} onclick={() => toggleRole(u)}
> >
<Icon name={u.role === 'admin' ? 'user' : 'crown'} /> <Icon name={u.user.role === 'admin' ? 'user' : 'crown'} />
</button> </button>
{:else} {:else}
<span class="icon-btn icon-btn--placeholder" aria-hidden="true"></span> <span class="icon-btn icon-btn--placeholder" aria-hidden="true"></span>
@@ -2926,7 +3010,7 @@
<!-- Slot 4: activate/deactivate. --> <!-- Slot 4: activate/deactivate. -->
<button <button
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}" class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
data-testid={`admin-user-toggle-active-${u.id}`} data-testid={`admin-user-toggle-active-${u.user.id}`}
title={u.active title={u.active
? t('admin.deactivate_title', 'Deactivate') ? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')} : t('admin.activate_title', 'Activate')}
@@ -2941,7 +3025,7 @@
<!-- Slot 5: delete. --> <!-- Slot 5: delete. -->
<button <button
class="icon-btn icon-btn--danger" class="icon-btn icon-btn--danger"
data-testid={`admin-user-delete-${u.id}`} data-testid={`admin-user-delete-${u.user.id}`}
title={t('admin.delete_title', 'Delete user')} title={t('admin.delete_title', 'Delete user')}
aria-label={t('admin.delete_title', 'Delete user')} aria-label={t('admin.delete_title', 'Delete user')}
disabled={isSelf(u)} disabled={isSelf(u)}
@@ -3275,11 +3359,21 @@
fall back to the owner's cap; 0 also means "no limit" fall back to the owner's cap; 0 also means "no limit"
(backend convention — see `User.storage_quota_bytes` doc). (backend convention — see `User.storage_quota_bytes` doc).
--> -->
<!-- Personal-drive fallback used to read
`owner.storage_quota_bytes` off the resolved DTO.
Post the UserDto refactor
(docs/plan/userdto-refactor.md) `owner` here is a
`PublicUser` (public identity, no quota); the
envelope quota only lives on `FullUser` /
`SelfUser`. Rather than widen the resolver's shape
just for this fallback, hold the effective quota at
`null` when the drive itself doesn't declare one —
the row renders "—" and the admin can consult the
user's row for their envelope cap. Explicit
shared-drive quota still surfaces as before. -->
{@const effectiveQuota = {@const effectiveQuota =
d.kind === 'personal' d.kind === 'personal'
? owner && owner.storage_quota_bytes > 0 ? null
? owner.storage_quota_bytes
: null
: d.quota_bytes && d.quota_bytes > 0 : d.quota_bytes && d.quota_bytes > 0
? d.quota_bytes ? d.quota_bytes
: null} : null}
@@ -4323,6 +4417,40 @@
margin-bottom: var(--space-4); 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 { .ds-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -4341,6 +4469,18 @@
color: var(--color-text-heading); 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 { .ds-bar {
height: 8px; height: 8px;
background: var(--color-bg-muted); background: var(--color-bg-muted);
@@ -4433,6 +4573,19 @@
white-space: nowrap; 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 { .quota-table__num {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
@@ -5213,9 +5366,17 @@
} }
.admin { .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; margin: 0 auto;
padding: 1.5rem 1rem; padding: 1.5rem var(--space-2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
+11 -1
View File
@@ -96,16 +96,26 @@ const dashboard = {
users_over_quota: 0 users_over_quota: 0
}; };
// FullUser fixture — post the three-layer UserDto refactor
// (docs/plan/userdto-refactor.md), /api/admin/users returns
// `Vec<FullUserDto>` where public identity nests under `.user`
// and admin-visible extras (quotas, active, has_password, OPAQUE
// flags) live at the top level.
const user = { const user = {
user: {
id: 'u1', id: 'u1',
username: 'bob', username: 'bob',
email: 'bob@x.test', email: 'bob@x.test',
role: 'user', role: 'user',
is_external: false
},
active: true, active: true,
is_active: true, is_active: true,
storage_used_bytes: 10, storage_used_bytes: 10,
storage_quota_bytes: 100, storage_quota_bytes: 100,
is_external: false has_password: true,
opaque_registered: false,
opaque_migrated: false
}; };
const mount = { const mount = {
@@ -713,8 +713,43 @@
* Upload a batch of files into the current folder, reporting aggregate * Upload a batch of files into the current folder, reporting aggregate
* progress through a single bell notification with a progress bar. * 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 `<input data-testid=
* "files-upload-file-input">` 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[]) { async function uploadBatch(files: File[]) {
if (files.length === 0) return; if (files.length === 0) return;
if (!guardUploadFolderReady()) return;
uploading = true; uploading = true;
// Arm the reload-guard + persist a "batch in flight" marker so a // Arm the reload-guard + persist a "batch in flight" marker so a
// page refresh mid-upload (a) prompts the browser's "Leave site?" // page refresh mid-upload (a) prompts the browser's "Leave site?"
@@ -1557,6 +1592,7 @@
*/ */
async function uploadTree(entries: { file: File; relativePath: string }[]) { async function uploadTree(entries: { file: File; relativePath: string }[]) {
if (entries.length === 0) return; if (entries.length === 0) return;
if (!guardUploadFolderReady()) return;
uploading = true; uploading = true;
// Same reload-guard + interrupted-uploads breadcrumb as uploadBatch — // Same reload-guard + interrupted-uploads breadcrumb as uploadBatch —
// the browser prompts on refresh, and if the user reloads anyway // the browser prompts on refresh, and if the user reloads anyway
+6
View File
@@ -158,6 +158,12 @@ it('keeps aggregate upload progress exact when one file restarts', async () => {
); );
render(FilesPage); render(FilesPage);
const input = await screen.findByTestId('files-upload-file-input'); 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')]; const uploads = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')];
Object.defineProperty(input, 'files', { configurable: true, value: uploads }); Object.defineProperty(input, 'files', { configurable: true, value: uploads });
+47 -37
View File
@@ -72,11 +72,11 @@
let creatingPw = $state(false); let creatingPw = $state(false);
let autoExpanded = $state(false); let autoExpanded = $state(false);
const isOidc = $derived(session.user?.federation_kind === 'oidc'); const isOidc = $derived(session.me?.full.federation_kind === 'oidc');
const isLocal = $derived(!session.user?.federation_kind); const isLocal = $derived(!session.me?.full.federation_kind);
const usernameClaimed = $derived(!!session.user?.username); const usernameClaimed = $derived(!!session.user?.username);
const isAdmin = $derived(session.user?.role === 'admin'); const isAdmin = $derived(session.user?.role === 'admin');
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal); const canEditImage = $derived(session.me?.can_edit_image === true && isLocal);
// Show the change-password card when the user CAN change their // Show the change-password card when the user CAN change their
// local password: they have `password_hash` on file AND the // local password: they have `password_hash` on file AND the
// deployment offers password login (backend `change_password` // deployment offers password login (backend `change_password`
@@ -87,14 +87,16 @@
// password) are a legitimate posture and MUST be able to rotate // password) are a legitimate posture and MUST be able to rotate
// their local credential; the new gate lets them, and the backend // their local credential; the new gate lets them, and the backend
// refusal covers the pure-SSO case where has_password is false. // refusal covers the pure-SSO case where has_password is false.
const showPasswordCard = $derived((session.user?.has_password ?? false) && passwordLoginEnabled); const showPasswordCard = $derived(
(session.me?.full.has_password ?? false) && passwordLoginEnabled
);
// SSO card gates — see docs/plan/oidc-account-linking.md. // SSO card gates — see docs/plan/oidc-account-linking.md.
// Connect: only when OIDC is enabled AND the user isn't already linked. // Connect: only when OIDC is enabled AND the user isn't already linked.
// Disconnect: only when currently OIDC-linked AND the user has an // Disconnect: only when currently OIDC-linked AND the user has an
// alternative auth method (password or OPAQUE-registered) — else // alternative auth method (password or OPAQUE-registered) — else
// unlinking would lock them out. // unlinking would lock them out.
const canConnectSso = $derived(oidcEnabled && !session.user?.federation_kind); const canConnectSso = $derived(oidcEnabled && !session.me?.full.federation_kind);
// Show the disconnect button whenever the user is OIDC-linked. // Show the disconnect button whenever the user is OIDC-linked.
// The backend guard (`AuthApplicationService::unlink_oidc`) is the // The backend guard (`AuthApplicationService::unlink_oidc`) is the
// source of truth for the "no alternative auth" refusal — it also // source of truth for the "no alternative auth" refusal — it also
@@ -103,7 +105,7 @@
// adoption status through user-directory endpoints). The UI shows // adoption status through user-directory endpoints). The UI shows
// the button unconditionally and surfaces the backend's 403 as a // the button unconditionally and surfaces the backend's 403 as a
// user-facing "set a password first" prompt. // user-facing "set a password first" prompt.
const canDisconnectSso = $derived(session.user?.federation_kind === 'oidc'); const canDisconnectSso = $derived(session.me?.full.federation_kind === 'oidc');
/** /**
* Mandatory change-password mode. TRUE when the backend has * Mandatory change-password mode. TRUE when the backend has
@@ -136,14 +138,11 @@
} }
}); });
const storagePct = $derived( const storagePct = $derived.by(() => {
session.user && session.user.storage_quota_bytes > 0 const full = session.me?.full;
? Math.min( if (!full || full.storage_quota_bytes <= 0) return 0;
100, return Math.min(100, Math.round((full.storage_used_bytes / full.storage_quota_bytes) * 100));
Math.round((session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100) });
)
: 0
);
const storageBarClass = $derived( const storageBarClass = $derived(
storagePct > 90 ? 'bar__fill--red' : storagePct > 70 ? 'bar__fill--orange' : 'bar__fill--green' storagePct > 90 ? 'bar__fill--red' : storagePct > 70 ? 'bar__fill--orange' : 'bar__fill--green'
); );
@@ -159,15 +158,20 @@
relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true }); relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true });
function hydrate() { function hydrate() {
const u = session.user; const me = session.me;
if (!u) return; if (!me) return;
givenName = u.given_name ?? ''; // Public identity (name / handle) reads via `me.full.user`;
familyName = u.family_name ?? ''; // admin-visible extras (preferred_locale) via `me.full`;
username = u.username ?? ''; // self-only bag flags (notify_on_share) via `me` directly.
preferredLocale = u.preferred_locale ?? ''; // The three-level indirection makes the audience of each
notifyOnShare = u.notify_on_share; // field visible at the callsite (docs/plan/userdto-refactor.md).
givenName = me.full.user.given_name ?? '';
familyName = me.full.user.family_name ?? '';
username = me.full.user.username ?? '';
preferredLocale = me.full.preferred_locale ?? '';
notifyOnShare = me.notify_on_share;
// Source of truth is the preferences store, which itself // Source of truth is the preferences store, which itself
// derives from `session.user.ui_preferences`. Reading through // derives from `session.me.ui_preferences`. Reading through
// the store here (rather than the raw bag) means a new // the store here (rather than the raw bag) means a new
// preference field just needs a getter in the store and its // preference field just needs a getter in the store and its
// own line here — no wire-format knowledge on the page. // own line here — no wire-format knowledge on the page.
@@ -176,21 +180,22 @@
async function saveProfile(e: SubmitEvent) { async function saveProfile(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const u = session.user; const me = session.me;
if (!u) return; if (!me) return;
// Build a sparse patch of only the fields the user actually changed. // Build a sparse patch of only the fields the user actually changed.
// Sending empty strings the user never touched would 400 on the server. // Sending empty strings the user never touched would 400 on the server.
const patch: ProfilePatch = {}; const patch: ProfilePatch = {};
if (!usernameClaimed && username.trim() && username.trim() !== (u.username ?? '')) { if (!usernameClaimed && username.trim() && username.trim() !== (me.full.user.username ?? '')) {
patch.username = username.trim(); patch.username = username.trim();
} }
if (givenName.trim() !== (u.given_name ?? '')) patch.given_name = givenName.trim(); if (givenName.trim() !== (me.full.user.given_name ?? '')) patch.given_name = givenName.trim();
if (familyName.trim() !== (u.family_name ?? '')) patch.family_name = familyName.trim(); if (familyName.trim() !== (me.full.user.family_name ?? ''))
if ((preferredLocale || '') !== (u.preferred_locale ?? '')) { patch.family_name = familyName.trim();
if ((preferredLocale || '') !== (me.full.preferred_locale ?? '')) {
patch.preferred_locale = preferredLocale || undefined; patch.preferred_locale = preferredLocale || undefined;
} }
if (notifyOnShare !== u.notify_on_share) patch.notify_on_share = notifyOnShare; if (notifyOnShare !== me.notify_on_share) patch.notify_on_share = notifyOnShare;
// Ship the diff as a partial `ui_preferences` patch — the // Ship the diff as a partial `ui_preferences` patch — the
// server does a shallow merge, so only the changed key is // server does a shallow merge, so only the changed key is
// touched; siblings set on other devices survive. // touched; siblings set on other devices survive.
@@ -205,8 +210,13 @@
savingProfile = true; savingProfile = true;
try { try {
// 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); const updated = await updateProfile(patch);
session.user = updated; session.me = updated;
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale); if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
ui.notify(t('profile.saved', 'Profile saved'), 'success'); ui.notify(t('profile.saved', 'Profile saved'), 'success');
} catch (err) { } catch (err) {
@@ -445,7 +455,7 @@
// (federation_kind should now be 'oidc'). // (federation_kind should now be 'oidc').
try { try {
const me = await fetchMe(); const me = await fetchMe();
if (me) session.user = me; if (me) session.me = me;
} catch { } catch {
/* stale session is recoverable — next request refreshes */ /* stale session is recoverable — next request refreshes */
} }
@@ -536,7 +546,7 @@
try { try {
await unlinkOidc(); await unlinkOidc();
const me = await fetchMe(); const me = await fetchMe();
if (me) session.user = me; if (me) session.me = me;
ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info'); ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info');
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') { if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') {
@@ -742,7 +752,7 @@
<Icon name="clock" /> <Icon name="clock" />
{t('profile.last_login', 'Last Login')} {t('profile.last_login', 'Last Login')}
</div> </div>
<div class="info-value">{timeAgo(session.user.last_login_at)}</div> <div class="info-value">{timeAgo(session.me?.full.last_login_at)}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -752,20 +762,20 @@
<h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2> <h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2>
<div class="storage-stats"> <div class="storage-stats">
<div class="storage-stat"> <div class="storage-stat">
<div class="stat-value">{formatBytes(session.user.storage_used_bytes)}</div> <div class="stat-value">{formatBytes(session.me?.full.storage_used_bytes ?? 0)}</div>
<div class="muted">{t('profile.used', 'Used')}</div> <div class="muted">{t('profile.used', 'Used')}</div>
</div> </div>
<div class="storage-stat"> <div class="storage-stat">
<div class="stat-value"> <div class="stat-value">
{session.user.storage_quota_bytes > 0 {(session.me?.full.storage_quota_bytes ?? 0) > 0
? formatBytes(session.user.storage_quota_bytes) ? formatBytes(session.me?.full.storage_quota_bytes ?? 0)
: '∞'} : '∞'}
</div> </div>
<div class="muted">{t('profile.quota', 'Quota')}</div> <div class="muted">{t('profile.quota', 'Quota')}</div>
</div> </div>
<div class="storage-stat"> <div class="storage-stat">
<div class="stat-value"> <div class="stat-value">
{session.user.storage_quota_bytes > 0 ? `${storagePct}%` : '—'} {(session.me?.full.storage_quota_bytes ?? 0) > 0 ? `${storagePct}%` : '—'}
</div> </div>
<div class="muted">{t('profile.usage', 'Usage')}</div> <div class="muted">{t('profile.usage', 'Usage')}</div>
</div> </div>
+44 -19
View File
@@ -1,10 +1,15 @@
import { it, expect, vi, beforeEach } from 'vitest'; import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({ // Test-double session store. Post the three-layer UserDto refactor
session: { // (docs/plan/userdto-refactor.md), production `session.user` is a
loaded: true, // derived accessor over `session.me.full.user`. The stub here mirrors
load: vi.fn(), // 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: { user: {
id: '1', id: '1',
username: 'admin', username: 'admin',
@@ -12,14 +17,41 @@ const { session, ui } = vi.hoisted(() => ({
given_name: 'A', given_name: 'A',
family_name: 'B', family_name: 'B',
role: 'admin', role: 'admin',
is_external: false
},
storage_used_bytes: 100, storage_used_bytes: 100,
storage_quota_bytes: 1000, storage_quota_bytes: 1000,
is_external: false,
has_password: true 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,
has_password: true
}
};
return {
session: {
loaded: true,
load: vi.fn(),
me,
user: me.full.user
}, },
ui: { notify: vi.fn() } ui: { notify: vi.fn() }
})); };
});
vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui })); vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() })); vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
@@ -44,20 +76,13 @@ const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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.loaded = true;
session.user = { const me = buildSelfMe();
id: '1', session.me = me;
username: 'admin', session.user = me.full.user;
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
};
m(profile.listAppPasswords).mockResolvedValue([]); m(profile.listAppPasswords).mockResolvedValue([]);
m(profile.updateProfile).mockResolvedValue(undefined); m(profile.updateProfile).mockResolvedValue(undefined);
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true }); m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
+12
View File
@@ -546,6 +546,7 @@
"empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.", "empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.",
"show_hidden": "Show hidden files", "show_hidden": "Show hidden files",
"upload_dotfile_hidden": "{{n}} file(s) uploaded but hidden by your dotfile preference.", "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.", "rename_dotfile_hidden": "Renamed to '{{name}}' — now hidden by your preference.",
"new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.", "new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.",
"dotfiles_hidden_toast": "Dotfiles hidden", "dotfiles_hidden_toast": "Dotfiles hidden",
@@ -836,6 +837,17 @@
"total_users": "Total Users", "total_users": "Total Users",
"active_users": "Active Users", "active_users": "Active Users",
"admins": "Admins", "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", "version": "Version",
"storage_overview": "Storage Overview", "storage_overview": "Storage Overview",
"used": "Used", "used": "Used",
+12
View File
@@ -459,6 +459,7 @@
"empty_hidden_hint": "Les fichiers dont le nom commence par '.' sont masqués. Modifiez le réglage pour les afficher.", "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", "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_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.", "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.", "new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.",
"dotfiles_hidden_toast": "Fichiers masqués", "dotfiles_hidden_toast": "Fichiers masqués",
@@ -801,6 +802,17 @@
"total_users": "Utilisateurs totaux", "total_users": "Utilisateurs totaux",
"active_users": "Utilisateurs actifs", "active_users": "Utilisateurs actifs",
"admins": "Admins", "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", "version": "Version",
"storage_overview": "Aperçu du stockage", "storage_overview": "Aperçu du stockage",
"used": "Utilisé", "used": "Utilisé",
+35 -5
View File
@@ -108,14 +108,15 @@ pub struct AdminResetPasswordDto {
pub new_password: String, 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)] #[derive(Debug, Serialize, Deserialize)]
pub struct ListUsersQueryDto { pub struct ListUsersQueryDto {
pub limit: Option<i64>, pub limit: Option<i64>,
pub offset: Option<i64>, pub offset: Option<i64>,
/// 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<bool>,
} }
/// Query parameters for the admin sessions listing. /// Query parameters for the admin sessions listing.
@@ -163,10 +164,39 @@ pub struct DashboardStatsDto {
pub auth_enabled: bool, pub auth_enabled: bool,
pub oidc_configured: bool, pub oidc_configured: bool,
pub quotas_enabled: 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 total_users: i64,
pub active_users: i64, pub active_users: i64,
pub admin_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 ── // ── Per-drive-kind quota accounting ──
// One row per drive kind (personal, shared). Pre-dedup, logical // One row per drive kind (personal, shared). Pre-dedup, logical
// file sizes summed from `drives.used_bytes` (personal rolls up // file sizes summed from `drives.used_bytes` (personal rolls up
+378 -239
View File
@@ -1,5 +1,4 @@
use crate::domain::entities::user::User; use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smol_str::SmolStr; use smol_str::SmolStr;
@@ -7,287 +6,283 @@ use std::sync::Arc;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
// ────────────────────────────────────────────────────────────────────────
// 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<FullUserDto>` 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.
//
// 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
/// 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)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserDto { pub struct PublicUserDto {
pub id: String, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>, pub username: Option<String>,
pub email: String, pub email: String,
/// Role string ("admin" | "user"). Kept public because the sharee /
/// group-member vignette renders an admin badge.
pub role: String, pub role: String,
pub storage_quota_bytes: i64, /// Avatar payload (base64 data-URI up to 512 KiB). Public so a share
pub storage_used_bytes: i64, /// picker can render the recipient's face directly. Will move to a
pub created_at: DateTime<Utc>, /// dedicated avatar endpoint in a future refactor — this shape is
pub updated_at: DateTime<Utc>, /// transitional.
pub last_login_at: Option<DateTime<Utc>>,
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<String>,
/// 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<String>,
pub image: Option<String>, pub image: Option<String>,
pub can_edit_image: bool,
/// `true` for grant-only external recipients (magic-link, OIDC-only, /// `true` for grant-only external recipients (magic-link, OIDC-only,
/// future OCM federated). External users have no home folder and /// future OCM federated). Renders the "external" badge on the vignette.
/// can't own storage; their quota is always 0. Internal users
/// default to `false`.
pub is_external: bool, pub is_external: bool,
/// Optional first/given name. Populated from the OIDC `given_name` /// Optional first/given name. Social identity.
/// 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>, pub given_name: Option<String>,
/// Optional last/family name. Same provenance + serde rules as /// Optional last/family name. Social identity.
/// `given_name`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>, pub family_name: Option<String>,
/// When the user first demonstrated control of their email (PR 23). /// Presence signal — TRUE when the server observed a request on any
/// `None` = unverified (omitted from JSON). Stamped on the first /// of this user's non-revoked sessions within the last
/// successful magic-link redemption or OIDC JIT with verified /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
/// claim. Idempotent — the original timestamp is preserved on /// (5 min). Sourced from an EXISTS subquery when the DTO is built
/// subsequent verifications. /// from a list-projection path; single-user endpoints that don't
#[serde(skip_serializing_if = "Option::is_none")] /// enrich presence ship `false`.
pub email_verified_at: Option<DateTime<Utc>>,
/// 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<String>,
/// 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<User>` — 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)] #[serde(default)]
pub force_password_change: bool, pub is_online: 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<User>` 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. /// Full user record — public identity + all fields BOTH an admin
/// (viewing another user) AND the subject themselves may see. Returned
/// as `Vec<FullUserDto>` by `/api/admin/users`; embedded in
/// [`SelfUserDto`] for `/api/auth/me`.
/// ///
/// Account-detail fields deliberately do not appear here. In particular, /// This is the DTO closest to the underlying `auth.users` row. Adding a
/// omitting `image` and `ui_preferences` prevents a 100-row page from turning /// field here means an admin looking at any user can see it, and the
/// into tens of MiB when users have uploaded avatars. `GET /api/admin/users/:id` /// subject themselves can see it in their `/me` response — but the field
/// remains the full-detail endpoint. /// stays off the public [`PublicUserDto`] surface.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminUserSummaryDto { pub struct FullUserDto {
pub id: String, /// Public identity — same set every authenticated caller sees.
#[serde(skip_serializing_if = "Option::is_none")] pub user: PublicUserDto,
pub username: Option<String>, /// Which trust chain minted this user's federation identity —
pub email: String, /// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local users.
pub role: String, /// Kept off `PublicUserDto` because a peer's federation kind is a
pub storage_quota_bytes: i64, /// soft org-affiliation leak; only self + admin need it.
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
/// See `UserDto::federation_kind` — same semantics, same wire spelling.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub federation_kind: Option<String>, pub federation_kind: Option<String>,
/// See `UserDto::federation_issuer` — same semantics, same wire spelling. /// 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub federation_issuer: Option<String>, pub federation_issuer: Option<String>,
pub is_external: bool, /// Subject's own locale preference. Only THEY or an admin managing
/// TRUE when the user has a server-verifiable password on file /// them needs this — other callers use their own locale.
/// (`password_hash IS NOT NULL`). The admin table uses this #[serde(skip_serializing_if = "Option::is_none")]
/// alongside `federation_issuer` and `opaque_registered` to render pub preferred_locale: Option<String>,
/// the user's full capability set: a `password` chip lights up /// When the user first demonstrated control of their email. Trust
/// here, an OIDC provider name renders the SSO badge, an /// signal — meaningful to admin (auditing verification status) and
/// envelope-on-file flips the OPAQUE chip. A user with none of /// to self (own record), but not to a share picker rendering a
/// the three is passwordless (magic-link only — the SPA renders /// vignette.
/// a distinct `passwordless` chip in that case). Admin-only #[serde(skip_serializing_if = "Option::is_none")]
/// exposure — see the DTO doc for why this isn't on `UserDto`. pub email_verified_at: Option<DateTime<Utc>>,
#[serde(default)] /// Row bookkeeping.
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Activity signal — private to the subject; admin sees it too.
pub last_login_at: Option<DateTime<Utc>>,
/// 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, pub has_password: bool,
/// Mirrors `UserListEntry::opaque_registered` — TRUE when the user /// TRUE when the user has an OPAQUE envelope on file.
/// 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, pub opaque_registered: bool,
/// Mirrors `UserListEntry::opaque_migrated` — TRUE when the user /// TRUE when the user has completed ≥1 successful OPAQUE login.
/// has completed at least one successful OPAQUE login. Distinct /// Distinct from `opaque_registered`: an admin can invalidate the
/// from `opaque_registered`: an admin can invalidate the envelope /// envelope leaving the user registered=false but with historical
/// (`clear_registration`) leaving the user registered=false but /// migrated=true.
/// with a historical migrated=true; the SPA's admin table shows
/// both so this operational nuance is visible.
#[serde(default)]
pub opaque_migrated: bool, pub opaque_migrated: bool,
} }
impl From<UserListEntry> for AdminUserSummaryDto { /// Self view — everything the caller may see about themselves.
fn from(entry: UserListEntry) -> Self { /// Returned by `/api/auth/me` and by every `AuthResponseDto` path
Self { /// (login / refresh / OIDC callback / magic-link redemption).
id: entry.id.to_string(), ///
username: entry.username, /// Composed on top of [`FullUserDto`] so `/me` and `/admin/users` share
email: entry.email, /// the SAME "full profile" contract for the fields both need — new
role: entry.role.to_string(), /// self+admin-visible fields go on `FullUserDto` and both endpoints get
storage_quota_bytes: entry.storage_quota_bytes, /// them together. Fields here are pure self-scoped state: preferences,
storage_used_bytes: entry.storage_used_bytes, /// session-scoped flags, and caller-scoped permissions.
last_login_at: entry.last_login_at, #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
active: entry.active, pub struct SelfUserDto {
federation_kind: entry.federation_kind, /// Full profile — same shape as one row of `/api/admin/users`.
federation_issuer: entry.federation_issuer, pub full: FullUserDto,
is_external: entry.is_external, /// Opaque UI preferences bag — my own UI state. Cross-device store
has_password: entry.has_password, /// for pure UI toggles (view mode, sidebar collapse, hide dotfiles,
opaque_registered: entry.opaque_registered, /// …). The server never inspects the contents. Always present on
opaque_migrated: entry.opaque_migrated, /// 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<User> for UserDto { impl PublicUserDto {
fn from(user: User) -> Self { /// Construct a `PublicUserDto` from a `User` entity + an explicit
// `user` is owned and dropped here, so every owned field is MOVED out /// `is_online` signal.
// via `into_parts` rather than cloned through the borrowing accessors — ///
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and /// **Why not `From<User>`?** The `User` entity models a row in
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin /// `auth.users`; `is_online` is a cross-table lookup on
// user listing (benches/ROUND20.md §A2). The two derived values read the /// `auth.sessions` (see the EXISTS subquery in
// entity before the move. /// `list_users_with_derived_flags` and `get_user_with_derived_flags`
/// on the user repo). A `From<User>` 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 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<User> 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(); let p = user.into_parts();
Self { Self {
id: p.id.to_string(), id: p.id.to_string(),
username: p.username, username: p.username,
email: p.email, email: p.email,
role, role,
storage_quota_bytes: p.storage_quota_bytes, image: p.image,
storage_used_bytes: p.storage_used_bytes, is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
is_online,
}
}
}
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, created_at: p.created_at,
updated_at: p.updated_at, updated_at: p.updated_at,
last_login_at: p.last_login_at, last_login_at: p.last_login_at,
active: p.active, active: p.active,
// NULL on both fields for local users (no federation wired). storage_quota_bytes: p.storage_quota_bytes,
// FE predicates use `!!federation_kind` for "is federated?" — storage_used_bytes: p.storage_used_bytes,
// no "local" sentinel string; the null tells the whole story. has_password: flags.has_password,
federation_kind: p.federation_kind.map(|k| k.as_str().to_string()), opaque_registered: flags.opaque_registered,
federation_issuer: p.federation_issuer, opaque_migrated: flags.opaque_migrated,
image: p.image, }
}
}
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, 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<User>` runs. Other UserDto emitters leave
// this at `false` (they lack session context).
is_dpop_bound: false,
} }
} }
} }
// ────────────────────────────────────────────────────────────────────────
// End of three-layer user DTO family.
// ────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct LoginDto { pub struct LoginDto {
/// Identifier the user typed. Accepts BOTH a username (no `@`) and /// Identifier the user typed. Accepts BOTH a username (no `@`) and
@@ -425,7 +420,12 @@ impl UpdateProfileDto {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthResponseDto { 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 access_token: String,
pub refresh_token: String, pub refresh_token: String,
pub token_type: String, pub token_type: String,
@@ -562,7 +562,7 @@ pub struct OidcProviderInfoDto {
/// users JIT-provisioned via this IdP. /// users JIT-provisioned via this IdP.
/// ///
/// Populated so the frontend can resolve display: when /// 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 /// `provider_name` as the human-friendly label (avoids showing raw
/// issuer URLs like `https://sso.example.com/realms/main` in the /// issuer URLs like `https://sso.example.com/realms/main` in the
/// admin badge / profile view). Falls back to the raw issuer when /// admin badge / profile view). Falls back to the raw issuer when
@@ -606,3 +606,142 @@ pub struct OidcUserInfoDto {
pub name: Option<String>, pub name: Option<String>,
pub groups: Vec<String>, pub groups: Vec<String>,
} }
#[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"
);
}
}
}
+30 -10
View File
@@ -3,7 +3,6 @@ use crate::domain::entities::app_password::AppPassword;
use crate::domain::entities::device_code::DeviceCode; use crate::domain::entities::device_code::DeviceCode;
use crate::domain::entities::session::Session; use crate::domain::entities::session::Session;
use crate::domain::entities::user::User; use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -123,6 +122,36 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Gets a user by ID /// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> Result<User, DomainError>; async fn get_user_by_id(&self, id: Uuid) -> Result<User, DomainError>;
/// 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 /// Batch-loads users by id. Order is unspecified; missing ids are
/// silently dropped. Used by group-recipient expansion in /// silently dropped. Used by group-recipient expansion in
/// `RecipientNotificationService` to avoid N+1 lookups when notifying /// `RecipientNotificationService` to avoid N+1 lookups when notifying
@@ -164,15 +193,6 @@ pub trait UserStoragePort: Send + Sync + 'static {
include_external: bool, include_external: bool,
) -> Result<Vec<User>, DomainError>; ) -> Result<Vec<User>, 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<Vec<UserListEntry>, DomainError>;
/// Searches users by username or email (SQL ILIKE) with a limit. /// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`. /// See [`list_users`] for the meaning of `include_external`.
async fn search_users( async fn search_users(
@@ -1,6 +1,6 @@
use crate::application::dtos::user_dto::{ use crate::application::dtos::user_dto::{
AdminUserSummaryDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, PublicUserDto, RefreshTokenDto,
RegisterDto, UpgradeToInternalDto, UserDto, RegisterDto, SelfUserDto, UpgradeToInternalDto,
}; };
use crate::application::ports::auth_ports::{ use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
@@ -319,11 +319,11 @@ pub enum OidcCallbackResult {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum RegisterResult { pub enum RegisterResult {
/// Boxed to avoid the `large_enum_variant` clippy warning — /// 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 /// so a heap-pointer indirection keeps the enum's stack size
/// small. `register` is called once per request; the /// small. `register` is called once per request; the
/// allocation cost is negligible. /// allocation cost is negligible.
Created(Box<UserDto>), Created(Box<PublicUserDto>),
UsernameTaken, UsernameTaken,
EmailTaken, EmailTaken,
} }
@@ -876,8 +876,9 @@ impl AuthApplicationService {
is_external = false, is_external = false,
"🛂 user registered", "🛂 user registered",
); );
Ok(RegisterResult::Created(Box::new(UserDto::from( Ok(RegisterResult::Created(Box::new(PublicUserDto::new(
created_user, created_user,
false,
)))) ))))
} }
@@ -894,7 +895,7 @@ impl AuthApplicationService {
username: String, username: String,
email: String, email: String,
password: String, password: String,
) -> Result<UserDto, DomainError> { ) -> Result<PublicUserDto, DomainError> {
// Validate username // Validate username
if username.len() < 3 || username.len() > 254 { if username.len() < 3 || username.len() > 254 {
return Err(DomainError::new( return Err(DomainError::new(
@@ -981,7 +982,7 @@ impl AuthApplicationService {
username, username,
created_user.id() created_user.id()
); );
Ok(UserDto::from(created_user)) Ok(PublicUserDto::new(created_user, false))
} }
pub async fn login( pub async fn login(
@@ -1320,12 +1321,16 @@ impl AuthApplicationService {
session = session.with_dpop_jkt(jkt); 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?; self.session_storage.create_session(session).await?;
// Authentication response // 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 { Ok(AuthResponseDto {
user: UserDto::from(user), user: user_dto,
access_token, access_token,
refresh_token, refresh_token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
@@ -1334,6 +1339,69 @@ 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<SelfUserDto, DomainError> {
// 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<SelfUserDto, DomainError> {
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 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, /// Read `force_password_change_at_next_login` for the given user,
/// with fail-open semantics on repo error (returns `false` and /// with fail-open semantics on repo error (returns `false` and
/// logs a warn). Every callsite that builds an `AuthResponseDto` /// logs a warn). Every callsite that builds an `AuthResponseDto`
@@ -1586,22 +1654,29 @@ impl AuthApplicationService {
let access_token = let access_token =
self.token_service self.token_service
.generate_access_token(&user, Some(session.id()), None)?; .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?; self.session_storage.create_session(session).await?;
tracing::info!( tracing::info!(
target: "audit", target: "audit",
event = "magic_link.redeemed", event = "magic_link.redeemed",
user_id = %user.id(), user_id = %user_id,
username = %user.display_for_audit(), username = %user_display,
is_external = user.is_external(), is_external = is_external,
resource_kind = ?mlt.resource_kind(), resource_kind = ?mlt.resource_kind(),
resource_id = ?mlt.resource_id(), resource_id = ?mlt.resource_id(),
cross_browser_confirmed = cross_browser_confirmed, 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 { let auth = AuthResponseDto {
user: UserDto::from(user), user: user_dto,
access_token, access_token,
refresh_token, refresh_token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
@@ -1789,6 +1864,12 @@ impl AuthApplicationService {
session.dpop_jkt(), 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 self.session_storage
.rotate_session(session.id(), new_session) .rotate_session(session.id(), new_session)
.await?; .await?;
@@ -1798,9 +1879,9 @@ impl AuthApplicationService {
// initial login. The SPA's post-refresh flow (silent, on // initial login. The SPA's post-refresh flow (silent, on
// its own timer) can then route the user to change-password // its own timer) can then route the user to change-password
// without waiting for an explicit re-login. // 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 { Ok(AuthResponseDto {
user: UserDto::from(user), user: user_dto,
access_token, access_token,
refresh_token: new_refresh_token, refresh_token: new_refresh_token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
@@ -2025,7 +2106,7 @@ impl AuthApplicationService {
&self, &self,
caller_id: Uuid, caller_id: Uuid,
dto: UpgradeToInternalDto, dto: UpgradeToInternalDto,
) -> Result<UserDto, DomainError> { ) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(caller_id).await?; let mut user = self.user_storage.get_user_by_id(caller_id).await?;
// Precondition: caller is currently external. Fast-path 409 so // Precondition: caller is currently external. Fast-path 409 so
@@ -2126,7 +2207,7 @@ impl AuthApplicationService {
lc.dispatch_upgraded_to_internal(&updated).await; lc.dispatch_upgraded_to_internal(&updated).await;
} }
Ok(UserDto::from(updated)) Ok(PublicUserDto::new(updated, false))
} }
/// Admin-driven external → internal promotion. /// Admin-driven external → internal promotion.
@@ -2155,7 +2236,7 @@ impl AuthApplicationService {
&self, &self,
admin_id: Uuid, admin_id: Uuid,
target_id: Uuid, target_id: Uuid,
) -> Result<UserDto, DomainError> { ) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(target_id).await?; let mut user = self.user_storage.get_user_by_id(target_id).await?;
if !user.is_external() { if !user.is_external() {
@@ -2237,7 +2318,7 @@ impl AuthApplicationService {
"👮🏻‍♂️ external user promoted to internal by admin", "👮🏻‍♂️ external user promoted to internal by admin",
); );
Ok(UserDto::from(updated)) Ok(PublicUserDto::new(updated, false))
} }
/// `keep_session_id` — when `Some`, revoke every OTHER session for /// `keep_session_id` — when `Some`, revoke every OTHER session for
@@ -2492,9 +2573,9 @@ impl AuthApplicationService {
Ok(()) Ok(())
} }
pub async fn get_user(&self, user_id: Uuid) -> Result<UserDto, DomainError> { pub async fn get_user(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?; let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user)) Ok(PublicUserDto::new(user, false))
} }
/// Cached, image-free lookup of the caller's authorization flags /// Cached, image-free lookup of the caller's authorization flags
@@ -2643,7 +2724,7 @@ impl AuthApplicationService {
caller_id: Uuid, caller_id: Uuid,
dto: crate::application::dtos::user_dto::UpdateProfileDto, dto: crate::application::dtos::user_dto::UpdateProfileDto,
locale_registry: &crate::common::locale::LocaleRegistry, locale_registry: &crate::common::locale::LocaleRegistry,
) -> Result<UserDto, DomainError> { ) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(caller_id).await?; let mut user = self.user_storage.get_user_by_id(caller_id).await?;
// For OIDC-managed users, refuse the patch ONLY when it touches // For OIDC-managed users, refuse the patch ONLY when it touches
@@ -2819,7 +2900,7 @@ impl AuthApplicationService {
if changed.is_empty() && ui_prefs_patch.is_none() { if changed.is_empty() && ui_prefs_patch.is_none() {
// No-op — return the current user without a DB write. // No-op — return the current user without a DB write.
return Ok(UserDto::from(user)); return Ok(PublicUserDto::new(user, false));
} }
// Persist the typed-field changes first (if any). Skip the // Persist the typed-field changes first (if any). Skip the
@@ -2850,11 +2931,11 @@ impl AuthApplicationService {
// Refetch so the returned DTO reflects the merged JSONB bag // Refetch so the returned DTO reflects the merged JSONB bag
// (the in-memory `user` above holds the pre-merge value). // (the in-memory `user` above holds the pre-merge value).
let refreshed = self.user_storage.get_user_by_id(caller_id).await?; let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
Ok(UserDto::from(refreshed)) Ok(PublicUserDto::new(refreshed, false))
} }
// Alias for consistency with handler method // Alias for consistency with handler method
pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<UserDto, DomainError> { pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
self.get_user(user_id).await self.get_user(user_id).await
} }
@@ -2871,6 +2952,26 @@ impl AuthApplicationService {
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await 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 /// Login-style identifier lookup: dispatches on `@` in the input
/// (email path when present, username path when not), identical /// (email path when present, username path when not), identical
/// to `login()`'s dispatch. Exposed so the OPAQUE login handler /// to `login()`'s dispatch. Exposed so the OPAQUE login handler
@@ -2927,12 +3028,23 @@ impl AuthApplicationService {
target_id: Uuid, target_id: Uuid,
expose_system_users: bool, expose_system_users: bool,
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> { ) -> Result<PublicUserDto, DomainError> {
// (1) Self — a single fetch suffices (the check compares the input // (1) Self — a single fetch suffices (the check compares the input
// UUIDs, so the target read is never needed on this path). // 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 { if caller_id == target_id {
let caller = self.user_storage.get_user_by_id(caller_id).await?; let (caller, flags) = self
return Ok(UserDto::from(caller)); .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 // Caller and target are independent point reads (the self-case already
@@ -2940,16 +3052,26 @@ impl AuthApplicationService {
// overlap them with `join!` instead of two serial round-trips. // overlap them with `join!` instead of two serial round-trips.
// `caller_res?` first preserves the caller-error precedence of the old // `caller_res?` first preserves the caller-error precedence of the old
// sequential form. (benches/ROUND23.md §P1) // 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!( let (caller_res, target_res) = tokio::join!(
self.user_storage.get_user_by_id(caller_id), 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?; let caller = caller_res?;
// Anti-enumeration: NotFound for everything that doesn't pass. // Anti-enumeration: NotFound for everything that doesn't pass.
// Convert a real NotFound on `target` to the same anonymous 404, // Convert a real NotFound on `target` to the same anonymous 404,
// so existence isn't leaked through differential responses. // 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, Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => { Err(e) if e.kind == ErrorKind::NotFound => {
tracing::info!( tracing::info!(
@@ -2993,7 +3115,7 @@ impl AuthApplicationService {
})?; })?;
if related.is_some() { if related.is_some() {
return Ok(UserDto::from(target)); return Ok(PublicUserDto::new(target, target_flags.is_online));
} }
// (3) External callers stop here — no directory enumeration. // (3) External callers stop here — no directory enumeration.
@@ -3021,12 +3143,12 @@ impl AuthApplicationService {
// (4) Internal target + system-address-book exposed: already public. // (4) Internal target + system-address-book exposed: already public.
if !target.is_external() && expose_system_users { if !target.is_external() && expose_system_users {
return Ok(UserDto::from(target)); return Ok(PublicUserDto::new(target, target_flags.is_online));
} }
// (5) Admin caller: always visible. // (5) Admin caller: always visible.
if caller.role() == UserRole::Admin { if caller.role() == UserRole::Admin {
return Ok(UserDto::from(target)); return Ok(PublicUserDto::new(target, target_flags.is_online));
} }
// (6) No relationship — anti-enumeration NotFound. // (6) No relationship — anti-enumeration NotFound.
@@ -3079,7 +3201,7 @@ impl AuthApplicationService {
username: &str, username: &str,
expose_system_users: bool, expose_system_users: bool,
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> { ) -> Result<PublicUserDto, DomainError> {
let target = match self.user_storage.get_user_by_username(username).await { let target = match self.user_storage.get_user_by_username(username).await {
Ok(u) => u, Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => { Err(e) if e.kind == ErrorKind::NotFound => {
@@ -3106,9 +3228,9 @@ impl AuthApplicationService {
} }
// New method to get user by username - needed for admin user handling // New method to get user by username - needed for admin user handling
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> { pub async fn get_user_by_username(&self, username: &str) -> Result<PublicUserDto, DomainError> {
let user = self.user_storage.get_user_by_username(username).await?; let user = self.user_storage.get_user_by_username(username).await?;
Ok(UserDto::from(user)) Ok(PublicUserDto::new(user, false))
} }
// Method to count how many admin users exist in the system // Method to count how many admin users exist in the system
@@ -3125,42 +3247,46 @@ impl AuthApplicationService {
/// out so that internal-user surfaces — system address book, OCS /// out so that internal-user surfaces — system address book, OCS
/// sharee search, etc. — never expose external identities. Admin /// sharee search, etc. — never expose external identities. Admin
/// surfaces that need the full list should call /// 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, offset: i64) -> Result<Vec<UserDto>, DomainError> { pub async fn list_users(
let users = self.user_storage.list_users(limit, offset, false).await?;
Ok(users.into_iter().map(UserDto::from).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<A: AuthorizationEngine>(
&self, &self,
authorization: &A,
caller_id: Uuid,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<UserDto>, DomainError> { ) -> Result<Vec<PublicUserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?; let users = self.user_storage.list_users(limit, offset, false).await?;
let users = self.user_storage.list_users(limit, offset, true).await?; Ok(users
Ok(users.into_iter().map(UserDto::from).collect()) .into_iter()
.map(|u| PublicUserDto::new(u, false))
.collect())
} }
/// Admin-only compact listing. The detail endpoint retains the complete /// Admin-only user listing. Returns `Vec<FullUserDto>` — same
/// [`UserDto`]; this path projects only what the management table renders so /// `FullUserDto` shape [`SelfUserDto`] embeds, so the FE reads
/// PostgreSQL never detoasts or transfers avatars/preferences for a page. /// 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). 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<A: AuthorizationEngine>( pub async fn list_user_summaries_including_external_with_perms<A: AuthorizationEngine>(
&self, &self,
authorization: &A, authorization: &A,
caller_id: Uuid, caller_id: Uuid,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<AdminUserSummaryDto>, DomainError> { ) -> Result<Vec<FullUserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?; self.require_admin_caller(authorization, caller_id).await?;
let users = self let rows = self
.user_storage .user_storage
.list_user_summaries(limit, offset, true) .list_users_with_derived_flags(limit, offset, true)
.await?; .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. /// Service-layer gate for administrator-scoped user-directory operations.
@@ -3183,9 +3309,16 @@ impl AuthApplicationService {
} }
/// Searches internal users only. See [`list_users`] for the rationale. /// Searches internal users only. See [`list_users`] for the rationale.
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> { pub async fn search_users(
&self,
query: &str,
limit: i64,
) -> Result<Vec<PublicUserDto>, DomainError> {
let users = self.user_storage.search_users(query, limit, false).await?; let users = self.user_storage.search_users(query, limit, false).await?;
Ok(users.into_iter().map(UserDto::from).collect()) Ok(users
.into_iter()
.map(|u| PublicUserDto::new(u, false))
.collect())
} }
/// Username-only search for the NC sharee autocomplete: identical /// Username-only search for the NC sharee autocomplete: identical
@@ -3215,8 +3348,8 @@ impl AuthApplicationService {
// `interfaces/api/routes.rs::admin_router`) — but every admin // `interfaces/api/routes.rs::admin_router`) — but every admin
// method here still calls `require_admin_caller` as a // method here still calls `require_admin_caller` as a
// defense-in-depth check, matching the pattern // defense-in-depth check, matching the pattern
// `list_users_including_external_with_perms` established. If a // `list_user_summaries_including_external_with_perms` established.
// handler is ever wired outside the /admin subtree, the AuthZ // If a handler is ever wired outside the /admin subtree, the AuthZ
// still holds. // still holds.
/// List sessions for the admin panel. `user_id_filter = Some(uuid)` /// List sessions for the admin panel. `user_id_filter = Some(uuid)`
@@ -3291,7 +3424,7 @@ impl AuthApplicationService {
pub async fn admin_create_user( pub async fn admin_create_user(
&self, &self,
dto: crate::application::dtos::settings_dto::AdminCreateUserDto, dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
) -> Result<UserDto, DomainError> { ) -> Result<FullUserDto, DomainError> {
// Validate username length // Validate username length
if dto.username.len() < 3 || dto.username.len() > 254 { if dto.username.len() < 3 || dto.username.len() > 254 {
return Err(DomainError::new( return Err(DomainError::new(
@@ -3449,7 +3582,16 @@ impl AuthApplicationService {
created.id(), created.id(),
created.is_external() created.is_external()
); );
Ok(UserDto::from(created)) // 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. /// Admin-only: reset a user's password.
@@ -3545,10 +3687,20 @@ impl AuthApplicationService {
Ok(()) Ok(())
} }
/// Get a single user by ID (for admin panel) /// Get a single user by ID (for admin panel).
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<UserDto, DomainError> { ///
let user = self.user_storage.get_user_by_id(user_id).await?; /// Returns `FullUserDto` — same shape as one row of
Ok(UserDto::from(user)) /// `/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<FullUserDto, DomainError> {
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). /// Delete a user by ID (admin only).
@@ -4654,11 +4806,17 @@ impl AuthApplicationService {
let access_token = let access_token =
self.token_service self.token_service
.generate_access_token(&user, Some(session.id()), None)?; .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?; 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 { let auth_response = AuthResponseDto {
user: UserDto::from(user), user: user_dto,
access_token, access_token,
refresh_token, refresh_token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
+1 -1
View File
@@ -207,7 +207,7 @@ pub struct User {
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` / /// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning /// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
/// them through the borrowing accessors — notably `image` (a data URI up to /// 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). /// (benches/ROUND20.md §A2).
pub struct UserParts { pub struct UserParts {
pub id: Uuid, pub id: Uuid,
+42 -45
View File
@@ -1,6 +1,5 @@
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::user::{User, UserRole};
use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -26,49 +25,26 @@ pub enum UserRepositoryError {
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>; pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
/// Narrow projection for user-directory tables that do not need secrets, /// DB-computed booleans about a user that aren't fields on the
/// profile pictures, or the cross-device UI-preferences document. /// [`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).
/// ///
/// The full [`User`] row intentionally carries all of those fields for account /// Not "admin-only" — every field ends up on `FullUserDto`, which
/// detail and the system address book. Reusing it for the paginated admin /// both admin AND self read. The name reflects "derived from the DB
/// table made PostgreSQL detoast and transfer an avatar of up to 512 KiB per /// row, not intrinsic to the User entity".
/// 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 /// See `docs/plan/userdto-refactor.md` for the design; this type
/// full-row field from silently returning to that hot path. /// replaced the earlier `UserListEntry` narrow projection as of P6.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Copy)]
pub struct UserListEntry { pub struct UserDerivedFlags {
pub id: Uuid,
pub username: Option<String>,
pub email: String,
pub role: UserRole,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub federation_kind: Option<String>,
pub federation_issuer: Option<String>,
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, 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, 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, pub opaque_migrated: bool,
pub is_online: bool,
} }
// Conversion from UserRepositoryError to DomainError // Conversion from UserRepositoryError to DomainError
@@ -94,6 +70,20 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by ID /// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>; async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>;
/// 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 /// Batch-loads a set of users by id, preserving no particular order
/// and silently skipping ids that don't match any row. Caller is /// and silently skipping ids that don't match any row. Caller is
/// responsible for de-duplicating the input vec. Returns an empty /// responsible for de-duplicating the input vec. Returns an empty
@@ -152,15 +142,22 @@ pub trait UserRepository: Send + Sync + 'static {
include_external: bool, include_external: bool,
) -> UserRepositoryResult<Vec<User>>; ) -> UserRepositoryResult<Vec<User>>;
/// Lists the columns needed by compact user-management tables. Unlike /// Paginated admin user listing — full `User` entity + the derived
/// [`Self::list_users`], this never fetches password hashes, OIDC subjects, /// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
/// avatars, names, locale state, or UI preferences. /// SELECT. Called by the admin service to build
async fn list_user_summaries( /// `Vec<FullUserDto>` 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, &self,
limit: i64, limit: i64,
offset: i64, offset: i64,
include_external: bool, include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>>; ) -> UserRepositoryResult<Vec<(User, UserDerivedFlags)>>;
/// Searches users by username or email (SQL ILIKE) with a limit. /// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`. /// See [`list_users`] for the meaning of `include_external`.
@@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::UserStoragePort;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserFlags, UserRole}; use crate::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::user_repository::{ use crate::domain::repositories::user_repository::{
StorageStats, UserListEntry, UserRepository, UserRepositoryError, UserRepositoryResult, StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult,
}; };
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction; use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
@@ -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<String> = 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::<Option<String>, _>("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::<serde_json::Value, _>("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 /// Gets a user by username
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> { async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
let row = sqlx::query( let row = sqlx::query(
@@ -835,51 +919,47 @@ impl UserRepository for UserPgRepository {
Ok(users) Ok(users)
} }
async fn list_user_summaries( async fn list_users_with_derived_flags(
&self, &self,
limit: i64, limit: i64,
offset: i64, offset: i64,
include_external: bool, include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>> { ) -> UserRepositoryResult<
let rows = sqlx::query_as::< Vec<(
_, User,
( crate::domain::repositories::user_repository::UserDerivedFlags,
Uuid, )>,
Option<String>, > {
String, // Full `User` column set (matches `get_user_by_id`) + the four
String, // derived booleans (IS-NOT-NULL for auth capability, EXISTS for
i64, // `is_online`) in one SELECT. Same rationale as the single-user
i64, // `get_user_with_derived_flags` variant. Widened over the older
Option<chrono::DateTime<chrono::Utc>>, // `list_user_summaries` projection because the FE now consumes
bool, // the full user profile from these rows (killing the per-row
Option<String>, // `/api/users/{id}` fetch the admin table used to fire for
Option<String>, // avatars — see docs/plan/userdto-refactor.md § N+1).
bool, //
bool, // `interval` bound as `$4` seconds
bool, // (`ONLINE_WINDOW.as_secs_f64()`), same pattern as
bool, // `session_liveness_gauges.rs` and `get_user_with_derived_flags`.
), let rows = sqlx::query(
>(
// 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).
r#" r#"
SELECT SELECT
id, username, email, role::text, id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes, storage_quota_bytes, storage_used_bytes,
last_login_at, active, created_at, updated_at, last_login_at, active,
federation_kind, federation_issuer, is_external, federation_kind, federation_issuer, federation_subject, image, is_external,
(password_hash IS NOT NULL) AS has_password, 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_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,
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 FROM auth.users
WHERE ($3 OR is_external = FALSE) WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
@@ -889,49 +969,57 @@ impl UserRepository for UserPgRepository {
.bind(limit) .bind(limit)
.bind(offset) .bind(offset)
.bind(include_external) .bind(include_external)
.fetch_all(self.pool.as_ref()) .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
.fetch_all(&*self.pool)
.await .await
.map_err(Self::map_sqlx_error)?; .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 Ok(rows
.into_iter() .into_iter()
.map( .map(|row| {
|( let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
id, let role = match role_str.as_deref() {
username, Some("admin") => UserRole::Admin,
email, _ => UserRole::User,
};
let user = User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
role, role,
storage_quota_bytes, row.get("storage_quota_bytes"),
storage_used_bytes, row.get("storage_used_bytes"),
last_login_at, row.get("created_at"),
active, row.get("updated_at"),
federation_kind, row.get("last_login_at"),
federation_issuer, row.get("active"),
is_external, row.get::<Option<String>, _>("federation_kind")
has_password, .as_deref()
opaque_registered, .and_then(crate::domain::entities::user::FederationKind::parse),
opaque_migrated, row.get("federation_issuer"),
)| UserListEntry { row.get("federation_subject"),
id, row.get("image"),
username, row.get("is_external"),
email, row.get("given_name"),
role: if role == "admin" { row.get("family_name"),
UserRole::Admin row.get("email_verified_at"),
} else { row.get("preferred_locale"),
UserRole::User row.get("notify_on_share"),
}, row.get::<serde_json::Value, _>("ui_preferences"),
storage_quota_bytes, );
storage_used_bytes, let flags = crate::domain::repositories::user_repository::UserDerivedFlags {
last_login_at, has_password: row.get("has_password_flag"),
active, opaque_registered: row.get("opaque_registered"),
federation_kind, opaque_migrated: row.get("opaque_migrated"),
federation_issuer, is_online: row.get("is_online"),
is_external, };
has_password, (user, flags)
opaque_registered, })
opaque_migrated,
},
)
.collect()) .collect())
} }
@@ -1302,6 +1390,21 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from) .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<Uuid>) -> Result<Vec<User>, DomainError> { async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError> {
UserRepository::get_users_by_ids(self, ids) UserRepository::get_users_by_ids(self, ids)
.await .await
@@ -1356,13 +1459,19 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from) .map_err(DomainError::from)
} }
async fn list_user_summaries( async fn list_users_with_derived_flags(
&self, &self,
limit: i64, limit: i64,
offset: i64, offset: i64,
include_external: bool, include_external: bool,
) -> Result<Vec<UserListEntry>, DomainError> { ) -> Result<
UserRepository::list_user_summaries(self, limit, offset, include_external) Vec<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)>,
DomainError,
> {
UserRepository::list_users_with_derived_flags(self, limit, offset, include_external)
.await .await
.map_err(DomainError::from) .map_err(DomainError::from)
} }
@@ -1743,26 +1852,27 @@ mod integration_tests {
) )
.await; .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 .await
.expect("compact projection query must decode"); .expect("compact projection query must decode");
assert_eq!(page.iter().map(|entry| entry.id).collect::<Vec<_>>(), ids); assert_eq!(page.iter().map(|(u, _)| u.id()).collect::<Vec<_>>(), ids);
assert_eq!(page[0].username.as_deref(), Some(username_a.as_str())); assert_eq!(page[0].0.username(), Some(username_a.as_str()));
assert_eq!(page[0].role, UserRole::Admin); assert_eq!(page[0].0.role(), UserRole::Admin);
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240); assert_eq!(page[0].0.storage_quota_bytes(), 10_737_418_240);
assert_eq!(page[1].username, None); assert_eq!(page[1].0.username(), None);
assert!(page[1].is_external); assert!(page[1].0.is_external());
assert_eq!( assert_eq!(page[1].0.federation_issuer(), Some("integration-idp"));
page[1].federation_issuer.as_deref(),
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 .await
.expect("internal compact projection query must decode"); .expect("internal compact projection query must decode");
assert!(internal.iter().any(|entry| entry.id == ids[0])); assert!(internal.iter().any(|(u, _)| u.id() == ids[0]));
assert!(internal.iter().any(|entry| entry.id == ids[2])); assert!(internal.iter().any(|(u, _)| u.id() == ids[2]));
assert!(!internal.iter().any(|entry| entry.id == ids[1])); assert!(!internal.iter().any(|(u, _)| u.id() == ids[1]));
sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)") sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)")
.bind(ids.as_slice()) .bind(ids.as_slice())
+68 -30
View File
@@ -22,7 +22,7 @@ use crate::application::dtos::settings_dto::{
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
UpdateUserRoleDto, UpdateUserRoleDto,
}; };
use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto}; use crate::application::dtos::user_dto::{FullUserDto, PublicUserDto};
use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
// JobStoreProvider is used only by the storage-migration shims below, // JobStoreProvider is used only by the storage-migration shims below,
@@ -39,16 +39,14 @@ use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
#[derive(serde::Serialize)] /// Response envelope for `GET /api/admin/users`. `users` is always
#[serde(untagged)] /// `Vec<FullUserDto>` — same shape one row of `/me`'s embedded
enum AdminUsersPayload { /// `full` block carries; the FE seeds `resolveUser` cache from
Full(Vec<UserDto>), /// `row.user` (kills the per-row `/api/users/{id}` fetch). See
Summary(Vec<AdminUserSummaryDto>), /// `docs/plan/userdto-refactor.md`.
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct AdminUsersPageResponse { struct AdminUsersPageResponse {
users: AdminUsersPayload, users: Vec<FullUserDto>,
total: i64, total: i64,
limit: i64, limit: i64,
offset: i64, offset: i64,
@@ -931,6 +929,51 @@ pub async fn get_dashboard_stats(
.await .await
.map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?; .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; use sqlx::Row;
// Per-drive-kind quota panel: // Per-drive-kind quota panel:
@@ -1015,6 +1058,9 @@ pub async fn get_dashboard_stats(
total_users: stats_row.get("total_users"), total_users: stats_row.get("total_users"),
active_users: stats_row.get("active_users"), active_users: stats_row.get("active_users"),
admin_users: stats_row.get("admin_users"), admin_users: stats_row.get("admin_users"),
external_users,
online_users,
online_sessions,
drive_usage, drive_usage,
users_over_80_percent: stats_row.get("users_over_80"), users_over_80_percent: stats_row.get("users_over_80"),
users_over_quota: stats_row.get("users_over_quota"), users_over_quota: stats_row.get("users_over_quota"),
@@ -1037,13 +1083,20 @@ pub async fn get_dashboard_stats(
// ============================================================================ // ============================================================================
/// GET /api/admin/users?limit=50&offset=0 — list all users /// GET /api/admin/users?limit=50&offset=0 — list all users
///
/// Always returns `Vec<FullUserDto>` — 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( #[utoipa::path(
get, get,
path = "/api/admin/users", path = "/api/admin/users",
params( params(
("limit" = Option<i64>, Query, description = "Max users to return (default 100, max 500)"), ("limit" = Option<i64>, Query, description = "Max users to return (default 100, max 500)"),
("offset" = Option<i64>, Query, description = "Pagination offset"), ("offset" = Option<i64>, Query, description = "Pagination offset")
("summary" = Option<bool>, Query, description = "Return the compact management-table projection")
), ),
responses( responses(
(status = 200, description = "List of users"), (status = 200, description = "List of users"),
@@ -1071,9 +1124,8 @@ pub async fn list_users(
// internal-only variant is used by system address book / sharee // internal-only variant is used by system address book / sharee
// search, where surfacing externals would leak identities. See // search, where surfacing externals would leak identities. See
// `auth_application_service::list_users` doc for the split. // `auth_application_service::list_users` doc for the split.
let users = if query.summary.unwrap_or(false) { let users = auth
AdminUsersPayload::Summary( .auth_application_service
auth.auth_application_service
.list_user_summaries_including_external_with_perms( .list_user_summaries_including_external_with_perms(
state.authorization.as_ref(), state.authorization.as_ref(),
auth_user.id, auth_user.id,
@@ -1081,21 +1133,7 @@ pub async fn list_users(
offset, offset,
) )
.await .await
.map_err(AppError::from)?, .map_err(AppError::from)?;
)
} 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)?,
)
};
let total = auth let total = auth
.auth_application_service .auth_application_service
@@ -1562,7 +1600,7 @@ pub async fn reset_user_password(
path = "/api/admin/users/{id}/promote-to-internal", path = "/api/admin/users/{id}/promote-to-internal",
params(("id" = String, Path, description = "Target user id")), params(("id" = String, Path, description = "Target user id")),
responses( 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 = 400, description = "Magic-link login is disabled on this deployment"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required (or target is OIDC-linked)"), (status = 403, description = "Admin required (or target is OIDC-linked)"),
@@ -61,7 +61,7 @@ async fn create_app_password(
} }
// Require a claimed username. NextCloud Basic Auth resolves users by // 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 // an empty string when the underlying `users.username` is NULL — the
// entity rejects empty strings on construction, so empty here is an // entity rejects empty strings on construction, so empty here is an
// unambiguous signal that the column is NULL. // unambiguous signal that the column is NULL.
+62 -46
View File
@@ -12,8 +12,8 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::{ use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto,
UserDto, UpgradeToInternalDto,
}; };
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
use crate::common::di::AppState; use crate::common::di::AppState;
@@ -89,7 +89,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
/// the `audit` channel as `auth.register` with `reason` one of /// the `audit` channel as `auth.register` with `reason` one of
/// `created`, `email_taken`, `username_taken`. /// `created`, `email_taken`, `username_taken`.
/// - **SMTP not configured**: there is no welcome-mail cover story, so /// - **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 /// apply. Anti-enumeration would just be misleading UX (telling the
/// user to check an email that will never arrive). Email-only /// user to check an email that will never arrive). Email-only
/// signup is **503** in this mode because the user would otherwise /// signup is **503** in this mode because the user would otherwise
@@ -106,7 +106,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
request_body = RegisterDto, request_body = RegisterDto,
responses( responses(
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"), (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 = 400, description = "Validation error (malformed request body)"),
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"), (status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
(status = 409, description = "Username or email already taken (SMTP not configured)"), (status = 409, description = "Username or email already taken (SMTP not configured)"),
@@ -280,7 +280,7 @@ pub async fn register(
} }
Ok(resp) Ok(resp)
} else { } 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 // log the user in directly with the password they just
// submitted. Unbox the DTO for the JSON serialisation. // submitted. Unbox the DTO for the JSON serialisation.
Ok((StatusCode::CREATED, Json(*user)).into_response()) Ok((StatusCode::CREATED, Json(*user)).into_response())
@@ -626,7 +626,7 @@ pub async fn refresh_token(
get, get,
path = "/api/auth/me", path = "/api/auth/me",
responses( responses(
(status = 200, description = "Current user profile", body = UserDto), (status = 200, description = "Current user profile", body = SelfUserDto),
(status = 401, description = "Not authenticated"), (status = 401, description = "Not authenticated"),
), ),
security(("bearerAuth" = [])), security(("bearerAuth" = [])),
@@ -652,37 +652,21 @@ pub async fn get_current_user(
// Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM // Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
// of `used_bytes` across the user's personal drives only. Shared drives // of `used_bytes` across the user's personal drives only. Shared drives
// never count against this envelope — collaborating in a team drive // never count against this envelope — collaborating in a team drive
// costs no personal bytes. The matching cap is // costs no personal bytes.
// `storage_quota_bytes` (admin-only mutation). //
let mut user = 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 .auth_application_service
.get_user_by_id(user_id) .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?; .await?;
// Overlay the cached `force_password_change` flag (see UserFlags). Ok((StatusCode::OK, Json(self_dto)))
// `From<User>` 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`)
// avoids a second DB round-trip on this hot endpoint.
if let Ok(flags) = auth_service
.auth_application_service
.get_user_flags(user_id)
.await
{
user.force_password_change = flags.force_password_change;
}
// 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();
Ok((StatusCode::OK, Json(user)))
} }
/// DTO for updating the user's profile image. /// DTO for updating the user's profile image.
@@ -837,14 +821,16 @@ pub async fn change_password(
/// self-registration policy. Refused with 403 /// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`. /// `error_type = "RegistrationDomainNotAllowed"`.
/// ///
/// Response: the updated `UserDto` (post-upgrade view — `is_external` /// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA
/// is false, `storage_quota_bytes` is set). /// 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( #[utoipa::path(
post, post,
path = "/api/auth/upgrade-to-internal", path = "/api/auth/upgrade-to-internal",
request_body = UpgradeToInternalDto, request_body = UpgradeToInternalDto,
responses( responses(
(status = 200, description = "Upgrade succeeded", body = UserDto), (status = 200, description = "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)", body = SelfUserDto),
(status = 400, description = "Password missing / too short"), (status = 400, description = "Password missing / too short"),
(status = 401, description = "Not authenticated"), (status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC user, or domain not in allowlist"), (status = 403, description = "OIDC user, or domain not in allowlist"),
@@ -855,9 +841,10 @@ pub async fn change_password(
)] )]
pub async fn upgrade_to_internal( pub async fn upgrade_to_internal(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId, auth_user: AuthUser,
Json(dto): Json<UpgradeToInternalDto>, Json(dto): Json<UpgradeToInternalDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state let auth_service = state
.auth_service .auth_service
.as_ref() .as_ref()
@@ -905,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 .auth_application_service
.upgrade_to_internal(user_id, dto) .upgrade_to_internal(user_id, dto)
.await .await
@@ -924,7 +915,11 @@ pub async fn upgrade_to_internal(
_ => AppError::from(err), _ => 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). /// Update the caller's profile (PR 24).
@@ -942,7 +937,7 @@ pub async fn upgrade_to_internal(
path = "/api/auth/me/profile", path = "/api/auth/me/profile",
request_body = crate::application::dtos::user_dto::UpdateProfileDto, request_body = crate::application::dtos::user_dto::UpdateProfileDto,
responses( responses(
(status = 200, description = "Updated profile (UserDto)", body = UserDto), (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 = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"),
(status = 401, description = "Not authenticated"), (status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC-managed profile — edit at the IdP"), (status = 403, description = "OIDC-managed profile — edit at the IdP"),
@@ -953,20 +948,39 @@ pub async fn upgrade_to_internal(
)] )]
pub async fn update_profile( pub async fn update_profile(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId, auth_user: AuthUser,
Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>, Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state let auth_service = state
.auth_service .auth_service
.as_ref() .as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; .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 .auth_application_service
.update_profile_with_perms(user_id, dto, &state.locale_registry) .update_profile_with_perms(user_id, dto, &state.locale_registry)
.await?; .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 // TODO: add utoipa
@@ -1226,7 +1240,7 @@ pub struct BackchannelLogoutForm {
path = "/api/setup", path = "/api/setup",
request_body = SetupAdminDto, request_body = SetupAdminDto,
responses( 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 = 403, description = "System already initialized"),
(status = 503, description = "Auth service not configured"), (status = 503, description = "Auth service not configured"),
), ),
@@ -1820,10 +1834,12 @@ pub async fn oidc_exchange(
tracing::info!( tracing::info!(
"OIDC token exchange successful for user: {}", "OIDC token exchange successful for user: {}",
auth_response auth_response
.user
.full
.user .user
.username .username
.as_deref() .as_deref()
.unwrap_or(&auth_response.user.email) .unwrap_or(&auth_response.user.full.user.email)
); );
// Set HttpOnly cookies for the browser // Set HttpOnly cookies for the browser
@@ -16,7 +16,7 @@ use crate::application::dtos::contact_dto::{
AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto, AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto,
GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto, 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::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::auth_application_service::AuthApplicationService;
use crate::application::services::contact_service::ContactService; 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. /// inside the virtual system address book.
/// ///
/// `given_name`/`family_name` come from OIDC standard claims at JIT /// `given_name`/`family_name` come from OIDC standard claims at JIT
/// provisioning (or NULL for password-only or pre-OIDC users). When /// provisioning (or NULL for password-only or pre-OIDC users). When
/// they're present, prefer a "First Last" full name; otherwise fall /// they're present, prefer a "First Last" full name; otherwise fall
/// back to the username (which is always present). /// 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. // Display fallback chain: given+family name → username → email.
// Username is `Option<String>` post PR 16; externals start with None. // Username is `Option<String>` post PR 16; externals start with None.
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) { 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(), photo_url: user.image.clone(),
birthday: None, birthday: None,
anniversary: None, anniversary: None,
created_at: user.created_at, // System-book contacts are VIRTUAL projections of the user
updated_at: user.updated_at, // 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, etag: user.id,
} }
} }
@@ -625,7 +625,7 @@ fn redirect_target(redemption: &MagicLinkRedemption) -> String {
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => { (Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
format!("/files/{}", 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(), _ => "/files".to_string(),
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
//! User-profile lookup for the frontend. //! 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 authenticated caller has a legitimate relationship with them.
//! The visibility rule lives in //! The visibility rule lives in
//! [`AuthApplicationService::get_user_profile`] — handlers never embed //! [`AuthApplicationService::get_user_profile`] — handlers never embed
+3 -3
View File
@@ -46,7 +46,7 @@ use crate::application::dtos::trash_dto::{
}; };
use crate::application::dtos::user_dto::{ use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto, AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, PublicUserDto, RefreshTokenDto, RegisterDto, SetupAdminDto,
}; };
use crate::application::ports::chunked_upload_ports::{ use crate::application::ports::chunked_upload_ports::{
ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto, ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto,
@@ -367,7 +367,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
PaginationDto, PaginationDto,
PaginationRequestDto, PaginationRequestDto,
// User / Auth schemas // User / Auth schemas
UserDto, PublicUserDto,
LoginDto, LoginDto,
RegisterDto, RegisterDto,
SetupAdminDto, SetupAdminDto,
@@ -583,7 +583,7 @@ mod tests {
"FolderDto", "FolderDto",
"ShareDto", "ShareDto",
"TrashedItemDto", "TrashedItemDto",
"UserDto", "PublicUserDto",
] { ] {
assert!(schemas.contains_key(name), "missing schema: {name}"); assert!(schemas.contains_key(name), "missing schema: {name}");
} }
+33 -7
View File
@@ -191,7 +191,15 @@ async fn user_provisioning_response(
return Json(ocs_err(997, "Database pool not available")).into_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( .get_user_profile_by_username_with_perms(
user.id, user.id,
&userid, &userid,
@@ -205,9 +213,27 @@ async fn user_provisioning_response(
return Json(ocs_err(404, "User not found")).into_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 // Determine groups based on role
let groups = if user_dto.role == "admin" { let groups = if user_dto.user.role == "admin" {
vec!["admin", "users"] vec!["admin", "users"]
} else { } else {
vec!["users"] vec!["users"]
@@ -235,7 +261,7 @@ async fn user_provisioning_response(
// Fetch quota from storage usage service // Fetch quota from storage usage service
let quota: (i64, i64) = match state.storage_usage_service.as_ref() { let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service 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 .await
{ {
Ok((used, total)) => (used, total), Ok((used, total)) => (used, total),
@@ -256,10 +282,10 @@ async fn user_provisioning_response(
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" }, "meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
"data": { "data": {
"enabled": user_dto.active, "enabled": user_dto.active,
"id": user_dto.username, "id": user_dto.user.username,
"display-name": user_dto.username, "display-name": user_dto.user.username,
"displayname": user_dto.username, "displayname": user_dto.user.username,
"email": user_dto.email, "email": user_dto.user.email,
"phone": "", "phone": "",
"address": "", "address": "",
"website": "", "website": "",
+3 -3
View File
@@ -56,7 +56,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
charlie_id: jsonpath "$.id" charlie_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -89,7 +89,7 @@ Authorization: Bearer {{charlie_token_v1}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_quota_bytes" == 209715200 jsonpath "$.full.storage_quota_bytes" == 209715200
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -108,7 +108,7 @@ Authorization: Bearer {{charlie_token_v1}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.role" == "admin" jsonpath "$.full.user.role" == "admin"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -19,7 +19,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.access_token" exists jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}" jsonpath "$.user.full.user.email" == "{{email}}"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.access_token" exists jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}" jsonpath "$.user.full.user.email" == "{{email}}"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -142,10 +142,10 @@ Authorization: Bearer {{alice_magic_access_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.email" == "{{email}}" jsonpath "$.full.user.email" == "{{email}}"
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
[Captures] [Captures]
admin_user_id: jsonpath "$.id" admin_user_id: jsonpath "$.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -78,7 +78,7 @@ Authorization: Bearer {{access_v2}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+9 -9
View File
@@ -52,7 +52,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -73,8 +73,8 @@ Authorization: Bearer {{bob_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == true jsonpath "$.full.user.is_external" == true
jsonpath "$.storage_quota_bytes" == 0 jsonpath "$.full.storage_quota_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -88,8 +88,8 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == false jsonpath "$.full.user.is_external" == false
jsonpath "$.storage_quota_bytes" > 0 jsonpath "$.full.storage_quota_bytes" > 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -100,8 +100,8 @@ Authorization: Bearer {{bob_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == false jsonpath "$.full.user.is_external" == false
jsonpath "$.storage_quota_bytes" > 0 jsonpath "$.full.storage_quota_bytes" > 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -179,7 +179,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
carol_user_id: jsonpath "$.id" carol_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -204,7 +204,7 @@ Authorization: Bearer {{carol_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == true jsonpath "$.full.user.is_external" == true
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -41,7 +41,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" 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 HTTP 200
[Captures] [Captures]
bob_token: jsonpath "$.access_token" bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id" bob_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -24,7 +24,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
token: jsonpath "$.access_token" token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
[Asserts] [Asserts]
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer" jsonpath "$.token_type" == "Bearer"
@@ -344,7 +344,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
bob_token: jsonpath "$.access_token" 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. # Step 17 — Bob's book listing does NOT include Alice's book.
+1 -1
View File
@@ -71,7 +71,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id" owner_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -67,7 +67,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id" owner_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -249,7 +249,7 @@ Content-Type: application/json
HTTP * HTTP *
[Captures] [Captures]
alice_id: jsonpath "$.id" alice_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -103,7 +103,7 @@ Content-Type: application/json
HTTP * HTTP *
[Captures] [Captures]
fresh_user_id: jsonpath "$.id" fresh_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -65,7 +65,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" 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 # Provision `dp_intruder` — a second internal user used only to
@@ -91,7 +91,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
intruder_token: jsonpath "$.access_token" intruder_token: jsonpath "$.access_token"
intruder_user_id: jsonpath "$.user.id" intruder_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -60,7 +60,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id" owner_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -82,7 +82,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" 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 HTTP 200
[Captures] [Captures]
target_user_id: jsonpath "$.user.id" target_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -31,7 +31,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
alice_user_id: jsonpath "$.id" alice_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}} Authorization: Bearer {{admin_token}}
@@ -79,7 +79,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login` # Alice's first login fires `PersonalDriveLifecycleHook::on_user_login`
+5 -5
View File
@@ -64,7 +64,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
alice_user_id: jsonpath "$.id" alice_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -470,7 +470,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -657,7 +657,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
carol_user_id: jsonpath "$.id" carol_user_id: jsonpath "$.user.id"
# 24a — Owner grants Carol Owner role (Owner-creates-Owner). # 24a — Owner grants Carol Owner role (Owner-creates-Owner).
@@ -836,7 +836,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
dave_user_id: jsonpath "$.id" dave_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+36 -10
View File
@@ -23,7 +23,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}} Authorization: Bearer {{alice_token}}
@@ -226,13 +226,16 @@ Authorization: Bearer {{bob_access_token}}
HTTP 200 HTTP 200
[Asserts] [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 "$.id" == "{{bob_user_id}}"
jsonpath "$.is_external" == true jsonpath "$.is_external" == true
jsonpath "$.email" == "bob@externalcompany.com" jsonpath "$.email" == "bob@externalcompany.com"
jsonpath "$.username" not exists 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 # 11d — bob CAN look up Alice (his granter) — shared-grant relationship
# lets the external recipient resolve the sharer's display name + # lets the external recipient resolve the sharer's display name +
@@ -244,14 +247,37 @@ HTTP 200
[Asserts] [Asserts]
jsonpath "$.id" == "{{alice_user_id}}" jsonpath "$.id" == "{{alice_user_id}}"
jsonpath "$.is_external" == false 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, # `email_verified_at = NOW()` — admin fiat counts as verification,
# matching the OIDC-JIT convention. Rationale: an operator running the # matching the OIDC-JIT convention. An operator running the first-run
# first-run wizard is authoritative by construction (they set the # wizard is authoritative by construction. Without this, flipping
# password at the console on a fresh install). Without this, flipping
# `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment # `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment
# would lock the sole admin out of their own instance. The admin login # would lock the sole admin out of their own instance.
# exemption is a second layer of defense; this stamp is the primary. 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 jsonpath "$.email_verified_at" exists
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404 # 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
+2 -2
View File
@@ -19,11 +19,11 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
token: jsonpath "$.access_token" token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
[Asserts] [Asserts]
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer" jsonpath "$.token_type" == "Bearer"
jsonpath "$.user.id" isString jsonpath "$.user.full.user.id" isString
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -29,7 +29,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
@@ -57,7 +57,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
mallory_user_id: jsonpath "$.id" mallory_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+5 -5
View File
@@ -23,7 +23,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}} Authorization: Bearer {{alice_token}}
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
dave_user_id: jsonpath "$.id" dave_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}} Authorization: Bearer {{alice_token}}
@@ -54,7 +54,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
eve_user_id: jsonpath "$.id" eve_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -371,7 +371,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
adam_user_id: jsonpath "$.id" adam_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -1044,7 +1044,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
frank_user_id: jsonpath "$.id" frank_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+1 -1
View File
@@ -44,7 +44,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
henry_user_id: jsonpath "$.id" henry_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+1 -1
View File
@@ -76,7 +76,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
dora_id: jsonpath "$.id" dora_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -48,7 +48,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
jwt: jsonpath "$.access_token" jwt: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -50,5 +50,5 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.access_token" exists jsonpath "$.access_token" exists
jsonpath "$.user.username" == "bob" jsonpath "$.user.full.user.username" == "bob"
jsonpath "$.user.email" == "bob@example.com" jsonpath "$.user.full.user.email" == "bob@example.com"
+5 -5
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_jwt: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
editor_user_id: jsonpath "$.id" editor_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}} Authorization: Bearer {{admin_jwt}}
@@ -85,7 +85,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
viewer_user_id: jsonpath "$.id" viewer_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}} Authorization: Bearer {{admin_jwt}}
@@ -99,7 +99,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
outsider_user_id: jsonpath "$.id" outsider_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -434,7 +434,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
quota_owner_id: jsonpath "$.id" quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+4 -4
View File
@@ -40,7 +40,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_jwt: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
editor_user_id: jsonpath "$.id" editor_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}} Authorization: Bearer {{admin_jwt}}
@@ -79,7 +79,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
viewer_user_id: jsonpath "$.id" viewer_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -351,7 +351,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
quota_owner_id: jsonpath "$.id" quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+1 -1
View File
@@ -85,7 +85,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
ncq_owner_jwt: jsonpath "$.access_token" 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 POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{ncq_owner_jwt}} Authorization: Bearer {{ncq_owner_jwt}}
+2 -2
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" 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 HTTP 200
[Captures] [Captures]
bob_token: jsonpath "$.access_token" bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id" bob_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+18 -18
View File
@@ -55,7 +55,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
charlie_token: jsonpath "$.access_token" 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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.email" == "pr18-emailonly@example.com" jsonpath "$.full.user.email" == "pr18-emailonly@example.com"
jsonpath "$.is_external" == false jsonpath "$.full.user.is_external" == false
jsonpath "$.username" not exists jsonpath "$.full.user.username" not exists
# PR 23 — the user redeemed the welcome magic-link in Step 5b, so # 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 # email_verified_at is stamped (the click IS the proof of inbox
# control, regardless of whether the redemption went through the # control, regardless of whether the redemption went through the
# direct or cross-browser-confirm path). # direct or cross-browser-confirm path).
jsonpath "$.email_verified_at" exists jsonpath "$.full.email_verified_at" exists
[Captures] [Captures]
pr18_user_id: jsonpath "$.id" pr18_user_id: jsonpath "$.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -162,9 +162,9 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.id" == "{{pr18_user_id}}" jsonpath "$.full.user.id" == "{{pr18_user_id}}"
jsonpath "$.username" not exists jsonpath "$.full.user.username" not exists
jsonpath "$.given_name" not exists jsonpath "$.full.user.given_name" not exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -178,9 +178,9 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.given_name" == "Pee Are" jsonpath "$.full.user.given_name" == "Pee Are"
jsonpath "$.family_name" == "Eighteen" jsonpath "$.full.user.family_name" == "Eighteen"
jsonpath "$.username" not exists jsonpath "$.full.user.username" not exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -220,7 +220,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "pr18handle" jsonpath "$.full.user.username" == "pr18handle"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -263,7 +263,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.given_name" == "Pr18@Handle" jsonpath "$.full.user.given_name" == "Pr18@Handle"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -276,10 +276,10 @@ Authorization: Bearer {{pr18_access_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "pr18handle" jsonpath "$.full.user.username" == "pr18handle"
jsonpath "$.given_name" == "Pr18@Handle" jsonpath "$.full.user.given_name" == "Pr18@Handle"
jsonpath "$.family_name" == "Eighteen" jsonpath "$.full.user.family_name" == "Eighteen"
jsonpath "$.email_verified_at" exists jsonpath "$.full.email_verified_at" exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -80,7 +80,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
user_token: jsonpath "$.access_token" user_token: jsonpath "$.access_token"
user_user_id: jsonpath "$.user.id" user_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -34,7 +34,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}} Authorization: Bearer {{admin_token}}
@@ -56,7 +56,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
renee_user_id: jsonpath "$.id" renee_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
@@ -66,7 +66,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
sam_user_id: jsonpath "$.id" sam_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+6 -3
View File
@@ -75,15 +75,18 @@ log "Probe blob and thumbnail confirmed present on disk."
# subsequent trash-empty triggers garbage_collect() to remove the # subsequent trash-empty triggers garbage_collect() to remove the
# now-orphaned blob files from disk. # now-orphaned blob files from disk.
# /api/admin/users returns { users: [...], total, limit, offset } # /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") USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500")
ADMIN_USER_ID=$(echo "$USERS_JSON" \ 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" [[ -z "$ADMIN_USER_ID" || "$ADMIN_USER_ID" == "null" ]] && fail "could not resolve admin user id"
OTHER_USER_IDS=$(echo "$USERS_JSON" \ 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 OTHER_USER_COUNT=0
while IFS= read -r uid; do while IFS= read -r uid; do
+2 -2
View File
@@ -35,7 +35,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
grace_user_id: jsonpath "$.id" grace_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -268,7 +268,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
helper_user_id: jsonpath "$.id" helper_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/groups/{{engineers_id}}/members POST {{base_url}}/api/groups/{{engineers_id}}/members
+2 -2
View File
@@ -51,7 +51,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
owner_user_id: jsonpath "$.id" owner_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -209,7 +209,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
viewer_user_id: jsonpath "$.id" viewer_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+6 -6
View File
@@ -64,7 +64,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" 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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 0 jsonpath "$.full.storage_used_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -173,7 +173,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 0 jsonpath "$.full.storage_used_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -202,7 +202,7 @@ retry-interval: 200ms
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 32 jsonpath "$.full.storage_used_bytes" == 32
# Confirm the sweep agrees with the delta — both code paths must # Confirm the sweep agrees with the delta — both code paths must
@@ -217,7 +217,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 32 jsonpath "$.full.storage_used_bytes" == 32
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -247,7 +247,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 0 jsonpath "$.full.storage_used_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -174,7 +174,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
quota_owner_id: jsonpath "$.id" quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+2 -2
View File
@@ -35,7 +35,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -151,7 +151,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
wq_owner_token: jsonpath "$.access_token" 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 POST {{base_url}}/api/drives
+2 -2
View File
@@ -39,7 +39,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
@@ -65,7 +65,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -47,7 +47,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+10 -1
View File
@@ -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 // Navigate straight into the (empty) folder by ID (the route keys on folder
// id, not name) — avoids the crowded root listing and click ambiguity. // id, not name) — avoids the crowded root listing and click ambiguity.
await page.goto(`/files/${created.id}`); 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. // Now inside the folder; upload a text file by setting the hidden input.
const f = SAMPLE_FILES.text(); const f = SAMPLE_FILES.text();
await page.getByTestId('files-upload-file-input').setInputFiles({ await page.getByTestId('files-upload-file-input').setInputFiles({
+26 -26
View File
@@ -98,11 +98,11 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
# federation_kind is skip_serializing_if=Option::is_none, so a # federation_kind is skip_serializing_if=Option::is_none, so a
# local user's response OMITS the field entirely. # local user's response OMITS the field entirely.
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -202,8 +202,8 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -252,8 +252,8 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -307,9 +307,9 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Scenario 9 — unlink success (admin has a password, so the # Scenario 9 — unlink success (admin has a password, so the
@@ -326,8 +326,8 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -380,7 +380,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
# Unlink to reset state before the auto-link scenarios. # Unlink to reset state before the auto-link scenarios.
@@ -447,9 +447,9 @@ HTTP 200
[Asserts] [Asserts]
# Auto-link resolved to the pre-existing admin, NOT a fresh # Auto-link resolved to the pre-existing admin, NOT a fresh
# JIT-provisioned user. The load-bearing assertion. # JIT-provisioned user. The load-bearing assertion.
jsonpath "$.user.username" == "{{username}}" jsonpath "$.user.full.user.username" == "{{username}}"
jsonpath "$.user.federation_kind" == "oidc" jsonpath "$.user.full.federation_kind" == "oidc"
jsonpath "$.user.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.user.full.federation_issuer" == "{{oidc_issuer}}"
[Captures] [Captures]
# Fresh cookies replace the password session's; capture the # Fresh cookies replace the password session's; capture the
# new CSRF for the unlink below. # new CSRF for the unlink below.
@@ -463,9 +463,9 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Reset admin state before the next scenario (auto-link would # Reset admin state before the next scenario (auto-link would
@@ -535,7 +535,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
# Reset fake IdP state (email_verified back to true, sub back # Reset fake IdP state (email_verified back to true, sub back
@@ -599,7 +599,7 @@ X-CSRF-Token: {{autolink_csrf_token}}
HTTP 201 HTTP 201
[Captures] [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 # 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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
# Cleanup — delete the collider so later scenarios see the same # Cleanup — delete the collider so later scenarios see the same
@@ -701,8 +701,8 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.federation_kind" == "oidc" jsonpath "$.user.full.federation_kind" == "oidc"
[Captures] [Captures]
# Fresh CSRF from the OIDC session cookies — the admin CSRFs # Fresh CSRF from the OIDC session cookies — the admin CSRFs
# won't validate against these new cookies. # won't validate against these new cookies.
@@ -730,5 +730,5 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
+19 -19
View File
@@ -172,7 +172,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [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 # Snapshotted so Step 7's refresh can prove the tokens rotated
# rather than being re-issued unchanged. The refresh handler in # rather than being re-issued unchanged. The refresh handler in
# auth_handler.rs always rotates all three cookies (access JWT, # 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_refresh_token: jsonpath "$.refresh_token"
initial_csrf_token: cookie "oxicloud_csrf" initial_csrf_token: cookie "oxicloud_csrf"
[Asserts] [Asserts]
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.email" == "oidc@example.com" jsonpath "$.user.full.user.email" == "oidc@example.com"
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
# Multiple Set-Cookie headers come back as a list of values, so # Multiple Set-Cookie headers come back as a list of values, so
# `contains` only matches whole-element strings. Each cookie shows up # `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 # 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 # second OIDC flow with the same `sub` must resolve back to this
# exact user, not silently create a duplicate. # exact user, not silently create a duplicate.
oidc_user_id: jsonpath "$.id" oidc_user_id: jsonpath "$.full.user.id"
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
jsonpath "$.email" == "oidc@example.com" jsonpath "$.full.user.email" == "oidc@example.com"
# Post the federation-identity rename (docs/plan/ocm.md § Schema # Post the federation-identity rename (docs/plan/ocm.md § Schema
# rename) UserDto exposes federation_kind + federation_issuer as # rename) UserDto exposes federation_kind + federation_issuer as
# separate nullable fields. Local users have both null; OIDC users # 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 # the fake IdP (tests/oidc/fake_idp/server.js) that URL is the
# issuer published in its discovery document, which matches # issuer published in its discovery document, which matches
# `oidc_issuer` from test.env. # `oidc_issuer` from test.env.
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js) # Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js)
# pins these values and OxiCloud must persist each one verbatim during # pins these values and OxiCloud must persist each one verbatim during
# JIT provisioning (see auth_application_service.rs around line 2257). # JIT provisioning (see auth_application_service.rs around line 2257).
# A regression that drops, swaps, or truncates a claim trips here. # A regression that drops, swaps, or truncates a claim trips here.
# Note the field name flip on the API side: OIDC `picture` becomes # Note the field name flip on the API side: OIDC `picture` becomes
# UserDto.image (a URL or data URI). # UserDto.image (a URL or data URI).
jsonpath "$.given_name" == "OIDC" jsonpath "$.full.user.given_name" == "OIDC"
jsonpath "$.family_name" == "Test" jsonpath "$.full.user.family_name" == "Test"
jsonpath "$.image" == "https://example.com/oidc-test-user.png" jsonpath "$.full.user.image" == "https://example.com/oidc-test-user.png"
# Group-to-role mapping. server-with-oidc.env sets # Group-to-role mapping. server-with-oidc.env sets
# OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include # OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include
# `groups: ["admin-users"]`. The JIT path intersects the claim against # `groups: ["admin-users"]`. The JIT path intersects the claim against
# the env and promotes the new user from `user` to `admin`. A # the env and promotes the new user from `user` to `admin`. A
# regression here would silently strip (or wrongly grant) admin rights # regression here would silently strip (or wrongly grant) admin rights
# for every SSO deployment that uses group-based role mapping. # 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. # the (freshly-rotated) `oxicloud_csrf` cookie on the browser.
refreshed_csrf_token: cookie "oxicloud_csrf" refreshed_csrf_token: cookie "oxicloud_csrf"
[Asserts] [Asserts]
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
jsonpath "$.refresh_token" isString jsonpath "$.refresh_token" isString
# All three cookies must rotate. If any value were re-used, a # All three cookies must rotate. If any value were re-used, a
@@ -296,7 +296,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -412,8 +412,8 @@ HTTP 200
[Asserts] [Asserts]
# Same local id — proves the existing-user resolver matched on `sub` # Same local id — proves the existing-user resolver matched on `sub`
# (or `oidc_provider + oidc_subject`) instead of minting a new row. # (or `oidc_provider + oidc_subject`) instead of minting a new row.
jsonpath "$.user.id" == "{{oidc_user_id}}" jsonpath "$.user.full.user.id" == "{{oidc_user_id}}"
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
# Role from the prior JIT-provisioned admin survives the re-login. # Role from the prior JIT-provisioned admin survives the re-login.
# Two regressions this catches: (a) the existing-user branch wiping # Two regressions this catches: (a) the existing-user branch wiping
# the role to a default `user`; (b) the existing-user branch # 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 # IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS
# still resolves to "admin"). Either way, the role should remain # still resolves to "admin"). Either way, the role should remain
# `admin` — otherwise we have a silent admin demotion on every login. # `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 HTTP 200
[Asserts] [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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -123,10 +123,10 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [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 # Group-to-role mapping worked — this is now the admin (and the only
# user). # user).
jsonpath "$.user.role" == "admin" jsonpath "$.user.full.user.role" == "admin"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -138,7 +138,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -132,9 +132,9 @@ echo " app password minted (id=$APP_PASSWORD_ID)"
# failure path. `storage_quota_bytes == 0` is the unlimited # failure path. `storage_quota_bytes == 0` is the unlimited
# sentinel (see `check_storage_quota`); we read it back here in # sentinel (see `check_storage_quota`); we read it back here in
# case a prior test set a real value. # 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" [[ -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: # Single cleanup on exit:
# - restore admin's original storage envelope (in case Case 3 # - 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 # `current + 100` — leaves enough headroom that MKCOL passes
# (`used + 0 = used < used + 100`) while a 200 B chunk PUT # (`used + 0 = used < used + 100`) while a 200 B chunk PUT
# overflows by exactly 100 (`used + 0 + 200 > used + 100`). # 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" [[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes"
TIGHT_QUOTA=$(( CURRENT_USED + 100 )) TIGHT_QUOTA=$(( CURRENT_USED + 100 ))