feat(drive): improve drive edition from owners

This commit is contained in:
Edouard Vanbelle
2026-06-24 01:51:05 +02:00
parent d77846119f
commit 6034dd47d9
8 changed files with 214 additions and 116 deletions
+15 -6
View File
@@ -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<Grant[]> {
export function fetchGrantsForResource(type: GrantResourceType, id: string): Promise<Grant[]> {
const params = new URLSearchParams({ resource_type: type, resource_id: id });
return apiJson<Grant[]>(`/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<CreateGrantResponse> {
@@ -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<void> {
@@ -148,7 +157,7 @@ export async function notifyGrantRecipient(grantId: string): Promise<NotifyOutco
}
export interface IncomingGrantItem {
resource_type: ItemType;
resource_type: GrantResourceType;
resource: ResourceBody;
granted_by?: string;
granted_at?: string;
@@ -169,7 +178,7 @@ export interface OutgoingResourceGrant {
}
export interface OutgoingGrantItem {
resource_type: ItemType;
resource_type: GrantResourceType;
resource: ResourceBody;
first_shared_at?: string;
/** One entry per (subject, permissions) pair. */
+4 -1
View File
@@ -52,8 +52,11 @@ async function systemContacts(includeSelf = false): Promise<Contact[]> {
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) {
+62 -23
View File
@@ -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<string | null>(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 @@
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
<div data-testid="share-dialog">
<div class="tabs" role="tablist">
<button
role="tab"
data-testid="share-dialog-people-tab"
aria-selected={tab === 'people'}
onclick={() => (tab = 'people')}
>
{t('share.people', 'People')}
</button>
<button
role="tab"
data-testid="share-dialog-link-tab"
aria-selected={tab === 'link'}
onclick={() => (tab = 'link')}
>
{t('share.public_link', 'Public link')}
</button>
</div>
<!-- People/Link tab switcher. Hidden entirely when `allowLinks=false`
(the drive-members surface) — with only one tab visible the
switcher would be visual noise. -->
{#if allowLinks}
<div class="tabs" role="tablist">
<button
role="tab"
data-testid="share-dialog-people-tab"
aria-selected={tab === 'people'}
onclick={() => (tab = 'people')}
>
{t('share.people', 'People')}
</button>
<button
role="tab"
data-testid="share-dialog-link-tab"
aria-selected={tab === 'link'}
onclick={() => (tab = 'link')}
>
{t('share.public_link', 'Public link')}
</button>
</div>
{/if}
{#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading}