refactor(User): apply changes on frontend

This commit is contained in:
Edouard Vanbelle
2026-08-21 16:49:32 +02:00
parent a11ae679cf
commit ec9b5087f3
15 changed files with 558 additions and 300 deletions
+11
View File
@@ -1,5 +1,16 @@
# UserDto Refactor — Three-Layer Split (Public / Full / Self) # 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 Establish three DTO shapes for representing a user on the wire, each
with a single unambiguous audience, composed hierarchically so the with a single unambiguous audience, composed hierarchically so the
overlap between audiences is defined ONCE: overlap between audiences is defined ONCE:
+7 -7
View File
@@ -5,7 +5,7 @@
*/ */
import { ApiError, apiFetch } from '$lib/api/client'; import { ApiError, apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; import { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse, User } from '$lib/api/types'; import type { AuthResponse, SelfUser } from '$lib/api/types';
/** /**
* Best-effort parse of the backend `ErrorResponse` shape * Best-effort parse of the backend `ErrorResponse` shape
@@ -45,7 +45,7 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' };
* Failure to build a proof (no keypair, missing WebCrypto) falls back to a * Failure to build a proof (no keypair, missing WebCrypto) falls back to a
* headerless request — the server still accepts it for unbound sessions. * headerless request — the server still accepts it for unbound sessions.
*/ */
export async function fetchMe(): Promise<User | null> { export async function fetchMe(): Promise<SelfUser | null> {
// Build + sign a DPoP proof, send with the header, harvest any // Build + sign a DPoP proof, send with the header, harvest any
// `DPoP-Nonce` off the response into the shared client cache // `DPoP-Nonce` off the response into the shared client cache
// (so the NEXT apiFetch call reuses it — no wasted round trip). // (so the NEXT apiFetch call reuses it — no wasted round trip).
@@ -80,7 +80,7 @@ export async function fetchMe(): Promise<User | null> {
if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send();
if (res.status === 401) return null; if (res.status === 401) return null;
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
return (await res.json()) as User; return (await res.json()) as SelfUser;
} }
/** /**
@@ -408,7 +408,7 @@ export async function setupAdmin(email: string, password: string): Promise<void>
* failure, not an expired access token. Returns the user on success, null on * failure, not an expired access token. Returns the user on success, null on
* any failure so the caller can fall through to the normal login UI. * any failure so the caller can fall through to the normal login UI.
*/ */
export async function exchangeOidcCode(code: string): Promise<User | null> { export async function exchangeOidcCode(code: string): Promise<SelfUser | null> {
try { try {
const res = await fetch('/api/auth/oidc/exchange', { const res = await fetch('/api/auth/oidc/exchange', {
method: 'POST', method: 'POST',
@@ -417,7 +417,7 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
body: JSON.stringify({ code }) body: JSON.stringify({ code })
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = (await res.json()) as { user?: User }; const data = (await res.json()) as { user?: SelfUser };
return data.user ?? null; return data.user ?? null;
} catch { } catch {
return null; return null;
@@ -463,7 +463,7 @@ export async function register(email: string, password?: string, username?: stri
* authenticated; a 401 here IS a genuine "session expired" and the * authenticated; a 401 here IS a genuine "session expired" and the
* refresh interceptor is the right response. * refresh interceptor is the right response.
*/ */
export async function upgradeToInternal(password?: string): Promise<User> { export async function upgradeToInternal(password?: string): Promise<SelfUser> {
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
if (password) body.password = password; if (password) body.password = password;
const res = await apiFetch('/api/auth/upgrade-to-internal', { const res = await apiFetch('/api/auth/upgrade-to-internal', {
@@ -482,7 +482,7 @@ export async function upgradeToInternal(password?: string): Promise<User> {
message message
); );
} }
return (await res.json()) as User; return (await res.json()) as SelfUser;
} }
export type MagicLinkResult = 'sent' | 'unavailable'; export type MagicLinkResult = 'sent' | 'unavailable';
+3 -3
View File
@@ -1,7 +1,7 @@
/** Profile / account endpoints — ported from views/profile/profile.js. */ /** Profile / account endpoints — ported from views/profile/profile.js. */
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; import { getCsrfHeaders } from '$lib/api/csrf';
import type { User } from '$lib/api/types'; import type { PublicUser } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
const JSON_HEADERS = { 'Content-Type': 'application/json' }; const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -24,7 +24,7 @@ export interface ProfilePatch {
ui_preferences?: Record<string, unknown>; ui_preferences?: Record<string, unknown>;
} }
export async function updateProfile(patch: ProfilePatch): Promise<User> { export async function updateProfile(patch: ProfilePatch): Promise<PublicUser> {
const res = await apiFetch('/api/auth/me/profile', { const res = await apiFetch('/api/auth/me/profile', {
method: 'PATCH', method: 'PATCH',
credentials: 'same-origin', credentials: 'same-origin',
@@ -57,7 +57,7 @@ export async function updateProfile(patch: ProfilePatch): Promise<User> {
} }
throw new Error(err.message || err.error || `profile update failed: ${res.status}`); throw new Error(err.message || err.error || `profile update failed: ${res.status}`);
} }
return (await res.json()) as User; return (await res.json()) as PublicUser;
} }
export async function changePassword(currentPw: string, newPw: string): Promise<void> { export async function changePassword(currentPw: string, newPw: string): Promise<void> {
+32
View File
@@ -57,3 +57,35 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
cache.set(id, pending); cache.set(id, pending);
return pending; return pending;
} }
/**
* Prime the resolver cache from data the caller already has in hand.
* When a list endpoint (e.g. `/api/admin/users`) ships full
* `PublicUser` rows, the admin page seeds this cache in its load path
* so every subsequent `resolveUser(id)` call (from `UserVignette`
* mounted per-row) hits the cache synchronously — no per-row
* `/api/users/{id}` follow-up fetch. Kills the N+1 that motivated
* widening `/api/admin/users` to include the avatar (see
* `docs/plan/userdto-refactor.md` § N+1).
*
* No-op when the id is already cached (in-flight or resolved). This
* makes seeding safe to call unconditionally — never clobbers an
* authoritative in-flight lookup with a stale seed.
*/
export function seedUser(u: {
id: string;
username?: string | null;
email: string;
image?: string | null;
is_external: boolean;
}): void {
if (cache.has(u.id)) return;
const resolved: ResolvedUser = {
id: u.id,
name: u.username?.trim() || u.email || u.id,
email: u.email,
image: u.image ?? null,
isExternal: u.is_external
};
cache.set(u.id, Promise.resolve(resolved));
}
+94 -126
View File
@@ -179,148 +179,116 @@ export interface TrashResourcesResponse {
export type Role = 'user' | 'admin'; export type Role = 'user' | 'admin';
/** Wire shape of `UserDto` (backend: src/application/dtos/user_dto.rs). */ // ─────────────────────────────────────────────────────────────────────────
export interface User { // Three-layer user family — mirrors src/application/dtos/user_dto.rs.
// See docs/plan/userdto-refactor.md.
//
// `PublicUser` — public identity. Every authenticated caller may see it.
// Returned by /api/users/{id}, share responses, group
// members, magic-link invitees, recipient enrichment.
// `FullUser` — `{ user: PublicUser, ...admin+self extras }`. Returned
// as Vec by /api/admin/users; embedded in `SelfUser`.
// `SelfUser` — `{ full: FullUser, ...self-only extras }`. Returned by
// /api/auth/me and by every auth response.
//
// Adding a field? Decide by audience:
// * Any authenticated caller may see it about another user → PublicUser.
// * Only admin (about another user) AND self (about self) → FullUser.
// * Only self about themselves → SelfUser.
// ─────────────────────────────────────────────────────────────────────────
/** Public identity — 9 fields visible to any authenticated caller. */
export interface PublicUser {
id: string; id: string;
username?: string; username?: string;
email: string; email: string;
role: string; role: string;
storage_quota_bytes: number; image?: string | null;
storage_used_bytes: number; is_external: boolean;
given_name?: string;
family_name?: string;
/** Presence — TRUE when the server observed a request on any of this
* user's non-revoked sessions within the last 5 min. Populated on
* list endpoints; single-user public paths default to `false`.
* Backwards-compat: missing on older backend builds → `false`. */
is_online?: boolean;
}
/** Full user record — public identity + all fields BOTH an admin (viewing
* another user) AND the subject themselves may see. Returned as `Vec` by
* `/api/admin/users`; embedded in `SelfUser` for `/api/auth/me`. */
export interface FullUser {
user: PublicUser;
/** IdP linkage. Load-bearing "is federated?" predicate:
* `full.federation_kind === 'oidc'`. */
federation_kind?: 'oidc' | 'ocm' | 'magic_link';
/** Authority that minted the OIDC/OCM identity — issuer URL for OIDC,
* peer domain for OCM. FE that wants a friendly label maps this
* against `OidcProviders.issuer → provider_name`. */
federation_issuer?: string;
preferred_locale?: string;
email_verified_at?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
last_login_at?: string | null; last_login_at?: string | null;
active: boolean; active: boolean;
/** storage_quota_bytes: number;
* Which trust chain minted this user's federation identity. `null` storage_used_bytes: number;
* (omitted from wire) for local users (password / OPAQUE only). /** TRUE when the account has a local Argon2id `password_hash` on file.
* `"oidc" | "ocm" | "magic_link"` for federated users. Predicate: * Distinct from `federation_kind`: an OIDC-linked account can ALSO
* `!user.federation_kind` = local; `user.federation_kind === 'oidc'` * carry a local password (hybrid). */
* = OIDC user. Mirrors `auth.users.federation_kind` verbatim. has_password: boolean;
*/ /** TRUE when the user has an OPAQUE envelope on file. Admin-visible
federation_kind?: 'oidc' | 'ocm' | 'magic_link'; * rollout signal — kept off `PublicUser` so directory endpoints don't
/** * leak OPAQUE adoption. */
* Authority that minted this user's OIDC/OCM identity — issuer URL opaque_registered: boolean;
* for OIDC (id_token `iss`), peer domain for OCM. `null` (omitted) /** TRUE when the user has completed ≥1 OPAQUE login. Distinct from
* for local users. FE that wants a friendly display label maps this * `opaque_registered` — envelope-on-file vs successful-login. */
* against `OidcProviders.issuer → provider_name` when they match; opaque_migrated: boolean;
* shows the raw value otherwise. Renamed from the historical
* `auth_provider` (which held a display label pre-Phase-B and a
* `"local"` sentinel for non-federated users — both are gone).
*/
federation_issuer?: string;
image?: string | null;
can_edit_image: boolean;
is_external: boolean;
given_name?: string;
family_name?: string;
email_verified_at?: string;
preferred_locale?: string;
notify_on_share: boolean;
/**
* Opaque UI preferences bag. Server-side JSONB column that persists
* pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …)
* across devices. The server never inspects the contents — the SPA
* defines the keys (see `lib/stores/preferences.svelte.ts` for the
* typed view). Always an object on the wire (empty bag is `{}`,
* never `null` or missing).
*
* When PATCHing back to the server via
* `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the
* server SHALLOW-merges — only the keys present in the patch are
* touched, so partial writes from one device don't clobber
* preferences set on another. Set a key to `null` in the patch to
* delete it from the bag.
*/
ui_preferences: Record<string, unknown>;
/**
* Mirrors `auth.users.force_password_change_at_next_login`. Only
* populated by `GET /api/auth/me` (see the backend UserDto doc for
* why other UserDto call-sites default to false). When true, the
* SPA MUST lock navigation to the password-change surface — the
* root layout's guard + the backend's `require_no_password_change_pending`
* middleware together enforce this. Optional on the wire because
* older backend builds omit it and `#[serde(default)]` maps
* missing → `false`.
*/
force_password_change?: boolean;
/**
* TRUE when the account has a local Argon2id `password_hash` on
* file. Distinct from `federation_kind`: an OIDC-linked account
* (`federation_kind === 'oidc'`) can ALSO carry a local password
* (hybrid posture — SSO for daily login, local password as
* fallback). The profile page's change-password card gates on this
* flag rather than on the federation shape so hybrid users can
* rotate their local credential. Optional on the wire for older-
* backend compatibility; missing → `false` (safe default: hide the
* card).
*/
has_password?: boolean;
/**
* TRUE when the caller's current session is DPoP-bound (row's
* `dpop_jkt IS NOT NULL`). Populated only by `/api/auth/me`; other
* User-emitting endpoints leave it unset.
*
* The session store reads this to skip a redundant
* `POST /api/auth/dpop/bind` call — the endpoint returns 409
* `already_bound` on repeated attempts (anti-downgrade invariant)
* and each rejection logs at audit INFO, so a naive "bind on
* every load" pattern was cluttering the audit stream. We only
* fire bind now when there's actual work to do (fresh OIDC /
* magic-link session that landed unbound).
*/
is_dpop_bound?: boolean;
} }
/** Fields rendered by the paginated admin table. Full account details remain /** Self view — everything the caller may see about themselves.
* available from the detail endpoint; this shape keeps avatars and preference * Returned by `/api/auth/me` and every `AuthResponse` (login / refresh /
* documents off every listing page. * OIDC callback / magic-link redemption ships this so the SPA's post-auth
* * state matches its post-`/me` state with no UI race). */
* The two OPAQUE flags below are ADMIN-ONLY signals: they surface per-user export interface SelfUser {
* OPAQUE rollout progress in the admin table. The backend deliberately keeps full: FullUser;
* them off `UserDto` (`/api/auth/me`, share-recipient DTOs, group members) /** Opaque UI-preferences bag. Cross-device store for pure UI toggles
* so a non-admin can't enumerate the adoption set through third-party * (view mode, sidebar collapse, hide-dotfiles, …). Server never
* endpoints. Both optional on the wire — older backend builds omit them and * inspects contents; the SPA defines the keys (see
* `#[serde(default)]` maps missing → `false`. */ * `lib/stores/preferences.svelte.ts`). Always an object on the wire
export type AdminUserSummary = Pick< * — empty bag is `{}`, never `null`. PATCH via `/api/auth/me/profile`
User, * shallow-merges; setting a key to `null` removes it. */
| 'id' ui_preferences: Record<string, unknown>;
| 'username' /** Whether the user wants share-notification emails. */
| 'email' notify_on_share: boolean;
| 'role' /** Session-scoped: my current session is DPoP-bound. SPA reads this
| 'storage_quota_bytes' * on `session.load()` to skip a redundant `/api/auth/dpop/bind` call
| 'storage_used_bytes' * (409 `already_bound` otherwise, noisy in the audit stream). */
| 'last_login_at' is_dpop_bound: boolean;
| 'active' /** Admin-set temp-password gate — SPA nav guard blocks everything
| 'federation_kind' * but /change-password until this flips back. Cleared by a successful
| 'federation_issuer' * `POST /api/auth/change-password`. */
| 'is_external' force_password_change: boolean;
> & { /** Caller-scoped: can I edit my own avatar? `false` for OIDC users
/** TRUE = user has a server-verifiable password on file (legacy or * whose avatar comes from the IdP. Only meaningful when caller ==
* admin-set). Combined with `opaque_registered` and `federation_kind`, * subject; nonsense on any other DTO. */
* the admin table derives the full auth capability set — a user with can_edit_image: boolean;
* `has_password=false`, `opaque_registered=false` AND }
* `federation_kind === undefined` (no federation) is passwordless
* (magic-link only, which is the default for externals). */ /** Backwards-compat alias while migrating call-sites. Prefer `PublicUser`
has_password?: boolean; * for public-identity contexts (sharee, group member, invitee) or
/** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration * `SelfUser` when reading `/api/auth/me`. Delete once no consumers reference
* succeeded, or the user completed a manual re-registration). */ * the bare `User` name. */
opaque_registered?: boolean; export type User = PublicUser;
/** TRUE = user has completed at least one successful OPAQUE login.
* Distinct from `opaque_registered` — the envelope may have been
* cleared by an admin reset while a stale migrated=true remains as
* historical signal (backend clears both atomically today, but the
* two-flag shape keeps the option open for a future policy split). */
opaque_migrated?: boolean;
};
export interface AdminUsersPage { export interface AdminUsersPage {
total: number; total: number;
users: AdminUserSummary[]; users: FullUser[];
} }
export interface AuthResponse { export interface AuthResponse {
user: User; user: SelfUser;
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
token_type: string; token_type: string;
+13 -13
View File
@@ -437,11 +437,11 @@
{ mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') } { mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') }
]; ];
const storagePct = $derived( const storagePct = $derived.by(() => {
session.user && session.user.storage_quota_bytes > 0 const full = session.me?.full;
? Math.min(100, (session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100) if (!full || full.storage_quota_bytes <= 0) return 0;
: 0 return Math.min(100, (full.storage_used_bytes / full.storage_quota_bytes) * 100);
); });
const initials = $derived(userInitials(session.user?.username || session.user?.email)); const initials = $derived(userInitials(session.user?.username || session.user?.email));
@@ -655,12 +655,12 @@
<div class="storage-fill" style:width="{storagePct}%"></div> <div class="storage-fill" style:width="{storagePct}%"></div>
</div> </div>
<div class="storage-info"> <div class="storage-info">
{#if session.user.storage_quota_bytes > 0} {#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
{Math.round(storagePct)}% · {formatBytes(session.user.storage_used_bytes)} / {formatBytes( {Math.round(storagePct)}% · {formatBytes(session.me?.full.storage_used_bytes ?? 0)} / {formatBytes(
session.user.storage_quota_bytes session.me?.full.storage_quota_bytes ?? 0
)} )}
{:else} {:else}
{formatBytes(session.user.storage_used_bytes)} {formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{/if} {/if}
</div> </div>
</div> </div>
@@ -903,18 +903,18 @@
<div class="user-menu-storage-fill" style:width="{storagePct}%"></div> <div class="user-menu-storage-fill" style:width="{storagePct}%"></div>
</div> </div>
<div class="user-menu-storage-text"> <div class="user-menu-storage-text">
{#if session.user.storage_quota_bytes > 0} {#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
{t( {t(
'storage.used', 'storage.used',
{ {
percentage: Math.round(storagePct), percentage: Math.round(storagePct),
used: formatBytes(session.user.storage_used_bytes), used: formatBytes(session.me?.full.storage_used_bytes ?? 0),
total: formatBytes(session.user.storage_quota_bytes) total: formatBytes(session.me?.full.storage_quota_bytes ?? 0)
}, },
'{{percentage}}% used ({{used}} / {{total}})' '{{percentage}}% used ({{used}} / {{total}})'
)} )}
{:else} {:else}
{formatBytes(session.user.storage_used_bytes)} {formatBytes(session.me?.full.storage_used_bytes ?? 0)}
{/if} {/if}
</div> </div>
</div> </div>
+14 -3
View File
@@ -26,16 +26,27 @@ const children = createRawSnippet(() => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
pageState.url = new URL('http://localhost/files'); pageState.url = new URL('http://localhost/files');
session.user = { // Post the three-layer UserDto refactor, `session.user` is a
// derived accessor over `session.me.full.user`; only `session.me`
// is settable. Fixture composes the nested shape — public identity
// (username/email/name) on `.full.user`, admin+self extras
// (storage_*, has_password) on `.full`, self-only bag (ui_prefs,
// dpop_bound, force_password_change, can_edit_image) at the top.
// See docs/plan/userdto-refactor.md.
session.me = {
full: {
user: {
id: '1', id: '1',
username: 'admin', username: 'admin',
email: 'a@x.test', email: 'a@x.test',
given_name: 'A', given_name: 'A',
family_name: 'B', family_name: 'B',
role: 'admin', role: 'admin',
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false is_external: false
},
storage_used_bytes: 10,
storage_quota_bytes: 100
}
} as never; } as never;
}); });
+13 -7
View File
@@ -69,12 +69,15 @@ const PATCH_DEBOUNCE_MS = 500;
class PreferencesStore { class PreferencesStore {
/** /**
* The typed view of the bag. Derived from `session.user?.ui_preferences` * The typed view of the bag. Derived from `session.me?.ui_preferences`
* so signing in / out / refresh flips it in lockstep with the session. * (moved from public `User.ui_preferences` to `SelfUser.ui_preferences`
* as part of the three-layer UserDto refactor — the bag is self-only
* state, not something other authenticated callers should see).
* Signing in / out / refresh flips it in lockstep with the session.
* Reads pass through DEFAULTS for any missing key. * Reads pass through DEFAULTS for any missing key.
*/ */
private bag = $derived<Record<string, unknown>>( private bag = $derived<Record<string, unknown>>(
(session.user?.ui_preferences as Record<string, unknown> | undefined) ?? {} (session.me?.ui_preferences as Record<string, unknown> | undefined) ?? {}
); );
// ── Typed accessors ────────────────────────────────────────── // ── Typed accessors ──────────────────────────────────────────
@@ -100,11 +103,14 @@ class PreferencesStore {
* `jsonb_strip_nulls` after the merge). * `jsonb_strip_nulls` after the merge).
*/ */
set(patch: Partial<Record<keyof UiPreferences, unknown>>): void { set(patch: Partial<Record<keyof UiPreferences, unknown>>): void {
if (!session.user) return; if (!session.me) return;
// Optimistic local write — mutate the reactive user shallowly. // Optimistic local write — mutate the reactive me shallowly.
// `ui_preferences` lives on `SelfUser` (self-only), not on the
// public `User` slice, so the mutation stays at the SelfUser
// level. The nested `full` / `full.user` blocks are untouched.
const nextBag = { const nextBag = {
...((session.user.ui_preferences as Record<string, unknown> | undefined) ?? {}), ...((session.me.ui_preferences as Record<string, unknown> | undefined) ?? {}),
...patch ...patch
}; };
// Strip any explicit-null locally so the derived getters see the // Strip any explicit-null locally so the derived getters see the
@@ -114,7 +120,7 @@ class PreferencesStore {
for (const [k, v] of Object.entries(patch)) { for (const [k, v] of Object.entries(patch)) {
if (v === null) delete (nextBag as Record<string, unknown>)[k]; if (v === null) delete (nextBag as Record<string, unknown>)[k];
} }
session.user = { ...session.user, ui_preferences: nextBag }; session.me = { ...session.me, ui_preferences: nextBag };
// Accumulate keys so successive `set` calls before the debounce // Accumulate keys so successive `set` calls before the debounce
// fires collapse into a single PATCH body — matters for // fires collapse into a single PATCH body — matters for
+12 -5
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '$lib/api/types'; import type { SelfUser } from '$lib/api/types';
// `vi.mock` is hoisted above imports, so the spy it references must be created // `vi.mock` is hoisted above imports, so the spy it references must be created
// with `vi.hoisted` (a plain top-level const isn't initialised yet when the // with `vi.hoisted` (a plain top-level const isn't initialised yet when the
@@ -14,7 +14,14 @@ vi.mock('$lib/api/endpoints/auth', () => ({
import { session } from './session.svelte'; import { session } from './session.svelte';
const userWithUsage = (used: number) => ({ storage_used_bytes: used }) as unknown as User; // `storage_used_bytes` moved to `FullUser` (embedded inside `SelfUser`)
// as part of the three-layer UserDto refactor
// (`docs/plan/userdto-refactor.md`). Build a minimal SelfUser shape that
// satisfies the type checker without hand-populating every field the
// production shape carries — the test only cares about the usage read
// path (`session.me.full.storage_used_bytes`).
const userWithUsage = (used: number) =>
({ full: { storage_used_bytes: used } }) as unknown as SelfUser;
describe('session.refresh', () => { describe('session.refresh', () => {
beforeEach(() => { beforeEach(() => {
@@ -25,7 +32,7 @@ describe('session.refresh', () => {
it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => { it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => {
fetchMeMock.mockResolvedValue(userWithUsage(2048)); fetchMeMock.mockResolvedValue(userWithUsage(2048));
await session.refresh(); await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048); expect(session.me?.full.storage_used_bytes).toBe(2048);
}); });
it('leaves the current user intact when the probe returns null', async () => { it('leaves the current user intact when the probe returns null', async () => {
@@ -33,7 +40,7 @@ describe('session.refresh', () => {
await session.refresh(); await session.refresh();
fetchMeMock.mockResolvedValue(null); fetchMeMock.mockResolvedValue(null);
await session.refresh(); await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048); expect(session.me?.full.storage_used_bytes).toBe(2048);
}); });
it('leaves the current user intact when the probe throws', async () => { it('leaves the current user intact when the probe throws', async () => {
@@ -41,6 +48,6 @@ describe('session.refresh', () => {
await session.refresh(); await session.refresh();
fetchMeMock.mockRejectedValue(new Error('network')); fetchMeMock.mockRejectedValue(new Error('network'));
await session.refresh(); await session.refresh();
expect(session.user?.storage_used_bytes).toBe(2048); expect(session.me?.full.storage_used_bytes).toBe(2048);
}); });
}); });
+39 -17
View File
@@ -11,17 +11,39 @@ import { setLogoutInProgress } from '$lib/api/client';
import { hasSessionHint } from '$lib/api/csrf'; import { hasSessionHint } from '$lib/api/csrf';
import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; import { seedNonceFromCookie } from '$lib/auth/dpop-proof';
import { drives } from '$lib/stores/drives.svelte'; import { drives } from '$lib/stores/drives.svelte';
import type { User } from '$lib/api/types'; import type { PublicUser, SelfUser } from '$lib/api/types';
import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs';
/**
* Session store — the authenticated user and derived flags.
*
* Post the three-layer UserDto refactor (`docs/plan/userdto-refactor.md`),
* `/api/auth/me` returns `SelfUser` (composed:
* `SelfUser.full.user: PublicUser`). Two shorthand accessors keep every
* existing consumer readable:
*
* - `session.user` → `PublicUser` (via `me.full.user`). Every callsite
* that read `session.user.username / email / id / role / image /
* is_external / given_name / family_name / is_online` keeps working.
* - `session.me` → full `SelfUser`. New code that needs self-only or
* admin-visible fields (`has_password`, `is_dpop_bound`, `active`,
* `ui_preferences`, `federation_kind`, `last_login_at`, quotas, …)
* reads through `session.me.full.foo` or `session.me.foo`.
*/
class SessionStore { class SessionStore {
user = $state<User | null>(null); /** Full `/api/auth/me` payload. Null when unauthenticated. */
me = $state<SelfUser | null>(null);
loaded = $state(false); loaded = $state(false);
homeFolderId = $state<string | null>(null); homeFolderId = $state<string | null>(null);
homeFolderName = $state<string | null>(null); homeFolderName = $state<string | null>(null);
isExternalUser = $derived(this.user?.is_external ?? false); /** Public-identity shorthand — same fields any authenticated caller
isAuthenticated = $derived(this.user !== null); * can see. Every legacy `session.user.foo` read (username, email, id,
* role, image, is_external, given_name, family_name, is_online) still
* works via this derived accessor. */
user = $derived<PublicUser | null>(this.me?.full.user ?? null);
isExternalUser = $derived(this.me?.full.user.is_external ?? false);
isAuthenticated = $derived(this.me !== null);
/** /**
* TRUE when the backend has set `force_password_change_at_next_login` * TRUE when the backend has set `force_password_change_at_next_login`
* on this account — an admin picked a temporary password and the * on this account — an admin picked a temporary password and the
@@ -33,7 +55,7 @@ class SessionStore {
* flag (or a malformed `/me` response) doesn't accidentally * flag (or a malformed `/me` response) doesn't accidentally
* quarantine every user. * quarantine every user.
*/ */
mustChangePassword = $derived(this.user?.force_password_change === true); mustChangePassword = $derived(this.me?.force_password_change === true);
/** /**
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single * Resolve the session once. Probes /api/auth/me; on 401 it makes a single
@@ -41,15 +63,15 @@ class SessionStore {
* what to do with an unauthenticated result. Idempotent: subsequent calls * what to do with an unauthenticated result. Idempotent: subsequent calls
* return the cached result (so client-side navigation doesn't re-probe). * return the cached result (so client-side navigation doesn't re-probe).
*/ */
async load(): Promise<User | null> { async load(): Promise<SelfUser | null> {
if (this.loaded) return this.user; if (this.loaded) return this.me;
// No JS-visible session hint ⇒ nothing to probe. The server sets // No JS-visible session hint ⇒ nothing to probe. The server sets
// `oxicloud_csrf` alongside the HttpOnly session cookies and clears // `oxicloud_csrf` alongside the HttpOnly session cookies and clears
// it on logout, so a missing hint means no session. Skips the // it on logout, so a missing hint means no session. Skips the
// doomed 2× /me + /refresh burst that would otherwise fire on // doomed 2× /me + /refresh burst that would otherwise fire on
// every first landing / post-logout re-mount with no cookies. // every first landing / post-logout re-mount with no cookies.
if (!hasSessionHint()) { if (!hasSessionHint()) {
this.user = null; this.me = null;
this.loaded = true; this.loaded = true;
return null; return null;
} }
@@ -71,25 +93,25 @@ class SessionStore {
// otherwise clutter the audit stream. Fire-and-forget // otherwise clutter the audit stream. Fire-and-forget
// so a slow IndexedDB open doesn't stall app boot. // so a slow IndexedDB open doesn't stall app boot.
if (me.is_dpop_bound === false) void bindDpopIfPossible(); if (me.is_dpop_bound === false) void bindDpopIfPossible();
} else this.user = null; } else this.me = null;
} catch { } catch {
this.user = null; this.me = null;
} }
this.loaded = true; this.loaded = true;
return this.user; return this.me;
} }
/** /**
* Set the authenticated user AND run per-user localStorage cleanup * Set the authenticated user AND run per-user localStorage cleanup
* (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct * (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct
* `session.user = …` assignments skip the cleanup — always call * `session.me = …` assignments skip the cleanup — always call
* `setUser` on login-flow entry points (form login, OIDC exchange, * `setUser` on login-flow entry points (form login, OIDC exchange,
* existing-session probe) so a switch-account flow inside the same * existing-session probe) so a switch-account flow inside the same
* tab observes the wipe. * tab observes the wipe.
*/ */
setUser(user: User): void { setUser(me: SelfUser): void {
this.user = user; this.me = me;
ensureActiveUser(user.id); ensureActiveUser(me.full.user.id);
// Any successful login clears the session-teardown gate. Without // Any successful login clears the session-teardown gate. Without
// this, a logout → login within the same SPA session leaves the // this, a logout → login within the same SPA session leaves the
// gate stuck at `true` — the login POST is exempted via // gate stuck at `true` — the login POST is exempted via
@@ -115,7 +137,7 @@ class SessionStore {
async refresh(): Promise<void> { async refresh(): Promise<void> {
try { try {
const me = await fetchMe(); const me = await fetchMe();
if (me) this.user = me; if (me) this.me = me;
} catch { } catch {
/* keep the existing user on a transient /api/auth/me failure */ /* keep the existing user on a transient /api/auth/me failure */
} }
@@ -142,7 +164,7 @@ class SessionStore {
} }
reset(): void { reset(): void {
this.user = null; this.me = null;
this.homeFolderId = null; this.homeFolderId = null;
this.homeFolderName = null; this.homeFolderName = null;
// Mark the store as `loaded` so any subsequent `session.load()` — // Mark the store as `loaded` so any subsequent `session.load()` —
+65 -48
View File
@@ -63,6 +63,7 @@
type StorageTestResult type StorageTestResult
} from '$lib/api/endpoints/admin'; } from '$lib/api/endpoints/admin';
import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives'; import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives';
import { seedUser } from '$lib/api/endpoints/users';
import { import {
ensureResolvers, ensureResolvers,
resolveRecipient, resolveRecipient,
@@ -70,7 +71,7 @@
type Recipient type Recipient
} from '$lib/api/endpoints/recipients'; } from '$lib/api/endpoints/recipients';
import type { import type {
AdminUserSummary, FullUser,
Drive, Drive,
DriveMember, DriveMember,
DrivePolicies, DrivePolicies,
@@ -156,11 +157,11 @@
deleteUserModal !== null && deleteUserModal !== null &&
deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase() deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase()
); );
function openDeleteUser(u: AdminUserSummary) { function openDeleteUser(u: FullUser) {
deleteUserModal = { deleteUserModal = {
userId: u.id, userId: u.user.id,
username: u.username || u.email, username: u.user.username || u.user.email,
email: u.email email: u.user.email
}; };
deleteUserEmailInput = ''; deleteUserEmailInput = '';
} }
@@ -817,7 +818,7 @@
} }
// Users // Users
let users = $state<AdminUserSummary[]>([]); let users = $state<FullUser[]>([]);
let total = $state(0); let total = $state(0);
let pageIndex = $state(0); let pageIndex = $state(0);
let usersError = $state<string | null>(null); let usersError = $state<string | null>(null);
@@ -923,6 +924,12 @@
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE); const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
users = page.users; users = page.users;
total = page.total; total = page.total;
// Seed the per-user resolver cache with the row's `PublicUser`
// slice so every `UserVignette` mounted per row hits the cache
// synchronously — no per-row `/api/users/{id}` follow-up.
// Kills the N+1 that motivated widening `/api/admin/users` to
// carry the avatar (docs/plan/userdto-refactor.md § N+1).
for (const row of page.users) seedUser(row.user);
} catch (e) { } catch (e) {
usersError = errorMessage(e); usersError = errorMessage(e);
} }
@@ -1003,49 +1010,49 @@
} }
/** True for the signed-in admin's own row — guards self-destructive actions. */ /** True for the signed-in admin's own row — guards self-destructive actions. */
function isSelf(u: AdminUserSummary): boolean { function isSelf(u: FullUser): boolean {
return u.id === currentAdminId; return u.user.id === currentAdminId;
} }
/** OIDC/SSO-provisioned account (no local password to reset). */ /** OIDC/SSO-provisioned account (no local password to reset). */
function isOidcUser(u: AdminUserSummary): boolean { function isOidcUser(u: FullUser): boolean {
return u.federation_kind === 'oidc'; return u.federation_kind === 'oidc';
} }
/** Used-quota percentage (0 when unlimited) for the per-user progress bar. */ /** Used-quota percentage (0 when unlimited) for the per-user progress bar. */
function quotaPct(u: AdminUserSummary): number { function quotaPct(u: FullUser): number {
return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0; return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
} }
async function toggleRole(u: AdminUserSummary) { async function toggleRole(u: FullUser) {
if (isSelf(u)) return; if (isSelf(u)) return;
const role = u.role === 'admin' ? 'user' : 'admin'; const role = u.user.role === 'admin' ? 'user' : 'admin';
if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return; if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return;
try { try {
await setUserRole(u.id, role); await setUserRole(u.user.id, role);
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
reportError(e); reportError(e);
} }
} }
async function toggleActive(u: AdminUserSummary) { async function toggleActive(u: FullUser) {
if (isSelf(u) && u.active) return; if (isSelf(u) && u.active) return;
const msg = u.active const msg = u.active
? t('admin.confirm_deactivate', 'Deactivate this user?') ? t('admin.confirm_deactivate', 'Deactivate this user?')
: t('admin.confirm_activate', 'Activate this user?'); : t('admin.confirm_activate', 'Activate this user?');
if (!(await showConfirm(msg))) return; if (!(await showConfirm(msg))) return;
try { try {
await setUserActive(u.id, !u.active); await setUserActive(u.user.id, !u.active);
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
reportError(e); reportError(e);
} }
} }
function openQuota(u: AdminUserSummary) { function openQuota(u: FullUser) {
quotaModalError = null; quotaModalError = null;
quotaModal = { quotaModal = {
userId: u.id, userId: u.user.id,
username: u.username || u.email, username: u.user.username || u.user.email,
initialBytes: u.storage_quota_bytes initialBytes: u.storage_quota_bytes
}; };
} }
@@ -1069,8 +1076,8 @@
} }
} }
function openReset(u: AdminUserSummary) { function openReset(u: FullUser) {
resetModal = { userId: u.id, username: u.username || u.email }; resetModal = { userId: u.user.id, username: u.user.username || u.user.email };
resetPassword = ''; resetPassword = '';
resetError = null; resetError = null;
} }
@@ -1094,7 +1101,7 @@
} }
} }
function removeUser(u: AdminUserSummary) { function removeUser(u: FullUser) {
if (isSelf(u)) return; if (isSelf(u)) return;
openDeleteUser(u); openDeleteUser(u);
} }
@@ -1103,20 +1110,20 @@
// provisions a home drive + flips the is_external flag; irreversible // provisions a home drive + flips the is_external flag; irreversible
// via the admin UI (there's no demote endpoint on purpose). Backend // via the admin UI (there's no demote endpoint on purpose). Backend
// refuses when magic-link login is disabled — surfaced as a toast. // refuses when magic-link login is disabled — surfaced as a toast.
async function promoteExternal(u: AdminUserSummary) { async function promoteExternal(u: FullUser) {
if (!u.is_external) return; if (!u.user.is_external) return;
if ( if (
!(await showConfirm( !(await showConfirm(
t( t(
'admin.confirm_promote_user', 'admin.confirm_promote_user',
{ name: u.username || u.email }, { name: u.user.username || u.user.email },
'Promote {{name}} to an internal user? This provisions a home drive and gives the account a normal storage envelope. The account keeps its identity; magic-link login stays the way in unless a password is set later.' 'Promote {{name}} to an internal user? This provisions a home drive and gives the account a normal storage envelope. The account keeps its identity; magic-link login stays the way in unless a password is set later.'
) )
)) ))
) )
return; return;
try { try {
await promoteUserToInternal(u.id); await promoteUserToInternal(u.user.id);
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
reportError(e); reportError(e);
@@ -2680,15 +2687,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each users as u (u.id)} {#each users as u (u.user.id)}
{@const pct = quotaPct(u)} {@const pct = quotaPct(u)}
<tr> <tr>
<td> <td>
<div class="user-vignette-cell"> <div class="user-vignette-cell">
<UserVignette <UserVignette
userId={u.id} userId={u.user.id}
fallbackLabel={u.username || u.email} fallbackLabel={u.user.username || u.user.email}
fallbackSublabel={u.email} fallbackSublabel={u.user.email}
/> />
{#if isSelf(u)} {#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span> <span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
@@ -2703,11 +2710,11 @@
badge is `white-space: nowrap` so the badge label badge is `white-space: nowrap` so the badge label
itself never wraps mid-word either. --> itself never wraps mid-word either. -->
<div class="role-badges"> <div class="role-badges">
<span class="badge badge--{u.role === 'admin' ? 'admin' : 'user'}"> <span class="badge badge--{u.user.role === 'admin' ? 'admin' : 'user'}">
{#if u.role === 'admin'}<Icon name="shield-alt" />{/if} {#if u.user.role === 'admin'}<Icon name="shield-alt" />{/if}
{u.role} {u.user.role}
</span> </span>
{#if u.is_external} {#if u.user.is_external}
<!-- Origin flag, orthogonal to `role`. Grant-only <!-- Origin flag, orthogonal to `role`. Grant-only
accounts (magic-link / OCM) can never be admin accounts (magic-link / OCM) can never be admin
(DB CHECK `users_external_not_admin`) so the two (DB CHECK `users_external_not_admin`) so the two
@@ -2738,7 +2745,7 @@
<td class="auth-cell"> <td class="auth-cell">
<!-- <!--
Auth-capability chip set — ADMIN-ONLY (fields Auth-capability chip set — ADMIN-ONLY (fields
scoped to `AdminUserSummaryDto`; never on scoped to `FullUserDto`; never on
`UserDto`). Any user carries ZERO OR MORE of: `UserDto`). Any user carries ZERO OR MORE of:
* SSO/OIDC — `federation_kind === 'oidc'`, * SSO/OIDC — `federation_kind === 'oidc'`,
identity delegated to the IdP; label is identity delegated to the IdP; label is
@@ -2825,7 +2832,7 @@
</span> </span>
</td> </td>
<td> <td>
{#if u.is_external} {#if u.user.is_external}
<!-- External accounts have no storage envelope by <!-- External accounts have no storage envelope by
design (DB CHECK `users_external_no_storage` design (DB CHECK `users_external_no_storage`
enforces storage_quota_bytes = 0). Rendering the enforces storage_quota_bytes = 0). Rendering the
@@ -2867,10 +2874,10 @@
actions render as invisible placeholders. --> actions render as invisible placeholders. -->
<div class="actions actions--user"> <div class="actions actions--user">
<!-- Slot 1: quota (internal) OR promote (external). --> <!-- Slot 1: quota (internal) OR promote (external). -->
{#if u.is_external} {#if u.user.is_external}
<button <button
class="icon-btn icon-btn--success" class="icon-btn icon-btn--success"
data-testid={`admin-user-promote-${u.id}`} data-testid={`admin-user-promote-${u.user.id}`}
title={t('admin.promote_to_internal_title', 'Promote to internal user')} title={t('admin.promote_to_internal_title', 'Promote to internal user')}
aria-label={t('admin.promote_to_internal_title', 'Promote to internal user')} aria-label={t('admin.promote_to_internal_title', 'Promote to internal user')}
onclick={() => promoteExternal(u)} onclick={() => promoteExternal(u)}
@@ -2880,7 +2887,7 @@
{:else} {:else}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-quota-${u.id}`} data-testid={`admin-user-quota-${u.user.id}`}
title={t('admin.edit_quota_title', 'Edit quota')} title={t('admin.edit_quota_title', 'Edit quota')}
aria-label={t('admin.edit_quota_title', 'Edit quota')} aria-label={t('admin.edit_quota_title', 'Edit quota')}
onclick={() => openQuota(u)} onclick={() => openQuota(u)}
@@ -2891,10 +2898,10 @@
<!-- Slot 2: reset password (local internal only — <!-- Slot 2: reset password (local internal only —
OIDC and external accounts have no password OIDC and external accounts have no password
to reset). Placeholder otherwise. --> to reset). Placeholder otherwise. -->
{#if !isOidcUser(u) && !u.is_external} {#if !isOidcUser(u) && !u.user.is_external}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-reset-password-${u.id}`} data-testid={`admin-user-reset-password-${u.user.id}`}
title={t('admin.reset_password_title', 'Reset password')} title={t('admin.reset_password_title', 'Reset password')}
aria-label={t('admin.reset_password_title', 'Reset password')} aria-label={t('admin.reset_password_title', 'Reset password')}
onclick={() => openReset(u)} onclick={() => openReset(u)}
@@ -2909,16 +2916,16 @@
`change_user_role` + DB CHECK `change_user_role` + DB CHECK
`users_external_not_admin`). Promotion to `users_external_not_admin`). Promotion to
internal is offered separately in slot 1. --> internal is offered separately in slot 1. -->
{#if !u.is_external} {#if !u.user.is_external}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-toggle-role-${u.id}`} data-testid={`admin-user-toggle-role-${u.user.id}`}
title={t('admin.toggle_role_title', 'Toggle admin role')} title={t('admin.toggle_role_title', 'Toggle admin role')}
aria-label={t('admin.toggle_role_title', 'Toggle admin role')} aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
disabled={isSelf(u)} disabled={isSelf(u)}
onclick={() => toggleRole(u)} onclick={() => toggleRole(u)}
> >
<Icon name={u.role === 'admin' ? 'user' : 'crown'} /> <Icon name={u.user.role === 'admin' ? 'user' : 'crown'} />
</button> </button>
{:else} {:else}
<span class="icon-btn icon-btn--placeholder" aria-hidden="true"></span> <span class="icon-btn icon-btn--placeholder" aria-hidden="true"></span>
@@ -2926,7 +2933,7 @@
<!-- Slot 4: activate/deactivate. --> <!-- Slot 4: activate/deactivate. -->
<button <button
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}" class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
data-testid={`admin-user-toggle-active-${u.id}`} data-testid={`admin-user-toggle-active-${u.user.id}`}
title={u.active title={u.active
? t('admin.deactivate_title', 'Deactivate') ? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')} : t('admin.activate_title', 'Activate')}
@@ -2941,7 +2948,7 @@
<!-- Slot 5: delete. --> <!-- Slot 5: delete. -->
<button <button
class="icon-btn icon-btn--danger" class="icon-btn icon-btn--danger"
data-testid={`admin-user-delete-${u.id}`} data-testid={`admin-user-delete-${u.user.id}`}
title={t('admin.delete_title', 'Delete user')} title={t('admin.delete_title', 'Delete user')}
aria-label={t('admin.delete_title', 'Delete user')} aria-label={t('admin.delete_title', 'Delete user')}
disabled={isSelf(u)} disabled={isSelf(u)}
@@ -3275,11 +3282,21 @@
fall back to the owner's cap; 0 also means "no limit" fall back to the owner's cap; 0 also means "no limit"
(backend convention — see `User.storage_quota_bytes` doc). (backend convention — see `User.storage_quota_bytes` doc).
--> -->
<!-- Personal-drive fallback used to read
`owner.storage_quota_bytes` off the resolved DTO.
Post the UserDto refactor
(docs/plan/userdto-refactor.md) `owner` here is a
`PublicUser` (public identity, no quota); the
envelope quota only lives on `FullUser` /
`SelfUser`. Rather than widen the resolver's shape
just for this fallback, hold the effective quota at
`null` when the drive itself doesn't declare one —
the row renders "—" and the admin can consult the
user's row for their envelope cap. Explicit
shared-drive quota still surfaces as before. -->
{@const effectiveQuota = {@const effectiveQuota =
d.kind === 'personal' d.kind === 'personal'
? owner && owner.storage_quota_bytes > 0 ? null
? owner.storage_quota_bytes
: null
: d.quota_bytes && d.quota_bytes > 0 : d.quota_bytes && d.quota_bytes > 0
? d.quota_bytes ? d.quota_bytes
: null} : null}
+11 -1
View File
@@ -96,16 +96,26 @@ const dashboard = {
users_over_quota: 0 users_over_quota: 0
}; };
// FullUser fixture — post the three-layer UserDto refactor
// (docs/plan/userdto-refactor.md), /api/admin/users returns
// `Vec<FullUserDto>` where public identity nests under `.user`
// and admin-visible extras (quotas, active, has_password, OPAQUE
// flags) live at the top level.
const user = { const user = {
user: {
id: 'u1', id: 'u1',
username: 'bob', username: 'bob',
email: 'bob@x.test', email: 'bob@x.test',
role: 'user', role: 'user',
is_external: false
},
active: true, active: true,
is_active: true, is_active: true,
storage_used_bytes: 10, storage_used_bytes: 10,
storage_quota_bytes: 100, storage_quota_bytes: 100,
is_external: false has_password: true,
opaque_registered: false,
opaque_migrated: false
}; };
const mount = { const mount = {
+48 -38
View File
@@ -72,11 +72,11 @@
let creatingPw = $state(false); let creatingPw = $state(false);
let autoExpanded = $state(false); let autoExpanded = $state(false);
const isOidc = $derived(session.user?.federation_kind === 'oidc'); const isOidc = $derived(session.me?.full.federation_kind === 'oidc');
const isLocal = $derived(!session.user?.federation_kind); const isLocal = $derived(!session.me?.full.federation_kind);
const usernameClaimed = $derived(!!session.user?.username); const usernameClaimed = $derived(!!session.user?.username);
const isAdmin = $derived(session.user?.role === 'admin'); const isAdmin = $derived(session.user?.role === 'admin');
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal); const canEditImage = $derived(session.me?.can_edit_image === true && isLocal);
// Show the change-password card when the user CAN change their // Show the change-password card when the user CAN change their
// local password: they have `password_hash` on file AND the // local password: they have `password_hash` on file AND the
// deployment offers password login (backend `change_password` // deployment offers password login (backend `change_password`
@@ -87,14 +87,16 @@
// password) are a legitimate posture and MUST be able to rotate // password) are a legitimate posture and MUST be able to rotate
// their local credential; the new gate lets them, and the backend // their local credential; the new gate lets them, and the backend
// refusal covers the pure-SSO case where has_password is false. // refusal covers the pure-SSO case where has_password is false.
const showPasswordCard = $derived((session.user?.has_password ?? false) && passwordLoginEnabled); const showPasswordCard = $derived(
(session.me?.full.has_password ?? false) && passwordLoginEnabled
);
// SSO card gates — see docs/plan/oidc-account-linking.md. // SSO card gates — see docs/plan/oidc-account-linking.md.
// Connect: only when OIDC is enabled AND the user isn't already linked. // Connect: only when OIDC is enabled AND the user isn't already linked.
// Disconnect: only when currently OIDC-linked AND the user has an // Disconnect: only when currently OIDC-linked AND the user has an
// alternative auth method (password or OPAQUE-registered) — else // alternative auth method (password or OPAQUE-registered) — else
// unlinking would lock them out. // unlinking would lock them out.
const canConnectSso = $derived(oidcEnabled && !session.user?.federation_kind); const canConnectSso = $derived(oidcEnabled && !session.me?.full.federation_kind);
// Show the disconnect button whenever the user is OIDC-linked. // Show the disconnect button whenever the user is OIDC-linked.
// The backend guard (`AuthApplicationService::unlink_oidc`) is the // The backend guard (`AuthApplicationService::unlink_oidc`) is the
// source of truth for the "no alternative auth" refusal — it also // source of truth for the "no alternative auth" refusal — it also
@@ -103,7 +105,7 @@
// adoption status through user-directory endpoints). The UI shows // adoption status through user-directory endpoints). The UI shows
// the button unconditionally and surfaces the backend's 403 as a // the button unconditionally and surfaces the backend's 403 as a
// user-facing "set a password first" prompt. // user-facing "set a password first" prompt.
const canDisconnectSso = $derived(session.user?.federation_kind === 'oidc'); const canDisconnectSso = $derived(session.me?.full.federation_kind === 'oidc');
/** /**
* Mandatory change-password mode. TRUE when the backend has * Mandatory change-password mode. TRUE when the backend has
@@ -136,14 +138,11 @@
} }
}); });
const storagePct = $derived( const storagePct = $derived.by(() => {
session.user && session.user.storage_quota_bytes > 0 const full = session.me?.full;
? Math.min( if (!full || full.storage_quota_bytes <= 0) return 0;
100, return Math.min(100, Math.round((full.storage_used_bytes / full.storage_quota_bytes) * 100));
Math.round((session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100) });
)
: 0
);
const storageBarClass = $derived( const storageBarClass = $derived(
storagePct > 90 ? 'bar__fill--red' : storagePct > 70 ? 'bar__fill--orange' : 'bar__fill--green' storagePct > 90 ? 'bar__fill--red' : storagePct > 70 ? 'bar__fill--orange' : 'bar__fill--green'
); );
@@ -159,15 +158,20 @@
relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true }); relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true });
function hydrate() { function hydrate() {
const u = session.user; const me = session.me;
if (!u) return; if (!me) return;
givenName = u.given_name ?? ''; // Public identity (name / handle) reads via `me.full.user`;
familyName = u.family_name ?? ''; // admin-visible extras (preferred_locale) via `me.full`;
username = u.username ?? ''; // self-only bag flags (notify_on_share) via `me` directly.
preferredLocale = u.preferred_locale ?? ''; // The three-level indirection makes the audience of each
notifyOnShare = u.notify_on_share; // field visible at the callsite (docs/plan/userdto-refactor.md).
givenName = me.full.user.given_name ?? '';
familyName = me.full.user.family_name ?? '';
username = me.full.user.username ?? '';
preferredLocale = me.full.preferred_locale ?? '';
notifyOnShare = me.notify_on_share;
// Source of truth is the preferences store, which itself // Source of truth is the preferences store, which itself
// derives from `session.user.ui_preferences`. Reading through // derives from `session.me.ui_preferences`. Reading through
// the store here (rather than the raw bag) means a new // the store here (rather than the raw bag) means a new
// preference field just needs a getter in the store and its // preference field just needs a getter in the store and its
// own line here — no wire-format knowledge on the page. // own line here — no wire-format knowledge on the page.
@@ -176,21 +180,22 @@
async function saveProfile(e: SubmitEvent) { async function saveProfile(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const u = session.user; const me = session.me;
if (!u) return; if (!me) return;
// Build a sparse patch of only the fields the user actually changed. // Build a sparse patch of only the fields the user actually changed.
// Sending empty strings the user never touched would 400 on the server. // Sending empty strings the user never touched would 400 on the server.
const patch: ProfilePatch = {}; const patch: ProfilePatch = {};
if (!usernameClaimed && username.trim() && username.trim() !== (u.username ?? '')) { if (!usernameClaimed && username.trim() && username.trim() !== (me.full.user.username ?? '')) {
patch.username = username.trim(); patch.username = username.trim();
} }
if (givenName.trim() !== (u.given_name ?? '')) patch.given_name = givenName.trim(); if (givenName.trim() !== (me.full.user.given_name ?? '')) patch.given_name = givenName.trim();
if (familyName.trim() !== (u.family_name ?? '')) patch.family_name = familyName.trim(); if (familyName.trim() !== (me.full.user.family_name ?? ''))
if ((preferredLocale || '') !== (u.preferred_locale ?? '')) { patch.family_name = familyName.trim();
if ((preferredLocale || '') !== (me.full.preferred_locale ?? '')) {
patch.preferred_locale = preferredLocale || undefined; patch.preferred_locale = preferredLocale || undefined;
} }
if (notifyOnShare !== u.notify_on_share) patch.notify_on_share = notifyOnShare; if (notifyOnShare !== me.notify_on_share) patch.notify_on_share = notifyOnShare;
// Ship the diff as a partial `ui_preferences` patch — the // Ship the diff as a partial `ui_preferences` patch — the
// server does a shallow merge, so only the changed key is // server does a shallow merge, so only the changed key is
// touched; siblings set on other devices survive. // touched; siblings set on other devices survive.
@@ -205,8 +210,13 @@
savingProfile = true; savingProfile = true;
try { try {
const updated = await updateProfile(patch); await updateProfile(patch);
session.user = updated; // Server returns the truncated `PublicUser` shape; re-fetch
// `/me` so `session.me` picks up self-only edits (locale,
// notify_on_share, ui_preferences bag) as well as the public
// identity changes. `session.user` is a derived accessor over
// `session.me.full.user`, so it updates in lockstep.
await session.refresh();
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale); if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
ui.notify(t('profile.saved', 'Profile saved'), 'success'); ui.notify(t('profile.saved', 'Profile saved'), 'success');
} catch (err) { } catch (err) {
@@ -445,7 +455,7 @@
// (federation_kind should now be 'oidc'). // (federation_kind should now be 'oidc').
try { try {
const me = await fetchMe(); const me = await fetchMe();
if (me) session.user = me; if (me) session.me = me;
} catch { } catch {
/* stale session is recoverable — next request refreshes */ /* stale session is recoverable — next request refreshes */
} }
@@ -536,7 +546,7 @@
try { try {
await unlinkOidc(); await unlinkOidc();
const me = await fetchMe(); const me = await fetchMe();
if (me) session.user = me; if (me) session.me = me;
ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info'); ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info');
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') { if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') {
@@ -742,7 +752,7 @@
<Icon name="clock" /> <Icon name="clock" />
{t('profile.last_login', 'Last Login')} {t('profile.last_login', 'Last Login')}
</div> </div>
<div class="info-value">{timeAgo(session.user.last_login_at)}</div> <div class="info-value">{timeAgo(session.me?.full.last_login_at)}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -752,20 +762,20 @@
<h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2> <h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2>
<div class="storage-stats"> <div class="storage-stats">
<div class="storage-stat"> <div class="storage-stat">
<div class="stat-value">{formatBytes(session.user.storage_used_bytes)}</div> <div class="stat-value">{formatBytes(session.me?.full.storage_used_bytes ?? 0)}</div>
<div class="muted">{t('profile.used', 'Used')}</div> <div class="muted">{t('profile.used', 'Used')}</div>
</div> </div>
<div class="storage-stat"> <div class="storage-stat">
<div class="stat-value"> <div class="stat-value">
{session.user.storage_quota_bytes > 0 {(session.me?.full.storage_quota_bytes ?? 0) > 0
? formatBytes(session.user.storage_quota_bytes) ? formatBytes(session.me?.full.storage_quota_bytes ?? 0)
: '∞'} : '∞'}
</div> </div>
<div class="muted">{t('profile.quota', 'Quota')}</div> <div class="muted">{t('profile.quota', 'Quota')}</div>
</div> </div>
<div class="storage-stat"> <div class="storage-stat">
<div class="stat-value"> <div class="stat-value">
{session.user.storage_quota_bytes > 0 ? `${storagePct}%` : '—'} {(session.me?.full.storage_quota_bytes ?? 0) > 0 ? `${storagePct}%` : '—'}
</div> </div>
<div class="muted">{t('profile.usage', 'Usage')}</div> <div class="muted">{t('profile.usage', 'Usage')}</div>
</div> </div>
+44 -19
View File
@@ -1,10 +1,15 @@
import { it, expect, vi, beforeEach } from 'vitest'; import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({ // Test-double session store. Post the three-layer UserDto refactor
session: { // (docs/plan/userdto-refactor.md), production `session.user` is a
loaded: true, // derived accessor over `session.me.full.user`. The stub here mirrors
load: vi.fn(), // that shape: `me` carries the whole SelfUser tree, and `user` mirrors
// `me.full.user` so any legacy `session.user.foo` read on the tested
// page keeps working through the mock without reproducing the derived
// mechanism.
const buildSelfMe = () => ({
full: {
user: { user: {
id: '1', id: '1',
username: 'admin', username: 'admin',
@@ -12,14 +17,41 @@ const { session, ui } = vi.hoisted(() => ({
given_name: 'A', given_name: 'A',
family_name: 'B', family_name: 'B',
role: 'admin', role: 'admin',
is_external: false
},
storage_used_bytes: 100, storage_used_bytes: 100,
storage_quota_bytes: 1000, storage_quota_bytes: 1000,
is_external: false,
has_password: true has_password: true
} }
});
const { session, ui } = vi.hoisted(() => {
const me = {
full: {
user: {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
is_external: false
},
storage_used_bytes: 100,
storage_quota_bytes: 1000,
has_password: true
}
};
return {
session: {
loaded: true,
load: vi.fn(),
me,
user: me.full.user
}, },
ui: { notify: vi.fn() } ui: { notify: vi.fn() }
})); };
});
vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui })); vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() })); vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
@@ -44,20 +76,13 @@ const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset the shared session each test (handlers may mutate session.user). // Reset the shared session each test (handlers may mutate session.me
// on save / refresh). `me` is the SelfUser tree; `user` mirrors
// `me.full.user` for legacy `session.user.foo` reads.
session.loaded = true; session.loaded = true;
session.user = { const me = buildSelfMe();
id: '1', session.me = me;
username: 'admin', session.user = me.full.user;
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false,
has_password: true
};
m(profile.listAppPasswords).mockResolvedValue([]); m(profile.listAppPasswords).mockResolvedValue([]);
m(profile.updateProfile).mockResolvedValue(undefined); m(profile.updateProfile).mockResolvedValue(undefined);
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true }); m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
+139
View File
@@ -586,3 +586,142 @@ pub struct OidcUserInfoDto {
pub name: Option<String>, pub name: Option<String>,
pub groups: Vec<String>, pub groups: Vec<String>,
} }
#[cfg(test)]
mod three_layer_quarantine {
use super::*;
use serde_json::Value;
/// Structural-quarantine guard for `SelfUserDto`. The self-only
/// bag (`ui_preferences`, `notify_on_share`, `is_dpop_bound`,
/// `force_password_change`, `can_edit_image`) MUST live at the
/// top level, NOT nested inside `.full` or `.full.user`. If a
/// future refactor accidentally moves one of them down, the
/// wire shape leaks it through every `PublicUserDto` /
/// `FullUserDto` emitter (share responses, group members,
/// `/api/admin/users`, magic-link invitees) — exactly what the
/// three-layer split exists to prevent. Fails loudly here.
#[test]
fn self_only_fields_stay_at_top_level_of_self_user_dto() {
let self_dto = SelfUserDto {
full: FullUserDto {
user: PublicUserDto {
id: "00000000-0000-0000-0000-000000000001".into(),
username: None,
email: "self@example.invalid".into(),
role: "user".into(),
image: None,
is_external: false,
given_name: None,
family_name: None,
is_online: false,
},
federation_kind: None,
federation_issuer: None,
preferred_locale: None,
email_verified_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
last_login_at: None,
active: true,
storage_quota_bytes: 0,
storage_used_bytes: 0,
has_password: true,
opaque_registered: false,
opaque_migrated: false,
},
ui_preferences: serde_json::json!({}),
notify_on_share: true,
is_dpop_bound: false,
force_password_change: false,
can_edit_image: true,
};
let json: Value = serde_json::to_value(&self_dto).expect("SelfUserDto serialises");
assert!(
json.get("ui_preferences").is_some(),
"top-level ui_preferences"
);
assert!(
json.get("full")
.expect("full block")
.get("ui_preferences")
.is_none(),
"ui_preferences must NOT appear inside `.full`"
);
assert!(
json.pointer("/full/user/ui_preferences").is_none(),
"ui_preferences must NOT appear inside `.full.user`"
);
// Same guard for the other self-only fields.
for k in [
"notify_on_share",
"is_dpop_bound",
"force_password_change",
"can_edit_image",
] {
assert!(json.get(k).is_some(), "{k} at top level");
assert!(
json.pointer(&format!("/full/{k}")).is_none(),
"{k} must NOT nest in .full"
);
assert!(
json.pointer(&format!("/full/user/{k}")).is_none(),
"{k} must NOT nest in .full.user"
);
}
}
/// Structural-quarantine guard for `FullUserDto`. Admin-visible
/// extras (`has_password`, OPAQUE flags, `federation_*`,
/// `last_login_at`, `active`, quotas, `preferred_locale`,
/// `email_verified_at`) MUST live at the top level of
/// `FullUserDto`, NOT inside `.user`. If a future refactor
/// accidentally lifts one of them onto `PublicUserDto` (the
/// embedded `user` field), it leaks through `/api/users/{id}`
/// and every other public directory endpoint.
#[test]
fn admin_only_fields_stay_at_top_level_of_full_user_dto() {
let full = FullUserDto {
user: PublicUserDto {
id: "00000000-0000-0000-0000-000000000002".into(),
username: Some("bob".into()),
email: "bob@example.invalid".into(),
role: "user".into(),
image: None,
is_external: false,
given_name: None,
family_name: None,
is_online: false,
},
federation_kind: None,
federation_issuer: None,
preferred_locale: None,
email_verified_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
last_login_at: None,
active: true,
storage_quota_bytes: 10_737_418_240,
storage_used_bytes: 0,
has_password: true,
opaque_registered: false,
opaque_migrated: false,
};
let json: Value = serde_json::to_value(&full).expect("FullUserDto serialises");
for k in [
"has_password",
"opaque_registered",
"opaque_migrated",
"last_login_at",
"active",
"storage_quota_bytes",
"storage_used_bytes",
] {
assert!(json.get(k).is_some(), "{k} at top level of FullUserDto");
assert!(
json.pointer(&format!("/user/{k}")).is_none(),
"{k} must NOT nest in .user"
);
}
}
}