feat(ui): 1 modal to manage shares (users & public share)

fix(share): ensure Authz parent is created/updated on publicShare create/update

fix(ShareModal): do not show Token (public) grants in People section
This commit is contained in:
Edouard Vanbelle
2026-05-25 16:54:50 +02:00
parent a88e4c2733
commit 79c1a37931
15 changed files with 1897 additions and 367 deletions
+1 -1
View File
@@ -7,10 +7,10 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
installFetchInterceptor();
import { Modal } from '../components/modal.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { oxiIconsInit } from '../core/icons.js';
import { Modal } from '../components/modal.js';
import { fileOps } from '../features/files/fileOperations.js';
import { multiSelect } from '../features/files/multiSelect.js';
import { favorites } from '../features/library/favorites.js';
+9 -118
View File
@@ -5,6 +5,7 @@
// @ts-check
import { shareModal } from '../components/shareModal.js';
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { OxiIcons } from '../core/icons.js';
@@ -15,7 +16,6 @@ import { multiSelect } from '../features/files/multiSelect.js';
import { wopiEditor } from '../features/files/wopiEditor.js';
import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { thumbnail } from '../features/thumbnail.js';
import { grants } from '../model/grants.js';
import { loadFiles } from './filesView.js';
@@ -146,115 +146,7 @@ const ui = {
document.body.appendChild(moveDialog);
}
// Share dialog
if (!document.getElementById('share-dialog')) {
const shareDialog = document.createElement('div');
shareDialog.classList.add('share-dialog', 'hidden');
shareDialog.id = 'share-dialog';
shareDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-oxiexport dialog-header-icon"></i>
<span data-i18n="dialogs.share_file">Share file</span>
</div>
<div class="shared-item-info">
<strong>Item:</strong> <span id="shared-item-name"></span>
</div>
<div id="existing-shares-section" class="share-section hidden">
<h3 data-i18n="dialogs.existing_shares">Existing shared links</h3>
<div id="existing-shares-container"></div>
</div>
<div class="share-options">
<h3 data-i18n="dialogs.share_options">Share options</h3>
<div class="form-group">
<label for="share-password" data-i18n="dialogs.password">Password (optional):</label>
<input type="password" id="share-password" placeholder="Protect with password">
</div>
<div class="form-group">
<label for="share-expiration" data-i18n="dialogs.expiration">Expiration date (optional):</label>
<input type="date" id="share-expiration">
</div>
<div class="form-group">
<label data-i18n="dialogs.permissions">Permissions:</label>
<div class="permission-options">
<div class="permission-option">
<input type="checkbox" id="share-permission-read" checked>
<label for="share-permission-read" data-i18n="permissions.read">Read</label>
</div>
<div class="permission-option">
<input type="checkbox" id="share-permission-write">
<label for="share-permission-write" data-i18n="permissions.write">Write</label>
</div>
<div class="permission-option">
<input type="checkbox" id="share-permission-reshare">
<label for="share-permission-reshare" data-i18n="permissions.reshare">Allow sharing</label>
</div>
</div>
</div>
<button class="btn btn-primary btn-small" id="share-confirm-btn" data-i18n="actions.share">Share</button>
</div>
<div id="new-share-section" class="share-section hidden">
<h3 data-i18n="dialogs.generated_link">Generated link</h3>
<div class="form-group">
<input type="text" id="generated-share-url" readonly>
<div class="share-link-actions">
<button class="btn btn-small" id="copy-share-btn">
<i class="fas fa-copy"></i> <span data-i18n="actions.copy">Copy</span>
</button>
<button class="btn btn-small" id="notify-share-btn">
<i class="fas fa-envelope"></i> <span data-i18n="actions.notify">Notify</span>
</button>
</div>
</div>
</div>
<div class="share-dialog-buttons">
<button class="btn btn-secondary" id="share-close-btn" data-i18n="actions.close">Close</button>
</div>
</div>
`;
i18n.translateElement(shareDialog);
document.body.appendChild(shareDialog);
// Add event listeners for share dialog
document.getElementById('share-close-btn')?.addEventListener('click', () => {
contextMenus.closeShareDialog();
});
document.getElementById('share-confirm-btn')?.addEventListener('click', async () => {
await contextMenus.createSharedLink();
});
document.getElementById('copy-share-btn')?.addEventListener('click', async () => {
const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
if (shareUrl) await fileSharing.copyLinkToClipboard(shareUrl);
});
document.getElementById('notify-share-btn')?.addEventListener('click', () => {
const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
if (shareUrl) contextMenus.showEmailNotificationDialog(shareUrl);
});
// FIXME make generic function (close all dialog / etc)
document.addEventListener('keydown', (e) => {
const dialog = document.getElementById('share-dialog');
if (e.key === 'Escape' && !dialog?.classList.contains('hidden')) {
contextMenus.closeShareDialog();
}
});
shareDialog.addEventListener('click', (e) => {
if (e.target === shareDialog) {
contextMenus.closeShareDialog();
}
});
}
// Share dialog is now handled by shareModal (components/shareModal.js)
// Notification dialog
if (!document.getElementById('notification-dialog')) {
@@ -1245,15 +1137,14 @@ const ui = {
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
// TODO corrently dirty
const item = /** @type {unknown} */ ({
id: itemId,
item_id: itemId,
item_type: itemType,
item_name: itemName
});
const item = /** @type {FileItem|FolderItem} */ (
/** @type {unknown} */ ({
id: itemId,
name: itemName
})
);
contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
shareModal.open(item, /** @type {'file'|'folder'} */ (itemType));
});
},
+98
View File
@@ -40,6 +40,14 @@ const Modal = {
// Rename mode: select only name without extension
_selectNameOnly: false,
// Panel mode — openPanel() sets this; skips input-focus logic
/** @private */
_panelMode: false,
// Saved modal-body innerHTML to restore when a panel closes
/** @private */
_savedBodyHTML: '',
/**
* Initialize modal system
*/
@@ -84,6 +92,13 @@ const Modal = {
this.close(false);
}
});
// Escape in panel mode (input isn't focused so the above handler won't fire)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this._panelMode && !this.overlay?.classList.contains('hidden')) {
this.close(false);
}
});
},
/** @param {string} message */
@@ -259,6 +274,8 @@ const Modal = {
this._action = null;
this.overlay.classList.remove('active');
const wasPanel = this._panelMode;
setTimeout(() => {
this.overlay.classList.add('hidden');
@@ -269,6 +286,15 @@ const Modal = {
// Clear callbacks
this.onConfirm = null;
this.onCancel = null;
// Restore original modal-body content after a panel closes
if (wasPanel) {
const bodyEl = this.overlay?.querySelector('.modal-body');
if (bodyEl) bodyEl.innerHTML = this._savedBodyHTML;
this.overlay?.querySelector('.modal-container')?.classList.remove('modal-container--panel');
this._panelMode = false;
this._savedBodyHTML = '';
}
}, 200);
},
@@ -277,6 +303,13 @@ const Modal = {
* until it resolves — closing only on success, showing the error inline on failure.
*/
async confirm() {
// Panel mode: delegate entirely to the caller-supplied onConfirm
if (this._panelMode) {
if (this.onConfirm) this.onConfirm();
this.close(true);
return;
}
if (!this._action) {
if (this.onConfirm) this.onConfirm();
this.close(true);
@@ -298,6 +331,71 @@ const Modal = {
this.confirmBtn.disabled = false;
this.input.focus();
}
},
/**
* Open the modal with fully custom body content (panel mode).
*
* The caller supplies a pre-built HTMLElement as `content`; it is injected
* into `.modal-body`, replacing the default label/input/error elements for
* the lifetime of this panel. The overlay, header, animation, footer
* buttons, click-outside, and Escape handling all come from Modal.
*
* Original `.modal-body` innerHTML is restored automatically when the
* panel closes.
*
* @param {Object} options
* @param {string} options.title
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
* @param {string} [options.confirmText] - Confirm button label
* @param {string} [options.cancelText] - Cancel button label
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
*/
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) {
if (!this.overlay) return;
this._panelMode = true;
// ── Header ──────────────────────────────────────────────────────────
const iconContainer = this.overlay.querySelector('.modal-icon');
if (iconContainer) {
iconContainer.innerHTML = `<i class="fas ${icon}"></i>`;
if (replaceIconsInElement) replaceIconsInElement(iconContainer);
}
if (this.title) this.title.textContent = title;
// ── Body swap ───────────────────────────────────────────────────────
const bodyEl = this.overlay.querySelector('.modal-body');
if (bodyEl) {
this._savedBodyHTML = bodyEl.innerHTML;
bodyEl.replaceChildren(content);
}
// ── Container size modifier ──────────────────────────────────────────
this.overlay.querySelector('.modal-container')?.classList.add('modal-container--panel');
// ── Footer buttons ──────────────────────────────────────────────────
if (this.confirmBtn) {
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
this.confirmBtn.disabled = false;
}
if (this.cancelBtn) {
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
}
// ── Callbacks ───────────────────────────────────────────────────────
this.onConfirm = onConfirm;
this.onCancel = onCancel;
this._action = null;
this.clearError();
// ── Show overlay (same animation as prompt, no input focus) ─────────
this.overlay.classList.remove('hidden');
requestAnimationFrame(() => {
this.overlay.classList.add('active');
});
}
};
File diff suppressed because it is too large Load Diff
+4
View File
@@ -266,6 +266,10 @@ const OxiIcons = {
384,
'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z'
],
'pencil-alt': [
512,
'M36.4 353.2c4.1-14.6 11.8-27.9 22.6-38.7l181.2-181.2 33.9-33.9c16.6 16.6 51.3 51.3 104 104l33.9 33.9-33.9 33.9-181.2 181.2c-10.7 10.7-24.1 18.5-38.7 22.6L30.4 510.6c-8.3 2.3-17.3 0-23.4-6.2S-1.4 489.3 .9 481L36.4 353.2zm55.6-3.7c-4.4 4.7-7.6 10.4-9.3 16.6l-24.1 86.9 86.9-24.1c6.4-1.8 12.2-5.1 17-9.7L91.9 349.5zm354-146.1c-16.6-16.6-51.3-51.3-104-104L308 65.5C334.5 39 349.4 24.1 352.9 20.6 366.4 7 384.8-.6 404-.6S441.6 7 455.1 20.6l35.7 35.7C504.4 69.9 512 88.3 512 107.4s-7.6 37.6-21.2 51.1c-3.5 3.5-18.4 18.4-44.9 44.9z'
],
shuffle: [
512,
'M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z'
+32 -1
View File
@@ -271,7 +271,7 @@
*/
/**
* @typedef {'user'|'group'|'external'} SubjectTypeEnum
* @typedef {'user'|'group'|'token'|'external'} SubjectTypeEnum
*/
/**
@@ -355,3 +355,34 @@
* @property {string} updated_at - ISO-8601
*/
// ------------------- share modal
/**
* Share roles (DTO-layer sugar for the ReBAC permission sets).
* @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum
*/
/**
* One collaborator row in the share modal's People section.
* @typedef {Object} MemberEntry
* @property {Grant} grant - The underlying grant (id, subject, resource, etc.)
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
*/
/**
* Existing public link with a pending local operation.
* @typedef {Object} LinkEntry
* @property {ShareItem} share - The existing share object.
* @property {'keep'|'remove'|'edit'} _op - Pending local operation.
* @property {DraftLink|null} _draft - Updated fields when _op === 'edit'.
*/
/**
* A public link staged for creation (not yet committed).
* @typedef {Object} DraftLink
* @property {string} name
* @property {string|null} password
* @property {string|null} expires_at - ISO-8601 date string or null.
*/
+5 -237
View File
@@ -7,11 +7,12 @@ import { resolveHomeFolder } from '../../app/authSession.js';
import { loadFiles } from '../../app/filesView.js';
import { switchToFilesSection } from '../../app/navigation.js';
import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js';
import { ui } from '../../app/ui.js';
import { Modal } from '../../components/modal.js';
import { shareModal } from '../../components/shareModal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { escapeHtml } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { Modal } from '../../components/modal.js';
import { favorites } from '../library/favorites.js';
import { musicView } from '../library/music.js';
import { fileSharing } from '../sharing/fileSharing.js';
@@ -157,7 +158,7 @@ const contextMenus = {
document.getElementById('share-folder-option').addEventListener('click', () => {
const folder = app.contextMenuTargetFolder;
if (folder) {
this.showShareDialog(folder, 'folder');
shareModal.open(folder, 'folder');
}
ui.closeContextMenu();
});
@@ -279,7 +280,7 @@ const contextMenus = {
document.getElementById('share-file-option').addEventListener('click', () => {
const file = app.contextMenuTargetFile;
if (file) {
this.showShareDialog(file, 'file');
shareModal.open(file, 'file');
}
ui.closeFileContextMenu();
});
@@ -722,229 +723,6 @@ const contextMenus = {
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
},
/**
* Show share dialog for files or folders
* @param {FileItem | FolderItem} item - File or folder object
* @param {ItemTypeEnum} itemType
*/
async showShareDialog(item, itemType) {
try {
const shareDialog = document.getElementById('share-dialog');
if (!shareDialog) {
console.error('Share dialog element not found in DOM');
ui.showNotification('Error', 'Share dialog not available');
return;
}
// Update dialog title — use the <span> inside header to preserve <i> icon
const dialogHeader = shareDialog.querySelector('.share-dialog-header');
if (dialogHeader) {
const headerSpan = dialogHeader.querySelector('span');
const titleText = itemType === 'file' ? i18n.t('dialogs.share_file') : i18n.t('dialogs.share_folder');
if (headerSpan) {
headerSpan.textContent = titleText;
} else {
dialogHeader.textContent = titleText;
}
}
const itemName = document.getElementById('shared-item-name');
if (itemName) itemName.textContent = item.name;
// Reset form
const pwField = /** @type HTMLInputElement */ (document.getElementById('share-password'));
const expField = /** @type HTMLInputElement */ (document.getElementById('share-expiration'));
if (pwField) pwField.value = '';
if (expField) expField.value = '';
const permRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read'));
const permWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write'));
const permReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare'));
if (permRead) permRead.checked = true;
if (permWrite) permWrite.checked = false;
if (permReshare) permReshare.checked = false;
// Store the current item and type for use when creating the share
app.shareDialogItem = item;
app.shareDialogItemType = itemType;
// Check if item already has shares (async API call)
const existingShares = await fileSharing.getSharedLinksForItem(item.id, itemType);
const existingSharesContainer = document.getElementById('existing-shares-container');
// Clear existing shares container
existingSharesContainer.innerHTML = '';
if (existingShares.length > 0) {
document.getElementById('existing-shares-section').classList.remove('hidden');
// Create elements for each existing share
existingShares.forEach((share) => {
const shareEl = document.createElement('div');
shareEl.className = 'existing-share-item';
const expiresText = share.expires_at ? `Expires: ${fileSharing.formatExpirationDate(share.expires_at)}` : 'No expiration';
// Share URL
const urlDiv = document.createElement('div');
urlDiv.className = 'share-url';
urlDiv.textContent = share.url;
shareEl.appendChild(urlDiv);
// Share info
const infoDiv = document.createElement('div');
infoDiv.className = 'share-info';
if (share.has_password) {
const protectedSpan = document.createElement('span');
protectedSpan.className = 'share-protected';
protectedSpan.innerHTML = '<i class="fas fa-lock"></i> Password protected';
infoDiv.appendChild(protectedSpan);
}
const expirationSpan = document.createElement('span');
expirationSpan.className = 'share-expiration';
expirationSpan.textContent = expiresText;
infoDiv.appendChild(expirationSpan);
shareEl.appendChild(infoDiv);
// Share actions
const actionsDiv = document.createElement('div');
actionsDiv.className = 'share-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'btn btn-small copy-link-btn';
copyBtn.dataset.shareUrl = share.url;
copyBtn.innerHTML = '<i class="fas fa-copy"></i> Copy';
actionsDiv.appendChild(copyBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-small btn-danger delete-link-btn';
deleteBtn.dataset.shareId = share.id;
deleteBtn.innerHTML = '<i class="fas fa-trash"></i> Delete';
actionsDiv.appendChild(deleteBtn);
shareEl.appendChild(actionsDiv);
existingSharesContainer.appendChild(shareEl);
});
// Add event listeners for copy and delete buttons
document.querySelectorAll('.copy-link-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const url = btn.getAttribute('data-share-url');
fileSharing.copyLinkToClipboard(url);
});
});
document.querySelectorAll('.delete-link-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const shareId = btn.getAttribute('data-share-id');
showConfirmDialog({
title: i18n.t('dialogs.confirm_delete_share'),
message: i18n.t('dialogs.confirm_delete_share_msg'),
confirmText: i18n.t('actions.delete')
}).then(async (confirmed) => {
if (confirmed) {
await fileSharing.removeSharedLink(shareId);
btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').classList.add('hidden');
ui.setSharedVisualState(item.id, itemType, false);
}
}
});
});
});
} else {
document.getElementById('existing-shares-section').classList.add('hidden');
}
// Hide new-share section from previous use
const newShareSection = document.getElementById('new-share-section');
if (newShareSection) newShareSection.classList.add('hidden');
// Show dialog
shareDialog.classList.remove('hidden');
console.log('Share dialog opened for', itemType, item.name);
} catch (error) {
console.error('Error opening share dialog:', error);
ui.showNotification('Error', 'Could not open share dialog');
}
},
/**
* Create a shared link with the configured options
*/
async createSharedLink() {
if (!app.shareDialogItem || !app.shareDialogItemType) {
ui.showNotification('Error', 'Could not share the item');
return;
}
// Get values from form
const password = /** @type HTMLInputElement */ (document.getElementById('share-password')).value;
const expirationDate = /** @type HTMLInputElement */ (document.getElementById('share-expiration')).value;
const permissionRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read')).checked;
const permissionWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write')).checked;
const permissionReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare')).checked;
const item = app.shareDialogItem;
const itemType = app.shareDialogItemType;
// Build DTO for backend API
const createDto = {
item_id: item.id,
item_name: item.name || null,
item_type: itemType,
password: password || null,
expires_at: expirationDate ? Math.floor(new Date(expirationDate).getTime() / 1000) : null,
permissions: {
read: permissionRead,
write: permissionWrite,
reshare: permissionReshare
}
};
try {
const headers = {
'Content-Type': 'application/json',
...getCsrfHeaders()
};
const response = await fetch('/api/shares', {
method: 'POST',
headers,
body: JSON.stringify(createDto)
});
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
throw new Error(errBody.error || `Server error ${response.status}`);
}
const shareInfo = await response.json();
// Update UI with new share
const shareUrl = /** @type HTMLInputElement */ (document.getElementById('generated-share-url'));
if (shareUrl) {
shareUrl.value = shareInfo.url;
document.getElementById('new-share-section').classList.remove('hidden');
shareUrl.focus();
shareUrl.select();
}
// Update Item's shared badge
ui.setSharedVisualState(item.id, itemType, true);
// Show success message
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
} catch (error) {
console.error('Error creating shared link:', error);
ui.showNotification('Error', /** @type {Error} */ (error).message || 'Could not create shared link');
}
},
/**
* Show email notification dialog
* @param {string} shareUrl - URL to share
@@ -991,16 +769,6 @@ const contextMenus = {
}
},
/**
* Close share dialog
*/
closeShareDialog() {
const dialog = document.getElementById('share-dialog');
if (dialog) dialog.classList.add('hidden');
app.shareDialogItem = null;
app.shareDialogItemType = null;
},
/**
* Close notification dialog
*/
+1 -1
View File
@@ -1,9 +1,9 @@
import { app } from '../../app/state.js';
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { formatFileSize } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { oxiIcon } from '../../core/icons.js';
import { Modal } from '../../components/modal.js';
import { notifications } from '../../core/notifications.js';
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
+96 -7
View File
@@ -2,6 +2,8 @@
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
*/
import { getCsrfHeaders } from '../core/csrf.js';
const grants = {
/** @type {Record<String, Record<String, Grant[]>>} */
outgoingGrants: {},
@@ -13,14 +15,15 @@ const grants = {
const response = await fetch('/api/grants/outgoing');
if (!response.ok) {
console.log(`error ${response.status} while fetching /api/grants/outgoing:`, await response.json());
console.error(`error ${response.status} while fetching /api/grants/outgoing`);
return;
}
/** @type {Grant[]} */
const outgoingGrants = await response.json();
console.log(outgoingGrants);
// Reset and rebuild cache
this.outgoingGrants = {};
// store grants by type, then by id
outgoingGrants.forEach((grant) => {
@@ -28,8 +31,6 @@ const grants = {
this.outgoingGrants[grant.resource.type][grant.resource.id] ??= [];
this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant);
});
console.log(`outgoing grants: `, this.outgoingGrants);
},
/**
@@ -50,7 +51,7 @@ const grants = {
const response = await fetch('/api/grants/incoming');
if (!response.ok) {
console.log(`error ${response.status} while fetching /api/grants/incoming:`, await response.json);
console.error(`error ${response.status} while fetching /api/grants/incoming`);
return;
}
@@ -63,8 +64,6 @@ const grants = {
this.incomingGrants[grant.resource.type][grant.resource.id] ??= [];
this.incomingGrants[grant.resource.type][grant.resource.id].push(grant);
});
console.log(`incoming grants: `, this.incomingGrants);
},
/**
@@ -105,6 +104,96 @@ const grants = {
}
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<Grant[]>}
*/
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 }`.
*
* @param {Object} dto - CreateGrantDto shape
* @returns {Promise<Grant[]>}
*/
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<Grant[]>}
*/
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<void>}
*/
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}`);
}
}
};