From 79c1a37931e6ad6b853e695a72cb9684e8313ce1 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 25 May 2026 16:54:50 +0200 Subject: [PATCH] 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 --- src/application/services/share_service.rs | 52 ++ src/common/di.rs | 4 +- static/css/components/modals.css | 20 + static/css/components/shareModal.css | 561 ++++++++++++ static/css/main.css | 1 + static/index.html | 2 +- static/js/app/main.js | 2 +- static/js/app/ui.js | 127 +-- static/js/components/modal.js | 98 ++ static/js/components/shareModal.js | 1013 +++++++++++++++++++++ static/js/core/icons.js | 4 + static/js/core/types.js | 33 +- static/js/features/files/contextMenus.js | 242 +---- static/js/features/library/music.js | 2 +- static/js/model/grants.js | 103 ++- 15 files changed, 1897 insertions(+), 367 deletions(-) create mode 100644 static/css/components/shareModal.css create mode 100644 static/js/components/shareModal.js diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 8217e4e4..26a41c7c 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -5,10 +5,12 @@ use tokio::sync::Semaphore; use uuid::Uuid; use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::{ application::{ dtos::{ @@ -17,6 +19,7 @@ use crate::{ }, ports::{ auth_ports::PasswordHasherPort, + authorization_ports::AuthorizationEngine, share_ports::{ShareStoragePort, ShareUseCase}, storage_ports::FileReadPort, }, @@ -78,6 +81,9 @@ pub struct ShareService { file_repository: Arc, folder_repository: Arc, password_hasher: Arc, + /// ReBAC engine — used to create/revoke token grants that mirror public + /// share links so that `GET /api/grants/outgoing` reflects them. + authorization: Arc, /// Bounds the number of in-flight Argon2 password hashes to avoid /// saturating the blocking thread pool and consuming excessive RAM. hash_semaphore: Arc, @@ -90,6 +96,7 @@ impl ShareService { file_repository: Arc, folder_repository: Arc, password_hasher: Arc, + authorization: Arc, ) -> Self { Self { config, @@ -97,6 +104,7 @@ impl ShareService { file_repository, folder_repository, password_hasher, + authorization, hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)), } } @@ -256,6 +264,50 @@ impl ShareUseCase for ShareService { .await .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + // Mirror the share permissions as ReBAC token grants so that + // `GET /api/grants/outgoing` picks them up and the UI can show the + // share badge without a separate `/api/shares` round-trip. + // The DELETE trigger `trg_cleanup_grants_token` handles cleanup when + // the share is later removed — no extra service-layer code needed there. + { + let share_id = saved_share.id(); + let item_id_uuid = Uuid::parse_str(saved_share.item_id()) + .map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?; + + let resource = match saved_share.item_type() { + ShareItemType::File => Resource::File(item_id_uuid), + ShareItemType::Folder => Resource::Folder(item_id_uuid), + }; + let subject = Subject::Token(share_id); + let perms = saved_share.permissions(); + + // Read is always granted + self.authorization + .grant(user_id, subject, Permission::Read, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + + // Write permission → Create + Update + if perms.write() { + self.authorization + .grant(user_id, subject, Permission::Create, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + self.authorization + .grant(user_id, subject, Permission::Update, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + } + + // Reshare permission → Share + if perms.reshare() { + self.authorization + .grant(user_id, subject, Permission::Share, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + } + } + // Convert the entity to DTO for the response Ok(ShareDto::from_entity(&saved_share, &self.config.base_url())) } diff --git a/src/common/di.rs b/src/common/di.rs index 65d42c01..d107eb1b 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -514,6 +514,7 @@ impl AppServiceFactory { &self, repos: &RepositoryServices, db_pool: &Arc, + authorization: &Arc, ) -> Option> { if !self.config.features.enable_file_sharing { tracing::info!("File sharing service is disabled in configuration"); @@ -537,6 +538,7 @@ impl AppServiceFactory { repos.file_read_repository.clone(), repos.folder_repository.clone(), password_hasher, + authorization.clone(), )); tracing::info!("File sharing service initialized"); @@ -652,7 +654,7 @@ impl AppServiceFactory { self.create_application_services(&core, &repos, trash_service.clone(), &authorization); // 5. Share service - let share_service = self.create_share_service(&repos, &pool); + let share_service = self.create_share_service(&repos, &pool, &authorization); apps.share_service = share_service.clone(); let share_browse_service = share_service.as_ref().map(|s| { diff --git a/static/css/components/modals.css b/static/css/components/modals.css index a69fdd58..d58b7829 100644 --- a/static/css/components/modals.css +++ b/static/css/components/modals.css @@ -190,6 +190,9 @@ color: var(--color-text-heading); margin: 0; flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .modal-close-btn { @@ -307,3 +310,20 @@ .modal-footer .btn-primary:active { transform: translateY(0); } + +/* ── Panel mode (ShareModal, etc.) ─────────────────────────────────────────── */ +/* Wider, taller container; body becomes a zero-padding scrollable slot. */ + +.modal-container--panel { + width: 520px; + max-width: 96vw; + max-height: 88vh; + display: flex; + flex-direction: column; +} + +.modal-container--panel .modal-body { + padding: 0; + overflow-y: auto; + flex: 1; +} diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css new file mode 100644 index 00000000..d219a84a --- /dev/null +++ b/static/css/components/shareModal.css @@ -0,0 +1,561 @@ +/* ── Share Modal — content styles ────────────────────────────────────────────── + * + * The overlay, container, header, footer, and animations come from modals.css + * (via Modal.openPanel()). This file only covers the body content: sections, + * member rows, chips, role selects, link rows, and new-link form. + * + * All colours use CSS custom properties. No raw hex/rgb/named values outside + * of :root declarations. + * ─────────────────────────────────────────────────────────────────────────── */ + +/* ── Body wrapper ────────────────────────────────────────────────────────────── */ + +.smd-body { + display: flex; + flex-direction: column; +} + +/* ── Sections ────────────────────────────────────────────────────────────────── */ + +.smd-section { + border-top: 0.5px solid var(--color-border); + padding: 16px 20px; +} + +.smd-section:first-child { + border-top: none; +} + +.smd-section-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 12px; +} + +/* ── Loading skeleton ────────────────────────────────────────────────────────── */ + +.smd-skeleton { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; +} + +.smd-skeleton-line { + height: 14px; + background: var(--color-bg-muted); + border-radius: 6px; + animation: smdSkeletonPulse 1.4s ease-in-out infinite; +} + +.smd-skeleton-line--short { + width: 40%; +} + +.smd-skeleton-line--medium { + width: 65%; +} + +@keyframes smdSkeletonPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* ── Search row ──────────────────────────────────────────────────────────────── */ + +.smd-search-row { + display: flex; + gap: 8px; + align-items: flex-start; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.smd-search-wrap { + position: relative; + flex: 1; + min-width: 180px; +} + +.smd-search-input { + width: 100%; + padding: 9px 12px; + font-size: 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-hover); + color: var(--color-text-heading); + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; +} + +.smd-search-input:focus { + border-color: var(--color-accent); + background: var(--color-bg-surface); + box-shadow: 0 0 0 3px var(--color-accent-ring); +} + +/* Suggestion dropdown */ +.smd-suggestions { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--color-bg-surface); + border: 0.5px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 8px 24px var(--color-shadow-xl); + z-index: 100; + overflow: hidden; +} + +.smd-suggestion-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + cursor: pointer; + transition: background 0.1s; +} + +.smd-suggestion-item:hover, +.smd-suggestion-item:focus { + background: var(--color-bg-hover); +} + +.smd-suggestion-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +.smd-suggestion-name { + font-size: 14px; + color: var(--color-text-heading); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smd-suggestion-email { + font-size: 12px; + color: var(--color-text-faint); +} + +/* Role picker beside the search box */ +.smd-role-select { + padding: 9px 10px; + font-size: 13px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-hover); + color: var(--color-text-heading); + cursor: pointer; + max-width: 120px; +} + +/* Add button */ +.smd-add-btn { + min-height: 36px; + min-width: 44px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + border-radius: 8px; +} + +/* ── Staged chips ────────────────────────────────────────────────────────────── */ + +.smd-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.smd-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + border: 0.5px solid var(--color-border-medium); + border-radius: 20px; + background: var(--color-bg-hover); + font-size: 13px; + color: var(--color-text-heading); +} + +.smd-chip-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.smd-chip-remove { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: none; + cursor: pointer; + color: var(--color-text-faint); + padding: 0; + border-radius: 50%; + transition: + background 0.1s, + color 0.1s; +} + +.smd-chip-remove:hover { + background: var(--color-bg-muted); + color: var(--color-text-heading); +} + +/* ── Member group headings ───────────────────────────────────────────────────── */ + +.smd-group { + margin-top: 12px; +} + +.smd-group:first-child { + margin-top: 0; +} + +.smd-group-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; +} + +.smd-group-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 11px; + font-weight: 700; + background: var(--color-bg-muted); + color: var(--color-text-subtle); +} + +/* ── Member rows ─────────────────────────────────────────────────────────────── */ + +.smd-member-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 0; +} + +.smd-member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} + +.smd-member-name { + flex: 1; + font-size: 14px; + color: var(--color-text-heading); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smd-member-role-select { + font-size: 13px; + padding: 4px 8px; + border: 0.5px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-hover); + color: var(--color-text-heading); + cursor: pointer; + max-width: 33%; +} + +.smd-row-action { + display: flex; + align-items: center; + justify-content: center; + min-height: 32px; + min-width: 32px; + border: none; + background: none; + cursor: pointer; + color: var(--color-text-faint); + border-radius: 6px; + padding: 0; + transition: + background 0.1s, + color 0.1s; +} + +.smd-row-action:hover { + background: var(--color-bg-hover); + color: var(--color-error-text); +} + +/* ── Avatar colour palette (cycled by memberIndex % 5) ──────────────────────── */ + +.smd-avatar--0 { + background: var(--color-badge-indigo-bg); + color: var(--color-badge-indigo-text); +} + +.smd-avatar--1 { + background: var(--color-badge-success-bg); + color: var(--color-badge-success-text); +} + +.smd-avatar--2 { + background: var(--color-accent-tint); + color: var(--color-accent); +} + +.smd-avatar--3 { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.smd-avatar--4 { + background: var(--color-warning-bg-light); + color: var(--color-warning-text-amber); +} + +/* ── Fallback when user-directory is unavailable ────────────────────────────── */ + +.smd-directory-unavailable { + font-size: 13px; + color: var(--color-text-faint); + font-style: italic; + padding: 4px 0 8px; +} + +/* ── Link rows ───────────────────────────────────────────────────────────────── */ + +.smd-link-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; +} + +.smd-link-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-bg-muted); + border: 0.5px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-subtle); + flex-shrink: 0; + font-size: 14px; +} + +.smd-link-info { + flex: 1; + overflow: hidden; +} + +.smd-link-name { + font-size: 14px; + color: var(--color-text-heading); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smd-link-tags { + display: flex; + gap: 6px; + margin-top: 3px; + flex-wrap: wrap; +} + +.smd-link-tag { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background: var(--color-bg-muted); + color: var(--color-text-subtle); + border: 0.5px solid var(--color-border); + white-space: nowrap; +} + +.smd-link-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +/* ── Inline edit sub-panel ───────────────────────────────────────────────────── */ + +.smd-edit-panel { + margin: 4px 0 8px 42px; + padding: 12px; + background: var(--color-bg-hover); + border: 0.5px solid var(--color-border); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.smd-edit-panel label { + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + display: block; + margin-bottom: 4px; +} + +.smd-edit-input { + width: 100%; + padding: 8px 10px; + font-size: 13px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-surface); + color: var(--color-text-heading); + outline: none; + box-sizing: border-box; + transition: border-color 0.15s; +} + +.smd-edit-input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-ring); +} + +.smd-edit-panel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ── New-link creation button + form ─────────────────────────────────────────── */ + +.smd-new-link-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px; + margin-top: 8px; + font-size: 13px; + color: var(--color-text-subtle); + background: none; + border: 1.5px dashed var(--color-border-medium); + border-radius: 8px; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} + +.smd-new-link-btn:hover { + background: var(--color-bg-hover); + border-color: var(--color-accent); + color: var(--color-accent); +} + +.smd-new-link-form { + margin-top: 8px; + padding: 14px; + background: var(--color-bg-hover); + border: 0.5px solid var(--color-border); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.smd-new-link-form label { + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + display: block; + margin-bottom: 3px; +} + +.smd-new-link-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +/* ── Password toggle row ─────────────────────────────────────────────────────── */ + +.smd-pw-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +/* ── Apply spinner ────────────────────────────────────────────────────────────── */ + +.smd-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: smdSpin 0.6s linear infinite; + vertical-align: middle; + margin-left: 6px; +} + +@keyframes smdSpin { + to { + transform: rotate(360deg); + } +} diff --git a/static/css/main.css b/static/css/main.css index 38c68ac7..eb2cd53e 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -18,6 +18,7 @@ @import url("./components/dialogs.css"); @import url("./components/modals.css"); @import url("./components/shareDialog.css"); +@import url("./components/shareModal.css"); @import url("./components/uploadDropdown.css"); @import url("./components/notifications.css"); @import url("./components/userMenu.css"); diff --git a/static/index.html b/static/index.html index e7416e36..ac175e22 100644 --- a/static/index.html +++ b/static/index.html @@ -25,7 +25,7 @@ - + diff --git a/static/js/app/main.js b/static/js/app/main.js index 7711c6f0..037712ac 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -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'; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 24b7008b..d965502c 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -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 = ` - - `; - 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)); }); }, diff --git a/static/js/components/modal.js b/static/js/components/modal.js index 17b11520..79166e72 100644 --- a/static/js/components/modal.js +++ b/static/js/components/modal.js @@ -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 = ``; + 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'); + }); } }; diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js new file mode 100644 index 00000000..1662dfae --- /dev/null +++ b/static/js/components/shareModal.js @@ -0,0 +1,1013 @@ +// @ts-check + +/** + * ShareModal — unified sharing dialog for files and folders. + * + * Covers two areas: + * • People (user-to-user grants via `/api/grants`) + * • Public links (via `/api/shares`) + * + * All mutations are staged locally and committed only when the user clicks + * Apply. The only immediate action is Copy Link (clipboard). + * + * The dialog shell (overlay, animation, header, footer, Escape/click-outside + * handling) is delegated entirely to `Modal.openPanel()`. + */ + +import { ui } from '../app/ui.js'; +import { i18n } from '../core/i18n.js'; +import { fileSharing } from '../features/sharing/fileSharing.js'; +import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js'; +import { grants } from '../model/grants.js'; +import { systemUsers } from '../model/systemUsers.js'; +import { Modal } from './modal.js'; + +/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */ + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Permissions that belong to each role (must mirror the Rust DTO). */ +const ROLE_PERMISSIONS = { + viewer: ['read'], + editor: ['read', 'comment', 'create', 'update'], + admin: ['read', 'comment', 'create', 'update', 'share', 'delete'] +}; + +/** + * Derive the highest role a set of grants represents for one subject. + * @param {Grant[]} subjectGrants + * @returns {ShareRoleEnum} + */ +function _roleFromGrants(subjectGrants) { + const perms = new Set(subjectGrants.map((g) => g.permission)); + if (perms.has('delete') || perms.has('share')) return 'admin'; + if (perms.has('create') || perms.has('update')) return 'editor'; + return 'viewer'; +} + +/** + * Group grants by subject id and return one MemberEntry per unique subject. + * @param {Grant[]} grantList + * @returns {MemberEntry[]} + */ +function _buildMembers(grantList) { + /** @type {Map} */ + const bySubject = new Map(); + for (const g of grantList) { + // Token grants represent public-link access — they belong in the Links + // section, not the People section. + if (g.subject.type === 'token') continue; + const key = g.subject.id; + if (!bySubject.has(key)) bySubject.set(key, []); + bySubject.get(key).push(g); + } + /** @type {MemberEntry[]} */ + const members = []; + for (const subjectGrants of bySubject.values()) { + members.push({ + grant: subjectGrants[0], // representative grant (used for subject info) + role: _roleFromGrants(subjectGrants), + _op: 'keep' + }); + } + return members; +} + +/** + * Get initials for an avatar (up to 2 chars). + * @param {string} name + * @returns {string} + */ +function _initials(name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +// ── Component ────────────────────────────────────────────────────────────────── + +const shareModal = { + // ── State ────────────────────────────────────────────────────────────────── + + /** @type {FileItem|FolderItem|null} */ + _item: null, + + /** @type {'file'|'folder'} */ + _itemType: 'file', + + /** @type {MemberEntry[]} */ + _localMembers: [], + + /** @type {LinkEntry[]} */ + _localLinks: [], + + /** @type {DraftLink[]} */ + _newLinks: [], + + /** @type {ContactItem[]} */ + _stagedUsers: [], + + /** @type {ShareRoleEnum} */ + _stagedRole: 'viewer', + + /** @type {HTMLElement|null} — body node injected into Modal */ + _bodyEl: null, + + // ── Public API ───────────────────────────────────────────────────────────── + + /** + * Open the share modal for a file or folder. + * @param {FileItem|FolderItem} item + * @param {'file'|'folder'} itemType + */ + async open(item, itemType) { + this._item = item; + this._itemType = itemType; + this._localMembers = []; + this._localLinks = []; + this._newLinks = []; + this._stagedUsers = []; + this._stagedRole = 'viewer'; + + const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`; + + // Build body with loading skeleton + this._bodyEl = this._buildSkeleton(); + + Modal.openPanel({ + title, + icon: 'fa-share-alt', + content: this._bodyEl, + confirmText: i18n.t('actions.apply', 'Apply'), + onConfirm: () => { + this._applyAll(); + } // intentionally discard Promise + }); + + // Prefetch system users in background so tooltips resolve instantly. + systemUsers.prefetch(); + + // Load data + try { + const [grantList, linkList] = await Promise.all([ + grants.fetchGrantsForResource(itemType, item.id), + fileSharing.getSharedLinksForItem(item.id, itemType) + ]); + + this._localMembers = _buildMembers(grantList); + this._localLinks = linkList.map((share) => /** @type {LinkEntry} */ ({ share, _op: 'keep', _draft: null })); + } catch (err) { + console.error('shareModal: load error', err); + } + + // Swap skeleton → real content + if (this._bodyEl) { + this._bodyEl.replaceChildren(...this._buildContent()); + } + }, + + /** + * Close the modal (delegates to Modal.close). + */ + close() { + Modal.close(false); + }, + + // ── Skeleton ─────────────────────────────────────────────────────────────── + + /** + * @returns {HTMLElement} + */ + _buildSkeleton() { + const body = document.createElement('div'); + body.className = 'smd-body'; + const skel = document.createElement('div'); + skel.className = 'smd-skeleton'; + for (const cls of ['smd-skeleton-line smd-skeleton-line--short', 'smd-skeleton-line smd-skeleton-line--medium', 'smd-skeleton-line']) { + const line = document.createElement('div'); + line.className = cls; + skel.appendChild(line); + } + body.appendChild(skel); + return body; + }, + + // ── Content builder ──────────────────────────────────────────────────────── + + /** + * Build the two sections (People + Links) as an array of elements. + * @returns {HTMLElement[]} + */ + _buildContent() { + return [this._buildPeopleSection(), this._buildLinksSection()]; + }, + + // ── People section ───────────────────────────────────────────────────────── + + /** + * @returns {HTMLElement} + */ + _buildPeopleSection() { + const section = document.createElement('div'); + section.className = 'smd-section'; + + const title = document.createElement('div'); + title.className = 'smd-section-title'; + title.textContent = i18n.t('share.people', 'People'); + section.appendChild(title); + + if (addressBook.isSystemAvailable()) { + section.appendChild(this._buildSearchRow()); + section.appendChild(this._buildChipsRow()); + } else { + const note = document.createElement('p'); + note.className = 'smd-directory-unavailable'; + note.textContent = i18n.t('share.directoryUnavailable', 'User directory unavailable'); + section.appendChild(note); + } + + section.appendChild(this._buildMemberGroups()); + return section; + }, + + /** + * @returns {HTMLElement} + */ + _buildSearchRow() { + const row = document.createElement('div'); + row.className = 'smd-search-row'; + + // ── Search input + dropdown ────────────────────────────────────────── + const wrap = document.createElement('div'); + wrap.className = 'smd-search-wrap'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'smd-search-input'; + input.placeholder = i18n.t('share.searchPlaceholder', 'Search people…'); + input.autocomplete = 'off'; + + const dropdown = document.createElement('div'); + dropdown.className = 'smd-suggestions hidden'; + + wrap.appendChild(input); + wrap.appendChild(dropdown); + + // ── Role select ────────────────────────────────────────────────────── + const roleSelect = document.createElement('select'); + roleSelect.className = 'smd-role-select'; + for (const [val, label] of [ + ['viewer', i18n.t('share.role.viewer', 'Viewer')], + ['editor', i18n.t('share.role.editor', 'Editor')], + ['admin', i18n.t('share.role.admin', 'Admin')] + ]) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label; + if (val === this._stagedRole) opt.selected = true; + roleSelect.appendChild(opt); + } + roleSelect.addEventListener('change', () => { + this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value); + }); + + // ── Add button ─────────────────────────────────────────────────────── + const addBtn = document.createElement('button'); + addBtn.className = 'smd-add-btn btn btn-secondary'; + addBtn.textContent = i18n.t('actions.add', 'Add'); + addBtn.disabled = true; + + // Search debounce + /** @type {ReturnType|null} */ + let debounce = null; + + input.addEventListener('input', () => { + if (debounce) clearTimeout(debounce); + const q = input.value.trim(); + if (!q) { + dropdown.classList.add('hidden'); + dropdown.replaceChildren(); + return; + } + debounce = setTimeout(async () => { + const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]); + this._renderSuggestions(dropdown, results.slice(0, 8), (contact) => { + this._stageUser(contact, input, dropdown, addBtn); + }); + }, 200); + }); + + // Close dropdown on click outside + document.addEventListener( + 'click', + (e) => { + if (!wrap.contains(/** @type {Node} */ (e.target))) { + dropdown.classList.add('hidden'); + } + }, + { once: false } + ); + + addBtn.addEventListener('click', () => { + if (this._stagedUsers.length === 0) return; + this._commitStagedUsers(); + addBtn.disabled = true; + }); + + row.appendChild(wrap); + row.appendChild(roleSelect); + row.appendChild(addBtn); + + return row; + }, + + /** + * @param {HTMLElement} container + * @param {ContactItem[]} results + * @param {(c: ContactItem) => void} onSelect + */ + _renderSuggestions(container, results, onSelect) { + container.replaceChildren(); + if (results.length === 0) { + container.classList.add('hidden'); + return; + } + results.forEach((c, i) => { + const item = document.createElement('div'); + item.className = 'smd-suggestion-item'; + item.tabIndex = 0; + + const avatar = document.createElement('div'); + avatar.className = `smd-suggestion-avatar smd-avatar--${i % 5}`; + const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8); + avatar.textContent = _initials(displayName); + + const nameEl = document.createElement('span'); + nameEl.className = 'smd-suggestion-name'; + nameEl.textContent = displayName; + + const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? ''; + if (primaryEmail) { + const emailEl = document.createElement('span'); + emailEl.className = 'smd-suggestion-email'; + emailEl.textContent = primaryEmail; + item.appendChild(avatar); + item.appendChild(nameEl); + item.appendChild(emailEl); + } else { + item.appendChild(avatar); + item.appendChild(nameEl); + } + + const select = () => onSelect(c); + item.addEventListener('click', select); + item.addEventListener('keydown', (e) => { + if (e.key === 'Enter') select(); + }); + container.appendChild(item); + }); + container.classList.remove('hidden'); + }, + + /** + * @param {ContactItem} contact + * @param {HTMLInputElement} inputEl + * @param {HTMLElement} dropdown + * @param {HTMLButtonElement} addBtn + */ + _stageUser(contact, inputEl, dropdown, addBtn) { + // Idempotent: skip duplicates and already-existing members + const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m._op !== 'remove'); + const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id); + if (alreadyMember || alreadyStaged) return; + + this._stagedUsers.push(contact); + this._refreshChips(); + addBtn.disabled = false; + + inputEl.value = ''; + dropdown.classList.add('hidden'); + dropdown.replaceChildren(); + }, + + /** + * @returns {HTMLElement} + */ + _buildChipsRow() { + const row = document.createElement('div'); + row.id = 'smd-chips-row'; + row.className = 'smd-chips'; + this._renderChipsInto(row); + return row; + }, + + _refreshChips() { + const row = /** @type {HTMLElement|null} */ (document.getElementById('smd-chips-row')); + if (row) this._renderChipsInto(row); + }, + + /** + * @param {HTMLElement} container + */ + _renderChipsInto(container) { + container.replaceChildren(); + this._stagedUsers.forEach((c, i) => { + const chip = document.createElement('div'); + chip.className = 'smd-chip'; + + const avatar = document.createElement('div'); + avatar.className = `smd-chip-avatar smd-avatar--${i % 5}`; + const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8); + avatar.textContent = _initials(displayName); + + const nameEl = document.createElement('span'); + nameEl.textContent = displayName; + + const rm = document.createElement('button'); + rm.className = 'smd-chip-remove'; + rm.innerHTML = '×'; + rm.title = i18n.t('actions.remove', 'Remove'); + rm.addEventListener('click', () => { + this._stagedUsers = this._stagedUsers.filter((u) => u.id !== c.id); + this._refreshChips(); + const addBtn = /** @type {HTMLButtonElement|null} */ (document.querySelector('.smd-add-btn')); + if (addBtn) addBtn.disabled = this._stagedUsers.length === 0; + }); + + chip.appendChild(avatar); + chip.appendChild(nameEl); + chip.appendChild(rm); + container.appendChild(chip); + }); + }, + + _commitStagedUsers() { + for (const contact of this._stagedUsers) { + this._localMembers.push({ + grant: { + id: '', // not yet persisted + granted_at: 0, // placeholder — grant hasn't been persisted yet + granted_by: '', + subject: { type: 'user', id: contact.id }, + permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]), + resource: { type: this._itemType, id: this._item?.id ?? '' } + }, + role: this._stagedRole, + _op: 'new' + }); + } + this._stagedUsers = []; + this._refreshChips(); + this._refreshMemberGroups(); + }, + + /** + * @returns {HTMLElement} + */ + _buildMemberGroups() { + const container = document.createElement('div'); + container.id = 'smd-member-groups'; + this._renderMemberGroupsInto(container); + return container; + }, + + _refreshMemberGroups() { + const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups')); + if (container) this._renderMemberGroupsInto(container); + }, + + /** + * @param {HTMLElement} container + */ + _renderMemberGroupsInto(container) { + container.replaceChildren(); + const groups = /** @type {ShareRoleEnum[]} */ (['admin', 'editor', 'viewer']); + let memberIndex = 0; + + for (const role of groups) { + const visible = this._localMembers.filter((m) => m.role === role && m._op !== 'remove'); + if (visible.length === 0) continue; + + const group = document.createElement('div'); + group.className = 'smd-group'; + + const header = document.createElement('div'); + header.className = 'smd-group-header'; + + const labelMap = { + admin: i18n.t('share.role.admin', 'Admin'), + editor: i18n.t('share.role.editor', 'Editor'), + viewer: i18n.t('share.role.viewer', 'Viewer') + }; + const badge = document.createElement('span'); + badge.className = 'smd-group-badge'; + badge.textContent = String(visible.length); + header.textContent = labelMap[role]; + header.appendChild(badge); + group.appendChild(header); + + for (const entry of visible) { + group.appendChild(this._buildMemberRow(entry, memberIndex)); + memberIndex++; + } + container.appendChild(group); + } + }, + + /** + * @param {MemberEntry} entry + * @param {number} idx + * @returns {HTMLElement} + */ + _buildMemberRow(entry, idx) { + const row = document.createElement('div'); + row.className = 'smd-member-row'; + + const avatar = document.createElement('div'); + avatar.className = `smd-member-avatar smd-avatar--${idx % 5}`; + + // Resolve display name async + systemUsers.getDisplayName(entry.grant.subject.id).then((name) => { + avatar.textContent = _initials(name); + nameEl.textContent = name; + }); + + const nameEl = document.createElement('span'); + nameEl.className = 'smd-member-name'; + nameEl.textContent = `${entry.grant.subject.id.slice(0, 8)}…`; + + const roleSelect = document.createElement('select'); + roleSelect.className = 'smd-member-role-select'; + for (const [val, label] of [ + ['viewer', i18n.t('share.role.viewer', 'Viewer')], + ['editor', i18n.t('share.role.editor', 'Editor')], + ['admin', i18n.t('share.role.admin', 'Admin')] + ]) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label; + if (val === entry.role) opt.selected = true; + roleSelect.appendChild(opt); + } + roleSelect.addEventListener('change', () => { + const newRole = /** @type {ShareRoleEnum} */ (roleSelect.value); + entry.role = newRole; + entry._op = entry._op === 'new' ? 'new' : 'change'; + this._refreshMemberGroups(); + }); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'smd-row-action'; + removeBtn.title = i18n.t('actions.remove', 'Remove'); + removeBtn.innerHTML = ''; + removeBtn.addEventListener('click', () => { + entry._op = 'remove'; + this._refreshMemberGroups(); + }); + + row.appendChild(avatar); + row.appendChild(nameEl); + row.appendChild(roleSelect); + row.appendChild(removeBtn); + return row; + }, + + // ── Links section ────────────────────────────────────────────────────────── + + /** + * @returns {HTMLElement} + */ + _buildLinksSection() { + const section = document.createElement('div'); + section.className = 'smd-section'; + + const title = document.createElement('div'); + title.className = 'smd-section-title'; + title.textContent = i18n.t('share.publicLinks', 'Public links'); + section.appendChild(title); + + const listEl = document.createElement('div'); + listEl.id = 'smd-links-list'; + this._renderLinksInto(listEl); + section.appendChild(listEl); + + const newLinkBtn = document.createElement('button'); + newLinkBtn.className = 'smd-new-link-btn'; + newLinkBtn.innerHTML = ` ${i18n.t('share.createLink', 'Create new public link')}`; + newLinkBtn.id = 'smd-new-link-btn'; + + const newLinkForm = document.createElement('div'); + newLinkForm.id = 'smd-new-link-form'; + newLinkForm.className = 'smd-new-link-form hidden'; + newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm)); + + newLinkBtn.addEventListener('click', () => { + newLinkBtn.classList.add('hidden'); + newLinkForm.classList.remove('hidden'); + }); + + section.appendChild(newLinkBtn); + section.appendChild(newLinkForm); + return section; + }, + + /** + * @param {HTMLElement} container + */ + _renderLinksInto(container) { + container.replaceChildren(); + + // Existing links + for (const entry of this._localLinks.filter((e) => e._op !== 'remove')) { + container.appendChild(this._buildLinkRow(entry)); + } + + // Draft (new) links + for (const draft of this._newLinks) { + container.appendChild(this._buildDraftLinkRow(draft)); + } + }, + + _refreshLinks() { + const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list')); + if (container) this._renderLinksInto(container); + }, + + /** + * @param {LinkEntry} entry + * @returns {HTMLElement} + */ + _buildLinkRow(entry) { + const share = entry.share; + const draft = entry._op === 'edit' ? entry._draft : null; + + // Display values: prefer draft overrides when in edit-pending state + const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link'); + const displayPw = draft ? draft.password !== null : share.has_password; + const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null; + + const row = document.createElement('div'); + row.className = 'smd-link-row'; + + const icon = document.createElement('div'); + icon.className = 'smd-link-icon'; + icon.innerHTML = ''; + + const info = document.createElement('div'); + info.className = 'smd-link-info'; + + const name = document.createElement('div'); + name.className = 'smd-link-name'; + name.textContent = displayName; + + const tags = document.createElement('div'); + tags.className = 'smd-link-tags'; + if (displayPw) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${i18n.t('share.passwordProtected', 'Password')}`; + tags.appendChild(t); + } + if (displayExp) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${displayExp}`; + tags.appendChild(t); + } + + info.appendChild(name); + if (tags.children.length) info.appendChild(tags); + + const actions = document.createElement('div'); + actions.className = 'smd-link-actions'; + + // Copy + const copyBtn = document.createElement('button'); + copyBtn.className = 'smd-row-action'; + copyBtn.title = i18n.t('actions.copy', 'Copy'); + copyBtn.innerHTML = ''; + copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url)); + + // Edit + const editBtn = document.createElement('button'); + editBtn.className = 'smd-row-action'; + editBtn.title = i18n.t('actions.edit', 'Edit'); + editBtn.innerHTML = ''; + editBtn.addEventListener('click', () => { + const panel = row.nextElementSibling; + if (panel?.classList.contains('smd-edit-panel')) { + panel.classList.toggle('hidden'); + } else { + const editPanel = this._buildEditPanel(entry, row); + row.after(editPanel); + } + }); + + // Delete + const delBtn = document.createElement('button'); + delBtn.className = 'smd-row-action'; + delBtn.title = i18n.t('actions.delete', 'Delete'); + delBtn.innerHTML = ''; + delBtn.addEventListener('click', () => { + entry._op = 'remove'; + this._refreshLinks(); + }); + + actions.appendChild(copyBtn); + actions.appendChild(editBtn); + actions.appendChild(delBtn); + + row.appendChild(icon); + row.appendChild(info); + row.appendChild(actions); + return row; + }, + + /** + * @param {DraftLink} draft + * @returns {HTMLElement} + */ + _buildDraftLinkRow(draft) { + const row = document.createElement('div'); + row.className = 'smd-link-row'; + + const icon = document.createElement('div'); + icon.className = 'smd-link-icon'; + icon.innerHTML = ''; + + const info = document.createElement('div'); + info.className = 'smd-link-info'; + + const name = document.createElement('div'); + name.className = 'smd-link-name'; + name.textContent = draft.name || i18n.t('share.newLink', 'New link'); + + const tags = document.createElement('div'); + tags.className = 'smd-link-tags'; + if (draft.password) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${i18n.t('share.passwordProtected', 'Password')}`; + tags.appendChild(t); + } + if (draft.expires_at) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${draft.expires_at}`; + tags.appendChild(t); + } + + const pending = document.createElement('span'); + pending.className = 'smd-link-tag'; + pending.textContent = i18n.t('share.pending', 'Pending'); + tags.appendChild(pending); + + info.appendChild(name); + if (tags.children.length) info.appendChild(tags); + + const actions = document.createElement('div'); + actions.className = 'smd-link-actions'; + + const delBtn = document.createElement('button'); + delBtn.className = 'smd-row-action'; + delBtn.title = i18n.t('actions.remove', 'Remove'); + delBtn.innerHTML = ''; + delBtn.addEventListener('click', () => { + this._newLinks = this._newLinks.filter((d) => d !== draft); + this._refreshLinks(); + }); + + actions.appendChild(delBtn); + row.appendChild(icon); + row.appendChild(info); + row.appendChild(actions); + return row; + }, + + /** + * @param {LinkEntry} entry + * @param {HTMLElement} row + * @returns {HTMLElement} + */ + _buildEditPanel(entry, row) { + const panel = document.createElement('div'); + panel.className = 'smd-edit-panel'; + + const pwLabel = document.createElement('label'); + pwLabel.textContent = i18n.t('dialogs.password', 'Password'); + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.className = 'smd-edit-input'; + pwInput.placeholder = i18n.t('share.passwordPlaceholder', 'Leave empty to keep unchanged'); + + const expLabel = document.createElement('label'); + expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date'); + const expInput = document.createElement('input'); + expInput.type = 'date'; + expInput.className = 'smd-edit-input'; + if (entry.share.expires_at) { + expInput.value = new Date(entry.share.expires_at * 1000).toISOString().slice(0, 10); + } + + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'smd-edit-panel-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-secondary'; + cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel'); + cancelBtn.addEventListener('click', () => panel.remove()); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'btn btn-primary'; + saveBtn.textContent = i18n.t('actions.save', 'Save'); + saveBtn.addEventListener('click', () => { + entry._op = 'edit'; + entry._draft = { + name: entry.share.item_name || '', + password: pwInput.value || null, + expires_at: expInput.value || null + }; + panel.remove(); + this._refreshLinks(); + }); + + actionsDiv.appendChild(cancelBtn); + actionsDiv.appendChild(saveBtn); + + panel.appendChild(pwLabel); + panel.appendChild(pwInput); + panel.appendChild(expLabel); + panel.appendChild(expInput); + panel.appendChild(actionsDiv); + + void row; // row is unused — panel is inserted via row.after() in caller + return panel; + }, + + /** + * @param {HTMLButtonElement} newLinkBtn + * @param {HTMLElement} formWrapper + * @returns {HTMLElement} + */ + _buildNewLinkForm(newLinkBtn, formWrapper) { + const inner = document.createElement('div'); + + const nameLabel = document.createElement('label'); + nameLabel.textContent = i18n.t('share.linkName', 'Link name'); + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'smd-edit-input'; + nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Optional name'); + + const pwToggleLabel = document.createElement('label'); + pwToggleLabel.className = 'smd-pw-toggle'; + const pwCheckbox = document.createElement('input'); + pwCheckbox.type = 'checkbox'; + pwToggleLabel.appendChild(pwCheckbox); + pwToggleLabel.appendChild(document.createTextNode(` ${i18n.t('share.addPassword', 'Add password')}`)); + + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.className = 'smd-edit-input hidden'; + pwInput.placeholder = i18n.t('dialogs.password', 'Password'); + pwCheckbox.addEventListener('change', () => { + pwInput.classList.toggle('hidden', !pwCheckbox.checked); + }); + + const expLabel = document.createElement('label'); + expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date'); + const expInput = document.createElement('input'); + expInput.type = 'date'; + expInput.className = 'smd-edit-input'; + + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'smd-new-link-form-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-secondary'; + cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel'); + cancelBtn.addEventListener('click', () => { + formWrapper.classList.add('hidden'); + newLinkBtn.classList.remove('hidden'); + }); + + const addBtn = document.createElement('button'); + addBtn.className = 'btn btn-primary'; + addBtn.textContent = i18n.t('share.addLink', 'Add link'); + addBtn.addEventListener('click', () => { + /** @type {DraftLink} */ + const draft = { + name: nameInput.value.trim(), + password: pwCheckbox.checked ? pwInput.value || null : null, + expires_at: expInput.value || null + }; + this._newLinks.push(draft); + this._refreshLinks(); + + // Reset form + nameInput.value = ''; + pwCheckbox.checked = false; + pwInput.value = ''; + pwInput.classList.add('hidden'); + expInput.value = ''; + + formWrapper.classList.add('hidden'); + newLinkBtn.classList.remove('hidden'); + }); + + actionsDiv.appendChild(cancelBtn); + actionsDiv.appendChild(addBtn); + + inner.appendChild(nameLabel); + inner.appendChild(nameInput); + inner.appendChild(pwToggleLabel); + inner.appendChild(pwInput); + inner.appendChild(expLabel); + inner.appendChild(expInput); + inner.appendChild(actionsDiv); + + return inner; + }, + + // ── Apply ────────────────────────────────────────────────────────────────── + + /** + * Commit all pending local operations to the server, then close. + * @returns {Promise} + */ + async _applyAll() { + if (!this._item) return; + + // Disable the Apply button while working + if (Modal.confirmBtn) Modal.confirmBtn.disabled = true; + + const item = this._item; + const itemType = this._itemType; + + try { + // ── Grants ───────────────────────────────────────────────────────── + for (const m of this._localMembers) { + if (m._op === 'remove' && m.grant.id) { + await grants.revokeGrant(m.grant.id); + } else if (m._op === 'change' && m.grant.id) { + await grants.updateRole({ + subject: { type: m.grant.subject.type, id: m.grant.subject.id }, + resource: { type: itemType, id: item.id }, + role: m.role + }); + } else if (m._op === 'new') { + await grants.createGrant({ + subject: { type: m.grant.subject.type, id: m.grant.subject.id }, + resource: { type: itemType, id: item.id }, + role: m.role + }); + } + } + + // ── Links ────────────────────────────────────────────────────────── + for (const e of this._localLinks) { + if (e._op === 'remove') { + await fileSharing.removeSharedLink(e.share.id); + } else if (e._op === 'edit' && e._draft) { + const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null; + await fileSharing.updateSharedLink(e.share.id, { + password: e._draft.password, + expires_at: expiresTs, + permissions: null + }); + } + } + + for (const draft of this._newLinks) { + await fileSharing.createSharedLink( + item.id, + itemType, + /** @type {import('../core/types.js').CreateShare} */ ({ + item_id: item.id, + item_name: item.name ?? null, + item_type: itemType, + password: draft.password, + // Pass as ms timestamp so fileSharing's new Date(expires_at) works correctly + expires_at: draft.expires_at ? new Date(draft.expires_at).getTime() : null, + permissions: { read: true, write: false, reshare: false } + }) + ); + } + + // ── Refresh badge cache ──────────────────────────────────────────── + await grants.fetchOutgoingGrants(); + + const hasAnyShare = + this._localMembers.some((m) => m._op !== 'remove') || this._localLinks.some((e) => e._op !== 'remove') || this._newLinks.length > 0; + + ui.setSharedVisualState(item.id, itemType, hasAnyShare); + + Modal.close(true); + } catch (err) { + console.error('shareModal._applyAll error:', err); + if (Modal.confirmBtn) Modal.confirmBtn.disabled = false; + } + } +}; + +export { shareModal }; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 1d2c5229..44c5333f 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -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' diff --git a/static/js/core/types.js b/static/js/core/types.js index 58c87cf8..9500dd9a 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -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. + */ + diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 54538ec3..e4395fb1 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -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 inside header to preserve 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 = ' 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 = ' 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 = ' 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 */ diff --git a/static/js/features/library/music.js b/static/js/features/library/music.js index 8cddd114..edb953c5 100644 --- a/static/js/features/library/music.js +++ b/static/js/features/library/music.js @@ -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' */ diff --git a/static/js/model/grants.js b/static/js/model/grants.js index e741f1c7..0cdb3440 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -2,6 +2,8 @@ * @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js' */ +import { getCsrfHeaders } from '../core/csrf.js'; + const grants = { /** @type {Record>} */ 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} + */ + 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} + */ + 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}`); + } } };