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', () => {
it('call apiJson for the listing/settings reads', async () => {
await admin.listUsers(25, 0);
expect(jsonMock).toHaveBeenCalledWith(
'/api/admin/users?limit=25&offset=0&summary=true',
expect.anything()
);
expect(jsonMock).toHaveBeenCalledWith('/api/admin/users?limit=25&offset=0', expect.anything());
await admin.getDashboard();
await admin.getSmtpInfo();
await admin.getOidcSettings();
+37 -11
View File
@@ -12,7 +12,7 @@ import type {
DriveMember,
DriveMemberSubject,
DriveRole,
User
FullUser
} from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -277,19 +277,24 @@ export function revokeAdminSession(sessionId: string): Promise<void> {
// ── Users ───────────────────────────────────────────────────────────────
/** List the compact rows rendered by the management table; full account
* details remain available through {@link getUserAdmin}. */
/** List admin users — always returns `FullUser` rows. The former
* `?summary` toggle is retired; a single canonical shape carries
* the vignette + admin-visible extras the table needs. Single-user
* details still available via {@link getUserAdmin}. */
export function listUsers(limit: number, offset: number): Promise<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'
});
}
/**
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
* Returns the full `User` DTO including `storage_quota_bytes` +
* `storage_used_bytes` which the non-admin `/api/users/{id}`
* response omits for privacy.
* Returns the full `FullUser` DTO (public identity in `.user` +
* admin-visible extras like `email_verified_at` / `has_password` /
* `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
* 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.
* 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);
if (hit) return hit;
const pending = (async (): Promise<User | null> => {
const pending = (async (): Promise<FullUser | null> => {
try {
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, {
return await apiJson<FullUser>(`/api/admin/users/${encodeURIComponent(id)}`, {
credentials: 'same-origin'
});
} catch {
@@ -387,9 +392,30 @@ export interface DriveKindUsage {
}
export interface AdminDashboard {
// ── User accounts (static breakdown of auth.users) ──
// All four are counts of the same table under different
// predicates. Rendered as one grouped section on the dashboard.
total_users: number;
active_users: number;
admin_users: number;
/** Grant-only accounts (magic-link / OIDC-only / OCM recipients).
* Filtered out of `total_users` / `active_users` — those count
* operational seats. Surfaced here as its own metric because
* external-heavy deployments (public-share collab, invited-only
* shops) need the invited population at a glance. */
external_users: number;
// ── Live activity (projection over auth.sessions) ──
// Both change minute-to-minute — a whole different cadence from
// the account counts above. Rendered as a separate section on
// the dashboard with the presence-dot visual cue.
/** Distinct users behind non-revoked sessions active in the last
* 5 min. Same 5-min window as the `oxicloud_sessions_online_users`
* Prometheus gauge; single source of truth on the backend. */
online_users: number;
/** Non-revoked sessions active in the last 5 min. Ratio
* `online_sessions / online_users` is the multi-device factor
* (browser + desktop + phone). */
online_sessions: number;
server_version: string;
drive_usage: DriveKindUsage[];
auth_enabled: boolean;
+7 -7
View File
@@ -5,7 +5,7 @@
*/
import { ApiError, apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse, User } from '$lib/api/types';
import type { AuthResponse, SelfUser } from '$lib/api/types';
/**
* Best-effort parse of the backend `ErrorResponse` shape
@@ -45,7 +45,7 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' };
* Failure to build a proof (no keypair, missing WebCrypto) falls back to a
* headerless request — the server still accepts it for unbound sessions.
*/
export async function fetchMe(): Promise<User | null> {
export async function fetchMe(): Promise<SelfUser | null> {
// Build + sign a DPoP proof, send with the header, harvest any
// `DPoP-Nonce` off the response into the shared client cache
// (so the NEXT apiFetch call reuses it — no wasted round trip).
@@ -80,7 +80,7 @@ export async function fetchMe(): Promise<User | null> {
if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send();
if (res.status === 401) return null;
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
return (await res.json()) as User;
return (await res.json()) as SelfUser;
}
/**
@@ -408,7 +408,7 @@ export async function setupAdmin(email: string, password: string): Promise<void>
* failure, not an expired access token. Returns the user on success, null on
* any failure so the caller can fall through to the normal login UI.
*/
export async function exchangeOidcCode(code: string): Promise<User | null> {
export async function exchangeOidcCode(code: string): Promise<SelfUser | null> {
try {
const res = await fetch('/api/auth/oidc/exchange', {
method: 'POST',
@@ -417,7 +417,7 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
body: JSON.stringify({ code })
});
if (!res.ok) return null;
const data = (await res.json()) as { user?: User };
const data = (await res.json()) as { user?: SelfUser };
return data.user ?? null;
} catch {
return null;
@@ -463,7 +463,7 @@ export async function register(email: string, password?: string, username?: stri
* authenticated; a 401 here IS a genuine "session expired" and the
* refresh interceptor is the right response.
*/
export async function upgradeToInternal(password?: string): Promise<User> {
export async function upgradeToInternal(password?: string): Promise<SelfUser> {
const body: Record<string, unknown> = {};
if (password) body.password = password;
const res = await apiFetch('/api/auth/upgrade-to-internal', {
@@ -482,7 +482,7 @@ export async function upgradeToInternal(password?: string): Promise<User> {
message
);
}
return (await res.json()) as User;
return (await res.json()) as SelfUser;
}
export type MagicLinkResult = 'sent' | 'unavailable';
+3 -3
View File
@@ -1,7 +1,7 @@
/** Profile / account endpoints — ported from views/profile/profile.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { User } from '$lib/api/types';
import type { SelfUser } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -24,7 +24,7 @@ export interface ProfilePatch {
ui_preferences?: Record<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', {
method: 'PATCH',
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}`);
}
return (await res.json()) as User;
return (await res.json()) as SelfUser;
}
export async function changePassword(currentPw: string, newPw: string): Promise<void> {
+47 -4
View File
@@ -16,15 +16,23 @@ export interface ResolvedUser {
email: string;
image: string | null;
isExternal: boolean;
/** Presence — TRUE when the server observed a request on any of this
* user's non-revoked sessions within the last 5 min (backend
* `PublicUserDto.is_online`). Drives the presence dot overlay on
* `<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. */
interface UserDtoShape {
/** Subset of the backend `PublicUserDto` we consume here. */
interface PublicUserShape {
id: string;
username?: string | null;
email?: string | null;
image?: string | null;
is_external: boolean;
is_online?: boolean;
}
// id → in-flight/resolved lookup (the Promise is cached so concurrent callers
@@ -41,13 +49,14 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
credentials: 'same-origin'
});
if (!res.ok) return null;
const u = (await res.json()) as UserDtoShape;
const u = (await res.json()) as PublicUserShape;
return {
id: u.id,
name: u.username?.trim() || u.email || u.id,
email: u.email ?? '',
image: u.image ?? null,
isExternal: u.is_external
isExternal: u.is_external,
isOnline: u.is_online ?? false
};
} catch {
return null;
@@ -57,3 +66,37 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
cache.set(id, pending);
return pending;
}
/**
* Prime the resolver cache from data the caller already has in hand.
* When a list endpoint (e.g. `/api/admin/users`) ships full
* `PublicUser` rows, the admin page seeds this cache in its load path
* so every subsequent `resolveUser(id)` call (from `UserVignette`
* mounted per-row) hits the cache synchronously — no per-row
* `/api/users/{id}` follow-up fetch. Kills the N+1 that motivated
* widening `/api/admin/users` to include the avatar (see
* `docs/plan/userdto-refactor.md` § N+1).
*
* No-op when the id is already cached (in-flight or resolved). This
* makes seeding safe to call unconditionally — never clobbers an
* authoritative in-flight lookup with a stale seed.
*/
export function seedUser(u: {
id: string;
username?: string | null;
email: string;
image?: string | null;
is_external: boolean;
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';
/** Wire shape of `UserDto` (backend: src/application/dtos/user_dto.rs). */
export interface User {
// ─────────────────────────────────────────────────────────────────────────
// Three-layer user family — mirrors src/application/dtos/user_dto.rs.
// See docs/plan/userdto-refactor.md.
//
// `PublicUser` — public identity. Every authenticated caller may see it.
// Returned by /api/users/{id}, share responses, group
// members, magic-link invitees, recipient enrichment.
// `FullUser` — `{ user: PublicUser, ...admin+self extras }`. Returned
// as Vec by /api/admin/users; embedded in `SelfUser`.
// `SelfUser` — `{ full: FullUser, ...self-only extras }`. Returned by
// /api/auth/me and by every auth response.
//
// Adding a field? Decide by audience:
// * Any authenticated caller may see it about another user → PublicUser.
// * Only admin (about another user) AND self (about self) → FullUser.
// * Only self about themselves → SelfUser.
// ─────────────────────────────────────────────────────────────────────────
/** Public identity — 9 fields visible to any authenticated caller. */
export interface PublicUser {
id: string;
username?: string;
email: string;
role: string;
storage_quota_bytes: number;
storage_used_bytes: number;
image?: string | null;
is_external: boolean;
given_name?: string;
family_name?: string;
/** Presence — TRUE when the server observed a request on any of this
* user's non-revoked sessions within the last 5 min. Populated on
* list endpoints; single-user public paths default to `false`.
* Backwards-compat: missing on older backend builds → `false`. */
is_online?: boolean;
}
/** Full user record — public identity + all fields BOTH an admin (viewing
* another user) AND the subject themselves may see. Returned as `Vec` by
* `/api/admin/users`; embedded in `SelfUser` for `/api/auth/me`. */
export interface FullUser {
user: PublicUser;
/** IdP linkage. Load-bearing "is federated?" predicate:
* `full.federation_kind === 'oidc'`. */
federation_kind?: 'oidc' | 'ocm' | 'magic_link';
/** Authority that minted the OIDC/OCM identity — issuer URL for OIDC,
* peer domain for OCM. FE that wants a friendly label maps this
* against `OidcProviders.issuer → provider_name`. */
federation_issuer?: string;
preferred_locale?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
last_login_at?: string | null;
active: boolean;
/**
* Which trust chain minted this user's federation identity. `null`
* (omitted from wire) for local users (password / OPAQUE only).
* `"oidc" | "ocm" | "magic_link"` for federated users. Predicate:
* `!user.federation_kind` = local; `user.federation_kind === 'oidc'`
* = OIDC user. Mirrors `auth.users.federation_kind` verbatim.
*/
federation_kind?: 'oidc' | 'ocm' | 'magic_link';
/**
* Authority that minted this user's OIDC/OCM identity — issuer URL
* for OIDC (id_token `iss`), peer domain for OCM. `null` (omitted)
* for local users. FE that wants a friendly display label maps this
* against `OidcProviders.issuer → provider_name` when they match;
* shows the raw value otherwise. Renamed from the historical
* `auth_provider` (which held a display label pre-Phase-B and a
* `"local"` sentinel for non-federated users — both are gone).
*/
federation_issuer?: string;
image?: string | null;
can_edit_image: boolean;
is_external: boolean;
given_name?: string;
family_name?: string;
email_verified_at?: string;
preferred_locale?: string;
notify_on_share: boolean;
/**
* Opaque UI preferences bag. Server-side JSONB column that persists
* pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …)
* across devices. The server never inspects the contents — the SPA
* defines the keys (see `lib/stores/preferences.svelte.ts` for the
* typed view). Always an object on the wire (empty bag is `{}`,
* never `null` or missing).
*
* When PATCHing back to the server via
* `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the
* server SHALLOW-merges — only the keys present in the patch are
* touched, so partial writes from one device don't clobber
* preferences set on another. Set a key to `null` in the patch to
* delete it from the bag.
*/
ui_preferences: Record<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;
storage_quota_bytes: number;
storage_used_bytes: number;
/** TRUE when the account has a local Argon2id `password_hash` on file.
* Distinct from `federation_kind`: an OIDC-linked account can ALSO
* carry a local password (hybrid). */
has_password: boolean;
/** TRUE when the user has an OPAQUE envelope on file. Admin-visible
* rollout signal — kept off `PublicUser` so directory endpoints don't
* leak OPAQUE adoption. */
opaque_registered: boolean;
/** TRUE when the user has completed ≥1 OPAQUE login. Distinct from
* `opaque_registered` — envelope-on-file vs successful-login. */
opaque_migrated: boolean;
}
/** Fields rendered by the paginated admin table. Full account details remain
* available from the detail endpoint; this shape keeps avatars and preference
* documents off every listing page.
*
* The two OPAQUE flags below are ADMIN-ONLY signals: they surface per-user
* OPAQUE rollout progress in the admin table. The backend deliberately keeps
* them off `UserDto` (`/api/auth/me`, share-recipient DTOs, group members)
* so a non-admin can't enumerate the adoption set through third-party
* endpoints. Both optional on the wire — older backend builds omit them and
* `#[serde(default)]` maps missing → `false`. */
export type AdminUserSummary = Pick<
User,
| 'id'
| 'username'
| 'email'
| 'role'
| 'storage_quota_bytes'
| 'storage_used_bytes'
| 'last_login_at'
| 'active'
| 'federation_kind'
| 'federation_issuer'
| 'is_external'
> & {
/** TRUE = user has a server-verifiable password on file (legacy or
* admin-set). Combined with `opaque_registered` and `federation_kind`,
* the admin table derives the full auth capability set — a user with
* `has_password=false`, `opaque_registered=false` AND
* `federation_kind === undefined` (no federation) is passwordless
* (magic-link only, which is the default for externals). */
has_password?: boolean;
/** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration
* succeeded, or the user completed a manual re-registration). */
opaque_registered?: boolean;
/** TRUE = user has completed at least one successful OPAQUE login.
* Distinct from `opaque_registered` — the envelope may have been
* cleared by an admin reset while a stale migrated=true remains as
* historical signal (backend clears both atomically today, but the
* two-flag shape keeps the option open for a future policy split). */
opaque_migrated?: boolean;
};
/** Self view — everything the caller may see about themselves.
* Returned by `/api/auth/me` and every `AuthResponse` (login / refresh /
* OIDC callback / magic-link redemption ships this so the SPA's post-auth
* state matches its post-`/me` state with no UI race). */
export interface SelfUser {
full: FullUser;
/** Opaque UI-preferences bag. Cross-device store for pure UI toggles
* (view mode, sidebar collapse, hide-dotfiles, …). Server never
* inspects contents; the SPA defines the keys (see
* `lib/stores/preferences.svelte.ts`). Always an object on the wire
* — empty bag is `{}`, never `null`. PATCH via `/api/auth/me/profile`
* shallow-merges; setting a key to `null` removes it. */
ui_preferences: Record<string, unknown>;
/** Whether the user wants share-notification emails. */
notify_on_share: boolean;
/** Session-scoped: my current session is DPoP-bound. SPA reads this
* on `session.load()` to skip a redundant `/api/auth/dpop/bind` call
* (409 `already_bound` otherwise, noisy in the audit stream). */
is_dpop_bound: boolean;
/** Admin-set temp-password gate — SPA nav guard blocks everything
* but /change-password until this flips back. Cleared by a successful
* `POST /api/auth/change-password`. */
force_password_change: boolean;
/** Caller-scoped: can I edit my own avatar? `false` for OIDC users
* whose avatar comes from the IdP. Only meaningful when caller ==
* subject; nonsense on any other DTO. */
can_edit_image: boolean;
}
/** Backwards-compat alias while migrating call-sites. Prefer `PublicUser`
* for public-identity contexts (sharee, group member, invitee) or
* `SelfUser` when reading `/api/auth/me`. Delete once no consumers reference
* the bare `User` name. */
export type User = PublicUser;
export interface AdminUsersPage {
total: number;
users: AdminUserSummary[];
users: FullUser[];
}
export interface AuthResponse {
user: User;
user: SelfUser;
access_token: string;
refresh_token: string;
token_type: string;
+13 -13
View File
@@ -437,11 +437,11 @@
{ mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') }
];
const storagePct = $derived(
session.user && session.user.storage_quota_bytes > 0
? Math.min(100, (session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100)
: 0
);
const storagePct = $derived.by(() => {
const full = session.me?.full;
if (!full || full.storage_quota_bytes <= 0) return 0;
return Math.min(100, (full.storage_used_bytes / full.storage_quota_bytes) * 100);
});
const initials = $derived(userInitials(session.user?.username || session.user?.email));
@@ -655,12 +655,12 @@
<div class="storage-fill" style:width="{storagePct}%"></div>
</div>
<div class="storage-info">
{#if session.user.storage_quota_bytes > 0}
{Math.round(storagePct)}% · {formatBytes(session.user.storage_used_bytes)} / {formatBytes(
session.user.storage_quota_bytes
{#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
{Math.round(storagePct)}% · {formatBytes(session.me?.full.storage_used_bytes ?? 0)} / {formatBytes(
session.me?.full.storage_quota_bytes ?? 0
)}
{:else}
{formatBytes(session.user.storage_used_bytes)}
{formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{/if}
</div>
</div>
@@ -903,18 +903,18 @@
<div class="user-menu-storage-fill" style:width="{storagePct}%"></div>
</div>
<div class="user-menu-storage-text">
{#if session.user.storage_quota_bytes > 0}
{#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
{t(
'storage.used',
{
percentage: Math.round(storagePct),
used: formatBytes(session.user.storage_used_bytes),
total: formatBytes(session.user.storage_quota_bytes)
used: formatBytes(session.me?.full.storage_used_bytes ?? 0),
total: formatBytes(session.me?.full.storage_quota_bytes ?? 0)
},
'{{percentage}}% used ({{used}} / {{total}})'
)}
{:else}
{formatBytes(session.user.storage_used_bytes)}
{formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{/if}
</div>
</div>
+14 -3
View File
@@ -26,16 +26,27 @@ const children = createRawSnippet(() => ({
beforeEach(() => {
vi.clearAllMocks();
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',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false
},
storage_used_bytes: 10,
storage_quota_bytes: 100
}
} as never;
});
+13 -1
View File
@@ -18,7 +18,19 @@
let { icon, title, hint, error = false, children }: Props = $props();
</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 title}<p class="empty-state__title">{title}</p>{/if}
{#if hint}<p class="empty-state__hint">{hint}</p>{/if}
@@ -33,6 +33,7 @@
const label = $derived(resolved?.name ?? fallbackLabel ?? userId);
const email = $derived(resolved?.email || fallbackSublabel || '');
const isExternal = $derived(resolved?.isExternal ?? false);
const isOnline = $derived(resolved?.isOnline ?? false);
const image = $derived(resolved?.image ?? null);
const colorIndex = $derived(avatarColorIndex(userId));
const initials = $derived(userInitials(label));
@@ -50,6 +51,22 @@
<Icon name="building-circle-xmark" />
</span>
{/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 class="uv__text">
<span class="uv__name">{label}</span>
@@ -128,6 +145,27 @@
font-size: 9px;
}
/* Presence dot — top-right, symmetric with `.uv__badge` at
bottom-right so the two corners don't collide. Slightly smaller
(10x10 vs the badge's 16x16) because it's a pure signal — no
icon, no text. The 2px `--color-bg-surface` border creates a
visual gap between dot and avatar so the green pops out cleanly
regardless of avatar palette (photo, dark initials, light
initials). `box-sizing: border-box` keeps the inner circle's
green footprint at 6x6 — same visual weight the sessions-table
dot has. See `docs/plan/sessions.md` § UI. */
.uv__presence {
position: absolute;
right: -2px;
top: -2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-success-alt);
border: 2px solid var(--color-bg-surface);
box-sizing: border-box;
}
.uv__text {
display: flex;
flex-direction: column;
+20 -10
View File
@@ -69,12 +69,15 @@ const PATCH_DEBOUNCE_MS = 500;
class PreferencesStore {
/**
* The typed view of the bag. Derived from `session.user?.ui_preferences`
* so signing in / out / refresh flips it in lockstep with the session.
* The typed view of the bag. Derived from `session.me?.ui_preferences`
* (moved from public `User.ui_preferences` to `SelfUser.ui_preferences`
* as part of the three-layer UserDto refactor — the bag is self-only
* state, not something other authenticated callers should see).
* Signing in / out / refresh flips it in lockstep with the session.
* Reads pass through DEFAULTS for any missing key.
*/
private bag = $derived<Record<string, unknown>>(
(session.user?.ui_preferences as Record<string, unknown> | undefined) ?? {}
(session.me?.ui_preferences as Record<string, unknown> | undefined) ?? {}
);
// ── Typed accessors ──────────────────────────────────────────
@@ -100,11 +103,14 @@ class PreferencesStore {
* `jsonb_strip_nulls` after the merge).
*/
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 = {
...((session.user.ui_preferences as Record<string, unknown> | undefined) ?? {}),
...((session.me.ui_preferences as Record<string, unknown> | undefined) ?? {}),
...patch
};
// Strip any explicit-null locally so the derived getters see the
@@ -114,7 +120,7 @@ class PreferencesStore {
for (const [k, v] of Object.entries(patch)) {
if (v === null) delete (nextBag as Record<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
// fires collapse into a single PATCH body — matters for
@@ -131,16 +137,20 @@ class PreferencesStore {
this.pendingPatch = {};
if (Object.keys(patch).length === 0) return;
const previousUser = session.user;
// `session.user` is a derived read-through on `session.me.full.user`
// — the source of truth is `session.me: SelfUser`. Snapshot + assign
// there so the optimistic update / rollback matches the store shape
// (see `docs/plan/userdto-refactor.md` for the layering).
const previousMe = session.me;
try {
const updated = await updateProfile({ ui_preferences: patch });
session.user = updated;
session.me = updated;
} catch {
// Roll back to whatever the server last confirmed. The
// optimistic local mutation is discarded and the derived
// `hideDotfiles` / other getters snap back on the next
// reactivity tick.
session.user = previousUser;
session.me = previousMe;
ui.notify(
t('preferences.save_failed', "Couldn't save your preference. Please try again."),
'error'
+12 -5
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '$lib/api/types';
import type { SelfUser } from '$lib/api/types';
// `vi.mock` is hoisted above imports, so the spy it references must be created
// with `vi.hoisted` (a plain top-level const isn't initialised yet when the
@@ -14,7 +14,14 @@ vi.mock('$lib/api/endpoints/auth', () => ({
import { session } from './session.svelte';
const userWithUsage = (used: number) => ({ storage_used_bytes: used }) as unknown as User;
// `storage_used_bytes` moved to `FullUser` (embedded inside `SelfUser`)
// as part of the three-layer UserDto refactor
// (`docs/plan/userdto-refactor.md`). Build a minimal SelfUser shape that
// satisfies the type checker without hand-populating every field the
// production shape carries — the test only cares about the usage read
// path (`session.me.full.storage_used_bytes`).
const userWithUsage = (used: number) =>
({ full: { storage_used_bytes: used } }) as unknown as SelfUser;
describe('session.refresh', () => {
beforeEach(() => {
@@ -25,7 +32,7 @@ describe('session.refresh', () => {
it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => {
fetchMeMock.mockResolvedValue(userWithUsage(2048));
await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048);
expect(session.me?.full.storage_used_bytes).toBe(2048);
});
it('leaves the current user intact when the probe returns null', async () => {
@@ -33,7 +40,7 @@ describe('session.refresh', () => {
await session.refresh();
fetchMeMock.mockResolvedValue(null);
await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048);
expect(session.me?.full.storage_used_bytes).toBe(2048);
});
it('leaves the current user intact when the probe throws', async () => {
@@ -41,6 +48,6 @@ describe('session.refresh', () => {
await session.refresh();
fetchMeMock.mockRejectedValue(new Error('network'));
await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048);
expect(session.me?.full.storage_used_bytes).toBe(2048);
});
});
+39 -17
View File
@@ -11,17 +11,39 @@ import { setLogoutInProgress } from '$lib/api/client';
import { hasSessionHint } from '$lib/api/csrf';
import { seedNonceFromCookie } from '$lib/auth/dpop-proof';
import { drives } from '$lib/stores/drives.svelte';
import type { User } from '$lib/api/types';
import type { PublicUser, SelfUser } from '$lib/api/types';
import { ensureActiveUser } from '$lib/utils/localStoragePrefs';
/**
* Session store — the authenticated user and derived flags.
*
* Post the three-layer UserDto refactor (`docs/plan/userdto-refactor.md`),
* `/api/auth/me` returns `SelfUser` (composed:
* `SelfUser.full.user: PublicUser`). Two shorthand accessors keep every
* existing consumer readable:
*
* - `session.user` → `PublicUser` (via `me.full.user`). Every callsite
* that read `session.user.username / email / id / role / image /
* is_external / given_name / family_name / is_online` keeps working.
* - `session.me` → full `SelfUser`. New code that needs self-only or
* admin-visible fields (`has_password`, `is_dpop_bound`, `active`,
* `ui_preferences`, `federation_kind`, `last_login_at`, quotas, …)
* reads through `session.me.full.foo` or `session.me.foo`.
*/
class SessionStore {
user = $state<User | null>(null);
/** Full `/api/auth/me` payload. Null when unauthenticated. */
me = $state<SelfUser | null>(null);
loaded = $state(false);
homeFolderId = $state<string | null>(null);
homeFolderName = $state<string | null>(null);
isExternalUser = $derived(this.user?.is_external ?? false);
isAuthenticated = $derived(this.user !== null);
/** Public-identity shorthand — same fields any authenticated caller
* can see. Every legacy `session.user.foo` read (username, email, id,
* role, image, is_external, given_name, family_name, is_online) still
* works via this derived accessor. */
user = $derived<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`
* on this account — an admin picked a temporary password and the
@@ -33,7 +55,7 @@ class SessionStore {
* flag (or a malformed `/me` response) doesn't accidentally
* quarantine every user.
*/
mustChangePassword = $derived(this.user?.force_password_change === true);
mustChangePassword = $derived(this.me?.force_password_change === true);
/**
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single
@@ -41,15 +63,15 @@ class SessionStore {
* what to do with an unauthenticated result. Idempotent: subsequent calls
* return the cached result (so client-side navigation doesn't re-probe).
*/
async load(): Promise<User | null> {
if (this.loaded) return this.user;
async load(): Promise<SelfUser | null> {
if (this.loaded) return this.me;
// No JS-visible session hint ⇒ nothing to probe. The server sets
// `oxicloud_csrf` alongside the HttpOnly session cookies and clears
// it on logout, so a missing hint means no session. Skips the
// doomed 2× /me + /refresh burst that would otherwise fire on
// every first landing / post-logout re-mount with no cookies.
if (!hasSessionHint()) {
this.user = null;
this.me = null;
this.loaded = true;
return null;
}
@@ -71,25 +93,25 @@ class SessionStore {
// otherwise clutter the audit stream. Fire-and-forget
// so a slow IndexedDB open doesn't stall app boot.
if (me.is_dpop_bound === false) void bindDpopIfPossible();
} else this.user = null;
} else this.me = null;
} catch {
this.user = null;
this.me = null;
}
this.loaded = true;
return this.user;
return this.me;
}
/**
* Set the authenticated user AND run per-user localStorage cleanup
* (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct
* `session.user = …` assignments skip the cleanup — always call
* `session.me = …` assignments skip the cleanup — always call
* `setUser` on login-flow entry points (form login, OIDC exchange,
* existing-session probe) so a switch-account flow inside the same
* tab observes the wipe.
*/
setUser(user: User): void {
this.user = user;
ensureActiveUser(user.id);
setUser(me: SelfUser): void {
this.me = me;
ensureActiveUser(me.full.user.id);
// Any successful login clears the session-teardown gate. Without
// this, a logout → login within the same SPA session leaves the
// gate stuck at `true` — the login POST is exempted via
@@ -115,7 +137,7 @@ class SessionStore {
async refresh(): Promise<void> {
try {
const me = await fetchMe();
if (me) this.user = me;
if (me) this.me = me;
} catch {
/* keep the existing user on a transient /api/auth/me failure */
}
@@ -142,7 +164,7 @@ class SessionStore {
}
reset(): void {
this.user = null;
this.me = null;
this.homeFolderId = null;
this.homeFolderName = null;
// Mark the store as `loaded` so any subsequent `session.load()` —
+216 -55
View File
@@ -63,6 +63,7 @@
type StorageTestResult
} from '$lib/api/endpoints/admin';
import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives';
import { seedUser } from '$lib/api/endpoints/users';
import {
ensureResolvers,
resolveRecipient,
@@ -70,7 +71,7 @@
type Recipient
} from '$lib/api/endpoints/recipients';
import type {
AdminUserSummary,
FullUser,
Drive,
DriveMember,
DrivePolicies,
@@ -156,11 +157,11 @@
deleteUserModal !== null &&
deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase()
);
function openDeleteUser(u: AdminUserSummary) {
function openDeleteUser(u: FullUser) {
deleteUserModal = {
userId: u.id,
username: u.username || u.email,
email: u.email
userId: u.user.id,
username: u.user.username || u.user.email,
email: u.user.email
};
deleteUserEmailInput = '';
}
@@ -817,7 +818,7 @@
}
// Users
let users = $state<AdminUserSummary[]>([]);
let users = $state<FullUser[]>([]);
let total = $state(0);
let pageIndex = $state(0);
let usersError = $state<string | null>(null);
@@ -923,6 +924,12 @@
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
users = page.users;
total = page.total;
// Seed the per-user resolver cache with the row's `PublicUser`
// slice so every `UserVignette` mounted per row hits the cache
// synchronously — no per-row `/api/users/{id}` follow-up.
// Kills the N+1 that motivated widening `/api/admin/users` to
// carry the avatar (docs/plan/userdto-refactor.md § N+1).
for (const row of page.users) seedUser(row.user);
} catch (e) {
usersError = errorMessage(e);
}
@@ -1003,49 +1010,49 @@
}
/** True for the signed-in admin's own row — guards self-destructive actions. */
function isSelf(u: AdminUserSummary): boolean {
return u.id === currentAdminId;
function isSelf(u: FullUser): boolean {
return u.user.id === currentAdminId;
}
/** OIDC/SSO-provisioned account (no local password to reset). */
function isOidcUser(u: AdminUserSummary): boolean {
function isOidcUser(u: FullUser): boolean {
return u.federation_kind === 'oidc';
}
/** Used-quota percentage (0 when unlimited) for the per-user progress bar. */
function quotaPct(u: AdminUserSummary): number {
function quotaPct(u: FullUser): number {
return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
}
async function toggleRole(u: AdminUserSummary) {
async function toggleRole(u: FullUser) {
if (isSelf(u)) return;
const role = u.role === 'admin' ? 'user' : 'admin';
const role = u.user.role === 'admin' ? 'user' : 'admin';
if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return;
try {
await setUserRole(u.id, role);
await setUserRole(u.user.id, role);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function toggleActive(u: AdminUserSummary) {
async function toggleActive(u: FullUser) {
if (isSelf(u) && u.active) return;
const msg = u.active
? t('admin.confirm_deactivate', 'Deactivate this user?')
: t('admin.confirm_activate', 'Activate this user?');
if (!(await showConfirm(msg))) return;
try {
await setUserActive(u.id, !u.active);
await setUserActive(u.user.id, !u.active);
await loadUsers();
} catch (e) {
reportError(e);
}
}
function openQuota(u: AdminUserSummary) {
function openQuota(u: FullUser) {
quotaModalError = null;
quotaModal = {
userId: u.id,
username: u.username || u.email,
userId: u.user.id,
username: u.user.username || u.user.email,
initialBytes: u.storage_quota_bytes
};
}
@@ -1069,8 +1076,8 @@
}
}
function openReset(u: AdminUserSummary) {
resetModal = { userId: u.id, username: u.username || u.email };
function openReset(u: FullUser) {
resetModal = { userId: u.user.id, username: u.user.username || u.user.email };
resetPassword = '';
resetError = null;
}
@@ -1094,7 +1101,7 @@
}
}
function removeUser(u: AdminUserSummary) {
function removeUser(u: FullUser) {
if (isSelf(u)) return;
openDeleteUser(u);
}
@@ -1103,20 +1110,20 @@
// provisions a home drive + flips the is_external flag; irreversible
// via the admin UI (there's no demote endpoint on purpose). Backend
// refuses when magic-link login is disabled — surfaced as a toast.
async function promoteExternal(u: AdminUserSummary) {
if (!u.is_external) return;
async function promoteExternal(u: FullUser) {
if (!u.user.is_external) return;
if (
!(await showConfirm(
t(
'admin.confirm_promote_user',
{ name: u.username || u.email },
{ name: u.user.username || u.user.email },
'Promote {{name}} to an internal user? This provisions a home drive and gives the account a normal storage envelope. The account keeps its identity; magic-link login stays the way in unless a password is set later.'
)
))
)
return;
try {
await promoteUserToInternal(u.id);
await promoteUserToInternal(u.user.id);
await loadUsers();
} catch (e) {
reportError(e);
@@ -1240,8 +1247,13 @@
.map(async (d) => {
const ownerMember = nextMembers[d.id]?.find((m) => m.subject.type === 'user');
if (!ownerMember) return;
const user = await getUserAdmin(ownerMember.subject.id);
if (user) nextOwners[d.id] = user;
// `getUserAdmin` returns `FullUser` (admin-visible extras
// + nested `.user: PublicUser`). The drive row only reads
// public-identity fields (username, email, image) so keep
// the map typed as `PublicUser` and unwrap the embedded
// public block on insert. See docs/plan/userdto-refactor.md.
const full = await getUserAdmin(ownerMember.subject.id);
if (full) nextOwners[d.id] = full.user;
})
);
personalDriveOwners = nextOwners;
@@ -1723,6 +1735,16 @@
{:else if !dashboard}
<p class="status">{t('common.loading', 'Loading…')}</p>
{: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-card">
<span class="ds-num">{dashboard.total_users}</span>{t('admin.total_users', 'Total users')}
@@ -1733,11 +1755,66 @@
<div class="ds-card">
<span class="ds-num">{dashboard.admin_users}</span>{t('admin.admin_users', 'Admins')}
</div>
<div class="ds-card">
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
<div
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>
<!-- 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-card">
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
@@ -1761,6 +1838,9 @@
</span>
{t('admin.quotas', 'Quotas')}
</div>
<div class="ds-card">
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
</div>
</div>
{#if dashboard.users_over_quota > 0}
@@ -1802,13 +1882,17 @@
row.kind === 'personal'
? t('admin.quota_personal', 'Personal drives')
: t('admin.quota_shared', 'Shared drives')}
{@const total = row.unlimited_count + row.capped_count}
{@const pct =
row.capped_quota_bytes && row.capped_quota_bytes > 0
? (row.used_bytes / row.capped_quota_bytes) * 100
: null}
{#if row.capped_count > 0 || row.unlimited_count > 0}
<tr>
<th scope="row">{label}</th>
<th scope="row">
<span class="quota-table__count">{total}</span>
{label}
</th>
<td class="quota-table__num">
{#if row.capped_quota_bytes !== null && pct !== null}
{formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)}
@@ -2680,15 +2764,15 @@
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
{#each users as u (u.user.id)}
{@const pct = quotaPct(u)}
<tr>
<td>
<div class="user-vignette-cell">
<UserVignette
userId={u.id}
fallbackLabel={u.username || u.email}
fallbackSublabel={u.email}
userId={u.user.id}
fallbackLabel={u.user.username || u.user.email}
fallbackSublabel={u.user.email}
/>
{#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
@@ -2703,11 +2787,11 @@
badge is `white-space: nowrap` so the badge label
itself never wraps mid-word either. -->
<div class="role-badges">
<span class="badge badge--{u.role === 'admin' ? 'admin' : 'user'}">
{#if u.role === 'admin'}<Icon name="shield-alt" />{/if}
{u.role}
<span class="badge badge--{u.user.role === 'admin' ? 'admin' : 'user'}">
{#if u.user.role === 'admin'}<Icon name="shield-alt" />{/if}
{u.user.role}
</span>
{#if u.is_external}
{#if u.user.is_external}
<!-- Origin flag, orthogonal to `role`. Grant-only
accounts (magic-link / OCM) can never be admin
(DB CHECK `users_external_not_admin`) so the two
@@ -2738,7 +2822,7 @@
<td class="auth-cell">
<!--
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:
* SSO/OIDC — `federation_kind === 'oidc'`,
identity delegated to the IdP; label is
@@ -2825,7 +2909,7 @@
</span>
</td>
<td>
{#if u.is_external}
{#if u.user.is_external}
<!-- External accounts have no storage envelope by
design (DB CHECK `users_external_no_storage`
enforces storage_quota_bytes = 0). Rendering the
@@ -2867,10 +2951,10 @@
actions render as invisible placeholders. -->
<div class="actions actions--user">
<!-- Slot 1: quota (internal) OR promote (external). -->
{#if u.is_external}
{#if u.user.is_external}
<button
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')}
aria-label={t('admin.promote_to_internal_title', 'Promote to internal user')}
onclick={() => promoteExternal(u)}
@@ -2880,7 +2964,7 @@
{:else}
<button
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')}
aria-label={t('admin.edit_quota_title', 'Edit quota')}
onclick={() => openQuota(u)}
@@ -2891,10 +2975,10 @@
<!-- Slot 2: reset password (local internal only —
OIDC and external accounts have no password
to reset). Placeholder otherwise. -->
{#if !isOidcUser(u) && !u.is_external}
{#if !isOidcUser(u) && !u.user.is_external}
<button
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')}
aria-label={t('admin.reset_password_title', 'Reset password')}
onclick={() => openReset(u)}
@@ -2909,16 +2993,16 @@
`change_user_role` + DB CHECK
`users_external_not_admin`). Promotion to
internal is offered separately in slot 1. -->
{#if !u.is_external}
{#if !u.user.is_external}
<button
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')}
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
disabled={isSelf(u)}
onclick={() => toggleRole(u)}
>
<Icon name={u.role === 'admin' ? 'user' : 'crown'} />
<Icon name={u.user.role === 'admin' ? 'user' : 'crown'} />
</button>
{:else}
<span class="icon-btn icon-btn--placeholder" aria-hidden="true"></span>
@@ -2926,7 +3010,7 @@
<!-- Slot 4: activate/deactivate. -->
<button
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
? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')}
@@ -2941,7 +3025,7 @@
<!-- Slot 5: delete. -->
<button
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')}
aria-label={t('admin.delete_title', 'Delete user')}
disabled={isSelf(u)}
@@ -3275,11 +3359,21 @@
fall back to the owner's cap; 0 also means "no limit"
(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 =
d.kind === 'personal'
? owner && owner.storage_quota_bytes > 0
? owner.storage_quota_bytes
: null
? null
: d.quota_bytes && d.quota_bytes > 0
? d.quota_bytes
: null}
@@ -4323,6 +4417,40 @@
margin-bottom: var(--space-4);
}
/* Section title bar above each dashboard grid — labels the
nature of the cards below (accounts vs live activity vs
system). Small, muted, so it structures the page without
competing with the numbers. `text-transform: uppercase` +
`letter-spacing` matches the small-caps section-header pattern
used elsewhere in the admin surface. */
.ds-section-title {
display: flex;
align-items: baseline;
gap: var(--space-2);
margin: var(--space-4) 0 var(--space-2) 0;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* "live" pill next to the "Live activity" section header —
subtle visual hint that the values in this grid change on
their own cadence. Matches the presence-dot's success token
so the whole live-activity block reads as one visual family. */
.ds-section-live {
display: inline-block;
padding: 0 var(--space-2);
border-radius: var(--radius-full);
background: var(--color-success-bg);
color: var(--color-success-text);
font-size: 0.65rem;
font-weight: var(--weight-bold);
letter-spacing: 0.08em;
vertical-align: middle;
}
.ds-card {
display: flex;
flex-direction: column;
@@ -4341,6 +4469,18 @@
color: var(--color-text-heading);
}
/* Live-count variant — same font size as `.ds-num`, plus a
flex container so the leading presence dot aligns with the
number baseline instead of the top of the digit. Reuses the
`.presence-dot--online` class from the sessions-panel work
so the visual signal for presence is identical across the
admin surface. */
.ds-num.ds-num--live {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.ds-bar {
height: 8px;
background: var(--color-bg-muted);
@@ -4433,6 +4573,19 @@
white-space: nowrap;
}
/* Prepended drive count: tabular-nums so single/double/triple digits align
vertically across rows; right-aligned inside a fixed-width box so the
ones-digits line up across "personal" and "shared" rows regardless of
how many digits each count has. */
.quota-table__count {
display: inline-block;
min-width: 1.5em;
margin-right: 0.25em;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--color-text-heading);
}
.quota-table__num {
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -5213,9 +5366,17 @@
}
.admin {
max-width: 64rem;
/* Raised from 64rem to 80rem so the data-dense tables (sessions
row with 9+ cells, users table with vignette + role + auth
chips + quota bar) have more horizontal room. At viewports
above 80rem `margin: 0 auto` still centers with the leftover
whitespace — DevTools shows that whitespace as horizontal
margin (not padding) and is what "content looks squeezed"
really means on wide displays. Vertical rhythm and the small
horizontal padding are unchanged. */
max-width: 80rem;
margin: 0 auto;
padding: 1.5rem 1rem;
padding: 1.5rem var(--space-2);
display: flex;
flex-direction: column;
gap: 1rem;
+11 -1
View File
@@ -96,16 +96,26 @@ const dashboard = {
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 = {
user: {
id: 'u1',
username: 'bob',
email: 'bob@x.test',
role: 'user',
is_external: false
},
active: true,
is_active: true,
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false
has_password: true,
opaque_registered: false,
opaque_migrated: false
};
const mount = {
@@ -713,8 +713,43 @@
* Upload a batch of files into the current folder, reporting aggregate
* progress through a single bell notification with a progress bar.
*/
/**
* Cold-navigation upload guard.
*
* `currentId` starts `null` and is only populated inside `load()` AFTER
* `session.loadHomeFolder()` resolves (see the `$effect` at the bottom of
* this file that drives `load()`, and the assignment at `currentId =
* folderId` inside `load()`). The hidden `<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[]) {
if (files.length === 0) return;
if (!guardUploadFolderReady()) return;
uploading = true;
// Arm the reload-guard + persist a "batch in flight" marker so a
// page refresh mid-upload (a) prompts the browser's "Leave site?"
@@ -1557,6 +1592,7 @@
*/
async function uploadTree(entries: { file: File; relativePath: string }[]) {
if (entries.length === 0) return;
if (!guardUploadFolderReady()) return;
uploading = true;
// Same reload-guard + interrupted-uploads breadcrumb as uploadBatch —
// the browser prompts on refresh, and if the user reloads anyway
+6
View File
@@ -158,6 +158,12 @@ it('keeps aggregate upload progress exact when one file restarts', async () => {
);
render(FilesPage);
const input = await screen.findByTestId('files-upload-file-input');
// The cold-navigation upload guard (`guardUploadFolderReady` in
// `+page.svelte`) refuses uploads while `currentId` is null — which is
// the initial state before `load()` runs. `load()` sets `currentId =
// folderId` BEFORE it calls `fetchFolderPage`, so waiting on the fetch
// mock is a stable "load() has progressed past the assignment" signal.
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
const uploads = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')];
Object.defineProperty(input, 'files', { configurable: true, value: uploads });
+47 -37
View File
@@ -72,11 +72,11 @@
let creatingPw = $state(false);
let autoExpanded = $state(false);
const isOidc = $derived(session.user?.federation_kind === 'oidc');
const isLocal = $derived(!session.user?.federation_kind);
const isOidc = $derived(session.me?.full.federation_kind === 'oidc');
const isLocal = $derived(!session.me?.full.federation_kind);
const usernameClaimed = $derived(!!session.user?.username);
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
// local password: they have `password_hash` on file AND the
// deployment offers password login (backend `change_password`
@@ -87,14 +87,16 @@
// password) are a legitimate posture and MUST be able to rotate
// their local credential; the new gate lets them, and the backend
// 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.
// Connect: only when OIDC is enabled AND the user isn't already linked.
// Disconnect: only when currently OIDC-linked AND the user has an
// alternative auth method (password or OPAQUE-registered) — else
// 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.
// The backend guard (`AuthApplicationService::unlink_oidc`) is the
// source of truth for the "no alternative auth" refusal — it also
@@ -103,7 +105,7 @@
// adoption status through user-directory endpoints). The UI shows
// the button unconditionally and surfaces the backend's 403 as a
// 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
@@ -136,14 +138,11 @@
}
});
const storagePct = $derived(
session.user && session.user.storage_quota_bytes > 0
? Math.min(
100,
Math.round((session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100)
)
: 0
);
const storagePct = $derived.by(() => {
const full = session.me?.full;
if (!full || full.storage_quota_bytes <= 0) return 0;
return Math.min(100, Math.round((full.storage_used_bytes / full.storage_quota_bytes) * 100));
});
const storageBarClass = $derived(
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 });
function hydrate() {
const u = session.user;
if (!u) return;
givenName = u.given_name ?? '';
familyName = u.family_name ?? '';
username = u.username ?? '';
preferredLocale = u.preferred_locale ?? '';
notifyOnShare = u.notify_on_share;
const me = session.me;
if (!me) return;
// Public identity (name / handle) reads via `me.full.user`;
// admin-visible extras (preferred_locale) via `me.full`;
// self-only bag flags (notify_on_share) via `me` directly.
// The three-level indirection makes the audience of each
// 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
// 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
// preference field just needs a getter in the store and its
// own line here — no wire-format knowledge on the page.
@@ -176,21 +180,22 @@
async function saveProfile(e: SubmitEvent) {
e.preventDefault();
const u = session.user;
if (!u) return;
const me = session.me;
if (!me) return;
// Build a sparse patch of only the fields the user actually changed.
// Sending empty strings the user never touched would 400 on the server.
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();
}
if (givenName.trim() !== (u.given_name ?? '')) patch.given_name = givenName.trim();
if (familyName.trim() !== (u.family_name ?? '')) patch.family_name = familyName.trim();
if ((preferredLocale || '') !== (u.preferred_locale ?? '')) {
if (givenName.trim() !== (me.full.user.given_name ?? '')) patch.given_name = givenName.trim();
if (familyName.trim() !== (me.full.user.family_name ?? ''))
patch.family_name = familyName.trim();
if ((preferredLocale || '') !== (me.full.preferred_locale ?? '')) {
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
// server does a shallow merge, so only the changed key is
// touched; siblings set on other devices survive.
@@ -205,8 +210,13 @@
savingProfile = true;
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);
session.user = updated;
session.me = updated;
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
ui.notify(t('profile.saved', 'Profile saved'), 'success');
} catch (err) {
@@ -445,7 +455,7 @@
// (federation_kind should now be 'oidc').
try {
const me = await fetchMe();
if (me) session.user = me;
if (me) session.me = me;
} catch {
/* stale session is recoverable — next request refreshes */
}
@@ -536,7 +546,7 @@
try {
await unlinkOidc();
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');
} catch (err) {
if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') {
@@ -742,7 +752,7 @@
<Icon name="clock" />
{t('profile.last_login', 'Last Login')}
</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>
@@ -752,20 +762,20 @@
<h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2>
<div class="storage-stats">
<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>
<div class="storage-stat">
<div class="stat-value">
{session.user.storage_quota_bytes > 0
? formatBytes(session.user.storage_quota_bytes)
{(session.me?.full.storage_quota_bytes ?? 0) > 0
? formatBytes(session.me?.full.storage_quota_bytes ?? 0)
: '∞'}
</div>
<div class="muted">{t('profile.quota', 'Quota')}</div>
</div>
<div class="storage-stat">
<div class="stat-value">
{session.user.storage_quota_bytes > 0 ? `${storagePct}%` : '—'}
{(session.me?.full.storage_quota_bytes ?? 0) > 0 ? `${storagePct}%` : '—'}
</div>
<div class="muted">{t('profile.usage', 'Usage')}</div>
</div>
+44 -19
View File
@@ -1,10 +1,15 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: {
loaded: true,
load: vi.fn(),
// Test-double session store. Post the three-layer UserDto refactor
// (docs/plan/userdto-refactor.md), production `session.user` is a
// derived accessor over `session.me.full.user`. The stub here mirrors
// that shape: `me` carries the whole SelfUser tree, and `user` mirrors
// `me.full.user` so any legacy `session.user.foo` read on the tested
// page keeps working through the mock without reproducing the derived
// mechanism.
const buildSelfMe = () => ({
full: {
user: {
id: '1',
username: 'admin',
@@ -12,14 +17,41 @@ const { session, ui } = vi.hoisted(() => ({
given_name: 'A',
family_name: 'B',
role: 'admin',
is_external: false
},
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false,
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() }
}));
};
});
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
@@ -44,20 +76,13 @@ const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
// Reset the shared session each test (handlers may mutate session.user).
// Reset the shared session each test (handlers may mutate session.me
// on save / refresh). `me` is the SelfUser tree; `user` mirrors
// `me.full.user` for legacy `session.user.foo` reads.
session.loaded = true;
session.user = {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false,
has_password: true
};
const me = buildSelfMe();
session.me = me;
session.user = me.full.user;
m(profile.listAppPasswords).mockResolvedValue([]);
m(profile.updateProfile).mockResolvedValue(undefined);
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
+12
View File
@@ -546,6 +546,7 @@
"empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.",
"show_hidden": "Show hidden files",
"upload_dotfile_hidden": "{{n}} file(s) uploaded but hidden by your dotfile preference.",
"upload_folder_not_ready": "Folder is still loading — please try again in a moment.",
"rename_dotfile_hidden": "Renamed to '{{name}}' — now hidden by your preference.",
"new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.",
"dotfiles_hidden_toast": "Dotfiles hidden",
@@ -836,6 +837,17 @@
"total_users": "Total Users",
"active_users": "Active Users",
"admins": "Admins",
"external_users": "External",
"external_users_tooltip": "Grant-only accounts — magic-link, OIDC-only, OCM recipients",
"online_users": "Online users",
"online_users_tooltip": "Distinct users with a session active in the last 5 minutes",
"online_sessions": "Online sessions",
"online_sessions_tooltip": "Non-revoked sessions active in the last 5 minutes — multi-device users contribute more than one",
"section_accounts": "User accounts",
"section_activity": "Live activity",
"section_system": "System",
"live": "live",
"live_tooltip": "Reflects sessions active in the last 5 minutes",
"version": "Version",
"storage_overview": "Storage Overview",
"used": "Used",
+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.",
"show_hidden": "Afficher les fichiers masqués",
"upload_dotfile_hidden": "{{n}} fichier(s) téléversé(s) mais masqué(s) par votre préférence.",
"upload_folder_not_ready": "Le dossier est encore en cours de chargement — merci de réessayer dans un instant.",
"rename_dotfile_hidden": "Renommé en \"{{name}}\" — désormais masqué par votre préférence.",
"new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.",
"dotfiles_hidden_toast": "Fichiers masqués",
@@ -801,6 +802,17 @@
"total_users": "Utilisateurs totaux",
"active_users": "Utilisateurs actifs",
"admins": "Admins",
"external_users": "Externes",
"external_users_tooltip": "Comptes invités — magic-link, OIDC seulement, destinataires OCM",
"online_users": "Utilisateurs en ligne",
"online_users_tooltip": "Utilisateurs distincts ayant une session active dans les 5 dernières minutes",
"online_sessions": "Sessions en ligne",
"online_sessions_tooltip": "Sessions non révoquées actives dans les 5 dernières minutes — les utilisateurs multi-appareils en contribuent plusieurs",
"section_accounts": "Comptes utilisateurs",
"section_activity": "Activité en direct",
"section_system": "Système",
"live": "en direct",
"live_tooltip": "Reflète les sessions actives dans les 5 dernières minutes",
"version": "Version",
"storage_overview": "Aperçu du stockage",
"used": "Utilisé",
+35 -5
View File
@@ -108,14 +108,15 @@ pub struct AdminResetPasswordDto {
pub new_password: String,
}
/// Query parameters for listing users
/// Query parameters for listing users. `/api/admin/users` used to
/// bifurcate on `?summary=` (flat `PublicUserDto` vs nested
/// `FullUserDto`); that split was retired — the endpoint now always
/// returns `FullUserDto`. Unknown query params are ignored, so
/// existing callers still passing `?summary=true` keep working.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListUsersQueryDto {
pub limit: Option<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.
@@ -163,10 +164,39 @@ pub struct DashboardStatsDto {
pub auth_enabled: bool,
pub oidc_configured: bool,
pub quotas_enabled: bool,
// User stats
// ── User accounts (static breakdown of auth.users) ──
// All four are counts of the SAME table under different
// predicates. `active`, `admin`, `external` are all subsets of
// `total`. `external` is disjoint from `admin` by DB constraint
// (`users_external_not_admin`). The dashboard renders these as
// one grouped section separate from the live-activity section
// below, so admins don't confuse "as-of-now row count" with
// "who's here right now".
pub total_users: i64,
pub active_users: i64,
pub admin_users: i64,
/// Grant-only accounts (magic-link / OIDC-only / OCM recipients).
/// Filtered out of `total_users` / `active_users` since those
/// columns count operational seats (see the SELECT comment). Here
/// as its own metric because operators of external-heavy
/// deployments (public shares, invited-collab shops) need to see
/// the invited population at a glance.
pub external_users: i64,
// ── Live activity (projection over auth.sessions) ──
// Both fields change minute-to-minute, unlike the user counts
// above which only move on register/deactivate/role-toggle.
// Same 5-min window as the Prometheus gauges
// (`oxicloud_sessions_online[_users]` in
// `session_liveness_gauges.rs`), computed via the shared
// `ONLINE_WINDOW` constant so per-user badges + aggregate
// counts + this dashboard number stay consistent by construction.
/// Distinct users behind non-revoked sessions active in the last
/// 5 min. Answers "how many humans are here right now?".
pub online_users: i64,
/// Non-revoked sessions active in the last 5 min. Answers "how
/// many concurrent connections must I serve?". Ratio
/// `online_sessions / online_users` is the multi-device factor.
pub online_sessions: i64,
// ── Per-drive-kind quota accounting ──
// One row per drive kind (personal, shared). Pre-dedup, logical
// file sizes summed from `drives.used_bytes` (personal rolls up
+379 -240
View File
@@ -1,5 +1,4 @@
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
@@ -7,287 +6,283 @@ use std::sync::Arc;
use utoipa::ToSchema;
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)]
pub struct UserDto {
pub struct PublicUserDto {
pub id: String,
/// Optional handle. `None` for users who have not claimed one
/// (externals, fresh email-only signups). Frontend display callers
/// should walk `username → given/family → email` as their fallback
/// chain. Omitted from JSON when None (consistent with the existing
/// given_name / family_name fields).
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub email: String,
/// Role string ("admin" | "user"). Kept public because the sharee /
/// group-member vignette renders an admin badge.
pub role: String,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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>,
/// Avatar payload (base64 data-URI up to 512 KiB). Public so a share
/// picker can render the recipient's face directly. Will move to a
/// dedicated avatar endpoint in a future refactor — this shape is
/// transitional.
pub image: Option<String>,
pub can_edit_image: bool,
/// `true` for grant-only external recipients (magic-link, OIDC-only,
/// future OCM federated). External users have no home folder and
/// can't own storage; their quota is always 0. Internal users
/// default to `false`.
/// future OCM federated). Renders the "external" badge on the vignette.
pub is_external: bool,
/// Optional first/given name. Populated from the OIDC `given_name`
/// claim at JIT provisioning, or via a profile-edit endpoint.
/// `None` until explicitly set — `skip_serializing_if = "Option::is_none"`
/// keeps the wire format compact for the common case.
/// Optional first/given name. Social identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
/// Optional last/family name. Same provenance + serde rules as
/// `given_name`.
/// Optional last/family name. Social identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
/// When the user first demonstrated control of their email (PR 23).
/// `None` = unverified (omitted from JSON). Stamped on the first
/// successful magic-link redemption or OIDC JIT with verified
/// claim. Idempotent — the original timestamp is preserved on
/// subsequent verifications.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified_at: Option<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.
/// Presence signal — TRUE when the server observed a request on any
/// of this user's non-revoked sessions within the last
/// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
/// (5 min). Sourced from an EXISTS subquery when the DTO is built
/// from a list-projection path; single-user endpoints that don't
/// enrich presence ship `false`.
#[serde(default)]
pub force_password_change: bool,
/// TRUE when the account has a local Argon2id `password_hash` on
/// file. Distinct from `federation_kind`: an OIDC-linked account
/// (`federation_kind == "oidc"`) can ALSO carry a local password if
/// it was set at signup or later — a hybrid posture. The SPA
/// gates the profile page's change-password card on this flag,
/// so hybrid users can rotate their local password even though
/// they normally sign in via SSO.
///
/// Populated only by the `/api/auth/me` handler. `From<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,
pub is_online: 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,
/// omitting `image` and `ui_preferences` prevents a 100-row page from turning
/// into tens of MiB when users have uploaded avatars. `GET /api/admin/users/:id`
/// remains the full-detail endpoint.
/// This is the DTO closest to the underlying `auth.users` row. Adding a
/// field here means an admin looking at any user can see it, and the
/// subject themselves can see it in their `/me` response — but the field
/// stays off the public [`PublicUserDto`] surface.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminUserSummaryDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub email: String,
pub role: String,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
/// See `UserDto::federation_kind` — same semantics, same wire spelling.
pub struct FullUserDto {
/// Public identity — same set every authenticated caller sees.
pub user: PublicUserDto,
/// Which trust chain minted this user's federation identity —
/// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local users.
/// Kept off `PublicUserDto` because a peer's federation kind is a
/// soft org-affiliation leak; only self + admin need it.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_kind: Option<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")]
pub federation_issuer: Option<String>,
pub is_external: bool,
/// TRUE when the user has a server-verifiable password on file
/// (`password_hash IS NOT NULL`). The admin table uses this
/// alongside `federation_issuer` and `opaque_registered` to render
/// the user's full capability set: a `password` chip lights up
/// here, an OIDC provider name renders the SSO badge, an
/// envelope-on-file flips the OPAQUE chip. A user with none of
/// the three is passwordless (magic-link only — the SPA renders
/// a distinct `passwordless` chip in that case). Admin-only
/// exposure — see the DTO doc for why this isn't on `UserDto`.
#[serde(default)]
/// Subject's own locale preference. Only THEY or an admin managing
/// them needs this — other callers use their own locale.
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_locale: Option<String>,
/// When the user first demonstrated control of their email. Trust
/// signal — meaningful to admin (auditing verification status) and
/// to self (own record), but not to a share picker rendering a
/// vignette.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified_at: Option<DateTime<Utc>>,
/// 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,
/// Mirrors `UserListEntry::opaque_registered` — TRUE when the user
/// has an OPAQUE envelope on file. Surfaced on the admin table so
/// operators can see per-user rollout progress during the
/// migration window. **Admin-only exposure**: this field is NOT
/// on `UserDto` — putting it there would leak adoption status
/// through every user-directory-adjacent endpoint (share targets,
/// group members, invite listings). `#[serde(default)]` keeps
/// older SPA builds tolerant of the added field.
#[serde(default)]
/// TRUE when the user has an OPAQUE envelope on file.
pub opaque_registered: bool,
/// Mirrors `UserListEntry::opaque_migrated` — TRUE when the user
/// has completed at least one successful OPAQUE login. Distinct
/// from `opaque_registered`: an admin can invalidate the envelope
/// (`clear_registration`) leaving the user registered=false but
/// with a historical migrated=true; the SPA's admin table shows
/// both so this operational nuance is visible.
#[serde(default)]
/// TRUE when the user has completed ≥1 successful OPAQUE login.
/// Distinct from `opaque_registered`: an admin can invalidate the
/// envelope leaving the user registered=false but with historical
/// migrated=true.
pub opaque_migrated: bool,
}
impl From<UserListEntry> for AdminUserSummaryDto {
fn from(entry: UserListEntry) -> Self {
Self {
id: entry.id.to_string(),
username: entry.username,
email: entry.email,
role: entry.role.to_string(),
storage_quota_bytes: entry.storage_quota_bytes,
storage_used_bytes: entry.storage_used_bytes,
last_login_at: entry.last_login_at,
active: entry.active,
federation_kind: entry.federation_kind,
federation_issuer: entry.federation_issuer,
is_external: entry.is_external,
has_password: entry.has_password,
opaque_registered: entry.opaque_registered,
opaque_migrated: entry.opaque_migrated,
}
}
/// Self view — everything the caller may see about themselves.
/// Returned by `/api/auth/me` and by every `AuthResponseDto` path
/// (login / refresh / OIDC callback / magic-link redemption).
///
/// Composed on top of [`FullUserDto`] so `/me` and `/admin/users` share
/// the SAME "full profile" contract for the fields both need — new
/// self+admin-visible fields go on `FullUserDto` and both endpoints get
/// them together. Fields here are pure self-scoped state: preferences,
/// session-scoped flags, and caller-scoped permissions.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SelfUserDto {
/// Full profile — same shape as one row of `/api/admin/users`.
pub full: FullUserDto,
/// Opaque UI preferences bag — my own UI state. Cross-device store
/// for pure UI toggles (view mode, sidebar collapse, hide dotfiles,
/// …). The server never inspects the contents. Always present on
/// the wire; empty bag is `{}`, never `null`.
pub ui_preferences: serde_json::Value,
/// Whether I want share-notification emails.
pub notify_on_share: bool,
/// Session-scoped: my current session carries a DPoP thumbprint.
/// SPA reads this on `session.load()` to skip a redundant
/// `POST /api/auth/dpop/bind` when the session is already bound.
pub is_dpop_bound: bool,
/// Admin-set temp-password gate — SPA nav guard blocks everything
/// but `/change-password` until this flips back. Cleared by a
/// successful `POST /api/auth/change-password`.
pub force_password_change: bool,
/// Caller-scoped permission: can I edit my own avatar? `false` for
/// OIDC users whose avatar comes from the IdP. Only meaningful when
/// caller == subject; nonsense on any other DTO.
pub can_edit_image: bool,
}
impl From<User> for UserDto {
fn from(user: User) -> Self {
// `user` is owned and dropped here, so every owned field is MOVED out
// via `into_parts` rather than cloned through the borrowing accessors —
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin
// user listing (benches/ROUND20.md §A2). The two derived values read the
// entity before the move.
impl PublicUserDto {
/// Construct a `PublicUserDto` from a `User` entity + an explicit
/// `is_online` signal.
///
/// **Why not `From<User>`?** The `User` entity models a row in
/// `auth.users`; `is_online` is a cross-table lookup on
/// `auth.sessions` (see the EXISTS subquery in
/// `list_users_with_derived_flags` and `get_user_with_derived_flags`
/// on the user repo). A `From<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 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();
Self {
id: p.id.to_string(),
username: p.username,
email: p.email,
role,
storage_quota_bytes: p.storage_quota_bytes,
storage_used_bytes: p.storage_used_bytes,
image: p.image,
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,
updated_at: p.updated_at,
last_login_at: p.last_login_at,
active: p.active,
// NULL on both fields for local users (no federation wired).
// FE predicates use `!!federation_kind` for "is federated?" —
// no "local" sentinel string; the null tells the whole story.
federation_kind: p.federation_kind.map(|k| k.as_str().to_string()),
federation_issuer: p.federation_issuer,
image: p.image,
can_edit_image,
is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
email_verified_at: p.email_verified_at,
preferred_locale: p.preferred_locale,
notify_on_share: p.notify_on_share,
ui_preferences: p.ui_preferences,
// Defaults to false. The `/me` handler + the login-response
// minter populate this via a distinct code path (a
// repo read that goes through the auth service's cache);
// admin listings and other UserDto consumers deliberately
// leave it false — the flag is per-session-account state,
// not a general user attribute.
force_password_change: false,
has_password,
// Populated only by `/api/auth/me` — the handler overlays
// the caller's session's actual DPoP binding state after
// this `From<User>` runs. Other UserDto emitters leave
// this at `false` (they lack session context).
is_dpop_bound: false,
storage_quota_bytes: p.storage_quota_bytes,
storage_used_bytes: p.storage_used_bytes,
has_password: flags.has_password,
opaque_registered: flags.opaque_registered,
opaque_migrated: flags.opaque_migrated,
}
}
}
impl SelfUserDto {
/// Assemble the `/me` response from a `FullUserDto` plus the two
/// session-scoped booleans that can't be derived from `User` alone:
/// the caller's DPoP-binding state (from the JWT `cnf.jkt` claim)
/// and the admin-set force-password-change flag (from the auth
/// service's cache).
///
/// The other self-only fields (`ui_preferences`, `notify_on_share`,
/// `can_edit_image`) come from `User` and are read off the entity
/// before it's moved into the FullUserDto; this method takes those
/// as explicit parameters so the caller can decide when to read
/// them (typically at the same point they read the DPoP-binding
/// state).
pub fn build(
full: FullUserDto,
ui_preferences: serde_json::Value,
notify_on_share: bool,
is_dpop_bound: bool,
force_password_change: bool,
can_edit_image: bool,
) -> Self {
Self {
full,
ui_preferences,
notify_on_share,
is_dpop_bound,
force_password_change,
can_edit_image,
}
}
}
// ────────────────────────────────────────────────────────────────────────
// End of three-layer user DTO family.
// ────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct LoginDto {
/// Identifier the user typed. Accepts BOTH a username (no `@`) and
@@ -425,7 +420,12 @@ impl UpdateProfileDto {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthResponseDto {
pub user: UserDto,
/// Full self view — identical shape to `/api/auth/me`. Every
/// login / refresh / OIDC-callback / magic-link redemption ships
/// this so the SPA's post-auth state matches its post-`/me` state
/// (no UI race between `AuthResponseDto` and the first `/me`
/// fetch). See `docs/plan/userdto-refactor.md` § Endpoint mapping.
pub user: SelfUserDto,
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
@@ -562,7 +562,7 @@ pub struct OidcProviderInfoDto {
/// users JIT-provisioned via this IdP.
///
/// Populated so the frontend can resolve display: when
/// `UserDto.federation_issuer` equals this `issuer`, render
/// `PublicUserDto.federation_issuer` equals this `issuer`, render
/// `provider_name` as the human-friendly label (avoids showing raw
/// issuer URLs like `https://sso.example.com/realms/main` in the
/// admin badge / profile view). Falls back to the raw issuer when
@@ -606,3 +606,142 @@ pub struct OidcUserInfoDto {
pub name: Option<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::session::Session;
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use std::sync::Arc;
use uuid::Uuid;
@@ -123,6 +122,36 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Gets a user by ID
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
/// silently dropped. Used by group-recipient expansion in
/// `RecipientNotificationService` to avoid N+1 lookups when notifying
@@ -164,15 +193,6 @@ pub trait UserStoragePort: Send + Sync + 'static {
include_external: bool,
) -> 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.
/// See [`list_users`] for the meaning of `include_external`.
async fn search_users(
@@ -1,6 +1,6 @@
use crate::application::dtos::user_dto::{
AdminUserSummaryDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto,
RegisterDto, UpgradeToInternalDto, UserDto,
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, PublicUserDto, RefreshTokenDto,
RegisterDto, SelfUserDto, UpgradeToInternalDto,
};
use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
@@ -319,11 +319,11 @@ pub enum OidcCallbackResult {
#[derive(Debug, Clone)]
pub enum RegisterResult {
/// Boxed to avoid the `large_enum_variant` clippy warning —
/// `UserDto` is ~250 bytes, the other variants are zero-sized,
/// `PublicUserDto` is ~250 bytes, the other variants are zero-sized,
/// so a heap-pointer indirection keeps the enum's stack size
/// small. `register` is called once per request; the
/// allocation cost is negligible.
Created(Box<UserDto>),
Created(Box<PublicUserDto>),
UsernameTaken,
EmailTaken,
}
@@ -876,8 +876,9 @@ impl AuthApplicationService {
is_external = false,
"🛂 user registered",
);
Ok(RegisterResult::Created(Box::new(UserDto::from(
Ok(RegisterResult::Created(Box::new(PublicUserDto::new(
created_user,
false,
))))
}
@@ -894,7 +895,7 @@ impl AuthApplicationService {
username: String,
email: String,
password: String,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
// Validate username
if username.len() < 3 || username.len() > 254 {
return Err(DomainError::new(
@@ -981,7 +982,7 @@ impl AuthApplicationService {
username,
created_user.id()
);
Ok(UserDto::from(created_user))
Ok(PublicUserDto::new(created_user, false))
}
pub async fn login(
@@ -1320,12 +1321,16 @@ impl AuthApplicationService {
session = session.with_dpop_jkt(jkt);
}
// Build the SelfUserDto BEFORE `session` moves into
// `create_session` — the builder reads `session.dpop_jkt()`.
let user_id = user.id();
let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
// Authentication response
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
Ok(AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
@@ -1334,6 +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,
/// with fail-open semantics on repo error (returns `false` and
/// logs a warn). Every callsite that builds an `AuthResponseDto`
@@ -1586,22 +1654,29 @@ impl AuthApplicationService {
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
// Snapshot fields still needed for logging + DTO before
// `session` and `user` are consumed by the storage call and
// the DTO builder below.
let user_id = user.id();
let user_display = user.display_for_audit().to_string();
let is_external = user.is_external();
let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
tracing::info!(
target: "audit",
event = "magic_link.redeemed",
user_id = %user.id(),
username = %user.display_for_audit(),
is_external = user.is_external(),
user_id = %user_id,
username = %user_display,
is_external = is_external,
resource_kind = ?mlt.resource_kind(),
resource_id = ?mlt.resource_id(),
cross_browser_confirmed = cross_browser_confirmed,
);
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
let auth = AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
@@ -1789,6 +1864,12 @@ impl AuthApplicationService {
session.dpop_jkt(),
)?;
// Build the SelfUserDto before `new_session` is consumed by
// the rotate call — the builder reads `session.dpop_jkt()`
// to compute `is_dpop_bound`.
let user_id = user.id();
let user_dto = self.build_self_user_dto(user_id, &new_session).await?;
self.session_storage
.rotate_session(session.id(), new_session)
.await?;
@@ -1798,9 +1879,9 @@ impl AuthApplicationService {
// initial login. The SPA's post-refresh flow (silent, on
// its own timer) can then route the user to change-password
// without waiting for an explicit re-login.
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
Ok(AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
@@ -2025,7 +2106,7 @@ impl AuthApplicationService {
&self,
caller_id: Uuid,
dto: UpgradeToInternalDto,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
// Precondition: caller is currently external. Fast-path 409 so
@@ -2126,7 +2207,7 @@ impl AuthApplicationService {
lc.dispatch_upgraded_to_internal(&updated).await;
}
Ok(UserDto::from(updated))
Ok(PublicUserDto::new(updated, false))
}
/// Admin-driven external → internal promotion.
@@ -2155,7 +2236,7 @@ impl AuthApplicationService {
&self,
admin_id: Uuid,
target_id: Uuid,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(target_id).await?;
if !user.is_external() {
@@ -2237,7 +2318,7 @@ impl AuthApplicationService {
"👮🏻‍♂️ 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
@@ -2492,9 +2573,9 @@ impl AuthApplicationService {
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?;
Ok(UserDto::from(user))
Ok(PublicUserDto::new(user, false))
}
/// Cached, image-free lookup of the caller's authorization flags
@@ -2643,7 +2724,7 @@ impl AuthApplicationService {
caller_id: Uuid,
dto: crate::application::dtos::user_dto::UpdateProfileDto,
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?;
// 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() {
// 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
@@ -2850,11 +2931,11 @@ impl AuthApplicationService {
// Refetch so the returned DTO reflects the merged JSONB bag
// (the in-memory `user` above holds the pre-merge value).
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
Ok(UserDto::from(refreshed))
Ok(PublicUserDto::new(refreshed, false))
}
// 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
}
@@ -2871,6 +2952,26 @@ impl AuthApplicationService {
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
}
/// Load the full `User` entity + `UserDerivedFlags` for the given
/// id in ONE query. See
/// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags)
/// for the shape and the SELECT that drives it. Used by
/// `/api/auth/me` to build a `SelfUserDto` and by future admin
/// single-user views to build a `FullUserDto` without paying two
/// round-trips.
pub async fn get_user_with_derived_flags(
&self,
user_id: Uuid,
) -> Result<
(
crate::domain::entities::user::User,
crate::domain::repositories::user_repository::UserDerivedFlags,
),
DomainError,
> {
UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await
}
/// Login-style identifier lookup: dispatches on `@` in the input
/// (email path when present, username path when not), identical
/// to `login()`'s dispatch. Exposed so the OPAQUE login handler
@@ -2927,12 +3028,23 @@ impl AuthApplicationService {
target_id: Uuid,
expose_system_users: bool,
pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
// (1) Self — a single fetch suffices (the check compares the input
// UUIDs, so the target read is never needed on this path).
//
// Both branches use `get_user_with_derived_flags` (not the narrow
// `get_user_by_id`) so `PublicUserDto.is_online` on the wire
// reflects the same EXISTS subquery the admin list uses. Without
// this the FE presence dot would only light up on list-derived
// paths (admin seed); single fetches from share pickers / group
// members would show every user as offline regardless of real
// state. See `docs/plan/userdto-refactor.md`.
if caller_id == target_id {
let caller = self.user_storage.get_user_by_id(caller_id).await?;
return Ok(UserDto::from(caller));
let (caller, flags) = self
.user_storage
.get_user_with_derived_flags(caller_id)
.await?;
return Ok(PublicUserDto::new(caller, flags.is_online));
}
// Caller and target are independent point reads (the self-case already
@@ -2940,16 +3052,26 @@ impl AuthApplicationService {
// overlap them with `join!` instead of two serial round-trips.
// `caller_res?` first preserves the caller-error precedence of the old
// sequential form. (benches/ROUND23.md §P1)
//
// `caller` uses the narrow `get_user_by_id` because we only read
// `is_external()` off it for the visibility gate; nothing about
// the caller ships on the wire. Only `target` needs the wider
// projection.
let (caller_res, target_res) = tokio::join!(
self.user_storage.get_user_by_id(caller_id),
self.user_storage.get_user_by_id(target_id)
self.user_storage.get_user_with_derived_flags(target_id)
);
let caller = caller_res?;
// Anti-enumeration: NotFound for everything that doesn't pass.
// Convert a real NotFound on `target` to the same anonymous 404,
// so existence isn't leaked through differential responses.
let target = match target_res {
//
// Destructure the (User, UserDerivedFlags) tuple immediately so
// `target` keeps its historical `User` shape (accessors still
// work below); the flags come along as `target_flags` for the
// `is_online` propagation into the returned `PublicUserDto`.
let (target, target_flags) = match target_res {
Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => {
tracing::info!(
@@ -2993,7 +3115,7 @@ impl AuthApplicationService {
})?;
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.
@@ -3021,12 +3143,12 @@ impl AuthApplicationService {
// (4) Internal target + system-address-book exposed: already public.
if !target.is_external() && expose_system_users {
return Ok(UserDto::from(target));
return Ok(PublicUserDto::new(target, target_flags.is_online));
}
// (5) Admin caller: always visible.
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.
@@ -3079,7 +3201,7 @@ impl AuthApplicationService {
username: &str,
expose_system_users: bool,
pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let target = match self.user_storage.get_user_by_username(username).await {
Ok(u) => u,
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
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?;
Ok(UserDto::from(user))
Ok(PublicUserDto::new(user, false))
}
// 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
/// sharee search, etc. — never expose external identities. Admin
/// surfaces that need the full list should call
/// [`list_users_including_external_with_perms`] instead.
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
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>(
/// [`list_user_summaries_including_external_with_perms`] instead.
pub async fn list_users(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<UserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
let users = self.user_storage.list_users(limit, offset, true).await?;
Ok(users.into_iter().map(UserDto::from).collect())
) -> Result<Vec<PublicUserDto>, DomainError> {
let users = self.user_storage.list_users(limit, offset, false).await?;
Ok(users
.into_iter()
.map(|u| PublicUserDto::new(u, false))
.collect())
}
/// Admin-only compact listing. The detail endpoint retains the complete
/// [`UserDto`]; this path projects only what the management table renders so
/// PostgreSQL never detoasts or transfers avatars/preferences for a page.
/// Admin-only user listing. Returns `Vec<FullUserDto>` — same
/// `FullUserDto` shape [`SelfUserDto`] embeds, so the FE reads
/// admin table rows and `/me` responses through identical field
/// paths. Includes the avatar (`user.image`) and presence
/// (`user.is_online`) so the admin table renders the vignette +
/// green dot without per-row follow-up fetches to
/// `/api/users/{id}` (the N+1 that motivated the widening — see
/// `docs/plan/userdto-refactor.md` § N+1). 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>(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AdminUserSummaryDto>, DomainError> {
) -> Result<Vec<FullUserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
let users = self
let rows = self
.user_storage
.list_user_summaries(limit, offset, true)
.list_users_with_derived_flags(limit, offset, true)
.await?;
Ok(users.into_iter().map(AdminUserSummaryDto::from).collect())
Ok(rows
.into_iter()
.map(|(user, flags)| FullUserDto::build(user, flags))
.collect())
}
/// Service-layer gate for administrator-scoped user-directory operations.
@@ -3183,9 +3309,16 @@ impl AuthApplicationService {
}
/// 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?;
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
@@ -3215,8 +3348,8 @@ impl AuthApplicationService {
// `interfaces/api/routes.rs::admin_router`) — but every admin
// method here still calls `require_admin_caller` as a
// defense-in-depth check, matching the pattern
// `list_users_including_external_with_perms` established. If a
// handler is ever wired outside the /admin subtree, the AuthZ
// `list_user_summaries_including_external_with_perms` established.
// If a handler is ever wired outside the /admin subtree, the AuthZ
// still holds.
/// List sessions for the admin panel. `user_id_filter = Some(uuid)`
@@ -3291,7 +3424,7 @@ impl AuthApplicationService {
pub async fn admin_create_user(
&self,
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
) -> Result<UserDto, DomainError> {
) -> Result<FullUserDto, DomainError> {
// Validate username length
if dto.username.len() < 3 || dto.username.len() > 254 {
return Err(DomainError::new(
@@ -3449,7 +3582,16 @@ impl AuthApplicationService {
created.id(),
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.
@@ -3545,10 +3687,20 @@ impl AuthApplicationService {
Ok(())
}
/// 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?;
Ok(UserDto::from(user))
/// Get a single user by ID (for admin panel).
///
/// Returns `FullUserDto` — same shape as one row of
/// `/api/admin/users` — so admin single-user views (detail modal,
/// per-user edit page) render the same fields the list surfaces.
/// The single-row admin view is the canonical observation surface
/// for admin-visible signals like `email_verified_at` /
/// `has_password` / `opaque_registered` / `last_login_at` — none
/// of which live on the peer-view `PublicUserDto`. See
/// `docs/plan/userdto-refactor.md`.
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<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).
@@ -4654,11 +4806,17 @@ impl AuthApplicationService {
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
// Build the SelfUserDto before `session` is consumed by the
// storage call — the builder reads `session.dpop_jkt()`
// (None here since OIDC callbacks land unbound and the SPA
// finishes binding post-redirect).
let user_id = user.id();
let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
let auth_response = AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
+1 -1
View File
@@ -207,7 +207,7 @@ pub struct User {
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
/// them through the borrowing accessors — notably `image` (a data URI up to
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
/// 512 KiB) and `ui_preferences` (a JSON tree). See `PublicUserDto::from`
/// (benches/ROUND20.md §A2).
pub struct UserParts {
pub id: Uuid,
+42 -45
View File
@@ -1,6 +1,5 @@
use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserRole};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -26,49 +25,26 @@ pub enum UserRepositoryError {
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
/// Narrow projection for user-directory tables that do not need secrets,
/// profile pictures, or the cross-device UI-preferences document.
/// DB-computed booleans about a user that aren't fields on the
/// [`User`](crate::domain::entities::user::User) entity itself —
/// either derived from column presence (`password_hash IS NOT NULL`)
/// or from a cross-table lookup (`auth.sessions.last_seen_at` for
/// `is_online`). Companion to `User` on the list projection: the
/// repo computes both, the application layer packs them into
/// [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto).
///
/// The full [`User`] row intentionally carries all of those fields for account
/// detail and the system address book. Reusing it for the paginated admin
/// table made PostgreSQL detoast and transfer an avatar of up to 512 KiB per
/// row, only for the handler to serialize it back to the browser where the
/// table never reads it. Keeping the projection explicit prevents a future
/// full-row field from silently returning to that hot path.
#[derive(Debug, Clone)]
pub struct UserListEntry {
pub id: Uuid,
pub username: Option<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).
/// Not "admin-only" — every field ends up on `FullUserDto`, which
/// both admin AND self read. The name reflects "derived from the DB
/// row, not intrinsic to the User entity".
///
/// See `docs/plan/userdto-refactor.md` for the design; this type
/// replaced the earlier `UserListEntry` narrow projection as of P6.
#[derive(Debug, Clone, Copy)]
pub struct UserDerivedFlags {
pub has_password: bool,
/// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user
/// has completed OPAQUE registration (typically via the Phase 2
/// silent-migration hook after a successful legacy login). Surfaced
/// on the admin user table so operators can see rollout progress
/// per-user. Admin-only exposure — see `AdminUserSummaryDto`.
pub opaque_registered: bool,
/// TRUE when `auth.users.opaque_migrated_at IS NOT NULL` — the
/// user has completed at least one successful OPAQUE login. Distinct
/// from `opaque_registered` because a user can have an envelope on
/// file without having actually logged in via OPAQUE yet (e.g.
/// admin cleared the envelope, silent-migration hasn't re-run).
pub opaque_migrated: bool,
pub is_online: bool,
}
// Conversion from UserRepositoryError to DomainError
@@ -94,6 +70,20 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by ID
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
/// and silently skipping ids that don't match any row. Caller is
/// responsible for de-duplicating the input vec. Returns an empty
@@ -152,15 +142,22 @@ pub trait UserRepository: Send + Sync + 'static {
include_external: bool,
) -> UserRepositoryResult<Vec<User>>;
/// Lists the columns needed by compact user-management tables. Unlike
/// [`Self::list_users`], this never fetches password hashes, OIDC subjects,
/// avatars, names, locale state, or UI preferences.
async fn list_user_summaries(
/// Paginated admin user listing — full `User` entity + the derived
/// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
/// SELECT. Called by the admin service to build
/// `Vec<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,
limit: i64,
offset: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>>;
) -> UserRepositoryResult<Vec<(User, UserDerivedFlags)>>;
/// Searches users by username or email (SQL ILIKE) with a limit.
/// 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::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::user_repository::{
StorageStats, UserListEntry, UserRepository, UserRepositoryError, UserRepositoryResult,
StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult,
};
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
@@ -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
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
@@ -835,51 +919,47 @@ impl UserRepository for UserPgRepository {
Ok(users)
}
async fn list_user_summaries(
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>> {
let rows = sqlx::query_as::<
_,
(
Uuid,
Option<String>,
String,
String,
i64,
i64,
Option<chrono::DateTime<chrono::Utc>>,
bool,
Option<String>,
Option<String>,
bool,
bool,
bool,
bool,
),
>(
// Auth-credential columns projected as booleans via `IS NOT
// NULL` rather than as timestamps / hashes so the row-mapping
// tuple stays small and the wire shape is exactly what the
// admin table needs. Per-row scalar tests — no cost beyond
// the full-table sequential scan the LIMIT/OFFSET already
// pays. `has_password` on the password_hash column tells
// the admin table whether a server-verifiable password is
// on file; combined with the two OPAQUE flags and
// federation_kind / federation_issuer, the SPA derives the
// full "capability set" per user (password / OPAQUE / SSO /
// passwordless).
) -> UserRepositoryResult<
Vec<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)>,
> {
// Full `User` column set (matches `get_user_by_id`) + the four
// derived booleans (IS-NOT-NULL for auth capability, EXISTS for
// `is_online`) in one SELECT. Same rationale as the single-user
// `get_user_with_derived_flags` variant. Widened over the older
// `list_user_summaries` projection because the FE now consumes
// the full user profile from these rows (killing the per-row
// `/api/users/{id}` fetch the admin table used to fire for
// avatars — see docs/plan/userdto-refactor.md § N+1).
//
// `interval` bound as `$4` seconds
// (`ONLINE_WINDOW.as_secs_f64()`), same pattern as
// `session_liveness_gauges.rs` and `get_user_with_derived_flags`.
let rows = sqlx::query(
r#"
SELECT
id, username, email, role::text,
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
last_login_at, active,
federation_kind, federation_issuer, is_external,
(password_hash IS NOT NULL) AS has_password,
created_at, updated_at, last_login_at, active,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences,
(password_hash IS NOT NULL) AS has_password_flag,
(opaque_envelope IS NOT NULL) AS opaque_registered,
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
(opaque_migrated_at IS NOT NULL) AS opaque_migrated,
EXISTS (
SELECT 1 FROM auth.sessions s
WHERE s.user_id = auth.users.id
AND s.revoked = FALSE
AND s.last_seen_at > NOW() - make_interval(secs => $4)
) AS is_online
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC, id DESC
@@ -889,49 +969,57 @@ impl UserRepository for UserPgRepository {
.bind(limit)
.bind(offset)
.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
.map_err(Self::map_sqlx_error)?;
// Note: the `has_password_flag` alias avoids colliding with the
// `password_hash` column selected above (the tuple destructure
// in `list_user_summaries` uses a shorter projection so it
// could reuse the raw `has_password` alias; here we keep both).
Ok(rows
.into_iter()
.map(
|(
id,
username,
email,
.map(|row| {
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,
storage_quota_bytes,
storage_used_bytes,
last_login_at,
active,
federation_kind,
federation_issuer,
is_external,
has_password,
opaque_registered,
opaque_migrated,
)| UserListEntry {
id,
username,
email,
role: if role == "admin" {
UserRole::Admin
} else {
UserRole::User
},
storage_quota_bytes,
storage_used_bytes,
last_login_at,
active,
federation_kind,
federation_issuer,
is_external,
has_password,
opaque_registered,
opaque_migrated,
},
)
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_flag"),
opaque_registered: row.get("opaque_registered"),
opaque_migrated: row.get("opaque_migrated"),
is_online: row.get("is_online"),
};
(user, flags)
})
.collect())
}
@@ -1302,6 +1390,21 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn get_user_with_derived_flags(
&self,
id: Uuid,
) -> Result<
(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
),
DomainError,
> {
UserRepository::get_user_with_derived_flags(self, id)
.await
.map_err(DomainError::from)
}
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError> {
UserRepository::get_users_by_ids(self, ids)
.await
@@ -1356,13 +1459,19 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn list_user_summaries(
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> Result<Vec<UserListEntry>, DomainError> {
UserRepository::list_user_summaries(self, limit, offset, include_external)
) -> Result<
Vec<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)>,
DomainError,
> {
UserRepository::list_users_with_derived_flags(self, limit, offset, include_external)
.await
.map_err(DomainError::from)
}
@@ -1743,26 +1852,27 @@ mod integration_tests {
)
.await;
let page = UserRepository::list_user_summaries(&repo, 3, 0, true)
// Migrated from the (now-deleted) `list_user_summaries` +
// `UserListEntry` to `list_users_with_derived_flags`, which
// returns `Vec<(User, UserDerivedFlags)>`. Field checks read
// through the `User` accessors instead of struct-field access.
let page = UserRepository::list_users_with_derived_flags(&repo, 3, 0, true)
.await
.expect("compact projection query must decode");
assert_eq!(page.iter().map(|entry| entry.id).collect::<Vec<_>>(), ids);
assert_eq!(page[0].username.as_deref(), Some(username_a.as_str()));
assert_eq!(page[0].role, UserRole::Admin);
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
assert_eq!(page[1].username, None);
assert!(page[1].is_external);
assert_eq!(
page[1].federation_issuer.as_deref(),
Some("integration-idp")
);
assert_eq!(page.iter().map(|(u, _)| u.id()).collect::<Vec<_>>(), ids);
assert_eq!(page[0].0.username(), Some(username_a.as_str()));
assert_eq!(page[0].0.role(), UserRole::Admin);
assert_eq!(page[0].0.storage_quota_bytes(), 10_737_418_240);
assert_eq!(page[1].0.username(), None);
assert!(page[1].0.is_external());
assert_eq!(page[1].0.federation_issuer(), Some("integration-idp"));
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
let internal = UserRepository::list_users_with_derived_flags(&repo, 10, 0, false)
.await
.expect("internal compact projection query must decode");
assert!(internal.iter().any(|entry| entry.id == ids[0]));
assert!(internal.iter().any(|entry| entry.id == ids[2]));
assert!(!internal.iter().any(|entry| entry.id == ids[1]));
assert!(internal.iter().any(|(u, _)| u.id() == ids[0]));
assert!(internal.iter().any(|(u, _)| u.id() == ids[2]));
assert!(!internal.iter().any(|(u, _)| u.id() == ids[1]));
sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)")
.bind(ids.as_slice())
+68 -30
View File
@@ -22,7 +22,7 @@ use crate::application::dtos::settings_dto::{
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
UpdateUserRoleDto,
};
use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto};
use crate::application::dtos::user_dto::{FullUserDto, PublicUserDto};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
// JobStoreProvider is used only by the storage-migration shims below,
@@ -39,16 +39,14 @@ use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
use uuid::Uuid;
#[derive(serde::Serialize)]
#[serde(untagged)]
enum AdminUsersPayload {
Full(Vec<UserDto>),
Summary(Vec<AdminUserSummaryDto>),
}
/// Response envelope for `GET /api/admin/users`. `users` is always
/// `Vec<FullUserDto>` — same shape one row of `/me`'s embedded
/// `full` block carries; the FE seeds `resolveUser` cache from
/// `row.user` (kills the per-row `/api/users/{id}` fetch). See
/// `docs/plan/userdto-refactor.md`.
#[derive(serde::Serialize)]
struct AdminUsersPageResponse {
users: AdminUsersPayload,
users: Vec<FullUserDto>,
total: i64,
limit: i64,
offset: i64,
@@ -931,6 +929,51 @@ pub async fn get_dashboard_stats(
.await
.map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?;
// External account count — distinct query (not FILTERed into
// `stats_row` above) because `stats_row` scopes to
// `is_external = false` for the operational-seat counts.
// Externals form their own population; the dashboard renders them
// as a separate stat card in the "User accounts" section.
let external_users: i64 =
sqlx::query_scalar(r#"SELECT COUNT(*)::INT8 FROM auth.users WHERE is_external = true"#)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("External user count failed: {}", e)))?;
// Live-activity counts — projection over auth.sessions, same
// `ONLINE_WINDOW` (5 min) the Prometheus gauges use so the
// dashboard number, admin-table green dot, and
// `oxicloud_sessions_online` scrape all agree by construction.
// Bound as `$1 = window_secs` via `make_interval(secs => $1)`
// to keep the single-source-of-truth pattern (no SQL literal
// for the window). Both queries hit the partial index
// `idx_sessions_last_seen_at WHERE revoked = FALSE` so per-run
// cost is ~μs even at tens of thousands of session rows.
let online_window_secs: f64 =
crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64();
let online_sessions: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)::INT8 FROM auth.sessions
WHERE revoked = FALSE
AND last_seen_at > NOW() - make_interval(secs => $1)
"#,
)
.bind(online_window_secs)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("Online session count failed: {}", e)))?;
let online_users: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(DISTINCT user_id)::INT8 FROM auth.sessions
WHERE revoked = FALSE
AND last_seen_at > NOW() - make_interval(secs => $1)
"#,
)
.bind(online_window_secs)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("Online user count failed: {}", e)))?;
use sqlx::Row;
// Per-drive-kind quota panel:
@@ -1015,6 +1058,9 @@ pub async fn get_dashboard_stats(
total_users: stats_row.get("total_users"),
active_users: stats_row.get("active_users"),
admin_users: stats_row.get("admin_users"),
external_users,
online_users,
online_sessions,
drive_usage,
users_over_80_percent: stats_row.get("users_over_80"),
users_over_quota: stats_row.get("users_over_quota"),
@@ -1037,13 +1083,20 @@ pub async fn get_dashboard_stats(
// ============================================================================
/// 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(
get,
path = "/api/admin/users",
params(
("limit" = Option<i64>, Query, description = "Max users to return (default 100, max 500)"),
("offset" = Option<i64>, Query, description = "Pagination offset"),
("summary" = Option<bool>, Query, description = "Return the compact management-table projection")
("offset" = Option<i64>, Query, description = "Pagination offset")
),
responses(
(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
// search, where surfacing externals would leak identities. See
// `auth_application_service::list_users` doc for the split.
let users = if query.summary.unwrap_or(false) {
AdminUsersPayload::Summary(
auth.auth_application_service
let users = auth
.auth_application_service
.list_user_summaries_including_external_with_perms(
state.authorization.as_ref(),
auth_user.id,
@@ -1081,21 +1133,7 @@ pub async fn list_users(
offset,
)
.await
.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)?,
)
};
.map_err(AppError::from)?;
let total = auth
.auth_application_service
@@ -1562,7 +1600,7 @@ pub async fn reset_user_password(
path = "/api/admin/users/{id}/promote-to-internal",
params(("id" = String, Path, description = "Target user id")),
responses(
(status = 200, description = "User promoted", body = UserDto),
(status = 200, description = "User promoted", body = PublicUserDto),
(status = 400, description = "Magic-link login is disabled on this deployment"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required (or target is OIDC-linked)"),
@@ -61,7 +61,7 @@ async fn create_app_password(
}
// Require a claimed username. NextCloud Basic Auth resolves users by
// username; an app password is unusable without one. UserDto carries
// username; an app password is unusable without one. PublicUserDto carries
// an empty string when the underlying `users.username` is NULL — the
// entity rejects empty strings on construction, so empty here is an
// unambiguous signal that the column is NULL.
+62 -46
View File
@@ -12,8 +12,8 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto,
UserDto,
OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto,
UpgradeToInternalDto,
};
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
use crate::common::di::AppState;
@@ -89,7 +89,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
/// the `audit` channel as `auth.register` with `reason` one of
/// `created`, `email_taken`, `username_taken`.
/// - **SMTP not configured**: there is no welcome-mail cover story, so
/// the classic `201 + UserDto` on success and `409` on collision
/// the classic `201 + PublicUserDto` on success and `409` on collision
/// apply. Anti-enumeration would just be misleading UX (telling the
/// user to check an email that will never arrive). Email-only
/// signup is **503** in this mode because the user would otherwise
@@ -106,7 +106,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
request_body = RegisterDto,
responses(
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"),
(status = 201, description = "User registered successfully (SMTP not configured)", body = UserDto),
(status = 201, description = "User registered successfully (SMTP not configured)", body = PublicUserDto),
(status = 400, description = "Validation error (malformed request body)"),
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
(status = 409, description = "Username or email already taken (SMTP not configured)"),
@@ -280,7 +280,7 @@ pub async fn register(
}
Ok(resp)
} else {
// Classic mode: clear 201 + UserDto so the frontend can
// Classic mode: clear 201 + PublicUserDto so the frontend can
// log the user in directly with the password they just
// submitted. Unbox the DTO for the JSON serialisation.
Ok((StatusCode::CREATED, Json(*user)).into_response())
@@ -626,7 +626,7 @@ pub async fn refresh_token(
get,
path = "/api/auth/me",
responses(
(status = 200, description = "Current user profile", body = UserDto),
(status = 200, description = "Current user profile", body = SelfUserDto),
(status = 401, description = "Not authenticated"),
),
security(("bearerAuth" = [])),
@@ -652,37 +652,21 @@ pub async fn get_current_user(
// Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
// of `used_bytes` across the user's personal drives only. Shared drives
// never count against this envelope — collaborating in a team drive
// costs no personal bytes. The matching cap is
// `storage_quota_bytes` (admin-only mutation).
let mut user = auth_service
// costs no personal bytes.
//
// Delegate to the shared `build_self_user_dto_for_id` — same code
// path `PATCH /me/profile` and `POST /upgrade-to-internal` use so
// all three self endpoints ship byte-for-byte identical shapes.
// The DPoP-bound signal comes from the JWT `cnf.jkt` claim
// (surfaced by the auth middleware into `AuthUser.dpop_jkt`);
// when present the session that minted this JWT is bound and
// the SPA can skip a redundant `/dpop/bind` call.
let self_dto = auth_service
.auth_application_service
.get_user_by_id(user_id)
.build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?;
// Overlay the cached `force_password_change` flag (see UserFlags).
// `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)))
Ok((StatusCode::OK, Json(self_dto)))
}
/// DTO for updating the user's profile image.
@@ -837,14 +821,16 @@ pub async fn change_password(
/// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`.
///
/// Response: the updated `UserDto` (post-upgrade view — `is_external`
/// is false, `storage_quota_bytes` is set).
/// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA
/// absorbs the post-upgrade state — new `storage_quota_bytes`,
/// `is_external = false`, updated OPAQUE / auth capability flags — in one
/// round trip without a follow-up `/me` fetch.
#[utoipa::path(
post,
path = "/api/auth/upgrade-to-internal",
request_body = UpgradeToInternalDto,
responses(
(status = 200, description = "Upgrade succeeded", body = UserDto),
(status = 200, description = "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)", body = SelfUserDto),
(status = 400, description = "Password missing / too short"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC user, or domain not in allowlist"),
@@ -855,9 +841,10 @@ pub async fn change_password(
)]
pub async fn upgrade_to_internal(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
auth_user: AuthUser,
Json(dto): Json<UpgradeToInternalDto>,
) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state
.auth_service
.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
.upgrade_to_internal(user_id, dto)
.await
@@ -924,7 +915,11 @@ pub async fn upgrade_to_internal(
_ => AppError::from(err),
})?;
Ok((StatusCode::OK, Json(updated)))
let self_dto = auth_service
.auth_application_service
.build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?;
Ok((StatusCode::OK, Json(self_dto)))
}
/// Update the caller's profile (PR 24).
@@ -942,7 +937,7 @@ pub async fn upgrade_to_internal(
path = "/api/auth/me/profile",
request_body = crate::application::dtos::user_dto::UpdateProfileDto,
responses(
(status = 200, description = "Updated profile (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 = 401, description = "Not authenticated"),
(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(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
auth_user: AuthUser,
Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>,
) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
let updated = auth_service
// Apply the patch. The service returns the updated `PublicUserDto`
// internally; we discard it and re-fetch the full self view below
// so the response matches `GET /me`'s `SelfUserDto` shape.
//
// Why SelfUserDto instead of PublicUserDto: a self-write endpoint
// whose response mirrors GET /me lets the SPA update its session
// store in one round trip. Returning a slim PublicUserDto would
// force the SPA to follow up with GET /me anyway to observe the
// just-written `ui_preferences` / `notify_on_share` / etc — those
// fields live on SelfUserDto only, not on the public identity
// slice. Same shape for both endpoints avoids "quiet lie" reads
// where a client PATCHes and then reads a stale local value.
let _ = auth_service
.auth_application_service
.update_profile_with_perms(user_id, dto, &state.locale_registry)
.await?;
Ok((StatusCode::OK, Json(updated)))
// Rebuild via the shared helper so the wire shape matches
// `GET /me` and `POST /upgrade-to-internal` byte-for-byte.
let self_dto = auth_service
.auth_application_service
.build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?;
Ok((StatusCode::OK, Json(self_dto)))
}
// TODO: add utoipa
@@ -1226,7 +1240,7 @@ pub struct BackchannelLogoutForm {
path = "/api/setup",
request_body = SetupAdminDto,
responses(
(status = 201, description = "First admin created and system initialized", body = UserDto),
(status = 201, description = "First admin created and system initialized", body = PublicUserDto),
(status = 403, description = "System already initialized"),
(status = 503, description = "Auth service not configured"),
),
@@ -1820,10 +1834,12 @@ pub async fn oidc_exchange(
tracing::info!(
"OIDC token exchange successful for user: {}",
auth_response
.user
.full
.user
.username
.as_deref()
.unwrap_or(&auth_response.user.email)
.unwrap_or(&auth_response.user.full.user.email)
);
// Set HttpOnly cookies for the browser
@@ -16,7 +16,7 @@ use crate::application::dtos::contact_dto::{
AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto,
GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto,
};
use crate::application::dtos::user_dto::UserDto;
use crate::application::dtos::user_dto::PublicUserDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::application::services::contact_service::ContactService;
@@ -185,14 +185,14 @@ fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool {
}
}
/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts
/// Map a `PublicUserDto` to a `ContactDto` so OxiCloud users appear as contacts
/// inside the virtual system address book.
///
/// `given_name`/`family_name` come from OIDC standard claims at JIT
/// provisioning (or NULL for password-only or pre-OIDC users). When
/// they're present, prefer a "First Last" full name; otherwise fall
/// back to the username (which is always present).
fn user_to_contact(user: UserDto) -> ContactDto {
fn user_to_contact(user: PublicUserDto) -> ContactDto {
// Display fallback chain: given+family name → username → email.
// Username is `Option<String>` post PR 16; externals start with None.
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
@@ -222,8 +222,19 @@ fn user_to_contact(user: UserDto) -> ContactDto {
photo_url: user.image.clone(),
birthday: None,
anniversary: None,
created_at: user.created_at,
updated_at: user.updated_at,
// System-book contacts are VIRTUAL projections of the user
// directory — they have no independent creation history. Stamp
// both timestamps with `Utc::now()` so the ContactDto shape is
// satisfied; CardDAV clients ETag on the vCard content (see
// `etag` below, keyed on the stable user id), not on these
// wrapper timestamps.
//
// Previously read `user.created_at` / `user.updated_at` from the
// fat `UserDto`; those fields moved to `FullUserDto` under the
// three-layer refactor (docs/plan/userdto-refactor.md) and are
// not exposed on the slim `PublicUserDto` this function receives.
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
etag: user.id,
}
}
@@ -625,7 +625,7 @@ fn redirect_target(redemption: &MagicLinkRedemption) -> String {
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
format!("/files/{}", folder_id)
}
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
_ if redemption.auth.user.full.user.is_external => "/shared-with-me".to_string(),
_ => "/files".to_string(),
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! User-profile lookup for the frontend.
//!
//! `GET /api/users/{id}` returns a [`UserDto`] for the target user iff
//! `GET /api/users/{id}` returns a [`PublicUserDto`] for the target user iff
//! the authenticated caller has a legitimate relationship with them.
//! The visibility rule lives in
//! [`AuthApplicationService::get_user_profile`] — handlers never embed
+3 -3
View File
@@ -46,7 +46,7 @@ use crate::application::dtos::trash_dto::{
};
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
PublicUserDto, RefreshTokenDto, RegisterDto, SetupAdminDto,
};
use crate::application::ports::chunked_upload_ports::{
ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto,
@@ -367,7 +367,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
PaginationDto,
PaginationRequestDto,
// User / Auth schemas
UserDto,
PublicUserDto,
LoginDto,
RegisterDto,
SetupAdminDto,
@@ -583,7 +583,7 @@ mod tests {
"FolderDto",
"ShareDto",
"TrashedItemDto",
"UserDto",
"PublicUserDto",
] {
assert!(schemas.contains_key(name), "missing schema: {name}");
}
+33 -7
View File
@@ -191,7 +191,15 @@ async fn user_provisioning_response(
return Json(ocs_err(997, "Database pool not available")).into_response();
};
let user_dto = match auth_service
// Two-step lookup: (1) `get_user_profile_by_username_with_perms`
// gates access via the same visibility engine the REST endpoint
// uses; (2) if visibility passes, `get_user_with_derived_flags`
// hydrates the OCS-specific fields (federation_kind / last_login_at
// / active) that live on `FullUserDto` but not on the slim
// `PublicUserDto` returned by the visibility gate. Second call is
// ~1 DB round-trip on the maintenance pool; NC OCS provisioning is
// not on any hot inner loop.
let public = match auth_service
.get_user_profile_by_username_with_perms(
user.id,
&userid,
@@ -205,9 +213,27 @@ async fn user_provisioning_response(
return Json(ocs_err(404, "User not found")).into_response();
}
};
let target_id = match uuid::Uuid::parse_str(&public.id) {
Ok(u) => u,
Err(_) => {
// Should be unreachable — PublicUserDto.id is always the
// serialised form of a Uuid. Fail closed if this invariant
// is ever violated.
return Json(ocs_err(500, "Malformed user id")).into_response();
}
};
let user_dto = match auth_service.get_user_with_derived_flags(target_id).await {
Ok((user, flags)) => crate::application::dtos::user_dto::FullUserDto::build(user, flags),
Err(_) => {
// Visibility already passed above; a miss here would mean
// the user was deleted between the two round-trips. Fall
// back to the 404 shape (anti-enum invariant still holds).
return Json(ocs_err(404, "User not found")).into_response();
}
};
// Determine groups based on role
let groups = if user_dto.role == "admin" {
let groups = if user_dto.user.role == "admin" {
vec!["admin", "users"]
} else {
vec!["users"]
@@ -235,7 +261,7 @@ async fn user_provisioning_response(
// Fetch quota from storage usage service
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service
.get_user_storage_info(uuid::Uuid::parse_str(&user_dto.id).unwrap_or_default())
.get_user_storage_info(uuid::Uuid::parse_str(&user_dto.user.id).unwrap_or_default())
.await
{
Ok((used, total)) => (used, total),
@@ -256,10 +282,10 @@ async fn user_provisioning_response(
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
"data": {
"enabled": user_dto.active,
"id": user_dto.username,
"display-name": user_dto.username,
"displayname": user_dto.username,
"email": user_dto.email,
"id": user_dto.user.username,
"display-name": user_dto.user.username,
"displayname": user_dto.user.username,
"email": user_dto.user.email,
"phone": "",
"address": "",
"website": "",
+3 -3
View File
@@ -56,7 +56,7 @@ Content-Type: application/json
HTTP 201
[Captures]
charlie_id: jsonpath "$.id"
charlie_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
@@ -89,7 +89,7 @@ Authorization: Bearer {{charlie_token_v1}}
HTTP 200
[Asserts]
jsonpath "$.storage_quota_bytes" == 209715200
jsonpath "$.full.storage_quota_bytes" == 209715200
# ─────────────────────────────────────────────────────────────
@@ -108,7 +108,7 @@ Authorization: Bearer {{charlie_token_v1}}
HTTP 200
[Asserts]
jsonpath "$.role" == "admin"
jsonpath "$.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -19,7 +19,7 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}"
jsonpath "$.user.full.user.email" == "{{email}}"
# ─────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}"
jsonpath "$.user.full.user.email" == "{{email}}"
# ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -142,10 +142,10 @@ Authorization: Bearer {{alice_magic_access_token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "{{email}}"
jsonpath "$.username" == "{{username}}"
jsonpath "$.full.user.email" == "{{email}}"
jsonpath "$.full.user.username" == "{{username}}"
[Captures]
admin_user_id: jsonpath "$.id"
admin_user_id: jsonpath "$.full.user.id"
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -78,7 +78,7 @@ Authorization: Bearer {{access_v2}}
HTTP 200
[Asserts]
jsonpath "$.username" == "{{username}}"
jsonpath "$.full.user.username" == "{{username}}"
# ─────────────────────────────────────────────────────────────
+9 -9
View File
@@ -52,7 +52,7 @@ Content-Type: application/json
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
bob_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
@@ -73,8 +73,8 @@ Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$.is_external" == true
jsonpath "$.storage_quota_bytes" == 0
jsonpath "$.full.user.is_external" == true
jsonpath "$.full.storage_quota_bytes" == 0
# ─────────────────────────────────────────────────────────────
@@ -88,8 +88,8 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.is_external" == false
jsonpath "$.storage_quota_bytes" > 0
jsonpath "$.full.user.is_external" == false
jsonpath "$.full.storage_quota_bytes" > 0
# ─────────────────────────────────────────────────────────────
@@ -100,8 +100,8 @@ Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$.is_external" == false
jsonpath "$.storage_quota_bytes" > 0
jsonpath "$.full.user.is_external" == false
jsonpath "$.full.storage_quota_bytes" > 0
# ─────────────────────────────────────────────────────────────
@@ -179,7 +179,7 @@ Content-Type: application/json
HTTP 201
[Captures]
carol_user_id: jsonpath "$.id"
carol_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -204,7 +204,7 @@ Authorization: Bearer {{carol_token}}
HTTP 200
[Asserts]
jsonpath "$.is_external" == true
jsonpath "$.full.user.is_external" == true
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -41,7 +41,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
alice_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -120,7 +120,7 @@ Content-Type: application/json
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id"
bob_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -24,7 +24,7 @@ Content-Type: application/json
HTTP 200
[Captures]
token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer"
@@ -344,7 +344,7 @@ Content-Type: application/json
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id"
bob_user_id: jsonpath "$.user.full.user.id"
# Step 17 — Bob's book listing does NOT include Alice's book.
+1 -1
View File
@@ -71,7 +71,7 @@ Content-Type: application/json
HTTP 200
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
owner_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -67,7 +67,7 @@ Content-Type: application/json
HTTP 200
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
owner_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -249,7 +249,7 @@ Content-Type: application/json
HTTP *
[Captures]
alice_id: jsonpath "$.id"
alice_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -103,7 +103,7 @@ Content-Type: application/json
HTTP *
[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
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
owner_user_id: jsonpath "$.user.full.user.id"
# Provision `dp_intruder` — a second internal user used only to
@@ -91,7 +91,7 @@ Content-Type: application/json
HTTP 200
[Captures]
intruder_token: jsonpath "$.access_token"
intruder_user_id: jsonpath "$.user.id"
intruder_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -60,7 +60,7 @@ Content-Type: application/json
HTTP 200
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
owner_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -82,7 +82,7 @@ Content-Type: application/json
HTTP 200
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
owner_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -107,7 +107,7 @@ Content-Type: application/json
HTTP 200
[Captures]
target_user_id: jsonpath "$.user.id"
target_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -31,7 +31,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -70,7 +70,7 @@ Content-Type: application/json
HTTP 201
[Captures]
alice_user_id: jsonpath "$.id"
alice_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
@@ -79,7 +79,7 @@ Content-Type: application/json
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
bob_user_id: jsonpath "$.user.id"
# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login`
+5 -5
View File
@@ -64,7 +64,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -113,7 +113,7 @@ Content-Type: application/json
HTTP 201
[Captures]
alice_user_id: jsonpath "$.id"
alice_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -470,7 +470,7 @@ Content-Type: application/json
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -657,7 +657,7 @@ Content-Type: application/json
HTTP 201
[Captures]
carol_user_id: jsonpath "$.id"
carol_user_id: jsonpath "$.user.id"
# 24a — Owner grants Carol Owner role (Owner-creates-Owner).
@@ -836,7 +836,7 @@ Content-Type: application/json
HTTP 201
[Captures]
dave_user_id: jsonpath "$.id"
dave_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
+36 -10
View File
@@ -23,7 +23,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
@@ -226,13 +226,16 @@ Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
# `/api/users/{id}` returns the slim `PublicUserDto` (9 fields) —
# id / email / username / role / image / is_external / given_name /
# family_name / is_online. Admin-visible fields like
# `email_verified_at` moved to `FullUserDto` under the three-layer
# refactor (docs/plan/userdto-refactor.md) and are checked below
# via `/api/admin/users`.
jsonpath "$.id" == "{{bob_user_id}}"
jsonpath "$.is_external" == true
jsonpath "$.email" == "bob@externalcompany.com"
jsonpath "$.username" not exists
# PR 23 — bob redeemed his invitation magic-link in Step 8, so his
# email_verified_at was stamped at that time and stays set.
jsonpath "$.email_verified_at" exists
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
# lets the external recipient resolve the sharer's display name +
@@ -244,14 +247,37 @@ HTTP 200
[Asserts]
jsonpath "$.id" == "{{alice_user_id}}"
jsonpath "$.is_external" == false
# Setup admin is auto-verified at creation. `setup_create_admin` stamps
# 11c/d/verify — admin (alice) observes email_verified_at on both
# users via `GET /api/admin/users/{id}` — returns `FullUserDto`
# (public identity in `.user` + admin-visible extras at top level).
#
# `email_verified_at` lives on `FullUserDto` (admin+self-visible),
# not on `PublicUserDto` — peer views via `/api/users/{id}` never
# expose it. The admin single-user endpoint is the correct
# observation surface. See `docs/plan/userdto-refactor.md` for the
# three-layer split.
#
# Setup admin auto-verified rationale: `setup_create_admin` stamps
# `email_verified_at = NOW()` — admin fiat counts as verification,
# matching the OIDC-JIT convention. Rationale: an operator running the
# first-run wizard is authoritative by construction (they set the
# password at the console on a fresh install). Without this, flipping
# matching the OIDC-JIT convention. An operator running the first-run
# wizard is authoritative by construction. Without this, flipping
# `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment
# would lock the sole admin out of their own instance. The admin login
# exemption is a second layer of defense; this stamp is the primary.
# would lock the sole admin out of their own instance.
GET {{base_url}}/api/admin/users/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.user.id" == "{{bob_user_id}}"
jsonpath "$.email_verified_at" exists
GET {{base_url}}/api/admin/users/{{alice_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.user.id" == "{{alice_user_id}}"
jsonpath "$.email_verified_at" exists
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
+2 -2
View File
@@ -19,11 +19,11 @@ Content-Type: application/json
HTTP 200
[Captures]
token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer"
jsonpath "$.user.id" isString
jsonpath "$.user.full.user.id" isString
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -29,7 +29,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders
@@ -57,7 +57,7 @@ Content-Type: application/json
HTTP 201
[Captures]
mallory_user_id: jsonpath "$.id"
mallory_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
+5 -5
View File
@@ -23,7 +23,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 201
[Captures]
dave_user_id: jsonpath "$.id"
dave_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
@@ -54,7 +54,7 @@ Content-Type: application/json
HTTP 201
[Captures]
eve_user_id: jsonpath "$.id"
eve_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
@@ -371,7 +371,7 @@ Content-Type: application/json
HTTP 201
[Captures]
adam_user_id: jsonpath "$.id"
adam_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -1044,7 +1044,7 @@ Content-Type: application/json
HTTP 201
[Captures]
frank_user_id: jsonpath "$.id"
frank_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
+1 -1
View File
@@ -44,7 +44,7 @@ Content-Type: application/json
HTTP 201
[Captures]
henry_user_id: jsonpath "$.id"
henry_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
+1 -1
View File
@@ -76,7 +76,7 @@ Content-Type: application/json
HTTP 201
[Captures]
dora_id: jsonpath "$.id"
dora_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -48,7 +48,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200
[Captures]
jwt: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -50,5 +50,5 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.user.username" == "bob"
jsonpath "$.user.email" == "bob@example.com"
jsonpath "$.user.full.user.username" == "bob"
jsonpath "$.user.full.user.email" == "bob@example.com"
+5 -5
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_jwt: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ═════════════════════════════════════════════════════════════
@@ -71,7 +71,7 @@ Content-Type: application/json
HTTP 201
[Captures]
editor_user_id: jsonpath "$.id"
editor_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}}
@@ -85,7 +85,7 @@ Content-Type: application/json
HTTP 201
[Captures]
viewer_user_id: jsonpath "$.id"
viewer_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}}
@@ -99,7 +99,7 @@ Content-Type: application/json
HTTP 201
[Captures]
outsider_user_id: jsonpath "$.id"
outsider_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
@@ -434,7 +434,7 @@ Content-Type: application/json
HTTP 201
[Captures]
quota_owner_id: jsonpath "$.id"
quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+4 -4
View File
@@ -40,7 +40,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_jwt: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ═════════════════════════════════════════════════════════════
@@ -65,7 +65,7 @@ Content-Type: application/json
HTTP 201
[Captures]
editor_user_id: jsonpath "$.id"
editor_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}}
@@ -79,7 +79,7 @@ Content-Type: application/json
HTTP 201
[Captures]
viewer_user_id: jsonpath "$.id"
viewer_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
@@ -351,7 +351,7 @@ Content-Type: application/json
HTTP 201
[Captures]
quota_owner_id: jsonpath "$.id"
quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+1 -1
View File
@@ -85,7 +85,7 @@ Content-Type: application/json
HTTP 200
[Captures]
ncq_owner_jwt: jsonpath "$.access_token"
ncq_owner_id: jsonpath "$.user.id"
ncq_owner_id: jsonpath "$.user.full.user.id"
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{ncq_owner_jwt}}
+2 -2
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
alice_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -126,7 +126,7 @@ Content-Type: application/json
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id"
bob_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+18 -18
View File
@@ -55,7 +55,7 @@ Content-Type: application/json
HTTP 200
[Captures]
charlie_token: jsonpath "$.access_token"
charlie_user_id: jsonpath "$.user.id"
charlie_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -139,16 +139,16 @@ Authorization: Bearer {{pr18_access_token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "pr18-emailonly@example.com"
jsonpath "$.is_external" == false
jsonpath "$.username" not exists
jsonpath "$.full.user.email" == "pr18-emailonly@example.com"
jsonpath "$.full.user.is_external" == false
jsonpath "$.full.user.username" not exists
# PR 23 — the user redeemed the welcome magic-link in Step 5b, so
# email_verified_at is stamped (the click IS the proof of inbox
# control, regardless of whether the redemption went through the
# direct or cross-browser-confirm path).
jsonpath "$.email_verified_at" exists
jsonpath "$.full.email_verified_at" exists
[Captures]
pr18_user_id: jsonpath "$.id"
pr18_user_id: jsonpath "$.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -162,9 +162,9 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.id" == "{{pr18_user_id}}"
jsonpath "$.username" not exists
jsonpath "$.given_name" not exists
jsonpath "$.full.user.id" == "{{pr18_user_id}}"
jsonpath "$.full.user.username" not exists
jsonpath "$.full.user.given_name" not exists
# ─────────────────────────────────────────────────────────────
@@ -178,9 +178,9 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.given_name" == "Pee Are"
jsonpath "$.family_name" == "Eighteen"
jsonpath "$.username" not exists
jsonpath "$.full.user.given_name" == "Pee Are"
jsonpath "$.full.user.family_name" == "Eighteen"
jsonpath "$.full.user.username" not exists
# ─────────────────────────────────────────────────────────────
@@ -220,7 +220,7 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.username" == "pr18handle"
jsonpath "$.full.user.username" == "pr18handle"
# ─────────────────────────────────────────────────────────────
@@ -263,7 +263,7 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.given_name" == "Pr18@Handle"
jsonpath "$.full.user.given_name" == "Pr18@Handle"
# ─────────────────────────────────────────────────────────────
@@ -276,10 +276,10 @@ Authorization: Bearer {{pr18_access_token}}
HTTP 200
[Asserts]
jsonpath "$.username" == "pr18handle"
jsonpath "$.given_name" == "Pr18@Handle"
jsonpath "$.family_name" == "Eighteen"
jsonpath "$.email_verified_at" exists
jsonpath "$.full.user.username" == "pr18handle"
jsonpath "$.full.user.given_name" == "Pr18@Handle"
jsonpath "$.full.user.family_name" == "Eighteen"
jsonpath "$.full.email_verified_at" exists
# ─────────────────────────────────────────────────────────────
@@ -80,7 +80,7 @@ Content-Type: application/json
HTTP 200
[Captures]
user_token: jsonpath "$.access_token"
user_user_id: jsonpath "$.user.id"
user_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -34,7 +34,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
@@ -56,7 +56,7 @@ Content-Type: application/json
HTTP 201
[Captures]
renee_user_id: jsonpath "$.id"
renee_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users
@@ -66,7 +66,7 @@ Content-Type: application/json
HTTP 201
[Captures]
sam_user_id: jsonpath "$.id"
sam_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
+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
# 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")
ADMIN_USER_ID=$(echo "$USERS_JSON" \
| jq -r --arg u "$username" '.users[] | select(.username == $u) | .id')
| jq -r --arg u "$username" '.users[] | select(.user.username == $u) | .user.id')
[[ -z "$ADMIN_USER_ID" || "$ADMIN_USER_ID" == "null" ]] && fail "could not resolve admin user id"
OTHER_USER_IDS=$(echo "$USERS_JSON" \
| jq -r --arg admin_id "$ADMIN_USER_ID" '.users[] | select(.id != $admin_id) | .id')
| jq -r --arg admin_id "$ADMIN_USER_ID" '.users[] | select(.user.id != $admin_id) | .user.id')
OTHER_USER_COUNT=0
while IFS= read -r uid; do
+2 -2
View File
@@ -35,7 +35,7 @@ Content-Type: application/json
HTTP 201
[Captures]
grace_user_id: jsonpath "$.id"
grace_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -268,7 +268,7 @@ Content-Type: application/json
HTTP 201
[Captures]
helper_user_id: jsonpath "$.id"
helper_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/groups/{{engineers_id}}/members
+2 -2
View File
@@ -51,7 +51,7 @@ Content-Type: application/json
HTTP 201
[Captures]
owner_user_id: jsonpath "$.id"
owner_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -209,7 +209,7 @@ Content-Type: application/json
HTTP 201
[Captures]
viewer_user_id: jsonpath "$.id"
viewer_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
+6 -6
View File
@@ -64,7 +64,7 @@ Content-Type: application/json
HTTP 200
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
owner_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -92,7 +92,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.storage_used_bytes" == 0
jsonpath "$.full.storage_used_bytes" == 0
# ─────────────────────────────────────────────────────────────
@@ -173,7 +173,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.storage_used_bytes" == 0
jsonpath "$.full.storage_used_bytes" == 0
# ─────────────────────────────────────────────────────────────
@@ -202,7 +202,7 @@ retry-interval: 200ms
HTTP 200
[Asserts]
jsonpath "$.storage_used_bytes" == 32
jsonpath "$.full.storage_used_bytes" == 32
# Confirm the sweep agrees with the delta — both code paths must
@@ -217,7 +217,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.storage_used_bytes" == 32
jsonpath "$.full.storage_used_bytes" == 32
# ─────────────────────────────────────────────────────────────
@@ -247,7 +247,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.storage_used_bytes" == 0
jsonpath "$.full.storage_used_bytes" == 0
# ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -174,7 +174,7 @@ Content-Type: application/json
HTTP 201
[Captures]
quota_owner_id: jsonpath "$.id"
quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+2 -2
View File
@@ -35,7 +35,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
@@ -54,7 +54,7 @@ Content-Type: application/json
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -151,7 +151,7 @@ Content-Type: application/json
HTTP 200
[Captures]
wq_owner_token: jsonpath "$.access_token"
wq_owner_id: jsonpath "$.user.id"
wq_owner_id: jsonpath "$.user.full.user.id"
POST {{base_url}}/api/drives
+2 -2
View File
@@ -39,7 +39,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders
@@ -65,7 +65,7 @@ Content-Type: application/json
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -47,7 +47,7 @@ Content-Type: application/json
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
admin_user_id: jsonpath "$.user.full.user.id"
# ─────────────────────────────────────────────────────────────
+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
// id, not name) — avoids the crowded root listing and click ambiguity.
await page.goto(`/files/${created.id}`);
await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 });
// The hidden file input renders unconditionally on mount, so waiting on
// `toBeAttached` fires BEFORE the page's `load()` populates `currentId`
// from the URL. Firing `setInputFiles` in that window used to race
// `load()` and post the upload with `folderId: null`, silently landing
// the file in the caller's home root — the guard in
// `guardUploadFolderReady` now refuses that upload with a toast. Wait
// for the empty-state hook instead — `ResourceList` only renders
// `EmptyState` once `load()` has definitively completed with zero
// items, so it doubles as a "folder is ready to accept uploads" signal.
await expect(page.getByTestId('empty-state')).toBeVisible({ timeout: 15_000 });
// Now inside the folder; upload a text file by setting the hidden input.
const f = SAMPLE_FILES.text();
await page.getByTestId('files-upload-file-input').setInputFiles({
+26 -26
View File
@@ -98,11 +98,11 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.username" == "{{username}}"
jsonpath "$.full.user.username" == "{{username}}"
# federation_kind is skip_serializing_if=Option::is_none, so a
# local user's response OMITS the field entirely.
jsonpath "$.federation_kind" not exists
jsonpath "$.federation_issuer" not exists
jsonpath "$.full.federation_kind" not exists
jsonpath "$.full.federation_issuer" not exists
# ─────────────────────────────────────────────────────────────
@@ -202,8 +202,8 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" not exists
jsonpath "$.federation_issuer" not exists
jsonpath "$.full.federation_kind" not exists
jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════
@@ -252,8 +252,8 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" not exists
jsonpath "$.federation_issuer" not exists
jsonpath "$.full.federation_kind" not exists
jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════
@@ -307,9 +307,9 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.username" == "{{username}}"
jsonpath "$.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}"
jsonpath "$.full.user.username" == "{{username}}"
jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Scenario 9 — unlink success (admin has a password, so the
@@ -326,8 +326,8 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" not exists
jsonpath "$.federation_issuer" not exists
jsonpath "$.full.federation_kind" not exists
jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════
@@ -380,7 +380,7 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" == "oidc"
jsonpath "$.full.federation_kind" == "oidc"
# Unlink to reset state before the auto-link scenarios.
@@ -447,9 +447,9 @@ HTTP 200
[Asserts]
# Auto-link resolved to the pre-existing admin, NOT a fresh
# JIT-provisioned user. The load-bearing assertion.
jsonpath "$.user.username" == "{{username}}"
jsonpath "$.user.federation_kind" == "oidc"
jsonpath "$.user.federation_issuer" == "{{oidc_issuer}}"
jsonpath "$.user.full.user.username" == "{{username}}"
jsonpath "$.user.full.federation_kind" == "oidc"
jsonpath "$.user.full.federation_issuer" == "{{oidc_issuer}}"
[Captures]
# Fresh cookies replace the password session's; capture the
# new CSRF for the unlink below.
@@ -463,9 +463,9 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.username" == "{{username}}"
jsonpath "$.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}"
jsonpath "$.full.user.username" == "{{username}}"
jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Reset admin state before the next scenario (auto-link would
@@ -535,7 +535,7 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" not exists
jsonpath "$.full.federation_kind" not exists
# Reset fake IdP state (email_verified back to true, sub back
@@ -599,7 +599,7 @@ X-CSRF-Token: {{autolink_csrf_token}}
HTTP 201
[Captures]
alias_user_id: jsonpath "$.id"
alias_user_id: jsonpath "$.user.id"
# Point the fake IdP at a fresh sub with admin's email. Both
@@ -636,7 +636,7 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" not exists
jsonpath "$.full.federation_kind" not exists
# Cleanup — delete the collider so later scenarios see the same
@@ -701,8 +701,8 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.user.username" == "oidc_user"
jsonpath "$.user.federation_kind" == "oidc"
jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.full.federation_kind" == "oidc"
[Captures]
# Fresh CSRF from the OIDC session cookies — the admin CSRFs
# won't validate against these new cookies.
@@ -730,5 +730,5 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}"
jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
+19 -19
View File
@@ -172,7 +172,7 @@ Content-Type: application/json
HTTP 200
[Captures]
oidc_session_user: jsonpath "$.user.username"
oidc_session_user: jsonpath "$.user.full.user.username"
# Snapshotted so Step 7's refresh can prove the tokens rotated
# rather than being re-issued unchanged. The refresh handler in
# auth_handler.rs always rotates all three cookies (access JWT,
@@ -184,8 +184,8 @@ initial_access_token: jsonpath "$.access_token"
initial_refresh_token: jsonpath "$.refresh_token"
initial_csrf_token: cookie "oxicloud_csrf"
[Asserts]
jsonpath "$.user.username" == "oidc_user"
jsonpath "$.user.email" == "oidc@example.com"
jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.full.user.email" == "oidc@example.com"
jsonpath "$.access_token" isString
# Multiple Set-Cookie headers come back as a list of values, so
# `contains` only matches whole-element strings. Each cookie shows up
@@ -211,10 +211,10 @@ HTTP 200
# Stash the user id for the re-login check in Step 10 below — a
# second OIDC flow with the same `sub` must resolve back to this
# exact user, not silently create a duplicate.
oidc_user_id: jsonpath "$.id"
oidc_user_id: jsonpath "$.full.user.id"
[Asserts]
jsonpath "$.username" == "oidc_user"
jsonpath "$.email" == "oidc@example.com"
jsonpath "$.full.user.username" == "oidc_user"
jsonpath "$.full.user.email" == "oidc@example.com"
# Post the federation-identity rename (docs/plan/ocm.md § Schema
# rename) UserDto exposes federation_kind + federation_issuer as
# separate nullable fields. Local users have both null; OIDC users
@@ -222,24 +222,24 @@ jsonpath "$.email" == "oidc@example.com"
# the fake IdP (tests/oidc/fake_idp/server.js) that URL is the
# issuer published in its discovery document, which matches
# `oidc_issuer` from test.env.
jsonpath "$.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}"
jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js)
# pins these values and OxiCloud must persist each one verbatim during
# JIT provisioning (see auth_application_service.rs around line 2257).
# A regression that drops, swaps, or truncates a claim trips here.
# Note the field name flip on the API side: OIDC `picture` becomes
# UserDto.image (a URL or data URI).
jsonpath "$.given_name" == "OIDC"
jsonpath "$.family_name" == "Test"
jsonpath "$.image" == "https://example.com/oidc-test-user.png"
jsonpath "$.full.user.given_name" == "OIDC"
jsonpath "$.full.user.family_name" == "Test"
jsonpath "$.full.user.image" == "https://example.com/oidc-test-user.png"
# Group-to-role mapping. server-with-oidc.env sets
# OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include
# `groups: ["admin-users"]`. The JIT path intersects the claim against
# the env and promotes the new user from `user` to `admin`. A
# regression here would silently strip (or wrongly grant) admin rights
# for every SSO deployment that uses group-based role mapping.
jsonpath "$.role" == "admin"
jsonpath "$.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
@@ -274,7 +274,7 @@ refreshed_refresh_token: jsonpath "$.refresh_token"
# the (freshly-rotated) `oxicloud_csrf` cookie on the browser.
refreshed_csrf_token: cookie "oxicloud_csrf"
[Asserts]
jsonpath "$.user.username" == "oidc_user"
jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.access_token" isString
jsonpath "$.refresh_token" isString
# All three cookies must rotate. If any value were re-used, a
@@ -296,7 +296,7 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.username" == "oidc_user"
jsonpath "$.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
@@ -412,8 +412,8 @@ HTTP 200
[Asserts]
# Same local id — proves the existing-user resolver matched on `sub`
# (or `oidc_provider + oidc_subject`) instead of minting a new row.
jsonpath "$.user.id" == "{{oidc_user_id}}"
jsonpath "$.user.username" == "oidc_user"
jsonpath "$.user.full.user.id" == "{{oidc_user_id}}"
jsonpath "$.user.full.user.username" == "oidc_user"
# Role from the prior JIT-provisioned admin survives the re-login.
# Two regressions this catches: (a) the existing-user branch wiping
# the role to a default `user`; (b) the existing-user branch
@@ -421,7 +421,7 @@ jsonpath "$.user.username" == "oidc_user"
# IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS
# still resolves to "admin"). Either way, the role should remain
# `admin` — otherwise we have a silent admin demotion on every login.
jsonpath "$.user.role" == "admin"
jsonpath "$.user.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
@@ -876,7 +876,7 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.user.username" == "oidc_user"
jsonpath "$.user.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
@@ -889,7 +889,7 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.username" == "oidc_user"
jsonpath "$.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -123,10 +123,10 @@ Content-Type: application/json
HTTP 200
[Asserts]
jsonpath "$.user.username" == "oidc_user"
jsonpath "$.user.full.user.username" == "oidc_user"
# Group-to-role mapping worked — this is now the admin (and the only
# user).
jsonpath "$.user.role" == "admin"
jsonpath "$.user.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
@@ -138,7 +138,7 @@ GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.username" == "oidc_user"
jsonpath "$.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
@@ -132,9 +132,9 @@ echo " app password minted (id=$APP_PASSWORD_ID)"
# failure path. `storage_quota_bytes == 0` is the unlimited
# sentinel (see `check_storage_quota`); we read it back here in
# case a prior test set a real value.
ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.id')
ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.full.user.id')
[[ -n "$ADMIN_ID" && "$ADMIN_ID" != "null" ]] || fail "Failed to read admin user id"
ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.storage_quota_bytes // 0')
ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.full.storage_quota_bytes // 0')
# Single cleanup on exit:
# - restore admin's original storage envelope (in case Case 3
@@ -247,7 +247,7 @@ echo "[3/3] QUOTA REJECTION — envelope tightened to (used + 100 B), PUT 200 B
# `current + 100` — leaves enough headroom that MKCOL passes
# (`used + 0 = used < used + 100`) while a 200 B chunk PUT
# overflows by exactly 100 (`used + 0 + 200 > used + 100`).
CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.storage_used_bytes')
CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.full.storage_used_bytes')
[[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes"
TIGHT_QUOTA=$(( CURRENT_USED + 100 ))