From 6034dd47d95739cc128b461e9c275d1d10b9b807 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 24 Jun 2026 01:51:05 +0200 Subject: [PATCH] feat(drive): improve drive edition from owners --- frontend/src/lib/api/endpoints/grants.ts | 21 ++- frontend/src/lib/api/endpoints/recipients.ts | 5 +- .../src/lib/components/ShareDialog.svelte | 85 +++++++--- .../routes/config/drive/[uuid]/+page.svelte | 145 ++++++++++-------- .../src/routes/shared-with-me/+page.svelte | 47 +++--- frontend/src/routes/shared/+page.svelte | 22 ++- src/application/dtos/drive_dto.rs | 1 - src/interfaces/api/handlers/admin_handler.rs | 4 +- 8 files changed, 214 insertions(+), 116 deletions(-) diff --git a/frontend/src/lib/api/endpoints/grants.ts b/frontend/src/lib/api/endpoints/grants.ts index cc96c0e1..ad6abd25 100644 --- a/frontend/src/lib/api/endpoints/grants.ts +++ b/frontend/src/lib/api/endpoints/grants.ts @@ -4,6 +4,15 @@ import { getCsrfHeaders } from '$lib/api/csrf'; import type { ItemType } from '$lib/api/types'; import type { ResourceBody, ResourcePage } from './resources'; +/** + * Resource kinds the `/api/grants` family addresses. File/folder grants flow + * through the cascade engine; drive grants flow through + * `DriveManagementService` server-side, which layers personal-drive guard + + * last-owner protection on top of the same role-grant write. Either way the + * wire shape is identical, so the FE helpers below accept all three. + */ +export type GrantResourceType = ItemType | 'drive'; + const JSON_HEADERS = { 'Content-Type': 'application/json' }; export type SubjectType = 'user' | 'group' | 'email' | 'token'; @@ -38,7 +47,7 @@ export interface Grant { granted_by?: string; subject: GrantSubject; role: string; - resource: { type: ItemType; id: string }; + resource: { type: GrantResourceType; id: string }; expires_at?: string | null; } @@ -80,14 +89,14 @@ export function expiryToIso(date: string | null | undefined): string | null { return date ? new Date(`${date}T00:00:00Z`).toISOString() : null; } -export function fetchGrantsForResource(type: ItemType, id: string): Promise { +export function fetchGrantsForResource(type: GrantResourceType, id: string): Promise { const params = new URLSearchParams({ resource_type: type, resource_id: id }); return apiJson(`/api/grants?${params}`, { credentials: 'same-origin' }); } export async function createGrant( subject: GrantSubjectInput, - resource: { type: ItemType; id: string }, + resource: { type: GrantResourceType; id: string }, role: ShareRole, expiresAt?: string | null ): Promise { @@ -106,7 +115,7 @@ export async function createGrant( export async function updateGrantRole( subject: GrantSubject, - resource: { type: ItemType; id: string }, + resource: { type: GrantResourceType; id: string }, role: ShareRole, expiresAt?: string | null ): Promise { @@ -148,7 +157,7 @@ export async function notifyGrantRecipient(grantId: string): Promise { const cached = includeSelf ? contactCacheWithSelf : contactCache; if (cached) return cached; try { + // `?include_self=true` (not `=1`) — Axum's `Query` extractor uses + // `serde_urlencoded`, which only deserialises `"true"`/`"false"` + // for `bool`. Sending `=1` would 400 before the handler runs. const url = includeSelf - ? '/api/address-books/system/contacts?include_self=1' + ? '/api/address-books/system/contacts?include_self=true' : '/api/address-books/system/contacts'; const res = await apiFetch(url, { credentials: 'same-origin' }); if (!res.ok) { diff --git a/frontend/src/lib/components/ShareDialog.svelte b/frontend/src/lib/components/ShareDialog.svelte index daf2a75e..31e32ddd 100644 --- a/frontend/src/lib/components/ShareDialog.svelte +++ b/frontend/src/lib/components/ShareDialog.svelte @@ -28,7 +28,8 @@ searchRecipients, type Recipient } from '$lib/api/endpoints/recipients'; - import type { ItemType, ShareItem } from '$lib/api/types'; + import type { ShareItem } from '$lib/api/types'; + import type { GrantResourceType } from '$lib/api/endpoints/grants'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; @@ -38,7 +39,7 @@ interface Target { id: string; name: string; - kind: ItemType; + kind: GrantResourceType; } interface Props { @@ -46,11 +47,32 @@ item: Target | null; /** Fired with the item id when an outgoing share (grant or link) is created. */ onshared?: (id: string) => void; + /** + * Fired with the item id on **any** membership mutation — create, + * role change, expiry change, removal, or public-link creation. + * Distinct from `onshared` because some callers (file/folder list + * views that toggle a "shared" badge) only care about creation; + * the drive-config view needs to refresh on every change. + */ + onchange?: (id: string) => void; + /** + * Whether the "Public link" (token-grant) tab is exposed. Defaults to + * `true` for file/folder sharing. Drives set this to `false`: a drive + * grant is per-member only, never via a shareable URL — exposing the + * tab would suggest a capability that doesn't exist. + */ + allowLinks?: boolean; } - let { open = $bindable(false), item, onshared }: Props = $props(); + let { open = $bindable(false), item, onshared, onchange, allowLinks = true }: Props = $props(); + // When the public-link tab is hidden, force the People view — otherwise a + // caller toggling `allowLinks` between renders could land on the now-hidden + // tab with no UI. let tab = $state<'people' | 'link'>('people'); + $effect(() => { + if (!allowLinks) tab = 'people'; + }); let directoryAvailable = $state(true); const ROLES: { v: ShareRole; l: string; icon: string }[] = [ @@ -163,6 +185,7 @@ results = []; summarizeNotifications(res.notification.outcomes); onshared?.(item.id); + onchange?.(item.id); await loadGrants(); } catch (e) { errorToast(e); @@ -178,6 +201,7 @@ role, expiryToIso(m.expiry) ); + onchange?.(item.id); await loadGrants(); } catch (e) { errorToast(e); @@ -193,6 +217,7 @@ m.role, expiryToIso(expiry) ); + onchange?.(item.id); await loadGrants(); } catch (e) { errorToast(e); @@ -202,6 +227,7 @@ async function removeMember(m: Member) { try { for (const id of m.grantIds) await revokeGrant(id); + if (item) onchange?.(item.id); await loadGrants(); } catch (e) { errorToast(e); @@ -259,7 +285,11 @@ let expiresAt = $state(null); async function loadShares() { - if (!item) return; + // The share-link API only supports file/folder items; the Link tab + // is hidden for drives (`allowLinks=false`) so this path is + // unreachable, but narrow the type here so TypeScript doesn't + // surface the widened `GrantResourceType` from `item.kind`. + if (!item || item.kind === 'drive') return; linkLoading = true; try { shares = await listSharesForItem(item.id, item.kind); @@ -271,7 +301,7 @@ } async function createLink() { - if (!item) return; + if (!item || item.kind === 'drive') return; creating = true; try { await createShare({ @@ -285,6 +315,7 @@ password = ''; expiresAt = null; onshared?.(item.id); + onchange?.(item.id); await loadShares(); ui.notify(t('share.created', 'Public link created'), 'success'); } catch (e) { @@ -297,6 +328,7 @@ async function editLinkExpiry(share: ShareItem, expiry: string | null) { try { await updateShare(share.id, { expiresAt: expiry }); + if (item) onchange?.(item.id); await loadShares(); } catch (e) { errorToast(e); @@ -306,6 +338,7 @@ async function editLinkPassword(share: ShareItem, pw: string | null) { try { await updateShare(share.id, { password: pw }); + if (item) onchange?.(item.id); await loadShares(); ui.notify( pw @@ -321,6 +354,7 @@ async function removeLink(share: ShareItem) { try { await deleteShare(share.id); + if (item) onchange?.(item.id); shares = shares.filter((s) => s.id !== share.id); } catch (e) { errorToast(e); @@ -378,24 +412,29 @@
-
- - -
+ + {#if allowLinks} +
+ + +
+ {/if} {#if tab === 'people'} {#if !directoryAvailable && !grantsLoading} diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index 33e4322d..93eb50b7 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -3,17 +3,13 @@ import { page } from '$app/state'; import { onMount } from 'svelte'; - import { - listDriveMembers, - removeDriveMember, - updateDriveMember - } from '$lib/api/endpoints/drives'; + import { listDriveMembers } from '$lib/api/endpoints/drives'; import type { Drive, DriveMember, DriveRole } from '$lib/api/types'; + import ShareDialog from '$lib/components/ShareDialog.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import Icon from '$lib/icons/Icon.svelte'; import { t } from '$lib/i18n/index.svelte'; import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; - import { errorToast } from '$lib/utils/errors'; import { formatDate } from '$lib/utils/display'; import { formatBytes } from '$lib/utils/format'; @@ -30,10 +26,6 @@ // honest UX. Shared drives + Owner role → full controls. const canManageMembers = $derived(drive?.kind === 'shared' && drive?.caller_role === 'owner'); - // Roles offered in the dropdown. Owner sets the bundle; other roles - // match the backend `Role` enum order (owner → viewer = strongest → weakest). - const ASSIGNABLE_ROLES: DriveRole[] = ['owner', 'editor', 'viewer']; - function roleLabel(role: DriveRole): string { switch (role) { case 'owner': @@ -49,6 +41,19 @@ } } + // `shareDialogOpen` drives the ShareDialog modal — the same dialog + // used for file/folder sharing, parameterised with `kind: 'drive'` + // + `allowLinks: false`. Add/change-role/remove flow through the + // dialog's existing grants plumbing (server-side those routes + // dispatch to `DriveManagementService`). + let shareDialogOpen = $state(false); + + // `dialogItem` is recomputed from the drive so the dialog title + // reflects renames. + const dialogItem = $derived( + drive ? { id: drive.id, name: drive.name, kind: 'drive' as const } : null + ); + async function loadMembers() { if (!uuid) return; try { @@ -64,27 +69,12 @@ } } - async function changeRole(member: DriveMember, role: DriveRole) { - if (member.role === role) return; - try { - const updated = await updateDriveMember(uuid, member.subject, role); - members = members.map((m) => (m.id === member.id ? updated : m)); - } catch (e) { - errorToast(e); - // Re-fetch so the dropdown reflects the server-side state, not the - // optimistic-but-rejected change. - await loadMembers(); - } - } - - async function removeMember(member: DriveMember) { - try { - await removeDriveMember(uuid, member.subject); - members = members.filter((m) => m.id !== member.id); - } catch (e) { - errorToast(e); - await loadMembers(); - } + // Refresh the on-page member list on every dialog mutation (add, + // role change, remove). ShareDialog fires `onchange` for the full + // set of grant mutations — `onshared` only covers creation, which + // would leave role-change and removal stale here. + function onShareDialogChange() { + void loadMembers(); } const kindLabel = $derived.by(() => { @@ -133,7 +123,21 @@ onMount(() => { void drivesStore.load(); - void loadMembers(); + }); + + // SvelteKit reuses this component when navigating between + // `/config/drive/` and `/config/drive/` (same route, different + // dynamic param), so `onMount` only fires once. Re-run the members + // fetch whenever `uuid` changes — without this, the previous drive's + // rows linger until a hard refresh. Resetting `members` + the loaded + // flag first prevents the brief flash of stale data before the new + // fetch returns. + $effect(() => { + const id = uuid; + members = []; + membersLoaded = false; + membersError = null; + if (id) void loadMembers(); }); @@ -210,7 +214,21 @@
-

{t('drive.members', 'Members')}

+
+

{t('drive.members', 'Members')}

+ {#if canManageMembers} + + {/if} +
+ {#if !membersLoaded}

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

{:else if members.length === 0} @@ -218,6 +236,10 @@ {membersError ?? t('drive.members_empty', 'No members.')}

{:else} +
    {#each members as m (m.id)}
  • @@ -234,33 +256,9 @@ {m.subject.id} {/if} - - {#if canManageMembers} - - - {:else} - - {roleLabel(m.role)} - - {/if} + + {roleLabel(m.role)} +
  • {/each}
@@ -290,6 +288,19 @@ {/if}
+ +{#if dialogItem} + +{/if} +