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. */ /**