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

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

fix(ShareModal): do not show Token (public) grants in People section
This commit is contained in:
Edouard Vanbelle
2026-05-25 16:54:50 +02:00
parent a88e4c2733
commit 79c1a37931
15 changed files with 1897 additions and 367 deletions
+52
View File
@@ -5,10 +5,12 @@ use tokio::sync::Semaphore;
use uuid::Uuid; use uuid::Uuid;
use crate::domain::repositories::folder_repository::FolderRepository; 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::SharePgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::{ use crate::{
application::{ application::{
dtos::{ dtos::{
@@ -17,6 +19,7 @@ use crate::{
}, },
ports::{ ports::{
auth_ports::PasswordHasherPort, auth_ports::PasswordHasherPort,
authorization_ports::AuthorizationEngine,
share_ports::{ShareStoragePort, ShareUseCase}, share_ports::{ShareStoragePort, ShareUseCase},
storage_ports::FileReadPort, storage_ports::FileReadPort,
}, },
@@ -78,6 +81,9 @@ pub struct ShareService {
file_repository: Arc<FileBlobReadRepository>, file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>, folder_repository: Arc<FolderDbRepository>,
password_hasher: Arc<Argon2PasswordHasher>, password_hasher: Arc<Argon2PasswordHasher>,
/// ReBAC engine — used to create/revoke token grants that mirror public
/// share links so that `GET /api/grants/outgoing` reflects them.
authorization: Arc<PgAclEngine>,
/// Bounds the number of in-flight Argon2 password hashes to avoid /// Bounds the number of in-flight Argon2 password hashes to avoid
/// saturating the blocking thread pool and consuming excessive RAM. /// saturating the blocking thread pool and consuming excessive RAM.
hash_semaphore: Arc<Semaphore>, hash_semaphore: Arc<Semaphore>,
@@ -90,6 +96,7 @@ impl ShareService {
file_repository: Arc<FileBlobReadRepository>, file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>, folder_repository: Arc<FolderDbRepository>,
password_hasher: Arc<Argon2PasswordHasher>, password_hasher: Arc<Argon2PasswordHasher>,
authorization: Arc<PgAclEngine>,
) -> Self { ) -> Self {
Self { Self {
config, config,
@@ -97,6 +104,7 @@ impl ShareService {
file_repository, file_repository,
folder_repository, folder_repository,
password_hasher, password_hasher,
authorization,
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)), hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
} }
} }
@@ -256,6 +264,50 @@ impl ShareUseCase for ShareService {
.await .await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?; .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 // Convert the entity to DTO for the response
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url())) Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
} }
+3 -1
View File
@@ -514,6 +514,7 @@ impl AppServiceFactory {
&self, &self,
repos: &RepositoryServices, repos: &RepositoryServices,
db_pool: &Arc<PgPool>, db_pool: &Arc<PgPool>,
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
) -> Option<Arc<ShareService>> { ) -> Option<Arc<ShareService>> {
if !self.config.features.enable_file_sharing { if !self.config.features.enable_file_sharing {
tracing::info!("File sharing service is disabled in configuration"); tracing::info!("File sharing service is disabled in configuration");
@@ -537,6 +538,7 @@ impl AppServiceFactory {
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
repos.folder_repository.clone(), repos.folder_repository.clone(),
password_hasher, password_hasher,
authorization.clone(),
)); ));
tracing::info!("File sharing service initialized"); tracing::info!("File sharing service initialized");
@@ -652,7 +654,7 @@ impl AppServiceFactory {
self.create_application_services(&core, &repos, trash_service.clone(), &authorization); self.create_application_services(&core, &repos, trash_service.clone(), &authorization);
// 5. Share service // 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(); apps.share_service = share_service.clone();
let share_browse_service = share_service.as_ref().map(|s| { let share_browse_service = share_service.as_ref().map(|s| {
+20
View File
@@ -190,6 +190,9 @@
color: var(--color-text-heading); color: var(--color-text-heading);
margin: 0; margin: 0;
flex: 1; flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.modal-close-btn { .modal-close-btn {
@@ -307,3 +310,20 @@
.modal-footer .btn-primary:active { .modal-footer .btn-primary:active {
transform: translateY(0); 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;
}
+561
View File
@@ -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);
}
}
+1
View File
@@ -18,6 +18,7 @@
@import url("./components/dialogs.css"); @import url("./components/dialogs.css");
@import url("./components/modals.css"); @import url("./components/modals.css");
@import url("./components/shareDialog.css"); @import url("./components/shareDialog.css");
@import url("./components/shareModal.css");
@import url("./components/uploadDropdown.css"); @import url("./components/uploadDropdown.css");
@import url("./components/notifications.css"); @import url("./components/notifications.css");
@import url("./components/userMenu.css"); @import url("./components/userMenu.css");
+1 -1
View File
@@ -25,7 +25,7 @@
<script defer type="module" src="/js/core/csrf.js"></script> <script defer type="module" src="/js/core/csrf.js"></script>
<script defer type="module" src="/js/core/languageSelector.js"></script> <script defer type="module" src="/js/core/languageSelector.js"></script>
<script defer type="module" src="/js/core/notifications.js"></script> <script defer type="module" src="/js/core/notifications.js"></script>
<script defer type="module" src="/js/core/modal.js"></script> <script defer type="module" src="/js/components/modal.js"></script>
<script defer type="module" src="/js/core/formatters.js"></script> <script defer type="module" src="/js/core/formatters.js"></script>
<script defer type="module" src="/js/app/state.js"></script> <script defer type="module" src="/js/app/state.js"></script>
<script defer type="module" src="/js/app/uiFileTypes.js"></script> <script defer type="module" src="/js/app/uiFileTypes.js"></script>
+1 -1
View File
@@ -7,10 +7,10 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
installFetchInterceptor(); installFetchInterceptor();
import { Modal } from '../components/modal.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js'; import { i18n } from '../core/i18n.js';
import { oxiIconsInit } from '../core/icons.js'; import { oxiIconsInit } from '../core/icons.js';
import { Modal } from '../components/modal.js';
import { fileOps } from '../features/files/fileOperations.js'; import { fileOps } from '../features/files/fileOperations.js';
import { multiSelect } from '../features/files/multiSelect.js'; import { multiSelect } from '../features/files/multiSelect.js';
import { favorites } from '../features/library/favorites.js'; import { favorites } from '../features/library/favorites.js';
+9 -118
View File
@@ -5,6 +5,7 @@
// @ts-check // @ts-check
import { shareModal } from '../components/shareModal.js';
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js'; import { i18n } from '../core/i18n.js';
import { OxiIcons } from '../core/icons.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 { wopiEditor } from '../features/files/wopiEditor.js';
import { favorites } from '../features/library/favorites.js'; import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js'; import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { thumbnail } from '../features/thumbnail.js'; import { thumbnail } from '../features/thumbnail.js';
import { grants } from '../model/grants.js'; import { grants } from '../model/grants.js';
import { loadFiles } from './filesView.js'; import { loadFiles } from './filesView.js';
@@ -146,115 +146,7 @@ const ui = {
document.body.appendChild(moveDialog); document.body.appendChild(moveDialog);
} }
// Share dialog // Share dialog is now handled by shareModal (components/shareModal.js)
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();
}
});
}
// Notification dialog // Notification dialog
if (!document.getElementById('notification-dialog')) { if (!document.getElementById('notification-dialog')) {
@@ -1245,15 +1137,14 @@ const ui = {
const itemType = itemElement.dataset.fileId ? 'file' : 'folder'; const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName; const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
// TODO corrently dirty const item = /** @type {FileItem|FolderItem} */ (
const item = /** @type {unknown} */ ({ /** @type {unknown} */ ({
id: itemId, id: itemId,
item_id: itemId, name: itemName
item_type: itemType, })
item_name: itemName );
});
contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType); shareModal.open(item, /** @type {'file'|'folder'} */ (itemType));
}); });
}, },
+98
View File
@@ -40,6 +40,14 @@ const Modal = {
// Rename mode: select only name without extension // Rename mode: select only name without extension
_selectNameOnly: false, _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 * Initialize modal system
*/ */
@@ -84,6 +92,13 @@ const Modal = {
this.close(false); 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 */ /** @param {string} message */
@@ -259,6 +274,8 @@ const Modal = {
this._action = null; this._action = null;
this.overlay.classList.remove('active'); this.overlay.classList.remove('active');
const wasPanel = this._panelMode;
setTimeout(() => { setTimeout(() => {
this.overlay.classList.add('hidden'); this.overlay.classList.add('hidden');
@@ -269,6 +286,15 @@ const Modal = {
// Clear callbacks // Clear callbacks
this.onConfirm = null; this.onConfirm = null;
this.onCancel = 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); }, 200);
}, },
@@ -277,6 +303,13 @@ const Modal = {
* until it resolves — closing only on success, showing the error inline on failure. * until it resolves — closing only on success, showing the error inline on failure.
*/ */
async confirm() { 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._action) {
if (this.onConfirm) this.onConfirm(); if (this.onConfirm) this.onConfirm();
this.close(true); this.close(true);
@@ -298,6 +331,71 @@ const Modal = {
this.confirmBtn.disabled = false; this.confirmBtn.disabled = false;
this.input.focus(); this.input.focus();
} }
},
/**
* Open the modal with fully custom body content (panel mode).
*
* The caller supplies a pre-built HTMLElement as `content`; it is injected
* into `.modal-body`, replacing the default label/input/error elements for
* the lifetime of this panel. The overlay, header, animation, footer
* buttons, click-outside, and Escape handling all come from Modal.
*
* Original `.modal-body` innerHTML is restored automatically when the
* panel closes.
*
* @param {Object} options
* @param {string} options.title
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
* @param {string} [options.confirmText] - Confirm button label
* @param {string} [options.cancelText] - Cancel button label
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
*/
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) {
if (!this.overlay) return;
this._panelMode = true;
// ── Header ──────────────────────────────────────────────────────────
const iconContainer = this.overlay.querySelector('.modal-icon');
if (iconContainer) {
iconContainer.innerHTML = `<i class="fas ${icon}"></i>`;
if (replaceIconsInElement) replaceIconsInElement(iconContainer);
}
if (this.title) this.title.textContent = title;
// ── Body swap ───────────────────────────────────────────────────────
const bodyEl = this.overlay.querySelector('.modal-body');
if (bodyEl) {
this._savedBodyHTML = bodyEl.innerHTML;
bodyEl.replaceChildren(content);
}
// ── Container size modifier ──────────────────────────────────────────
this.overlay.querySelector('.modal-container')?.classList.add('modal-container--panel');
// ── Footer buttons ──────────────────────────────────────────────────
if (this.confirmBtn) {
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
this.confirmBtn.disabled = false;
}
if (this.cancelBtn) {
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
}
// ── Callbacks ───────────────────────────────────────────────────────
this.onConfirm = onConfirm;
this.onCancel = onCancel;
this._action = null;
this.clearError();
// ── Show overlay (same animation as prompt, no input focus) ─────────
this.overlay.classList.remove('hidden');
requestAnimationFrame(() => {
this.overlay.classList.add('active');
});
} }
}; };
File diff suppressed because it is too large Load Diff
+4
View File
@@ -266,6 +266,10 @@ const OxiIcons = {
384, 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' '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: [ shuffle: [
512, 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' 'M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z'
+32 -1
View File
@@ -271,7 +271,7 @@
*/ */
/** /**
* @typedef {'user'|'group'|'external'} SubjectTypeEnum * @typedef {'user'|'group'|'token'|'external'} SubjectTypeEnum
*/ */
/** /**
@@ -355,3 +355,34 @@
* @property {string} updated_at - ISO-8601 * @property {string} updated_at - ISO-8601
*/ */
// ------------------- share modal
/**
* Share roles (DTO-layer sugar for the ReBAC permission sets).
* @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum
*/
/**
* One collaborator row in the share modal's People section.
* @typedef {Object} MemberEntry
* @property {Grant} grant - The underlying grant (id, subject, resource, etc.)
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
*/
/**
* Existing public link with a pending local operation.
* @typedef {Object} LinkEntry
* @property {ShareItem} share - The existing share object.
* @property {'keep'|'remove'|'edit'} _op - Pending local operation.
* @property {DraftLink|null} _draft - Updated fields when _op === 'edit'.
*/
/**
* A public link staged for creation (not yet committed).
* @typedef {Object} DraftLink
* @property {string} name
* @property {string|null} password
* @property {string|null} expires_at - ISO-8601 date string or null.
*/
+5 -237
View File
@@ -7,11 +7,12 @@ import { resolveHomeFolder } from '../../app/authSession.js';
import { loadFiles } from '../../app/filesView.js'; import { loadFiles } from '../../app/filesView.js';
import { switchToFilesSection } from '../../app/navigation.js'; import { switchToFilesSection } from '../../app/navigation.js';
import { app } from '../../app/state.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 { getCsrfHeaders } from '../../core/csrf.js';
import { escapeHtml } from '../../core/formatters.js'; import { escapeHtml } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { Modal } from '../../components/modal.js';
import { favorites } from '../library/favorites.js'; import { favorites } from '../library/favorites.js';
import { musicView } from '../library/music.js'; import { musicView } from '../library/music.js';
import { fileSharing } from '../sharing/fileSharing.js'; import { fileSharing } from '../sharing/fileSharing.js';
@@ -157,7 +158,7 @@ const contextMenus = {
document.getElementById('share-folder-option').addEventListener('click', () => { document.getElementById('share-folder-option').addEventListener('click', () => {
const folder = app.contextMenuTargetFolder; const folder = app.contextMenuTargetFolder;
if (folder) { if (folder) {
this.showShareDialog(folder, 'folder'); shareModal.open(folder, 'folder');
} }
ui.closeContextMenu(); ui.closeContextMenu();
}); });
@@ -279,7 +280,7 @@ const contextMenus = {
document.getElementById('share-file-option').addEventListener('click', () => { document.getElementById('share-file-option').addEventListener('click', () => {
const file = app.contextMenuTargetFile; const file = app.contextMenuTargetFile;
if (file) { if (file) {
this.showShareDialog(file, 'file'); shareModal.open(file, 'file');
} }
ui.closeFileContextMenu(); ui.closeFileContextMenu();
}); });
@@ -722,229 +723,6 @@ const contextMenus = {
await this.loadMoveDialogFolders(app.userHomeFolderId || null); 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 * Show email notification dialog
* @param {string} shareUrl - URL to share * @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 * Close notification dialog
*/ */
+1 -1
View File
@@ -1,9 +1,9 @@
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { formatFileSize } from '../../core/formatters.js'; import { formatFileSize } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { oxiIcon } from '../../core/icons.js'; import { oxiIcon } from '../../core/icons.js';
import { Modal } from '../../components/modal.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */ /** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
+96 -7
View File
@@ -2,6 +2,8 @@
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js' * @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
*/ */
import { getCsrfHeaders } from '../core/csrf.js';
const grants = { const grants = {
/** @type {Record<String, Record<String, Grant[]>>} */ /** @type {Record<String, Record<String, Grant[]>>} */
outgoingGrants: {}, outgoingGrants: {},
@@ -13,14 +15,15 @@ const grants = {
const response = await fetch('/api/grants/outgoing'); const response = await fetch('/api/grants/outgoing');
if (!response.ok) { 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; return;
} }
/** @type {Grant[]} */ /** @type {Grant[]} */
const outgoingGrants = await response.json(); const outgoingGrants = await response.json();
console.log(outgoingGrants); // Reset and rebuild cache
this.outgoingGrants = {};
// store grants by type, then by id // store grants by type, then by id
outgoingGrants.forEach((grant) => { 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] ??= [];
this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant); 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'); const response = await fetch('/api/grants/incoming');
if (!response.ok) { 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; return;
} }
@@ -63,8 +64,6 @@ const grants = {
this.incomingGrants[grant.resource.type][grant.resource.id] ??= []; this.incomingGrants[grant.resource.type][grant.resource.id] ??= [];
this.incomingGrants[grant.resource.type][grant.resource.id].push(grant); 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(); 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}`);
}
} }
}; };