/** Sharing (ReBAC grants) endpoints — ported from model/grants.js. */ import { apiFetch, apiJson } from '$lib/api/client'; 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'; /** Roles the share UI exposes. The backend role enum also has `commenter` and * `contributor`, which {@link displayRole} collapses to the nearest of these. */ export type ShareRole = 'viewer' | 'editor' | 'owner'; export interface GrantSubject { type: SubjectType; id: string; } /** * Subject shape accepted by `POST /api/grants`. The `email` variant feeds the * invite-by-email flow — the server resolves it to (or provisions) an external * user. Mirrors the backend `SubjectInputDto`. */ export type GrantSubjectInput = | { type: 'user'; id: string } | { type: 'group'; id: string } | { type: 'token'; id: string } | { type: 'email'; email: string }; /** * One role grant for a (subject, resource). Role-keyed since the role-grants * migration: each row carries an explicit `role` (the backend enum, which may * be `owner`/`editor`/`viewer`/`commenter`/`contributor`). */ export interface Grant { id: string; granted_at?: string; granted_by?: string; subject: GrantSubject; role: string; resource: { type: GrantResourceType; id: string }; expires_at?: string | null; } // ── Notification outcomes (PR N1/N2) ───────────────────────────────────────── export interface NotifyOutcome { kind: 'sent' | 'coalesced' | 'rate_limited' | 'not_applicable'; detail?: string; last_sent_at?: string; retry_after_secs?: number; reason?: string; } export interface NotifyOutcomeSet { total_recipients: number; outcomes: NotifyOutcome[]; } export interface CreateGrantResponse { grants: Grant[]; notification: NotifyOutcomeSet; } /** * Map a backend role string to the role the UI exposes. The server may emit the * full enum (`owner`/`editor`/`viewer`/`commenter`/`contributor`); the picker * only shows Owner/Editor/Viewer, so collapse the two unexposed roles to their * closest neighbour rather than render an unknown option. */ export function displayRole(role: string | undefined): ShareRole { if (role === 'owner' || role === 'editor' || role === 'viewer') return role; if (role === 'contributor') return 'editor'; if (role === 'commenter') return 'viewer'; return 'viewer'; } /** Convert a YYYY-MM-DD date (or null) to an ISO-8601 datetime at midnight UTC. */ export function expiryToIso(date: string | null | undefined): string | null { return date ? new Date(`${date}T00:00:00Z`).toISOString() : null; } 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: GrantResourceType; id: string }, role: ShareRole, expiresAt?: string | null ): Promise { const res = await apiFetch('/api/grants', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ subject, resource, role, expires_at: expiresAt ?? null }) }); if (!res.ok) { const e = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(e.error || `create grant failed: ${res.status}`); } return (await res.json()) as CreateGrantResponse; } export async function updateGrantRole( subject: GrantSubject, resource: { type: GrantResourceType; id: string }, role: ShareRole, expiresAt?: string | null ): Promise { const res = await apiFetch('/api/grants/role', { method: 'PUT', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ subject, resource, role, expires_at: expiresAt ?? null }) }); if (!res.ok) throw new Error(`update role failed: ${res.status}`); } export async function revokeGrant(grantId: string): Promise { const res = await apiFetch(`/api/grants/${encodeURIComponent(grantId)}`, { method: 'DELETE', credentials: 'same-origin', headers: getCsrfHeaders() }); if (!res.ok) throw new Error(`revoke grant failed: ${res.status}`); } /** * Resend / send a share notification for a single grant. * `POST /api/grants/{id}/notify`. Returns the aggregated outcome set, or a * `rate_limited` summary when the whole call was rate-limited (HTTP 429). */ export async function notifyGrantRecipient(grantId: string): Promise { const res = await apiFetch(`/api/grants/${encodeURIComponent(grantId)}/notify`, { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() }); if (res.status === 204) return { total_recipients: 0, outcomes: [] }; if (res.status === 429) { return { total_recipients: 1, outcomes: [{ kind: 'rate_limited' }] }; } if (res.ok) return (await res.json()) as NotifyOutcomeSet; throw new Error(`notify failed: ${res.status}`); } export interface IncomingGrantItem { resource_type: GrantResourceType; resource: ResourceBody; granted_by?: string; granted_at?: string; role?: string; } /** One (subject, permissions) entry within an outgoing resource item. */ export interface OutgoingResourceGrant { grant_id: string; subject_type: 'user' | 'group' | 'token'; subject_id: string; subject_display: string; role: ShareRole; granted_at: string; expires_at?: string | null; has_password: boolean; is_external: boolean; } export interface OutgoingGrantItem { resource_type: GrantResourceType; resource: ResourceBody; first_shared_at?: string; /** One entry per (subject, permissions) pair. */ grants: OutgoingResourceGrant[]; } interface GrantsPageOpts { cursor?: string; orderBy?: string; limit?: number; reverse?: boolean; resourceTypes?: ItemType[]; } function params(opts: GrantsPageOpts): string { const { cursor, orderBy, limit = 50, reverse = false, resourceTypes } = opts; const p = new URLSearchParams({ limit: String(limit) }); if (resourceTypes?.length) p.set('resource_types', resourceTypes.join(',')); if (cursor) p.set('cursor', cursor); if (orderBy) p.set('sort_by', orderBy); if (reverse) p.set('reverse', 'true'); return p.toString(); } export async function fetchSharedWithMe( opts: GrantsPageOpts = {} ): Promise> { const res = await apiFetch( `/api/grants/incoming/resources?${params({ resourceTypes: ['file', 'folder'], ...opts })}`, { credentials: 'same-origin' } ); if (!res.ok) throw new Error(`shared-with-me failed: ${res.status}`); return (await res.json()) as ResourcePage; } export async function fetchMyShares( opts: GrantsPageOpts = {} ): Promise> { const res = await apiFetch(`/api/grants/outgoing/resources?${params(opts)}`, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`my-shares failed: ${res.status}`); return (await res.json()) as ResourcePage; }