diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 3aa84f38..f29e74e1 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -5,7 +5,7 @@ */ import { apiFetch, apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { User } from '$lib/api/types'; +import type { Drive, DriveMember, DriveMemberSubject, DriveRole, User } from '$lib/api/types'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -69,6 +69,94 @@ export function generateEncryptionKey(): Promise { return postJson('/api/admin/settings/storage/generate-key'); } +// ── Drives ────────────────────────────────────────────────────────────── + +/** + * `GET /api/admin/drives` — every drive on the system, admin-only. + * + * Distinct from `listDrives()` in `$lib/api/endpoints/drives`, which is + * the caller's own listing (filtered through `role_grants`). An admin + * who creates a shared drive for someone else has no role on it, so + * the user-facing listing would skip it — this endpoint returns + * everything for the admin panel's "Drives" tab. + */ +export function listAllDrives(): Promise { + return apiJson('/api/admin/drives', { credentials: 'same-origin' }); +} + +/** + * `GET /api/admin/drives/{id}/members` — every role grant on a drive, + * admin-only. The user-facing `/api/drives/{id}/members` requires + * `Permission::Read` on the drive; an admin who created the drive + * for someone else has no role on it and would hit a 404 there. This + * endpoint reuses `list_grants_on_resource` with the admin guard at + * the route edge, so the same `DriveMember` shape comes back. + */ +export function listDriveMembersAdmin(driveId: string): Promise { + return apiJson(`/api/admin/drives/${encodeURIComponent(driveId)}/members`, { + credentials: 'same-origin' + }); +} + +/** + * `POST /api/admin/drives/{id}/members` — add (or refresh) a member as + * an admin, bypassing the per-drive `Manage` check. Personal-drive + * guard + last-owner protection still apply. Throws on non-2xx. + */ +export async function addDriveMemberAdmin( + driveId: string, + subject: DriveMemberSubject, + role: DriveRole, + expiresAt?: string | null +): Promise { + const res = await apiFetch(`/api/admin/drives/${encodeURIComponent(driveId)}/members`, { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ subject, role, expires_at: expiresAt ?? null }) + }); + if (!res.ok) { + let detail = ''; + try { + const parsed = (await res.json()) as { error?: string; message?: string }; + detail = parsed.error ?? parsed.message ?? ''; + } catch { + /* response body wasn't JSON */ + } + throw new Error(detail || `add member failed: ${res.status}`); + } + return (await res.json()) as DriveMember; +} + +/** + * `DELETE /api/admin/drives/{id}/members/{kind}/{sid}` — remove a + * member as an admin. Idempotent (removing a non-member returns 204). + * Last-owner protection still applies (400 with `reason='last_owner'`). + */ +export async function removeDriveMemberAdmin( + driveId: string, + subject: DriveMemberSubject +): Promise { + const url = + `/api/admin/drives/${encodeURIComponent(driveId)}/members/` + + `${encodeURIComponent(subject.type)}/${encodeURIComponent(subject.id)}`; + const res = await apiFetch(url, { + method: 'DELETE', + credentials: 'same-origin', + headers: getCsrfHeaders() + }); + if (!res.ok) { + let detail = ''; + try { + const parsed = (await res.json()) as { error?: string; message?: string }; + detail = parsed.error ?? parsed.message ?? ''; + } catch { + /* response body wasn't JSON */ + } + throw new Error(detail || `remove member failed: ${res.status}`); + } +} + // ── Users ─────────────────────────────────────────────────────────────── export interface AdminUsersPage { diff --git a/frontend/src/lib/api/endpoints/drives.ts b/frontend/src/lib/api/endpoints/drives.ts index 2a3600cb..23409fbb 100644 --- a/frontend/src/lib/api/endpoints/drives.ts +++ b/frontend/src/lib/api/endpoints/drives.ts @@ -1,6 +1,6 @@ /** - * Drives endpoints. D0 ships read-only listing; D2 adds the membership API. - * D3 will add the create-shared-drive flow under the same module. + * Drives endpoints. D0 ships read-only listing; D2 adds the membership API; + * D3a adds the create-shared-drive flow. * * Consumers usually go through the `drives` store (`$lib/stores/drives.svelte`) * which dedupes the request and caches the list — touch this module directly @@ -8,7 +8,13 @@ */ import { apiFetch, apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { Drive, DriveMember, DriveMemberSubject, DriveRole } from '$lib/api/types'; +import type { + CreateDriveBody, + Drive, + DriveMember, + DriveMemberSubject, + DriveRole +} from '$lib/api/types'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -17,6 +23,33 @@ export function listDrives(): Promise { return apiJson('/api/drives', { credentials: 'same-origin' }); } +/** + * `POST /api/drives` — create a drive (D3a). Today only `kind: 'shared'` is + * implemented; `kind: 'personal'` is accepted on the wire but returns 501. + * Admin-only at the server; callers should already have gated the UI on + * `session.user?.role === 'admin'`. Throws on non-2xx with the server's + * error body parsed where possible. + */ +export async function createDrive(body: CreateDriveBody): Promise { + const res = await apiFetch('/api/drives', { + method: 'POST', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + credentials: 'same-origin', + body: JSON.stringify(body) + }); + if (!res.ok) { + let detail = ''; + try { + const parsed = (await res.json()) as { error?: string; message?: string }; + detail = parsed.error ?? parsed.message ?? ''; + } catch { + /* response body wasn't JSON */ + } + throw new Error(detail || `create drive failed: ${res.status}`); + } + return (await res.json()) as Drive; +} + /** `GET /api/drives/{id}/members` — every role grant on the drive. */ export function listDriveMembers(driveId: string): Promise { return apiJson(`/api/drives/${encodeURIComponent(driveId)}/members`, { diff --git a/frontend/src/lib/api/endpoints/recipients.ts b/frontend/src/lib/api/endpoints/recipients.ts index 5a5451ff..62fab3de 100644 --- a/frontend/src/lib/api/endpoints/recipients.ts +++ b/frontend/src/lib/api/endpoints/recipients.ts @@ -40,28 +40,45 @@ function looksLikeEmail(q: string): boolean { } // The system book lists all users; we filter client-side (matches the original). +// Two caches because the backend response differs (default excludes the caller, +// `?include_self=1` returns them). Keying by flag avoids one variant overwriting +// the other. let contactCache: Contact[] | null = null; +let contactCacheWithSelf: Contact[] | null = null; /** `false` once we confirm the system address book is unavailable. */ let directoryAvailable: boolean | null = null; -async function systemContacts(): Promise { - if (contactCache) return contactCache; +async function systemContacts(includeSelf = false): Promise { + const cached = includeSelf ? contactCacheWithSelf : contactCache; + if (cached) return cached; try { - const res = await apiFetch('/api/address-books/system/contacts', { - credentials: 'same-origin' - }); + const url = includeSelf + ? '/api/address-books/system/contacts?include_self=1' + : '/api/address-books/system/contacts'; + const res = await apiFetch(url, { credentials: 'same-origin' }); if (!res.ok) { directoryAvailable = false; + if (includeSelf) { + contactCacheWithSelf = []; + return contactCacheWithSelf; + } contactCache = []; return contactCache; } directoryAvailable = true; - contactCache = (await res.json()) as Contact[]; + const list = (await res.json()) as Contact[]; + if (includeSelf) contactCacheWithSelf = list; + else contactCache = list; + return list; } catch { directoryAvailable = false; + if (includeSelf) { + contactCacheWithSelf = []; + return contactCacheWithSelf; + } contactCache = []; + return contactCache; } - return contactCache; } /** @@ -135,16 +152,23 @@ export function resolveRecipient(type: 'user' | 'group', id: string): Recipient /** * Combined user + group results matching the query (case-insensitive), plus a * synthetic invite-by-email suggestion when the query is an email that no - * contact already owns. The current logged-in user is excluded — you can't - * share with yourself. Capped at 8 combined (groups, then users, then email). + * contact already owns. Capped at 8 combined (groups, then users, then email). + * + * `includeSelf` defaults to `false` — the share modal excludes the current + * caller from the picker because "you can't share with yourself". The admin + * drive-owners surface flips it on: an admin legitimately needs to add + * themselves (or anyone) as Owner without that personal-share restriction. */ -export async function searchRecipients(query: string): Promise { +export async function searchRecipients( + query: string, + { includeSelf = false }: { includeSelf?: boolean } = {} +): Promise { const q = query.toLowerCase().trim(); if (!q) return []; const currentUserId = session.user?.id ?? null; - const [contacts, groups] = await Promise.all([systemContacts(), searchGroups(q)]); + const [contacts, groups] = await Promise.all([systemContacts(includeSelf), searchGroups(q)]); const matched = contacts - .filter((c) => c.id !== currentUserId) + .filter((c) => includeSelf || c.id !== currentUserId) .map((c) => ({ c, ...contactLabel(c) })) .filter( ({ label, email }) => label.toLowerCase().includes(q) || email.toLowerCase().includes(q) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 56356ed5..3d8fb9dd 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -243,6 +243,19 @@ export interface Drive { caller_role?: DriveRole | null; } +/** + * Request body for `POST /api/drives` (D3a). Mirrors `CreateDriveDto` in + * `src/interfaces/api/handlers/drive_handler.rs`. `kind: 'personal'` is a + * recognised wire shape but returns 501 today (the authz model + quota + * source for secondary personals are still open product questions). + */ +export interface CreateDriveBody { + kind: DriveKind; + name: string; + owner: DriveMemberSubject; + quota_bytes?: number | null; +} + /** * One row from `GET /api/drives/{id}/members`. Mirrors `GrantDto` in * `src/application/dtos/grant_dto.rs` — the shape is the same as any diff --git a/frontend/src/lib/components/DrivePicker.svelte b/frontend/src/lib/components/DrivePicker.svelte index d2353c73..985c007f 100644 --- a/frontend/src/lib/components/DrivePicker.svelte +++ b/frontend/src/lib/components/DrivePicker.svelte @@ -62,23 +62,13 @@ onMount(() => { void drivesStore.load(); }); - - // Dev/test override — set `localStorage.setItem('oxi-show-drive-picker', '1')` - // from DevTools to force the picker visible even with a single drive (useful - // for testing the UI before D3's shared-drive creation lands). Evaluated once - // at component mount; reload after toggling to apply. - const forceShowPicker = $derived( - typeof localStorage !== 'undefined' && localStorage.getItem('oxi-show-drive-picker') === '1' - ); -{#if drivesStore.loaded && (drivesStore.drives.length > 1 || forceShowPicker)} + (e.g. a shared one created from the admin Drives tab) exists. --> +{#if drivesStore.loaded && drivesStore.drives.length > 1}
    {#each sortedDrives as d (d.id)}
  • diff --git a/frontend/src/lib/components/OwnerAvatarStack.svelte b/frontend/src/lib/components/OwnerAvatarStack.svelte new file mode 100644 index 00000000..d1fb2330 --- /dev/null +++ b/frontend/src/lib/components/OwnerAvatarStack.svelte @@ -0,0 +1,207 @@ + + +{#if owners.length === 0} + {t('admin.drive_no_owners', 'No owners')} +{:else} +
      + {#each shown as m (`${m.subject.type}-${m.subject.id}`)} + {@const title = titleFor(m)} + {@const image = imageFor(m)} +
    • + {#if m.subject.type === 'group'} + + + + {:else if image} + + {:else} + + {userInitials(nameFor(m))} + + {/if} + {#if isExternalFor(m)} + + + + {/if} +
    • + {/each} + {#if overflow > 0} +
    • + + +{overflow} + +
    • + {/if} +
    +{/if} + + diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index d3147bc4..528741e8 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -42,16 +42,30 @@ type PluginLogEntry, type PluginRetention, type ReextractResult, + addDriveMemberAdmin, + listAllDrives, + listDriveMembersAdmin, + removeDriveMemberAdmin, type SmtpInfo, type SmtpTestResult, type StorageSettings, type StorageTestResult } from '$lib/api/endpoints/admin'; - import type { User } from '$lib/api/types'; + import { createDrive } from '$lib/api/endpoints/drives'; + import { + ensureResolvers, + resolveRecipient, + searchRecipients, + type Recipient + } from '$lib/api/endpoints/recipients'; + import type { Drive, DriveMember, User } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; + import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; + import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; import { session } from '$lib/stores/session.svelte'; + import { drives as drivesStore } from '$lib/stores/drives.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { formatBytes } from '$lib/utils/format'; @@ -100,7 +114,7 @@ confirmState = null; } - type Tab = 'dashboard' | 'users' | 'plugins' | 'oidc' | 'storage' | 'smtp'; + type Tab = 'dashboard' | 'users' | 'drives' | 'plugins' | 'oidc' | 'storage' | 'smtp'; let tab = $state('dashboard'); // Dashboard @@ -833,6 +847,259 @@ } } + // ── Drives (D3a admin create-shared-drive) ─────────────────────────────── + let drivesList = $state([]); + let drivesError = $state(null); + let driveCreateOpen = $state(false); + let driveCreating = $state(false); + let driveCreateError = $state(null); + let driveForm = $state({ + name: '', + ownerQuery: '', + ownerPick: null as Recipient | null, + quotaValue: 0, + quotaUnit: (1024 ** 3) as number + }); + let ownerSuggestions = $state([]); + let ownerSearching = $state(false); + let ownerSearchToken = 0; + + // Members keyed by drive id. The admin Drives table renders an Owner + // avatar stack per row; we lazily fetch members for each drive in + // parallel after the drives listing comes back. Missing entries mean + // "still loading" — the stack treats undefined as no-owners-yet. + let driveMembers = $state>({}); + + async function loadDrivesTab() { + drivesError = null; + try { + // `/api/admin/drives` — system-wide view; an admin who creates + // a drive for another user has no `role_grants` row on it and + // wouldn't see it via the user-facing `/api/drives` listing. + drivesList = await listAllDrives(); + } catch (e) { + drivesError = errorMessage(e); + return; + } + // Seed the contact + group caches so the avatar stack renders real + // labels (and stable initials/colours) instead of bare UUIDs. + void ensureResolvers(); + // Fan out one members fetch per drive in parallel. A swallowed + // error per drive degrades gracefully — that row's stack shows + // "No owners" rather than blocking the whole page. + const nextMembers: Record = {}; + await Promise.all( + drivesList.map(async (d) => { + try { + nextMembers[d.id] = await listDriveMembersAdmin(d.id); + } catch { + nextMembers[d.id] = []; + } + }) + ); + driveMembers = nextMembers; + } + + function driveKindLabel(d: Drive): string { + if (d.kind === 'shared') return t('admin.drive_kind_shared', 'Shared'); + return d.default_for_user + ? t('admin.drive_kind_personal_default', 'Personal (default)') + : t('admin.drive_kind_personal', 'Personal'); + } + + function openDriveCreate() { + driveForm = { name: '', ownerQuery: '', ownerPick: null, quotaValue: 0, quotaUnit: 1024 ** 3 }; + ownerSuggestions = []; + driveCreateError = null; + driveCreateOpen = true; + } + + // Search runs in the background; a monotonically-incrementing `token` + // guards against out-of-order results overwriting a newer query — the + // network races by query length and keystroke timing. + async function searchOwnerCandidates(q: string) { + driveForm.ownerPick = null; + const trimmed = q.trim(); + if (!trimmed) { + ownerSuggestions = []; + return; + } + const token = ++ownerSearchToken; + ownerSearching = true; + try { + // `includeSelf` — admin creating a drive may legitimately want to + // own it themselves; the default share-modal "no self" rule + // doesn't apply in the admin context. + const results = await searchRecipients(trimmed, { includeSelf: true }); + if (token !== ownerSearchToken) return; // a newer query is in flight + // Filter out the synthetic invite-by-email row — POST /api/drives + // refuses email subjects (drive Owner must be a real user or group). + ownerSuggestions = results.filter((r) => r.type === 'user' || r.type === 'group'); + } finally { + if (token === ownerSearchToken) ownerSearching = false; + } + } + + function pickOwner(r: Recipient) { + driveForm.ownerPick = r; + driveForm.ownerQuery = r.label; + ownerSuggestions = []; + } + + // ── Manage-owners modal (D3a admin bypass) ────────────────────────────── + // State is null when closed; carries the drive being edited otherwise. + let manageOwnersDrive = $state(null); + let manageOwnersError = $state(null); + let manageOwnersBusy = $state(false); + // Independent owner-search state so the "manage owners" autocomplete + // doesn't fight with the create-drive form's autocomplete. + let manageOwnersQuery = $state(''); + let manageOwnersSuggestions = $state([]); + let manageOwnersSearchToken = 0; + let manageOwnersSearching = $state(false); + + function openManageOwners(d: Drive) { + manageOwnersDrive = d; + manageOwnersError = null; + manageOwnersQuery = ''; + manageOwnersSuggestions = []; + // Members were already fetched on tab load; nothing else to do. + } + + function closeManageOwners() { + manageOwnersDrive = null; + manageOwnersError = null; + manageOwnersQuery = ''; + manageOwnersSuggestions = []; + } + + async function searchManageOwnersCandidates(q: string) { + const trimmed = q.trim(); + if (!trimmed) { + manageOwnersSuggestions = []; + return; + } + const token = ++manageOwnersSearchToken; + manageOwnersSearching = true; + try { + // Admin adding owners — allow self (the share-modal "no + // self" guard doesn't apply to drive-owner management). + const results = await searchRecipients(trimmed, { includeSelf: true }); + if (token !== manageOwnersSearchToken) return; + // Filter out emails (POST admin/members refuses them) and any + // subject already an Owner of this drive (no point re-adding). + const currentOwnerIds = new Set( + (driveMembers[manageOwnersDrive?.id ?? ''] ?? []) + .filter((m) => m.role === 'owner') + .map((m) => `${m.subject.type}-${m.subject.id}`) + ); + manageOwnersSuggestions = results.filter( + (r) => + (r.type === 'user' || r.type === 'group') && !currentOwnerIds.has(`${r.type}-${r.id}`) + ); + } finally { + if (token === manageOwnersSearchToken) manageOwnersSearching = false; + } + } + + // Pessimistic refetch after every mutation — the membership list is + // small (a handful of owners) and the alternative (mutating local + // state) duplicates the server's role-resolution + last-owner logic. + async function reloadDriveMembers(driveId: string) { + try { + driveMembers = { + ...driveMembers, + [driveId]: await listDriveMembersAdmin(driveId) + }; + } catch (e) { + manageOwnersError = errorMessage(e); + } + } + + async function addOwner(r: Recipient) { + if (!manageOwnersDrive || (r.type !== 'user' && r.type !== 'group')) return; + manageOwnersBusy = true; + manageOwnersError = null; + try { + await addDriveMemberAdmin(manageOwnersDrive.id, { type: r.type, id: r.id }, 'owner'); + manageOwnersQuery = ''; + manageOwnersSuggestions = []; + await reloadDriveMembers(manageOwnersDrive.id); + } catch (e) { + manageOwnersError = errorMessage(e); + } finally { + manageOwnersBusy = false; + } + } + + async function removeOwner(m: DriveMember) { + if (!manageOwnersDrive) return; + const confirmMsg = t('admin.drive_owner_remove_confirm', 'Remove this owner from the drive?'); + if (!(await showConfirm(confirmMsg))) return; + manageOwnersBusy = true; + manageOwnersError = null; + try { + await removeDriveMemberAdmin(manageOwnersDrive.id, { + type: m.subject.type, + id: m.subject.id + }); + await reloadDriveMembers(manageOwnersDrive.id); + } catch (e) { + manageOwnersError = errorMessage(e); + } finally { + manageOwnersBusy = false; + } + } + + // Re-derive the current owners list inside the modal so it reacts to + // `driveMembers` changes after add/remove. + const manageOwnersList = $derived( + manageOwnersDrive + ? (driveMembers[manageOwnersDrive.id] ?? []).filter( + (m) => m.role === 'owner' && (m.subject.type === 'user' || m.subject.type === 'group') + ) + : [] + ); + + async function submitDriveCreate(e: SubmitEvent) { + e.preventDefault(); + const name = driveForm.name.trim(); + if (name.length === 0) { + driveCreateError = t('admin.drive_error_name_required', 'Drive name is required.'); + return; + } + const owner = driveForm.ownerPick; + if (!owner || (owner.type !== 'user' && owner.type !== 'group')) { + driveCreateError = t( + 'admin.drive_error_owner_required', + 'Pick a user or group as the drive owner.' + ); + return; + } + driveCreating = true; + driveCreateError = null; + try { + await createDrive({ + kind: 'shared', + name, + owner: { type: owner.type, id: owner.id }, + quota_bytes: + driveForm.quotaValue > 0 ? Math.round(driveForm.quotaValue * driveForm.quotaUnit) : null + }); + driveCreateOpen = false; + await loadDrivesTab(); + // The global drives store backs the sidebar picker; drop its cache + // so the new drive shows up for every consumer (picker, breadcrumb, + // session bootstrap) without a page reload. + drivesStore.invalidate(); + ui.notify(t('admin.drive_created', 'Drive created.'), 'success'); + } catch (err) { + driveCreateError = errorMessage(err); + } finally { + driveCreating = false; + } + } + async function togglePlugin(p: PluginInfo) { try { await setPluginEnabled(p.id, !p.enabled); @@ -868,6 +1135,7 @@ let loaded = $state>({ dashboard: false, users: false, + drives: false, plugins: false, oidc: false, storage: false, @@ -879,6 +1147,7 @@ loaded[tab] = true; if (tab === 'dashboard') void loadDashboard(); else if (tab === 'users') void loadUsers(); + else if (tab === 'drives') void loadDrivesTab(); else if (tab === 'plugins') void loadPlugins(); else if (tab === 'oidc') void loadOidc(); else if (tab === 'storage') { @@ -932,6 +1201,15 @@ {t('admin.users', 'Users')} + + + {#if drivesError} +

    {drivesError}

    + {:else if drivesList.length === 0} +

    {t('admin.no_drives', 'No drives yet.')}

    + {:else} + + + + + + + + + + + + + {#each drivesList as d (d.id)} + {@const pct = + d.quota_bytes && d.quota_bytes > 0 + ? Math.min(100, (d.used_bytes / d.quota_bytes) * 100) + : null} + + + + + + + + + {/each} + +
    {t('admin.drive_name', 'Name')}{t('admin.drive_kind', 'Kind')}{t('admin.drive_owners', 'Owners')}{t('admin.drive_usage', 'Usage')}{t('admin.drive_created_at', 'Created')}
    +
    + {d.name} + {d.id} +
    +
    + + {driveKindLabel(d)} + + + {#if driveMembers[d.id]} + + {:else} + {t('common.loading', 'Loading…')} + {/if} + +
    + {#if pct !== null} +
    +
    70} + class:quota-fill--danger={pct > 90} + style:width="{pct}%" + >
    +
    + {/if} + + {formatBytes(d.used_bytes)} / {d.quota_bytes && d.quota_bytes > 0 + ? formatBytes(d.quota_bytes) + : '∞'} + +
    +
    {timeAgo(d.created_at)} + +
    + {#if d.kind === 'shared'} + + {/if} +
    +
    + {/if} {:else if !pluginsAvailable}

    {t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}

    {:else if pluginsError} @@ -2013,6 +2387,221 @@ {/snippet} + + (driveCreateOpen = false)} +> +
    + + + + {#if driveCreateError}

    {driveCreateError}

    {/if} +
    + {#snippet footer()} + + + {/snippet} +
    + + + + {#if manageOwnersDrive} +
    +
    + + searchManageOwnersCandidates(e.currentTarget.value)} + placeholder={t('admin.drive_owner_placeholder', 'Search a user or group…')} + autocomplete="off" + disabled={manageOwnersBusy} + /> + {#if manageOwnersSearching} + {t('common.loading', 'Loading…')} + {:else if manageOwnersSuggestions.length > 0} +
      + {#each manageOwnersSuggestions as r (`${r.type}-${r.id}`)} +
    • + +
    • + {/each} +
    + {/if} +
    + +
    +

    + {t('admin.drive_current_owners', 'Current owners')} + ({manageOwnersList.length}) +

    + {#if manageOwnersList.length === 0} +

    {t('admin.drive_no_owners', 'No owners')}

    + {:else} +
      + {#each manageOwnersList as m (`${m.subject.type}-${m.subject.id}`)} +
    • + {#if m.subject.type === 'user'} + + {:else} + + + + + {resolveRecipient('group', m.subject.id).label} + + + {/if} + +
    • + {/each} +
    + {/if} +
    + + {#if manageOwnersError} +

    {manageOwnersError}

    + {/if} +
    + {/if} + {#snippet footer()} + + {/snippet} +
    + diff --git a/src/application/dtos/drive_dto.rs b/src/application/dtos/drive_dto.rs index ed464646..de35a67e 100644 --- a/src/application/dtos/drive_dto.rs +++ b/src/application/dtos/drive_dto.rs @@ -100,3 +100,4 @@ impl From for DriveDto { } } } + diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index acc1838a..7b26edf8 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -173,54 +173,105 @@ impl DriveManagementService { /// /// `set_role` is idempotent — `(subject, resource)` is unique — so the /// two HTTP shapes share one service method. Returns the resulting grant. + /// + /// `caller_is_admin = true` skips the per-drive `Manage` check + /// (used by `/api/admin/drives/{id}/members` so an admin who + /// created the drive for someone else can still edit owners). + /// Personal-drive guard and last-owner protection still apply — + /// admin bypass is about *access*, not invariants. The audit log + /// flags admin-driven changes via `via_admin = true` so a reader + /// can tell what fired the mutation. The caller (HTTP handler) is + /// authoritative for `caller_is_admin`; the route gate is the + /// source of truth and the service trusts the flag. pub async fn set_member_role( &self, caller_id: Uuid, + caller_is_admin: bool, drive_id: Uuid, subject: Subject, role: Role, expires_at: Option>, ) -> Result { let resource = Resource::Drive(drive_id); - self.authz - .require(Subject::User(caller_id), Permission::Manage, resource) - .await?; + if !caller_is_admin { + self.authz + .require(Subject::User(caller_id), Permission::Manage, resource) + .await?; + } self.refuse_if_personal(drive_id, "set_member_role").await?; // Demotion of the last owner = last-owner protection trips. A fresh // owner-role write or any non-owner subject is fine; only the case // "this subject is currently the only owner AND the new role is not - // owner" is refused. + // owner" is refused. Applies to admin-bypass too: orphaning a + // shared drive is the same category error regardless of who fires + // the request. if !matches!(role, Role::Owner) { self.refuse_if_last_owner_change(drive_id, subject, caller_id) .await?; } - self.authz + let grant = self + .authz .set_role(caller_id, subject, role, resource, expires_at) - .await + .await?; + + if caller_is_admin { + tracing::info!( + target: "audit", + event = "drive_membership.set_via_admin", + drive_id = %drive_id, + subject_type = subject.type_str(), + subject_id = %subject.id(), + role = role.as_str(), + by = %caller_id, + "👮🏻‍♂️ admin set drive member role bypassing Manage check", + ); + } + Ok(grant) } /// `DELETE /api/drives/{id}/members/{subject_id}`. Idempotent — removing /// a subject with no current grant succeeds (matches `clear_role`). + /// + /// `caller_is_admin` mirrors `set_member_role`: skips the per-drive + /// `Manage` check but keeps personal-drive guard + last-owner + /// protection. Audit emits `drive_membership.removed_via_admin` + /// when the bypass fires. pub async fn remove_member( &self, caller_id: Uuid, + caller_is_admin: bool, drive_id: Uuid, subject: Subject, ) -> Result<(), DomainError> { let resource = Resource::Drive(drive_id); - self.authz - .require(Subject::User(caller_id), Permission::Manage, resource) - .await?; + if !caller_is_admin { + self.authz + .require(Subject::User(caller_id), Permission::Manage, resource) + .await?; + } self.refuse_if_personal(drive_id, "remove_member").await?; self.refuse_if_last_owner_change(drive_id, subject, caller_id) .await?; - self.authz.clear_role(subject, resource).await + self.authz.clear_role(subject, resource).await?; + + if caller_is_admin { + tracing::info!( + target: "audit", + event = "drive_membership.removed_via_admin", + drive_id = %drive_id, + subject_type = subject.type_str(), + subject_id = %subject.id(), + by = %caller_id, + "👮🏻‍♂️ admin removed drive member bypassing Manage check", + ); + } + Ok(()) } // ── Business rules ────────────────────────────────────────────────────── diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index 65bba24f..7bb23f4e 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -176,6 +176,21 @@ pub trait DriveRepository: Send + Sync + 'static { subject_types: &[&str], subject_ids: &[Uuid], ) -> Result, DriveRepositoryError>; + + /// List every drive on the system, regardless of caller membership. + /// + /// Used by the admin panel's `GET /api/admin/drives`. Distinct from + /// `list_for_subjects` (which filters by `role_grants`) because an + /// admin who creates a shared drive for someone else has no grant + /// on it — but still needs to see, audit, and manage it. The HTTP + /// gate (admin-only middleware) is what makes the unrestricted + /// listing safe; no role-based filtering happens here. + /// + /// Returns rows ordered by display name. `caller_role` is left + /// unset on the returned `DriveWithRootName` — the admin is not + /// necessarily a member, so the per-drive role would be misleading + /// here. + async fn list_all(&self) -> Result, DriveRepositoryError>; } /// Convenience: convert the canonical kind discriminator from its SQL diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 73ebc0eb..3de44d2d 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -431,4 +431,28 @@ impl DriveRepository for DrivePgRepository { .map(Self::row_to_drive_with_name_and_role) .collect() } + + async fn list_all(&self) -> Result, DriveRepositoryError> { + // No subject filter: every drive on the system. The HTTP layer + // (admin guard on `/api/admin/drives`) is the access control — + // adding a role filter here would defeat the point of the + // endpoint (an admin without explicit membership wouldn't see + // the drives they created for other users). + let rows = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + ORDER BY LOWER(f.name) ASC + "#, + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("list_all", e))?; + + rows.iter().map(Self::row_to_drive_with_name).collect() + } } diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index ce184be4..f479877e 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -19,8 +19,13 @@ use crate::application::dtos::settings_dto::{ SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto, }; +use crate::application::dtos::drive_dto::DriveDto; +use crate::application::dtos::grant_dto::{GrantDto, RoleDto, SubjectDto, SubjectTypeDto}; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; use crate::common::di::AppState; +use crate::domain::repositories::drive_repository::DriveRepository; +use crate::domain::services::authorization::{Resource, Subject}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::admin::require_admin; use std::sync::Arc; @@ -90,6 +95,17 @@ pub fn admin_routes() -> Router> { // when `OXICLOUD_SMTP_MOCK` is off, so production deployments // can route the path freely without leaking inboxes. .route("/smtp/test/captured", get(get_captured_email)) + // Drives — admin-wide view (distinct from `/api/drives` which + // is filtered to the caller's role grants). + .route("/drives", get(list_all_drives)) + .route( + "/drives/{id}/members", + get(list_drive_members_admin).post(add_drive_member_admin), + ) + .route( + "/drives/{id}/members/{kind}/{sid}", + axum::routing::patch(update_drive_member_admin).delete(remove_drive_member_admin), + ) } /// Validate JWT and require admin role. Returns (user_id, role). @@ -1706,3 +1722,238 @@ pub async fn set_plugin_retention( Ok((StatusCode::OK, Json(dto))) } + +/// GET /api/admin/drives — list every drive on the system, admin-only. +/// +/// Distinct from `GET /api/drives`, which is the caller's own listing +/// filtered through `role_grants`. An admin who creates a shared drive +/// for someone else has no grant on it — but the admin panel still +/// needs to see the drive (to audit, to manage, to delete). The admin +/// guard at the handler edge is the access control; no role filtering +/// happens in the repo (see `drive_repository::list_all`). +/// +/// Returns rows ordered by display name. `caller_role` is omitted — +/// the admin is not necessarily a drive member, so the field would be +/// misleading here. +#[utoipa::path( + get, + path = "/api/admin/drives", + responses( + (status = 200, description = "Every drive on the system", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn list_all_drives( + State(state): State>, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + let drives = state + .drive_repo + .list_all() + .await + .map_err(|e| AppError::internal_error(format!("Failed to list drives: {e}")))?; + let dtos: Vec = drives.into_iter().map(DriveDto::from).collect(); + Ok((StatusCode::OK, Json(dtos))) +} + +/// GET /api/admin/drives/{id}/members — list every role grant on a drive, +/// admin-only. +/// +/// Distinct from `GET /api/drives/{id}/members` which goes through +/// `DriveManagementService::list_members` and requires `Permission::Read` +/// on the drive. The admin who created the drive for someone else has +/// no role on it, so the user-facing endpoint would 404 for them. +/// +/// This endpoint reuses the engine's `list_grants_on_resource` directly +/// — same query, same shape, just gated by the admin middleware instead +/// of by `authz.require`. Returns the same `Vec` so the +/// frontend renders it through the existing grant types. +#[utoipa::path( + get, + path = "/api/admin/drives/{id}/members", + params(("id" = Uuid, Path, description = "Drive UUID")), + responses( + (status = 200, description = "Role grants on the drive", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn list_drive_members_admin( + State(state): State>, + headers: HeaderMap, + axum::extract::Path(drive_id): axum::extract::Path, +) -> Result { + admin_guard(&state, &headers).await?; + let grants = state + .authorization + .list_grants_on_resource(Resource::Drive(drive_id)) + .await + .map_err(AppError::from)?; + let dtos: Vec = grants.into_iter().map(GrantDto::from).collect(); + Ok((StatusCode::OK, Json(dtos))) +} + +/// Body for `POST /api/admin/drives/{id}/members` and +/// `PATCH /api/admin/drives/{id}/members/{kind}/{sid}` — same wire shape +/// as the user-facing endpoints, kept here so this handler doesn't pull +/// in the regular drive-handler module's DTOs (which would create a +/// circular feel between admin and user-facing surfaces). +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct AdminAddDriveMemberDto { + pub subject: SubjectDto, + pub role: RoleDto, + #[serde(default)] + pub expires_at: Option>, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct AdminUpdateDriveMemberDto { + pub role: RoleDto, + #[serde(default)] + pub expires_at: Option>, +} + +fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject { + match kind { + SubjectTypeDto::User => Subject::User(id), + SubjectTypeDto::Group => Subject::Group(id), + SubjectTypeDto::Token => Subject::Token(id), + } +} + +/// POST /api/admin/drives/{id}/members — add or refresh a member's role +/// without holding `Manage` on the drive. Admin-only; bypasses the +/// per-drive authz check via the `caller_is_admin = true` argument on +/// `DriveManagementService::set_member_role`. Personal-drive guard and +/// last-owner protection still apply. +#[utoipa::path( + post, + path = "/api/admin/drives/{id}/members", + params(("id" = Uuid, Path, description = "Drive UUID")), + request_body = AdminAddDriveMemberDto, + responses( + (status = 201, description = "Member added", body = GrantDto), + (status = 400, description = "Validation error (e.g. last-owner constraint)"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 405, description = "Personal drive — membership is immutable"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn add_drive_member_admin( + State(state): State>, + headers: HeaderMap, + axum::extract::Path(drive_id): axum::extract::Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let subject = admin_parse_subject(dto.subject.kind, dto.subject.id); + let grant = state + .drive_management_service + .set_member_role( + admin_id, + true, + drive_id, + subject, + dto.role.into(), + dto.expires_at, + ) + .await + .map_err(AppError::from)?; + Ok((StatusCode::CREATED, Json(GrantDto::from(grant)))) +} + +/// PATCH /api/admin/drives/{id}/members/{kind}/{sid} — change a member's +/// role / expiry as an admin. Same admin-bypass shape as +/// `add_drive_member_admin`. +#[utoipa::path( + patch, + path = "/api/admin/drives/{id}/members/{kind}/{sid}", + params( + ("id" = Uuid, Path, description = "Drive UUID"), + ("kind" = String, Path, description = "Subject kind: user|group|token"), + ("sid" = Uuid, Path, description = "Subject UUID"), + ), + request_body = AdminUpdateDriveMemberDto, + responses( + (status = 200, description = "Member role updated", body = GrantDto), + (status = 400, description = "Validation error (e.g. last-owner demotion)"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 405, description = "Personal drive — membership is immutable"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn update_drive_member_admin( + State(state): State>, + headers: HeaderMap, + axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( + Uuid, + SubjectTypeDto, + Uuid, + )>, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let subject = admin_parse_subject(kind, subject_id); + let grant = state + .drive_management_service + .set_member_role( + admin_id, + true, + drive_id, + subject, + dto.role.into(), + dto.expires_at, + ) + .await + .map_err(AppError::from)?; + Ok((StatusCode::OK, Json(GrantDto::from(grant)))) +} + +/// DELETE /api/admin/drives/{id}/members/{kind}/{sid} — remove a +/// member as an admin. Bypasses `Manage`; keeps last-owner protection. +#[utoipa::path( + delete, + path = "/api/admin/drives/{id}/members/{kind}/{sid}", + params( + ("id" = Uuid, Path, description = "Drive UUID"), + ("kind" = String, Path, description = "Subject kind: user|group|token"), + ("sid" = Uuid, Path, description = "Subject UUID"), + ), + responses( + (status = 204, description = "Member removed (or wasn't a member — idempotent)"), + (status = 400, description = "Last-owner protection — promote another member first"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 405, description = "Personal drive — membership is immutable"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn remove_drive_member_admin( + State(state): State>, + headers: HeaderMap, + axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( + Uuid, + SubjectTypeDto, + Uuid, + )>, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let subject = admin_parse_subject(kind, subject_id); + state + .drive_management_service + .remove_member(admin_id, true, drive_id, subject) + .await + .map_err(AppError::from)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs index daa63b55..5b69cfe7 100644 --- a/src/interfaces/api/handlers/contacts_handler.rs +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -127,6 +127,13 @@ pub struct AddMemberRequest { pub struct ListQuery { limit: Option, offset: Option, + /// System address book only — when `true`, includes the calling user + /// in the returned contacts. Default `false` matches the share-modal + /// "you can't share with yourself" semantics; the admin drive-owner + /// surface flips it on so an admin can add themselves as Owner. + /// Ignored for non-system books. + #[serde(default)] + include_self: bool, } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -491,9 +498,10 @@ pub async fn list_contacts( .await { Ok(users) => { + let include_self = params.include_self; let contacts: Vec = users .into_iter() - .filter(|u| u.id != caller_id) + .filter(|u| include_self || u.id != caller_id) .map(user_to_contact) .collect(); (StatusCode::OK, Json(contacts)).into_response() diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index 3ecc264f..aa488986 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -269,6 +269,7 @@ pub async fn add_drive_member( .drive_management_service .set_member_role( auth_user.id, + false, // caller_is_admin — user-facing route, always require Manage drive_id, subject, dto.role.into(), @@ -310,6 +311,7 @@ pub async fn update_drive_member( .drive_management_service .set_member_role( auth_user.id, + false, // caller_is_admin — user-facing route, always require Manage drive_id, subject, dto.role.into(), @@ -347,7 +349,7 @@ pub async fn remove_drive_member( let subject = parse_subject(kind, subject_id); match state .drive_management_service - .remove_member(auth_user.id, drive_id, subject) + .remove_member(auth_user.id, false, drive_id, subject) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index de0ea673..714ea58a 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -164,7 +164,7 @@ pub async fn create_grant( let grant = if let Resource::Drive(drive_id) = resource { match state .drive_management_service - .set_member_role(caller_id, drive_id, subject, role, expires_at) + .set_member_role(caller_id, false, drive_id, subject, role, expires_at) .await { Ok(g) => g, @@ -322,7 +322,7 @@ pub async fn revoke_grant( } if let Err(e) = state .drive_management_service - .remove_member(caller_id, drive_id, subject) + .remove_member(caller_id, false, drive_id, subject) .await { return AppError::from(e).into_response(); @@ -609,7 +609,7 @@ pub async fn set_role( let grant = if let Resource::Drive(drive_id) = resource { match state .drive_management_service - .set_member_role(caller_id, drive_id, subject, role, expires_at) + .set_member_role(caller_id, false, drive_id, subject, role, expires_at) .await { Ok(g) => g,