refactor(User): apply changes on frontend
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
id: '1',
|
// derived accessor over `session.me.full.user`; only `session.me`
|
||||||
username: 'admin',
|
// is settable. Fixture composes the nested shape — public identity
|
||||||
email: 'a@x.test',
|
// (username/email/name) on `.full.user`, admin+self extras
|
||||||
given_name: 'A',
|
// (storage_*, has_password) on `.full`, self-only bag (ui_prefs,
|
||||||
family_name: 'B',
|
// dpop_bound, force_password_change, can_edit_image) at the top.
|
||||||
role: 'admin',
|
// See docs/plan/userdto-refactor.md.
|
||||||
storage_used_bytes: 10,
|
session.me = {
|
||||||
storage_quota_bytes: 100,
|
full: {
|
||||||
is_external: false
|
user: {
|
||||||
|
id: '1',
|
||||||
|
username: 'admin',
|
||||||
|
email: 'a@x.test',
|
||||||
|
given_name: 'A',
|
||||||
|
family_name: 'B',
|
||||||
|
role: 'admin',
|
||||||
|
is_external: false
|
||||||
|
},
|
||||||
|
storage_used_bytes: 10,
|
||||||
|
storage_quota_bytes: 100
|
||||||
|
}
|
||||||
} as never;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()` —
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
id: 'u1',
|
user: {
|
||||||
username: 'bob',
|
id: 'u1',
|
||||||
email: 'bob@x.test',
|
username: 'bob',
|
||||||
role: 'user',
|
email: 'bob@x.test',
|
||||||
|
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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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_quota_bytes: 1000,
|
||||||
|
has_password: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { session, ui } = vi.hoisted(() => {
|
||||||
|
const me = {
|
||||||
|
full: {
|
||||||
|
user: {
|
||||||
|
id: '1',
|
||||||
|
username: 'admin',
|
||||||
|
email: 'a@x.test',
|
||||||
|
given_name: 'A',
|
||||||
|
family_name: 'B',
|
||||||
|
role: 'admin',
|
||||||
|
is_external: false
|
||||||
|
},
|
||||||
storage_used_bytes: 100,
|
storage_used_bytes: 100,
|
||||||
storage_quota_bytes: 1000,
|
storage_quota_bytes: 1000,
|
||||||
is_external: false,
|
|
||||||
has_password: true
|
has_password: true
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
ui: { notify: vi.fn() }
|
return {
|
||||||
}));
|
session: {
|
||||||
|
loaded: true,
|
||||||
|
load: vi.fn(),
|
||||||
|
me,
|
||||||
|
user: me.full.user
|
||||||
|
},
|
||||||
|
ui: { notify: vi.fn() }
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
vi.mock('$lib/stores/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 });
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user