refactor(share,grants): migrate expiration from legacy share into grants, simplify legacy share, normalize shareModal for better UX
This commit is contained in:
@@ -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;
|
||||||
@@ -191,6 +191,9 @@ pub struct CreateGrantDto {
|
|||||||
pub permissions: Option<Vec<PermissionDto>>,
|
pub permissions: Option<Vec<PermissionDto>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub role: Option<Role>,
|
pub role: Option<Role>,
|
||||||
|
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
||||||
@@ -199,6 +202,9 @@ pub struct UpdateRoleDto {
|
|||||||
pub subject: SubjectDto,
|
pub subject: SubjectDto,
|
||||||
pub resource: ResourceDto,
|
pub resource: ResourceDto,
|
||||||
pub role: Role,
|
pub role: Role,
|
||||||
|
/// Optional expiry applied to every grant written or updated by this call.
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -213,6 +219,8 @@ pub struct GrantDto {
|
|||||||
pub permission: PermissionDto,
|
pub permission: PermissionDto,
|
||||||
pub granted_by: Uuid,
|
pub granted_by: Uuid,
|
||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Grant> for GrantDto {
|
impl From<Grant> for GrantDto {
|
||||||
@@ -224,6 +232,7 @@ impl From<Grant> for GrantDto {
|
|||||||
permission: g.permission.into(),
|
permission: g.permission.into(),
|
||||||
granted_by: g.granted_by,
|
granted_by: g.granted_by,
|
||||||
granted_at: g.granted_at,
|
granted_at: g.granted_at,
|
||||||
|
expires_at: g.expires_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::domain::entities::share::{Share, SharePermissions};
|
use crate::domain::entities::share::Share;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ShareDto {
|
pub struct ShareDto {
|
||||||
@@ -13,19 +13,11 @@ pub struct ShareDto {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub permissions: SharePermissionsDto,
|
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
pub access_count: u64,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CreateShareDto {
|
pub struct CreateShareDto {
|
||||||
pub item_id: String,
|
pub item_id: String,
|
||||||
@@ -33,17 +25,14 @@ pub struct CreateShareDto {
|
|||||||
pub item_type: String,
|
pub item_type: String,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub permissions: Option<SharePermissionsDto>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateShareDto {
|
pub struct UpdateShareDto {
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub permissions: Option<SharePermissionsDto>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension methods to convert between DTOs and domain entities
|
|
||||||
impl ShareDto {
|
impl ShareDto {
|
||||||
pub fn from_entity(share: &Share, base_url: &str) -> Self {
|
pub fn from_entity(share: &Share, base_url: &str) -> Self {
|
||||||
let url = format!("{}/s/{}", base_url, share.token());
|
let url = format!("{}/s/{}", base_url, share.token());
|
||||||
@@ -57,24 +46,9 @@ impl ShareDto {
|
|||||||
url,
|
url,
|
||||||
has_password: share.has_password(),
|
has_password: share.has_password(),
|
||||||
expires_at: share.expires_at(),
|
expires_at: share.expires_at(),
|
||||||
permissions: SharePermissionsDto::from_entity(share.permissions()),
|
|
||||||
created_at: share.created_at(),
|
created_at: share.created_at(),
|
||||||
created_by: share.created_by().to_string(),
|
created_by: share.created_by().to_string(),
|
||||||
access_count: share.access_count(),
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,15 +98,35 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
|
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
|
||||||
|
|
||||||
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
|
/// 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(
|
async fn grant(
|
||||||
&self,
|
&self,
|
||||||
granted_by: Uuid,
|
granted_by: Uuid,
|
||||||
subject: Subject,
|
subject: Subject,
|
||||||
permission: Permission,
|
permission: Permission,
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
) -> Result<Grant, DomainError>;
|
) -> Result<Grant, DomainError>;
|
||||||
|
|
||||||
|
/// 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<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> 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<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
|
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
|
||||||
/// the row existed (idempotent revoke).
|
/// the row existed (idempotent revoke).
|
||||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
|
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use crate::{
|
|||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
errors::{DomainError, ErrorKind},
|
errors::{DomainError, ErrorKind},
|
||||||
},
|
},
|
||||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
domain::entities::share::{Share, ShareItemType},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -229,87 +229,59 @@ impl ShareUseCase for ShareService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
dto: CreateShareDto,
|
dto: CreateShareDto,
|
||||||
) -> Result<ShareDto, DomainError> {
|
) -> Result<ShareDto, DomainError> {
|
||||||
// Convert the item type
|
|
||||||
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
||||||
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
||||||
|
|
||||||
// Verify that the item exists
|
|
||||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
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 {
|
let password_hash = match dto.password {
|
||||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the Share entity
|
|
||||||
let share = Share::new(
|
let share = Share::new(
|
||||||
dto.item_id.clone(),
|
dto.item_id.clone(),
|
||||||
dto.item_name.clone(),
|
dto.item_name.clone(),
|
||||||
item_type,
|
item_type,
|
||||||
user_id,
|
user_id,
|
||||||
permissions,
|
|
||||||
password_hash,
|
password_hash,
|
||||||
dto.expires_at,
|
|
||||||
)
|
)
|
||||||
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
// Save to the repository
|
|
||||||
let saved_share = self
|
let saved_share = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.save_share(&share)
|
.save_share(&share)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||||
|
|
||||||
// Mirror the share permissions as ReBAC token grants so that
|
// Create one Read-only grant for the token subject, carrying expires_at.
|
||||||
// `GET /api/grants/outgoing` picks them up and the UI can show the
|
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
|
||||||
// share badge without a separate `/api/shares` round-trip.
|
// cleans up this grant when the share is later deleted.
|
||||||
// The DELETE trigger `trg_cleanup_grants_token` handles cleanup when
|
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
|
||||||
// the share is later removed — no extra service-layer code needed there.
|
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||||
{
|
let resource = match saved_share.item_type() {
|
||||||
let share_id = saved_share.id();
|
ShareItemType::File => Resource::File(item_id_uuid),
|
||||||
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
|
ShareItemType::Folder => Resource::Folder(item_id_uuid),
|
||||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
};
|
||||||
|
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() {
|
// Return DTO with the requested expires_at (grant subquery on the share
|
||||||
ShareItemType::File => Resource::File(item_id_uuid),
|
// row would return NULL at this point since INSERT ran before the grant).
|
||||||
ShareItemType::Folder => Resource::Folder(item_id_uuid),
|
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
|
||||||
};
|
response.expires_at = dto.expires_at;
|
||||||
let subject = Subject::Token(share_id);
|
Ok(response)
|
||||||
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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
|
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
|
||||||
@@ -364,16 +336,6 @@ impl ShareUseCase for ShareService {
|
|||||||
// SECURITY: ownership-verified lookup — prevents IDOR
|
// SECURITY: ownership-verified lookup — prevents IDOR
|
||||||
let mut share = self.fetch_owned_share(id, requester_id).await?;
|
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)
|
// Update password if provided (async, semaphore-bounded)
|
||||||
if let Some(password) = dto.password {
|
if let Some(password) = dto.password {
|
||||||
let password_hash = if password.is_empty() {
|
let password_hash = if password.is_empty() {
|
||||||
@@ -384,23 +346,33 @@ impl ShareUseCase for ShareService {
|
|||||||
share = share.with_password(password_hash);
|
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() {
|
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
|
let updated_share = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.update_share(&share)
|
.update_share(&share)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||||
|
|
||||||
// Convert the entity to DTO for the response
|
// Use the requested expires_at for the response (subquery in update_share
|
||||||
Ok(ShareDto::from_entity(
|
// runs before set_expiry_for_subject committed, so entity may lag).
|
||||||
&updated_share,
|
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
|
||||||
&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> {
|
async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> {
|
||||||
@@ -510,8 +482,6 @@ impl ShareUseCase for ShareService {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[allow(unused_imports)]
|
|
||||||
use crate::application::dtos::share_dto::SharePermissionsDto;
|
|
||||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||||
use crate::application::ports::share_ports::ShareStoragePort;
|
use crate::application::ports::share_ports::ShareStoragePort;
|
||||||
use crate::application::ports::storage_ports::FileReadPort;
|
use crate::application::ports::storage_ports::FileReadPort;
|
||||||
@@ -606,7 +576,6 @@ mod tests {
|
|||||||
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
||||||
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
||||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
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 {
|
let password_hash = match dto.password {
|
||||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||||
None => None,
|
None => None,
|
||||||
@@ -616,9 +585,7 @@ mod tests {
|
|||||||
dto.item_name.clone(),
|
dto.item_name.clone(),
|
||||||
item_type,
|
item_type,
|
||||||
user_id,
|
user_id,
|
||||||
permissions,
|
|
||||||
password_hash,
|
password_hash,
|
||||||
dto.expires_at,
|
|
||||||
)
|
)
|
||||||
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
||||||
let saved_share = self
|
let saved_share = self
|
||||||
@@ -692,9 +659,6 @@ mod tests {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ShareServiceError::NotFound(format!("Share {} not found: {}", id, 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 {
|
if let Some(password) = dto.password {
|
||||||
let hash = if password.is_empty() {
|
let hash = if password.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -703,9 +667,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
share = share.with_password(hash);
|
share = share.with_password(hash);
|
||||||
}
|
}
|
||||||
if dto.expires_at.is_some() {
|
|
||||||
share = share.with_expiration(dto.expires_at);
|
|
||||||
}
|
|
||||||
let updated = self
|
let updated = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.update_share(&share)
|
.update_share(&share)
|
||||||
@@ -1208,11 +1169,6 @@ mod tests {
|
|||||||
item_type: "file".to_string(),
|
item_type: "file".to_string(),
|
||||||
password: Some("secret".to_string()),
|
password: Some("secret".to_string()),
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
permissions: Some(SharePermissionsDto {
|
|
||||||
read: true,
|
|
||||||
write: false,
|
|
||||||
reshare: false,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
|
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
|
||||||
|
|||||||
@@ -12,20 +12,13 @@ pub struct Share {
|
|||||||
item_type: ShareItemType,
|
item_type: ShareItemType,
|
||||||
token: String,
|
token: String,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
|
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
|
||||||
expires_at: Option<u64>,
|
expires_at: Option<u64>,
|
||||||
permissions: SharePermissions,
|
|
||||||
created_at: u64,
|
created_at: u64,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
access_count: u64,
|
access_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct SharePermissions {
|
|
||||||
read: bool,
|
|
||||||
write: bool,
|
|
||||||
reshare: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ShareItemType {
|
pub enum ShareItemType {
|
||||||
File,
|
File,
|
||||||
@@ -38,31 +31,14 @@ impl Share {
|
|||||||
item_name: Option<String>,
|
item_name: Option<String>,
|
||||||
item_type: ShareItemType,
|
item_type: ShareItemType,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
permissions: Option<SharePermissions>,
|
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
expires_at: Option<u64>,
|
|
||||||
) -> Result<Self, ShareError> {
|
) -> Result<Self, ShareError> {
|
||||||
// Validate item_id
|
|
||||||
if item_id.is_empty() {
|
if item_id.is_empty() {
|
||||||
return Err(ShareError::ValidationError(
|
return Err(ShareError::ValidationError(
|
||||||
"Item ID cannot be empty".to_string(),
|
"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()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.expect("Time went backwards")
|
.expect("Time went backwards")
|
||||||
@@ -75,12 +51,7 @@ impl Share {
|
|||||||
item_type,
|
item_type,
|
||||||
token: Uuid::new_v4().to_string(),
|
token: Uuid::new_v4().to_string(),
|
||||||
password_hash,
|
password_hash,
|
||||||
expires_at,
|
expires_at: None,
|
||||||
permissions: permissions.unwrap_or(SharePermissions {
|
|
||||||
read: true,
|
|
||||||
write: false,
|
|
||||||
reshare: false,
|
|
||||||
}),
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
created_by,
|
created_by,
|
||||||
access_count: 0,
|
access_count: 0,
|
||||||
@@ -96,7 +67,6 @@ impl Share {
|
|||||||
token: String,
|
token: String,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
expires_at: Option<u64>,
|
expires_at: Option<u64>,
|
||||||
permissions: SharePermissions,
|
|
||||||
created_at: u64,
|
created_at: u64,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
access_count: u64,
|
access_count: u64,
|
||||||
@@ -109,7 +79,6 @@ impl Share {
|
|||||||
token,
|
token,
|
||||||
password_hash,
|
password_hash,
|
||||||
expires_at,
|
expires_at,
|
||||||
permissions,
|
|
||||||
created_at,
|
created_at,
|
||||||
created_by,
|
created_by,
|
||||||
access_count,
|
access_count,
|
||||||
@@ -142,10 +111,6 @@ impl Share {
|
|||||||
self.expires_at
|
self.expires_at
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn permissions(&self) -> &SharePermissions {
|
|
||||||
&self.permissions
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn created_at(&self) -> u64 {
|
pub fn created_at(&self) -> u64 {
|
||||||
self.created_at
|
self.created_at
|
||||||
}
|
}
|
||||||
@@ -160,21 +125,11 @@ impl Share {
|
|||||||
|
|
||||||
// ── Builder-style modifiers (immutable) ──
|
// ── 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<String>) -> Self {
|
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
|
||||||
self.password_hash = password_hash;
|
self.password_hash = password_hash;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_expiration(mut self, expires_at: Option<u64>) -> Self {
|
|
||||||
self.expires_at = expires_at;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_token(mut self, token: String) -> Self {
|
pub fn with_token(mut self, token: String) -> Self {
|
||||||
self.token = token;
|
self.token = token;
|
||||||
self
|
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 {
|
impl std::fmt::Display for ShareItemType {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -275,59 +208,17 @@ mod tests {
|
|||||||
ShareItemType::File,
|
ShareItemType::File,
|
||||||
uid,
|
uid,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(share.item_id(), "test_file_id");
|
assert_eq!(share.item_id(), "test_file_id");
|
||||||
assert_eq!(*share.item_type(), ShareItemType::File);
|
assert_eq!(*share.item_type(), ShareItemType::File);
|
||||||
assert_eq!(share.created_by(), uid);
|
assert_eq!(share.created_by(), uid);
|
||||||
assert!(share.permissions().read());
|
|
||||||
assert!(!share.permissions().write());
|
|
||||||
assert!(!share.permissions().reshare());
|
|
||||||
assert!(!share.has_password());
|
assert!(!share.has_password());
|
||||||
assert!(share.expires_at().is_none());
|
assert!(share.expires_at().is_none());
|
||||||
assert_eq!(share.access_count(), 0);
|
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]
|
#[test]
|
||||||
fn test_share_item_type_conversion() {
|
fn test_share_item_type_conversion() {
|
||||||
assert_eq!(ShareItemType::File.to_string(), "file");
|
assert_eq!(ShareItemType::File.to_string(), "file");
|
||||||
@@ -355,9 +246,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
ShareItemType::File,
|
ShareItemType::File,
|
||||||
test_user_id(),
|
test_user_id(),
|
||||||
None,
|
|
||||||
Some("some_hash_value".to_string()),
|
Some("some_hash_value".to_string()),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -373,8 +262,6 @@ mod tests {
|
|||||||
ShareItemType::File,
|
ShareItemType::File,
|
||||||
test_user_id(),
|
test_user_id(),
|
||||||
None,
|
None,
|
||||||
None, // No password
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,14 @@ pub struct Grant {
|
|||||||
pub permission: Permission,
|
pub permission: Permission,
|
||||||
pub granted_by: Uuid,
|
pub granted_by: Uuid,
|
||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grant {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
self.expires_at
|
||||||
|
.is_some_and(|exp| exp < chrono::Utc::now())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
application::ports::share_ports::ShareStoragePort,
|
application::ports::share_ports::ShareStoragePort,
|
||||||
common::errors::DomainError,
|
common::errors::DomainError,
|
||||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
domain::entities::share::{Share, ShareItemType},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// PostgreSQL implementation of [`ShareStoragePort`].
|
/// PostgreSQL implementation of [`ShareStoragePort`].
|
||||||
@@ -37,6 +37,8 @@ impl SharePgRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
/// 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<Share, DomainError> {
|
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
||||||
let id: Uuid = row
|
let id: Uuid = row
|
||||||
.try_get("id")
|
.try_get("id")
|
||||||
@@ -52,10 +54,8 @@ impl SharePgRepository {
|
|||||||
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
|
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
|
||||||
})?;
|
})?;
|
||||||
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
|
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
|
||||||
|
// expires_at derived from access_grants subquery (unix seconds as i64)
|
||||||
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
|
let expires_at: Option<i64> = 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| {
|
let created_at: i64 = row.try_get("created_at").map_err(|e| {
|
||||||
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
|
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
|
||||||
})?;
|
})?;
|
||||||
@@ -66,8 +66,6 @@ impl SharePgRepository {
|
|||||||
|
|
||||||
let item_type =
|
let item_type =
|
||||||
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
|
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(
|
Ok(Share::from_raw(
|
||||||
id,
|
id,
|
||||||
@@ -77,7 +75,6 @@ impl SharePgRepository {
|
|||||||
token,
|
token,
|
||||||
password_hash,
|
password_hash,
|
||||||
expires_at.map(|v| v as u64),
|
expires_at.map(|v| v as u64),
|
||||||
permissions,
|
|
||||||
created_at as u64,
|
created_at as u64,
|
||||||
created_by,
|
created_by,
|
||||||
access_count as u64,
|
access_count as u64,
|
||||||
@@ -91,23 +88,18 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.shares
|
INSERT INTO storage.shares
|
||||||
(id, item_id, item_name, item_type, token, password_hash,
|
(id, item_id, item_name, item_type, token, password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
|
||||||
created_at, created_by, access_count)
|
created_at, created_by, access_count)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6,
|
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
$7, $8, $9, $10,
|
|
||||||
$11, $12, $13)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
item_name = EXCLUDED.item_name,
|
item_name = EXCLUDED.item_name,
|
||||||
password_hash = EXCLUDED.password_hash,
|
password_hash = EXCLUDED.password_hash,
|
||||||
expires_at = EXCLUDED.expires_at,
|
access_count = EXCLUDED.access_count
|
||||||
permissions_read = EXCLUDED.permissions_read,
|
|
||||||
permissions_write = EXCLUDED.permissions_write,
|
|
||||||
permissions_reshare = EXCLUDED.permissions_reshare,
|
|
||||||
access_count = EXCLUDED.access_count
|
|
||||||
RETURNING
|
RETURNING
|
||||||
id, item_id, item_name, item_type, token, password_hash,
|
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
|
created_at, created_by, access_count
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -117,10 +109,6 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
.bind(share.item_type().to_string())
|
.bind(share.item_type().to_string())
|
||||||
.bind(share.token())
|
.bind(share.token())
|
||||||
.bind(share.password_hash())
|
.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_at() as i64)
|
||||||
.bind(share.created_by())
|
.bind(share.created_by())
|
||||||
.bind(share.access_count() as i64)
|
.bind(share.access_count() as i64)
|
||||||
@@ -137,11 +125,13 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count
|
FROM storage.access_grants ag
|
||||||
FROM storage.shares
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
WHERE token = $1
|
s.created_at, s.created_by, s.access_count
|
||||||
|
FROM storage.shares s
|
||||||
|
WHERE s.token = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
@@ -168,11 +158,13 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
) -> Result<Share, DomainError> {
|
) -> Result<Share, DomainError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count
|
FROM storage.access_grants ag
|
||||||
FROM storage.shares
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
WHERE id = $1 AND created_by = $2
|
s.created_at, s.created_by, s.access_count
|
||||||
|
FROM storage.shares s
|
||||||
|
WHERE s.id = $1 AND s.created_by = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -224,12 +216,14 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
) -> Result<Vec<Share>, DomainError> {
|
) -> Result<Vec<Share>, DomainError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count
|
FROM storage.access_grants ag
|
||||||
FROM storage.shares
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
WHERE item_id = $1 AND item_type = $2 AND created_by = $3
|
s.created_at, s.created_by, s.access_count
|
||||||
ORDER BY created_at DESC
|
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)
|
.bind(item_id)
|
||||||
@@ -249,27 +243,21 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.shares SET
|
UPDATE storage.shares SET
|
||||||
item_name = $2,
|
item_name = $2,
|
||||||
password_hash = $3,
|
password_hash = $3,
|
||||||
expires_at = $4,
|
access_count = $4
|
||||||
permissions_read = $5,
|
|
||||||
permissions_write = $6,
|
|
||||||
permissions_reshare = $7,
|
|
||||||
access_count = $8
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
id, item_id, item_name, item_type, token, password_hash,
|
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
|
created_at, created_by, access_count
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(share.id())
|
.bind(share.id())
|
||||||
.bind(share.item_name())
|
.bind(share.item_name())
|
||||||
.bind(share.password_hash())
|
.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)
|
.bind(share.access_count() as i64)
|
||||||
.fetch_optional(&*self.db_pool)
|
.fetch_optional(&*self.db_pool)
|
||||||
.await
|
.await
|
||||||
@@ -296,13 +284,15 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
// Single query with window function — count + rows in one roundtrip
|
// Single query with window function — count + rows in one roundtrip
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count,
|
FROM storage.access_grants ag
|
||||||
COUNT(*) OVER() AS total_count
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
FROM storage.shares
|
s.created_at, s.created_by, s.access_count,
|
||||||
WHERE created_by = $1
|
COUNT(*) OVER() AS total_count
|
||||||
ORDER BY created_at DESC
|
FROM storage.shares s
|
||||||
|
WHERE s.created_by = $1
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ impl PgAclEngine {
|
|||||||
AND g.subject_id = $2
|
AND g.subject_id = $2
|
||||||
AND g.permission = $3
|
AND g.permission = $3
|
||||||
AND g.resource_type = 'folder'
|
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)
|
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
@@ -135,6 +136,7 @@ impl PgAclEngine {
|
|||||||
FROM storage.access_grants
|
FROM storage.access_grants
|
||||||
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
|
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
|
||||||
AND resource_type = 'file' AND resource_id = $4
|
AND resource_type = 'file' AND resource_id = $4
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
UNION ALL
|
UNION ALL
|
||||||
-- cascading from any ancestor folder of the file's containing folder
|
-- cascading from any ancestor folder of the file's containing folder
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -145,6 +147,7 @@ impl PgAclEngine {
|
|||||||
AND g.subject_id = $2
|
AND g.subject_id = $2
|
||||||
AND g.permission = $3
|
AND g.permission = $3
|
||||||
AND g.resource_type = 'folder'
|
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 target_f.folder_id IS NOT NULL
|
||||||
AND gf.lpath @> (SELECT lpath FROM storage.folders
|
AND gf.lpath @> (SELECT lpath FROM storage.folders
|
||||||
WHERE id = target_f.folder_id)
|
WHERE id = target_f.folder_id)
|
||||||
@@ -186,8 +189,9 @@ impl PgAclEngine {
|
|||||||
Ok(Some((res, granter)))
|
Ok(Some((res, granter)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a (id, subject_type, subject_id, resource_type, resource_id,
|
/// Row type for all full-grant SELECT queries:
|
||||||
/// permission, granted_by, granted_at) row into a `Grant`.
|
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn row_to_grant(
|
fn row_to_grant(
|
||||||
row: (
|
row: (
|
||||||
Uuid,
|
Uuid,
|
||||||
@@ -198,6 +202,7 @@ impl PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
) -> Result<Grant, DomainError> {
|
) -> Result<Grant, DomainError> {
|
||||||
let subject = Subject::from_parts(&row.1, row.2)
|
let subject = Subject::from_parts(&row.1, row.2)
|
||||||
@@ -213,6 +218,7 @@ impl PgAclEngine {
|
|||||||
permission,
|
permission,
|
||||||
granted_by: row.6,
|
granted_by: row.6,
|
||||||
granted_at: row.7,
|
granted_at: row.7,
|
||||||
|
expires_at: row.8,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,11 +277,12 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
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
|
FROM storage.access_grants
|
||||||
WHERE subject_type = $1
|
WHERE subject_type = $1
|
||||||
AND subject_id = $2
|
AND subject_id = $2
|
||||||
@@ -636,11 +643,12 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
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
|
FROM storage.access_grants
|
||||||
WHERE resource_type = $1
|
WHERE resource_type = $1
|
||||||
AND resource_id = $2
|
AND resource_id = $2
|
||||||
@@ -668,11 +676,12 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
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
|
FROM storage.access_grants
|
||||||
WHERE granted_by = $1
|
WHERE granted_by = $1
|
||||||
ORDER BY granted_at DESC
|
ORDER BY granted_at DESC
|
||||||
@@ -692,10 +701,8 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
subject: Subject,
|
subject: Subject,
|
||||||
permission: Permission,
|
permission: Permission,
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
) -> Result<Grant, DomainError> {
|
) -> Result<Grant, DomainError> {
|
||||||
// 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::<
|
let row = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
(
|
(
|
||||||
@@ -707,16 +714,17 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.access_grants
|
INSERT INTO storage.access_grants
|
||||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
|
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
|
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,
|
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())
|
.bind(subject.type_str())
|
||||||
@@ -725,6 +733,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
.bind(resource.id())
|
.bind(resource.id())
|
||||||
.bind(permission.as_str())
|
.bind(permission.as_str())
|
||||||
.bind(granted_by)
|
.bind(granted_by)
|
||||||
|
.bind(expires_at)
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
|
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
|
||||||
@@ -732,6 +741,45 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
Self::row_to_grant(row)
|
Self::row_to_grant(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_expiry_for_subject(
|
||||||
|
&self,
|
||||||
|
subject: Subject,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> 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<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> 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> {
|
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||||
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
|
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
|
||||||
.bind(grant_id)
|
.bind(grant_id)
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub async fn create_grant(
|
|||||||
|
|
||||||
let subject: Subject = dto.subject.into();
|
let subject: Subject = dto.subject.into();
|
||||||
let resource: Resource = dto.resource.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).
|
// Caller must have Share on the resource (owners pass via short-circuit).
|
||||||
if let Err(e) = authz
|
if let Err(e) = authz
|
||||||
@@ -97,7 +98,7 @@ pub async fn create_grant(
|
|||||||
|
|
||||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||||
for perm in permissions {
|
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()),
|
Ok(grant) => results.push(grant.into()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("grant insert failed for {perm:?}: {err}");
|
error!("grant insert failed for {perm:?}: {err}");
|
||||||
@@ -189,6 +190,7 @@ pub async fn set_role(
|
|||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let subject: Subject = dto.subject.into();
|
let subject: Subject = dto.subject.into();
|
||||||
let resource: Resource = dto.resource.into();
|
let resource: Resource = dto.resource.into();
|
||||||
|
let expires_at = dto.expires_at;
|
||||||
let target_perms: std::collections::HashSet<Permission> =
|
let target_perms: std::collections::HashSet<Permission> =
|
||||||
dto.role.expand().iter().copied().collect();
|
dto.role.expand().iter().copied().collect();
|
||||||
|
|
||||||
@@ -225,11 +227,19 @@ pub async fn set_role(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for perm in &to_add {
|
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();
|
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.
|
// Return the new full set.
|
||||||
let after = match authz.list_grants_on_resource(resource).await {
|
let after = match authz.list_grants_on_resource(resource).await {
|
||||||
Ok(g) => g,
|
Ok(g) => g,
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ use crate::application::dtos::search_dto::{
|
|||||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
||||||
SearchSuggestionItem, SearchSuggestionsDto,
|
SearchSuggestionItem, SearchSuggestionsDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::share_dto::{
|
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
|
||||||
CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto,
|
|
||||||
};
|
|
||||||
use crate::application::dtos::trash_dto::{
|
use crate::application::dtos::trash_dto::{
|
||||||
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
|
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
|
||||||
};
|
};
|
||||||
@@ -257,7 +255,6 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
OidcExchangeDto,
|
OidcExchangeDto,
|
||||||
// Share schemas
|
// Share schemas
|
||||||
ShareDto,
|
ShareDto,
|
||||||
SharePermissionsDto,
|
|
||||||
CreateShareDto,
|
CreateShareDto,
|
||||||
UpdateShareDto,
|
UpdateShareDto,
|
||||||
// Trash schemas
|
// Trash schemas
|
||||||
|
|||||||
@@ -315,7 +315,7 @@
|
|||||||
/* Wider, taller container; body becomes a zero-padding scrollable slot. */
|
/* Wider, taller container; body becomes a zero-padding scrollable slot. */
|
||||||
|
|
||||||
.modal-container--panel {
|
.modal-container--panel {
|
||||||
width: 520px;
|
width: 620px;
|
||||||
max-width: 96vw;
|
max-width: 96vw;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -328,6 +328,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.smd-link-name {
|
.smd-link-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -431,32 +433,6 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.smd-new-link-form {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
border: 0.5px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smd-new-link-form label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smd-new-link-form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Password toggle row ─────────────────────────────────────────────────────── */
|
/* ── Password toggle row ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.smd-pw-toggle {
|
.smd-pw-toggle {
|
||||||
@@ -488,3 +464,89 @@
|
|||||||
transform: rotate(360deg);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,3 +120,7 @@
|
|||||||
background: var(--color-bg-alt);
|
background: var(--color-bg-alt);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .smd-expiry-date-input::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -346,14 +346,15 @@ const Modal = {
|
|||||||
*
|
*
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {string} options.title
|
* @param {string} options.title
|
||||||
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
|
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
|
||||||
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
|
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
|
||||||
* @param {string} [options.confirmText] - Confirm button label
|
* @param {string} [options.confirmText] - Confirm button label
|
||||||
* @param {string} [options.cancelText] - Cancel button label
|
* @param {string} [options.cancelText] - Cancel button label
|
||||||
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
|
* @param {boolean} [options.confirmDisabled] - Initial disabled state of the confirm button
|
||||||
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
|
* @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;
|
if (!this.overlay) return;
|
||||||
|
|
||||||
this._panelMode = true;
|
this._panelMode = true;
|
||||||
@@ -379,7 +380,7 @@ const Modal = {
|
|||||||
// ── Footer buttons ──────────────────────────────────────────────────
|
// ── Footer buttons ──────────────────────────────────────────────────
|
||||||
if (this.confirmBtn) {
|
if (this.confirmBtn) {
|
||||||
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
|
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
|
||||||
this.confirmBtn.disabled = false;
|
this.confirmBtn.disabled = confirmDisabled;
|
||||||
}
|
}
|
||||||
if (this.cancelBtn) {
|
if (this.cancelBtn) {
|
||||||
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
|
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
|
||||||
|
|||||||
+312
-252
@@ -27,6 +27,16 @@ import { createUserVignette } from './userVignette.js';
|
|||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── 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). */
|
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||||
const ROLE_PERMISSIONS = {
|
const ROLE_PERMISSIONS = {
|
||||||
viewer: ['read'],
|
viewer: ['read'],
|
||||||
@@ -101,6 +111,9 @@ const shareModal = {
|
|||||||
/** @type {ShareRoleEnum} */
|
/** @type {ShareRoleEnum} */
|
||||||
_stagedRole: 'viewer',
|
_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 */
|
/** @type {HTMLElement|null} — body node injected into Modal */
|
||||||
_bodyEl: null,
|
_bodyEl: null,
|
||||||
|
|
||||||
@@ -119,6 +132,7 @@ const shareModal = {
|
|||||||
this._newLinks = [];
|
this._newLinks = [];
|
||||||
this._stagedUsers = [];
|
this._stagedUsers = [];
|
||||||
this._stagedRole = 'viewer';
|
this._stagedRole = 'viewer';
|
||||||
|
this._stagedExpiry = null;
|
||||||
|
|
||||||
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
|
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
|
||||||
|
|
||||||
@@ -130,6 +144,7 @@ const shareModal = {
|
|||||||
icon: 'fa-share-alt',
|
icon: 'fa-share-alt',
|
||||||
content: this._bodyEl,
|
content: this._bodyEl,
|
||||||
confirmText: i18n.t('actions.apply', 'Apply'),
|
confirmText: i18n.t('actions.apply', 'Apply'),
|
||||||
|
confirmDisabled: true,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
this._applyAll();
|
this._applyAll();
|
||||||
} // intentionally discard Promise
|
} // intentionally discard Promise
|
||||||
@@ -164,6 +179,17 @@ const shareModal = {
|
|||||||
Modal.close(false);
|
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 ───────────────────────────────────────────────────────────────
|
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,6 +288,11 @@ const shareModal = {
|
|||||||
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
|
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Expiry chip ──────────────────────────────────────────────────────
|
||||||
|
const expiryChip = this._buildExpiryChip(null, (v) => {
|
||||||
|
this._stagedExpiry = v;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Add button ───────────────────────────────────────────────────────
|
// ── Add button ───────────────────────────────────────────────────────
|
||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'smd-add-btn btn btn-secondary';
|
addBtn.className = 'smd-add-btn btn btn-secondary';
|
||||||
@@ -316,6 +347,7 @@ const shareModal = {
|
|||||||
|
|
||||||
row.appendChild(wrap);
|
row.appendChild(wrap);
|
||||||
row.appendChild(roleSelect);
|
row.appendChild(roleSelect);
|
||||||
|
row.appendChild(expiryChip);
|
||||||
row.appendChild(addBtn);
|
row.appendChild(addBtn);
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -419,7 +451,7 @@ const shareModal = {
|
|||||||
/** @type {Grant} */
|
/** @type {Grant} */
|
||||||
const placeholderGrant = {
|
const placeholderGrant = {
|
||||||
id: '', // not yet persisted
|
id: '', // not yet persisted
|
||||||
granted_at: 0,
|
granted_at: '',
|
||||||
granted_by: '',
|
granted_by: '',
|
||||||
subject: { type: 'user', id: contact.id },
|
subject: { type: 'user', id: contact.id },
|
||||||
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
||||||
@@ -429,7 +461,8 @@ const shareModal = {
|
|||||||
grant: placeholderGrant,
|
grant: placeholderGrant,
|
||||||
_grants: [], // no server grants yet — nothing to revoke on remove
|
_grants: [], // no server grants yet — nothing to revoke on remove
|
||||||
role: this._stagedRole,
|
role: this._stagedRole,
|
||||||
_op: 'new'
|
_op: 'new',
|
||||||
|
expires_at: this._stagedExpiry
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._stagedUsers = [];
|
this._stagedUsers = [];
|
||||||
@@ -450,6 +483,7 @@ const shareModal = {
|
|||||||
_refreshMemberGroups() {
|
_refreshMemberGroups() {
|
||||||
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
|
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
|
||||||
if (container) this._renderMemberGroupsInto(container);
|
if (container) this._renderMemberGroupsInto(container);
|
||||||
|
this._syncApplyBtn();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -521,6 +555,19 @@ const shareModal = {
|
|||||||
this._refreshMemberGroups();
|
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');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'smd-row-action';
|
removeBtn.className = 'smd-row-action';
|
||||||
removeBtn.title = i18n.t('actions.remove', 'Remove');
|
removeBtn.title = i18n.t('actions.remove', 'Remove');
|
||||||
@@ -532,10 +579,162 @@ const shareModal = {
|
|||||||
|
|
||||||
row.appendChild(vignette);
|
row.appendChild(vignette);
|
||||||
row.appendChild(roleSelect);
|
row.appendChild(roleSelect);
|
||||||
|
row.appendChild(expiryChip);
|
||||||
row.appendChild(removeBtn);
|
row.appendChild(removeBtn);
|
||||||
return row;
|
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 =
|
||||||
|
`<i class="fas fa-clock"></i> ${_formatExpiryDate(current)}` +
|
||||||
|
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||||
|
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
current = null;
|
||||||
|
onChange(null);
|
||||||
|
updateChip();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
chip.className = 'smd-expiry-chip';
|
||||||
|
chip.innerHTML = `<i class="fas fa-infinity"></i> ${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 =
|
||||||
|
`<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}` +
|
||||||
|
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||||
|
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hasPassword = false;
|
||||||
|
onChange('');
|
||||||
|
updateChip();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
chip.className = 'smd-expiry-chip';
|
||||||
|
chip.innerHTML = `<i class="fas fa-lock-open"></i> ${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 ──────────────────────────────────────────────────────────
|
// ── Links section ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,29 +749,72 @@ const shareModal = {
|
|||||||
title.textContent = i18n.t('share.publicLinks', 'Public links');
|
title.textContent = i18n.t('share.publicLinks', 'Public links');
|
||||||
section.appendChild(title);
|
section.appendChild(title);
|
||||||
|
|
||||||
|
section.appendChild(this._buildAddLinkRow());
|
||||||
|
|
||||||
const listEl = document.createElement('div');
|
const listEl = document.createElement('div');
|
||||||
listEl.id = 'smd-links-list';
|
listEl.id = 'smd-links-list';
|
||||||
this._renderLinksInto(listEl);
|
this._renderLinksInto(listEl);
|
||||||
section.appendChild(listEl);
|
section.appendChild(listEl);
|
||||||
|
|
||||||
const newLinkBtn = document.createElement('button');
|
return section;
|
||||||
newLinkBtn.className = 'smd-new-link-btn';
|
},
|
||||||
newLinkBtn.innerHTML = `<i class="fas fa-plus"></i> ${i18n.t('share.createLink', 'Create new public link')}`;
|
|
||||||
newLinkBtn.id = 'smd-new-link-btn';
|
|
||||||
|
|
||||||
const newLinkForm = document.createElement('div');
|
/**
|
||||||
newLinkForm.id = 'smd-new-link-form';
|
* Always-visible add-link row — mirrors the People search row layout.
|
||||||
newLinkForm.className = 'smd-new-link-form hidden';
|
* Rebuilds itself after each Add to reset chip state.
|
||||||
newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm));
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_buildAddLinkRow() {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'smd-search-row';
|
||||||
|
row.id = 'smd-add-link-row';
|
||||||
|
|
||||||
newLinkBtn.addEventListener('click', () => {
|
// Name input — wrapped in smd-search-wrap so it inherits flex:1
|
||||||
newLinkBtn.classList.add('hidden');
|
const wrap = document.createElement('div');
|
||||||
newLinkForm.classList.remove('hidden');
|
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);
|
const expChip = this._buildExpiryChip(null, (v) => {
|
||||||
section.appendChild(newLinkForm);
|
stagedExpiry = v;
|
||||||
return section;
|
});
|
||||||
|
|
||||||
|
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() {
|
_refreshLinks() {
|
||||||
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
|
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
|
||||||
if (container) this._renderLinksInto(container);
|
if (container) this._renderLinksInto(container);
|
||||||
|
this._syncApplyBtn();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -603,87 +846,65 @@ const shareModal = {
|
|||||||
*/
|
*/
|
||||||
_buildLinkRow(entry) {
|
_buildLinkRow(entry) {
|
||||||
const share = entry.share;
|
const share = entry.share;
|
||||||
const draft = entry._op === 'edit' ? entry._draft : null;
|
|
||||||
|
|
||||||
// Display values: prefer draft overrides when in edit-pending state
|
const ensureDraft = () => {
|
||||||
const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link');
|
if (!entry._draft) {
|
||||||
const displayPw = draft ? draft.password !== null : share.has_password;
|
entry._draft = {
|
||||||
const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null;
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'smd-link-row';
|
row.className = 'smd-link-row';
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.className = 'smd-link-icon';
|
|
||||||
icon.innerHTML = '<i class="fas fa-link"></i>';
|
|
||||||
|
|
||||||
const info = document.createElement('div');
|
|
||||||
info.className = 'smd-link-info';
|
|
||||||
|
|
||||||
const name = document.createElement('div');
|
const name = document.createElement('div');
|
||||||
name.className = 'smd-link-name';
|
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 = `<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
if (displayExp) {
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.className = 'smd-link-tag';
|
|
||||||
t.innerHTML = `<i class="fas fa-clock"></i> ${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');
|
const copyBtn = document.createElement('button');
|
||||||
copyBtn.className = 'smd-row-action';
|
copyBtn.className = 'smd-row-action';
|
||||||
copyBtn.title = i18n.t('actions.copy', 'Copy');
|
copyBtn.title = i18n.t('actions.copy', 'Copy link');
|
||||||
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||||
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
|
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
|
||||||
|
|
||||||
// Edit
|
const pwChip = this._buildPasswordChip(currentHasPassword, (v) => {
|
||||||
const editBtn = document.createElement('button');
|
ensureDraft().password = v;
|
||||||
editBtn.className = 'smd-row-action';
|
});
|
||||||
editBtn.title = i18n.t('actions.edit', 'Edit');
|
|
||||||
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
const expChip = this._buildExpiryChip(currentExpiry, (v) => {
|
||||||
editBtn.addEventListener('click', () => {
|
ensureDraft().expires_at = v;
|
||||||
const panel = row.nextElementSibling;
|
|
||||||
if (panel?.classList.contains('smd-edit-panel')) {
|
|
||||||
panel.classList.toggle('hidden');
|
|
||||||
} else {
|
|
||||||
const editPanel = this._buildEditPanel(entry, row);
|
|
||||||
row.after(editPanel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'smd-row-action';
|
delBtn.className = 'smd-row-action';
|
||||||
delBtn.title = i18n.t('actions.delete', 'Delete');
|
delBtn.title = i18n.t('actions.delete', 'Delete');
|
||||||
delBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
delBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
delBtn.addEventListener('click', () => {
|
delBtn.addEventListener('click', () => {
|
||||||
entry._op = 'remove';
|
entry._op = 'remove';
|
||||||
this._refreshLinks();
|
this._refreshLinks();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.appendChild(copyBtn);
|
row.appendChild(name);
|
||||||
actions.appendChild(editBtn);
|
row.appendChild(copyBtn);
|
||||||
actions.appendChild(delBtn);
|
row.appendChild(pwChip);
|
||||||
|
row.appendChild(expChip);
|
||||||
row.appendChild(icon);
|
row.appendChild(delBtn);
|
||||||
row.appendChild(info);
|
|
||||||
row.appendChild(actions);
|
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -695,42 +916,21 @@ const shareModal = {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'smd-link-row';
|
row.className = 'smd-link-row';
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.className = 'smd-link-icon';
|
|
||||||
icon.innerHTML = '<i class="fas fa-link"></i>';
|
|
||||||
|
|
||||||
const info = document.createElement('div');
|
|
||||||
info.className = 'smd-link-info';
|
|
||||||
|
|
||||||
const name = document.createElement('div');
|
const name = document.createElement('div');
|
||||||
name.className = 'smd-link-name';
|
name.className = 'smd-link-name';
|
||||||
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
|
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 = `<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
if (draft.expires_at) {
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.className = 'smd-link-tag';
|
|
||||||
t.innerHTML = `<i class="fas fa-clock"></i> ${draft.expires_at}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = document.createElement('span');
|
const pending = document.createElement('span');
|
||||||
pending.className = 'smd-link-tag';
|
pending.className = 'smd-link-tag';
|
||||||
pending.textContent = i18n.t('share.pending', 'Pending');
|
pending.textContent = i18n.t('share.pending', 'Pending');
|
||||||
tags.appendChild(pending);
|
|
||||||
|
|
||||||
info.appendChild(name);
|
const pwChip = this._buildPasswordChip(!!draft.password, (v) => {
|
||||||
if (tags.children.length) info.appendChild(tags);
|
draft.password = v || null;
|
||||||
|
});
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const expChip = this._buildExpiryChip(draft.expires_at, (v) => {
|
||||||
actions.className = 'smd-link-actions';
|
draft.expires_at = v;
|
||||||
|
});
|
||||||
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'smd-row-action';
|
delBtn.className = 'smd-row-action';
|
||||||
@@ -741,158 +941,14 @@ const shareModal = {
|
|||||||
this._refreshLinks();
|
this._refreshLinks();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.appendChild(delBtn);
|
row.appendChild(name);
|
||||||
row.appendChild(icon);
|
row.appendChild(pending);
|
||||||
row.appendChild(info);
|
row.appendChild(pwChip);
|
||||||
row.appendChild(actions);
|
row.appendChild(expChip);
|
||||||
|
row.appendChild(delBtn);
|
||||||
return row;
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Apply ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -911,6 +967,8 @@ const shareModal = {
|
|||||||
try {
|
try {
|
||||||
// ── Grants ─────────────────────────────────────────────────────────
|
// ── Grants ─────────────────────────────────────────────────────────
|
||||||
for (const m of this._localMembers) {
|
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') {
|
if (m._op === 'remove') {
|
||||||
// Revoke every individual grant for this subject (one per permission).
|
// Revoke every individual grant for this subject (one per permission).
|
||||||
for (const g of m._grants) {
|
for (const g of m._grants) {
|
||||||
@@ -920,13 +978,15 @@ const shareModal = {
|
|||||||
await grants.updateRole({
|
await grants.updateRole({
|
||||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||||
resource: { type: itemType, id: item.id },
|
resource: { type: itemType, id: item.id },
|
||||||
role: m.role
|
role: m.role,
|
||||||
|
expires_at: expiresIso
|
||||||
});
|
});
|
||||||
} else if (m._op === 'new') {
|
} else if (m._op === 'new') {
|
||||||
await grants.createGrant({
|
await grants.createGrant({
|
||||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||||
resource: { type: itemType, id: item.id },
|
resource: { type: itemType, id: item.id },
|
||||||
role: m.role
|
role: m.role,
|
||||||
|
expires_at: expiresIso
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,11 +284,12 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {Object} Grant
|
* @typedef {Object} Grant
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
* @property {number} granted_at
|
* @property {string} granted_at - ISO-8601 datetime string.
|
||||||
* @property {string} granted_by
|
* @property {string} granted_by
|
||||||
* @property {Subject} subject
|
* @property {Subject} subject
|
||||||
* @property {PermissionTypeEnum} permission
|
* @property {PermissionTypeEnum} permission
|
||||||
* @property {Resource} resource
|
* @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 {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
|
||||||
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
||||||
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
|
* @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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user