/** * @import {Grant, ResourceTypeEnum, SharedWithMeResponse, OutgoingResourcesResponse} from '../core/types.js' */ import { getCsrfHeaders } from '../core/csrf.js'; const grants = { /** @type {Record>} */ outgoingGrants: {}, /** @type {Record>} */ incomingGrants: {}, async fetchOutgoingGrants() { const response = await fetch('/api/grants/outgoing'); if (!response.ok) { console.error(`error ${response.status} while fetching /api/grants/outgoing`); return; } /** @type {Grant[]} */ const outgoingGrants = await response.json(); // Reset and rebuild cache this.outgoingGrants = {}; // store grants by type, then by id outgoingGrants.forEach((grant) => { this.outgoingGrants[grant.resource.type] ??= {}; this.outgoingGrants[grant.resource.type][grant.resource.id] ??= []; this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant); }); }, /** * get grant for a resource * @param {ResourceTypeEnum} resourceType * @param {String} id * @returns {Grant[] | null} */ getOutgoingGrantsFor(resourceType, id) { try { return this.outgoingGrants[resourceType][id] ?? []; } catch { return []; } }, async fetchIncomingGrants() { const response = await fetch('/api/grants/incoming'); if (!response.ok) { console.error(`error ${response.status} while fetching /api/grants/incoming`); return; } /** @type {Grant[]} */ const incomingGrants = await response.json(); // store grants by type, then by id incomingGrants.forEach((grant) => { this.incomingGrants[grant.resource.type] ??= {}; this.incomingGrants[grant.resource.type][grant.resource.id] ??= []; this.incomingGrants[grant.resource.type][grant.resource.id].push(grant); }); }, /** * get grant for a resource * @param {ResourceTypeEnum} resourceType * @param {String} id * @returns {Grant[] | null} */ getIncomingGrantsFor(resourceType, id) { try { return this.incomingGrants[resourceType][id] ?? []; } catch { return []; } }, /** * Fetch a cursor-paginated list of resources shared with the current user, * with full file / folder metadata resolved server-side. * * @param {object} [opts] * @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']). * @param {number} [opts.limit] - Max items per page (1–200, default 50). * @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page. * @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at'). * @param {boolean} [opts.reverse] - Reverse the sort order (default: false). * @returns {Promise} */ async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy, reverse = false } = {}) { const params = new URLSearchParams({ limit: String(limit), resource_types: resourceTypes.join(',') }); if (cursor) params.set('cursor', cursor); if (orderBy) params.set('sort_by', orderBy); if (reverse) params.set('reverse', 'true'); const response = await fetch(`/api/grants/incoming/resources?${params}`); if (!response.ok) { throw new Error(`Failed to fetch shared-with-me items: HTTP ${response.status}`); } return response.json(); }, /** * Fetch a cursor-paginated list of resources the current user has shared * with others, with full file / folder metadata resolved server-side. * * @param {object} [opts] * @param {number} [opts.limit] - Max items per page (1–200, default 50). * @param {string} [opts.cursor] - Opaque cursor from a previous call. * @param {string} [opts.orderBy] - Sort: 'first_shared_at' | 'name' | 'type' | 'subject'. * @param {boolean} [opts.reverse] - Reverse sort order. * @returns {Promise} */ async fetchMySharesPage({ limit = 50, cursor, orderBy, reverse = false } = {}) { const params = new URLSearchParams({ limit: String(limit) }); if (cursor) params.set('cursor', cursor); if (orderBy) params.set('sort_by', orderBy); if (reverse) params.set('reverse', 'true'); const response = await fetch(`/api/grants/outgoing/resources?${params}`); if (!response.ok) { throw new Error(`Failed to fetch my shares: HTTP ${response.status}`); } return response.json(); }, /** * Fetch all grants on a specific resource (for the "Manage sharing" panel). * Refreshes the outgoingGrants cache for this resource. * * @param {ResourceTypeEnum} resourceType * @param {string} resourceId * @returns {Promise} */ async fetchGrantsForResource(resourceType, resourceId) { const params = new URLSearchParams({ resource_type: resourceType, resource_id: resourceId }); const response = await fetch(`/api/grants?${params}`, { credentials: 'same-origin' }); if (!response.ok) { throw new Error(`fetchGrantsForResource: HTTP ${response.status}`); } /** @type {Grant[]} */ const result = await response.json(); // Refresh the outgoing cache for this resource this.outgoingGrants[resourceType] ??= {}; this.outgoingGrants[resourceType][resourceId] = result; return result; }, /** * Create a new grant. * Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`. * * Response shape (PR N1 — `CreateGrantResponseDto`): * * ```json * { * "grants": [ {Grant}, … ], * "notification": { * "total_recipients": 1, * "outcomes": [{ "kind": "sent", "detail": "plain_notification" }] * } * } * ``` * * `notification.outcomes` is empty for token subjects; size 1 for * user subjects; size N for group subjects (one entry per resolved * member). Callers that just need the grant rows can `.grants`; * callers that want to surface "did Carol get my email?" UX read * `.notification.outcomes[]`. * * @param {Object} dto - CreateGrantDto shape * @returns {Promise<{ grants: Grant[], notification: { total_recipients: number, outcomes: Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}> } }>} */ async createGrant(dto) { const response = await fetch('/api/grants', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, body: JSON.stringify(dto) }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `createGrant: HTTP ${response.status}`); } return response.json(); }, /** * Reconcile a subject's role on a resource (replaces all their permissions). * Body mirrors `UpdateRoleDto`: `{ subject, resource, role }`. * * @param {Object} dto - UpdateRoleDto shape * @returns {Promise} */ async updateRole(dto) { const response = await fetch('/api/grants/role', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, body: JSON.stringify(dto) }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `updateRole: HTTP ${response.status}`); } return response.json(); }, /** * Revoke a single grant by its UUID. * * @param {string} grantId * @returns {Promise} */ async revokeGrant(grantId) { const response = await fetch(`/api/grants/${encodeURIComponent(grantId)}`, { method: 'DELETE', credentials: 'same-origin', headers: getCsrfHeaders() }); if (!response.ok) { throw new Error(`revokeGrant: HTTP ${response.status}`); } } }; export { grants };