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