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:
@@ -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
@@ -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));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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,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' */
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user