From 880035390061ccc03d356e44078601bf800e1111 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 19:56:31 +0200 Subject: [PATCH 01/12] refactor(share,grants): migrate expiration from legacy share into grants, simplify legacy share, normalize shareModal for better UX --- ...1000000_rebac_expiry_and_perms_cleanup.sql | 68 +++ src/application/dtos/grant_dto.rs | 9 + src/application/dtos/share_dto.rs | 28 +- src/application/ports/authorization_ports.rs | 22 +- src/application/services/share_service.rs | 136 ++--- src/domain/entities/share.rs | 117 +--- src/domain/services/authorization.rs | 8 + .../repositories/pg/share_pg_repository.rs | 106 ++-- src/infrastructure/services/pg_acl_engine.rs | 72 ++- src/interfaces/api/handlers/grant_handler.rs | 14 +- src/interfaces/api/mod.rs | 5 +- static/css/components/modals.css | 2 +- static/css/components/shareModal.css | 114 +++- static/css/themes/dark.css | 4 + static/js/components/modal.js | 17 +- static/js/components/shareModal.js | 564 ++++++++++-------- static/js/core/types.js | 4 +- 17 files changed, 693 insertions(+), 597 deletions(-) create mode 100644 migrations/20260601000000_rebac_expiry_and_perms_cleanup.sql diff --git a/migrations/20260601000000_rebac_expiry_and_perms_cleanup.sql b/migrations/20260601000000_rebac_expiry_and_perms_cleanup.sql new file mode 100644 index 00000000..a93d1eb1 --- /dev/null +++ b/migrations/20260601000000_rebac_expiry_and_perms_cleanup.sql @@ -0,0 +1,68 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- ReBAC Phase 2: grant-level expiry + dead permission column cleanup +-- ════════════════════════════════════════════════════════════════════════════ +-- This migration: +-- 1. Adds expires_at (TIMESTAMPTZ) to access_grants — uniform expiry for +-- all subject types (token, user, future external). +-- 2. Migrates existing token expiry from storage.shares.expires_at. +-- 3. Backfills any Read grants missing for shares created after the +-- initial migration (safety net — idempotent via NOT EXISTS). +-- 4. Adds two performance indexes (expires_at partial, granted_by). +-- 5. Drops the now-dead permission and expiry columns from storage.shares. +-- storage.shares becomes token-only metadata: id, token, password_hash, +-- access_count, created_at, created_by, item_id, item_type, item_name. +-- +-- Conceptual model: a share token is an authentication principal, not a +-- permission type. Access = having a non-expired Read grant in access_grants +-- for Subject::Token(share.id). Tokens are always read-only by definition. + +-- ── 1. Add expires_at ──────────────────────────────────────────────────────── +ALTER TABLE storage.access_grants + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; + +-- ── 2. Migrate token expiry (shares.expires_at is BIGINT unix seconds) ─────── +UPDATE storage.access_grants ag +SET expires_at = to_timestamp(s.expires_at) +FROM storage.shares s +WHERE ag.subject_type = 'token' + AND ag.subject_id = s.id + AND s.expires_at IS NOT NULL; + +-- ── 3. Backfill Read grants for shares that missed the initial migration ────── +INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at) +SELECT + 'token', + s.id, + s.item_type, + s.item_id::UUID, + 'read', + s.created_by, + to_timestamp(s.created_at) +FROM storage.shares s +WHERE s.permissions_read + AND NOT EXISTS ( + SELECT 1 FROM storage.access_grants ag + WHERE ag.subject_type = 'token' + AND ag.subject_id = s.id + AND ag.permission = 'read' + ) +ON CONFLICT DO NOTHING; + +-- ── 4. Performance indexes ─────────────────────────────────────────────────── +-- Partial index for expiry checks (only rows that actually expire) +CREATE INDEX IF NOT EXISTS idx_grants_expires_at + ON storage.access_grants (expires_at) WHERE expires_at IS NOT NULL; + +-- Needed for GET /api/grants/outgoing/resources (currently missing) +CREATE INDEX IF NOT EXISTS idx_grants_granted_by + ON storage.access_grants (granted_by); + +-- ── 5. Drop dead columns from storage.shares ───────────────────────────────── +-- Permissions were never enforced (no public write endpoints, frontend +-- hard-codes write=false/reshare=false). Expiry is now in access_grants. +ALTER TABLE storage.shares + DROP COLUMN IF EXISTS permissions_read, + DROP COLUMN IF EXISTS permissions_write, + DROP COLUMN IF EXISTS permissions_reshare, + DROP COLUMN IF EXISTS expires_at; diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 9725a25a..2b2697e2 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -191,6 +191,9 @@ pub struct CreateGrantDto { pub permissions: Option>, #[serde(default)] pub role: Option, + /// Optional expiry for every grant in this request. RFC 3339 / ISO 8601. + #[serde(default)] + pub expires_at: Option>, } /// `PUT /api/grants/role` — reconcile a subject's role on a resource. @@ -199,6 +202,9 @@ pub struct UpdateRoleDto { pub subject: SubjectDto, pub resource: ResourceDto, pub role: Role, + /// Optional expiry applied to every grant written or updated by this call. + #[serde(default)] + pub expires_at: Option>, } // ════════════════════════════════════════════════════════════════════════════ @@ -213,6 +219,8 @@ pub struct GrantDto { pub permission: PermissionDto, pub granted_by: Uuid, pub granted_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, } impl From for GrantDto { @@ -224,6 +232,7 @@ impl From for GrantDto { permission: g.permission.into(), granted_by: g.granted_by, granted_at: g.granted_at, + expires_at: g.expires_at, } } } diff --git a/src/application/dtos/share_dto.rs b/src/application/dtos/share_dto.rs index 9a527dec..55ecd18a 100644 --- a/src/application/dtos/share_dto.rs +++ b/src/application/dtos/share_dto.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::domain::entities::share::{Share, SharePermissions}; +use crate::domain::entities::share::Share; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ShareDto { @@ -13,19 +13,11 @@ pub struct ShareDto { pub url: String, pub has_password: bool, pub expires_at: Option, - pub permissions: SharePermissionsDto, pub created_at: u64, pub created_by: String, pub access_count: u64, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SharePermissionsDto { - pub read: bool, - pub write: bool, - pub reshare: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateShareDto { pub item_id: String, @@ -33,17 +25,14 @@ pub struct CreateShareDto { pub item_type: String, pub password: Option, pub expires_at: Option, - pub permissions: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateShareDto { pub password: Option, pub expires_at: Option, - pub permissions: Option, } -/// Extension methods to convert between DTOs and domain entities impl ShareDto { pub fn from_entity(share: &Share, base_url: &str) -> Self { let url = format!("{}/s/{}", base_url, share.token()); @@ -57,24 +46,9 @@ impl ShareDto { url, has_password: share.has_password(), expires_at: share.expires_at(), - permissions: SharePermissionsDto::from_entity(share.permissions()), created_at: share.created_at(), created_by: share.created_by().to_string(), access_count: share.access_count(), } } } - -impl SharePermissionsDto { - pub fn from_entity(permissions: &SharePermissions) -> Self { - Self { - read: permissions.read(), - write: permissions.write(), - reshare: permissions.reshare(), - } - } - - pub fn to_entity(&self) -> SharePermissions { - SharePermissions::new(self.read, self.write, self.reshare) - } -} diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 6fa74909..bd579923 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -98,15 +98,35 @@ pub trait AuthorizationEngine: Send + Sync + 'static { async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError>; /// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE - /// constraint and the existing row is returned. + /// constraint; if the row already exists its `expires_at` is updated. async fn grant( &self, granted_by: Uuid, subject: Subject, permission: Permission, resource: Resource, + expires_at: Option>, ) -> Result; + /// Update `expires_at` on every grant row for the given subject. + /// Used when a share's expiry is changed — one call updates all + /// permission rows for that token in a single UPDATE. + async fn set_expiry_for_subject( + &self, + subject: Subject, + expires_at: Option>, + ) -> Result<(), DomainError>; + + /// Update `expires_at` on every grant row for the given `(subject, resource)` + /// pair. Used by `set_role` to sync the expiry of retained grants when the + /// caller changes expiry without changing permissions. + async fn set_expiry_on_resource( + &self, + subject: Subject, + resource: Resource, + expires_at: Option>, + ) -> Result<(), DomainError>; + /// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not /// the row existed (idempotent revoke). async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>; diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 26a41c7c..3250c7fe 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -28,7 +28,7 @@ use crate::{ config::AppConfig, errors::{DomainError, ErrorKind}, }, - domain::entities::share::{Share, ShareItemType, SharePermissions}, + domain::entities::share::{Share, ShareItemType}, }; #[derive(Debug, Error)] @@ -229,87 +229,59 @@ impl ShareUseCase for ShareService { user_id: Uuid, dto: CreateShareDto, ) -> Result { - // Convert the item type let item_type = ShareItemType::try_from(dto.item_type.as_str()) .map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?; - // Verify that the item exists self.verify_item_exists(&dto.item_id, &item_type).await?; - // Convert the permissions DTO if it exists - let permissions = dto.permissions.map(|p| p.to_entity()); - - // Hash the password if provided (async, semaphore-bounded) let password_hash = match dto.password { Some(p) => Some(self.hash_password_async(&p).await?), None => None, }; - // Create the Share entity let share = Share::new( dto.item_id.clone(), dto.item_name.clone(), item_type, user_id, - permissions, password_hash, - dto.expires_at, ) .map_err(|e| ShareServiceError::Validation(e.to_string()))?; - // Save to the repository let saved_share = self .share_repository .save_share(&share) .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()))?; + // Create one Read-only grant for the token subject, carrying expires_at. + // Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token` + // cleans up this grant when the share is later deleted. + 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 expires_dt = dto + .expires_at + .and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0)); + self.authorization + .grant( + user_id, + Subject::Token(saved_share.id()), + Permission::Read, + resource, + expires_dt, + ) + .await + .map_err(|e| ShareServiceError::Repository(e.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())) + // Return DTO with the requested expires_at (grant subquery on the share + // row would return NULL at this point since INSERT ran before the grant). + let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url()); + response.expires_at = dto.expires_at; + Ok(response) } async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result { @@ -364,16 +336,6 @@ impl ShareUseCase for ShareService { // SECURITY: ownership-verified lookup — prevents IDOR let mut share = self.fetch_owned_share(id, requester_id).await?; - // Update permissions if provided - if let Some(permissions_dto) = dto.permissions { - let permissions = SharePermissions::new( - permissions_dto.read, - permissions_dto.write, - permissions_dto.reshare, - ); - share = share.with_permissions(permissions); - } - // Update password if provided (async, semaphore-bounded) if let Some(password) = dto.password { let password_hash = if password.is_empty() { @@ -384,23 +346,33 @@ impl ShareUseCase for ShareService { share = share.with_password(password_hash); } - // Update expiration date if provided + // Expiry is managed at the grant level; update all grants for this token. + let new_expires_at = if dto.expires_at.is_some() { + dto.expires_at + .and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0)) + } else { + None + }; if dto.expires_at.is_some() { - share = share.with_expiration(dto.expires_at); + self.authorization + .set_expiry_for_subject(Subject::Token(share.id()), new_expires_at) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; } - // Save the changes let updated_share = self .share_repository .update_share(&share) .await .map_err(|e| ShareServiceError::Repository(e.to_string()))?; - // Convert the entity to DTO for the response - Ok(ShareDto::from_entity( - &updated_share, - &self.config.base_url(), - )) + // Use the requested expires_at for the response (subquery in update_share + // runs before set_expiry_for_subject committed, so entity may lag). + let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url()); + if dto.expires_at.is_some() { + response.expires_at = dto.expires_at; + } + Ok(response) } async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> { @@ -510,8 +482,6 @@ impl ShareUseCase for ShareService { #[allow(dead_code)] mod tests { use super::*; - #[allow(unused_imports)] - use crate::application::dtos::share_dto::SharePermissionsDto; use crate::application::ports::auth_ports::PasswordHasherPort; use crate::application::ports::share_ports::ShareStoragePort; use crate::application::ports::storage_ports::FileReadPort; @@ -606,7 +576,6 @@ mod tests { let item_type = ShareItemType::try_from(dto.item_type.as_str()) .map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?; self.verify_item_exists(&dto.item_id, &item_type).await?; - let permissions = dto.permissions.map(|p| p.to_entity()); let password_hash = match dto.password { Some(p) => Some(self.hash_password_async(&p).await?), None => None, @@ -616,9 +585,7 @@ mod tests { dto.item_name.clone(), item_type, user_id, - permissions, password_hash, - dto.expires_at, ) .map_err(|e| ShareServiceError::Validation(e.to_string()))?; let saved_share = self @@ -692,9 +659,6 @@ mod tests { .map_err(|e| { ShareServiceError::NotFound(format!("Share {} not found: {}", id, e)) })?; - if let Some(p) = dto.permissions { - share = share.with_permissions(SharePermissions::new(p.read, p.write, p.reshare)); - } if let Some(password) = dto.password { let hash = if password.is_empty() { None @@ -703,9 +667,6 @@ mod tests { }; share = share.with_password(hash); } - if dto.expires_at.is_some() { - share = share.with_expiration(dto.expires_at); - } let updated = self .share_repository .update_share(&share) @@ -1208,11 +1169,6 @@ mod tests { item_type: "file".to_string(), password: Some("secret".to_string()), expires_at: None, - permissions: Some(SharePermissionsDto { - read: true, - write: false, - reshare: false, - }), }; let result = service.create_shared_link(Uuid::new_v4(), dto).await; diff --git a/src/domain/entities/share.rs b/src/domain/entities/share.rs index 68915229..24a09f5c 100644 --- a/src/domain/entities/share.rs +++ b/src/domain/entities/share.rs @@ -12,20 +12,13 @@ pub struct Share { item_type: ShareItemType, token: String, password_hash: Option, + /// Derived from `storage.access_grants.expires_at` — not stored on the share row. expires_at: Option, - permissions: SharePermissions, created_at: u64, created_by: Uuid, access_count: u64, } -#[derive(Debug, Clone, PartialEq)] -pub struct SharePermissions { - read: bool, - write: bool, - reshare: bool, -} - #[derive(Debug, Clone, PartialEq)] pub enum ShareItemType { File, @@ -38,31 +31,14 @@ impl Share { item_name: Option, item_type: ShareItemType, created_by: Uuid, - permissions: Option, password_hash: Option, - expires_at: Option, ) -> Result { - // Validate item_id if item_id.is_empty() { return Err(ShareError::ValidationError( "Item ID cannot be empty".to_string(), )); } - // Validate expiration date if provided - if let Some(expires) = expires_at { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - - if expires <= now { - return Err(ShareError::InvalidExpiration( - "Expiration date must be in the future".to_string(), - )); - } - } - let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -75,12 +51,7 @@ impl Share { item_type, token: Uuid::new_v4().to_string(), password_hash, - expires_at, - permissions: permissions.unwrap_or(SharePermissions { - read: true, - write: false, - reshare: false, - }), + expires_at: None, created_at: now, created_by, access_count: 0, @@ -96,7 +67,6 @@ impl Share { token: String, password_hash: Option, expires_at: Option, - permissions: SharePermissions, created_at: u64, created_by: Uuid, access_count: u64, @@ -109,7 +79,6 @@ impl Share { token, password_hash, expires_at, - permissions, created_at, created_by, access_count, @@ -142,10 +111,6 @@ impl Share { self.expires_at } - pub fn permissions(&self) -> &SharePermissions { - &self.permissions - } - pub fn created_at(&self) -> u64 { self.created_at } @@ -160,21 +125,11 @@ impl Share { // ── Builder-style modifiers (immutable) ── - pub fn with_permissions(mut self, permissions: SharePermissions) -> Self { - self.permissions = permissions; - self - } - pub fn with_password(mut self, password_hash: Option) -> Self { self.password_hash = password_hash; self } - pub fn with_expiration(mut self, expires_at: Option) -> Self { - self.expires_at = expires_at; - self - } - pub fn with_token(mut self, token: String) -> Self { self.token = token; self @@ -212,28 +167,6 @@ impl Share { } } -impl SharePermissions { - pub fn new(read: bool, write: bool, reshare: bool) -> Self { - Self { - read, - write, - reshare, - } - } - - pub fn read(&self) -> bool { - self.read - } - - pub fn write(&self) -> bool { - self.write - } - - pub fn reshare(&self) -> bool { - self.reshare - } -} - impl std::fmt::Display for ShareItemType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -275,59 +208,17 @@ mod tests { ShareItemType::File, uid, None, - None, - None, ) .unwrap(); assert_eq!(share.item_id(), "test_file_id"); assert_eq!(*share.item_type(), ShareItemType::File); assert_eq!(share.created_by(), uid); - assert!(share.permissions().read()); - assert!(!share.permissions().write()); - assert!(!share.permissions().reshare()); assert!(!share.has_password()); assert!(share.expires_at().is_none()); assert_eq!(share.access_count(), 0); } - #[test] - fn test_share_is_expired() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - - // Create a share that expires in the future - let future = now + 3600; // 1 hour in the future - let share = Share::new( - "test_file_id".to_string(), - None, - ShareItemType::File, - test_user_id(), - None, - None, - Some(future), - ) - .unwrap(); - - assert!(!share.is_expired()); - - // Test with past expiration (should fail during creation) - let past = now - 3600; // 1 hour in the past - let share_result = Share::new( - "test_file_id".to_string(), - None, - ShareItemType::File, - test_user_id(), - None, - None, - Some(past), - ); - - assert!(share_result.is_err()); - } - #[test] fn test_share_item_type_conversion() { assert_eq!(ShareItemType::File.to_string(), "file"); @@ -355,9 +246,7 @@ mod tests { None, ShareItemType::File, test_user_id(), - None, Some("some_hash_value".to_string()), - None, ) .unwrap(); @@ -373,8 +262,6 @@ mod tests { ShareItemType::File, test_user_id(), None, - None, // No password - None, ) .unwrap(); diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 64fe3e80..51de60b8 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -200,6 +200,14 @@ pub struct Grant { pub permission: Permission, pub granted_by: Uuid, pub granted_at: chrono::DateTime, + pub expires_at: Option>, +} + +impl Grant { + pub fn is_expired(&self) -> bool { + self.expires_at + .is_some_and(|exp| exp < chrono::Utc::now()) + } } // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/infrastructure/repositories/pg/share_pg_repository.rs b/src/infrastructure/repositories/pg/share_pg_repository.rs index 592ae040..d5c30183 100644 --- a/src/infrastructure/repositories/pg/share_pg_repository.rs +++ b/src/infrastructure/repositories/pg/share_pg_repository.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::{ application::ports::share_ports::ShareStoragePort, common::errors::DomainError, - domain::entities::share::{Share, ShareItemType, SharePermissions}, + domain::entities::share::{Share, ShareItemType}, }; /// PostgreSQL implementation of [`ShareStoragePort`]. @@ -37,6 +37,8 @@ impl SharePgRepository { } /// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity. + /// Expects columns: id, item_id, item_name, item_type, token, password_hash, + /// expires_at (derived from access_grants subquery), created_at, created_by, access_count. fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result { let id: Uuid = row .try_get("id") @@ -52,10 +54,8 @@ impl SharePgRepository { DomainError::internal_error("Share", format!("Failed to read token: {e}")) })?; let password_hash: Option = row.try_get("password_hash").unwrap_or(None); + // expires_at derived from access_grants subquery (unix seconds as i64) let expires_at: Option = row.try_get("expires_at").unwrap_or(None); - let permissions_read: bool = row.try_get("permissions_read").unwrap_or(true); - let permissions_write: bool = row.try_get("permissions_write").unwrap_or(false); - let permissions_reshare: bool = row.try_get("permissions_reshare").unwrap_or(false); let created_at: i64 = row.try_get("created_at").map_err(|e| { DomainError::internal_error("Share", format!("Failed to read created_at: {e}")) })?; @@ -66,8 +66,6 @@ impl SharePgRepository { let item_type = ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File); - let permissions = - SharePermissions::new(permissions_read, permissions_write, permissions_reshare); Ok(Share::from_raw( id, @@ -77,7 +75,6 @@ impl SharePgRepository { token, password_hash, expires_at.map(|v| v as u64), - permissions, created_at as u64, created_by, access_count as u64, @@ -91,23 +88,18 @@ impl ShareStoragePort for SharePgRepository { r#" INSERT INTO storage.shares (id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, created_at, created_by, access_count) VALUES - ($1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13) + ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET - item_name = EXCLUDED.item_name, - password_hash = EXCLUDED.password_hash, - expires_at = EXCLUDED.expires_at, - permissions_read = EXCLUDED.permissions_read, - permissions_write = EXCLUDED.permissions_write, - permissions_reshare = EXCLUDED.permissions_reshare, - access_count = EXCLUDED.access_count + item_name = EXCLUDED.item_name, + password_hash = EXCLUDED.password_hash, + access_count = EXCLUDED.access_count RETURNING id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, + (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) + FROM storage.access_grants ag + WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at, created_at, created_by, access_count "#, ) @@ -117,10 +109,6 @@ impl ShareStoragePort for SharePgRepository { .bind(share.item_type().to_string()) .bind(share.token()) .bind(share.password_hash()) - .bind(share.expires_at().map(|v| v as i64)) - .bind(share.permissions().read()) - .bind(share.permissions().write()) - .bind(share.permissions().reshare()) .bind(share.created_at() as i64) .bind(share.created_by()) .bind(share.access_count() as i64) @@ -137,11 +125,13 @@ impl ShareStoragePort for SharePgRepository { async fn find_share_by_token(&self, token: &str) -> Result { let row = sqlx::query( r#" - SELECT id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, - created_at, created_by, access_count - FROM storage.shares - WHERE token = $1 + SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, + (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) + FROM storage.access_grants ag + WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, + s.created_at, s.created_by, s.access_count + FROM storage.shares s + WHERE s.token = $1 "#, ) .bind(token) @@ -168,11 +158,13 @@ impl ShareStoragePort for SharePgRepository { ) -> Result { let row = sqlx::query( r#" - SELECT id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, - created_at, created_by, access_count - FROM storage.shares - WHERE id = $1 AND created_by = $2 + SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, + (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) + FROM storage.access_grants ag + WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, + s.created_at, s.created_by, s.access_count + FROM storage.shares s + WHERE s.id = $1 AND s.created_by = $2 "#, ) .bind(id) @@ -224,12 +216,14 @@ impl ShareStoragePort for SharePgRepository { ) -> Result, DomainError> { let rows = sqlx::query( r#" - SELECT id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, - created_at, created_by, access_count - FROM storage.shares - WHERE item_id = $1 AND item_type = $2 AND created_by = $3 - ORDER BY created_at DESC + SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, + (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) + FROM storage.access_grants ag + WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, + s.created_at, s.created_by, s.access_count + FROM storage.shares s + WHERE s.item_id = $1 AND s.item_type = $2 AND s.created_by = $3 + ORDER BY s.created_at DESC "#, ) .bind(item_id) @@ -249,27 +243,21 @@ impl ShareStoragePort for SharePgRepository { let row = sqlx::query( r#" UPDATE storage.shares SET - item_name = $2, - password_hash = $3, - expires_at = $4, - permissions_read = $5, - permissions_write = $6, - permissions_reshare = $7, - access_count = $8 + item_name = $2, + password_hash = $3, + access_count = $4 WHERE id = $1 RETURNING id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, + (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) + FROM storage.access_grants ag + WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at, created_at, created_by, access_count "#, ) .bind(share.id()) .bind(share.item_name()) .bind(share.password_hash()) - .bind(share.expires_at().map(|v| v as i64)) - .bind(share.permissions().read()) - .bind(share.permissions().write()) - .bind(share.permissions().reshare()) .bind(share.access_count() as i64) .fetch_optional(&*self.db_pool) .await @@ -296,13 +284,15 @@ impl ShareStoragePort for SharePgRepository { // Single query with window function — count + rows in one roundtrip let rows = sqlx::query( r#" - SELECT id, item_id, item_name, item_type, token, password_hash, - expires_at, permissions_read, permissions_write, permissions_reshare, - created_at, created_by, access_count, - COUNT(*) OVER() AS total_count - FROM storage.shares - WHERE created_by = $1 - ORDER BY created_at DESC + SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, + (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) + FROM storage.access_grants ag + WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, + s.created_at, s.created_by, s.access_count, + COUNT(*) OVER() AS total_count + FROM storage.shares s + WHERE s.created_by = $1 + ORDER BY s.created_at DESC LIMIT $2 OFFSET $3 "#, ) diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 4542f167..e98c05b5 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -103,6 +103,7 @@ impl PgAclEngine { AND g.subject_id = $2 AND g.permission = $3 AND g.resource_type = 'folder' + AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4) LIMIT 1 "#, @@ -135,6 +136,7 @@ impl PgAclEngine { FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2 AND permission = $3 AND resource_type = 'file' AND resource_id = $4 + AND (expires_at IS NULL OR expires_at > NOW()) UNION ALL -- cascading from any ancestor folder of the file's containing folder SELECT 1 @@ -145,6 +147,7 @@ impl PgAclEngine { AND g.subject_id = $2 AND g.permission = $3 AND g.resource_type = 'folder' + AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND target_f.folder_id IS NOT NULL AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = target_f.folder_id) @@ -186,8 +189,9 @@ impl PgAclEngine { Ok(Some((res, granter))) } - /// Decode a (id, subject_type, subject_id, resource_type, resource_id, - /// permission, granted_by, granted_at) row into a `Grant`. + /// Row type for all full-grant SELECT queries: + /// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at) + #[allow(clippy::type_complexity)] fn row_to_grant( row: ( Uuid, @@ -198,6 +202,7 @@ impl PgAclEngine { String, Uuid, chrono::DateTime, + Option>, ), ) -> Result { let subject = Subject::from_parts(&row.1, row.2) @@ -213,6 +218,7 @@ impl PgAclEngine { permission, granted_by: row.6, granted_at: row.7, + expires_at: row.8, }) } } @@ -271,11 +277,12 @@ impl AuthorizationEngine for PgAclEngine { String, Uuid, chrono::DateTime, + Option>, ), >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at + permission, granted_by, granted_at, expires_at FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2 @@ -636,11 +643,12 @@ impl AuthorizationEngine for PgAclEngine { String, Uuid, chrono::DateTime, + Option>, ), >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at + permission, granted_by, granted_at, expires_at FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2 @@ -668,11 +676,12 @@ impl AuthorizationEngine for PgAclEngine { String, Uuid, chrono::DateTime, + Option>, ), >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at + permission, granted_by, granted_at, expires_at FROM storage.access_grants WHERE granted_by = $1 ORDER BY granted_at DESC @@ -692,10 +701,8 @@ impl AuthorizationEngine for PgAclEngine { subject: Subject, permission: Permission, resource: Resource, + expires_at: Option>, ) -> Result { - // Idempotent: ON CONFLICT DO UPDATE so we always return the row - // (whether newly inserted or pre-existing). The "update" is a no-op - // (granted_by/granted_at preserved from the existing row). let row = sqlx::query_as::< _, ( @@ -707,16 +714,17 @@ impl AuthorizationEngine for PgAclEngine { String, Uuid, chrono::DateTime, + Option>, ), >( r#" INSERT INTO storage.access_grants - (subject_type, subject_id, resource_type, resource_id, permission, granted_by) - VALUES ($1, $2, $3, $4, $5, $6) + (subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) - DO UPDATE SET subject_type = EXCLUDED.subject_type + DO UPDATE SET expires_at = EXCLUDED.expires_at RETURNING id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at + permission, granted_by, granted_at, expires_at "#, ) .bind(subject.type_str()) @@ -725,6 +733,7 @@ impl AuthorizationEngine for PgAclEngine { .bind(resource.id()) .bind(permission.as_str()) .bind(granted_by) + .bind(expires_at) .fetch_one(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?; @@ -732,6 +741,45 @@ impl AuthorizationEngine for PgAclEngine { Self::row_to_grant(row) } + async fn set_expiry_for_subject( + &self, + subject: Subject, + expires_at: Option>, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2", + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(expires_at) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?; + Ok(()) + } + + async fn set_expiry_on_resource( + &self, + subject: Subject, + resource: Resource, + expires_at: Option>, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE storage.access_grants SET expires_at = $3 \ + WHERE subject_type = $1 AND subject_id = $2 \ + AND resource_type = $4 AND resource_id = $5", + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(expires_at) + .bind(resource.type_str()) + .bind(resource.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")))?; + Ok(()) + } + async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> { sqlx::query("DELETE FROM storage.access_grants WHERE id = $1") .bind(grant_id) diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 260e77ab..17b04b0d 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -86,6 +86,7 @@ pub async fn create_grant( let subject: Subject = dto.subject.into(); let resource: Resource = dto.resource.into(); + let expires_at = dto.expires_at; // Caller must have Share on the resource (owners pass via short-circuit). if let Err(e) = authz @@ -97,7 +98,7 @@ pub async fn create_grant( let mut results: Vec = Vec::with_capacity(permissions.len()); for perm in permissions { - match authz.grant(caller_id, subject, perm, resource).await { + match authz.grant(caller_id, subject, perm, resource, expires_at).await { Ok(grant) => results.push(grant.into()), Err(err) => { error!("grant insert failed for {perm:?}: {err}"); @@ -189,6 +190,7 @@ pub async fn set_role( let caller_id = auth_user.id; let subject: Subject = dto.subject.into(); let resource: Resource = dto.resource.into(); + let expires_at = dto.expires_at; let target_perms: std::collections::HashSet = dto.role.expand().iter().copied().collect(); @@ -225,11 +227,19 @@ pub async fn set_role( } } for perm in &to_add { - if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await { + if let Err(e) = authz.grant(caller_id, subject, *perm, resource, expires_at).await { return AppError::from(e).into_response(); } } + // Sync expiry on all remaining grants for this (subject, resource) pair — + // includes newly added ones and any that were already present (retained). + // Callers that omit expires_at will clear any existing expiry; this is + // intentional: it keeps all permission rows for the pair consistent. + if let Err(e) = authz.set_expiry_on_resource(subject, resource, expires_at).await { + return AppError::from(e).into_response(); + } + // Return the new full set. let after = match authz.list_grants_on_resource(resource).await { Ok(g) => g, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 31c68bc1..f1f3996b 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -34,9 +34,7 @@ use crate::application::dtos::search_dto::{ SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto, SearchSuggestionItem, SearchSuggestionsDto, }; -use crate::application::dtos::share_dto::{ - CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto, -}; +use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto}; use crate::application::dtos::trash_dto::{ DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto, }; @@ -257,7 +255,6 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; OidcExchangeDto, // Share schemas ShareDto, - SharePermissionsDto, CreateShareDto, UpdateShareDto, // Trash schemas diff --git a/static/css/components/modals.css b/static/css/components/modals.css index d58b7829..7820a0fb 100644 --- a/static/css/components/modals.css +++ b/static/css/components/modals.css @@ -315,7 +315,7 @@ /* Wider, taller container; body becomes a zero-padding scrollable slot. */ .modal-container--panel { - width: 520px; + width: 620px; max-width: 96vw; max-height: 88vh; display: flex; diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css index 1b92d07f..c1e98753 100644 --- a/static/css/components/shareModal.css +++ b/static/css/components/shareModal.css @@ -328,6 +328,8 @@ } .smd-link-name { + flex: 1; + min-width: 0; font-size: 14px; color: var(--color-text-heading); overflow: hidden; @@ -431,32 +433,6 @@ 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 { @@ -488,3 +464,89 @@ transform: rotate(360deg); } } + +/* ── Expiry chip toggle ──────────────────────────────────────────────────────── */ + +/* + * The wrapper reserves a fixed width equal to the date input so that toggling + * between chip and input never shifts the surrounding flex row. + */ +.smd-expiry-chip-wrap { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + width: 130px; +} + +.smd-expiry-chip { + display: inline-flex; + align-items: center; + gap: 5px; + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + font-size: 12px; + border: 1px dashed var(--color-border-medium); + border-radius: 6px; + background: transparent; + color: var(--color-text-faint); + cursor: pointer; + white-space: nowrap; + transition: + border-color 0.15s, + color 0.15s, + background 0.15s; +} + +.smd-expiry-chip:hover { + border-color: var(--color-accent); + color: var(--color-accent); + background: var(--color-bg-hover); +} + +.smd-expiry-chip--set { + border-style: solid; + border-color: var(--color-border); + background: var(--color-bg-muted); + color: var(--color-text-secondary); +} + +.smd-expiry-chip--set:hover { + border-color: var(--color-border-medium); + color: var(--color-text-heading); + background: var(--color-bg-hover); +} + +.smd-expiry-chip-clear { + font-size: 13px; + line-height: 1; + color: var(--color-text-faint); + margin-left: auto; + padding: 0; + transition: color 0.1s; +} + +.smd-expiry-chip-clear:hover { + color: var(--color-error-text); +} + +.smd-expiry-date-input { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + font-size: 12px; + border: 1px solid var(--color-accent); + border-radius: 6px; + background: var(--color-bg-surface); + color: var(--color-text-heading); + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-ring); +} + +/* In the search row the chip/input must match the height of the role select and + Add button (both use padding: 9px, font-size: 13px). */ +.smd-search-row .smd-expiry-chip, +.smd-search-row .smd-expiry-date-input { + padding: 9px 10px; + font-size: 13px; +} diff --git a/static/css/themes/dark.css b/static/css/themes/dark.css index 3aaa9001..182f337c 100644 --- a/static/css/themes/dark.css +++ b/static/css/themes/dark.css @@ -120,3 +120,7 @@ background: var(--color-bg-alt); color: var(--color-accent); } + +[data-theme="dark"] .smd-expiry-date-input::-webkit-calendar-picker-indicator { + filter: invert(1); +} diff --git a/static/js/components/modal.js b/static/js/components/modal.js index 79166e72..8704ca31 100644 --- a/static/js/components/modal.js +++ b/static/js/components/modal.js @@ -346,14 +346,15 @@ const Modal = { * * @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 + * @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 {boolean} [options.confirmDisabled] - Initial disabled state of the confirm button + * @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 }) { + openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, confirmDisabled = false, onConfirm = null, onCancel = null }) { if (!this.overlay) return; this._panelMode = true; @@ -379,7 +380,7 @@ const Modal = { // ── Footer buttons ────────────────────────────────────────────────── if (this.confirmBtn) { this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply'); - this.confirmBtn.disabled = false; + this.confirmBtn.disabled = confirmDisabled; } if (this.cancelBtn) { this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel'); diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index 929186c3..a8a6e33a 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -27,6 +27,16 @@ import { createUserVignette } from './userVignette.js'; // ── Helpers ──────────────────────────────────────────────────────────────────── +/** + * Format a YYYY-MM-DD date string for display ("Dec 31, 2026"). + * @param {string} dateStr + * @returns {string} + */ +function _formatExpiryDate(dateStr) { + const d = new Date(`${dateStr}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + /** Permissions that belong to each role (must mirror the Rust DTO). */ const ROLE_PERMISSIONS = { viewer: ['read'], @@ -101,6 +111,9 @@ const shareModal = { /** @type {ShareRoleEnum} */ _stagedRole: 'viewer', + /** @type {string|null} — YYYY-MM-DD expiry for the next staged users batch */ + _stagedExpiry: null, + /** @type {HTMLElement|null} — body node injected into Modal */ _bodyEl: null, @@ -119,6 +132,7 @@ const shareModal = { this._newLinks = []; this._stagedUsers = []; this._stagedRole = 'viewer'; + this._stagedExpiry = null; const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`; @@ -130,6 +144,7 @@ const shareModal = { icon: 'fa-share-alt', content: this._bodyEl, confirmText: i18n.t('actions.apply', 'Apply'), + confirmDisabled: true, onConfirm: () => { this._applyAll(); } // intentionally discard Promise @@ -164,6 +179,17 @@ const shareModal = { Modal.close(false); }, + // ── Apply-button state ───────────────────────────────────────────────────── + + /** @returns {boolean} */ + _hasPendingChanges() { + return this._localMembers.some((m) => m._op !== 'keep') || this._localLinks.some((e) => e._op !== 'keep') || this._newLinks.length > 0; + }, + + _syncApplyBtn() { + if (Modal.confirmBtn) Modal.confirmBtn.disabled = !this._hasPendingChanges(); + }, + // ── Skeleton ─────────────────────────────────────────────────────────────── /** @@ -262,6 +288,11 @@ const shareModal = { this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value); }); + // ── Expiry chip ────────────────────────────────────────────────────── + const expiryChip = this._buildExpiryChip(null, (v) => { + this._stagedExpiry = v; + }); + // ── Add button ─────────────────────────────────────────────────────── const addBtn = document.createElement('button'); addBtn.className = 'smd-add-btn btn btn-secondary'; @@ -316,6 +347,7 @@ const shareModal = { row.appendChild(wrap); row.appendChild(roleSelect); + row.appendChild(expiryChip); row.appendChild(addBtn); return row; @@ -419,7 +451,7 @@ const shareModal = { /** @type {Grant} */ const placeholderGrant = { id: '', // not yet persisted - granted_at: 0, + granted_at: '', granted_by: '', subject: { type: 'user', id: contact.id }, permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]), @@ -429,7 +461,8 @@ const shareModal = { grant: placeholderGrant, _grants: [], // no server grants yet — nothing to revoke on remove role: this._stagedRole, - _op: 'new' + _op: 'new', + expires_at: this._stagedExpiry }); } this._stagedUsers = []; @@ -450,6 +483,7 @@ const shareModal = { _refreshMemberGroups() { const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups')); if (container) this._renderMemberGroupsInto(container); + this._syncApplyBtn(); }, /** @@ -521,6 +555,19 @@ const shareModal = { this._refreshMemberGroups(); }); + // ── Expiry chip ────────────────────────────────────────────────────── + // Initialise entry.expires_at once from the representative grant so that + // role-only changes preserve the current expiry across row rebuilds. + if (!Object.hasOwn(entry, 'expires_at')) { + const raw = entry.grant.expires_at ?? null; + entry.expires_at = raw ? String(raw).slice(0, 10) : null; + } + const expiryChip = this._buildExpiryChip(entry.expires_at, (v) => { + entry.expires_at = v; + if (entry._op !== 'new') entry._op = 'change'; + this._syncApplyBtn(); + }); + const removeBtn = document.createElement('button'); removeBtn.className = 'smd-row-action'; removeBtn.title = i18n.t('actions.remove', 'Remove'); @@ -532,10 +579,162 @@ const shareModal = { row.appendChild(vignette); row.appendChild(roleSelect); + row.appendChild(expiryChip); row.appendChild(removeBtn); return row; }, + // ── Expiry chip toggle ───────────────────────────────────────────────────── + + /** + * Build a compact expiry chip that toggles to an inline date input on click. + * + * Chip states: + * • "∞ No expiry" — dashed border, faint text (value is null) + * • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set) + * + * @param {string|null} initialValue - YYYY-MM-DD or null + * @param {(v: string|null) => void} onChange - called whenever the value changes + * @returns {HTMLElement} + */ + _buildExpiryChip(initialValue, onChange) { + let current = initialValue; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const dateInput = document.createElement('input'); + dateInput.type = 'date'; + dateInput.className = 'smd-expiry-date-input hidden'; + + const updateChip = () => { + if (current) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${_formatExpiryDate(current)}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + current = null; + onChange(null); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noExpiry', 'No expiry')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + if (current) dateInput.value = current; + dateInput.classList.remove('hidden'); + dateInput.focus(); + }); + + const confirm = () => { + const val = dateInput.value || null; + current = val; + onChange(val); + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + updateChip(); + }; + dateInput.addEventListener('blur', confirm); + dateInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(dateInput); + return wrap; + }, + + /** + * @param {boolean} initialHasPassword + * @param {(v: string) => void} onChange '' = remove / clear, non-empty = set new password + * @returns {HTMLElement} + */ + _buildPasswordChip(initialHasPassword, onChange) { + let hasPassword = initialHasPassword; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.className = 'smd-expiry-date-input hidden'; + pwInput.placeholder = i18n.t('dialogs.password', 'Password'); + pwInput.autocomplete = 'new-password'; + + const updateChip = () => { + if (hasPassword) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${i18n.t('share.passwordProtected', 'Password')}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + hasPassword = false; + onChange(''); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noPassword', 'No password')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + pwInput.value = ''; + pwInput.classList.remove('hidden'); + pwInput.focus(); + }); + + const confirm = () => { + const val = pwInput.value; + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + if (val) { + hasPassword = true; + onChange(val); + } + updateChip(); + }; + + pwInput.addEventListener('blur', confirm); + pwInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(pwInput); + return wrap; + }, + // ── Links section ────────────────────────────────────────────────────────── /** @@ -550,29 +749,72 @@ const shareModal = { title.textContent = i18n.t('share.publicLinks', 'Public links'); section.appendChild(title); + section.appendChild(this._buildAddLinkRow()); + 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'; + return section; + }, - const newLinkForm = document.createElement('div'); - newLinkForm.id = 'smd-new-link-form'; - newLinkForm.className = 'smd-new-link-form hidden'; - newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm)); + /** + * Always-visible add-link row — mirrors the People search row layout. + * Rebuilds itself after each Add to reset chip state. + * @returns {HTMLElement} + */ + _buildAddLinkRow() { + const row = document.createElement('div'); + row.className = 'smd-search-row'; + row.id = 'smd-add-link-row'; - newLinkBtn.addEventListener('click', () => { - newLinkBtn.classList.add('hidden'); - newLinkForm.classList.remove('hidden'); + // Name input — wrapped in smd-search-wrap so it inherits flex:1 + const wrap = document.createElement('div'); + wrap.className = 'smd-search-wrap'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'smd-search-input'; + nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Link name (optional)'); + wrap.appendChild(nameInput); + + /** @type {string|null} */ + let stagedPassword = null; + /** @type {string|null} */ + let stagedExpiry = null; + + const pwChip = this._buildPasswordChip(false, (v) => { + stagedPassword = v || null; }); - section.appendChild(newLinkBtn); - section.appendChild(newLinkForm); - return section; + const expChip = this._buildExpiryChip(null, (v) => { + stagedExpiry = v; + }); + + const addBtn = document.createElement('button'); + addBtn.className = 'smd-add-btn btn btn-secondary'; + addBtn.textContent = i18n.t('actions.add', 'Add'); + + addBtn.addEventListener('click', () => { + /** @type {DraftLink} */ + const draft = { + name: nameInput.value.trim(), + password: stagedPassword, + expires_at: stagedExpiry + }; + this._newLinks.push(draft); + this._refreshLinks(); + // Reset row (also resets chips via closure state) + const fresh = this._buildAddLinkRow(); + row.replaceWith(fresh); + }); + + row.appendChild(wrap); + row.appendChild(pwChip); + row.appendChild(expChip); + row.appendChild(addBtn); + + return row; }, /** @@ -595,6 +837,7 @@ const shareModal = { _refreshLinks() { const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list')); if (container) this._renderLinksInto(container); + this._syncApplyBtn(); }, /** @@ -603,87 +846,65 @@ const shareModal = { */ _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 ensureDraft = () => { + if (!entry._draft) { + entry._draft = { + name: share.item_name || '', + password: null, + expires_at: share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null + }; + entry._op = 'edit'; + this._syncApplyBtn(); + } + return entry._draft; + }; + + // Derive current display values from draft if present, otherwise from share + const currentHasPassword = entry._draft + ? entry._draft.password === '' + ? false + : entry._draft.password + ? true + : share.has_password + : share.has_password; + const currentExpiry = entry._draft ? entry._draft.expires_at : share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : 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; + name.textContent = entry._draft?.name || share.item_name || i18n.t('share.sharedLink', 'Shared link'); - 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.title = i18n.t('actions.copy', 'Copy link'); 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); - } + const pwChip = this._buildPasswordChip(currentHasPassword, (v) => { + ensureDraft().password = v; + }); + + const expChip = this._buildExpiryChip(currentExpiry, (v) => { + ensureDraft().expires_at = v; }); - // Delete const delBtn = document.createElement('button'); delBtn.className = 'smd-row-action'; delBtn.title = i18n.t('actions.delete', 'Delete'); - delBtn.innerHTML = ''; + 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); + row.appendChild(name); + row.appendChild(copyBtn); + row.appendChild(pwChip); + row.appendChild(expChip); + row.appendChild(delBtn); return row; }, @@ -695,42 +916,21 @@ const shareModal = { 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 pwChip = this._buildPasswordChip(!!draft.password, (v) => { + draft.password = v || null; + }); - const actions = document.createElement('div'); - actions.className = 'smd-link-actions'; + const expChip = this._buildExpiryChip(draft.expires_at, (v) => { + draft.expires_at = v; + }); const delBtn = document.createElement('button'); delBtn.className = 'smd-row-action'; @@ -741,158 +941,14 @@ const shareModal = { this._refreshLinks(); }); - actions.appendChild(delBtn); - row.appendChild(icon); - row.appendChild(info); - row.appendChild(actions); + row.appendChild(name); + row.appendChild(pending); + row.appendChild(pwChip); + row.appendChild(expChip); + row.appendChild(delBtn); 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 ────────────────────────────────────────────────────────────────── /** @@ -911,6 +967,8 @@ const shareModal = { try { // ── Grants ───────────────────────────────────────────────────────── for (const m of this._localMembers) { + // Convert YYYY-MM-DD from date input to ISO-8601 datetime (midnight UTC). + const expiresIso = m.expires_at ? new Date(`${m.expires_at}T00:00:00Z`).toISOString() : null; if (m._op === 'remove') { // Revoke every individual grant for this subject (one per permission). for (const g of m._grants) { @@ -920,13 +978,15 @@ const shareModal = { await grants.updateRole({ subject: { type: m.grant.subject.type, id: m.grant.subject.id }, resource: { type: itemType, id: item.id }, - role: m.role + role: m.role, + expires_at: expiresIso }); } 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 + role: m.role, + expires_at: expiresIso }); } } diff --git a/static/js/core/types.js b/static/js/core/types.js index c30b6b44..06c84168 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -284,11 +284,12 @@ /** * @typedef {Object} Grant * @property {string} id - * @property {number} granted_at + * @property {string} granted_at - ISO-8601 datetime string. * @property {string} granted_by * @property {Subject} subject * @property {PermissionTypeEnum} permission * @property {Resource} resource + * @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry. */ /** @@ -405,6 +406,7 @@ * @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1). * @property {ShareRoleEnum} role - Derived role label shown in the UI. * @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation. + * @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry. */ /** From 63722c868ea555842ba27b882959d1fde43fdc09 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 02:16:15 +0200 Subject: [PATCH 02/12] feat(myshares): new version of myshares sections, supporting grants Users + Tokens --- src/application/dtos/grant_dto.rs | 49 ++ src/application/ports/authorization_ports.rs | 18 +- src/domain/services/authorization.rs | 39 +- src/infrastructure/services/pg_acl_engine.rs | 620 ++++++++++++++++++- src/interfaces/api/handlers/grant_handler.rs | 197 +++++- src/interfaces/api/routes.rs | 1 + static/css/components/linkChip.css | 46 ++ static/css/main.css | 1 + static/css/views/mySharesView.css | 334 ++++++++++ static/index.html | 1 + static/js/app/main.js | 32 +- static/js/app/navigation.js | 34 +- static/js/app/ui.js | 2 +- static/js/components/linkChip.js | 53 ++ static/js/components/mySharesList.js | 600 ++++++++++++++++++ static/js/components/resourceIcon.js | 61 ++ static/js/components/resourceList.js | 28 +- static/js/components/shareModal.js | 169 +---- static/js/core/formatters.js | 43 +- static/js/core/icons.js | 4 + static/js/core/types.js | 35 +- static/js/features/sharing/fileSharing.js | 14 + static/js/model/grants.js | 28 +- static/js/utils/expiryChip.js | 93 +++ static/js/utils/passwordChip.js | 88 +++ static/js/views/myShares/mySharesView.js | 235 +++++++ static/locales/ar.json | 6 +- static/locales/de.json | 6 +- static/locales/en.json | 2 + static/locales/es.json | 6 +- static/locales/fa.json | 6 +- static/locales/fr.json | 6 +- static/locales/hi.json | 6 +- static/locales/it.json | 6 +- static/locales/ja.json | 6 +- static/locales/ko.json | 6 +- static/locales/nl.json | 6 +- static/locales/pl.json | 6 +- static/locales/pt.json | 6 +- static/locales/ru.json | 6 +- static/locales/zh-TW.json | 6 +- static/locales/zh.json | 6 +- static/sw.js | 2 +- 43 files changed, 2676 insertions(+), 243 deletions(-) create mode 100644 static/css/components/linkChip.css create mode 100644 static/css/views/mySharesView.css create mode 100644 static/js/components/linkChip.js create mode 100644 static/js/components/mySharesList.js create mode 100644 static/js/components/resourceIcon.js create mode 100644 static/js/utils/expiryChip.js create mode 100644 static/js/utils/passwordChip.js create mode 100644 static/js/views/myShares/mySharesView.js diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 2b2697e2..ec0575c7 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -312,5 +312,54 @@ pub struct SharedWithMeItemDto { pub resource: ResourceContentDto, } +/// Derive the closest-matching role label from a set of permissions. +/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`. +pub fn role_from_permissions(perms: &[Permission]) -> &'static str { + if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) { + "admin" + } else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) { + "editor" + } else { + "viewer" + } +} + /// Response for `GET /api/grants/incoming/resources`. pub type SharedWithMeDto = CursorListResponse; + +// ════════════════════════════════════════════════════════════════════════════ +// My-Shares DTOs (GET /api/grants/outgoing/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// One (subject, permissions) entry within an outgoing resource item. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct OutgoingResourceGrantDto { + pub grant_id: Uuid, + /// `"user"` | `"token"` + pub subject_type: String, + pub subject_id: Uuid, + /// Human-readable label (username for users, share name for tokens). + pub subject_display: String, + /// Derived role label: `"viewer"` | `"editor"` | `"admin"`. + pub role: String, + pub granted_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + /// Whether the token has a password set. Always `false` for user subjects. + pub has_password: bool, +} + +/// One item in the my-shares list. +#[derive(Debug, Serialize, ToSchema)] +pub struct OutgoingResourceItemDto { + pub resource_type: ResourceTypeDto, + /// Earliest grant date across all subjects on this resource. + pub first_shared_at: chrono::DateTime, + /// Full resource details. Shape is determined by `resource_type`. + pub resource: ResourceContentDto, + /// One entry per (subject, permissions) pair. + pub grants: Vec, +} + +/// Response for `GET /api/grants/outgoing/resources`. +pub type MySharesDto = CursorListResponse; diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index bd579923..ecd8831c 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::services::authorization::{ - Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, + Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, + ResourceKind, Subject, }; pub trait AuthorizationEngine: Send + Sync + 'static { @@ -97,6 +98,21 @@ pub trait AuthorizationEngine: Send + Sync + 'static { /// `GET /api/grants/outgoing` ("things I've shared with others"). async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError>; + /// Cursor-paginated list of resources that `granted_by` has shared with + /// others. Multiple permission rows for the same (subject, resource) pair + /// are collapsed into one `OutgoingGrantEntry`; multiple subjects on the + /// same resource are grouped into one `OutgoingResourceSummary`. + /// + /// Returns `(summaries, next_cursor)`. + async fn list_outgoing_resources_paged( + &self, + granted_by: Uuid, + limit: u32, + cursor: Option, + sort_by: &str, + reverse: bool, + ) -> Result<(Vec, Option), DomainError>; + /// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE /// constraint; if the row already exists its `expires_at` is updated. async fn grant( diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 51de60b8..b75fb784 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -205,8 +205,7 @@ pub struct Grant { impl Grant { pub fn is_expired(&self) -> bool { - self.expires_at - .is_some_and(|exp| exp < chrono::Utc::now()) + self.expires_at.is_some_and(|exp| exp < chrono::Utc::now()) } } @@ -260,6 +259,42 @@ pub struct IncomingGrantSummary { pub granted_by: Uuid, } +// ════════════════════════════════════════════════════════════════════════════ +// OutgoingGrantEntry / OutgoingResourceSummary — per-subject grant within a +// resource that the current user shared with others +// ════════════════════════════════════════════════════════════════════════════ + +/// One (subject, permissions) pair within an outgoing resource summary. +/// The `subject_display` field is resolved by the SQL layer: username for +/// `user` subjects, share item_name for `token` subjects. +#[derive(Debug, Clone)] +pub struct OutgoingGrantEntry { + pub grant_id: Uuid, + pub subject_type: String, + pub subject_id: Uuid, + /// Human-readable label: username (users) or share name (tokens). + pub subject_display: String, + /// All permissions held by this subject on the resource (aggregated). + pub permissions: Vec, + pub granted_at: chrono::DateTime, + pub expires_at: Option>, + /// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`). + /// Always `false` for `user` subjects. + pub has_password: bool, +} + +/// All subjects that the current user has shared a single resource with, +/// together with the resource type, id, and when it was first shared. +#[derive(Debug, Clone)] +pub struct OutgoingResourceSummary { + pub resource_type: ResourceKind, + pub resource_id: Uuid, + /// Earliest `granted_at` across all grants on this resource. + pub first_shared_at: chrono::DateTime, + /// One entry per (subject, permissions) pair. + pub grants: Vec, +} + // ════════════════════════════════════════════════════════════════════════════ // GrantCursor — opaque pagination cursor for list_incoming_resources_paged // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index e98c05b5..afa9532b 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -36,7 +36,8 @@ use sqlx::PgPool; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; use crate::domain::services::authorization::{ - Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, + Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary, + Permission, Resource, ResourceKind, Subject, }; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; @@ -664,6 +665,619 @@ impl AuthorizationEngine for PgAclEngine { rows.into_iter().map(Self::row_to_grant).collect() } + async fn list_outgoing_resources_paged( + &self, + granted_by: Uuid, + limit: u32, + cursor: Option, + sort_by: &str, + reverse: bool, + ) -> Result<(Vec, Option), DomainError> { + let fetch_limit = (limit as i64) + 1; + + // Row shape — one row per (resource, subject, permission). + // Columns: + // 0 resource_type String + // 1 resource_id Uuid + // 2 first_shared_at DateTime — MIN(granted_at) across resource + // 3 subject_type String + // 4 subject_id Uuid + // 5 subject_display String — username or share item_name + // 6 grant_id Uuid + // 7 granted_at DateTime — this (subject, perm) row + // 8 expires_at Option> + // 9 permission String + // 10 sort_str Option + // 11 sort_int Option + // 12 has_password bool — token: shares.password_hash IS NOT NULL + type Row = ( + String, + Uuid, + chrono::DateTime, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + Option>, + String, + Option, + Option, + bool, + ); + + let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); + let cursor_int = cursor.as_ref().and_then(|c| c.sort_int); + let cursor_at = cursor.as_ref().map(|c| c.granted_at); + let cursor_id = cursor.as_ref().map(|c| c.resource_id); + + // ── Resource-page CTE (one row per resource, cursor-paginated) ───────── + // We page on resources (by first_shared_at + resource_id) so that the + // limit/cursor semantics are consistent with the incoming endpoint. + // All grants for each paged resource are then retrieved in the same query. + // + // $1 = granted_by + // $2 = cursor_str (resource_name for name/type, owner_name for granted_by) + // $3 = cursor_int (category_order for type, size for size) + // $4 = cursor_at (first_shared_at) + // $5 = cursor_id (resource_id) + // $6 = fetch_limit + let sql = match sort_by { + "name" | "type" => { + let sort_int_expr = if sort_by == "type" { + "CASE WHEN ag.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END" + } else { + "NULL::bigint" + }; + let (page_where, page_order) = if sort_by == "type" { + if reverse { + ( + r#"( $3::integer IS NULL + OR sort_int < $3 + OR (sort_int = $3 AND LOWER(sort_str) < $2) + OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#, + "sort_int DESC, LOWER(sort_str) DESC, resource_id DESC", + ) + } else { + ( + r#"( $3::integer IS NULL + OR sort_int > $3 + OR (sort_int = $3 AND LOWER(sort_str) > $2) + OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#, + "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC", + ) + } + } else if reverse { + ( + r#"( $2::text IS NULL + OR LOWER(sort_str) < $2 + OR (LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#, + "LOWER(sort_str) DESC, resource_id DESC", + ) + } else { + ( + r#"( $2::text IS NULL + OR LOWER(sort_str) > $2 + OR (LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#, + "LOWER(sort_str) ASC, resource_id ASC", + ) + }; + format!( + r#"WITH resource_page AS ( + SELECT ag.resource_type, ag.resource_id, MIN(ag.granted_at) AS first_shared_at, + COALESCE( + CASE WHEN ag.resource_type = 'folder' THEN f.name END, + CASE WHEN ag.resource_type = 'file' THEN fi.name END + ) AS sort_str, + {sort_int_expr} AS sort_int + FROM storage.access_grants ag + LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder' + LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file' + WHERE ag.granted_by = $1 + GROUP BY ag.resource_type, ag.resource_id, f.name, fi.name, fi.category_order + ), + rp AS ( + SELECT * FROM resource_page + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, + ag.subject_type, ag.subject_id, + COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, + ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, + rp.sort_str, rp.sort_int, + (sh.password_hash IS NOT NULL) AS has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id + AND ag.granted_by = $1 + LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + ORDER BY {page_order}, ag.subject_id, ag.granted_at"# + ) + } + "subject" => { + // Page on (subject_type_order, subject_display, resource_id) triples so + // every swimlane is always contiguous across cursor pages. + // + // subject_type_order: 0 = user, 1 = token without password, 2 = token with password + // + // Cursor encodes: sort_int = subject_type_order, resource_name = LOWER(subject_display), + // resource_id = last resource_id. + let (page_where, page_order) = if reverse { + ( + r#"( $3::bigint IS NULL + OR sort_int < $3 + OR (sort_int = $3 AND LOWER(subject_display) < $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#, + "sort_int DESC, LOWER(subject_display) DESC, resource_id DESC", + ) + } else { + ( + r#"( $3::bigint IS NULL + OR sort_int > $3 + OR (sort_int = $3 AND LOWER(subject_display) > $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#, + "sort_int ASC, LOWER(subject_display) ASC, resource_id ASC", + ) + }; + format!( + r#"WITH pairs AS ( + SELECT + ag.resource_type, + ag.resource_id, + ag.subject_type, + ag.subject_id, + MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display, + BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, + MAX(CASE + WHEN ag.subject_type = 'user' THEN 0 + WHEN ag.subject_type = 'token' AND sh.password_hash IS NULL THEN 1 + ELSE 2 + END)::bigint AS sort_int, + MIN(ag.granted_at) AS first_granted_at + FROM storage.access_grants ag + LEFT JOIN auth.users u + ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh + ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi + ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld + ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + WHERE ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id + ), + rp AS ( + SELECT * FROM pairs + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT + ag.resource_type, + ag.resource_id, + rp.first_granted_at AS first_shared_at, + ag.subject_type, + ag.subject_id, + rp.subject_display, + ag.id AS grant_id, + ag.granted_at, + ag.expires_at, + ag.permission, + LOWER(rp.subject_display) AS sort_str, + rp.sort_int, + rp.has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type + AND ag.resource_id = rp.resource_id + AND ag.subject_type = rp.subject_type + AND ag.subject_id = rp.subject_id + AND ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + ORDER BY {page_order}"# + ) + } + "role" => { + // Page on (role_order, subject_display, resource_id) triples so that all + // of one person's grants within a role are contiguous — enabling aggregation + // ("Bob on Folder A, Folder B") to work correctly across cursor pages. + // role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer + // Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id + let (page_where, page_order) = if reverse { + ( + r#"( $3::bigint IS NULL + OR sort_int < $3 + OR (sort_int = $3 AND LOWER(subject_display) < $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#, + "sort_int DESC, LOWER(subject_display) DESC, resource_id DESC", + ) + } else { + ( + r#"( $3::bigint IS NULL + OR sort_int > $3 + OR (sort_int = $3 AND LOWER(subject_display) > $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#, + "sort_int ASC, LOWER(subject_display) ASC, resource_id ASC", + ) + }; + format!( + r#"WITH pairs AS ( + SELECT + ag.resource_type, + ag.resource_id, + ag.subject_type, + ag.subject_id, + MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display, + BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, + CASE + WHEN BOOL_OR(ag.permission = 'delete') + AND BOOL_OR(ag.permission = 'share') THEN 0 + WHEN BOOL_OR(ag.permission = 'create') + OR BOOL_OR(ag.permission = 'update') THEN 1 + ELSE 2 + END::bigint AS sort_int, + MIN(ag.granted_at) AS first_granted_at + FROM storage.access_grants ag + LEFT JOIN auth.users u + ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh + ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi + ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld + ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + WHERE ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id + ), + rp AS ( + SELECT * FROM pairs + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT + ag.resource_type, + ag.resource_id, + rp.first_granted_at AS first_shared_at, + ag.subject_type, + ag.subject_id, + rp.subject_display, + ag.id AS grant_id, + ag.granted_at, + ag.expires_at, + ag.permission, + LOWER(rp.subject_display) AS sort_str, + rp.sort_int, + rp.has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type + AND ag.resource_id = rp.resource_id + AND ag.subject_type = rp.subject_type + AND ag.subject_id = rp.subject_id + AND ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + ORDER BY {page_order}"# + ) + } + _ => { + // Default: sort by first_shared_at DESC (newest resource shared first). + let (page_where, page_order) = if reverse { + ( + r#"( $4::timestamptz IS NULL + OR first_shared_at > $4 + OR (first_shared_at = $4 AND resource_id > $5::uuid))"#, + "first_shared_at ASC, resource_id ASC", + ) + } else { + ( + r#"( $4::timestamptz IS NULL + OR first_shared_at < $4 + OR (first_shared_at = $4 AND resource_id < $5::uuid))"#, + "first_shared_at DESC, resource_id DESC", + ) + }; + format!( + r#"WITH resource_page AS ( + SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at, + NULL::text AS sort_str, + NULL::bigint AS sort_int + FROM storage.access_grants + WHERE granted_by = $1 + GROUP BY resource_type, resource_id + ), + rp AS ( + SELECT * FROM resource_page + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, + ag.subject_type, ag.subject_id, + COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, + ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, + NULL::text AS sort_str, NULL::bigint AS sort_int, + (sh.password_hash IS NOT NULL) AS has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id + AND ag.granted_by = $1 + LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + ORDER BY {page_order}, ag.subject_id, ag.granted_at"# + ) + } + }; + + let rows: Vec = sqlx::query_as::<_, Row>(&sql) + .bind(granted_by) // $1 + .bind(&cursor_str) // $2 sort_str cursor + .bind(cursor_int) // $3 sort_int cursor + .bind(cursor_at) // $4 first_shared_at cursor + .bind(cursor_id) // $5 resource_id cursor + .bind(fetch_limit) // $6 + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error( + "PgAcl", + format!("list_outgoing_resources_paged ({sort_by}): {e}"), + ) + })?; + + // ── Subject / Role sorts: page on (resource_id, subject_id) pairs ─────── + // Each pair becomes one OutgoingResourceSummary with exactly one grant, + // preserving the SQL-ordered swimlane sequence across cursor pages. + if matches!(sort_by, "subject" | "role") { + let mut seen_pairs: Vec<(Uuid, Uuid)> = Vec::new(); + let mut seen_pair_set: std::collections::HashSet<(Uuid, Uuid)> = + std::collections::HashSet::new(); + for r in &rows { + if seen_pair_set.insert((r.1, r.4)) { + seen_pairs.push((r.1, r.4)); + } + } + let has_next = seen_pairs.len() > limit as usize; + seen_pairs.truncate(limit as usize); + let keep: std::collections::HashSet<(Uuid, Uuid)> = + seen_pairs.iter().copied().collect(); + + let last_row = rows.iter().rfind(|r| keep.contains(&(r.1, r.4))); + let next_cursor = if has_next { + last_row.map(|r| { + let resource_name = r.10.clone(); // LOWER(subject_display) for both subject and role sort + GrantCursor { + sort_by: sort_by.to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name, + sort_int: r.11, + reverse, + } + }) + } else { + None + }; + + // Group rows: (resource_id, subject_id) → OutgoingGrantEntry. + let mut entry_map: std::collections::HashMap< + (Uuid, Uuid), + (ResourceKind, OutgoingGrantEntry), + > = std::collections::HashMap::new(); + for r in rows.into_iter().filter(|r| keep.contains(&(r.1, r.4))) { + let ( + rt_str, + resource_id, + _first_shared_at, + subj_type, + subj_id, + subj_display, + grant_id, + granted_at, + expires_at, + perm_str, + _, + _, + has_password, + ) = r; + let Some(resource_type) = ResourceKind::parse(&rt_str) else { + continue; + }; + let Some(perm) = Permission::parse(&perm_str) else { + continue; + }; + let key = (resource_id, subj_id); + let (_, entry) = entry_map.entry(key).or_insert_with(|| { + ( + resource_type, + OutgoingGrantEntry { + grant_id, + subject_type: subj_type.clone(), + subject_id: subj_id, + subject_display: subj_display.clone(), + permissions: Vec::new(), + granted_at, + expires_at, + has_password, + }, + ) + }); + if !entry.permissions.contains(&perm) { + entry.permissions.push(perm); + } + } + + let summaries: Vec = seen_pairs + .into_iter() + .filter_map(|(rid, sid)| { + let (resource_type, grant) = entry_map.remove(&(rid, sid))?; + Some(OutgoingResourceSummary { + resource_type, + resource_id: rid, + first_shared_at: grant.granted_at, + grants: vec![grant], + }) + }) + .collect(); + + return Ok((summaries, next_cursor)); + } + + // ── All other sorts: page on distinct resource_ids ──────────────────── + let mut seen_resources: Vec = Vec::new(); + let mut seen_set: std::collections::HashSet = std::collections::HashSet::new(); + for r in &rows { + if seen_set.insert(r.1) { + seen_resources.push(r.1); + } + } + + let has_next = seen_resources.len() > limit as usize; + seen_resources.truncate(limit as usize); + let keep: std::collections::HashSet = seen_resources.iter().copied().collect(); + + let last_row = rows.iter().rfind(|r| keep.contains(&r.1)); + let next_cursor = if has_next { + last_row.map(|r| { + let sort_str_lc = r.10.as_deref().map(str::to_lowercase); + match sort_by { + "name" => GrantCursor { + sort_by: "name".to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: None, + reverse, + }, + "type" => GrantCursor { + sort_by: "type".to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: r.11, + reverse, + }, + _ => GrantCursor { + sort_by: "first_shared_at".to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name: None, + sort_int: None, + reverse, + }, + } + }) + } else { + None + }; + + // Group flat rows by resource_id → (ResourceKind, first_shared_at, subjects). + type ResourceEntry = ( + ResourceKind, + chrono::DateTime, + std::collections::HashMap, + ); + let mut resource_map: std::collections::HashMap = + std::collections::HashMap::new(); + + for r in rows.into_iter().filter(|r| keep.contains(&r.1)) { + let ( + rt_str, + resource_id, + first_shared_at, + subj_type, + subj_id, + subj_display, + grant_id, + granted_at, + expires_at, + perm_str, + _, + _, + has_password, + ) = r; + let Some(resource_type) = ResourceKind::parse(&rt_str) else { + continue; + }; + let Some(perm) = Permission::parse(&perm_str) else { + continue; + }; + + let (_, _, subj_map) = resource_map.entry(resource_id).or_insert_with(|| { + ( + resource_type, + first_shared_at, + std::collections::HashMap::new(), + ) + }); + let entry = subj_map + .entry(subj_id) + .or_insert_with(|| OutgoingGrantEntry { + grant_id, + subject_type: subj_type.clone(), + subject_id: subj_id, + subject_display: subj_display.clone(), + permissions: Vec::new(), + granted_at, + expires_at, + has_password, + }); + if !entry.permissions.contains(&perm) { + entry.permissions.push(perm); + } + } + + let summaries: Vec = seen_resources + .into_iter() + .filter_map(|rid| { + let (resource_type, first_shared_at, subj_map) = resource_map.remove(&rid)?; + let mut grants: Vec = subj_map.into_values().collect(); + let role_rank = |perms: &[Permission]| -> u8 { + if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) { + 0 // admin → Can manage + } else if perms.contains(&Permission::Create) + || perms.contains(&Permission::Update) + { + 1 // editor → Can edit + } else { + 2 // viewer → Can view + } + }; + grants.sort_by(|a, b| { + role_rank(&a.permissions) + .cmp(&role_rank(&b.permissions)) + .then_with(|| { + // users before tokens + let type_rank = |st: &str| if st == "user" { 0u8 } else { 1 }; + type_rank(&a.subject_type).cmp(&type_rank(&b.subject_type)) + }) + .then_with(|| { + a.subject_display + .to_lowercase() + .cmp(&b.subject_display.to_lowercase()) + }) + }); + Some(OutgoingResourceSummary { + resource_type, + resource_id: rid, + first_shared_at, + grants, + }) + }) + .collect(); + + Ok((summaries, next_cursor)) + } + async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError> { let rows = sqlx::query_as::< _, @@ -776,7 +1390,9 @@ impl AuthorizationEngine for PgAclEngine { .bind(resource.id()) .execute(self.pool.as_ref()) .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")))?; + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")) + })?; Ok(()) } diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 17b04b0d..eac659e3 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -20,8 +20,9 @@ use uuid::Uuid; use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, - SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, + CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto, + PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, + SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; @@ -31,7 +32,8 @@ use crate::common::di::AppState; use crate::common::errors::DomainError; use crate::domain::errors::ErrorKind; use crate::domain::services::authorization::{ - GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, + GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind, + Subject, }; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -98,7 +100,10 @@ pub async fn create_grant( let mut results: Vec = Vec::with_capacity(permissions.len()); for perm in permissions { - match authz.grant(caller_id, subject, perm, resource, expires_at).await { + match authz + .grant(caller_id, subject, perm, resource, expires_at) + .await + { Ok(grant) => results.push(grant.into()), Err(err) => { error!("grant insert failed for {perm:?}: {err}"); @@ -227,7 +232,10 @@ pub async fn set_role( } } for perm in &to_add { - if let Err(e) = authz.grant(caller_id, subject, *perm, resource, expires_at).await { + if let Err(e) = authz + .grant(caller_id, subject, *perm, resource, expires_at) + .await + { return AppError::from(e).into_response(); } } @@ -236,7 +244,10 @@ pub async fn set_role( // includes newly added ones and any that were already present (retained). // Callers that omit expires_at will clear any existing expiry; this is // intentional: it keeps all permission rows for the pair consistent. - if let Err(e) = authz.set_expiry_on_resource(subject, resource, expires_at).await { + if let Err(e) = authz + .set_expiry_on_resource(subject, resource, expires_at) + .await + { return AppError::from(e).into_response(); } @@ -560,6 +571,180 @@ pub async fn list_on_resource( } } +// ════════════════════════════════════════════════════════════════════════════ +// GET /api/grants/outgoing/resources +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + get, + path = "/api/grants/outgoing/resources", + params(SharedWithMeQuery), + responses( + (status = 200, + description = "Cursor-paginated resources the caller has shared with others. \ + Each item carries the full resource details plus all subjects \ + (users and tokens) the resource was shared with. \ + `next_cursor` is absent on the last page.", + body = MySharesDto), + ), + security(("bearerAuth" = [])), + tag = "grants" +)] +pub async fn list_my_shares( + State(state): State, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let caller_id = auth_user.id; + + let limit = q.limit_clamped() as u32; + + let sort_by = q.sort_by.as_deref().unwrap_or("first_shared_at"); + if !matches!( + sort_by, + "first_shared_at" | "name" | "type" | "subject" | "role" + ) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid sort_by; valid values: first_shared_at, name, type, subject, role"})), + ) + .into_response(); + } + + let reverse = q.reverse; + + let cursor = q + .decode_cursor::() + .filter(|c| c.sort_by == sort_by && c.reverse == reverse); + + let (summaries, next_cursor) = match state + .authorization + .list_outgoing_resources_paged(caller_id, limit, cursor, sort_by, reverse) + .await + { + Ok(r) => r, + Err(e) => return AppError::from(e).into_response(), + }; + + let file_service = &state.applications.file_retrieval_service; + let folder_service = &state.applications.folder_service_concrete; + + // Split summaries by resource kind for parallel resolution. + let file_summaries: Vec<&OutgoingResourceSummary> = summaries + .iter() + .filter(|s| matches!(s.resource_type, ResourceKind::File)) + .collect(); + let folder_summaries: Vec<&OutgoingResourceSummary> = summaries + .iter() + .filter(|s| matches!(s.resource_type, ResourceKind::Folder)) + .collect(); + + let file_ids: Vec = file_summaries + .iter() + .map(|s| s.resource_id.to_string()) + .collect(); + let folder_ids: Vec = folder_summaries + .iter() + .map(|s| s.resource_id.to_string()) + .collect(); + + let (file_results, folder_results) = tokio::join!( + join_all(file_ids.iter().map(|id| file_service.get_file(id))), + join_all(folder_ids.iter().map(|id| folder_service.get_folder(id))) + ); + + let mut file_idx = 0usize; + let mut folder_idx = 0usize; + let mut items: Vec = Vec::with_capacity(summaries.len()); + + for summary in &summaries { + let grants: Vec = summary + .grants + .iter() + .map(|g| OutgoingResourceGrantDto { + grant_id: g.grant_id, + subject_type: g.subject_type.clone(), + subject_id: g.subject_id, + subject_display: g.subject_display.clone(), + role: role_from_permissions(&g.permissions).to_owned(), + granted_at: g.granted_at, + expires_at: g.expires_at, + has_password: g.has_password, + }) + .collect(); + + match summary.resource_type { + ResourceKind::File => { + let result = &file_results[file_idx]; + file_idx += 1; + match result { + Ok(file_dto) => { + items.push(OutgoingResourceItemDto { + resource_type: ResourceTypeDto::File, + first_shared_at: summary.first_shared_at, + resource: ResourceContentDto::File( + file_dto.clone().without_hierarchy_info(), + ), + grants, + }); + } + Err(e) if e.kind == ErrorKind::NotFound => { + warn!( + "Skipping stale outgoing file grant for resource_id={}: not found", + summary.resource_id + ); + } + Err(e) => { + return AppError::internal_error(format!( + "Failed to fetch file {}: {e}", + summary.resource_id + )) + .into_response(); + } + } + } + ResourceKind::Folder => { + let result = &folder_results[folder_idx]; + folder_idx += 1; + match result { + Ok(folder_dto) => { + items.push(OutgoingResourceItemDto { + resource_type: ResourceTypeDto::Folder, + first_shared_at: summary.first_shared_at, + resource: ResourceContentDto::Folder( + folder_dto.clone().without_hierarchy_info(), + ), + grants, + }); + } + Err(e) if e.kind == ErrorKind::NotFound => { + warn!( + "Skipping stale outgoing folder grant for resource_id={}: not found", + summary.resource_id + ); + } + Err(e) => { + return AppError::internal_error(format!( + "Failed to fetch folder {}: {e}", + summary.resource_id + )) + .into_response(); + } + } + } + } + } + + ( + StatusCode::OK, + Json(MySharesDto::with_cursor( + items, + next_cursor.map(|c| c.encode()), + )), + ) + .into_response() +} + // Silence unused-import warnings for SubjectDto when only certain endpoints // touch it directly. #[allow(dead_code)] diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 82186958..da26f78d 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { get(grant_handler::list_shared_with_me), ) .route("/outgoing", get(grant_handler::list_outgoing)) + .route("/outgoing/resources", get(grant_handler::list_my_shares)) .with_state(app_state.clone()) }; diff --git a/static/css/components/linkChip.css b/static/css/components/linkChip.css new file mode 100644 index 00000000..71320fd7 --- /dev/null +++ b/static/css/components/linkChip.css @@ -0,0 +1,46 @@ +/* ── Link chip ─────────────────────────────────────────────────────────────── + * + * Inline clickable element representing a share link. + * Usage: buildLinkChip(grant) → HTMLButtonElement with class .link-chip + * ─────────────────────────────────────────────────────────────────────────── */ + +.link-chip { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 2px 0; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + font-size: 13px; + text-align: left; + transition: color 0.12s; +} + +.link-chip:hover { + color: var(--color-accent); +} + +.link-chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.link-chip__icon { + font-size: 12px; + color: var(--color-text-faint); + flex-shrink: 0; + transition: color 0.12s; +} + +.link-chip:hover .link-chip__icon { + color: var(--color-accent); +} + +.link-chip__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/static/css/main.css b/static/css/main.css index 158ed1ee..44c2bbcb 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -21,6 +21,7 @@ @import url("./components/shareDialog.css"); @import url("./components/shareModal.css"); @import url("./components/userVignette.css"); +@import url("./components/linkChip.css"); @import url("./components/uploadDropdown.css"); @import url("./components/notifications.css"); @import url("./components/userMenu.css"); diff --git a/static/css/views/mySharesView.css b/static/css/views/mySharesView.css new file mode 100644 index 00000000..95768af9 --- /dev/null +++ b/static/css/views/mySharesView.css @@ -0,0 +1,334 @@ +/* ── My Shares view ───────────────────────────────────────────────────────── + * + * BEM classes for the row-per-grant MySharesList component. + * All colors via var(--*). Mobile-first layout. + * ─────────────────────────────────────────────────────────────────────────── */ + +/* ── Load-more wrapper ───────────────────────────────────────────────────── */ + +.ms-load-more-wrapper { + display: flex; + justify-content: center; + padding: 16px 0 8px; +} + +.ms-load-more-wrapper.hidden { + display: none; +} + +/* ── Container: dissolve when lanes are present ──────────────────────────── */ + +.files-list-view:has(.ms-lane) { + background-color: transparent; + box-shadow: none; + border-radius: 0; + overflow: visible; + gap: 10px; + display: flex; + flex-direction: column; +} + +/* ── Lane (swimlane card) ────────────────────────────────────────────────── */ + +.ms-lane { + background-color: var(--color-item); + border-radius: 10px; + box-shadow: 0 1px 3px var(--color-shadow-xs); + overflow: hidden; +} + +.ms-lane__header { + background: var(--color-bg-muted); + border-bottom: 1px solid var(--color-border-faint); +} + +/* Vignette lane headers need the same padding as the resource row */ +.ms-lane__header .user-vignette { + padding: 10px 14px; + font-weight: 600; +} + +.ms-lane__header .user-vignette .user-vignette__name { + font-size: 13px; + color: var(--color-text-heading); + font-weight: 600; +} + +.ms-lane__body { + /* rows are direct children */ +} + +/* ── Resource lane header (items mode) ───────────────────────────────────── */ + +.ms-resource-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; +} + +/* Size the shared .file-icon component for inline use in myShares rows */ +.ms-resource-row .file-icon { + width: 36px; + height: 36px; + border-radius: 6px; + font-size: 14px; + flex-shrink: 0; +} + +/* Smaller icon for child grant rows and inline resource links */ +.ms-grant-row__identity .file-icon, +.ms-link-identity__resource .file-icon { + width: 24px; + height: 24px; + border-radius: 4px; + font-size: 11px; + flex-shrink: 0; +} + +.ms-resource-row__name { + flex: 1; + font-weight: 600; + color: var(--color-text); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-resource-row__name:hover { + color: var(--color-accent); + text-decoration: underline; +} + +.ms-resource-row__edit { + flex-shrink: 0; + font-size: 12px; + padding: 3px 8px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.ms-resource-row__edit:hover { + opacity: 1; +} + +/* ── Subject lane header (sharedWith mode — link bucket) ─────────────────── */ + +.ms-link-lane-label { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.ms-link-lane-label__icon { + color: var(--color-text-faint); + font-size: 12px; +} + +/* ── Grant row ───────────────────────────────────────────────────────────── */ + +.ms-grant-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px 7px 28px; + border-bottom: 1px solid var(--color-border-faint); + transition: background 0.1s; +} + +.ms-grant-row:last-child { + border-bottom: none; +} + +.ms-grant-row:hover { + background: var(--color-bg-hover); +} + +.ms-grant-row--expired { + opacity: 0.6; +} + +/* ── Grant row — identity ─────────────────────────────────────────────────── */ + +.ms-grant-row__identity { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.ms-identity__name { + font-size: 13px; + font-weight: 500; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-identity__resource-name { + font-size: 13px; + font-weight: 500; + color: var(--color-text); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-identity__resource-name:hover { + color: var(--color-accent); + text-decoration: underline; +} + +/* Token in sharedWith mode: arrow + resource link */ + +.ms-link-identity__arrow { + font-size: 11px; + color: var(--color-text-faint); + flex-shrink: 0; +} + +.ms-link-identity__resource { + font-size: 12px; + color: var(--color-text-secondary); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-link-identity__resource:hover { + color: var(--color-accent); + text-decoration: underline; +} + +/* ── Role pill ───────────────────────────────────────────────────────────── */ + +.ms-role-pill { + display: inline-block; + flex-shrink: 0; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.ms-role-pill--manage { + background: var(--color-badge-orange-bg); + color: var(--color-badge-orange-text); +} + +.ms-role-pill--edit { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.ms-role-pill--view { + background: var(--color-bg-muted); + color: var(--color-text-muted); +} + +/* ── Expiry chip ─────────────────────────────────────────────────────────── */ + +.ms-expiry-chip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 4px; + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + white-space: nowrap; +} + +.ms-expiry-chip--never { + background: var(--color-bg-muted); + color: var(--color-text-faint); +} + +.ms-expiry-chip--active { + background: var(--color-bg-muted); + color: var(--color-text-muted); +} + +.ms-expiry-chip--soon { + background: var(--color-badge-amber-bg); + color: var(--color-badge-amber-text); +} + +.ms-expiry-chip--expired { + background: var(--color-danger-lighter); + color: var(--color-danger-text-alt); +} + +/* ── Kebab / icon buttons ────────────────────────────────────────────────── */ + +.ms-btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: 11px; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} + +.ms-btn-icon:hover { + background: var(--color-bg-hover); + color: var(--color-text); +} + +.ms-btn-icon:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ms-kebab-btn { + margin-left: auto; +} + +/* ── Context menu current-item indicator ─────────────────────────────────── */ + +.ms-menu-item--current { + font-weight: 600; +} + +/* ── Context menu expiry row ─────────────────────────────────────────────── */ + +.ms-menu-expiry-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; +} + +.ms-menu-expiry-label { + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +/* Stretch the chip to fill remaining space in the expiry row */ +.ms-menu-expiry-row .smd-expiry-chip-wrap { + flex: 1; + min-width: 0; +} diff --git a/static/index.html b/static/index.html index 129ae26e..204d0884 100644 --- a/static/index.html +++ b/static/index.html @@ -15,6 +15,7 @@ + diff --git a/static/js/app/main.js b/static/js/app/main.js index cfc21802..e991c638 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -162,12 +162,30 @@ const ACTIONS_BAR_TEMPLATES = {
${_batchToolbarButons} ${_toggleButtons} + `, + shared: ` +
+
+ + +
` }; /** * - * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'hidden'} mode + * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'shared' | 'hidden'} mode * @param {boolean} [force=false] * @returns */ @@ -259,8 +277,12 @@ function syncGroupByMenu(defs = []) { // Rebuild menu options — call i18n.t() directly so each label is resolved // at call time (translations are loaded by the time any section switch runs). - menu.innerHTML = ``; + // A def with key='' lets the section override the default "None" label. + const noneOverride = defs.find((d) => d.key === ''); + const noneLabel = noneOverride ? noneOverride.label : i18n.t('groupby.none', 'None'); + menu.innerHTML = ``; for (const def of defs) { + if (def.key === '') continue; menu.insertAdjacentHTML('beforeend', ``); } @@ -640,8 +662,10 @@ function setupEventListeners() { } else { // change is from history, data provided in event switchSectionTo(e.state.section); - app.currentPath = e.state.id; - loadFiles({ insertHistory: false }); + if (e.state.section === 'files') { + app.currentPath = e.state.id; + loadFiles({ insertHistory: false }); + } } }); diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 0274f44a..c58cca24 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -11,6 +11,7 @@ import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; +import { mySharesView } from '../views/myShares/mySharesView.js'; import { recentView } from '../views/recent/recentView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; @@ -165,9 +166,10 @@ function setCurrentSection(section) { appElements.pageTitle.setAttribute('data-i18n', titleKey); } - // Hide sharedView when switching to any other section - if (section !== 'shared' && sharedView) { - sharedView.hide(); + // Hide sharedView and mySharesView when switching to any other section + if (section !== 'shared') { + if (sharedView) sharedView.hide(); + mySharesView.hide(); } // Hide "Load more" button when leaving the sharedwithme section @@ -208,21 +210,27 @@ function switchToSharedSection() { const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.add('hidden'); - // Hide actions-bar for shared view - setActionsBarMode('hidden'); + // Show actions-bar with group-by controls only (no grid/list toggle — + // MySharesList is always in list mode). + setActionsBarMode('shared'); - //reset files view + remove any error - ui.resetFilesList(); + // Populate the group-by dropdown with this section's dimensions. + setGroupByView(mySharesView); + syncGroupByMenu(mySharesView.groupByDefs); - // Hide file containers - toggleFileContainer(false); + // Restore the saved group-by selection in the dropdown. + const msPrefs = viewPrefs.load('shared'); + applyGroupByMenuState(msPrefs.groupBy, msPrefs.reversed); - // Show shared view - sharedView.init().then(() => { - sharedView.show(); - }); + // Show the files container always in list view — grid is not applicable here. + toggleFileContainer(true); + app.currentView = 'list'; + syncViewContainers(); if (batchToolbar) batchToolbar.clear(); + + // Load and render items into the files container + mySharesView.init(); } function switchToSharedWithMeSection() { diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 991280a3..d16854e5 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -882,7 +882,7 @@ const ui = { loadFiles(); return; } - if (app.currentSection === 'sharedwithme') { + if (app.currentSection === 'sharedwithme' || app.currentSection === 'shared') { // Activate Files UI (nav, breadcrumb, actions bar) without // resetting the path — the shared folder becomes the entry point. activateFilesUI(); diff --git a/static/js/components/linkChip.js b/static/js/components/linkChip.js new file mode 100644 index 00000000..1ee863fb --- /dev/null +++ b/static/js/components/linkChip.js @@ -0,0 +1,53 @@ +/** + * linkChip — inline clickable element representing a share link. + * + * Renders: [🔒/🔗] Link - ...{last4 of UUID} - {name} + * Clicking copies the share URL to the clipboard. + */ + +import { i18n } from '../core/i18n.js'; +import { fileSharing } from '../features/sharing/fileSharing.js'; + +/** @import {OutgoingResourceGrant} from '../core/types.js' */ + +/** + * Build an inline link chip. Clicking it copies the share URL. + * @param {OutgoingResourceGrant} grant + * @returns {HTMLButtonElement} + */ +function buildLinkChip(grant) { + const btn = /** @type {HTMLButtonElement} */ (document.createElement('button')); + btn.className = `link-chip${grant.has_password ? ' link-chip--locked' : ''}`; + btn.type = 'button'; + btn.title = i18n.t('share.copyLink', 'Copy link'); + + const icon = document.createElement('i'); + icon.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon'; + btn.appendChild(icon); + + const last4 = grant.subject_id.slice(-4); + const label = `${i18n.t('share.link', 'Link')} - ...${last4} - ${grant.subject_display}`; + + const text = document.createElement('span'); + text.className = 'link-chip__label'; + text.textContent = label; + btn.appendChild(text); + + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + btn.disabled = true; + try { + const share = await fileSharing.getShareById(grant.subject_id); + await fileSharing.copyLinkToClipboard(share.url); + } catch (err) { + console.error('linkChip: copy failed', err); + } finally { + btn.disabled = false; + } + }); + + return btn; +} + +export { buildLinkChip }; diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js new file mode 100644 index 00000000..49a4ae2e --- /dev/null +++ b/static/js/components/mySharesList.js @@ -0,0 +1,600 @@ +/** + * MySharesList — row-per-grant list for the My Shares view. + * + * Both view modes emit one row per grant. Lane headers are emitted on + * grouping-key change — the server guarantees ORDER BY group key first. + * + * Modes: + * 'items' — lane = resource; row identity = subject + * 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource + */ + +import { i18n } from '../core/i18n.js'; +import { fileSharing } from '../features/sharing/fileSharing.js'; +import { grants } from '../model/grants.js'; +import { buildExpiryChip } from '../utils/expiryChip.js'; +import { buildPasswordChip } from '../utils/passwordChip.js'; +import { buildLinkChip } from './linkChip.js'; +import { buildResourceIcon } from './resourceIcon.js'; +import { createUserVignette } from './userVignette.js'; + +/** + * @import {OutgoingResourceItem, OutgoingResourceGrant, FileItem, FolderItem} from '../core/types.js' + * @typedef {'items'|'sharedWith'} ViewMode + * @typedef {'never'|'active'|'soon'|'expired'} ExpiryState + */ + +const SOON_DAYS = 30; + +/** + * @param {string|null|undefined} expiresAt + * @returns {ExpiryState} + */ +function _expiryState(expiresAt) { + if (!expiresAt) return 'never'; + const ms = new Date(expiresAt).getTime() - Date.now(); + if (ms < 0) return 'expired'; + if (ms <= SOON_DAYS * 86_400_000) return 'soon'; + return 'active'; +} + +/** @param {string} role @returns {string} */ +function _roleLabel(role) { + /** @type {Record} */ + const m = { + admin: i18n.t('share.role.canManage', 'Can manage'), + editor: i18n.t('share.role.canEdit', 'Can edit'), + viewer: i18n.t('share.role.canView', 'Can view') + }; + return m[role] ?? role; +} + +/** @param {string} role @returns {'manage'|'edit'|'view'} */ +function _roleMod(role) { + if (role === 'admin') return 'manage'; + if (role === 'editor') return 'edit'; + return 'view'; +} + +class MySharesList { + /** + * @param {HTMLElement} container + * @param {{ + * onResourceOpen: (resource: FileItem|FolderItem, resourceType: string) => void, + * onShareEdit: (resource: FileItem|FolderItem, resourceType: string) => void, + * }} config + */ + constructor(container, config) { + this._container = container; + this._config = config; + /** @type {string|null} */ + this._lastSwimKey = null; + /** @type {HTMLElement|null} */ + this._lastSwimEl = null; + } + + clear() { + this._container.innerHTML = ''; + this._lastSwimKey = null; + this._lastSwimEl = null; + } + + /** + * Full re-render (page 1). + * @param {OutgoingResourceItem[]} items + * @param {ViewMode} viewMode + */ + render(items, viewMode) { + this.clear(); + this._ingest(items, viewMode); + } + + /** + * Cursor append (page 2+). + * @param {OutgoingResourceItem[]} items + * @param {ViewMode} viewMode + */ + append(items, viewMode) { + this._ingest(items, viewMode); + } + + // ── Core ingest ─────────────────────────────────────────────────────────── + + /** + * @param {OutgoingResourceItem[]} items + * @param {ViewMode} viewMode + */ + _ingest(items, viewMode) { + for (const item of items) { + if (viewMode === 'items') { + this._ingestItemsMode(item); + } else { + this._ingestSharedWithMode(item); + } + } + } + + /** + * Items mode — one lane per resource, one grant row per grant. + * @param {OutgoingResourceItem} item + */ + _ingestItemsMode(item) { + const swimKey = `resource:${item.resource.id}`; + const laneBody = this._ensureLane(swimKey, () => this._buildResourceLaneHeader(item)); + for (const grant of item.grants) { + laneBody.appendChild(this._buildGrantRow(grant, item, 'items')); + } + } + + /** + * SharedWith mode — one lane per user or per link bucket, one row per grant. + * @param {OutgoingResourceItem} item + */ + _ingestSharedWithMode(item) { + for (const grant of item.grants) { + let swimKey; + if (grant.subject_type === 'user') { + swimKey = `user:${grant.subject_id}`; + } else if (grant.has_password) { + swimKey = 'links:password'; + } else { + swimKey = 'links:public'; + } + const laneBody = this._ensureLane(swimKey, () => this._buildSubjectLaneHeader(swimKey, grant)); + laneBody.appendChild(this._buildGrantRow(grant, item, 'sharedWith')); + } + } + + // ── Lane management ─────────────────────────────────────────────────────── + + /** + * Return the existing lane body when swimKey matches, else create a new lane. + * @param {string} swimKey + * @param {() => HTMLElement} buildHeader + * @returns {HTMLElement} + */ + _ensureLane(swimKey, buildHeader) { + if (swimKey === this._lastSwimKey && this._lastSwimEl) return this._lastSwimEl; + + const lane = document.createElement('div'); + lane.className = 'ms-lane'; + lane.dataset.swimKey = swimKey; + + const header = document.createElement('div'); + header.className = 'ms-lane__header'; + header.appendChild(buildHeader()); + lane.appendChild(header); + + const body = document.createElement('div'); + body.className = 'ms-lane__body'; + lane.appendChild(body); + + this._container.appendChild(lane); + this._lastSwimKey = swimKey; + this._lastSwimEl = body; + return body; + } + + /** + * Lane header for items mode: resource icon + name link + Edit sharing button. + * @param {OutgoingResourceItem} item + * @returns {HTMLElement} + */ + _buildResourceLaneHeader(item) { + const row = document.createElement('div'); + row.className = 'ms-resource-row'; + + row.appendChild(buildResourceIcon(item.resource, item.resource_type)); + + const nameLink = document.createElement('a'); + nameLink.className = 'ms-resource-row__name'; + nameLink.href = '#'; + nameLink.textContent = item.resource.name; + nameLink.addEventListener('click', (e) => { + e.preventDefault(); + this._config.onResourceOpen(item.resource, item.resource_type); + }); + row.appendChild(nameLink); + + const editBtn = document.createElement('button'); + editBtn.className = 'ms-resource-row__edit button ghost'; + editBtn.innerHTML = ` ${i18n.t('myshares.editSharing', 'Edit sharing')}`; + editBtn.addEventListener('click', () => this._config.onShareEdit(item.resource, item.resource_type)); + row.appendChild(editBtn); + + return row; + } + + /** + * Lane header for sharedWith mode: user vignette or link bucket label. + * @param {string} swimKey + * @param {OutgoingResourceGrant} grant + * @returns {HTMLElement} + */ + _buildSubjectLaneHeader(swimKey, grant) { + if (swimKey.startsWith('user:')) { + return createUserVignette(grant.subject_id, 'list'); + } + const el = document.createElement('div'); + el.className = 'ms-link-lane-label'; + const icon = document.createElement('i'); + if (swimKey === 'links:password') { + icon.className = 'fas fa-lock ms-link-lane-label__icon'; + el.appendChild(icon); + el.appendChild(document.createTextNode(` ${i18n.t('myshares.passwordLinks', 'Password-protected links')}`)); + } else { + icon.className = 'fas fa-link ms-link-lane-label__icon'; + el.appendChild(icon); + el.appendChild(document.createTextNode(` ${i18n.t('myshares.publicLinks', 'Public links')}`)); + } + return el; + } + + // ── Grant row ───────────────────────────────────────────────────────────── + + /** + * One grant row: identity + role pill + expiry chip + ⋯ button. + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {ViewMode} viewMode + * @returns {HTMLElement} + */ + _buildGrantRow(grant, item, viewMode) { + const row = document.createElement('div'); + row.className = 'ms-grant-row'; + if (_expiryState(grant.expires_at ?? null) === 'expired') { + row.classList.add('ms-grant-row--expired'); + } + + row.appendChild(this._buildIdentity(grant, item, viewMode)); + row.appendChild(this._buildRolePill(grant.role)); + row.appendChild(this._buildExpiryChip(grant.expires_at ?? null)); + row.appendChild(this._buildKebabBtn(grant, item, row)); + + return row; + } + + /** + * Identity: user vignette or link icon + name; tokens in sharedWith mode add → resource. + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {ViewMode} viewMode + * @returns {HTMLElement} + */ + _buildIdentity(grant, item, viewMode) { + const el = document.createElement('div'); + el.className = 'ms-grant-row__identity'; + + if (grant.subject_type === 'user' && viewMode === 'sharedWith') { + // Lane header is already the user — show the resource instead + el.appendChild(buildResourceIcon(item.resource, item.resource_type)); + const nameLink = document.createElement('a'); + nameLink.className = 'ms-identity__resource-name'; + nameLink.href = '#'; + nameLink.textContent = item.resource.name; + nameLink.addEventListener('click', (e) => { + e.preventDefault(); + this._config.onResourceOpen(item.resource, item.resource_type); + }); + el.appendChild(nameLink); + } else if (grant.subject_type === 'user') { + el.appendChild(createUserVignette(grant.subject_id, 'xs')); + } else { + // Token — link chip handles icon + label + copy-on-click + el.appendChild(buildLinkChip(grant)); + + if (viewMode === 'sharedWith') { + const arrow = document.createElement('span'); + arrow.className = 'ms-link-identity__arrow'; + arrow.textContent = '→'; + el.appendChild(arrow); + + const resLink = document.createElement('a'); + resLink.className = 'ms-link-identity__resource'; + resLink.href = '#'; + resLink.appendChild(buildResourceIcon(item.resource, item.resource_type)); + resLink.appendChild(document.createTextNode(` ${item.resource.name}`)); + resLink.addEventListener('click', (e) => { + e.preventDefault(); + this._config.onResourceOpen(item.resource, item.resource_type); + }); + el.appendChild(resLink); + } + } + + return el; + } + + /** @param {string} role @returns {HTMLElement} */ + _buildRolePill(role) { + const pill = document.createElement('span'); + pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`; + pill.textContent = _roleLabel(role); + return pill; + } + + /** + * 4-state expiry chip: never / active / soon / expired. + * @param {string|null} expiresAt + * @returns {HTMLElement} + */ + _buildExpiryChip(expiresAt) { + const state = _expiryState(expiresAt); + const chip = document.createElement('span'); + chip.className = `ms-expiry-chip ms-expiry-chip--${state}`; + + const icon = document.createElement('i'); + const text = document.createTextNode(''); + + if (state === 'never') { + icon.className = 'fas fa-infinity'; + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${i18n.t('myshares.neverExpires', 'Never expires')}`)); + } else if (state === 'expired') { + icon.className = 'fas fa-exclamation-triangle'; + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${i18n.t('myshares.expired', 'Expired')}`)); + } else if (state === 'soon' && expiresAt) { + icon.className = 'fas fa-clock'; + const days = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000); + const label = + days <= 1 + ? i18n.t('myshares.expiresTomorrow', 'Expires tomorrow') + : i18n.t('myshares.expiresInDays', 'Expires in {n} days').replace('{n}', String(days)); + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${label}`)); + } else if (expiresAt) { + icon.className = 'fas fa-clock'; + const d = new Date(expiresAt); + const fmt = d.toLocaleDateString('default', { day: 'numeric', month: 'short', year: 'numeric' }); + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${i18n.t('myshares.until', 'Until')} ${fmt}`)); + } + + // unused ref kept to avoid TS unused-var warning suppression + void text; + return chip; + } + + // ── Kebab menu ──────────────────────────────────────────────────────────── + + /** + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {HTMLElement} rowEl + * @returns {HTMLButtonElement} + */ + _buildKebabBtn(grant, item, rowEl) { + const btn = /** @type {HTMLButtonElement} */ (document.createElement('button')); + btn.className = 'ms-kebab-btn ms-btn-icon'; + btn.setAttribute('aria-label', i18n.t('myshares.manageAccess', 'Manage access')); + btn.innerHTML = ''; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this._openGrantMenu(btn, grant, item, rowEl); + }); + return btn; + } + + /** + * Build and show a dynamic context menu positioned below the trigger button. + * @param {HTMLButtonElement} btn + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {HTMLElement} rowEl + */ + _openGrantMenu(btn, grant, item, rowEl) { + document.querySelector('.ms-grant-menu')?.remove(); + + const menu = document.createElement('div'); + menu.className = 'context-menu ms-grant-menu'; + + // Current expiry as YYYY-MM-DD (or null) + const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null; + + if (grant.subject_type === 'user') { + for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) { + const isCurrent = grant.role === role; + const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', _roleLabel(role), false, async () => { + menu.remove(); + if (isCurrent) return; + await grants.updateRole({ + subject: { type: grant.subject_type, id: grant.subject_id }, + resource: { type: item.resource_type, id: item.resource.id }, + role + }); + const pill = rowEl.querySelector('.ms-role-pill'); + if (pill) { + pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`; + pill.textContent = _roleLabel(role); + } + grant.role = role; + }); + if (isCurrent) mi.classList.add('ms-menu-item--current'); + menu.appendChild(mi); + } + menu.appendChild(this._menuSeparator()); + menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry)); + menu.appendChild(this._menuSeparator()); + menu.appendChild( + this._menuItem('fas fa-user-times', i18n.t('myshares.removeAccess', 'Remove access'), true, async () => { + menu.remove(); + await grants.revokeGrant(grant.grant_id); + this._removeRowAndCleanLane(rowEl); + }) + ); + } else { + menu.appendChild( + this._menuItem('fas fa-copy', i18n.t('myshares.copyLink', 'Copy link'), false, async () => { + menu.remove(); + const share = await fileSharing.getShareById(grant.subject_id); + await fileSharing.copyLinkToClipboard(share.url); + }) + ); + menu.appendChild(this._menuSeparator()); + menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry)); + menu.appendChild(this._menuPasswordRow(grant, rowEl)); + menu.appendChild(this._menuSeparator()); + menu.appendChild( + this._menuItem('fas fa-trash', i18n.t('myshares.deleteLink', 'Delete link'), true, async () => { + menu.remove(); + await fileSharing.removeSharedLink(grant.subject_id); + this._removeRowAndCleanLane(rowEl); + }) + ); + } + + document.body.appendChild(menu); + + // Position below the trigger, right-aligned to it, clamped to viewport + const rect = btn.getBoundingClientRect(); + const mw = menu.offsetWidth || 200; + const left = Math.min(rect.right - mw, window.innerWidth - mw - 8); + menu.style.position = 'absolute'; + menu.style.top = `${rect.bottom + window.scrollY + 4}px`; + menu.style.left = `${Math.max(8, left)}px`; + + const close = (/** @type {Event} */ e) => { + if (e.type === 'keydown' && /** @type {KeyboardEvent} */ (e).key !== 'Escape') return; + // Keep menu open when interacting with elements inside it (e.g. the date input) + if (e.type === 'click' && menu.contains(/** @type {Node} */ (e.target))) return; + menu.remove(); + document.removeEventListener('click', close, true); + document.removeEventListener('keydown', close, true); + }; + setTimeout(() => { + document.addEventListener('click', close, true); + document.addEventListener('keydown', close, true); + }, 0); + } + + /** + * Non-closing expiry row embedded in the context menu. + * Uses the shared smd-expiry-chip; saves on blur/Enter. + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {HTMLElement} rowEl + * @param {string|null} initialExpiry YYYY-MM-DD or null + * @returns {HTMLElement} + */ + _menuExpiryRow(grant, item, rowEl, initialExpiry) { + const row = document.createElement('div'); + row.className = 'ms-menu-expiry-row'; + + const label = document.createElement('span'); + label.className = 'ms-menu-expiry-label'; + label.textContent = i18n.t('share.expiry', 'Expiry'); + row.appendChild(label); + + const chip = buildExpiryChip(initialExpiry, async (dateStr) => { + const expiresIso = dateStr ? new Date(`${dateStr}T00:00:00Z`).toISOString() : null; + try { + await grants.updateRole({ + subject: { type: grant.subject_type, id: grant.subject_id }, + resource: { type: item.resource_type, id: item.resource.id }, + role: grant.role, + expires_at: expiresIso + }); + grant.expires_at = expiresIso; + // Replace the display chip in the grant row + const displayChip = rowEl.querySelector('.ms-expiry-chip'); + if (displayChip) { + const newChip = this._buildExpiryChip(expiresIso); + displayChip.replaceWith(newChip); + } + } catch (err) { + console.error('mySharesList: setExpiry failed', err); + } + }); + row.appendChild(chip); + + return row; + } + + /** + * Non-closing password row embedded in the link context menu. + * Saves immediately on confirm (blur / Enter). + * @param {OutgoingResourceGrant} grant + * @param {HTMLElement} rowEl + * @returns {HTMLElement} + */ + _menuPasswordRow(grant, rowEl) { + const row = document.createElement('div'); + row.className = 'ms-menu-expiry-row'; + + const label = document.createElement('span'); + label.className = 'ms-menu-expiry-label'; + label.textContent = i18n.t('share.password', 'Password'); + row.appendChild(label); + + const chip = buildPasswordChip(grant.has_password, async (newPassword) => { + try { + await fileSharing.updateSharedLink(grant.subject_id, { + password: newPassword || null + }); + grant.has_password = !!newPassword; + // Update the lock icon on the link chip in the row + const linkChipEl = rowEl.querySelector('.link-chip'); + if (linkChipEl) { + linkChipEl.classList.toggle('link-chip--locked', grant.has_password); + const iconEl = linkChipEl.querySelector('.link-chip__icon'); + if (iconEl) { + iconEl.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon'; + } + } + } catch (err) { + console.error('mySharesList: setPassword failed', err); + } + }); + row.appendChild(chip); + + return row; + } + + /** + * @param {string} iconClass + * @param {string} label + * @param {boolean} danger + * @param {() => void} onClick + * @returns {HTMLElement} + */ + _menuItem(iconClass, label, danger, onClick) { + const el = document.createElement('div'); + el.className = danger ? 'context-menu-item context-menu-item-danger' : 'context-menu-item'; + el.setAttribute('role', 'menuitem'); + if (iconClass) { + el.innerHTML = ` `; + } + el.appendChild(document.createTextNode(label)); + el.addEventListener('click', /** @type {EventListener} */ (onClick)); + return el; + } + + /** @returns {HTMLElement} */ + _menuSeparator() { + const el = document.createElement('div'); + el.className = 'context-menu-separator'; + return el; + } + + /** + * Remove the row; if the lane body is now empty, remove the whole lane. + * @param {HTMLElement} rowEl + */ + _removeRowAndCleanLane(rowEl) { + const laneBody = rowEl.closest('.ms-lane__body'); + rowEl.remove(); + if (laneBody instanceof HTMLElement && laneBody.children.length === 0) { + const lane = laneBody.closest('.ms-lane'); + if (lane instanceof HTMLElement) { + if (lane.dataset.swimKey === this._lastSwimKey) { + this._lastSwimKey = null; + this._lastSwimEl = null; + } + lane.remove(); + } + } + } +} + +export { MySharesList }; diff --git a/static/js/components/resourceIcon.js b/static/js/components/resourceIcon.js new file mode 100644 index 00000000..ec0a797d --- /dev/null +++ b/static/js/components/resourceIcon.js @@ -0,0 +1,61 @@ +/** + * resourceIcon — shared resource icon builder. + * + * Returns a `.file-icon` element identical to the one in resourceList: + * • Folders: `.file-icon.folder-icon` with CSS tab (no visible ) + * • Files: `.file-icon.{specialClass}` + optional thumbnail + + * + * CSS lives in fileType.css (folder/file type colours) and resourceList.css + * (base size in grid/list context). Consumer views add their own size overrides. + */ + +import { thumbnail } from '../features/thumbnail.js'; + +/** @import {FileItem, FolderItem} from '../core/types.js' */ + +/** + * @param {FileItem|FolderItem} item + * @param {'file'|'folder'} resourceType + * @returns {HTMLElement} + */ +function buildResourceIcon(item, resourceType) { + const el = document.createElement('div'); + + if (resourceType === 'folder') { + el.className = 'file-icon folder-icon'; + const i = document.createElement('i'); + i.className = 'fas fa-folder'; + el.appendChild(i); + return el; + } + + const file = /** @type {FileItem} */ (item); + const iconClass = file.icon_class || 'fas fa-file'; + const iconSpecialClass = file.icon_special_class || ''; + el.className = `file-icon${iconSpecialClass ? ` ${iconSpecialClass}` : ''}`; + + const canThumbnail = thumbnail?.canHandle(file) ?? false; + if (canThumbnail) { + const img = document.createElement('img'); + img.className = 'file-thumb'; + img.src = `/api/files/${file.id}/thumbnail/icon`; + img.loading = 'lazy'; + img.alt = ''; + img.addEventListener('error', () => { + img.classList.add('hidden'); + thumbnail?.queueGenerate(file, (dataUrl) => { + img.src = dataUrl; + img.classList.remove('hidden'); + }); + }); + el.appendChild(img); + } + + const i = document.createElement('i'); + i.className = iconClass; + el.appendChild(i); + + return el; +} + +export { buildResourceIcon }; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index a160ac3c..4f96b9cf 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -20,8 +20,8 @@ import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; -import { thumbnail } from '../features/thumbnail.js'; import { systemUsers } from '../model/systemUsers.js'; +import { buildResourceIcon } from './resourceIcon.js'; import { createUserVignette } from './userVignette.js'; /** @@ -444,9 +444,7 @@ export class ResourceListComponent { el.innerHTML = ` ${cfg.selectable ? '
' : ''}
-
- -
+
${escapeHtml(folder.name)} ${cfg.showFavorite ? `
` : ''} ${cfg.showShareBadge ? `
` : ''} @@ -461,6 +459,7 @@ export class ResourceListComponent {
`; + el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder')); this._bindItemEvents(el, folder); return el; } @@ -472,8 +471,6 @@ export class ResourceListComponent { */ _createFileItem(file) { const cfg = this._cfg; - const iconClass = file.icon_class || 'fas fa-file'; - const iconSpecialClass = file.icon_special_class || ''; const cat = file.category || ''; const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); const fileSize = file.size_formatted || formatFileSize(file.size); @@ -481,7 +478,6 @@ export class ResourceListComponent { const formattedDate = formatDateTime(new Date(dateVal)); const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false; const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false; - const canThumbnail = thumbnail?.canHandle(file) ?? false; const el = document.createElement('div'); const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : ''; @@ -496,10 +492,7 @@ export class ResourceListComponent { el.innerHTML = ` ${cfg.selectable ? '
' : ''}
-
- ${canThumbnail ? `` : ''} - -
+
${escapeHtml(file.name)} ${cfg.showFavorite ? `
` : ''} ${cfg.showShareBadge ? `
` : ''} @@ -514,18 +507,7 @@ export class ResourceListComponent {
`; - const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb')); - if (thumb) { - thumb.addEventListener('error', () => { - console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`); - thumb.classList.add('hidden'); - thumbnail?.queueGenerate(file, (dataUrl) => { - thumb.src = dataUrl; - thumb.classList.remove('hidden'); - }); - }); - } - + el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file')); this._bindItemEvents(el, file); return el; } diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index a8a6e33a..dd6635f7 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -20,23 +20,13 @@ 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 { buildExpiryChip } from '../utils/expiryChip.js'; +import { buildPasswordChip } from '../utils/passwordChip.js'; import { Modal } from './modal.js'; import { createUserVignette } from './userVignette.js'; /** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */ -// ── Helpers ──────────────────────────────────────────────────────────────────── - -/** - * Format a YYYY-MM-DD date string for display ("Dec 31, 2026"). - * @param {string} dateStr - * @returns {string} - */ -function _formatExpiryDate(dateStr) { - const d = new Date(`${dateStr}T00:00:00`); - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} - /** Permissions that belong to each role (must mirror the Rust DTO). */ const ROLE_PERMISSIONS = { viewer: ['read'], @@ -274,9 +264,9 @@ const shareModal = { 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')] + ['viewer', i18n.t('share.role.canView', 'Can view')], + ['editor', i18n.t('share.role.canEdit', 'Can edit')], + ['admin', i18n.t('share.role.canManage', 'Can manage')] ]) { const opt = document.createElement('option'); opt.value = val; @@ -505,9 +495,9 @@ const shareModal = { 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') + admin: i18n.t('share.role.canManage', 'Can manage'), + editor: i18n.t('share.role.canEdit', 'Can edit'), + viewer: i18n.t('share.role.canView', 'Can view') }; const badge = document.createElement('span'); badge.className = 'smd-group-badge'; @@ -538,9 +528,9 @@ const shareModal = { 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')] + ['viewer', i18n.t('share.role.canView', 'Can view')], + ['editor', i18n.t('share.role.canEdit', 'Can edit')], + ['admin', i18n.t('share.role.canManage', 'Can manage')] ]) { const opt = document.createElement('option'); opt.value = val; @@ -587,78 +577,12 @@ const shareModal = { // ── Expiry chip toggle ───────────────────────────────────────────────────── /** - * Build a compact expiry chip that toggles to an inline date input on click. - * - * Chip states: - * • "∞ No expiry" — dashed border, faint text (value is null) - * • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set) - * * @param {string|null} initialValue - YYYY-MM-DD or null - * @param {(v: string|null) => void} onChange - called whenever the value changes + * @param {(v: string|null) => void} onChange * @returns {HTMLElement} */ _buildExpiryChip(initialValue, onChange) { - let current = initialValue; - - const wrap = document.createElement('div'); - wrap.className = 'smd-expiry-chip-wrap'; - - const chip = document.createElement('button'); - chip.type = 'button'; - - const dateInput = document.createElement('input'); - dateInput.type = 'date'; - dateInput.className = 'smd-expiry-date-input hidden'; - - const updateChip = () => { - if (current) { - chip.className = 'smd-expiry-chip smd-expiry-chip--set'; - chip.innerHTML = - ` ${_formatExpiryDate(current)}` + - `×`; - chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { - e.stopPropagation(); - current = null; - onChange(null); - updateChip(); - }); - } else { - chip.className = 'smd-expiry-chip'; - chip.innerHTML = ` ${i18n.t('share.noExpiry', 'No expiry')}`; - } - }; - - chip.addEventListener('click', () => { - chip.classList.add('hidden'); - if (current) dateInput.value = current; - dateInput.classList.remove('hidden'); - dateInput.focus(); - }); - - const confirm = () => { - const val = dateInput.value || null; - current = val; - onChange(val); - dateInput.classList.add('hidden'); - chip.classList.remove('hidden'); - updateChip(); - }; - dateInput.addEventListener('blur', confirm); - dateInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirm(); - } - if (e.key === 'Escape') { - dateInput.classList.add('hidden'); - chip.classList.remove('hidden'); - } - }); - - updateChip(); - wrap.appendChild(chip); - wrap.appendChild(dateInput); - return wrap; + return buildExpiryChip(initialValue, onChange); }, /** @@ -667,72 +591,7 @@ const shareModal = { * @returns {HTMLElement} */ _buildPasswordChip(initialHasPassword, onChange) { - let hasPassword = initialHasPassword; - - const wrap = document.createElement('div'); - wrap.className = 'smd-expiry-chip-wrap'; - - const chip = document.createElement('button'); - chip.type = 'button'; - - const pwInput = document.createElement('input'); - pwInput.type = 'password'; - pwInput.className = 'smd-expiry-date-input hidden'; - pwInput.placeholder = i18n.t('dialogs.password', 'Password'); - pwInput.autocomplete = 'new-password'; - - const updateChip = () => { - if (hasPassword) { - chip.className = 'smd-expiry-chip smd-expiry-chip--set'; - chip.innerHTML = - ` ${i18n.t('share.passwordProtected', 'Password')}` + - `×`; - chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { - e.stopPropagation(); - hasPassword = false; - onChange(''); - updateChip(); - }); - } else { - chip.className = 'smd-expiry-chip'; - chip.innerHTML = ` ${i18n.t('share.noPassword', 'No password')}`; - } - }; - - chip.addEventListener('click', () => { - chip.classList.add('hidden'); - pwInput.value = ''; - pwInput.classList.remove('hidden'); - pwInput.focus(); - }); - - const confirm = () => { - const val = pwInput.value; - pwInput.classList.add('hidden'); - chip.classList.remove('hidden'); - if (val) { - hasPassword = true; - onChange(val); - } - updateChip(); - }; - - pwInput.addEventListener('blur', confirm); - pwInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirm(); - } - if (e.key === 'Escape') { - pwInput.classList.add('hidden'); - chip.classList.remove('hidden'); - } - }); - - updateChip(); - wrap.appendChild(chip); - wrap.appendChild(pwInput); - return wrap; + return buildPasswordChip(initialHasPassword, onChange); }, // ── Links section ────────────────────────────────────────────────────────── diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js index 84b069a3..2b328dc1 100644 --- a/static/js/core/formatters.js +++ b/static/js/core/formatters.js @@ -138,6 +138,36 @@ function normalizeDateBucket(value) { return String(date.getFullYear()); } +/** + * Normalize a future expiry value into a human-readable bucket label. + * Buckets (soonest-first): Expired | Tomorrow | In less than 7 days | In less than 30 days | | No expiration + * + * Accepts the same input types as `normalizeDateBucket`. + * + * @param {string | number | Date | null | undefined} value + * @returns {string} + */ +function normalizeExpiryBucket(value) { + if (value === null || value === undefined) { + return i18n.t('expiryBucket.noExpiry', 'No expiration'); + } + /** @type {Date} */ + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else { + date = new Date(value); + } + const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000); + if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired'); + if (daysUntil <= 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow'); + if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days'); + if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days'); + return String(date.getFullYear()); +} + /** * Maps a file size in bytes to a coarse, human-readable bucket label. * @@ -167,4 +197,15 @@ function sizeBucket(bytes) { return i18n.t('sizeBucket.huge', '> 5 GB'); } -export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket }; +export { + escapeHtml, + formatDateShort, + formatDateTime, + formatFileSize, + formatQuotaSize, + isEmailValid, + isTextViewable, + normalizeDateBucket, + normalizeExpiryBucket, + sizeBucket +}; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 125d945f..a8ca5c3b 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -239,6 +239,10 @@ const OxiIcons = { 576, 'M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z' ], + 'infinity': [ + 640, + 'M0 256c0-88.4 71.6-160 160-160 50.4 0 97.8 23.7 128 64l32 42.7 32-42.7c30.2-40.3 77.6-64 128-64 88.4 0 160 71.6 160 160S568.4 416 480 416c-50.4 0-97.8-23.7-128-64l-32-42.7-32 42.7c-30.2 40.3-77.6 64-128 64-88.4 0-160-71.6-160-160zm280 0l-43.2-57.6c-18.1-24.2-46.6-38.4-76.8-38.4-53 0-96 43-96 96s43 96 96 96c30.2 0 58.7-14.2 76.8-38.4L280 256zm80 0l43.2 57.6c18.1 24.2 46.6 38.4 76.8 38.4 53 0 96-43 96-96s-43-96-96-96c-30.2 0-58.7 14.2-76.8 38.4L360 256z' + ], 'info-circle': [ 512, 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z' diff --git a/static/js/core/types.js b/static/js/core/types.js index 06c84168..4a533d4c 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -81,9 +81,9 @@ /** * @typedef {Object} UpdateShare - * @property {string|null} password - * @property {number|null} expires_at - timestamp - * @property {SharePermissions|null} permissions + * @property {string|null} [password] + * @property {number|null} [expires_at] + * @property {SharePermissions|null} [permissions] */ /** @@ -334,6 +334,35 @@ * @property {string|undefined} [next_cursor] - Absent when the last page is reached. */ +/** + * One (subject, permissions) entry within an outgoing resource item. + * @typedef {Object} OutgoingResourceGrant + * @property {string} grant_id + * @property {'user'|'token'} subject_type + * @property {string} subject_id + * @property {string} subject_display - Username (users) or share name (tokens). + * @property {'viewer'|'editor'|'admin'} role + * @property {string} granted_at - ISO-8601 + * @property {string|null} [expires_at] - ISO-8601 or absent. + * @property {boolean} has_password - True when a token subject has a password set. + */ + +/** + * One item returned by `GET /api/grants/outgoing/resources`. + * @typedef {Object} OutgoingResourceItem + * @property {ResourceTypeEnum} resource_type + * @property {string} first_shared_at - ISO-8601 earliest grant date. + * @property {FileItem|FolderItem} resource - Full resource details. + * @property {OutgoingResourceGrant[]} grants - One entry per (subject, permissions). + */ + +/** + * Response for `GET /api/grants/outgoing/resources`. + * @typedef {Object} OutgoingResourcesResponse + * @property {OutgoingResourceItem[]} items + * @property {string|undefined} [next_cursor] - Absent when the last page is reached. + */ + /** * One item returned by `GET /api/favorites/resources`. * `resource_type` discriminates the shape of `resource`. diff --git a/static/js/features/sharing/fileSharing.js b/static/js/features/sharing/fileSharing.js index fd54d0e8..98117154 100644 --- a/static/js/features/sharing/fileSharing.js +++ b/static/js/features/sharing/fileSharing.js @@ -154,6 +154,20 @@ const fileSharing = { } }, + /** + * Fetch a single share by its UUID and return the full ShareItem. + * Used to resolve a token's URL on demand (lazy fetch on copy-link click). + * @param {string} shareId + * @returns {Promise} + */ + async getShareById(shareId) { + const res = await fetch(`/api/shares/${shareId}`, { + headers: this._headers(false) + }); + if (!res.ok) throw new Error(`getShareById ${shareId}: HTTP ${res.status}`); + return res.json(); + }, + /** * Copy a shared link to clipboard * @param {string} url diff --git a/static/js/model/grants.js b/static/js/model/grants.js index 4eddcbe7..f3b6b7de 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -1,5 +1,5 @@ /** - * @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js' + * @import {Grant, ResourceTypeEnum, SharedWithMeResponse, OutgoingResourcesResponse} from '../core/types.js' */ import { getCsrfHeaders } from '../core/csrf.js'; @@ -110,6 +110,32 @@ const grants = { return response.json(); }, + /** + * Fetch a cursor-paginated list of resources the current user has shared + * with others, with full file / folder metadata resolved server-side. + * + * @param {object} [opts] + * @param {number} [opts.limit] - Max items per page (1–200, default 50). + * @param {string} [opts.cursor] - Opaque cursor from a previous call. + * @param {string} [opts.orderBy] - Sort: 'first_shared_at' | 'name' | 'type' | 'subject'. + * @param {boolean} [opts.reverse] - Reverse sort order. + * @returns {Promise} + */ + async fetchMySharesPage({ limit = 50, cursor, orderBy, reverse = false } = {}) { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + if (orderBy) params.set('sort_by', orderBy); + if (reverse) params.set('reverse', 'true'); + + const response = await fetch(`/api/grants/outgoing/resources?${params}`); + + if (!response.ok) { + throw new Error(`Failed to fetch my shares: HTTP ${response.status}`); + } + + return response.json(); + }, + /** * Fetch all grants on a specific resource (for the "Manage sharing" panel). * Refreshes the outgoingGrants cache for this resource. diff --git a/static/js/utils/expiryChip.js b/static/js/utils/expiryChip.js new file mode 100644 index 00000000..248412b6 --- /dev/null +++ b/static/js/utils/expiryChip.js @@ -0,0 +1,93 @@ +/** + * buildExpiryChip — shared compact expiry editor chip. + * + * Chip states: + * • "∞ No expiry" — dashed border, faint text (value is null) + * • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set) + * + * Clicking the chip toggles to an inline . + * CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-date-input) + * live in shareModal.css. + */ + +import { i18n } from '../core/i18n.js'; + +/** + * Format a YYYY-MM-DD string for display ("Dec 31, 2026"). + * @param {string} dateStr + * @returns {string} + */ +export function formatExpiryDate(dateStr) { + const d = new Date(`${dateStr}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/** + * Build an interactive expiry chip. + * @param {string|null} initialValue YYYY-MM-DD or null + * @param {(v: string|null) => void} onChange called whenever the value changes + * @returns {HTMLElement} + */ +export function buildExpiryChip(initialValue, onChange) { + let current = initialValue; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const dateInput = document.createElement('input'); + dateInput.type = 'date'; + dateInput.className = 'smd-expiry-date-input hidden'; + + const updateChip = () => { + if (current) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${formatExpiryDate(current)}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + current = null; + onChange(null); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noExpiry', 'No expiry')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + if (current) dateInput.value = current; + dateInput.classList.remove('hidden'); + dateInput.focus(); + }); + + const confirm = () => { + const val = dateInput.value || null; + current = val; + onChange(val); + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + updateChip(); + }; + dateInput.addEventListener('blur', confirm); + dateInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(dateInput); + return wrap; +} diff --git a/static/js/utils/passwordChip.js b/static/js/utils/passwordChip.js new file mode 100644 index 00000000..f5a8d480 --- /dev/null +++ b/static/js/utils/passwordChip.js @@ -0,0 +1,88 @@ +/** + * buildPasswordChip — shared inline password editor chip. + * + * States: + * • "🔓 No password" — unset (default) + * • "🔒 Password ×" — set; × clears it + * + * Clicking the chip shows a hidden . + * Blur / Enter confirms; Escape cancels. + * CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-chip--set, + * .smd-expiry-date-input) live in shareModal.css. + */ + +import { i18n } from '../core/i18n.js'; + +/** + * @param {boolean} initialHasPassword + * @param {(v: string) => void} onChange '' = remove, non-empty = set new password + * @returns {HTMLElement} + */ +export function buildPasswordChip(initialHasPassword, onChange) { + let hasPassword = initialHasPassword; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.className = 'smd-expiry-date-input hidden'; + pwInput.placeholder = i18n.t('dialogs.password', 'Password'); + pwInput.autocomplete = 'new-password'; + + const updateChip = () => { + if (hasPassword) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${i18n.t('share.passwordProtected', 'Password')}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + hasPassword = false; + onChange(''); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noPassword', 'No password')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + pwInput.value = ''; + pwInput.classList.remove('hidden'); + pwInput.focus(); + }); + + const confirm = () => { + const val = pwInput.value; + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + if (val) { + hasPassword = true; + onChange(val); + } + updateChip(); + }; + + pwInput.addEventListener('blur', confirm); + pwInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(pwInput); + return wrap; +} diff --git a/static/js/views/myShares/mySharesView.js b/static/js/views/myShares/mySharesView.js new file mode 100644 index 00000000..f33ddc79 --- /dev/null +++ b/static/js/views/myShares/mySharesView.js @@ -0,0 +1,235 @@ +/** + * OxiCloud – "My Shares" view. + * + * Renders resources the current user has shared with others, using the + * cursor-paginated `GET /api/grants/outgoing/resources` endpoint. + * + * Group-by modes exposed to the navigation toolbar: + * 'items' — one card per resource (sort_by=type) [default / None] + * 'sharedWith' — swimlanes per subject (sort_by=subject) + * + * The "None" option (key='') from the toolbar maps to the default 'items' mode. + */ + +import { ui } from '../../app/ui.js'; +import { MySharesList } from '../../components/mySharesList.js'; +import { shareModal } from '../../components/shareModal.js'; +import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; +import { grants } from '../../model/grants.js'; + +/** @import {FileItem, FolderItem} from '../../core/types.js' */ + +/** + * @typedef {{ key: string, label: string, orderBy: string }} GroupByDef + * @typedef {'items'|'sharedWith'} ViewMode + */ + +/** + * @type {{ [key: string]: { orderBy: string, viewMode: ViewMode } }} + */ +const MODE_MAP = { + '': { orderBy: 'type', viewMode: 'items' }, + items: { orderBy: 'type', viewMode: 'items' }, + sharedWith: { orderBy: 'subject', viewMode: 'sharedWith' } +}; + +/** @type {GroupByDef[]} */ +const GROUP_BY_DEFS = [ + { + key: '', + get label() { + return i18n.t('groupby.byFiles', 'By files'); + }, + orderBy: 'type' + }, + { + key: 'sharedWith', + get label() { + return i18n.t('groupby.sharedWith', 'Shared with'); + }, + orderBy: 'subject' + } +]; + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'ms-load-more-wrapper'; + +const mySharesView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + /** @type {MySharesList|null} */ + _component: null, + + /** @type {string} */ + _groupBy: '', + + /** @type {boolean} */ + _reversed: false, + + // ── Public API ──────────────────────────────────────────────────────────── + + /** @returns {GroupByDef[]} */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Called by navigation.js when the user picks a pill. + * Empty string '' maps to the default items mode. + * @param {string} key + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * Flip sort direction and reload from page 1. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (this._reversed === reversed) return; + this._reversed = reversed; + viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + async init() { + this._nextCursor = null; + this._loading = false; + const saved = viewPrefs.load('shared'); + this._groupBy = saved.groupBy || ''; + this._reversed = saved.reversed; + + this._ensureLoadMoreButton(); + + ui.resetFilesList(); + ui.updateBreadcrumb(); + + const filesList = document.getElementById('files-list'); + if (filesList) { + if (!this._component) { + this._component = new MySharesList(filesList, { + onResourceOpen: (resource, resourceType) => { + if (resourceType === 'folder') { + ui.openItem(resource); + } else { + // File: navigate to the parent folder in Files section. + const file = /** @type {FileItem} */ (resource); + const parts = (file.path || '').split('/').filter(Boolean); + const parentName = parts.length >= 2 ? parts[parts.length - 2] : ''; + ui.openItem(/** @type {FolderItem} */ ({ id: file.folder_id, name: parentName })); + } + }, + onShareEdit: (resource, resourceType) => { + shareModal.open(resource, /** @type {'file'|'folder'} */ (resourceType)); + } + }); + } + } + + await this._loadPage(); + }, + + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.destroy(filesList); + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + async _loadPage() { + if (this._loading) return; + this._loading = true; + + const isFirstPage = this._nextCursor === null; + + try { + const mode = MODE_MAP[this._groupBy] ?? MODE_MAP['']; + + const data = await grants.fetchMySharesPage({ + limit: 50, + cursor: this._nextCursor ?? undefined, + orderBy: mode.orderBy, + reverse: this._reversed + }); + + this._nextCursor = data.next_cursor ?? null; + + if (data.items.length === 0 && isFirstPage) { + ui.showError(` + +

${i18n.t('myshares.emptyStateTitle', "You haven't shared anything yet")}

+

${i18n.t('myshares.emptyStateDesc', 'Items you share with others will appear here')}

+ `); + this._setLoadMoreVisible(false); + return; + } + + if (isFirstPage) { + this._component?.render(data.items, mode.viewMode); + } else { + this._component?.append(data.items, mode.viewMode); + } + + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.init(filesList); + + this._setLoadMoreVisible(!!this._nextCursor); + } catch (err) { + ui.showError(` + +

${i18n.t('errors_loadFailed', 'Failed to load items')}

+ `); + console.error('mySharesView: load error', err); + } finally { + this._loading = false; + } + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'ms-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'ms-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('myshares.loadMore', 'Load more'); + btn.addEventListener('click', () => this._loadPage()); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); + }, + + /** @param {boolean} visible */ + _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); + } +}; + +export { mySharesView }; diff --git a/static/locales/ar.json b/static/locales/ar.json index 7a433772..3c374b4c 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -723,11 +723,13 @@ "modifiedAt": "تاريخ التعديل", "createdAt": "تاريخ الإنشاء", "size": "الحجم", - "favoriteDate": "تاريخ المفضلة" + "favoriteDate": "تاريخ المفضلة", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "اليوم", "last7days": "آخر 7 أيام", "last30days": "آخر 30 يومًا" } -} +} \ No newline at end of file diff --git a/static/locales/de.json b/static/locales/de.json index afee44b1..eed46b8f 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -723,11 +723,13 @@ "modifiedAt": "Änderungsdatum", "createdAt": "Erstellungsdatum", "size": "Größe", - "favoriteDate": "Datum der Markierung" + "favoriteDate": "Datum der Markierung", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Heute", "last7days": "Letzte 7 Tage", "last30days": "Letzte 30 Tage" } -} +} \ No newline at end of file diff --git a/static/locales/en.json b/static/locales/en.json index e1889828..5e1fa75c 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -714,6 +714,8 @@ }, "groupby": { "none": "None", + "byFiles": "By files", + "sharedWith": "Shared with", "title": "Group by", "type": "Type", "type.folders": "Folders", diff --git a/static/locales/es.json b/static/locales/es.json index 9a29e8ae..4630de55 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -723,11 +723,13 @@ "modifiedAt": "Fecha de modificación", "createdAt": "Fecha de creación", "size": "Tamaño", - "favoriteDate": "Fecha de favorito" + "favoriteDate": "Fecha de favorito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Hoy", "last7days": "Últimos 7 días", "last30days": "Últimos 30 días" } -} +} \ No newline at end of file diff --git a/static/locales/fa.json b/static/locales/fa.json index 83c636ef..0f1b9dea 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -723,11 +723,13 @@ "modifiedAt": "تاریخ تغییر", "createdAt": "تاریخ ایجاد", "size": "اندازه", - "favoriteDate": "تاریخ مورد علاقه" + "favoriteDate": "تاریخ مورد علاقه", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "امروز", "last7days": "۷ روز گذشته", "last30days": "۳۰ روز گذشته" } -} +} \ No newline at end of file diff --git a/static/locales/fr.json b/static/locales/fr.json index 51bf3a90..997951b0 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -723,11 +723,13 @@ "accessedAt": "Date d'accès", "modifiedAt": "Date de modification", "createdAt": "Date de création", - "size": "Taille" + "size": "Taille", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Aujourd'hui", "last7days": "7 derniers jours", "last30days": "30 derniers jours" } -} +} \ No newline at end of file diff --git a/static/locales/hi.json b/static/locales/hi.json index fb827113..bb071227 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -723,11 +723,13 @@ "modifiedAt": "संशोधन की तारीख", "createdAt": "बनाने की तारीख", "size": "आकार", - "favoriteDate": "पसंदीदा की तारीख" + "favoriteDate": "पसंदीदा की तारीख", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "आज", "last7days": "पिछले 7 दिन", "last30days": "पिछले 30 दिन" } -} +} \ No newline at end of file diff --git a/static/locales/it.json b/static/locales/it.json index bf8eb86f..d4fd3c28 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -723,11 +723,13 @@ "modifiedAt": "Data di modifica", "createdAt": "Data di creazione", "size": "Dimensione", - "favoriteDate": "Data preferito" + "favoriteDate": "Data preferito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Oggi", "last7days": "Ultimi 7 giorni", "last30days": "Ultimi 30 giorni" } -} +} \ No newline at end of file diff --git a/static/locales/ja.json b/static/locales/ja.json index 720eb836..6a3d54db 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -723,11 +723,13 @@ "modifiedAt": "更新日", "createdAt": "作成日", "size": "サイズ", - "favoriteDate": "お気に入り登録日" + "favoriteDate": "お気に入り登録日", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今日", "last7days": "過去7日間", "last30days": "過去30日間" } -} +} \ No newline at end of file diff --git a/static/locales/ko.json b/static/locales/ko.json index a4428fbd..fd0b63b3 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -723,11 +723,13 @@ "modifiedAt": "수정 날짜", "createdAt": "생성 날짜", "size": "크기", - "favoriteDate": "즐겨찾기 날짜" + "favoriteDate": "즐겨찾기 날짜", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "오늘", "last7days": "최근 7일", "last30days": "최근 30일" } -} +} \ No newline at end of file diff --git a/static/locales/nl.json b/static/locales/nl.json index 4b4f3c72..6dce12fd 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -723,11 +723,13 @@ "modifiedAt": "Wijzigingsdatum", "createdAt": "Aanmaakdatum", "size": "Grootte", - "favoriteDate": "Favoritendatum" + "favoriteDate": "Favoritendatum", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Vandaag", "last7days": "Afgelopen 7 dagen", "last30days": "Afgelopen 30 dagen" } -} +} \ No newline at end of file diff --git a/static/locales/pl.json b/static/locales/pl.json index 724125f4..1123ab46 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -723,11 +723,13 @@ "modifiedAt": "Data modyfikacji", "createdAt": "Data utworzenia", "size": "Rozmiar", - "favoriteDate": "Data dodania do ulubionych" + "favoriteDate": "Data dodania do ulubionych", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Dzisiaj", "last7days": "Ostatnie 7 dni", "last30days": "Ostatnie 30 dni" } -} +} \ No newline at end of file diff --git a/static/locales/pt.json b/static/locales/pt.json index 63cada28..abf41dfe 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -723,11 +723,13 @@ "modifiedAt": "Data de modificação", "createdAt": "Data de criação", "size": "Tamanho", - "favoriteDate": "Data de favorito" + "favoriteDate": "Data de favorito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Hoje", "last7days": "Últimos 7 dias", "last30days": "Últimos 30 dias" } -} +} \ No newline at end of file diff --git a/static/locales/ru.json b/static/locales/ru.json index 1232cdf4..f5107676 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -723,11 +723,13 @@ "modifiedAt": "Дата изменения", "createdAt": "Дата создания", "size": "Размер", - "favoriteDate": "Дата добавления в избранное" + "favoriteDate": "Дата добавления в избранное", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Сегодня", "last7days": "Последние 7 дней", "last30days": "Последние 30 дней" } -} +} \ No newline at end of file diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 7e053115..c4bf8c6d 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -723,11 +723,13 @@ "modifiedAt": "修改日期", "createdAt": "建立日期", "size": "大小", - "favoriteDate": "收藏日期" + "favoriteDate": "收藏日期", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今天", "last7days": "近7天", "last30days": "近30天" } -} +} \ No newline at end of file diff --git a/static/locales/zh.json b/static/locales/zh.json index 81a0ab32..4edc74d8 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -723,11 +723,13 @@ "modifiedAt": "修改日期", "createdAt": "创建日期", "size": "大小", - "favoriteDate": "收藏日期" + "favoriteDate": "收藏日期", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今天", "last7days": "近7天", "last30days": "近30天" } -} +} \ No newline at end of file diff --git a/static/sw.js b/static/sw.js index 6bb430c5..ef92a431 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v21'; +const CACHE_NAME = 'oxicloud-cache-v22'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest From d88b3a313f775a2853b5dd6a0bff04e9e686e836 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 12:12:29 +0200 Subject: [PATCH 03/12] fix(recent): prevent recent API having non UUID entries + correct any invalid entry in recent database note: issue may have been on my side during test implementation, in any case the protection is required --- .../20260601000001_cleanup_recent_invalid_item_ids.sql | 10 ++++++++++ src/interfaces/api/handlers/recent_handler.rs | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 migrations/20260601000001_cleanup_recent_invalid_item_ids.sql diff --git a/migrations/20260601000001_cleanup_recent_invalid_item_ids.sql b/migrations/20260601000001_cleanup_recent_invalid_item_ids.sql new file mode 100644 index 00000000..13645c51 --- /dev/null +++ b/migrations/20260601000001_cleanup_recent_invalid_item_ids.sql @@ -0,0 +1,10 @@ +-- Remove rows from auth.user_recent_files and auth.user_favorites whose +-- item_id is not a valid UUID (e.g. composite "uuid1_uuid2" values written +-- by a previous code path that joined owner_id and resource_id with '_'). +-- These rows would cause a cast failure on `item_id::UUID` in list queries. + +DELETE FROM auth.user_recent_files +WHERE item_id !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; + +DELETE FROM auth.user_favorites +WHERE item_id !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 62f72126..5ce50631 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -21,6 +21,7 @@ use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::services::recent_service::RecentService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; +use uuid::Uuid; /// Query parameters for getting recent items #[derive(Deserialize)] @@ -83,7 +84,7 @@ pub async fn get_recent_items( pub async fn record_item_access( State(recent_service): State>, auth_user: AuthUser, - Path((item_type, item_id)): Path<(String, String)>, + Path((item_type, item_id)): Path<(String, Uuid)>, ) -> impl IntoResponse { let user_id = auth_user.id; @@ -99,7 +100,7 @@ pub async fn record_item_access( } match recent_service - .record_item_access(user_id, &item_id, &item_type) + .record_item_access(user_id, &item_id.to_string(), &item_type) .await { Ok(_) => { @@ -143,12 +144,12 @@ pub async fn record_item_access( pub async fn remove_from_recent( State(recent_service): State>, auth_user: AuthUser, - Path((item_type, item_id)): Path<(String, String)>, + Path((item_type, item_id)): Path<(String, Uuid)>, ) -> impl IntoResponse { let user_id = auth_user.id; match recent_service - .remove_from_recent(user_id, &item_id, &item_type) + .remove_from_recent(user_id, &item_id.to_string(), &item_type) .await { Ok(removed) => { From 6e30353c4b696d2ce0369f5fe81e84c4f0aa2e35 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 12:54:09 +0200 Subject: [PATCH 04/12] chore(deprecated api): log a warning on serverside if a deprecated route is still used --- src/interfaces/api/handlers/favorites_handler.rs | 3 ++- src/interfaces/api/handlers/folder_handler.rs | 2 ++ src/interfaces/api/handlers/recent_handler.rs | 3 ++- src/interfaces/api/routes.rs | 6 +++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index a1ee2703..bd4da7ba 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -6,7 +6,7 @@ use axum::{ }; use serde::Deserialize; use std::sync::Arc; -use tracing::{error, info}; +use tracing::{error, info, warn}; use utoipa::ToSchema; use crate::application::dtos::display_helpers::{ @@ -56,6 +56,7 @@ pub async fn get_favorites( auth_user: AuthUser, ) -> impl IntoResponse { let user_id = auth_user.id; + warn!("Deprecated endpoint called: GET /api/favorites — use GET /api/favorites/resources instead"); match favorites_service.get_favorites(user_id).await { Ok(favorites) => { diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 8c8b19c5..e8aea4ca 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -490,6 +490,7 @@ pub async fn list_folder_contents( auth_user: AuthUser, path: Path, ) -> axum::response::Response { + tracing::warn!("Deprecated endpoint called: GET /api/folders/{{id}}/contents — use GET /api/folders/{{id}}/resources?resource_types=folder instead"); FolderHandler::list_folder_contents_impl(state, auth_user, path).await } @@ -533,6 +534,7 @@ pub async fn list_folder_contents_paginated( path: Path, pagination: Query, ) -> axum::response::Response { + tracing::warn!("Deprecated endpoint called: GET /api/folders/{{id}}/contents/paginated — use GET /api/folders/{{id}}/resources instead"); FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await } diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 5ce50631..fe6d978a 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -6,7 +6,7 @@ use axum::{ }; use serde::Deserialize; use std::sync::Arc; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::application::dtos::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, @@ -47,6 +47,7 @@ pub async fn get_recent_items( Query(params): Query, ) -> impl IntoResponse { let user_id = auth_user.id; + warn!("Deprecated endpoint called: GET /api/recent — use GET /api/recent/resources instead"); match recent_service.get_recent_items(user_id, params.limit).await { Ok(items) => { diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index da26f78d..17ae3e82 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -338,8 +338,8 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { }; Router::new() - .route("/", get(get_favorites)) // deprecated, kept for compat - .route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint + .route("/", get(get_favorites)) // deprecated — kept for external compat + .route("/resources", get(list_favorites_resources)) .route("/batch", post(favorites_handler::batch_add_favorites)) .route( "/{item_type}/{item_id}", @@ -360,7 +360,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { use crate::interfaces::api::handlers::recent_handler; Router::new() - .route("/", get(recent_handler::get_recent_items)) + .route("/", get(recent_handler::get_recent_items)) // deprecated — kept for external compat .route("/resources", get(recent_handler::list_recent_resources)) .route( "/{item_type}/{item_id}", From ae4fb7603ab8ef5aef93ca550957f6b1fa65ec3b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 12:54:52 +0200 Subject: [PATCH 05/12] refactor(front): remove dead code (old Shared section) --- static/css/views/shared.css | 294 ---------- static/index.html | 2 - static/js/app/main.js | 8 +- static/js/app/navigation.js | 9 +- static/js/components/shareModal.js | 3 +- static/js/core/types.js | 14 +- static/js/features/sharing/fileSharing.js | 10 +- static/js/views/shared/sharedView.js | 672 ---------------------- 8 files changed, 9 insertions(+), 1003 deletions(-) delete mode 100644 static/css/views/shared.css delete mode 100644 static/js/views/shared/sharedView.js diff --git a/static/css/views/shared.css b/static/css/views/shared.css deleted file mode 100644 index 380605b2..00000000 --- a/static/css/views/shared.css +++ /dev/null @@ -1,294 +0,0 @@ -.page-description { - color: var(--color-text-muted); - font-size: 16px; - margin-top: -15px; - margin-bottom: 25px; -} - -.shared-page-container { - max-width: 1280px; - margin: 20px auto; - padding: 0 20px; -} - -.shared-header { - margin-bottom: 25px; -} - -.shared-header h2 { - font-size: 24px; - color: var(--color-text); - margin-bottom: 8px; -} - -.shared-header p { - color: var(--color-text-muted); - font-size: 16px; -} - -/* In-app Shared view (index.html dynamic container) */ -.shared-view-container .shared-header { - margin-bottom: 20px; -} - -.shared-view-container .shared-filters { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 0; - padding: 15px; - background-color: var(--color-bg-subtle); - box-shadow: 0 1px 3px var(--color-shadow-xs); - border-radius: 10px 10px 0 0; - border-bottom: 1px solid var(--color-border-faint); - font-weight: 600; - color: var(--color-text); -} - -.shared-view-container .shared-custom-select { - position: relative; -} - -.shared-view-container .shared-select-toggle { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 10px 40px 10px 16px; - border-radius: 12px; - border: 2px solid var(--color-border); - background-color: var(--color-bg-hover); - font-size: 14px; - font-weight: 500; - color: var(--color-text-heading); - cursor: pointer; - min-width: 170px; - height: 44px; - transition: all 0.2s ease; - text-align: left; -} - -.shared-view-container .shared-select-toggle:hover { - border-color: var(--color-border-medium); - background-color: var(--color-bg-surface); -} - -.shared-view-container .shared-custom-select.open .shared-select-toggle { - border-color: var(--color-accent); - background-color: var(--color-bg-surface); - box-shadow: 0 0 0 4px var(--color-accent-ring); -} - -.shared-view-container .shared-select-arrow { - position: absolute; - right: 14px; - top: 50%; - transform: translateY(-50%); - font-size: 10px; - color: var(--color-text-muted); - transition: transform 0.2s ease; - pointer-events: none; -} - -.shared-view-container .shared-custom-select.open .shared-select-arrow { - transform: translateY(-50%) rotate(180deg); -} - -.shared-view-container .shared-select-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - min-width: 100%; - background-color: var(--color-bg-surface); - border-radius: 12px; - box-shadow: 0 4px 20px var(--color-shadow-md); - border: 1px solid var(--color-border); - opacity: 0; - visibility: hidden; - transform: translateY(-10px); - transition: all 0.2s ease; - z-index: 1000; - overflow: hidden; -} - -.shared-view-container .shared-custom-select.open .shared-select-dropdown { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.shared-view-container .shared-select-option { - display: flex; - align-items: center; - padding: 12px 16px; - cursor: pointer; - font-size: 14px; - font-weight: 400; - color: var(--color-text-secondary); - transition: background-color 0.15s ease; -} - -.shared-view-container .shared-select-option:hover { - background-color: var(--color-bg-alt); -} - -.shared-view-container .shared-select-option.active { - background-color: var(--color-accent-tint); - color: var(--color-accent); - font-weight: 500; -} - -.shared-filters { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0; - flex-wrap: wrap; - gap: 15px; - background-color: var(--color-bg-subtle); - border-radius: 10px 10px 0 0; - padding: 15px; - border-bottom: 1px solid var(--color-border-faint); - box-shadow: 0 1px 3px var(--color-shadow-xs); - font-weight: 600; - color: var(--color-text); -} - -.filter-group { - display: flex; - align-items: center; - gap: 10px; -} - -.filter-group label { - font-size: 14px; - color: var(--color-text-secondary); - font-weight: 500; -} - -.filter-group select { - padding: 8px 12px; - border: 1px solid var(--color-border); - border-radius: 6px; - background-color: var(--color-bg-surface); - font-size: 14px; - min-width: 120px; -} - -.search-box { - display: flex; - gap: 10px; -} - -.search-box input { - padding: 8px 15px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 14px; - width: 250px; -} - -.shared-list-container { - background-color: var(--color-bg-surface); - border-radius: 0 0 10px 10px; - box-shadow: 0 1px 3px var(--color-shadow-xs); - overflow: hidden; - margin-bottom: 25px; -} - -.shared-list, -.shared-table { - width: 100%; - border-collapse: collapse; -} - -.shared-list thead th, -.shared-table thead th { - padding: 15px; - text-align: left; - font-weight: 600; - color: var(--color-text); - background-color: var(--color-bg-subtle); - border-bottom: 1px solid var(--color-border-faint); -} - -.shared-list tbody td, -.shared-table tbody td { - padding: 15px; - border-bottom: 1px solid var(--color-border-xfaint); - vertical-align: middle; - color: var(--color-text); -} - -.shared-item-name { - display: flex; - align-items: center; - gap: 10px; -} - -.shared-item-actions { - display: flex; - gap: 10px; -} - -.shared-list .action-btn, -.shared-table .action-btn { - width: 32px; - height: 32px; - border: 1px solid var(--color-border); - border-radius: 6px; - background-color: var(--color-bg-surface); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; -} - -.shared-list .action-btn:hover, -.shared-table .action-btn:hover { - background-color: var(--color-item-hover-blue); - border-color: var(--color-info-border-light); -} - -.action-icon { - font-size: 14px; -} - -#empty-shared-state.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 50px 20px; - background-color: transparent; - border-radius: 0; - box-shadow: none; - text-align: center; - color: var(--color-text-gray); -} - -#empty-shared-state p { - margin-bottom: 10px; - max-width: 400px; -} - -@media (max-width: 768px) { - .shared-filters { - flex-direction: column; - align-items: flex-start; - } - - .shared-list thead th:nth-child(4), - .shared-list thead th:nth-child(5), - .shared-list tbody td:nth-child(4), - .shared-list tbody td:nth-child(5), - .shared-table thead th:nth-child(4), - .shared-table thead th:nth-child(5), - .shared-table tbody td:nth-child(4), - .shared-table tbody td:nth-child(5) { - display: none; - } -} diff --git a/static/index.html b/static/index.html index 204d0884..d5526fb6 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,6 @@ - @@ -41,7 +40,6 @@ - diff --git a/static/js/app/main.js b/static/js/app/main.js index e991c638..ec71788d 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -18,7 +18,6 @@ import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; import { grants } from '../model/grants.js'; import { recentView } from '../views/recent/recentView.js'; -import { sharedView } from '../views/shared/sharedView.js'; import { checkAuthentication } from './authSession.js'; import { loadFiles } from './filesView.js'; import { @@ -700,11 +699,8 @@ function setupEventListeners() { if (searchDebounceTimer) clearTimeout(searchDebounceTimer); const query = elements.searchInput?.value.trim(); - // In shared section, filter locally - if (app.currentSection === 'shared' && sharedView) { - sharedView.filterAndSortItems(); - return; - } + // My Shares section does not support in-page search + if (app.currentSection === 'shared') return; if (query) { performSearch(query); diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index c58cca24..c6d89bba 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -13,7 +13,6 @@ import { photosView } from '../features/library/photos.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; import { mySharesView } from '../views/myShares/mySharesView.js'; import { recentView } from '../views/recent/recentView.js'; -import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { filesView, loadFiles } from './filesView.js'; import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js'; @@ -166,9 +165,8 @@ function setCurrentSection(section) { appElements.pageTitle.setAttribute('data-i18n', titleKey); } - // Hide sharedView and mySharesView when switching to any other section + // Hide mySharesView when switching to any other section if (section !== 'shared') { - if (sharedView) sharedView.hide(); mySharesView.hide(); } @@ -301,10 +299,7 @@ function switchToFilesSection() { ui.updateBreadcrumb(); if (batchToolbar) batchToolbar.clear(); - // temp solution - sharedView.loadItems().then(() => { - loadFiles(); - }); + loadFiles(); } function switchToFavoritesSection() { diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index dd6635f7..1307b9df 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -858,8 +858,7 @@ const shareModal = { 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 + expires_at: expiresTs }); } } diff --git a/static/js/core/types.js b/static/js/core/types.js index 4a533d4c..d89054a8 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -46,13 +46,6 @@ * @property {number} sort_date */ -/** - * @typedef {Object} SharePermissions - * @property {boolean} read - * @property {boolean} reshare - * @property {boolean} write - */ - /** * @typedef {Object} ShareItem * @property {number} access_count @@ -64,7 +57,6 @@ * @property {string} item_id * @property {string} item_name * @property {ItemTypeEnum} item_type - * @property {SharePermissions} permissions * @property {string | null} token * @property {string} url */ @@ -76,14 +68,12 @@ * @property {ItemTypeEnum} item_type * @property {string|null} password * @property {number|null} expires_at - timestamp - * @property {SharePermissions|null} permissions */ /** * @typedef {Object} UpdateShare - * @property {string|null} [password] - * @property {number|null} [expires_at] - * @property {SharePermissions|null} [permissions] + * @property {string|null} [password] + * @property {number|null} [expires_at] */ /** diff --git a/static/js/features/sharing/fileSharing.js b/static/js/features/sharing/fileSharing.js index 98117154..5c873a69 100644 --- a/static/js/features/sharing/fileSharing.js +++ b/static/js/features/sharing/fileSharing.js @@ -35,12 +35,7 @@ const fileSharing = { item_name: options.item_name || null, item_type: itemType, password: options.password || null, - expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null, - permissions: options.permissions || { - read: true, - write: false, - reshare: false - } + expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null }; const res = await fetch('/api/shares', { @@ -113,12 +108,11 @@ const fileSharing = { /** * Update a shared link * @param {string} shareId - * @param {UpdateShare} updateData - { permissions, password, expires_at } + * @param {UpdateShare} updateData - { password, expires_at } * @returns {Promise} Updated ShareDto */ async updateSharedLink(shareId, updateData) { const body = {}; - if (updateData.permissions) body.permissions = updateData.permissions; if (updateData.password !== undefined) body.password = updateData.password; if (updateData.expires_at !== undefined) body.expires_at = updateData.expires_at; diff --git a/static/js/views/shared/sharedView.js b/static/js/views/shared/sharedView.js deleted file mode 100644 index 4fe18e4e..00000000 --- a/static/js/views/shared/sharedView.js +++ /dev/null @@ -1,672 +0,0 @@ -/** - * OxiCloud - Shared View Component - * In-app shared files view. All operations go through the backend API. - */ - -import { switchToFilesSection } from '../../app/navigation.js'; -import { ui } from '../../app/ui.js'; -import { getCsrfHeaders } from '../../core/csrf.js'; -import { formatDateShort, isEmailValid } from '../../core/formatters.js'; -import { i18n } from '../../core/i18n.js'; -import { fileSharing } from '../../features/sharing/fileSharing.js'; - -/** @import {ShareItem} from '../../core/types.js' */ - -const TTL = 5 * 60 * 1000; // 5 min - -const sharedView = { - // State - - /** @type {Array} */ - items: [], - - _expires: 0, - - /** @type {Map} key = "file:" | "folder:" */ - _knownItemsId: new Map(), - - /** @type {Array} */ - filteredItems: [], - - /** @type {ShareItem | null} */ - currentItem: null, - - /** Auth header helper — tokens are in HttpOnly cookies now */ - _headers(json = false) { - const h = { ...getCsrfHeaders() }; - if (json) h['Content-Type'] = 'application/json'; - return h; - }, - - async init() { - console.log('Initializing shared view component (API-backed)'); - await this.loadItems(); - }, - - show() { - this.displayUI(); - this.attachEventListeners(); - this.filterAndSortItems(); - const c = document.getElementById('shared-container'); - if (c) c.classList.remove('hidden'); - }, - - hide() { - const c = document.getElementById('shared-container'); - if (c) c.classList.add('hidden'); - }, - - /** - * tells if item_id is shared - * - * @param {string} id the item_id - * @param {string} type folder|file - * @returns {boolean} true if this item is shared - */ - isShared(id, type) { - return this._knownItemsId.has(`${type}:${id}`); - }, - - // Load shared items from backend API, - // TODO cache entries to minimize calls - /** - * load shared items - * - * @param {boolean} force ignore cache - */ - async loadItems(force = false) { - if (this._expires > Date.now() && !force) return; - - try { - const res = await fetch('/api/shares?page=1&per_page=1000', { - headers: this._headers() - }); - if (res.ok) { - const data = await res.json(); - this.items = data.items || []; - } else { - this.items = []; - } - this.filteredItems = { ...this.items }; - this._knownItemsId.clear(); - this.items.forEach((item) => { - this._knownItemsId.set(`${item.item_type}:${item.item_id}`, true); - }); - this._expires = Date.now() + TTL; - } catch (err) { - console.error('Error loading shared items:', err); - this.items = []; - } - }, - - // Create and display the shared view UI - displayUI() { - const contentArea = document.querySelector('.content-area'); - - let container = document.getElementById('shared-container'); - if (!container) { - container = document.createElement('div'); - container.id = 'shared-container'; - container.className = 'shared-view-container'; - if (contentArea) contentArea.appendChild(container); - } - - container.innerHTML = ` -
-
-
- -
-
All
-
Files
-
Folders
-
-
-
- -
-
Sort by date
-
Sort by name
-
Sort by expiration
-
-
-
-
- - - - - - - - - - - `; - - i18n.translateElement(container); - }, - - // Attach event listeners - attachEventListeners() { - // Custom dropdown logic for filter-type - this._initCustomSelect('filter-type-wrapper', 'filter-type-toggle', 'filter-type-dropdown'); - // Custom dropdown logic for sort-by - this._initCustomSelect('sort-by-wrapper', 'sort-by-toggle', 'sort-by-dropdown'); - - // Close dropdowns when clicking outside - document.addEventListener('click', (e) => { - document.querySelectorAll('.shared-custom-select.open').forEach((sel) => { - if (!(e.target instanceof Node)) return; - if (!sel.contains(e.target)) sel.classList.remove('open'); - }); - }); - - // Share dialog (sharedView-specific IDs) - const shareDialog = document.getElementById('shared-view-edit-dialog'); - if (shareDialog) { - const closeBtn = shareDialog.querySelector('.close-dialog-btn'); - if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog()); - const copyLinkBtn = document.getElementById('sv-copy-link-btn'); - if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink()); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - if (enablePw) - enablePw.addEventListener('change', () => { - if (pwField) { - pwField.disabled = !enablePw.checked; - if (enablePw.checked) pwField.focus(); - } - }); - const genPwBtn = document.getElementById('sv-generate-password'); - if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword()); - const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration')); - const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration')); - if (enableExp) - enableExp.addEventListener('change', () => { - if (expField) { - expField.disabled = !enableExp.checked; - if (enableExp.checked) expField.focus(); - } - }); - const updateBtn = document.getElementById('sv-update-share-btn'); - if (updateBtn) updateBtn.addEventListener('click', () => this.updateSharedItem()); - const removeBtn = document.getElementById('sv-remove-share-btn'); - if (removeBtn) removeBtn.addEventListener('click', () => this.removeSharedItem()); - } - - // Notification dialog (sharedView-specific IDs) - const notifDialog = document.getElementById('sv-notification-dialog'); - if (notifDialog) { - const closeBtn = notifDialog.querySelector('.close-dialog-btn'); - if (closeBtn) closeBtn.addEventListener('click', () => this.closeNotificationDialog()); - const sendBtn = document.getElementById('sv-send-notification-btn'); - if (sendBtn) sendBtn.addEventListener('click', () => this.sendNotification()); - } - - // "Go to Files" button in empty state - const goToFilesBtn = document.getElementById('go-to-files-btn'); - if (goToFilesBtn) { - goToFilesBtn.addEventListener('click', () => { - if (switchToFilesSection) switchToFilesSection(); - }); - } - }, - - // Initialize a custom select dropdown - /** - * - * @param {string} wrapperId - * @param {string} toggleId - * @param {string} dropdownId - * @returns - */ - _initCustomSelect(wrapperId, toggleId, dropdownId) { - const wrapper = document.getElementById(wrapperId); - const toggle = document.getElementById(toggleId); - const dropdown = document.getElementById(dropdownId); - if (!wrapper || !toggle || !dropdown) return; - - toggle.addEventListener('click', (e) => { - e.stopPropagation(); - // Close other open selects - document.querySelectorAll('.shared-custom-select.open').forEach((sel) => { - if (sel !== wrapper) sel.classList.remove('open'); - }); - wrapper.classList.toggle('open'); - }); - - dropdown.querySelectorAll('.shared-select-option').forEach((option) => { - option.addEventListener('click', (e) => { - e.stopPropagation(); - // Update active state - dropdown.querySelectorAll('.shared-select-option').forEach((o) => { - o.classList.remove('active'); - }); - option.classList.add('active'); - // Update label - const label = toggle.querySelector('.shared-select-label'); - if (label) label.textContent = option.textContent; - // Close dropdown - wrapper.classList.remove('open'); - // Trigger filter - this.filterAndSortItems(); - }); - }); - }, - - // Filter and sort items - filterAndSortItems() { - const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-dropdown .shared-select-option.active')); - const sortByActive = /** @type {HTMLDivElement} */ (document.querySelector('#sort-by-dropdown .shared-select-option.active')); - - const type = filterTypeActive ? filterTypeActive.dataset.value : 'all'; - const sort = sortByActive ? sortByActive.dataset.value : 'date'; - - // Use the main top-bar search input - const searchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input')); - const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; - - this.filteredItems = this.items.filter((item) => { - if (type !== 'all' && item.item_type !== type) return false; - const name = (item.item_name || item.item_id || '').toLowerCase(); - return name.includes(searchTerm); - }); - - this.filteredItems.sort((a, b) => { - if (sort === 'name') { - return (a.item_name || a.item_id || '').localeCompare(b.item_name || b.item_id || ''); - } else if (sort === 'date') { - return (b.created_at || 0) - (a.created_at || 0); - } else if (sort === 'expiration') { - if (!a.expires_at && !b.expires_at) return 0; - if (!a.expires_at) return 1; - if (!b.expires_at) return -1; - return a.expires_at - b.expires_at; - } - return 0; - }); - - this.displaySharedItems(); - }, - - // Display items in the table - displaySharedItems() { - const sharedItemsList = document.getElementById('shared-items-list'); - const emptyState = document.getElementById('empty-shared-state'); - const listContainer = document.querySelector('.shared-list-container'); - - if (!sharedItemsList || !emptyState || !listContainer) return; - sharedItemsList.innerHTML = ''; - - if (this.filteredItems.length === 0) { - emptyState.classList.remove('hidden'); - listContainer.classList.add('hidden'); - return; - } - - emptyState.classList.add('hidden'); - listContainer.classList.remove('hidden'); - - this.filteredItems.forEach((item) => { - const row = document.createElement('tr'); - const displayName = item.item_name || item.item_id || 'Unknown'; - - const nameCell = document.createElement('td'); - nameCell.className = 'shared-item-name'; - const iconSpan = document.createElement('span'); - iconSpan.className = 'item-icon'; - iconSpan.textContent = item.item_type === 'file' ? '📄' : '📁'; - const nameSpan = document.createElement('span'); - nameSpan.textContent = displayName; - nameCell.appendChild(iconSpan); - nameCell.appendChild(nameSpan); - - const typeCell = document.createElement('td'); - typeCell.textContent = item.item_type === 'file' ? i18n.t('shared_typeFile', 'File') : i18n.t('shared_typeFolder', 'Folder'); - - const dateCell = document.createElement('td'); - dateCell.textContent = formatDateShort(item.created_at); - - const expCell = document.createElement('td'); - expCell.textContent = item.expires_at ? formatDateShort(item.expires_at) : i18n.t('shared_noExpiration', 'No expiration'); - - const permCell = document.createElement('td'); - const perms = []; - if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read')); - if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write')); - if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare')); - permCell.textContent = perms.join(', ') || 'Read'; - - const pwCell = document.createElement('td'); - pwCell.textContent = item.has_password ? i18n.t('shared_hasPassword', 'Yes') : i18n.t('shared_noPassword', 'No'); - - const actionsCell = document.createElement('td'); - actionsCell.className = 'shared-item-actions'; - - const editBtn = document.createElement('button'); - editBtn.className = 'action-btn edit-btn'; - editBtn.innerHTML = '✏️'; - editBtn.title = i18n.t('shared_editShare', 'Edit Share'); - editBtn.addEventListener('click', () => this.openShareDialog(item)); - - const notifyBtn = document.createElement('button'); - notifyBtn.className = 'action-btn notify-btn'; - notifyBtn.innerHTML = '📧'; - notifyBtn.title = i18n.t('shared_notifyShare', 'Notify Someone'); - notifyBtn.addEventListener('click', () => this.openNotificationDialog(item)); - - const copyBtn = document.createElement('button'); - copyBtn.className = 'action-btn copy-btn'; - copyBtn.innerHTML = '📋'; - copyBtn.title = i18n.t('shared_copyLink', 'Copy Link'); - copyBtn.addEventListener('click', () => { - navigator.clipboard - .writeText(item.url) - .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success')) - .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error')); - }); - - const rmBtn = document.createElement('button'); - rmBtn.className = 'action-btn remove-btn'; - rmBtn.innerHTML = '🗑️'; - rmBtn.title = i18n.t('shared_removeShare', 'Remove Share'); - rmBtn.addEventListener('click', () => { - this.currentItem = item; - this.removeSharedItem(); - }); - - actionsCell.append(editBtn, notifyBtn, copyBtn, rmBtn); - row.append(nameCell, typeCell, dateCell, expCell, permCell, pwCell, actionsCell); - sharedItemsList.appendChild(row); - }); - }, - - // Open share dialog - /** - * - * @param {ShareItem} item - * @returns {void} - */ - openShareDialog(item) { - this.currentItem = item; - const shareDialog = document.getElementById('shared-view-edit-dialog'); - const dn = item.item_name || item.item_id || 'Unknown'; - - const iconEl = document.getElementById('sv-dialog-icon'); - const nameEl = document.getElementById('sv-dialog-name'); - const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url')); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration')); - const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration')); - const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read')); - const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write')); - const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare')); - - if (!shareDialog) return; - if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; - if (nameEl) nameEl.textContent = dn; - if (urlEl) urlEl.value = item.url || ''; - - if (permRead) permRead.checked = item.permissions?.read !== false; - if (permWrite) permWrite.checked = !!item.permissions?.write; - if (permReshare) permReshare.checked = !!item.permissions?.reshare; - - if (enablePw) { - enablePw.checked = item.has_password; - if (pwField) { - pwField.disabled = !enablePw.checked; - pwField.value = ''; - } - } - if (enableExp) { - enableExp.checked = !!item.expires_at; - if (expField) { - expField.disabled = !enableExp.checked; - expField.value = item.expires_at ? new Date(item.expires_at * 1000).toISOString().split('T')[0] : ''; - } - } - - shareDialog.classList.remove('hidden'); - }, - - closeShareDialog() { - const d = document.getElementById('shared-view-edit-dialog'); - if (d) d.classList.add('hidden'); - this.currentItem = null; - }, - - /** - * - * @param {ShareItem} item - * @returns {void} - */ - openNotificationDialog(item) { - this.currentItem = item; - const dn = item.item_name || item.item_id || 'Unknown'; - const d = document.getElementById('sv-notification-dialog'); - const iconEl = document.getElementById('sv-notify-dialog-icon'); - const nameEl = document.getElementById('sv-notify-dialog-name'); - const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email')); - const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message')); - - if (!d) return; - if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; - if (nameEl) nameEl.textContent = dn; - if (emailEl) emailEl.value = ''; - if (msgEl) msgEl.value = ''; - d.classList.remove('hidden'); - }, - - closeNotificationDialog() { - const d = document.getElementById('sv-notification-dialog'); - if (d) d.classList.add('hidden'); - this.currentItem = null; - }, - - copyShareLink() { - const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url')); - if (!el) return; - navigator.clipboard - .writeText(el.value) - .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success')) - .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error')); - }, - - // Generate secure password with crypto API - generatePassword() { - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - if (!pwField || !enablePw) return; - - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; - const array = new Uint32Array(16); - crypto.getRandomValues(array); - let password = ''; - for (let i = 0; i < 16; i++) { - password += chars[array[i] % chars.length]; - } - pwField.value = password; - enablePw.checked = true; - pwField.disabled = false; - }, - - // Update share via API - async updateSharedItem() { - if (!this.currentItem) return; - - const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read')); - const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write')); - const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare')); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration')); - const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration')); - - const body = { - permissions: { - read: permRead ? permRead.checked : true, - write: permWrite ? permWrite.checked : false, - reshare: permReshare ? permReshare.checked : false - }, - password: enablePw?.checked && pwField?.value ? pwField.value : null, - expires_at: enableExp?.checked && expField?.value ? Math.floor(new Date(expField.value).getTime() / 1000) : null - }; - - try { - // FIXME: redundance with fileSharing - const res = await fetch(`/api/shares/${this.currentItem.id}`, { - method: 'PUT', - headers: this._headers(true), - body: JSON.stringify(body) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || `Server error ${res.status}`); - } - ui.showNotification(i18n.t('shared_itemUpdated', 'Share settings updated'), 'success'); - } catch (err) { - console.error('Error updating share:', err); - ui.showNotification(/** @type {Error} */ (err).message || 'Error updating share', 'error'); - } - // update UI - ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true); - this.closeShareDialog(); - await this.loadItems(true); - this.filterAndSortItems(); - }, - - // Remove share via API - async removeSharedItem() { - if (!this.currentItem) return; - - try { - // FIXME: redundance with fileSharing - const res = await fetch(`/api/shares/${this.currentItem.id}`, { - method: 'DELETE', - headers: this._headers() - }); - if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`); - ui.showNotification(i18n.t('shared_itemRemoved', 'Share removed'), 'success'); - } catch (err) { - console.error('Error removing share:', err); - ui.showNotification('Error removing share', 'error'); - } - - this.closeShareDialog(); - await this.loadItems(true); - this.filterAndSortItems(); - // update UI - ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, this.isShared(this.currentItem.item_id, this.currentItem.item_type)); - }, - - // Send notification (stub) - sendNotification() { - if (!this.currentItem) return; - const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email')); - const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message')); - const email = emailEl ? emailEl.value.trim() : ''; - const message = msgEl ? msgEl.value.trim() : ''; - - if (!email || !isEmailValid(email)) { - ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error'); - return; - } - - if (fileSharing?.sendShareNotification) { - fileSharing - .sendShareNotification(this.currentItem.url, email, message) - .then(() => { - this.closeNotificationDialog(); - ui.showNotification(i18n.t('shared_notificationSent', 'Notification sent'), 'success'); - }) - .catch(() => ui.showNotification(i18n.t('shared_notificationFailed', 'Failed to send notification'), 'error')); - } - } -}; - -export { sharedView }; From 5a43b14e29fe834a2a6c97ca302edc47b5cf28bd Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 12:55:26 +0200 Subject: [PATCH 06/12] test(api): move deprecated call to new one --- tests/api/favorites.hurl | 38 +++++++++++++++++++------------------- tests/api/grants.hurl | 4 ++-- tests/api/recent.hurl | 14 +++++++------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/api/favorites.hurl b/tests/api/favorites.hurl index 83976e4f..f766d997 100644 --- a/tests/api/favorites.hurl +++ b/tests/api/favorites.hurl @@ -30,13 +30,13 @@ jsonpath "$.access_token" isString # ───────────────────────────────────────────────────────────── # Step 2 – No favorites yet # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" isCollection -jsonpath "$" count == 0 +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0 # ───────────────────────────────────────────────────────────── @@ -85,15 +85,15 @@ HTTP 201 # ───────────────────────────────────────────────────────────── # Step 5 – Favorites contains only hello-renamed.txt # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].item_id" == {{file_id}} -jsonpath "$[0].item_type" == "file" -jsonpath "$[0].item_name" == "hello-renamed.txt" +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource.id" == {{file_id}} +jsonpath "$.items[0].resource_type" == "file" +jsonpath "$.items[0].resource.name" == "hello-renamed.txt" # ───────────────────────────────────────────────────────────── @@ -108,14 +108,14 @@ HTTP 201 # ───────────────────────────────────────────────────────────── # Step 7 – Favorites contains both items (order-independent) # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 2 -jsonpath "$[*].item_id" contains {{file_id}} -jsonpath "$[*].item_id" contains {{test1_id}} +jsonpath "$.items" count == 2 +jsonpath "$.items[*].resource.id" contains {{file_id}} +jsonpath "$.items[*].resource.id" contains {{test1_id}} # ───────────────────────────────────────────────────────────── @@ -130,15 +130,15 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 9 – Favorites contains only test1 folder # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].item_id" == {{test1_id}} -jsonpath "$[0].item_type" == "folder" -jsonpath "$[0].item_name" == "test1" +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource.id" == {{test1_id}} +jsonpath "$.items[0].resource_type" == "folder" +jsonpath "$.items[0].resource.name" == "test1" # ───────────────────────────────────────────────────────────── @@ -153,9 +153,9 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 11 – Favorites is empty again # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 0 +jsonpath "$.items" count == 0 diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index a10ba6f1..264ae704 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -391,7 +391,7 @@ Authorization: Bearer {{adam_token}} HTTP 404 -GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated +GET {{base_url}}/api/folders/{{perm_folder_id}}/resources Authorization: Bearer {{adam_token}} HTTP 404 @@ -514,7 +514,7 @@ HTTP 200 jsonpath "$" count == 1 jsonpath "$[0].id" == "{{perm_child_id}}" -GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated +GET {{base_url}}/api/folders/{{perm_folder_id}}/resources Authorization: Bearer {{adam_token}} HTTP 200 diff --git a/tests/api/recent.hurl b/tests/api/recent.hurl index 2fd5c08b..c745adb4 100644 --- a/tests/api/recent.hurl +++ b/tests/api/recent.hurl @@ -71,14 +71,14 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 4 – Recent list contains hello-renamed.txt # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/recent +GET {{base_url}}/api/recent/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].item_type" == "file" -jsonpath "$[0].item_name" == "hello-renamed.txt" +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource_type" == "file" +jsonpath "$.items[0].resource.name" == "hello-renamed.txt" # ───────────────────────────────────────────────────────────── @@ -93,10 +93,10 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 6 – Recent list is empty after clear # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/recent +GET {{base_url}}/api/recent/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" isCollection -jsonpath "$" count == 0 +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0 From b782a597667ec8268d4c0b41d12b5fb3addeac19 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 13:03:12 +0200 Subject: [PATCH 07/12] refactor(ui): move deprecated to call /api/folders/id/content to the cursor version /api/folders/id/resources --- static/js/features/files/contextMenus.js | 31 ++++++++++-------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index c0520e40..35a33366 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -489,24 +489,20 @@ const contextMenus = { return; } - // Use the contents endpoint to get children - const url = `/api/folders/${effectiveParentId}/contents`; - - console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId); - const response = await fetch(url, { credentials: 'same-origin' }); - if (!response.ok) { - console.error('Failed to load folders:', response.status); - return; - } - - const data = await response.json(); - console.log('[Move Dialog] API response:', data); - - // The contents endpoint returns an array of child folders - // The fallback /api/folders returns root folders (home folder itself) /** @type {FolderItem[]} */ - const folders = Array.isArray(data) ? data : data.folders || []; - console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders); + const folders = []; + let cursor = /** @type {string|null} */ (null); + do { + const qs = cursor ? `resource_types=folder&cursor=${encodeURIComponent(cursor)}` : 'resource_types=folder'; + const response = await fetch(`/api/folders/${effectiveParentId}/resources?${qs}`, { credentials: 'same-origin' }); + if (!response.ok) { + console.error('Failed to load folders:', response.status); + return; + } + const data = await response.json(); + folders.push(...(data.items || [])); + cursor = data.next_cursor ?? null; + } while (cursor); const folderSelectContainer = document.getElementById('folder-select-container'); const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb'); @@ -719,7 +715,6 @@ const contextMenus = { app.moveDialogBreadcrumb = []; app.moveDialogCurrentFolderId = app.userHomeFolderId || null; - // Use loadMoveDialogFolders which uses /api/folders/{id}/contents await this.loadMoveDialogFolders(app.userHomeFolderId || null); }, From 3f70c7e20afdec61a63158db4c5b2a8092efad30 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 13:10:13 +0200 Subject: [PATCH 08/12] refactor(server): remove sort by `size` in pg_acl_engine.rs (dead code) --- src/infrastructure/services/pg_acl_engine.rs | 44 ------------------- .../api/handlers/favorites_handler.rs | 4 +- src/interfaces/api/handlers/folder_handler.rs | 8 +++- src/interfaces/api/handlers/grant_handler.rs | 7 +-- 4 files changed, 11 insertions(+), 52 deletions(-) diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index afa9532b..e4a6898c 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -469,42 +469,6 @@ impl AuthorizationEngine for PgAclEngine { LIMIT $8"# ) } - "size" => { - // Folders have no size — sentinel -1 (sorts first ASC, last DESC). - // Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused. - let (where_clause, order_clause) = if reverse { - ( - r#"( $5::bigint IS NULL - OR sort_int < $5 - OR (sort_int = $5 AND resource_id < $7::uuid))"#, - "sort_int DESC, resource_id DESC", - ) - } else { - ( - r#"( $5::bigint IS NULL - OR sort_int > $5 - OR (sort_int = $5 AND resource_id > $7::uuid))"#, - "sort_int ASC, resource_id ASC", - ) - }; - format!( - r#"WITH {AGG}, - sized AS ( - SELECT agg.*, - NULL::text AS sort_str, - CASE WHEN agg.resource_type = 'folder' THEN -1 - ELSE fi.size - END AS sort_int - FROM agg - LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' - ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int - FROM sized - WHERE {where_clause} - ORDER BY {order_clause} - LIMIT $8"# - ) - } _ => { // Default: sort by grant date. // Normal = DESC (newest first); reversed = ASC (oldest first). @@ -588,14 +552,6 @@ impl AuthorizationEngine for PgAclEngine { sort_int: None, reverse, }, - "size" => GrantCursor { - sort_by: "size".to_owned(), - granted_at: r.3, - resource_id: r.1, - resource_name: None, - sort_int: r.6, - reverse, - }, _ => GrantCursor { sort_by: "granted_at".to_owned(), granted_at: r.3, diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index bd4da7ba..05d21264 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -56,7 +56,9 @@ pub async fn get_favorites( auth_user: AuthUser, ) -> impl IntoResponse { let user_id = auth_user.id; - warn!("Deprecated endpoint called: GET /api/favorites — use GET /api/favorites/resources instead"); + warn!( + "Deprecated endpoint called: GET /api/favorites — use GET /api/favorites/resources instead" + ); match favorites_service.get_favorites(user_id).await { Ok(favorites) => { diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index e8aea4ca..088015b5 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -490,7 +490,9 @@ pub async fn list_folder_contents( auth_user: AuthUser, path: Path, ) -> axum::response::Response { - tracing::warn!("Deprecated endpoint called: GET /api/folders/{{id}}/contents — use GET /api/folders/{{id}}/resources?resource_types=folder instead"); + tracing::warn!( + "Deprecated endpoint called: GET /api/folders/{{id}}/contents — use GET /api/folders/{{id}}/resources?resource_types=folder instead" + ); FolderHandler::list_folder_contents_impl(state, auth_user, path).await } @@ -534,7 +536,9 @@ pub async fn list_folder_contents_paginated( path: Path, pagination: Query, ) -> axum::response::Response { - tracing::warn!("Deprecated endpoint called: GET /api/folders/{{id}}/contents/paginated — use GET /api/folders/{{id}}/resources instead"); + tracing::warn!( + "Deprecated endpoint called: GET /api/folders/{{id}}/contents/paginated — use GET /api/folders/{{id}}/resources instead" + ); FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await } diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index eac659e3..77ab00ee 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -351,13 +351,10 @@ pub async fn list_shared_with_me( // Validate sort_by (defaults to "granted_at"). let sort_by = q.sort_by.as_deref().unwrap_or("granted_at"); - if !matches!( - sort_by, - "granted_at" | "granted_by" | "name" | "type" | "size" - ) { + if !matches!(sort_by, "granted_at" | "granted_by" | "name" | "type") { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})), + Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type"})), ) .into_response(); } From 6e5c5edd855d05dd04f200e16f5fa43462bdcb4e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 13:15:25 +0200 Subject: [PATCH 09/12] chore(ui): apply biome formatter --- static/css/views/mySharesView.css | 4 +++- static/js/core/icons.js | 2 +- static/locales/ar.json | 2 +- static/locales/de.json | 2 +- static/locales/es.json | 2 +- static/locales/fa.json | 2 +- static/locales/fr.json | 2 +- static/locales/hi.json | 2 +- static/locales/it.json | 2 +- static/locales/ja.json | 2 +- static/locales/ko.json | 2 +- static/locales/nl.json | 2 +- static/locales/pl.json | 2 +- static/locales/pt.json | 2 +- static/locales/ru.json | 2 +- static/locales/zh-TW.json | 2 +- static/locales/zh.json | 2 +- 17 files changed, 19 insertions(+), 17 deletions(-) diff --git a/static/css/views/mySharesView.css b/static/css/views/mySharesView.css index 95768af9..bf780612 100644 --- a/static/css/views/mySharesView.css +++ b/static/css/views/mySharesView.css @@ -287,7 +287,9 @@ cursor: pointer; font-size: 11px; flex-shrink: 0; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; } .ms-btn-icon:hover { diff --git a/static/js/core/icons.js b/static/js/core/icons.js index a8ca5c3b..679caff1 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -239,7 +239,7 @@ const OxiIcons = { 576, 'M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z' ], - 'infinity': [ + infinity: [ 640, 'M0 256c0-88.4 71.6-160 160-160 50.4 0 97.8 23.7 128 64l32 42.7 32-42.7c30.2-40.3 77.6-64 128-64 88.4 0 160 71.6 160 160S568.4 416 480 416c-50.4 0-97.8-23.7-128-64l-32-42.7-32 42.7c-30.2 40.3-77.6 64-128 64-88.4 0-160-71.6-160-160zm280 0l-43.2-57.6c-18.1-24.2-46.6-38.4-76.8-38.4-53 0-96 43-96 96s43 96 96 96c30.2 0 58.7-14.2 76.8-38.4L280 256zm80 0l43.2 57.6c18.1 24.2 46.6 38.4 76.8 38.4 53 0 96-43 96-96s-43-96-96-96c-30.2 0-58.7 14.2-76.8 38.4L360 256z' ], diff --git a/static/locales/ar.json b/static/locales/ar.json index 3c374b4c..940e18d7 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -732,4 +732,4 @@ "last7days": "آخر 7 أيام", "last30days": "آخر 30 يومًا" } -} \ No newline at end of file +} diff --git a/static/locales/de.json b/static/locales/de.json index eed46b8f..24d1a2c7 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -732,4 +732,4 @@ "last7days": "Letzte 7 Tage", "last30days": "Letzte 30 Tage" } -} \ No newline at end of file +} diff --git a/static/locales/es.json b/static/locales/es.json index 4630de55..a0deed14 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -732,4 +732,4 @@ "last7days": "Últimos 7 días", "last30days": "Últimos 30 días" } -} \ No newline at end of file +} diff --git a/static/locales/fa.json b/static/locales/fa.json index 0f1b9dea..e4ffcce5 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -732,4 +732,4 @@ "last7days": "۷ روز گذشته", "last30days": "۳۰ روز گذشته" } -} \ No newline at end of file +} diff --git a/static/locales/fr.json b/static/locales/fr.json index 997951b0..b410050f 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -732,4 +732,4 @@ "last7days": "7 derniers jours", "last30days": "30 derniers jours" } -} \ No newline at end of file +} diff --git a/static/locales/hi.json b/static/locales/hi.json index bb071227..011dbec5 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -732,4 +732,4 @@ "last7days": "पिछले 7 दिन", "last30days": "पिछले 30 दिन" } -} \ No newline at end of file +} diff --git a/static/locales/it.json b/static/locales/it.json index d4fd3c28..0977c1f3 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -732,4 +732,4 @@ "last7days": "Ultimi 7 giorni", "last30days": "Ultimi 30 giorni" } -} \ No newline at end of file +} diff --git a/static/locales/ja.json b/static/locales/ja.json index 6a3d54db..17b3d1fc 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -732,4 +732,4 @@ "last7days": "過去7日間", "last30days": "過去30日間" } -} \ No newline at end of file +} diff --git a/static/locales/ko.json b/static/locales/ko.json index fd0b63b3..131b5805 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -732,4 +732,4 @@ "last7days": "최근 7일", "last30days": "최근 30일" } -} \ No newline at end of file +} diff --git a/static/locales/nl.json b/static/locales/nl.json index 6dce12fd..397bc034 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -732,4 +732,4 @@ "last7days": "Afgelopen 7 dagen", "last30days": "Afgelopen 30 dagen" } -} \ No newline at end of file +} diff --git a/static/locales/pl.json b/static/locales/pl.json index 1123ab46..da2a023e 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -732,4 +732,4 @@ "last7days": "Ostatnie 7 dni", "last30days": "Ostatnie 30 dni" } -} \ No newline at end of file +} diff --git a/static/locales/pt.json b/static/locales/pt.json index abf41dfe..9835d164 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -732,4 +732,4 @@ "last7days": "Últimos 7 dias", "last30days": "Últimos 30 dias" } -} \ No newline at end of file +} diff --git a/static/locales/ru.json b/static/locales/ru.json index f5107676..f470d2cc 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -732,4 +732,4 @@ "last7days": "Последние 7 дней", "last30days": "Последние 30 дней" } -} \ No newline at end of file +} diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index c4bf8c6d..d6bd270e 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -732,4 +732,4 @@ "last7days": "近7天", "last30days": "近30天" } -} \ No newline at end of file +} diff --git a/static/locales/zh.json b/static/locales/zh.json index 4edc74d8..6f04a4a2 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -732,4 +732,4 @@ "last7days": "近7天", "last30days": "近30天" } -} \ No newline at end of file +} From 804cb7c6af4deb38b8707839c6c74745ea8314fa Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 29 May 2026 13:32:30 +0200 Subject: [PATCH 10/12] fix(build): improve builder to use CSS from html rather an hardcoded list --- build.rs | 51 +++++++++++++++++++++++++++++++++++---------------- justfile | 2 ++ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/build.rs b/build.rs index e0a2b905..de115e8c 100644 --- a/build.rs +++ b/build.rs @@ -27,18 +27,6 @@ const HTML_INCLUDE: &[&str] = &[ "share.html", ]; -// ─── View CSS files linked directly in index.html (not via @import) ────────── -const INDEX_VIEW_CSS: &[&str] = &[ - "views/inlineViewer.css", - "views/favorites.css", - "views/recent.css", - "views/shared.css", - "views/trash.css", - "views/photos.css", - "views/photosLightbox.css", - "views/music.css", -]; - // ═══════════════════════════════════════════════════════════════════════════════ // Entry point // ═══════════════════════════════════════════════════════════════════════════════ @@ -91,18 +79,28 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) { let css_dir = static_dir.join("css"); + // Read index.html once — used for both CSS and JS extraction. + let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html"); + // ── 2. Resolve main.css @imports ───────────────────────────────────────── let resolved_main = resolve_css_imports(&css_dir.join("main.css"), &css_dir); let minified_main = css_minify_safe(&resolved_main); fs::write(dist_dir.join("css/main.css"), &minified_main).expect("write main.css"); // ── 3. Build CSS bundle for index.html ─────────────────────────────────── + // Derive the list of view CSS files directly from the tags in index.html + // so build.rs never needs to be updated when a new stylesheet is added. let mut css_all = resolved_main; - for view in INDEX_VIEW_CSS { - let p = css_dir.join(view); + for view in extract_css_links(&index_html) { + let p = css_dir.join(&view); if p.exists() { css_all.push_str(&fs::read_to_string(&p).unwrap_or_default()); css_all.push('\n'); + } else { + eprintln!( + "cargo:warning=CSS link in index.html not found: {}", + p.display() + ); } } let css_bundle = css_minify_safe(&css_all); @@ -116,7 +114,6 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) { // ── 5. Bundle all ES modules into one IIFE ─────────────────────────────── // Walk the import graph starting from every