diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index dda24e49..c74da06e 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -199,6 +199,48 @@ export function listUsers(limit: number, offset: number): Promise>(); + +export function getUserAdmin(id: string): Promise { + const hit = adminUserCache.get(id); + if (hit) return hit; + const pending = (async (): Promise => { + try { + return await apiJson(`/api/admin/users/${encodeURIComponent(id)}`, { + credentials: 'same-origin' + }); + } catch { + return null; + } + })(); + adminUserCache.set(id, pending); + return pending; +} + +/** Drop cached admin lookups so mutations (quota change, role change, + * delete) don't return stale data. Called with no arg = clear all, + * or with a specific user id to drop just that entry. */ +export function invalidateAdminUserCache(userId?: string): void { + if (userId) adminUserCache.delete(userId); + else adminUserCache.clear(); +} + export interface CreateUserInput { username: string; password: string; diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 23b91c24..b10e5886 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -17,6 +17,7 @@ installPlugin, listPlugins, listUsers, + getUserAdmin, migrationAction, reextractAudioMetadata, reextractPhotoMetadata, @@ -883,6 +884,17 @@ // "still loading" — the stack treats undefined as no-owners-yet. let driveMembers = $state>({}); + // Owner user record for each PERSONAL drive, keyed by drive id. + // Personal drives have exactly one user-subject owner and their + // `d.quota_bytes` is null — the effective quota comes from the + // owner user's `storage_quota_bytes` envelope (see memory + // `project_user_envelope_quota_model`). We resolve the owner + // once at load time via the admin-scoped `/api/admin/users/{id}` + // endpoint so the Name column can show the username instead of + // the drive UUID, and the Quota column can display the envelope + // cap. Shared drives are absent from this map. + let personalDriveOwners = $state>({}); + async function loadDrivesTab() { drivesError = null; try { @@ -911,6 +923,26 @@ }) ); driveMembers = nextMembers; + + // Resolve owner users for personal drives — used by the Name + // column (username instead of UUID) and the Quota column + // (envelope cap instead of "∞" for a NULL drive.quota_bytes). + // getUserAdmin caches per-id at module scope, so N personal + // drives owned by the same user share one fetch. Runs after + // `driveMembers` is populated because the owner subject id + // comes from the first user-typed member of the drive. + const nextOwners: Record = {}; + await Promise.all( + drivesList + .filter((d) => d.kind === 'personal') + .map(async (d) => { + const ownerMember = nextMembers[d.id]?.find((m) => m.subject.type === 'user'); + if (!ownerMember) return; + const user = await getUserAdmin(ownerMember.subject.id); + if (user) nextOwners[d.id] = user; + }) + ); + personalDriveOwners = nextOwners; } function driveKindLabel(d: Drive): string { @@ -2252,15 +2284,40 @@ {#each drivesList as d (d.id)} + {@const owner = d.kind === 'personal' ? personalDriveOwners[d.id] : undefined} + + {@const effectiveQuota = + d.kind === 'personal' + ? owner && owner.storage_quota_bytes > 0 + ? owner.storage_quota_bytes + : null + : d.quota_bytes && d.quota_bytes > 0 + ? d.quota_bytes + : null} {@const pct = - d.quota_bytes && d.quota_bytes > 0 - ? Math.min(100, (d.used_bytes / d.quota_bytes) * 100) + effectiveQuota !== null + ? Math.min(100, (d.used_bytes / effectiveQuota) * 100) : null}
{d.name} - {d.id} + {#if d.kind === 'personal'} + {#if owner} + {owner.username ?? owner.email} + {:else} + {t('common.loading', 'Loading…')} + {/if} + {/if}
@@ -2288,8 +2345,8 @@ {/if} - {formatBytes(d.used_bytes)} / {d.quota_bytes && d.quota_bytes > 0 - ? formatBytes(d.quota_bytes) + {formatBytes(d.used_bytes)} / {effectiveQuota !== null + ? formatBytes(effectiveQuota) : '∞'}