From 07320a3925d3592b432a3ce0309bd0d7b9451347 Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Thu, 25 Jun 2026 01:07:03 -0600 Subject: [PATCH] =?UTF-8?q?feat(mounts):=20P4=20=E2=80=94=20admin=20CRUD?= =?UTF-8?q?=20endpoints=20+=20frontend=20admin=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts are now configurable at runtime without DB surgery. Backend (admin-gated, /api/admin/external-mounts): - GET list all configured mounts - POST create: validate the provider config up front, create a mount-root folder under the admin's drive, insert the row, hot-reload the registry - DELETE remove the mount row + its root folder (host content is left intact), then hot-reload - ExternalMountRepositoryPort gains create/delete (PG impl); registry.reload() runs in-process so changes are live immediately - audit lines event="external_mount.config" action=create|delete Frontend: - admin.ts endpoints: listExternalMounts / createExternalMount / deleteExternalMount - new "External Mounts" tab in the admin page: add form (name + host path + read-only) and a list with delete Integration test for the repo create/delete round-trip (testcontainers). --- frontend/src/lib/api/endpoints/admin.ts | 49 ++++ frontend/src/routes/admin/+page.svelte | 139 +++++++++++- src/application/ports/external_mount_ports.rs | 35 +++ .../pg/external_mount_repository.rs | 66 +++++- .../api/handlers/admin_external_mounts.rs | 214 ++++++++++++++++++ src/interfaces/api/handlers/admin_handler.rs | 10 + src/interfaces/api/handlers/mod.rs | 1 + 7 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 src/interfaces/api/handlers/admin_external_mounts.rs diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index f29e74e1..686178a2 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -512,3 +512,52 @@ export function getPluginLogs( credentials: 'same-origin' }); } + +// ── External file mounts ──────────────────────────────────────────────────── + +/** A configured external mount as returned by the admin API. */ +export interface ExternalMount { + mount_folder_id: string; + name: string; + kind: string; + owner_id: string; + read_only: boolean; + drive_id: string; + mount_path: string; + config: Record; +} + +/** Request body for creating an external mount. */ +export interface CreateExternalMountInput { + name: string; + host_path: string; + kind?: string; + read_only?: boolean; +} + +/** GET /api/admin/external-mounts — list all configured mounts. */ +export function listExternalMounts(): Promise { + return apiJson('/api/admin/external-mounts', { + credentials: 'same-origin' + }); +} + +/** POST /api/admin/external-mounts — create a mount in the admin's drive. */ +export async function createExternalMount(input: CreateExternalMountInput): Promise { + const res = await apiFetch('/api/admin/external-mounts', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify(input) + }); + if (!res.ok) { + const e = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(e.message || `Create mount failed: ${res.status}`); + } + return (await res.json()) as ExternalMount; +} + +/** DELETE /api/admin/external-mounts/{id} — remove a mount (host content kept). */ +export function deleteExternalMount(mountFolderId: string): Promise { + return mutate(`/api/admin/external-mounts/${mountFolderId}`, 'DELETE'); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 528741e8..e96c1892 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -32,6 +32,11 @@ testOidc, testStorage, verifyMigration, + createExternalMount, + deleteExternalMount, + listExternalMounts, + type ExternalMount, + type CreateExternalMountInput, type AdminDashboard, type GeneratedKey, type MigrationStatus, @@ -114,13 +119,53 @@ confirmState = null; } - type Tab = 'dashboard' | 'users' | 'drives' | 'plugins' | 'oidc' | 'storage' | 'smtp'; + type Tab = 'dashboard' | 'users' | 'drives' | 'mounts' | 'plugins' | 'oidc' | 'storage' | 'smtp'; let tab = $state('dashboard'); // Dashboard let dashboard = $state(null); let dashboardError = $state(null); + // External mounts + let mounts = $state(null); + let mountsError = $state(null); + let newMount = $state({ name: '', host_path: '', read_only: false }); + let mountCreating = $state(false); + + async function loadMounts() { + mountsError = null; + try { + mounts = await listExternalMounts(); + } catch (e) { + mountsError = errorMessage(e); + } + } + + async function createMount() { + if (!newMount.name.trim() || !newMount.host_path.trim()) return; + mountCreating = true; + try { + const created = await createExternalMount(newMount); + mounts = [...(mounts ?? []), created]; + newMount = { name: '', host_path: '', read_only: false }; + } catch (e) { + mountsError = errorMessage(e); + } finally { + mountCreating = false; + } + } + + async function deleteMount(id: string) { + if (!(await showConfirm('Remove this mount? Files on the host are kept.'))) return; + try { + await deleteExternalMount(id); + mounts = mounts?.filter((m) => m.mount_folder_id !== id) ?? null; + } catch (e) { + reportError(e); + await loadMounts(); + } + } + // SMTP let smtp = $state(null); let smtpTo = $state(''); @@ -1136,6 +1181,7 @@ dashboard: false, users: false, drives: false, + mounts: false, plugins: false, oidc: false, storage: false, @@ -1148,6 +1194,7 @@ if (tab === 'dashboard') void loadDashboard(); else if (tab === 'users') void loadUsers(); else if (tab === 'drives') void loadDrivesTab(); + else if (tab === 'mounts') void loadMounts(); else if (tab === 'plugins') void loadPlugins(); else if (tab === 'oidc') void loadOidc(); else if (tab === 'storage') { @@ -1210,6 +1257,15 @@ {t('admin.drives', 'Drives')} + + + + {#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.path', 'Path')}{t('admin.mounts.readonly', 'Read-only')}
{m.name}{m.kind}{m.mount_path}{m.read_only ? t('common.yes', 'Yes') : t('common.no', 'No')} + +
+ {/if} + {:else} +

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

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