diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 3c620e08..e461888c 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -64,6 +64,8 @@ } from '$lib/api/endpoints/admin'; import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives'; import { seedUser } from '$lib/api/endpoints/users'; + import { resolveOwnerName } from '$lib/api/endpoints/favorites'; + import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte'; import { ensureResolvers, resolveRecipient, @@ -133,7 +135,10 @@ ] as const; /* ── Styled confirm modal (replaces native confirm) ── */ - let confirmState = $state<{ message: string; resolve: (ok: boolean) => void } | null>(null); + let confirmState = $state<{ + message: string; + resolve: (ok: boolean) => void; + } | null>(null); function showConfirm(message: string): Promise { return new Promise((resolve) => { confirmState = { message, resolve }; @@ -150,7 +155,11 @@ fat-finger deletion — a single accidental click on the wrong row won't wipe an account. The admin still bears final responsibility; this is UX friction, not authorization. */ - let deleteUserModal = $state<{ userId: string; username: string; email: string } | null>(null); + let deleteUserModal = $state<{ + userId: string; + username: string; + email: string; + } | null>(null); let deleteUserEmailInput = $state(''); let deleteUserBusy = $state(false); const deleteUserEmailMatches = $derived( @@ -233,7 +242,7 @@ case 'drives': return t('admin.drives', 'Drives'); case 'mounts': - return t('admin.mounts', 'External Mounts'); + return t('admin.mounts.tab', 'External Mounts'); case 'plugins': return t('admin.plugins', 'Plugins'); case 'oidc': @@ -263,19 +272,77 @@ }); let mountCreating = $state(false); + // Owner display cache for personal-drive rows in the mount tab. + // Every user's personal drive is hard-coded to the display name + // "Personal" (see `drive_pg_repository::create_personal_drive_atomic`), + // so the admin's mount-target selector reads as "Personal / Personal / + // Personal / …" without disambiguation. Resolve `default_for_user` + // via the shared owner-name resolver + memoised cache so the selector + // shows "Personal (alice)" per row. + const mountOwners = useOwnerCache(resolveOwnerName); + async function loadMounts() { mountsError = null; try { [mounts, mountDrives] = await Promise.all([listExternalMounts(), listAllDrives()]); + // Warm the owner cache for every personal drive in one parallel + // batch — the useOwnerCache resolver dedups and returns cached + // entries immediately, so a second loadMounts() call is free. + await mountOwners.resolve( + mountDrives + .filter((d) => d.kind === 'personal') + .map((d) => d.default_for_user) + .filter((id): id is string => !!id) + ); } catch (e) { mountsError = errorMessage(e); } } - function mountDriveName(driveId: string): string { - return mountDrives.find((drive) => drive.id === driveId)?.name ?? driveId; + /** + * Human-readable drive label for the mount selector + the mounts table. + * Personal drives get their owner appended (`Personal (alice)`) since + * the bare "Personal" is ambiguous across every user. Shared drives + * already have unique display names, so no suffix is added. + * + * `mountOwners.name(id)` returns `null` while the owner lookup is in + * flight — we fall back to the bare name in that window so the option + * text never flashes an empty/pending state. + */ + function driveDisplayLabel(drive: Drive): string { + if (drive.kind === 'personal' && drive.default_for_user) { + const owner = mountOwners.name(drive.default_for_user); + return owner ? `${drive.name} (${owner})` : drive.name; + } + return drive.name; } + function mountDriveName(driveId: string): string { + const drive = mountDrives.find((d) => d.id === driveId); + return drive ? driveDisplayLabel(drive) : driveId; + } + + /** + * Alphabetically sorted `mountDrives` — by display label so personal + * drives group together correctly under "Personal (…)". `$derived` + * so the order reactively re-computes when either the list changes + * or a pending owner-name resolution lands (`mountOwners.name(id)` + * starts `null` and settles to a string, which would otherwise + * stall alice next to zoe until the tab refreshes). + * + * `localeCompare` with `sensitivity: 'base'` — case-insensitive, + * accent-insensitive, locale-aware ordering (French `é` sorts with + * `e`, German `ä` with `a`, …). Matches how users expect a + * name-based list to be alphabetised. + */ + const sortedMountDrives = $derived( + [...mountDrives].sort((a, b) => + driveDisplayLabel(a).localeCompare(driveDisplayLabel(b), undefined, { + sensitivity: 'base' + }) + ) + ); + async function createMount() { if (!newMount.name.trim() || !newMount.host_path.trim() || !newMount.drive_id) return; mountCreating = true; @@ -388,7 +455,10 @@ disable_password_login: oidc.disable_password_login, provider_name: oidc.provider_name || null }); - oidcMsg = { text: t('admin.settings_saved_ok', 'Settings saved.'), ok: true }; + oidcMsg = { + text: t('admin.settings_saved_ok', 'Settings saved.'), + ok: true + }; } catch (e) { oidcMsg = { text: errorMessage(e), ok: false }; } finally { @@ -430,7 +500,10 @@ const r: StorageTestResult = await testStorage({ entry_name: name }); entryTest = { ...entryTest, [name]: { busy: false, result: r } }; } catch (e) { - entryTest = { ...entryTest, [name]: { busy: false, error: errorMessage(e) } }; + entryTest = { + ...entryTest, + [name]: { busy: false, error: errorMessage(e) } + }; } } @@ -1285,7 +1358,13 @@ } function openDriveCreate() { - driveForm = { name: '', ownerQuery: '', ownerPick: null, quotaValue: 0, quotaUnit: 1024 ** 3 }; + driveForm = { + name: '', + ownerQuery: '', + ownerPick: null, + quotaValue: 0, + quotaUnit: 1024 ** 3 + }; ownerSuggestions = []; driveCreateError = null; driveCreateOpen = true; @@ -3254,98 +3333,104 @@ {/if} {:else if tab === 'mounts'} -
-

{t('admin.mounts.title', 'External File Mounts')}

-

- {t( - 'admin.mounts.help', - 'Mount a host directory as a folder in your drive. Files stay on the host and are read live; deletes here are permanent.' - )} -

- -
{ - e.preventDefault(); - void createMount(); - }} + + { + e.preventDefault(); + void createMount(); + }} + > + + + + + -
- - {#if mountsError} -

{mountsError}

- {/if} - - {#if mounts} - {#if mounts.length === 0} -

{t('admin.mounts.empty', 'No mounts configured.')}

- {:else} - - - - - - - - - - - - - {#each mounts as m (m.mount_folder_id)} - - - - - - - - - {/each} - -
{t('admin.mounts.name', 'Name')}{t('admin.mounts.kind', 'Kind')}{t('admin.mounts.drive', 'Drive')}{t('admin.mounts.path', 'Path')}{t('admin.mounts.readonly', 'Read-only')}
{m.name}{m.kind}{mountDriveName(m.drive_id)}{m.mount_path}{m.read_only ? t('common.yes', 'Yes') : t('common.no', 'No')} - -
- {/if} - {:else} -

{t('common.loading', 'Loading…')}

- {/if} -
+ + + {/if} {:else if tab === 'drives'}