Merge pull request #402 from EdouardVanbelle/feat/my-shares

This commit is contained in:
Dionisio Pozo
2026-05-29 17:03:25 +02:00
committed by GitHub
66 changed files with 3384 additions and 1803 deletions
+35 -16
View File
@@ -27,18 +27,6 @@ const HTML_INCLUDE: &[&str] = &[
"share.html",
];
// ─── View CSS files linked directly in index.html (not via @import) ──────────
const INDEX_VIEW_CSS: &[&str] = &[
"views/inlineViewer.css",
"views/favorites.css",
"views/recent.css",
"views/shared.css",
"views/trash.css",
"views/photos.css",
"views/photosLightbox.css",
"views/music.css",
];
// ═══════════════════════════════════════════════════════════════════════════════
// Entry point
// ═══════════════════════════════════════════════════════════════════════════════
@@ -91,18 +79,28 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
let css_dir = static_dir.join("css");
// Read index.html once — used for both CSS and JS extraction.
let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
// ── 2. Resolve main.css @imports ─────────────────────────────────────────
let resolved_main = resolve_css_imports(&css_dir.join("main.css"), &css_dir);
let minified_main = css_minify_safe(&resolved_main);
fs::write(dist_dir.join("css/main.css"), &minified_main).expect("write main.css");
// ── 3. Build CSS bundle for index.html ───────────────────────────────────
// Derive the list of view CSS files directly from the <link> tags in index.html
// so build.rs never needs to be updated when a new stylesheet is added.
let mut css_all = resolved_main;
for view in INDEX_VIEW_CSS {
let p = css_dir.join(view);
for view in extract_css_links(&index_html) {
let p = css_dir.join(&view);
if p.exists() {
css_all.push_str(&fs::read_to_string(&p).unwrap_or_default());
css_all.push('\n');
} else {
eprintln!(
"cargo:warning=CSS link in index.html not found: {}",
p.display()
);
}
}
let css_bundle = css_minify_safe(&css_all);
@@ -116,7 +114,6 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
// ── 5. Bundle all ES modules into one IIFE ───────────────────────────────
// Walk the import graph starting from every <script type="module"> in index.html,
// strip import/export syntax, wrap in an IIFE, then minify as a classic script.
let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
let module_scripts = extract_module_scripts(&index_html);
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
// Validate the raw bundle with OXC before minifying — catches re-declaration
@@ -254,6 +251,29 @@ fn minify_tree_css(dir: &Path) {
// JS bundling (ES module → single IIFE)
// ═══════════════════════════════════════════════════════════════════════════════
/// Collect `<link rel="stylesheet" href="/css/…">` paths from HTML as paths
/// relative to the CSS directory (e.g. `views/mySharesView.css`).
/// Skips `main.css` (resolved separately via @import chain).
fn extract_css_links(html: &str) -> Vec<String> {
html.lines()
.filter_map(|l| {
let t = l.trim();
if t.starts_with("<link") && t.contains("stylesheet") && t.contains("href=\"/css/") {
let s = t.find("href=\"/css/")? + 11; // skip `href="/css/`
let e = t[s..].find('"')? + s;
let rel = t[s..e].to_string();
if rel == "main.css" || rel.starts_with("app.") {
None
} else {
Some(rel)
}
} else {
None
}
})
.collect()
}
/// Collect `<script type="module" src="…">` paths from HTML.
fn extract_module_scripts(html: &str) -> Vec<String> {
html.lines()
@@ -953,7 +973,6 @@ fn minify_tree_js(dir: &Path) {
continue;
}
if let Ok(src) = fs::read_to_string(&p) {
println!("cargo:warning=minify-js: {}", p.display());
let _ = fs::write(&p, js_minify_safe(&src));
}
}
+2
View File
@@ -8,6 +8,8 @@ build:
release:
cargo build --release
# check that app is clean
node --check static-dist/js/app.*.js
run:
cargo run
@@ -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;
@@ -0,0 +1,10 @@
-- Remove rows from auth.user_recent_files and auth.user_favorites whose
-- item_id is not a valid UUID (e.g. composite "uuid1_uuid2" values written
-- by a previous code path that joined owner_id and resource_id with '_').
-- These rows would cause a cast failure on `item_id::UUID` in list queries.
DELETE FROM auth.user_recent_files
WHERE item_id !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
DELETE FROM auth.user_favorites
WHERE item_id !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
+58
View File
@@ -191,6 +191,9 @@ pub struct CreateGrantDto {
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
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.
@@ -199,6 +202,9 @@ pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
/// Optional expiry applied to every grant written or updated by this call.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ════════════════════════════════════════════════════════════════════════════
@@ -213,6 +219,8 @@ pub struct GrantDto {
pub permission: PermissionDto,
pub granted_by: Uuid,
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 {
@@ -224,6 +232,7 @@ impl From<Grant> for GrantDto {
permission: g.permission.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
expires_at: g.expires_at,
}
}
}
@@ -303,5 +312,54 @@ pub struct SharedWithMeItemDto {
pub resource: ResourceContentDto,
}
/// Derive the closest-matching role label from a set of permissions.
/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`.
pub fn role_from_permissions(perms: &[Permission]) -> &'static str {
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
"admin"
} else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) {
"editor"
} else {
"viewer"
}
}
/// Response for `GET /api/grants/incoming/resources`.
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
// ════════════════════════════════════════════════════════════════════════════
// My-Shares DTOs (GET /api/grants/outgoing/resources)
// ════════════════════════════════════════════════════════════════════════════
/// One (subject, permissions) entry within an outgoing resource item.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct OutgoingResourceGrantDto {
pub grant_id: Uuid,
/// `"user"` | `"token"`
pub subject_type: String,
pub subject_id: Uuid,
/// Human-readable label (username for users, share name for tokens).
pub subject_display: String,
/// Derived role label: `"viewer"` | `"editor"` | `"admin"`.
pub role: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// Whether the token has a password set. Always `false` for user subjects.
pub has_password: bool,
}
/// One item in the my-shares list.
#[derive(Debug, Serialize, ToSchema)]
pub struct OutgoingResourceItemDto {
pub resource_type: ResourceTypeDto,
/// Earliest grant date across all subjects on this resource.
pub first_shared_at: chrono::DateTime<chrono::Utc>,
/// Full resource details. Shape is determined by `resource_type`.
pub resource: ResourceContentDto,
/// One entry per (subject, permissions) pair.
pub grants: Vec<OutgoingResourceGrantDto>,
}
/// Response for `GET /api/grants/outgoing/resources`.
pub type MySharesDto = CursorListResponse<OutgoingResourceItemDto>;
+1 -27
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::domain::entities::share::{Share, SharePermissions};
use crate::domain::entities::share::Share;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ShareDto {
@@ -13,19 +13,11 @@ pub struct ShareDto {
pub url: String,
pub has_password: bool,
pub expires_at: Option<u64>,
pub permissions: SharePermissionsDto,
pub created_at: u64,
pub created_by: String,
pub access_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SharePermissionsDto {
pub read: bool,
pub write: bool,
pub reshare: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateShareDto {
pub item_id: String,
@@ -33,17 +25,14 @@ pub struct CreateShareDto {
pub item_type: String,
pub password: Option<String>,
pub expires_at: Option<u64>,
pub permissions: Option<SharePermissionsDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateShareDto {
pub password: Option<String>,
pub expires_at: Option<u64>,
pub permissions: Option<SharePermissionsDto>,
}
/// Extension methods to convert between DTOs and domain entities
impl ShareDto {
pub fn from_entity(share: &Share, base_url: &str) -> Self {
let url = format!("{}/s/{}", base_url, share.token());
@@ -57,24 +46,9 @@ impl ShareDto {
url,
has_password: share.has_password(),
expires_at: share.expires_at(),
permissions: SharePermissionsDto::from_entity(share.permissions()),
created_at: share.created_at(),
created_by: share.created_by().to_string(),
access_count: share.access_count(),
}
}
}
impl SharePermissionsDto {
pub fn from_entity(permissions: &SharePermissions) -> Self {
Self {
read: permissions.read(),
write: permissions.write(),
reshare: permissions.reshare(),
}
}
pub fn to_entity(&self) -> SharePermissions {
SharePermissions::new(self.read, self.write, self.reshare)
}
}
+38 -2
View File
@@ -12,7 +12,8 @@ use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
ResourceKind, Subject,
};
pub trait AuthorizationEngine: Send + Sync + 'static {
@@ -97,16 +98,51 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// `GET /api/grants/outgoing` ("things I've shared with others").
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
/// Cursor-paginated list of resources that `granted_by` has shared with
/// others. Multiple permission rows for the same (subject, resource) pair
/// are collapsed into one `OutgoingGrantEntry`; multiple subjects on the
/// same resource are grouped into one `OutgoingResourceSummary`.
///
/// Returns `(summaries, next_cursor)`.
async fn list_outgoing_resources_paged(
&self,
granted_by: Uuid,
limit: u32,
cursor: Option<GrantCursor>,
sort_by: &str,
reverse: bool,
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint and the existing row is returned.
/// constraint; if the row already exists its `expires_at` is updated.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> 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
/// the row existed (idempotent revoke).
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
+46 -90
View File
@@ -28,7 +28,7 @@ use crate::{
config::AppConfig,
errors::{DomainError, ErrorKind},
},
domain::entities::share::{Share, ShareItemType, SharePermissions},
domain::entities::share::{Share, ShareItemType},
};
#[derive(Debug, Error)]
@@ -229,87 +229,59 @@ impl ShareUseCase for ShareService {
user_id: Uuid,
dto: CreateShareDto,
) -> Result<ShareDto, DomainError> {
// Convert the item type
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
// Verify that the item exists
self.verify_item_exists(&dto.item_id, &item_type).await?;
// Convert the permissions DTO if it exists
let permissions = dto.permissions.map(|p| p.to_entity());
// Hash the password if provided (async, semaphore-bounded)
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
};
// Create the Share entity
let share = Share::new(
dto.item_id.clone(),
dto.item_name.clone(),
item_type,
user_id,
permissions,
password_hash,
dto.expires_at,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
// Save to the repository
let saved_share = self
.share_repository
.save_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Mirror the share permissions as ReBAC token grants so that
// `GET /api/grants/outgoing` picks them up and the UI can show the
// share badge without a separate `/api/shares` round-trip.
// The DELETE trigger `trg_cleanup_grants_token` handles cleanup when
// the share is later removed — no extra service-layer code needed there.
{
let share_id = saved_share.id();
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
// Create one Read-only grant for the token subject, carrying expires_at.
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
// cleans up this grant when the share is later deleted.
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
let resource = match saved_share.item_type() {
ShareItemType::File => Resource::File(item_id_uuid),
ShareItemType::Folder => Resource::Folder(item_id_uuid),
};
let expires_dt = dto
.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
self.authorization
.grant(
user_id,
Subject::Token(saved_share.id()),
Permission::Read,
resource,
expires_dt,
)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
let resource = match saved_share.item_type() {
ShareItemType::File => Resource::File(item_id_uuid),
ShareItemType::Folder => Resource::Folder(item_id_uuid),
};
let subject = Subject::Token(share_id);
let perms = saved_share.permissions();
// Read is always granted
self.authorization
.grant(user_id, subject, Permission::Read, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Write permission → Create + Update
if perms.write() {
self.authorization
.grant(user_id, subject, Permission::Create, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
self.authorization
.grant(user_id, subject, Permission::Update, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
}
// Reshare permission → Share
if perms.reshare() {
self.authorization
.grant(user_id, subject, Permission::Share, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
}
}
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
// Return DTO with the requested expires_at (grant subquery on the share
// row would return NULL at this point since INSERT ran before the grant).
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
response.expires_at = dto.expires_at;
Ok(response)
}
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
@@ -364,16 +336,6 @@ impl ShareUseCase for ShareService {
// SECURITY: ownership-verified lookup — prevents IDOR
let mut share = self.fetch_owned_share(id, requester_id).await?;
// Update permissions if provided
if let Some(permissions_dto) = dto.permissions {
let permissions = SharePermissions::new(
permissions_dto.read,
permissions_dto.write,
permissions_dto.reshare,
);
share = share.with_permissions(permissions);
}
// Update password if provided (async, semaphore-bounded)
if let Some(password) = dto.password {
let password_hash = if password.is_empty() {
@@ -384,23 +346,33 @@ impl ShareUseCase for ShareService {
share = share.with_password(password_hash);
}
// Update expiration date if provided
// Expiry is managed at the grant level; update all grants for this token.
let new_expires_at = if dto.expires_at.is_some() {
dto.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0))
} else {
None
};
if dto.expires_at.is_some() {
share = share.with_expiration(dto.expires_at);
self.authorization
.set_expiry_for_subject(Subject::Token(share.id()), new_expires_at)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
}
// Save the changes
let updated_share = self
.share_repository
.update_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(
&updated_share,
&self.config.base_url(),
))
// Use the requested expires_at for the response (subquery in update_share
// runs before set_expiry_for_subject committed, so entity may lag).
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
if dto.expires_at.is_some() {
response.expires_at = dto.expires_at;
}
Ok(response)
}
async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> {
@@ -510,8 +482,6 @@ impl ShareUseCase for ShareService {
#[allow(dead_code)]
mod tests {
use super::*;
#[allow(unused_imports)]
use crate::application::dtos::share_dto::SharePermissionsDto;
use crate::application::ports::auth_ports::PasswordHasherPort;
use crate::application::ports::share_ports::ShareStoragePort;
use crate::application::ports::storage_ports::FileReadPort;
@@ -606,7 +576,6 @@ mod tests {
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
self.verify_item_exists(&dto.item_id, &item_type).await?;
let permissions = dto.permissions.map(|p| p.to_entity());
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
@@ -616,9 +585,7 @@ mod tests {
dto.item_name.clone(),
item_type,
user_id,
permissions,
password_hash,
dto.expires_at,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
let saved_share = self
@@ -692,9 +659,6 @@ mod tests {
.map_err(|e| {
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
})?;
if let Some(p) = dto.permissions {
share = share.with_permissions(SharePermissions::new(p.read, p.write, p.reshare));
}
if let Some(password) = dto.password {
let hash = if password.is_empty() {
None
@@ -703,9 +667,6 @@ mod tests {
};
share = share.with_password(hash);
}
if dto.expires_at.is_some() {
share = share.with_expiration(dto.expires_at);
}
let updated = self
.share_repository
.update_share(&share)
@@ -1208,11 +1169,6 @@ mod tests {
item_type: "file".to_string(),
password: Some("secret".to_string()),
expires_at: None,
permissions: Some(SharePermissionsDto {
read: true,
write: false,
reshare: false,
}),
};
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
+2 -115
View File
@@ -12,20 +12,13 @@ pub struct Share {
item_type: ShareItemType,
token: String,
password_hash: Option<String>,
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
expires_at: Option<u64>,
permissions: SharePermissions,
created_at: u64,
created_by: Uuid,
access_count: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SharePermissions {
read: bool,
write: bool,
reshare: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShareItemType {
File,
@@ -38,31 +31,14 @@ impl Share {
item_name: Option<String>,
item_type: ShareItemType,
created_by: Uuid,
permissions: Option<SharePermissions>,
password_hash: Option<String>,
expires_at: Option<u64>,
) -> Result<Self, ShareError> {
// Validate item_id
if item_id.is_empty() {
return Err(ShareError::ValidationError(
"Item ID cannot be empty".to_string(),
));
}
// Validate expiration date if provided
if let Some(expires) = expires_at {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
if expires <= now {
return Err(ShareError::InvalidExpiration(
"Expiration date must be in the future".to_string(),
));
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
@@ -75,12 +51,7 @@ impl Share {
item_type,
token: Uuid::new_v4().to_string(),
password_hash,
expires_at,
permissions: permissions.unwrap_or(SharePermissions {
read: true,
write: false,
reshare: false,
}),
expires_at: None,
created_at: now,
created_by,
access_count: 0,
@@ -96,7 +67,6 @@ impl Share {
token: String,
password_hash: Option<String>,
expires_at: Option<u64>,
permissions: SharePermissions,
created_at: u64,
created_by: Uuid,
access_count: u64,
@@ -109,7 +79,6 @@ impl Share {
token,
password_hash,
expires_at,
permissions,
created_at,
created_by,
access_count,
@@ -142,10 +111,6 @@ impl Share {
self.expires_at
}
pub fn permissions(&self) -> &SharePermissions {
&self.permissions
}
pub fn created_at(&self) -> u64 {
self.created_at
}
@@ -160,21 +125,11 @@ impl Share {
// ── Builder-style modifiers (immutable) ──
pub fn with_permissions(mut self, permissions: SharePermissions) -> Self {
self.permissions = permissions;
self
}
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
self.password_hash = password_hash;
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 {
self.token = token;
self
@@ -212,28 +167,6 @@ impl Share {
}
}
impl SharePermissions {
pub fn new(read: bool, write: bool, reshare: bool) -> Self {
Self {
read,
write,
reshare,
}
}
pub fn read(&self) -> bool {
self.read
}
pub fn write(&self) -> bool {
self.write
}
pub fn reshare(&self) -> bool {
self.reshare
}
}
impl std::fmt::Display for ShareItemType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -275,59 +208,17 @@ mod tests {
ShareItemType::File,
uid,
None,
None,
None,
)
.unwrap();
assert_eq!(share.item_id(), "test_file_id");
assert_eq!(*share.item_type(), ShareItemType::File);
assert_eq!(share.created_by(), uid);
assert!(share.permissions().read());
assert!(!share.permissions().write());
assert!(!share.permissions().reshare());
assert!(!share.has_password());
assert!(share.expires_at().is_none());
assert_eq!(share.access_count(), 0);
}
#[test]
fn test_share_is_expired() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
// Create a share that expires in the future
let future = now + 3600; // 1 hour in the future
let share = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
test_user_id(),
None,
None,
Some(future),
)
.unwrap();
assert!(!share.is_expired());
// Test with past expiration (should fail during creation)
let past = now - 3600; // 1 hour in the past
let share_result = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
test_user_id(),
None,
None,
Some(past),
);
assert!(share_result.is_err());
}
#[test]
fn test_share_item_type_conversion() {
assert_eq!(ShareItemType::File.to_string(), "file");
@@ -355,9 +246,7 @@ mod tests {
None,
ShareItemType::File,
test_user_id(),
None,
Some("some_hash_value".to_string()),
None,
)
.unwrap();
@@ -373,8 +262,6 @@ mod tests {
ShareItemType::File,
test_user_id(),
None,
None, // No password
None,
)
.unwrap();
+43
View File
@@ -200,6 +200,13 @@ pub struct Grant {
pub permission: Permission,
pub granted_by: Uuid,
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())
}
}
// ════════════════════════════════════════════════════════════════════════════
@@ -252,6 +259,42 @@ pub struct IncomingGrantSummary {
pub granted_by: Uuid,
}
// ════════════════════════════════════════════════════════════════════════════
// OutgoingGrantEntry / OutgoingResourceSummary — per-subject grant within a
// resource that the current user shared with others
// ════════════════════════════════════════════════════════════════════════════
/// One (subject, permissions) pair within an outgoing resource summary.
/// The `subject_display` field is resolved by the SQL layer: username for
/// `user` subjects, share item_name for `token` subjects.
#[derive(Debug, Clone)]
pub struct OutgoingGrantEntry {
pub grant_id: Uuid,
pub subject_type: String,
pub subject_id: Uuid,
/// Human-readable label: username (users) or share name (tokens).
pub subject_display: String,
/// All permissions held by this subject on the resource (aggregated).
pub permissions: Vec<Permission>,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`).
/// Always `false` for `user` subjects.
pub has_password: bool,
}
/// All subjects that the current user has shared a single resource with,
/// together with the resource type, id, and when it was first shared.
#[derive(Debug, Clone)]
pub struct OutgoingResourceSummary {
pub resource_type: ResourceKind,
pub resource_id: Uuid,
/// Earliest `granted_at` across all grants on this resource.
pub first_shared_at: chrono::DateTime<chrono::Utc>,
/// One entry per (subject, permissions) pair.
pub grants: Vec<OutgoingGrantEntry>,
}
// ════════════════════════════════════════════════════════════════════════════
// GrantCursor — opaque pagination cursor for list_incoming_resources_paged
// ════════════════════════════════════════════════════════════════════════════
@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{
application::ports::share_ports::ShareStoragePort,
common::errors::DomainError,
domain::entities::share::{Share, ShareItemType, SharePermissions},
domain::entities::share::{Share, ShareItemType},
};
/// PostgreSQL implementation of [`ShareStoragePort`].
@@ -37,6 +37,8 @@ impl SharePgRepository {
}
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
let id: Uuid = row
.try_get("id")
@@ -52,10 +54,8 @@ impl SharePgRepository {
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
})?;
let password_hash: Option<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 permissions_read: bool = row.try_get("permissions_read").unwrap_or(true);
let permissions_write: bool = row.try_get("permissions_write").unwrap_or(false);
let permissions_reshare: bool = row.try_get("permissions_reshare").unwrap_or(false);
let created_at: i64 = row.try_get("created_at").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
})?;
@@ -66,8 +66,6 @@ impl SharePgRepository {
let item_type =
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
let permissions =
SharePermissions::new(permissions_read, permissions_write, permissions_reshare);
Ok(Share::from_raw(
id,
@@ -77,7 +75,6 @@ impl SharePgRepository {
token,
password_hash,
expires_at.map(|v| v as u64),
permissions,
created_at as u64,
created_by,
access_count as u64,
@@ -91,23 +88,18 @@ impl ShareStoragePort for SharePgRepository {
r#"
INSERT INTO storage.shares
(id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count)
VALUES
($1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13)
($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
item_name = EXCLUDED.item_name,
password_hash = EXCLUDED.password_hash,
expires_at = EXCLUDED.expires_at,
permissions_read = EXCLUDED.permissions_read,
permissions_write = EXCLUDED.permissions_write,
permissions_reshare = EXCLUDED.permissions_reshare,
access_count = EXCLUDED.access_count
item_name = EXCLUDED.item_name,
password_hash = EXCLUDED.password_hash,
access_count = EXCLUDED.access_count
RETURNING
id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
created_at, created_by, access_count
"#,
)
@@ -117,10 +109,6 @@ impl ShareStoragePort for SharePgRepository {
.bind(share.item_type().to_string())
.bind(share.token())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.created_at() as i64)
.bind(share.created_by())
.bind(share.access_count() as i64)
@@ -137,11 +125,13 @@ impl ShareStoragePort for SharePgRepository {
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE token = $1
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
WHERE s.token = $1
"#,
)
.bind(token)
@@ -168,11 +158,13 @@ impl ShareStoragePort for SharePgRepository {
) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE id = $1 AND created_by = $2
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
WHERE s.id = $1 AND s.created_by = $2
"#,
)
.bind(id)
@@ -224,12 +216,14 @@ impl ShareStoragePort for SharePgRepository {
) -> Result<Vec<Share>, DomainError> {
let rows = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE item_id = $1 AND item_type = $2 AND created_by = $3
ORDER BY created_at DESC
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
WHERE s.item_id = $1 AND s.item_type = $2 AND s.created_by = $3
ORDER BY s.created_at DESC
"#,
)
.bind(item_id)
@@ -249,27 +243,21 @@ impl ShareStoragePort for SharePgRepository {
let row = sqlx::query(
r#"
UPDATE storage.shares SET
item_name = $2,
password_hash = $3,
expires_at = $4,
permissions_read = $5,
permissions_write = $6,
permissions_reshare = $7,
access_count = $8
item_name = $2,
password_hash = $3,
access_count = $4
WHERE id = $1
RETURNING
id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
created_at, created_by, access_count
"#,
)
.bind(share.id())
.bind(share.item_name())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.access_count() as i64)
.fetch_optional(&*self.db_pool)
.await
@@ -296,13 +284,15 @@ impl ShareStoragePort for SharePgRepository {
// Single query with window function — count + rows in one roundtrip
let rows = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count,
COUNT(*) OVER() AS total_count
FROM storage.shares
WHERE created_by = $1
ORDER BY created_at DESC
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count,
COUNT(*) OVER() AS total_count
FROM storage.shares s
WHERE s.created_by = $1
ORDER BY s.created_at DESC
LIMIT $2 OFFSET $3
"#,
)
+677 -57
View File
@@ -36,7 +36,8 @@ use sqlx::PgPool;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
Permission, Resource, ResourceKind, Subject,
};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
@@ -103,6 +104,7 @@ impl PgAclEngine {
AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
LIMIT 1
"#,
@@ -135,6 +137,7 @@ impl PgAclEngine {
FROM storage.access_grants
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
AND resource_type = 'file' AND resource_id = $4
AND (expires_at IS NULL OR expires_at > NOW())
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
@@ -145,6 +148,7 @@ impl PgAclEngine {
AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND target_f.folder_id IS NOT NULL
AND gf.lpath @> (SELECT lpath FROM storage.folders
WHERE id = target_f.folder_id)
@@ -186,8 +190,9 @@ impl PgAclEngine {
Ok(Some((res, granter)))
}
/// Decode a (id, subject_type, subject_id, resource_type, resource_id,
/// permission, granted_by, granted_at) row into a `Grant`.
/// Row type for all full-grant SELECT queries:
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
#[allow(clippy::type_complexity)]
fn row_to_grant(
row: (
Uuid,
@@ -198,6 +203,7 @@ impl PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
) -> Result<Grant, DomainError> {
let subject = Subject::from_parts(&row.1, row.2)
@@ -213,6 +219,7 @@ impl PgAclEngine {
permission,
granted_by: row.6,
granted_at: row.7,
expires_at: row.8,
})
}
}
@@ -271,11 +278,12 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
WHERE subject_type = $1
AND subject_id = $2
@@ -461,42 +469,6 @@ impl AuthorizationEngine for PgAclEngine {
LIMIT $8"#
)
}
"size" => {
// Folders have no size — sentinel -1 (sorts first ASC, last DESC).
// Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused.
let (where_clause, order_clause) = if reverse {
(
r#"( $5::bigint IS NULL
OR sort_int < $5
OR (sort_int = $5 AND resource_id < $7::uuid))"#,
"sort_int DESC, resource_id DESC",
)
} else {
(
r#"( $5::bigint IS NULL
OR sort_int > $5
OR (sort_int = $5 AND resource_id > $7::uuid))"#,
"sort_int ASC, resource_id ASC",
)
};
format!(
r#"WITH {AGG},
sized AS (
SELECT agg.*,
NULL::text AS sort_str,
CASE WHEN agg.resource_type = 'folder' THEN -1
ELSE fi.size
END AS sort_int
FROM agg
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
)
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
FROM sized
WHERE {where_clause}
ORDER BY {order_clause}
LIMIT $8"#
)
}
_ => {
// Default: sort by grant date.
// Normal = DESC (newest first); reversed = ASC (oldest first).
@@ -580,14 +552,6 @@ impl AuthorizationEngine for PgAclEngine {
sort_int: None,
reverse,
},
"size" => GrantCursor {
sort_by: "size".to_owned(),
granted_at: r.3,
resource_id: r.1,
resource_name: None,
sort_int: r.6,
reverse,
},
_ => GrantCursor {
sort_by: "granted_at".to_owned(),
granted_at: r.3,
@@ -636,11 +600,12 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
WHERE resource_type = $1
AND resource_id = $2
@@ -656,6 +621,619 @@ impl AuthorizationEngine for PgAclEngine {
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn list_outgoing_resources_paged(
&self,
granted_by: Uuid,
limit: u32,
cursor: Option<GrantCursor>,
sort_by: &str,
reverse: bool,
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError> {
let fetch_limit = (limit as i64) + 1;
// Row shape — one row per (resource, subject, permission).
// Columns:
// 0 resource_type String
// 1 resource_id Uuid
// 2 first_shared_at DateTime<Utc> — MIN(granted_at) across resource
// 3 subject_type String
// 4 subject_id Uuid
// 5 subject_display String — username or share item_name
// 6 grant_id Uuid
// 7 granted_at DateTime<Utc> — this (subject, perm) row
// 8 expires_at Option<DateTime<Utc>>
// 9 permission String
// 10 sort_str Option<String>
// 11 sort_int Option<i64>
// 12 has_password bool — token: shares.password_hash IS NOT NULL
type Row = (
String,
Uuid,
chrono::DateTime<chrono::Utc>,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
String,
Option<String>,
Option<i64>,
bool,
);
let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone());
let cursor_int = cursor.as_ref().and_then(|c| c.sort_int);
let cursor_at = cursor.as_ref().map(|c| c.granted_at);
let cursor_id = cursor.as_ref().map(|c| c.resource_id);
// ── Resource-page CTE (one row per resource, cursor-paginated) ─────────
// We page on resources (by first_shared_at + resource_id) so that the
// limit/cursor semantics are consistent with the incoming endpoint.
// All grants for each paged resource are then retrieved in the same query.
//
// $1 = granted_by
// $2 = cursor_str (resource_name for name/type, owner_name for granted_by)
// $3 = cursor_int (category_order for type, size for size)
// $4 = cursor_at (first_shared_at)
// $5 = cursor_id (resource_id)
// $6 = fetch_limit
let sql = match sort_by {
"name" | "type" => {
let sort_int_expr = if sort_by == "type" {
"CASE WHEN ag.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END"
} else {
"NULL::bigint"
};
let (page_where, page_order) = if sort_by == "type" {
if reverse {
(
r#"( $3::integer IS NULL
OR sort_int < $3
OR (sort_int = $3 AND LOWER(sort_str) < $2)
OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#,
"sort_int DESC, LOWER(sort_str) DESC, resource_id DESC",
)
} else {
(
r#"( $3::integer IS NULL
OR sort_int > $3
OR (sort_int = $3 AND LOWER(sort_str) > $2)
OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#,
"sort_int ASC, LOWER(sort_str) ASC, resource_id ASC",
)
}
} else if reverse {
(
r#"( $2::text IS NULL
OR LOWER(sort_str) < $2
OR (LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#,
"LOWER(sort_str) DESC, resource_id DESC",
)
} else {
(
r#"( $2::text IS NULL
OR LOWER(sort_str) > $2
OR (LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#,
"LOWER(sort_str) ASC, resource_id ASC",
)
};
format!(
r#"WITH resource_page AS (
SELECT ag.resource_type, ag.resource_id, MIN(ag.granted_at) AS first_shared_at,
COALESCE(
CASE WHEN ag.resource_type = 'folder' THEN f.name END,
CASE WHEN ag.resource_type = 'file' THEN fi.name END
) AS sort_str,
{sort_int_expr} AS sort_int
FROM storage.access_grants ag
LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder'
LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file'
WHERE ag.granted_by = $1
GROUP BY ag.resource_type, ag.resource_id, f.name, fi.name, fi.category_order
),
rp AS (
SELECT * FROM resource_page
WHERE {page_where}
ORDER BY {page_order}
LIMIT $6
)
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
ag.subject_type, ag.subject_id,
COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
rp.sort_str, rp.sort_int,
(sh.password_hash IS NOT NULL) AS has_password
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
AND ag.granted_by = $1
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id
LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id
LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id
ORDER BY {page_order}, ag.subject_id, ag.granted_at"#
)
}
"subject" => {
// Page on (subject_type_order, subject_display, resource_id) triples so
// every swimlane is always contiguous across cursor pages.
//
// subject_type_order: 0 = user, 1 = token without password, 2 = token with password
//
// Cursor encodes: sort_int = subject_type_order, resource_name = LOWER(subject_display),
// resource_id = last resource_id.
let (page_where, page_order) = if reverse {
(
r#"( $3::bigint IS NULL
OR sort_int < $3
OR (sort_int = $3 AND LOWER(subject_display) < $2)
OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#,
"sort_int DESC, LOWER(subject_display) DESC, resource_id DESC",
)
} else {
(
r#"( $3::bigint IS NULL
OR sort_int > $3
OR (sort_int = $3 AND LOWER(subject_display) > $2)
OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#,
"sort_int ASC, LOWER(subject_display) ASC, resource_id ASC",
)
};
format!(
r#"WITH pairs AS (
SELECT
ag.resource_type,
ag.resource_id,
ag.subject_type,
ag.subject_id,
MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display,
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
MAX(CASE
WHEN ag.subject_type = 'user' THEN 0
WHEN ag.subject_type = 'token' AND sh.password_hash IS NULL THEN 1
ELSE 2
END)::bigint AS sort_int,
MIN(ag.granted_at) AS first_granted_at
FROM storage.access_grants ag
LEFT JOIN auth.users u
ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN storage.shares sh
ON ag.subject_type = 'token' AND sh.id = ag.subject_id
LEFT JOIN storage.files fi
ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id
LEFT JOIN storage.folders fld
ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id
WHERE ag.granted_by = $1
AND (ag.expires_at IS NULL OR ag.expires_at > NOW())
GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id
),
rp AS (
SELECT * FROM pairs
WHERE {page_where}
ORDER BY {page_order}
LIMIT $6
)
SELECT
ag.resource_type,
ag.resource_id,
rp.first_granted_at AS first_shared_at,
ag.subject_type,
ag.subject_id,
rp.subject_display,
ag.id AS grant_id,
ag.granted_at,
ag.expires_at,
ag.permission,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type
AND ag.resource_id = rp.resource_id
AND ag.subject_type = rp.subject_type
AND ag.subject_id = rp.subject_id
AND ag.granted_by = $1
AND (ag.expires_at IS NULL OR ag.expires_at > NOW())
ORDER BY {page_order}"#
)
}
"role" => {
// Page on (role_order, subject_display, resource_id) triples so that all
// of one person's grants within a role are contiguous — enabling aggregation
// ("Bob on Folder A, Folder B") to work correctly across cursor pages.
// role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer
// Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id
let (page_where, page_order) = if reverse {
(
r#"( $3::bigint IS NULL
OR sort_int < $3
OR (sort_int = $3 AND LOWER(subject_display) < $2)
OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#,
"sort_int DESC, LOWER(subject_display) DESC, resource_id DESC",
)
} else {
(
r#"( $3::bigint IS NULL
OR sort_int > $3
OR (sort_int = $3 AND LOWER(subject_display) > $2)
OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#,
"sort_int ASC, LOWER(subject_display) ASC, resource_id ASC",
)
};
format!(
r#"WITH pairs AS (
SELECT
ag.resource_type,
ag.resource_id,
ag.subject_type,
ag.subject_id,
MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display,
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
CASE
WHEN BOOL_OR(ag.permission = 'delete')
AND BOOL_OR(ag.permission = 'share') THEN 0
WHEN BOOL_OR(ag.permission = 'create')
OR BOOL_OR(ag.permission = 'update') THEN 1
ELSE 2
END::bigint AS sort_int,
MIN(ag.granted_at) AS first_granted_at
FROM storage.access_grants ag
LEFT JOIN auth.users u
ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN storage.shares sh
ON ag.subject_type = 'token' AND sh.id = ag.subject_id
LEFT JOIN storage.files fi
ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id
LEFT JOIN storage.folders fld
ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id
WHERE ag.granted_by = $1
AND (ag.expires_at IS NULL OR ag.expires_at > NOW())
GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id
),
rp AS (
SELECT * FROM pairs
WHERE {page_where}
ORDER BY {page_order}
LIMIT $6
)
SELECT
ag.resource_type,
ag.resource_id,
rp.first_granted_at AS first_shared_at,
ag.subject_type,
ag.subject_id,
rp.subject_display,
ag.id AS grant_id,
ag.granted_at,
ag.expires_at,
ag.permission,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type
AND ag.resource_id = rp.resource_id
AND ag.subject_type = rp.subject_type
AND ag.subject_id = rp.subject_id
AND ag.granted_by = $1
AND (ag.expires_at IS NULL OR ag.expires_at > NOW())
ORDER BY {page_order}"#
)
}
_ => {
// Default: sort by first_shared_at DESC (newest resource shared first).
let (page_where, page_order) = if reverse {
(
r#"( $4::timestamptz IS NULL
OR first_shared_at > $4
OR (first_shared_at = $4 AND resource_id > $5::uuid))"#,
"first_shared_at ASC, resource_id ASC",
)
} else {
(
r#"( $4::timestamptz IS NULL
OR first_shared_at < $4
OR (first_shared_at = $4 AND resource_id < $5::uuid))"#,
"first_shared_at DESC, resource_id DESC",
)
};
format!(
r#"WITH resource_page AS (
SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at,
NULL::text AS sort_str,
NULL::bigint AS sort_int
FROM storage.access_grants
WHERE granted_by = $1
GROUP BY resource_type, resource_id
),
rp AS (
SELECT * FROM resource_page
WHERE {page_where}
ORDER BY {page_order}
LIMIT $6
)
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
ag.subject_type, ag.subject_id,
COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
NULL::text AS sort_str, NULL::bigint AS sort_int,
(sh.password_hash IS NOT NULL) AS has_password
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
AND ag.granted_by = $1
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id
LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id
LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id
ORDER BY {page_order}, ag.subject_id, ag.granted_at"#
)
}
};
let rows: Vec<Row> = sqlx::query_as::<_, Row>(&sql)
.bind(granted_by) // $1
.bind(&cursor_str) // $2 sort_str cursor
.bind(cursor_int) // $3 sort_int cursor
.bind(cursor_at) // $4 first_shared_at cursor
.bind(cursor_id) // $5 resource_id cursor
.bind(fetch_limit) // $6
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error(
"PgAcl",
format!("list_outgoing_resources_paged ({sort_by}): {e}"),
)
})?;
// ── Subject / Role sorts: page on (resource_id, subject_id) pairs ───────
// Each pair becomes one OutgoingResourceSummary with exactly one grant,
// preserving the SQL-ordered swimlane sequence across cursor pages.
if matches!(sort_by, "subject" | "role") {
let mut seen_pairs: Vec<(Uuid, Uuid)> = Vec::new();
let mut seen_pair_set: std::collections::HashSet<(Uuid, Uuid)> =
std::collections::HashSet::new();
for r in &rows {
if seen_pair_set.insert((r.1, r.4)) {
seen_pairs.push((r.1, r.4));
}
}
let has_next = seen_pairs.len() > limit as usize;
seen_pairs.truncate(limit as usize);
let keep: std::collections::HashSet<(Uuid, Uuid)> =
seen_pairs.iter().copied().collect();
let last_row = rows.iter().rfind(|r| keep.contains(&(r.1, r.4)));
let next_cursor = if has_next {
last_row.map(|r| {
let resource_name = r.10.clone(); // LOWER(subject_display) for both subject and role sort
GrantCursor {
sort_by: sort_by.to_owned(),
granted_at: r.2,
resource_id: r.1,
resource_name,
sort_int: r.11,
reverse,
}
})
} else {
None
};
// Group rows: (resource_id, subject_id) → OutgoingGrantEntry.
let mut entry_map: std::collections::HashMap<
(Uuid, Uuid),
(ResourceKind, OutgoingGrantEntry),
> = std::collections::HashMap::new();
for r in rows.into_iter().filter(|r| keep.contains(&(r.1, r.4))) {
let (
rt_str,
resource_id,
_first_shared_at,
subj_type,
subj_id,
subj_display,
grant_id,
granted_at,
expires_at,
perm_str,
_,
_,
has_password,
) = r;
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
};
let Some(perm) = Permission::parse(&perm_str) else {
continue;
};
let key = (resource_id, subj_id);
let (_, entry) = entry_map.entry(key).or_insert_with(|| {
(
resource_type,
OutgoingGrantEntry {
grant_id,
subject_type: subj_type.clone(),
subject_id: subj_id,
subject_display: subj_display.clone(),
permissions: Vec::new(),
granted_at,
expires_at,
has_password,
},
)
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
}
}
let summaries: Vec<OutgoingResourceSummary> = seen_pairs
.into_iter()
.filter_map(|(rid, sid)| {
let (resource_type, grant) = entry_map.remove(&(rid, sid))?;
Some(OutgoingResourceSummary {
resource_type,
resource_id: rid,
first_shared_at: grant.granted_at,
grants: vec![grant],
})
})
.collect();
return Ok((summaries, next_cursor));
}
// ── All other sorts: page on distinct resource_ids ────────────────────
let mut seen_resources: Vec<Uuid> = Vec::new();
let mut seen_set: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
for r in &rows {
if seen_set.insert(r.1) {
seen_resources.push(r.1);
}
}
let has_next = seen_resources.len() > limit as usize;
seen_resources.truncate(limit as usize);
let keep: std::collections::HashSet<Uuid> = seen_resources.iter().copied().collect();
let last_row = rows.iter().rfind(|r| keep.contains(&r.1));
let next_cursor = if has_next {
last_row.map(|r| {
let sort_str_lc = r.10.as_deref().map(str::to_lowercase);
match sort_by {
"name" => GrantCursor {
sort_by: "name".to_owned(),
granted_at: r.2,
resource_id: r.1,
resource_name: sort_str_lc,
sort_int: None,
reverse,
},
"type" => GrantCursor {
sort_by: "type".to_owned(),
granted_at: r.2,
resource_id: r.1,
resource_name: sort_str_lc,
sort_int: r.11,
reverse,
},
_ => GrantCursor {
sort_by: "first_shared_at".to_owned(),
granted_at: r.2,
resource_id: r.1,
resource_name: None,
sort_int: None,
reverse,
},
}
})
} else {
None
};
// Group flat rows by resource_id → (ResourceKind, first_shared_at, subjects).
type ResourceEntry = (
ResourceKind,
chrono::DateTime<chrono::Utc>,
std::collections::HashMap<Uuid, OutgoingGrantEntry>,
);
let mut resource_map: std::collections::HashMap<Uuid, ResourceEntry> =
std::collections::HashMap::new();
for r in rows.into_iter().filter(|r| keep.contains(&r.1)) {
let (
rt_str,
resource_id,
first_shared_at,
subj_type,
subj_id,
subj_display,
grant_id,
granted_at,
expires_at,
perm_str,
_,
_,
has_password,
) = r;
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
};
let Some(perm) = Permission::parse(&perm_str) else {
continue;
};
let (_, _, subj_map) = resource_map.entry(resource_id).or_insert_with(|| {
(
resource_type,
first_shared_at,
std::collections::HashMap::new(),
)
});
let entry = subj_map
.entry(subj_id)
.or_insert_with(|| OutgoingGrantEntry {
grant_id,
subject_type: subj_type.clone(),
subject_id: subj_id,
subject_display: subj_display.clone(),
permissions: Vec::new(),
granted_at,
expires_at,
has_password,
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
}
}
let summaries: Vec<OutgoingResourceSummary> = seen_resources
.into_iter()
.filter_map(|rid| {
let (resource_type, first_shared_at, subj_map) = resource_map.remove(&rid)?;
let mut grants: Vec<OutgoingGrantEntry> = subj_map.into_values().collect();
let role_rank = |perms: &[Permission]| -> u8 {
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
0 // admin → Can manage
} else if perms.contains(&Permission::Create)
|| perms.contains(&Permission::Update)
{
1 // editor → Can edit
} else {
2 // viewer → Can view
}
};
grants.sort_by(|a, b| {
role_rank(&a.permissions)
.cmp(&role_rank(&b.permissions))
.then_with(|| {
// users before tokens
let type_rank = |st: &str| if st == "user" { 0u8 } else { 1 };
type_rank(&a.subject_type).cmp(&type_rank(&b.subject_type))
})
.then_with(|| {
a.subject_display
.to_lowercase()
.cmp(&b.subject_display.to_lowercase())
})
});
Some(OutgoingResourceSummary {
resource_type,
resource_id: rid,
first_shared_at,
grants,
})
})
.collect();
Ok((summaries, next_cursor))
}
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
@@ -668,11 +1246,12 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
WHERE granted_by = $1
ORDER BY granted_at DESC
@@ -692,10 +1271,8 @@ impl AuthorizationEngine for PgAclEngine {
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> 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::<
_,
(
@@ -707,16 +1284,17 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
DO UPDATE SET subject_type = EXCLUDED.subject_type
DO UPDATE SET expires_at = EXCLUDED.expires_at
RETURNING id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
"#,
)
.bind(subject.type_str())
@@ -725,6 +1303,7 @@ impl AuthorizationEngine for PgAclEngine {
.bind(resource.id())
.bind(permission.as_str())
.bind(granted_by)
.bind(expires_at)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
@@ -732,6 +1311,47 @@ impl AuthorizationEngine for PgAclEngine {
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> {
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
.bind(grant_id)
@@ -6,7 +6,7 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use tracing::{error, info, warn};
use utoipa::ToSchema;
use crate::application::dtos::display_helpers::{
@@ -56,6 +56,9 @@ pub async fn get_favorites(
auth_user: AuthUser,
) -> impl IntoResponse {
let user_id = auth_user.id;
warn!(
"Deprecated endpoint called: GET /api/favorites — use GET /api/favorites/resources instead"
);
match favorites_service.get_favorites(user_id).await {
Ok(favorites) => {
@@ -490,6 +490,9 @@ pub async fn list_folder_contents(
auth_user: AuthUser,
path: Path<String>,
) -> axum::response::Response {
tracing::warn!(
"Deprecated endpoint called: GET /api/folders/{{id}}/contents — use GET /api/folders/{{id}}/resources?resource_types=folder instead"
);
FolderHandler::list_folder_contents_impl(state, auth_user, path).await
}
@@ -533,6 +536,9 @@ pub async fn list_folder_contents_paginated(
path: Path<String>,
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
tracing::warn!(
"Deprecated endpoint called: GET /api/folders/{{id}}/contents/paginated — use GET /api/folders/{{id}}/resources instead"
);
FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await
}
+202 -10
View File
@@ -20,8 +20,9 @@ use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::grant_dto::{
CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto,
SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
@@ -31,7 +32,8 @@ use crate::common::di::AppState;
use crate::common::errors::DomainError;
use crate::domain::errors::ErrorKind;
use crate::domain::services::authorization::{
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
Subject,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -86,6 +88,7 @@ pub async fn create_grant(
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
// Caller must have Share on the resource (owners pass via short-circuit).
if let Err(e) = authz
@@ -97,7 +100,10 @@ pub async fn create_grant(
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz.grant(caller_id, subject, perm, resource).await {
match authz
.grant(caller_id, subject, perm, resource, expires_at)
.await
{
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
@@ -189,6 +195,7 @@ pub async fn set_role(
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
@@ -225,11 +232,25 @@ pub async fn set_role(
}
}
for perm in &to_add {
if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await {
if let Err(e) = authz
.grant(caller_id, subject, *perm, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
}
// Sync expiry on all remaining grants for this (subject, resource) pair —
// includes newly added ones and any that were already present (retained).
// Callers that omit expires_at will clear any existing expiry; this is
// intentional: it keeps all permission rows for the pair consistent.
if let Err(e) = authz
.set_expiry_on_resource(subject, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
@@ -330,13 +351,10 @@ pub async fn list_shared_with_me(
// Validate sort_by (defaults to "granted_at").
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
if !matches!(
sort_by,
"granted_at" | "granted_by" | "name" | "type" | "size"
) {
if !matches!(sort_by, "granted_at" | "granted_by" | "name" | "type") {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})),
Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type"})),
)
.into_response();
}
@@ -550,6 +568,180 @@ pub async fn list_on_resource(
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/outgoing/resources
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/outgoing/resources",
params(SharedWithMeQuery),
responses(
(status = 200,
description = "Cursor-paginated resources the caller has shared with others. \
Each item carries the full resource details plus all subjects \
(users and tokens) the resource was shared with. \
`next_cursor` is absent on the last page.",
body = MySharesDto),
),
security(("bearerAuth" = [])),
tag = "grants"
)]
pub async fn list_my_shares(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<SharedWithMeQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let limit = q.limit_clamped() as u32;
let sort_by = q.sort_by.as_deref().unwrap_or("first_shared_at");
if !matches!(
sort_by,
"first_shared_at" | "name" | "type" | "subject" | "role"
) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid sort_by; valid values: first_shared_at, name, type, subject, role"})),
)
.into_response();
}
let reverse = q.reverse;
let cursor = q
.decode_cursor::<GrantCursor>()
.filter(|c| c.sort_by == sort_by && c.reverse == reverse);
let (summaries, next_cursor) = match state
.authorization
.list_outgoing_resources_paged(caller_id, limit, cursor, sort_by, reverse)
.await
{
Ok(r) => r,
Err(e) => return AppError::from(e).into_response(),
};
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service_concrete;
// Split summaries by resource kind for parallel resolution.
let file_summaries: Vec<&OutgoingResourceSummary> = summaries
.iter()
.filter(|s| matches!(s.resource_type, ResourceKind::File))
.collect();
let folder_summaries: Vec<&OutgoingResourceSummary> = summaries
.iter()
.filter(|s| matches!(s.resource_type, ResourceKind::Folder))
.collect();
let file_ids: Vec<String> = file_summaries
.iter()
.map(|s| s.resource_id.to_string())
.collect();
let folder_ids: Vec<String> = folder_summaries
.iter()
.map(|s| s.resource_id.to_string())
.collect();
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
);
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
let grants: Vec<OutgoingResourceGrantDto> = summary
.grants
.iter()
.map(|g| OutgoingResourceGrantDto {
grant_id: g.grant_id,
subject_type: g.subject_type.clone(),
subject_id: g.subject_id,
subject_display: g.subject_display.clone(),
role: role_from_permissions(&g.permissions).to_owned(),
granted_at: g.granted_at,
expires_at: g.expires_at,
has_password: g.has_password,
})
.collect();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
grants,
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale outgoing file grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch file {}: {e}",
summary.resource_id
))
.into_response();
}
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
grants,
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale outgoing folder grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch folder {}: {e}",
summary.resource_id
))
.into_response();
}
}
}
}
}
(
StatusCode::OK,
Json(MySharesDto::with_cursor(
items,
next_cursor.map(|c| c.encode()),
)),
)
.into_response()
}
// Silence unused-import warnings for SubjectDto when only certain endpoints
// touch it directly.
#[allow(dead_code)]
@@ -6,7 +6,7 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use tracing::{error, info, warn};
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
@@ -21,6 +21,7 @@ use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::services::recent_service::RecentService;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid;
/// Query parameters for getting recent items
#[derive(Deserialize)]
@@ -46,6 +47,7 @@ pub async fn get_recent_items(
Query(params): Query<GetRecentParams>,
) -> impl IntoResponse {
let user_id = auth_user.id;
warn!("Deprecated endpoint called: GET /api/recent — use GET /api/recent/resources instead");
match recent_service.get_recent_items(user_id, params.limit).await {
Ok(items) => {
@@ -83,7 +85,7 @@ pub async fn get_recent_items(
pub async fn record_item_access(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
Path((item_type, item_id)): Path<(String, String)>,
Path((item_type, item_id)): Path<(String, Uuid)>,
) -> impl IntoResponse {
let user_id = auth_user.id;
@@ -99,7 +101,7 @@ pub async fn record_item_access(
}
match recent_service
.record_item_access(user_id, &item_id, &item_type)
.record_item_access(user_id, &item_id.to_string(), &item_type)
.await
{
Ok(_) => {
@@ -143,12 +145,12 @@ pub async fn record_item_access(
pub async fn remove_from_recent(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
Path((item_type, item_id)): Path<(String, String)>,
Path((item_type, item_id)): Path<(String, Uuid)>,
) -> impl IntoResponse {
let user_id = auth_user.id;
match recent_service
.remove_from_recent(user_id, &item_id, &item_type)
.remove_from_recent(user_id, &item_id.to_string(), &item_type)
.await
{
Ok(removed) => {
+1 -4
View File
@@ -34,9 +34,7 @@ use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
SearchSuggestionItem, SearchSuggestionsDto,
};
use crate::application::dtos::share_dto::{
CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto,
};
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
use crate::application::dtos::trash_dto::{
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
};
@@ -257,7 +255,6 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
OidcExchangeDto,
// Share schemas
ShareDto,
SharePermissionsDto,
CreateShareDto,
UpdateShareDto,
// Trash schemas
+4 -3
View File
@@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
get(grant_handler::list_shared_with_me),
)
.route("/outgoing", get(grant_handler::list_outgoing))
.route("/outgoing/resources", get(grant_handler::list_my_shares))
.with_state(app_state.clone())
};
@@ -337,8 +338,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
};
Router::new()
.route("/", get(get_favorites)) // deprecated, kept for compat
.route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint
.route("/", get(get_favorites)) // deprecated — kept for external compat
.route("/resources", get(list_favorites_resources))
.route("/batch", post(favorites_handler::batch_add_favorites))
.route(
"/{item_type}/{item_id}",
@@ -359,7 +360,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
use crate::interfaces::api::handlers::recent_handler;
Router::new()
.route("/", get(recent_handler::get_recent_items))
.route("/", get(recent_handler::get_recent_items)) // deprecated — kept for external compat
.route("/resources", get(recent_handler::list_recent_resources))
.route(
"/{item_type}/{item_id}",
+46
View File
@@ -0,0 +1,46 @@
/* ── Link chip ───────────────────────────────────────────────────────────────
*
* Inline clickable element representing a share link.
* Usage: buildLinkChip(grant) → HTMLButtonElement with class .link-chip
* ─────────────────────────────────────────────────────────────────────────── */
.link-chip {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
padding: 2px 0;
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-size: 13px;
text-align: left;
transition: color 0.12s;
}
.link-chip:hover {
color: var(--color-accent);
}
.link-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.link-chip__icon {
font-size: 12px;
color: var(--color-text-faint);
flex-shrink: 0;
transition: color 0.12s;
}
.link-chip:hover .link-chip__icon {
color: var(--color-accent);
}
.link-chip__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+1 -1
View File
@@ -315,7 +315,7 @@
/* Wider, taller container; body becomes a zero-padding scrollable slot. */
.modal-container--panel {
width: 520px;
width: 620px;
max-width: 96vw;
max-height: 88vh;
display: flex;
+88 -26
View File
@@ -328,6 +328,8 @@
}
.smd-link-name {
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--color-text-heading);
overflow: hidden;
@@ -431,32 +433,6 @@
color: var(--color-accent);
}
.smd-new-link-form {
margin-top: 8px;
padding: 14px;
background: var(--color-bg-hover);
border: 0.5px solid var(--color-border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.smd-new-link-form label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
display: block;
margin-bottom: 3px;
}
.smd-new-link-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
/* ── Password toggle row ─────────────────────────────────────────────────────── */
.smd-pw-toggle {
@@ -488,3 +464,89 @@
transform: rotate(360deg);
}
}
/* ── Expiry chip toggle ──────────────────────────────────────────────────────── */
/*
* The wrapper reserves a fixed width equal to the date input so that toggling
* between chip and input never shifts the surrounding flex row.
*/
.smd-expiry-chip-wrap {
display: inline-flex;
align-items: stretch;
flex-shrink: 0;
width: 130px;
}
.smd-expiry-chip {
display: inline-flex;
align-items: center;
gap: 5px;
width: 100%;
box-sizing: border-box;
padding: 4px 8px;
font-size: 12px;
border: 1px dashed var(--color-border-medium);
border-radius: 6px;
background: transparent;
color: var(--color-text-faint);
cursor: pointer;
white-space: nowrap;
transition:
border-color 0.15s,
color 0.15s,
background 0.15s;
}
.smd-expiry-chip:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-bg-hover);
}
.smd-expiry-chip--set {
border-style: solid;
border-color: var(--color-border);
background: var(--color-bg-muted);
color: var(--color-text-secondary);
}
.smd-expiry-chip--set:hover {
border-color: var(--color-border-medium);
color: var(--color-text-heading);
background: var(--color-bg-hover);
}
.smd-expiry-chip-clear {
font-size: 13px;
line-height: 1;
color: var(--color-text-faint);
margin-left: auto;
padding: 0;
transition: color 0.1s;
}
.smd-expiry-chip-clear:hover {
color: var(--color-error-text);
}
.smd-expiry-date-input {
width: 100%;
box-sizing: border-box;
padding: 4px 8px;
font-size: 12px;
border: 1px solid var(--color-accent);
border-radius: 6px;
background: var(--color-bg-surface);
color: var(--color-text-heading);
outline: none;
box-shadow: 0 0 0 3px var(--color-accent-ring);
}
/* In the search row the chip/input must match the height of the role select and
Add button (both use padding: 9px, font-size: 13px). */
.smd-search-row .smd-expiry-chip,
.smd-search-row .smd-expiry-date-input {
padding: 9px 10px;
font-size: 13px;
}
+1
View File
@@ -21,6 +21,7 @@
@import url("./components/shareDialog.css");
@import url("./components/shareModal.css");
@import url("./components/userVignette.css");
@import url("./components/linkChip.css");
@import url("./components/uploadDropdown.css");
@import url("./components/notifications.css");
@import url("./components/userMenu.css");
+4
View File
@@ -120,3 +120,7 @@
background: var(--color-bg-alt);
color: var(--color-accent);
}
[data-theme="dark"] .smd-expiry-date-input::-webkit-calendar-picker-indicator {
filter: invert(1);
}
+336
View File
@@ -0,0 +1,336 @@
/* ── My Shares view ─────────────────────────────────────────────────────────
*
* BEM classes for the row-per-grant MySharesList component.
* All colors via var(--*). Mobile-first layout.
* ─────────────────────────────────────────────────────────────────────────── */
/* ── Load-more wrapper ───────────────────────────────────────────────────── */
.ms-load-more-wrapper {
display: flex;
justify-content: center;
padding: 16px 0 8px;
}
.ms-load-more-wrapper.hidden {
display: none;
}
/* ── Container: dissolve when lanes are present ──────────────────────────── */
.files-list-view:has(.ms-lane) {
background-color: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
gap: 10px;
display: flex;
flex-direction: column;
}
/* ── Lane (swimlane card) ────────────────────────────────────────────────── */
.ms-lane {
background-color: var(--color-item);
border-radius: 10px;
box-shadow: 0 1px 3px var(--color-shadow-xs);
overflow: hidden;
}
.ms-lane__header {
background: var(--color-bg-muted);
border-bottom: 1px solid var(--color-border-faint);
}
/* Vignette lane headers need the same padding as the resource row */
.ms-lane__header .user-vignette {
padding: 10px 14px;
font-weight: 600;
}
.ms-lane__header .user-vignette .user-vignette__name {
font-size: 13px;
color: var(--color-text-heading);
font-weight: 600;
}
.ms-lane__body {
/* rows are direct children */
}
/* ── Resource lane header (items mode) ───────────────────────────────────── */
.ms-resource-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
}
/* Size the shared .file-icon component for inline use in myShares rows */
.ms-resource-row .file-icon {
width: 36px;
height: 36px;
border-radius: 6px;
font-size: 14px;
flex-shrink: 0;
}
/* Smaller icon for child grant rows and inline resource links */
.ms-grant-row__identity .file-icon,
.ms-link-identity__resource .file-icon {
width: 24px;
height: 24px;
border-radius: 4px;
font-size: 11px;
flex-shrink: 0;
}
.ms-resource-row__name {
flex: 1;
font-weight: 600;
color: var(--color-text);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-resource-row__name:hover {
color: var(--color-accent);
text-decoration: underline;
}
.ms-resource-row__edit {
flex-shrink: 0;
font-size: 12px;
padding: 3px 8px;
opacity: 0.6;
transition: opacity 0.15s;
}
.ms-resource-row__edit:hover {
opacity: 1;
}
/* ── Subject lane header (sharedWith mode — link bucket) ─────────────────── */
.ms-link-lane-label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.ms-link-lane-label__icon {
color: var(--color-text-faint);
font-size: 12px;
}
/* ── Grant row ───────────────────────────────────────────────────────────── */
.ms-grant-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px 7px 28px;
border-bottom: 1px solid var(--color-border-faint);
transition: background 0.1s;
}
.ms-grant-row:last-child {
border-bottom: none;
}
.ms-grant-row:hover {
background: var(--color-bg-hover);
}
.ms-grant-row--expired {
opacity: 0.6;
}
/* ── Grant row — identity ─────────────────────────────────────────────────── */
.ms-grant-row__identity {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.ms-identity__name {
font-size: 13px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-identity__resource-name {
font-size: 13px;
font-weight: 500;
color: var(--color-text);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-identity__resource-name:hover {
color: var(--color-accent);
text-decoration: underline;
}
/* Token in sharedWith mode: arrow + resource link */
.ms-link-identity__arrow {
font-size: 11px;
color: var(--color-text-faint);
flex-shrink: 0;
}
.ms-link-identity__resource {
font-size: 12px;
color: var(--color-text-secondary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-link-identity__resource:hover {
color: var(--color-accent);
text-decoration: underline;
}
/* ── Role pill ───────────────────────────────────────────────────────────── */
.ms-role-pill {
display: inline-block;
flex-shrink: 0;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.ms-role-pill--manage {
background: var(--color-badge-orange-bg);
color: var(--color-badge-orange-text);
}
.ms-role-pill--edit {
background: var(--color-badge-blue-bg);
color: var(--color-badge-blue-text);
}
.ms-role-pill--view {
background: var(--color-bg-muted);
color: var(--color-text-muted);
}
/* ── Expiry chip ─────────────────────────────────────────────────────────── */
.ms-expiry-chip {
display: inline-flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
padding: 2px 7px;
border-radius: 10px;
font-size: 11px;
white-space: nowrap;
}
.ms-expiry-chip--never {
background: var(--color-bg-muted);
color: var(--color-text-faint);
}
.ms-expiry-chip--active {
background: var(--color-bg-muted);
color: var(--color-text-muted);
}
.ms-expiry-chip--soon {
background: var(--color-badge-amber-bg);
color: var(--color-badge-amber-text);
}
.ms-expiry-chip--expired {
background: var(--color-danger-lighter);
color: var(--color-danger-text-alt);
}
/* ── Kebab / icon buttons ────────────────────────────────────────────────── */
.ms-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 11px;
flex-shrink: 0;
transition:
background 0.12s,
color 0.12s;
}
.ms-btn-icon:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.ms-btn-icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ms-kebab-btn {
margin-left: auto;
}
/* ── Context menu current-item indicator ─────────────────────────────────── */
.ms-menu-item--current {
font-weight: 600;
}
/* ── Context menu expiry row ─────────────────────────────────────────────── */
.ms-menu-expiry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
}
.ms-menu-expiry-label {
font-size: 12px;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* Stretch the chip to fill remaining space in the expiry row */
.ms-menu-expiry-row .smd-expiry-chip-wrap {
flex: 1;
min-width: 0;
}
-294
View File
@@ -1,294 +0,0 @@
.page-description {
color: var(--color-text-muted);
font-size: 16px;
margin-top: -15px;
margin-bottom: 25px;
}
.shared-page-container {
max-width: 1280px;
margin: 20px auto;
padding: 0 20px;
}
.shared-header {
margin-bottom: 25px;
}
.shared-header h2 {
font-size: 24px;
color: var(--color-text);
margin-bottom: 8px;
}
.shared-header p {
color: var(--color-text-muted);
font-size: 16px;
}
/* In-app Shared view (index.html dynamic container) */
.shared-view-container .shared-header {
margin-bottom: 20px;
}
.shared-view-container .shared-filters {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 0;
padding: 15px;
background-color: var(--color-bg-subtle);
box-shadow: 0 1px 3px var(--color-shadow-xs);
border-radius: 10px 10px 0 0;
border-bottom: 1px solid var(--color-border-faint);
font-weight: 600;
color: var(--color-text);
}
.shared-view-container .shared-custom-select {
position: relative;
}
.shared-view-container .shared-select-toggle {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 40px 10px 16px;
border-radius: 12px;
border: 2px solid var(--color-border);
background-color: var(--color-bg-hover);
font-size: 14px;
font-weight: 500;
color: var(--color-text-heading);
cursor: pointer;
min-width: 170px;
height: 44px;
transition: all 0.2s ease;
text-align: left;
}
.shared-view-container .shared-select-toggle:hover {
border-color: var(--color-border-medium);
background-color: var(--color-bg-surface);
}
.shared-view-container .shared-custom-select.open .shared-select-toggle {
border-color: var(--color-accent);
background-color: var(--color-bg-surface);
box-shadow: 0 0 0 4px var(--color-accent-ring);
}
.shared-view-container .shared-select-arrow {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
color: var(--color-text-muted);
transition: transform 0.2s ease;
pointer-events: none;
}
.shared-view-container .shared-custom-select.open .shared-select-arrow {
transform: translateY(-50%) rotate(180deg);
}
.shared-view-container .shared-select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 100%;
background-color: var(--color-bg-surface);
border-radius: 12px;
box-shadow: 0 4px 20px var(--color-shadow-md);
border: 1px solid var(--color-border);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
z-index: 1000;
overflow: hidden;
}
.shared-view-container .shared-custom-select.open .shared-select-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.shared-view-container .shared-select-option {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
font-weight: 400;
color: var(--color-text-secondary);
transition: background-color 0.15s ease;
}
.shared-view-container .shared-select-option:hover {
background-color: var(--color-bg-alt);
}
.shared-view-container .shared-select-option.active {
background-color: var(--color-accent-tint);
color: var(--color-accent);
font-weight: 500;
}
.shared-filters {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0;
flex-wrap: wrap;
gap: 15px;
background-color: var(--color-bg-subtle);
border-radius: 10px 10px 0 0;
padding: 15px;
border-bottom: 1px solid var(--color-border-faint);
box-shadow: 0 1px 3px var(--color-shadow-xs);
font-weight: 600;
color: var(--color-text);
}
.filter-group {
display: flex;
align-items: center;
gap: 10px;
}
.filter-group label {
font-size: 14px;
color: var(--color-text-secondary);
font-weight: 500;
}
.filter-group select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-bg-surface);
font-size: 14px;
min-width: 120px;
}
.search-box {
display: flex;
gap: 10px;
}
.search-box input {
padding: 8px 15px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 14px;
width: 250px;
}
.shared-list-container {
background-color: var(--color-bg-surface);
border-radius: 0 0 10px 10px;
box-shadow: 0 1px 3px var(--color-shadow-xs);
overflow: hidden;
margin-bottom: 25px;
}
.shared-list,
.shared-table {
width: 100%;
border-collapse: collapse;
}
.shared-list thead th,
.shared-table thead th {
padding: 15px;
text-align: left;
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border-faint);
}
.shared-list tbody td,
.shared-table tbody td {
padding: 15px;
border-bottom: 1px solid var(--color-border-xfaint);
vertical-align: middle;
color: var(--color-text);
}
.shared-item-name {
display: flex;
align-items: center;
gap: 10px;
}
.shared-item-actions {
display: flex;
gap: 10px;
}
.shared-list .action-btn,
.shared-table .action-btn {
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-bg-surface);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.shared-list .action-btn:hover,
.shared-table .action-btn:hover {
background-color: var(--color-item-hover-blue);
border-color: var(--color-info-border-light);
}
.action-icon {
font-size: 14px;
}
#empty-shared-state.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 20px;
background-color: transparent;
border-radius: 0;
box-shadow: none;
text-align: center;
color: var(--color-text-gray);
}
#empty-shared-state p {
margin-bottom: 10px;
max-width: 400px;
}
@media (max-width: 768px) {
.shared-filters {
flex-direction: column;
align-items: flex-start;
}
.shared-list thead th:nth-child(4),
.shared-list thead th:nth-child(5),
.shared-list tbody td:nth-child(4),
.shared-list tbody td:nth-child(5),
.shared-table thead th:nth-child(4),
.shared-table thead th:nth-child(5),
.shared-table tbody td:nth-child(4),
.shared-table tbody td:nth-child(5) {
display: none;
}
}
+1 -2
View File
@@ -13,8 +13,8 @@
<link rel="stylesheet" href="/css/views/inlineViewer.css">
<link rel="stylesheet" href="/css/views/favorites.css">
<link rel="stylesheet" href="/css/views/recent.css">
<link rel="stylesheet" href="/css/views/shared.css">
<link rel="stylesheet" href="/css/views/sharedWithMe.css">
<link rel="stylesheet" href="/css/views/mySharesView.css">
<link rel="stylesheet" href="/css/views/trash.css">
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css">
@@ -40,7 +40,6 @@
<script defer type="module" src="/js/features/library/photos.js"></script>
<script defer type="module" src="/js/features/library/music.js"></script>
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
<script defer type="module" src="/js/views/shared/sharedView.js"></script>
<script defer type="module" src="/js/model/recentModel.js"></script>
<script defer type="module" src="/js/views/recent/recentView.js"></script>
<script defer type="module" src="/js/features/files/inlineViewer.js"></script>
+16 -1
View File
@@ -15,6 +15,7 @@
*/
import { ResourceListComponent } from '../components/resourceList.js';
import { shareModal } from '../components/shareModal.js';
import { normalizeDateBucket, sizeBucket } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import * as viewPrefs from '../core/viewPrefs.js';
@@ -218,6 +219,12 @@ function _ensureComponent() {
_component?.setFavoriteVisualState(item.id, type, true);
}
},
onShareBadgeClick: (item) => {
const isFile = 'mime_type' in item;
shareModal.open(item, isFile ? 'file' : 'folder', () => {
grants.fetchOutgoingGrants().then(() => refreshSharedBadges());
});
},
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
onSelectionChange: (selectedItems) => {
batchToolbar._selected.clear();
@@ -454,4 +461,12 @@ async function loadFiles(options = { insertHistory: true }) {
}
}
export { addItem, filesView, loadFiles };
/**
* Re-evaluate the shared badge for every item currently rendered in the Files list.
* Call this after the outgoing grants cache has been refreshed.
*/
function refreshSharedBadges() {
_component?.refreshSharedBadges();
}
export { addItem, filesView, loadFiles, refreshSharedBadges };
+36 -13
View File
@@ -18,7 +18,6 @@ import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { grants } from '../model/grants.js';
import { recentView } from '../views/recent/recentView.js';
import { sharedView } from '../views/shared/sharedView.js';
import { checkAuthentication } from './authSession.js';
import { loadFiles } from './filesView.js';
import {
@@ -162,12 +161,30 @@ const ACTIONS_BAR_TEMPLATES = {
<div class="action-buttons" id="default-buttons"></div>
${_batchToolbarButons}
${_toggleButtons}
`,
shared: `
<div class="action-buttons" id="default-buttons"></div>
<div class="view-toggle">
<div class="group-by-selector hidden" id="group-by-selector">
<button class="toggle-btn group-by-btn" id="group-by-btn"
title="Group by" data-i18n-title="groupby.title">
<i class="fas fa-layer-group"></i>
<span class="group-by-label"></span>
</button>
<button class="toggle-btn sort-dir-btn" id="sort-dir-btn"
title="Sort direction" data-i18n-title="sortdir.title">
<i class="fas fa-arrow-up" id="sort-dir-icon"></i>
</button>
<div class="group-by-menu hidden" id="group-by-menu"></div>
</div>
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
</div>
`
};
/**
*
* @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'hidden'} mode
* @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'shared' | 'hidden'} mode
* @param {boolean} [force=false]
* @returns
*/
@@ -259,8 +276,12 @@ function syncGroupByMenu(defs = []) {
// Rebuild menu options — call i18n.t() directly so each label is resolved
// at call time (translations are loaded by the time any section switch runs).
menu.innerHTML = `<button class="group-by-option active" data-group-by="">${escapeHtml(i18n.t('groupby.none', 'None'))}</button>`;
// A def with key='' lets the section override the default "None" label.
const noneOverride = defs.find((d) => d.key === '');
const noneLabel = noneOverride ? noneOverride.label : i18n.t('groupby.none', 'None');
menu.innerHTML = `<button class="group-by-option active" data-group-by="">${escapeHtml(noneLabel)}</button>`;
for (const def of defs) {
if (def.key === '') continue;
menu.insertAdjacentHTML('beforeend', `<button class="group-by-option" data-group-by="${escapeHtml(def.key)}">${escapeHtml(def.label)}</button>`);
}
@@ -508,6 +529,12 @@ function initApp() {
window.addEventListener('authenticationDone', async () => {
// Check if a context was provided in the URL
const hashContext = deserializeHash();
// Always fetch grants so shared badges are correct regardless of the
// initial section. Fire in the background — don't block section init.
grants.fetchIncomingGrants();
grants.fetchOutgoingGrants();
switchSectionTo(hashContext.section);
if (hashContext.section === 'files') {
if (hashContext.path) {
@@ -519,9 +546,6 @@ function initApp() {
app.viewFile = hashContext.file;
}
// get grants (xxx: async methods)
await grants.fetchIncomingGrants();
await grants.fetchOutgoingGrants();
loadFiles();
}
});
@@ -640,8 +664,10 @@ function setupEventListeners() {
} else {
// change is from history, data provided in event
switchSectionTo(e.state.section);
app.currentPath = e.state.id;
loadFiles({ insertHistory: false });
if (e.state.section === 'files') {
app.currentPath = e.state.id;
loadFiles({ insertHistory: false });
}
}
});
@@ -676,11 +702,8 @@ function setupEventListeners() {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput?.value.trim();
// In shared section, filter locally
if (app.currentSection === 'shared' && sharedView) {
sharedView.filterAndSortItems();
return;
}
// My Shares section does not support in-page search
if (app.currentSection === 'shared') return;
if (query) {
performSearch(query);
+29 -19
View File
@@ -10,11 +10,12 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
import { favorites } from '../features/library/favorites.js';
import { musicView } from '../features/library/music.js';
import { photosView } from '../features/library/photos.js';
import { grants } from '../model/grants.js';
import { favoritesView } from '../views/favorites/favoritesView.js';
import { mySharesView } from '../views/myShares/mySharesView.js';
import { recentView } from '../views/recent/recentView.js';
import { sharedView } from '../views/shared/sharedView.js';
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
import { filesView, loadFiles } from './filesView.js';
import { filesView, loadFiles, refreshSharedBadges } from './filesView.js';
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
import { app, appElements } from './state.js';
import { loadTrashItems } from './trashView.js';
@@ -165,9 +166,9 @@ function setCurrentSection(section) {
appElements.pageTitle.setAttribute('data-i18n', titleKey);
}
// Hide sharedView when switching to any other section
if (section !== 'shared' && sharedView) {
sharedView.hide();
// Hide mySharesView when switching to any other section
if (section !== 'shared') {
mySharesView.hide();
}
// Hide "Load more" button when leaving the sharedwithme section
@@ -208,21 +209,27 @@ function switchToSharedSection() {
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.add('hidden');
// Hide actions-bar for shared view
setActionsBarMode('hidden');
// Show actions-bar with group-by controls only (no grid/list toggle —
// MySharesList is always in list mode).
setActionsBarMode('shared');
//reset files view + remove any error
ui.resetFilesList();
// Populate the group-by dropdown with this section's dimensions.
setGroupByView(mySharesView);
syncGroupByMenu(mySharesView.groupByDefs);
// Hide file containers
toggleFileContainer(false);
// Restore the saved group-by selection in the dropdown.
const msPrefs = viewPrefs.load('shared');
applyGroupByMenuState(msPrefs.groupBy, msPrefs.reversed);
// Show shared view
sharedView.init().then(() => {
sharedView.show();
});
// Show the files container always in list view — grid is not applicable here.
toggleFileContainer(true);
app.currentView = 'list';
syncViewContainers();
if (batchToolbar) batchToolbar.clear();
// Load and render items into the files container
mySharesView.init();
}
function switchToSharedWithMeSection() {
@@ -293,10 +300,13 @@ function switchToFilesSection() {
ui.updateBreadcrumb();
if (batchToolbar) batchToolbar.clear();
// temp solution
sharedView.loadItems().then(() => {
loadFiles();
});
loadFiles();
// Refresh outgoing grants in the background and repaint badges once done.
// Badges are rendered synchronously from the in-memory cache, so any staleness
// from navigating away and back (or starting on a different section) is corrected
// without blocking the file list render.
grants.fetchOutgoingGrants().then(() => refreshSharedBadges());
}
function switchToFavoritesSection() {
+1 -1
View File
@@ -882,7 +882,7 @@ const ui = {
loadFiles();
return;
}
if (app.currentSection === 'sharedwithme') {
if (app.currentSection === 'sharedwithme' || app.currentSection === 'shared') {
// Activate Files UI (nav, breadcrumb, actions bar) without
// resetting the path — the shared folder becomes the entry point.
activateFilesUI();
+53
View File
@@ -0,0 +1,53 @@
/**
* linkChip — inline clickable element representing a share link.
*
* Renders: [🔒/🔗] Link - ...{last4 of UUID} - {name}
* Clicking copies the share URL to the clipboard.
*/
import { i18n } from '../core/i18n.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
/** @import {OutgoingResourceGrant} from '../core/types.js' */
/**
* Build an inline link chip. Clicking it copies the share URL.
* @param {OutgoingResourceGrant} grant
* @returns {HTMLButtonElement}
*/
function buildLinkChip(grant) {
const btn = /** @type {HTMLButtonElement} */ (document.createElement('button'));
btn.className = `link-chip${grant.has_password ? ' link-chip--locked' : ''}`;
btn.type = 'button';
btn.title = i18n.t('share.copyLink', 'Copy link');
const icon = document.createElement('i');
icon.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon';
btn.appendChild(icon);
const last4 = grant.subject_id.slice(-4);
const label = `${i18n.t('share.link', 'Link')} - ...${last4} - ${grant.subject_display}`;
const text = document.createElement('span');
text.className = 'link-chip__label';
text.textContent = label;
btn.appendChild(text);
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
btn.disabled = true;
try {
const share = await fileSharing.getShareById(grant.subject_id);
await fileSharing.copyLinkToClipboard(share.url);
} catch (err) {
console.error('linkChip: copy failed', err);
} finally {
btn.disabled = false;
}
});
return btn;
}
export { buildLinkChip };
+9 -8
View File
@@ -346,14 +346,15 @@ const Modal = {
*
* @param {Object} options
* @param {string} options.title
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
* @param {string} [options.confirmText] - Confirm button label
* @param {string} [options.cancelText] - Cancel button label
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
* @param {string} [options.confirmText] - Confirm button label
* @param {string} [options.cancelText] - Cancel button label
* @param {boolean} [options.confirmDisabled] - Initial disabled state of the confirm button
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
*/
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) {
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, confirmDisabled = false, onConfirm = null, onCancel = null }) {
if (!this.overlay) return;
this._panelMode = true;
@@ -379,7 +380,7 @@ const Modal = {
// ── Footer buttons ──────────────────────────────────────────────────
if (this.confirmBtn) {
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
this.confirmBtn.disabled = false;
this.confirmBtn.disabled = confirmDisabled;
}
if (this.cancelBtn) {
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
+600
View File
@@ -0,0 +1,600 @@
/**
* MySharesList — row-per-grant list for the My Shares view.
*
* Both view modes emit one row per grant. Lane headers are emitted on
* grouping-key change — the server guarantees ORDER BY group key first.
*
* Modes:
* 'items' — lane = resource; row identity = subject
* 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource
*/
import { i18n } from '../core/i18n.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { grants } from '../model/grants.js';
import { buildExpiryChip } from '../utils/expiryChip.js';
import { buildPasswordChip } from '../utils/passwordChip.js';
import { buildLinkChip } from './linkChip.js';
import { buildResourceIcon } from './resourceIcon.js';
import { createUserVignette } from './userVignette.js';
/**
* @import {OutgoingResourceItem, OutgoingResourceGrant, FileItem, FolderItem} from '../core/types.js'
* @typedef {'items'|'sharedWith'} ViewMode
* @typedef {'never'|'active'|'soon'|'expired'} ExpiryState
*/
const SOON_DAYS = 30;
/**
* @param {string|null|undefined} expiresAt
* @returns {ExpiryState}
*/
function _expiryState(expiresAt) {
if (!expiresAt) return 'never';
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms < 0) return 'expired';
if (ms <= SOON_DAYS * 86_400_000) return 'soon';
return 'active';
}
/** @param {string} role @returns {string} */
function _roleLabel(role) {
/** @type {Record<string,string>} */
const m = {
admin: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
return m[role] ?? role;
}
/** @param {string} role @returns {'manage'|'edit'|'view'} */
function _roleMod(role) {
if (role === 'admin') return 'manage';
if (role === 'editor') return 'edit';
return 'view';
}
class MySharesList {
/**
* @param {HTMLElement} container
* @param {{
* onResourceOpen: (resource: FileItem|FolderItem, resourceType: string) => void,
* onShareEdit: (resource: FileItem|FolderItem, resourceType: string) => void,
* }} config
*/
constructor(container, config) {
this._container = container;
this._config = config;
/** @type {string|null} */
this._lastSwimKey = null;
/** @type {HTMLElement|null} */
this._lastSwimEl = null;
}
clear() {
this._container.innerHTML = '';
this._lastSwimKey = null;
this._lastSwimEl = null;
}
/**
* Full re-render (page 1).
* @param {OutgoingResourceItem[]} items
* @param {ViewMode} viewMode
*/
render(items, viewMode) {
this.clear();
this._ingest(items, viewMode);
}
/**
* Cursor append (page 2+).
* @param {OutgoingResourceItem[]} items
* @param {ViewMode} viewMode
*/
append(items, viewMode) {
this._ingest(items, viewMode);
}
// ── Core ingest ───────────────────────────────────────────────────────────
/**
* @param {OutgoingResourceItem[]} items
* @param {ViewMode} viewMode
*/
_ingest(items, viewMode) {
for (const item of items) {
if (viewMode === 'items') {
this._ingestItemsMode(item);
} else {
this._ingestSharedWithMode(item);
}
}
}
/**
* Items mode — one lane per resource, one grant row per grant.
* @param {OutgoingResourceItem} item
*/
_ingestItemsMode(item) {
const swimKey = `resource:${item.resource.id}`;
const laneBody = this._ensureLane(swimKey, () => this._buildResourceLaneHeader(item));
for (const grant of item.grants) {
laneBody.appendChild(this._buildGrantRow(grant, item, 'items'));
}
}
/**
* SharedWith mode — one lane per user or per link bucket, one row per grant.
* @param {OutgoingResourceItem} item
*/
_ingestSharedWithMode(item) {
for (const grant of item.grants) {
let swimKey;
if (grant.subject_type === 'user') {
swimKey = `user:${grant.subject_id}`;
} else if (grant.has_password) {
swimKey = 'links:password';
} else {
swimKey = 'links:public';
}
const laneBody = this._ensureLane(swimKey, () => this._buildSubjectLaneHeader(swimKey, grant));
laneBody.appendChild(this._buildGrantRow(grant, item, 'sharedWith'));
}
}
// ── Lane management ───────────────────────────────────────────────────────
/**
* Return the existing lane body when swimKey matches, else create a new lane.
* @param {string} swimKey
* @param {() => HTMLElement} buildHeader
* @returns {HTMLElement}
*/
_ensureLane(swimKey, buildHeader) {
if (swimKey === this._lastSwimKey && this._lastSwimEl) return this._lastSwimEl;
const lane = document.createElement('div');
lane.className = 'ms-lane';
lane.dataset.swimKey = swimKey;
const header = document.createElement('div');
header.className = 'ms-lane__header';
header.appendChild(buildHeader());
lane.appendChild(header);
const body = document.createElement('div');
body.className = 'ms-lane__body';
lane.appendChild(body);
this._container.appendChild(lane);
this._lastSwimKey = swimKey;
this._lastSwimEl = body;
return body;
}
/**
* Lane header for items mode: resource icon + name link + Edit sharing button.
* @param {OutgoingResourceItem} item
* @returns {HTMLElement}
*/
_buildResourceLaneHeader(item) {
const row = document.createElement('div');
row.className = 'ms-resource-row';
row.appendChild(buildResourceIcon(item.resource, item.resource_type));
const nameLink = document.createElement('a');
nameLink.className = 'ms-resource-row__name';
nameLink.href = '#';
nameLink.textContent = item.resource.name;
nameLink.addEventListener('click', (e) => {
e.preventDefault();
this._config.onResourceOpen(item.resource, item.resource_type);
});
row.appendChild(nameLink);
const editBtn = document.createElement('button');
editBtn.className = 'ms-resource-row__edit button ghost';
editBtn.innerHTML = `<i class="fas fa-pencil-alt"></i> ${i18n.t('myshares.editSharing', 'Edit sharing')}`;
editBtn.addEventListener('click', () => this._config.onShareEdit(item.resource, item.resource_type));
row.appendChild(editBtn);
return row;
}
/**
* Lane header for sharedWith mode: user vignette or link bucket label.
* @param {string} swimKey
* @param {OutgoingResourceGrant} grant
* @returns {HTMLElement}
*/
_buildSubjectLaneHeader(swimKey, grant) {
if (swimKey.startsWith('user:')) {
return createUserVignette(grant.subject_id, 'list');
}
const el = document.createElement('div');
el.className = 'ms-link-lane-label';
const icon = document.createElement('i');
if (swimKey === 'links:password') {
icon.className = 'fas fa-lock ms-link-lane-label__icon';
el.appendChild(icon);
el.appendChild(document.createTextNode(` ${i18n.t('myshares.passwordLinks', 'Password-protected links')}`));
} else {
icon.className = 'fas fa-link ms-link-lane-label__icon';
el.appendChild(icon);
el.appendChild(document.createTextNode(` ${i18n.t('myshares.publicLinks', 'Public links')}`));
}
return el;
}
// ── Grant row ─────────────────────────────────────────────────────────────
/**
* One grant row: identity + role pill + expiry chip + ⋯ button.
* @param {OutgoingResourceGrant} grant
* @param {OutgoingResourceItem} item
* @param {ViewMode} viewMode
* @returns {HTMLElement}
*/
_buildGrantRow(grant, item, viewMode) {
const row = document.createElement('div');
row.className = 'ms-grant-row';
if (_expiryState(grant.expires_at ?? null) === 'expired') {
row.classList.add('ms-grant-row--expired');
}
row.appendChild(this._buildIdentity(grant, item, viewMode));
row.appendChild(this._buildRolePill(grant.role));
row.appendChild(this._buildExpiryChip(grant.expires_at ?? null));
row.appendChild(this._buildKebabBtn(grant, item, row));
return row;
}
/**
* Identity: user vignette or link icon + name; tokens in sharedWith mode add → resource.
* @param {OutgoingResourceGrant} grant
* @param {OutgoingResourceItem} item
* @param {ViewMode} viewMode
* @returns {HTMLElement}
*/
_buildIdentity(grant, item, viewMode) {
const el = document.createElement('div');
el.className = 'ms-grant-row__identity';
if (grant.subject_type === 'user' && viewMode === 'sharedWith') {
// Lane header is already the user — show the resource instead
el.appendChild(buildResourceIcon(item.resource, item.resource_type));
const nameLink = document.createElement('a');
nameLink.className = 'ms-identity__resource-name';
nameLink.href = '#';
nameLink.textContent = item.resource.name;
nameLink.addEventListener('click', (e) => {
e.preventDefault();
this._config.onResourceOpen(item.resource, item.resource_type);
});
el.appendChild(nameLink);
} else if (grant.subject_type === 'user') {
el.appendChild(createUserVignette(grant.subject_id, 'xs'));
} else {
// Token — link chip handles icon + label + copy-on-click
el.appendChild(buildLinkChip(grant));
if (viewMode === 'sharedWith') {
const arrow = document.createElement('span');
arrow.className = 'ms-link-identity__arrow';
arrow.textContent = '→';
el.appendChild(arrow);
const resLink = document.createElement('a');
resLink.className = 'ms-link-identity__resource';
resLink.href = '#';
resLink.appendChild(buildResourceIcon(item.resource, item.resource_type));
resLink.appendChild(document.createTextNode(` ${item.resource.name}`));
resLink.addEventListener('click', (e) => {
e.preventDefault();
this._config.onResourceOpen(item.resource, item.resource_type);
});
el.appendChild(resLink);
}
}
return el;
}
/** @param {string} role @returns {HTMLElement} */
_buildRolePill(role) {
const pill = document.createElement('span');
pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`;
pill.textContent = _roleLabel(role);
return pill;
}
/**
* 4-state expiry chip: never / active / soon / expired.
* @param {string|null} expiresAt
* @returns {HTMLElement}
*/
_buildExpiryChip(expiresAt) {
const state = _expiryState(expiresAt);
const chip = document.createElement('span');
chip.className = `ms-expiry-chip ms-expiry-chip--${state}`;
const icon = document.createElement('i');
const text = document.createTextNode('');
if (state === 'never') {
icon.className = 'fas fa-infinity';
chip.appendChild(icon);
chip.appendChild(document.createTextNode(` ${i18n.t('myshares.neverExpires', 'Never expires')}`));
} else if (state === 'expired') {
icon.className = 'fas fa-exclamation-triangle';
chip.appendChild(icon);
chip.appendChild(document.createTextNode(` ${i18n.t('myshares.expired', 'Expired')}`));
} else if (state === 'soon' && expiresAt) {
icon.className = 'fas fa-clock';
const days = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
const label =
days <= 1
? i18n.t('myshares.expiresTomorrow', 'Expires tomorrow')
: i18n.t('myshares.expiresInDays', 'Expires in {n} days').replace('{n}', String(days));
chip.appendChild(icon);
chip.appendChild(document.createTextNode(` ${label}`));
} else if (expiresAt) {
icon.className = 'fas fa-clock';
const d = new Date(expiresAt);
const fmt = d.toLocaleDateString('default', { day: 'numeric', month: 'short', year: 'numeric' });
chip.appendChild(icon);
chip.appendChild(document.createTextNode(` ${i18n.t('myshares.until', 'Until')} ${fmt}`));
}
// unused ref kept to avoid TS unused-var warning suppression
void text;
return chip;
}
// ── Kebab menu ────────────────────────────────────────────────────────────
/**
* @param {OutgoingResourceGrant} grant
* @param {OutgoingResourceItem} item
* @param {HTMLElement} rowEl
* @returns {HTMLButtonElement}
*/
_buildKebabBtn(grant, item, rowEl) {
const btn = /** @type {HTMLButtonElement} */ (document.createElement('button'));
btn.className = 'ms-kebab-btn ms-btn-icon';
btn.setAttribute('aria-label', i18n.t('myshares.manageAccess', 'Manage access'));
btn.innerHTML = '<i class="fas fa-ellipsis-v"></i>';
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._openGrantMenu(btn, grant, item, rowEl);
});
return btn;
}
/**
* Build and show a dynamic context menu positioned below the trigger button.
* @param {HTMLButtonElement} btn
* @param {OutgoingResourceGrant} grant
* @param {OutgoingResourceItem} item
* @param {HTMLElement} rowEl
*/
_openGrantMenu(btn, grant, item, rowEl) {
document.querySelector('.ms-grant-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'context-menu ms-grant-menu';
// Current expiry as YYYY-MM-DD (or null)
const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null;
if (grant.subject_type === 'user') {
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
const isCurrent = grant.role === role;
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', _roleLabel(role), false, async () => {
menu.remove();
if (isCurrent) return;
await grants.updateRole({
subject: { type: grant.subject_type, id: grant.subject_id },
resource: { type: item.resource_type, id: item.resource.id },
role
});
const pill = rowEl.querySelector('.ms-role-pill');
if (pill) {
pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`;
pill.textContent = _roleLabel(role);
}
grant.role = role;
});
if (isCurrent) mi.classList.add('ms-menu-item--current');
menu.appendChild(mi);
}
menu.appendChild(this._menuSeparator());
menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry));
menu.appendChild(this._menuSeparator());
menu.appendChild(
this._menuItem('fas fa-user-times', i18n.t('myshares.removeAccess', 'Remove access'), true, async () => {
menu.remove();
await grants.revokeGrant(grant.grant_id);
this._removeRowAndCleanLane(rowEl);
})
);
} else {
menu.appendChild(
this._menuItem('fas fa-copy', i18n.t('myshares.copyLink', 'Copy link'), false, async () => {
menu.remove();
const share = await fileSharing.getShareById(grant.subject_id);
await fileSharing.copyLinkToClipboard(share.url);
})
);
menu.appendChild(this._menuSeparator());
menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry));
menu.appendChild(this._menuPasswordRow(grant, rowEl));
menu.appendChild(this._menuSeparator());
menu.appendChild(
this._menuItem('fas fa-trash', i18n.t('myshares.deleteLink', 'Delete link'), true, async () => {
menu.remove();
await fileSharing.removeSharedLink(grant.subject_id);
this._removeRowAndCleanLane(rowEl);
})
);
}
document.body.appendChild(menu);
// Position below the trigger, right-aligned to it, clamped to viewport
const rect = btn.getBoundingClientRect();
const mw = menu.offsetWidth || 200;
const left = Math.min(rect.right - mw, window.innerWidth - mw - 8);
menu.style.position = 'absolute';
menu.style.top = `${rect.bottom + window.scrollY + 4}px`;
menu.style.left = `${Math.max(8, left)}px`;
const close = (/** @type {Event} */ e) => {
if (e.type === 'keydown' && /** @type {KeyboardEvent} */ (e).key !== 'Escape') return;
// Keep menu open when interacting with elements inside it (e.g. the date input)
if (e.type === 'click' && menu.contains(/** @type {Node} */ (e.target))) return;
menu.remove();
document.removeEventListener('click', close, true);
document.removeEventListener('keydown', close, true);
};
setTimeout(() => {
document.addEventListener('click', close, true);
document.addEventListener('keydown', close, true);
}, 0);
}
/**
* Non-closing expiry row embedded in the context menu.
* Uses the shared smd-expiry-chip; saves on blur/Enter.
* @param {OutgoingResourceGrant} grant
* @param {OutgoingResourceItem} item
* @param {HTMLElement} rowEl
* @param {string|null} initialExpiry YYYY-MM-DD or null
* @returns {HTMLElement}
*/
_menuExpiryRow(grant, item, rowEl, initialExpiry) {
const row = document.createElement('div');
row.className = 'ms-menu-expiry-row';
const label = document.createElement('span');
label.className = 'ms-menu-expiry-label';
label.textContent = i18n.t('share.expiry', 'Expiry');
row.appendChild(label);
const chip = buildExpiryChip(initialExpiry, async (dateStr) => {
const expiresIso = dateStr ? new Date(`${dateStr}T00:00:00Z`).toISOString() : null;
try {
await grants.updateRole({
subject: { type: grant.subject_type, id: grant.subject_id },
resource: { type: item.resource_type, id: item.resource.id },
role: grant.role,
expires_at: expiresIso
});
grant.expires_at = expiresIso;
// Replace the display chip in the grant row
const displayChip = rowEl.querySelector('.ms-expiry-chip');
if (displayChip) {
const newChip = this._buildExpiryChip(expiresIso);
displayChip.replaceWith(newChip);
}
} catch (err) {
console.error('mySharesList: setExpiry failed', err);
}
});
row.appendChild(chip);
return row;
}
/**
* Non-closing password row embedded in the link context menu.
* Saves immediately on confirm (blur / Enter).
* @param {OutgoingResourceGrant} grant
* @param {HTMLElement} rowEl
* @returns {HTMLElement}
*/
_menuPasswordRow(grant, rowEl) {
const row = document.createElement('div');
row.className = 'ms-menu-expiry-row';
const label = document.createElement('span');
label.className = 'ms-menu-expiry-label';
label.textContent = i18n.t('share.password', 'Password');
row.appendChild(label);
const chip = buildPasswordChip(grant.has_password, async (newPassword) => {
try {
await fileSharing.updateSharedLink(grant.subject_id, {
password: newPassword || null
});
grant.has_password = !!newPassword;
// Update the lock icon on the link chip in the row
const linkChipEl = rowEl.querySelector('.link-chip');
if (linkChipEl) {
linkChipEl.classList.toggle('link-chip--locked', grant.has_password);
const iconEl = linkChipEl.querySelector('.link-chip__icon');
if (iconEl) {
iconEl.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon';
}
}
} catch (err) {
console.error('mySharesList: setPassword failed', err);
}
});
row.appendChild(chip);
return row;
}
/**
* @param {string} iconClass
* @param {string} label
* @param {boolean} danger
* @param {() => void} onClick
* @returns {HTMLElement}
*/
_menuItem(iconClass, label, danger, onClick) {
const el = document.createElement('div');
el.className = danger ? 'context-menu-item context-menu-item-danger' : 'context-menu-item';
el.setAttribute('role', 'menuitem');
if (iconClass) {
el.innerHTML = `<i class="${iconClass}"></i> `;
}
el.appendChild(document.createTextNode(label));
el.addEventListener('click', /** @type {EventListener} */ (onClick));
return el;
}
/** @returns {HTMLElement} */
_menuSeparator() {
const el = document.createElement('div');
el.className = 'context-menu-separator';
return el;
}
/**
* Remove the row; if the lane body is now empty, remove the whole lane.
* @param {HTMLElement} rowEl
*/
_removeRowAndCleanLane(rowEl) {
const laneBody = rowEl.closest('.ms-lane__body');
rowEl.remove();
if (laneBody instanceof HTMLElement && laneBody.children.length === 0) {
const lane = laneBody.closest('.ms-lane');
if (lane instanceof HTMLElement) {
if (lane.dataset.swimKey === this._lastSwimKey) {
this._lastSwimKey = null;
this._lastSwimEl = null;
}
lane.remove();
}
}
}
}
export { MySharesList };
+61
View File
@@ -0,0 +1,61 @@
/**
* resourceIcon — shared resource icon builder.
*
* Returns a `.file-icon` element identical to the one in resourceList:
* • Folders: `.file-icon.folder-icon` with CSS tab (no visible <i>)
* • Files: `.file-icon.{specialClass}` + optional thumbnail <img> + <i>
*
* CSS lives in fileType.css (folder/file type colours) and resourceList.css
* (base size in grid/list context). Consumer views add their own size overrides.
*/
import { thumbnail } from '../features/thumbnail.js';
/** @import {FileItem, FolderItem} from '../core/types.js' */
/**
* @param {FileItem|FolderItem} item
* @param {'file'|'folder'} resourceType
* @returns {HTMLElement}
*/
function buildResourceIcon(item, resourceType) {
const el = document.createElement('div');
if (resourceType === 'folder') {
el.className = 'file-icon folder-icon';
const i = document.createElement('i');
i.className = 'fas fa-folder';
el.appendChild(i);
return el;
}
const file = /** @type {FileItem} */ (item);
const iconClass = file.icon_class || 'fas fa-file';
const iconSpecialClass = file.icon_special_class || '';
el.className = `file-icon${iconSpecialClass ? ` ${iconSpecialClass}` : ''}`;
const canThumbnail = thumbnail?.canHandle(file) ?? false;
if (canThumbnail) {
const img = document.createElement('img');
img.className = 'file-thumb';
img.src = `/api/files/${file.id}/thumbnail/icon`;
img.loading = 'lazy';
img.alt = '';
img.addEventListener('error', () => {
img.classList.add('hidden');
thumbnail?.queueGenerate(file, (dataUrl) => {
img.src = dataUrl;
img.classList.remove('hidden');
});
});
el.appendChild(img);
}
const i = document.createElement('i');
i.className = iconClass;
el.appendChild(i);
return el;
}
export { buildResourceIcon };
+28 -27
View File
@@ -20,8 +20,8 @@
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { thumbnail } from '../features/thumbnail.js';
import { systemUsers } from '../model/systemUsers.js';
import { buildResourceIcon } from './resourceIcon.js';
import { createUserVignette } from './userVignette.js';
/**
@@ -55,7 +55,9 @@ import { createUserVignette } from './userVignette.js';
* @property {(item: FileItem|FolderItem) => Promise<void>} [onFavoriteToggle]
* Called when the user clicks the favorite-star button.
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu]
* Called for the three-dots button click, right-click, and shared-badge click.
* Called for the three-dots button click and right-click.
* @property {(item: FileItem|FolderItem) => void} [onShareBadgeClick]
* Called when the user clicks the shared badge. Falls back to onContextMenu if absent.
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange]
* Called whenever the selection set changes.
*/
@@ -333,6 +335,19 @@ export class ResourceListComponent {
item.querySelector('.file-badge-shared')?.classList.toggle('hidden', !isShared);
}
/**
* Re-evaluate the shared badge for every currently rendered item using the
* `isShared` callback from config. Call this after the grants cache is refreshed.
*/
refreshSharedBadges() {
if (!this._cfg.isShared) return;
for (const item of this._items.values()) {
const isFile = 'mime_type' in item;
const type = /** @type {'file'|'folder'} */ (isFile ? 'file' : 'folder');
this.setSharedVisualState(item.id, type, this._cfg.isShared(item.id, type));
}
}
// ── Private helpers ─────────────────────────────────────────────────────
/**
@@ -444,9 +459,7 @@ export class ResourceListComponent {
el.innerHTML = `
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
<div class="name-cell">
<div class="file-icon folder-icon">
<i class="fas fa-folder"></i>
</div>
<div class="resource-icon-slot"></div>
<span>${escapeHtml(folder.name)}</span>
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
@@ -461,6 +474,7 @@ export class ResourceListComponent {
</div>
`;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
this._bindItemEvents(el, folder);
return el;
}
@@ -472,8 +486,6 @@ export class ResourceListComponent {
*/
_createFileItem(file) {
const cfg = this._cfg;
const iconClass = file.icon_class || 'fas fa-file';
const iconSpecialClass = file.icon_special_class || '';
const cat = file.category || '';
const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document');
const fileSize = file.size_formatted || formatFileSize(file.size);
@@ -481,7 +493,6 @@ export class ResourceListComponent {
const formattedDate = formatDateTime(new Date(dateVal));
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
const canThumbnail = thumbnail?.canHandle(file) ?? false;
const el = document.createElement('div');
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
@@ -496,10 +507,7 @@ export class ResourceListComponent {
el.innerHTML = `
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
<div class="name-cell">
<div class="file-icon ${iconSpecialClass}">
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
<i class="${iconClass}"></i>
</div>
<div class="resource-icon-slot"></div>
<span>${escapeHtml(file.name)}</span>
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
@@ -514,18 +522,7 @@ export class ResourceListComponent {
</div>
`;
const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
if (thumb) {
thumb.addEventListener('error', () => {
console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`);
thumb.classList.add('hidden');
thumbnail?.queueGenerate(file, (dataUrl) => {
thumb.src = dataUrl;
thumb.classList.remove('hidden');
});
});
}
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
this._bindItemEvents(el, file);
return el;
}
@@ -550,14 +547,18 @@ export class ResourceListComponent {
});
}
// Shared-badge click → treat as context-menu trigger (e.g. open share modal)
if (cfg.showShareBadge && cfg.onContextMenu) {
// Shared-badge click → open share modal (or fall back to context menu)
if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) {
const badge = el.querySelector('.file-badge-shared');
badge?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
if (cfg.onShareBadgeClick) {
cfg.onShareBadgeClick(item);
} else {
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
}
});
}
}
+190 -266
View File
@@ -20,13 +20,13 @@ import { fileSharing } from '../features/sharing/fileSharing.js';
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
import { buildExpiryChip } from '../utils/expiryChip.js';
import { buildPasswordChip } from '../utils/passwordChip.js';
import { Modal } from './modal.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Permissions that belong to each role (must mirror the Rust DTO). */
const ROLE_PERMISSIONS = {
viewer: ['read'],
@@ -101,24 +101,33 @@ const shareModal = {
/** @type {ShareRoleEnum} */
_stagedRole: 'viewer',
/** @type {string|null} — YYYY-MM-DD expiry for the next staged users batch */
_stagedExpiry: null,
/** @type {HTMLElement|null} — body node injected into Modal */
_bodyEl: null,
/** @type {(() => void)|null} — called after changes are successfully committed */
_onApplied: null,
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Open the share modal for a file or folder.
* @param {FileItem|FolderItem} item
* @param {'file'|'folder'} itemType
* @param {(() => void)=} onApplied - called after changes are successfully committed
*/
async open(item, itemType) {
async open(item, itemType, onApplied) {
this._item = item;
this._itemType = itemType;
this._onApplied = onApplied ?? null;
this._localMembers = [];
this._localLinks = [];
this._newLinks = [];
this._stagedUsers = [];
this._stagedRole = 'viewer';
this._stagedExpiry = null;
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
@@ -130,6 +139,7 @@ const shareModal = {
icon: 'fa-share-alt',
content: this._bodyEl,
confirmText: i18n.t('actions.apply', 'Apply'),
confirmDisabled: true,
onConfirm: () => {
this._applyAll();
} // intentionally discard Promise
@@ -164,6 +174,17 @@ const shareModal = {
Modal.close(false);
},
// ── Apply-button state ─────────────────────────────────────────────────────
/** @returns {boolean} */
_hasPendingChanges() {
return this._localMembers.some((m) => m._op !== 'keep') || this._localLinks.some((e) => e._op !== 'keep') || this._newLinks.length > 0;
},
_syncApplyBtn() {
if (Modal.confirmBtn) Modal.confirmBtn.disabled = !this._hasPendingChanges();
},
// ── Skeleton ───────────────────────────────────────────────────────────────
/**
@@ -248,9 +269,9 @@ const shareModal = {
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-role-select';
for (const [val, label] of [
['viewer', i18n.t('share.role.viewer', 'Viewer')],
['editor', i18n.t('share.role.editor', 'Editor')],
['admin', i18n.t('share.role.admin', 'Admin')]
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['admin', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
@@ -262,6 +283,11 @@ const shareModal = {
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
});
// ── Expiry chip ──────────────────────────────────────────────────────
const expiryChip = this._buildExpiryChip(null, (v) => {
this._stagedExpiry = v;
});
// ── Add button ───────────────────────────────────────────────────────
const addBtn = document.createElement('button');
addBtn.className = 'smd-add-btn btn btn-secondary';
@@ -316,6 +342,7 @@ const shareModal = {
row.appendChild(wrap);
row.appendChild(roleSelect);
row.appendChild(expiryChip);
row.appendChild(addBtn);
return row;
@@ -419,7 +446,7 @@ const shareModal = {
/** @type {Grant} */
const placeholderGrant = {
id: '', // not yet persisted
granted_at: 0,
granted_at: '',
granted_by: '',
subject: { type: 'user', id: contact.id },
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
@@ -429,7 +456,8 @@ const shareModal = {
grant: placeholderGrant,
_grants: [], // no server grants yet — nothing to revoke on remove
role: this._stagedRole,
_op: 'new'
_op: 'new',
expires_at: this._stagedExpiry
});
}
this._stagedUsers = [];
@@ -450,6 +478,7 @@ const shareModal = {
_refreshMemberGroups() {
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
if (container) this._renderMemberGroupsInto(container);
this._syncApplyBtn();
},
/**
@@ -471,9 +500,9 @@ const shareModal = {
header.className = 'smd-group-header';
const labelMap = {
admin: i18n.t('share.role.admin', 'Admin'),
editor: i18n.t('share.role.editor', 'Editor'),
viewer: i18n.t('share.role.viewer', 'Viewer')
admin: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
const badge = document.createElement('span');
badge.className = 'smd-group-badge';
@@ -504,9 +533,9 @@ const shareModal = {
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';
for (const [val, label] of [
['viewer', i18n.t('share.role.viewer', 'Viewer')],
['editor', i18n.t('share.role.editor', 'Editor')],
['admin', i18n.t('share.role.admin', 'Admin')]
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['admin', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
@@ -521,6 +550,19 @@ const shareModal = {
this._refreshMemberGroups();
});
// ── Expiry chip ──────────────────────────────────────────────────────
// Initialise entry.expires_at once from the representative grant so that
// role-only changes preserve the current expiry across row rebuilds.
if (!Object.hasOwn(entry, 'expires_at')) {
const raw = entry.grant.expires_at ?? null;
entry.expires_at = raw ? String(raw).slice(0, 10) : null;
}
const expiryChip = this._buildExpiryChip(entry.expires_at, (v) => {
entry.expires_at = v;
if (entry._op !== 'new') entry._op = 'change';
this._syncApplyBtn();
});
const removeBtn = document.createElement('button');
removeBtn.className = 'smd-row-action';
removeBtn.title = i18n.t('actions.remove', 'Remove');
@@ -532,10 +574,31 @@ const shareModal = {
row.appendChild(vignette);
row.appendChild(roleSelect);
row.appendChild(expiryChip);
row.appendChild(removeBtn);
return row;
},
// ── Expiry chip toggle ─────────────────────────────────────────────────────
/**
* @param {string|null} initialValue - YYYY-MM-DD or null
* @param {(v: string|null) => void} onChange
* @returns {HTMLElement}
*/
_buildExpiryChip(initialValue, onChange) {
return buildExpiryChip(initialValue, onChange);
},
/**
* @param {boolean} initialHasPassword
* @param {(v: string) => void} onChange '' = remove / clear, non-empty = set new password
* @returns {HTMLElement}
*/
_buildPasswordChip(initialHasPassword, onChange) {
return buildPasswordChip(initialHasPassword, onChange);
},
// ── Links section ──────────────────────────────────────────────────────────
/**
@@ -550,29 +613,72 @@ const shareModal = {
title.textContent = i18n.t('share.publicLinks', 'Public links');
section.appendChild(title);
section.appendChild(this._buildAddLinkRow());
const listEl = document.createElement('div');
listEl.id = 'smd-links-list';
this._renderLinksInto(listEl);
section.appendChild(listEl);
const newLinkBtn = document.createElement('button');
newLinkBtn.className = 'smd-new-link-btn';
newLinkBtn.innerHTML = `<i class="fas fa-plus"></i> ${i18n.t('share.createLink', 'Create new public link')}`;
newLinkBtn.id = 'smd-new-link-btn';
return section;
},
const newLinkForm = document.createElement('div');
newLinkForm.id = 'smd-new-link-form';
newLinkForm.className = 'smd-new-link-form hidden';
newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm));
/**
* Always-visible add-link row — mirrors the People search row layout.
* Rebuilds itself after each Add to reset chip state.
* @returns {HTMLElement}
*/
_buildAddLinkRow() {
const row = document.createElement('div');
row.className = 'smd-search-row';
row.id = 'smd-add-link-row';
newLinkBtn.addEventListener('click', () => {
newLinkBtn.classList.add('hidden');
newLinkForm.classList.remove('hidden');
// Name input — wrapped in smd-search-wrap so it inherits flex:1
const wrap = document.createElement('div');
wrap.className = 'smd-search-wrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'smd-search-input';
nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Link name (optional)');
wrap.appendChild(nameInput);
/** @type {string|null} */
let stagedPassword = null;
/** @type {string|null} */
let stagedExpiry = null;
const pwChip = this._buildPasswordChip(false, (v) => {
stagedPassword = v || null;
});
section.appendChild(newLinkBtn);
section.appendChild(newLinkForm);
return section;
const expChip = this._buildExpiryChip(null, (v) => {
stagedExpiry = v;
});
const addBtn = document.createElement('button');
addBtn.className = 'smd-add-btn btn btn-secondary';
addBtn.textContent = i18n.t('actions.add', 'Add');
addBtn.addEventListener('click', () => {
/** @type {DraftLink} */
const draft = {
name: nameInput.value.trim(),
password: stagedPassword,
expires_at: stagedExpiry
};
this._newLinks.push(draft);
this._refreshLinks();
// Reset row (also resets chips via closure state)
const fresh = this._buildAddLinkRow();
row.replaceWith(fresh);
});
row.appendChild(wrap);
row.appendChild(pwChip);
row.appendChild(expChip);
row.appendChild(addBtn);
return row;
},
/**
@@ -595,6 +701,7 @@ const shareModal = {
_refreshLinks() {
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
if (container) this._renderLinksInto(container);
this._syncApplyBtn();
},
/**
@@ -603,87 +710,65 @@ const shareModal = {
*/
_buildLinkRow(entry) {
const share = entry.share;
const draft = entry._op === 'edit' ? entry._draft : null;
// Display values: prefer draft overrides when in edit-pending state
const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link');
const displayPw = draft ? draft.password !== null : share.has_password;
const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null;
const ensureDraft = () => {
if (!entry._draft) {
entry._draft = {
name: share.item_name || '',
password: null,
expires_at: share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null
};
entry._op = 'edit';
this._syncApplyBtn();
}
return entry._draft;
};
// Derive current display values from draft if present, otherwise from share
const currentHasPassword = entry._draft
? entry._draft.password === ''
? false
: entry._draft.password
? true
: share.has_password
: share.has_password;
const currentExpiry = entry._draft ? entry._draft.expires_at : share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null;
const row = document.createElement('div');
row.className = 'smd-link-row';
const icon = document.createElement('div');
icon.className = 'smd-link-icon';
icon.innerHTML = '<i class="fas fa-link"></i>';
const info = document.createElement('div');
info.className = 'smd-link-info';
const name = document.createElement('div');
name.className = 'smd-link-name';
name.textContent = displayName;
name.textContent = entry._draft?.name || share.item_name || i18n.t('share.sharedLink', 'Shared link');
const tags = document.createElement('div');
tags.className = 'smd-link-tags';
if (displayPw) {
const t = document.createElement('span');
t.className = 'smd-link-tag';
t.innerHTML = `<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');
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.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
// Edit
const editBtn = document.createElement('button');
editBtn.className = 'smd-row-action';
editBtn.title = i18n.t('actions.edit', 'Edit');
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
editBtn.addEventListener('click', () => {
const panel = row.nextElementSibling;
if (panel?.classList.contains('smd-edit-panel')) {
panel.classList.toggle('hidden');
} else {
const editPanel = this._buildEditPanel(entry, row);
row.after(editPanel);
}
const pwChip = this._buildPasswordChip(currentHasPassword, (v) => {
ensureDraft().password = v;
});
const expChip = this._buildExpiryChip(currentExpiry, (v) => {
ensureDraft().expires_at = v;
});
// Delete
const delBtn = document.createElement('button');
delBtn.className = 'smd-row-action';
delBtn.title = i18n.t('actions.delete', 'Delete');
delBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
delBtn.innerHTML = '<i class="fas fa-times"></i>';
delBtn.addEventListener('click', () => {
entry._op = 'remove';
this._refreshLinks();
});
actions.appendChild(copyBtn);
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(icon);
row.appendChild(info);
row.appendChild(actions);
row.appendChild(name);
row.appendChild(copyBtn);
row.appendChild(pwChip);
row.appendChild(expChip);
row.appendChild(delBtn);
return row;
},
@@ -695,42 +780,21 @@ const shareModal = {
const row = document.createElement('div');
row.className = 'smd-link-row';
const icon = document.createElement('div');
icon.className = 'smd-link-icon';
icon.innerHTML = '<i class="fas fa-link"></i>';
const info = document.createElement('div');
info.className = 'smd-link-info';
const name = document.createElement('div');
name.className = 'smd-link-name';
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
const tags = document.createElement('div');
tags.className = 'smd-link-tags';
if (draft.password) {
const t = document.createElement('span');
t.className = 'smd-link-tag';
t.innerHTML = `<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');
pending.className = 'smd-link-tag';
pending.textContent = i18n.t('share.pending', 'Pending');
tags.appendChild(pending);
info.appendChild(name);
if (tags.children.length) info.appendChild(tags);
const pwChip = this._buildPasswordChip(!!draft.password, (v) => {
draft.password = v || null;
});
const actions = document.createElement('div');
actions.className = 'smd-link-actions';
const expChip = this._buildExpiryChip(draft.expires_at, (v) => {
draft.expires_at = v;
});
const delBtn = document.createElement('button');
delBtn.className = 'smd-row-action';
@@ -741,158 +805,14 @@ const shareModal = {
this._refreshLinks();
});
actions.appendChild(delBtn);
row.appendChild(icon);
row.appendChild(info);
row.appendChild(actions);
row.appendChild(name);
row.appendChild(pending);
row.appendChild(pwChip);
row.appendChild(expChip);
row.appendChild(delBtn);
return row;
},
/**
* @param {LinkEntry} entry
* @param {HTMLElement} row
* @returns {HTMLElement}
*/
_buildEditPanel(entry, row) {
const panel = document.createElement('div');
panel.className = 'smd-edit-panel';
const pwLabel = document.createElement('label');
pwLabel.textContent = i18n.t('dialogs.password', 'Password');
const pwInput = document.createElement('input');
pwInput.type = 'password';
pwInput.className = 'smd-edit-input';
pwInput.placeholder = i18n.t('share.passwordPlaceholder', 'Leave empty to keep unchanged');
const expLabel = document.createElement('label');
expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
const expInput = document.createElement('input');
expInput.type = 'date';
expInput.className = 'smd-edit-input';
if (entry.share.expires_at) {
expInput.value = new Date(entry.share.expires_at * 1000).toISOString().slice(0, 10);
}
const actionsDiv = document.createElement('div');
actionsDiv.className = 'smd-edit-panel-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-secondary';
cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
cancelBtn.addEventListener('click', () => panel.remove());
const saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-primary';
saveBtn.textContent = i18n.t('actions.save', 'Save');
saveBtn.addEventListener('click', () => {
entry._op = 'edit';
entry._draft = {
name: entry.share.item_name || '',
password: pwInput.value || null,
expires_at: expInput.value || null
};
panel.remove();
this._refreshLinks();
});
actionsDiv.appendChild(cancelBtn);
actionsDiv.appendChild(saveBtn);
panel.appendChild(pwLabel);
panel.appendChild(pwInput);
panel.appendChild(expLabel);
panel.appendChild(expInput);
panel.appendChild(actionsDiv);
void row; // row is unused — panel is inserted via row.after() in caller
return panel;
},
/**
* @param {HTMLButtonElement} newLinkBtn
* @param {HTMLElement} formWrapper
* @returns {HTMLElement}
*/
_buildNewLinkForm(newLinkBtn, formWrapper) {
const inner = document.createElement('div');
const nameLabel = document.createElement('label');
nameLabel.textContent = i18n.t('share.linkName', 'Link name');
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'smd-edit-input';
nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Optional name');
const pwToggleLabel = document.createElement('label');
pwToggleLabel.className = 'smd-pw-toggle';
const pwCheckbox = document.createElement('input');
pwCheckbox.type = 'checkbox';
pwToggleLabel.appendChild(pwCheckbox);
pwToggleLabel.appendChild(document.createTextNode(` ${i18n.t('share.addPassword', 'Add password')}`));
const pwInput = document.createElement('input');
pwInput.type = 'password';
pwInput.className = 'smd-edit-input hidden';
pwInput.placeholder = i18n.t('dialogs.password', 'Password');
pwCheckbox.addEventListener('change', () => {
pwInput.classList.toggle('hidden', !pwCheckbox.checked);
});
const expLabel = document.createElement('label');
expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
const expInput = document.createElement('input');
expInput.type = 'date';
expInput.className = 'smd-edit-input';
const actionsDiv = document.createElement('div');
actionsDiv.className = 'smd-new-link-form-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-secondary';
cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
cancelBtn.addEventListener('click', () => {
formWrapper.classList.add('hidden');
newLinkBtn.classList.remove('hidden');
});
const addBtn = document.createElement('button');
addBtn.className = 'btn btn-primary';
addBtn.textContent = i18n.t('share.addLink', 'Add link');
addBtn.addEventListener('click', () => {
/** @type {DraftLink} */
const draft = {
name: nameInput.value.trim(),
password: pwCheckbox.checked ? pwInput.value || null : null,
expires_at: expInput.value || null
};
this._newLinks.push(draft);
this._refreshLinks();
// Reset form
nameInput.value = '';
pwCheckbox.checked = false;
pwInput.value = '';
pwInput.classList.add('hidden');
expInput.value = '';
formWrapper.classList.add('hidden');
newLinkBtn.classList.remove('hidden');
});
actionsDiv.appendChild(cancelBtn);
actionsDiv.appendChild(addBtn);
inner.appendChild(nameLabel);
inner.appendChild(nameInput);
inner.appendChild(pwToggleLabel);
inner.appendChild(pwInput);
inner.appendChild(expLabel);
inner.appendChild(expInput);
inner.appendChild(actionsDiv);
return inner;
},
// ── Apply ──────────────────────────────────────────────────────────────────
/**
@@ -911,6 +831,8 @@ const shareModal = {
try {
// ── Grants ─────────────────────────────────────────────────────────
for (const m of this._localMembers) {
// Convert YYYY-MM-DD from date input to ISO-8601 datetime (midnight UTC).
const expiresIso = m.expires_at ? new Date(`${m.expires_at}T00:00:00Z`).toISOString() : null;
if (m._op === 'remove') {
// Revoke every individual grant for this subject (one per permission).
for (const g of m._grants) {
@@ -920,13 +842,15 @@ const shareModal = {
await grants.updateRole({
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
resource: { type: itemType, id: item.id },
role: m.role
role: m.role,
expires_at: expiresIso
});
} else if (m._op === 'new') {
await grants.createGrant({
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
resource: { type: itemType, id: item.id },
role: m.role
role: m.role,
expires_at: expiresIso
});
}
}
@@ -939,8 +863,7 @@ const shareModal = {
const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null;
await fileSharing.updateSharedLink(e.share.id, {
password: e._draft.password,
expires_at: expiresTs,
permissions: null
expires_at: expiresTs
});
}
}
@@ -970,6 +893,7 @@ const shareModal = {
ui.setSharedVisualState(item.id, itemType, hasAnyShare);
Modal.close(true);
this._onApplied?.();
} catch (err) {
console.error('shareModal._applyAll error:', err);
if (Modal.confirmBtn) Modal.confirmBtn.disabled = false;
+42 -1
View File
@@ -138,6 +138,36 @@ function normalizeDateBucket(value) {
return String(date.getFullYear());
}
/**
* Normalize a future expiry value into a human-readable bucket label.
* Buckets (soonest-first): Expired | Tomorrow | In less than 7 days | In less than 30 days | <YYYY> | No expiration
*
* Accepts the same input types as `normalizeDateBucket`.
*
* @param {string | number | Date | null | undefined} value
* @returns {string}
*/
function normalizeExpiryBucket(value) {
if (value === null || value === undefined) {
return i18n.t('expiryBucket.noExpiry', 'No expiration');
}
/** @type {Date} */
let date;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = new Date(value < 1e12 ? value * 1000 : value);
} else {
date = new Date(value);
}
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired');
if (daysUntil <= 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow');
if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days');
if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days');
return String(date.getFullYear());
}
/**
* Maps a file size in bytes to a coarse, human-readable bucket label.
*
@@ -167,4 +197,15 @@ function sizeBucket(bytes) {
return i18n.t('sizeBucket.huge', '> 5 GB');
}
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket };
export {
escapeHtml,
formatDateShort,
formatDateTime,
formatFileSize,
formatQuotaSize,
isEmailValid,
isTextViewable,
normalizeDateBucket,
normalizeExpiryBucket,
sizeBucket
};
+4
View File
@@ -239,6 +239,10 @@ const OxiIcons = {
576,
'M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z'
],
infinity: [
640,
'M0 256c0-88.4 71.6-160 160-160 50.4 0 97.8 23.7 128 64l32 42.7 32-42.7c30.2-40.3 77.6-64 128-64 88.4 0 160 71.6 160 160S568.4 416 480 416c-50.4 0-97.8-23.7-128-64l-32-42.7-32 42.7c-30.2 40.3-77.6 64-128 64-88.4 0-160-71.6-160-160zm280 0l-43.2-57.6c-18.1-24.2-46.6-38.4-76.8-38.4-53 0-96 43-96 96s43 96 96 96c30.2 0 58.7-14.2 76.8-38.4L280 256zm80 0l43.2 57.6c18.1 24.2 46.6 38.4 76.8 38.4 53 0 96-43 96-96s-43-96-96-96c-30.2 0-58.7 14.2-76.8 38.4L360 256z'
],
'info-circle': [
512,
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z'
+34 -13
View File
@@ -46,13 +46,6 @@
* @property {number} sort_date
*/
/**
* @typedef {Object} SharePermissions
* @property {boolean} read
* @property {boolean} reshare
* @property {boolean} write
*/
/**
* @typedef {Object} ShareItem
* @property {number} access_count
@@ -64,7 +57,6 @@
* @property {string} item_id
* @property {string} item_name
* @property {ItemTypeEnum} item_type
* @property {SharePermissions} permissions
* @property {string | null} token
* @property {string} url
*/
@@ -76,14 +68,12 @@
* @property {ItemTypeEnum} item_type
* @property {string|null} password
* @property {number|null} expires_at - timestamp
* @property {SharePermissions|null} permissions
*/
/**
* @typedef {Object} UpdateShare
* @property {string|null} password
* @property {number|null} expires_at - timestamp
* @property {SharePermissions|null} permissions
* @property {string|null} [password]
* @property {number|null} [expires_at]
*/
/**
@@ -284,11 +274,12 @@
/**
* @typedef {Object} Grant
* @property {string} id
* @property {number} granted_at
* @property {string} granted_at - ISO-8601 datetime string.
* @property {string} granted_by
* @property {Subject} subject
* @property {PermissionTypeEnum} permission
* @property {Resource} resource
* @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry.
*/
/**
@@ -333,6 +324,35 @@
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
*/
/**
* One (subject, permissions) entry within an outgoing resource item.
* @typedef {Object} OutgoingResourceGrant
* @property {string} grant_id
* @property {'user'|'token'} subject_type
* @property {string} subject_id
* @property {string} subject_display - Username (users) or share name (tokens).
* @property {'viewer'|'editor'|'admin'} role
* @property {string} granted_at - ISO-8601
* @property {string|null} [expires_at] - ISO-8601 or absent.
* @property {boolean} has_password - True when a token subject has a password set.
*/
/**
* One item returned by `GET /api/grants/outgoing/resources`.
* @typedef {Object} OutgoingResourceItem
* @property {ResourceTypeEnum} resource_type
* @property {string} first_shared_at - ISO-8601 earliest grant date.
* @property {FileItem|FolderItem} resource - Full resource details.
* @property {OutgoingResourceGrant[]} grants - One entry per (subject, permissions).
*/
/**
* Response for `GET /api/grants/outgoing/resources`.
* @typedef {Object} OutgoingResourcesResponse
* @property {OutgoingResourceItem[]} items
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
*/
/**
* One item returned by `GET /api/favorites/resources`.
* `resource_type` discriminates the shape of `resource`.
@@ -405,6 +425,7 @@
* @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
*/
/**
+13 -18
View File
@@ -489,24 +489,20 @@ const contextMenus = {
return;
}
// Use the contents endpoint to get children
const url = `/api/folders/${effectiveParentId}/contents`;
console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId);
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
console.error('Failed to load folders:', response.status);
return;
}
const data = await response.json();
console.log('[Move Dialog] API response:', data);
// The contents endpoint returns an array of child folders
// The fallback /api/folders returns root folders (home folder itself)
/** @type {FolderItem[]} */
const folders = Array.isArray(data) ? data : data.folders || [];
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
const folders = [];
let cursor = /** @type {string|null} */ (null);
do {
const qs = cursor ? `resource_types=folder&cursor=${encodeURIComponent(cursor)}` : 'resource_types=folder';
const response = await fetch(`/api/folders/${effectiveParentId}/resources?${qs}`, { credentials: 'same-origin' });
if (!response.ok) {
console.error('Failed to load folders:', response.status);
return;
}
const data = await response.json();
folders.push(...(data.items || []));
cursor = data.next_cursor ?? null;
} while (cursor);
const folderSelectContainer = document.getElementById('folder-select-container');
const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb');
@@ -719,7 +715,6 @@ const contextMenus = {
app.moveDialogBreadcrumb = [];
app.moveDialogCurrentFolderId = app.userHomeFolderId || null;
// Use loadMoveDialogFolders which uses /api/folders/{id}/contents
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
},
+16 -8
View File
@@ -35,12 +35,7 @@ const fileSharing = {
item_name: options.item_name || null,
item_type: itemType,
password: options.password || null,
expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null,
permissions: options.permissions || {
read: true,
write: false,
reshare: false
}
expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null
};
const res = await fetch('/api/shares', {
@@ -113,12 +108,11 @@ const fileSharing = {
/**
* Update a shared link
* @param {string} shareId
* @param {UpdateShare} updateData - { permissions, password, expires_at }
* @param {UpdateShare} updateData - { password, expires_at }
* @returns {Promise<Object>} Updated ShareDto
*/
async updateSharedLink(shareId, updateData) {
const body = {};
if (updateData.permissions) body.permissions = updateData.permissions;
if (updateData.password !== undefined) body.password = updateData.password;
if (updateData.expires_at !== undefined) body.expires_at = updateData.expires_at;
@@ -154,6 +148,20 @@ const fileSharing = {
}
},
/**
* Fetch a single share by its UUID and return the full ShareItem.
* Used to resolve a token's URL on demand (lazy fetch on copy-link click).
* @param {string} shareId
* @returns {Promise<import('../../core/types.js').ShareItem>}
*/
async getShareById(shareId) {
const res = await fetch(`/api/shares/${shareId}`, {
headers: this._headers(false)
});
if (!res.ok) throw new Error(`getShareById ${shareId}: HTTP ${res.status}`);
return res.json();
},
/**
* Copy a shared link to clipboard
* @param {string} url
+27 -1
View File
@@ -1,5 +1,5 @@
/**
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse, OutgoingResourcesResponse} from '../core/types.js'
*/
import { getCsrfHeaders } from '../core/csrf.js';
@@ -110,6 +110,32 @@ const grants = {
return response.json();
},
/**
* Fetch a cursor-paginated list of resources the current user has shared
* with others, with full file / folder metadata resolved server-side.
*
* @param {object} [opts]
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
* @param {string} [opts.cursor] - Opaque cursor from a previous call.
* @param {string} [opts.orderBy] - Sort: 'first_shared_at' | 'name' | 'type' | 'subject'.
* @param {boolean} [opts.reverse] - Reverse sort order.
* @returns {Promise<OutgoingResourcesResponse>}
*/
async fetchMySharesPage({ limit = 50, cursor, orderBy, reverse = false } = {}) {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set('cursor', cursor);
if (orderBy) params.set('sort_by', orderBy);
if (reverse) params.set('reverse', 'true');
const response = await fetch(`/api/grants/outgoing/resources?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch my shares: HTTP ${response.status}`);
}
return response.json();
},
/**
* Fetch all grants on a specific resource (for the "Manage sharing" panel).
* Refreshes the outgoingGrants cache for this resource.
+93
View File
@@ -0,0 +1,93 @@
/**
* buildExpiryChip — shared compact expiry editor chip.
*
* Chip states:
* • "∞ No expiry" — dashed border, faint text (value is null)
* • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set)
*
* Clicking the chip toggles to an inline <input type="date">.
* CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-date-input)
* live in shareModal.css.
*/
import { i18n } from '../core/i18n.js';
/**
* Format a YYYY-MM-DD string for display ("Dec 31, 2026").
* @param {string} dateStr
* @returns {string}
*/
export function formatExpiryDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
/**
* Build an interactive expiry chip.
* @param {string|null} initialValue YYYY-MM-DD or null
* @param {(v: string|null) => void} onChange called whenever the value changes
* @returns {HTMLElement}
*/
export function buildExpiryChip(initialValue, onChange) {
let current = initialValue;
const wrap = document.createElement('div');
wrap.className = 'smd-expiry-chip-wrap';
const chip = document.createElement('button');
chip.type = 'button';
const dateInput = document.createElement('input');
dateInput.type = 'date';
dateInput.className = 'smd-expiry-date-input hidden';
const updateChip = () => {
if (current) {
chip.className = 'smd-expiry-chip smd-expiry-chip--set';
chip.innerHTML =
`<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;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* buildPasswordChip — shared inline password editor chip.
*
* States:
* • "🔓 No password" — unset (default)
* • "🔒 Password ×" — set; × clears it
*
* Clicking the chip shows a hidden <input type="password">.
* Blur / Enter confirms; Escape cancels.
* CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-chip--set,
* .smd-expiry-date-input) live in shareModal.css.
*/
import { i18n } from '../core/i18n.js';
/**
* @param {boolean} initialHasPassword
* @param {(v: string) => void} onChange '' = remove, non-empty = set new password
* @returns {HTMLElement}
*/
export function buildPasswordChip(initialHasPassword, onChange) {
let hasPassword = initialHasPassword;
const wrap = document.createElement('div');
wrap.className = 'smd-expiry-chip-wrap';
const chip = document.createElement('button');
chip.type = 'button';
const pwInput = document.createElement('input');
pwInput.type = 'password';
pwInput.className = 'smd-expiry-date-input hidden';
pwInput.placeholder = i18n.t('dialogs.password', 'Password');
pwInput.autocomplete = 'new-password';
const updateChip = () => {
if (hasPassword) {
chip.className = 'smd-expiry-chip smd-expiry-chip--set';
chip.innerHTML =
`<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;
}
+239
View File
@@ -0,0 +1,239 @@
/**
* OxiCloud – "My Shares" view.
*
* Renders resources the current user has shared with others, using the
* cursor-paginated `GET /api/grants/outgoing/resources` endpoint.
*
* Group-by modes exposed to the navigation toolbar:
* 'items' — one card per resource (sort_by=type) [default / None]
* 'sharedWith' — swimlanes per subject (sort_by=subject)
*
* The "None" option (key='') from the toolbar maps to the default 'items' mode.
*/
import { ui } from '../../app/ui.js';
import { MySharesList } from '../../components/mySharesList.js';
import { shareModal } from '../../components/shareModal.js';
import { i18n } from '../../core/i18n.js';
import * as viewPrefs from '../../core/viewPrefs.js';
import * as itemTooltip from '../../features/itemTooltip.js';
import { grants } from '../../model/grants.js';
/** @import {FileItem, FolderItem} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string }} GroupByDef
* @typedef {'items'|'sharedWith'} ViewMode
*/
/**
* @type {{ [key: string]: { orderBy: string, viewMode: ViewMode } }}
*/
const MODE_MAP = {
'': { orderBy: 'type', viewMode: 'items' },
items: { orderBy: 'type', viewMode: 'items' },
sharedWith: { orderBy: 'subject', viewMode: 'sharedWith' }
};
/** @type {GroupByDef[]} */
const GROUP_BY_DEFS = [
{
key: '',
get label() {
return i18n.t('groupby.byFiles', 'By files');
},
orderBy: 'type'
},
{
key: 'sharedWith',
get label() {
return i18n.t('groupby.sharedWith', 'Shared with');
},
orderBy: 'subject'
}
];
/** ID of the "Load more" wrapper injected below `.files-container`. */
const LOAD_MORE_ID = 'ms-load-more-wrapper';
const mySharesView = {
// ── State ─────────────────────────────────────────────────────────────────
/** @type {string|null} */
_nextCursor: null,
_loading: false,
/** @type {MySharesList|null} */
_component: null,
/** @type {string} */
_groupBy: '',
/** @type {boolean} */
_reversed: false,
// ── Public API ────────────────────────────────────────────────────────────
/** @returns {GroupByDef[]} */
get groupByDefs() {
return GROUP_BY_DEFS;
},
/**
* Change the active group-by dimension and reload from page 1.
* Called by navigation.js when the user picks a pill.
* Empty string '' maps to the default items mode.
* @param {string} key
*/
setGroupBy(key) {
if (this._groupBy === key) return;
this._groupBy = key;
viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view);
this._nextCursor = null;
this._component?.clear();
this._loadPage();
},
/**
* Flip sort direction and reload from page 1.
* @param {boolean} reversed
*/
setDirection(reversed) {
if (this._reversed === reversed) return;
this._reversed = reversed;
viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view);
this._nextCursor = null;
this._component?.clear();
this._loadPage();
},
async init() {
this._nextCursor = null;
this._loading = false;
const saved = viewPrefs.load('shared');
this._groupBy = saved.groupBy || '';
this._reversed = saved.reversed;
this._ensureLoadMoreButton();
ui.resetFilesList();
ui.updateBreadcrumb();
const filesList = document.getElementById('files-list');
if (filesList) {
if (!this._component) {
this._component = new MySharesList(filesList, {
onResourceOpen: (resource, resourceType) => {
if (resourceType === 'folder') {
ui.openItem(resource);
} else {
// File: navigate to the parent folder in Files section.
const file = /** @type {FileItem} */ (resource);
const parts = (file.path || '').split('/').filter(Boolean);
const parentName = parts.length >= 2 ? parts[parts.length - 2] : '';
ui.openItem(/** @type {FolderItem} */ ({ id: file.folder_id, name: parentName }));
}
},
onShareEdit: (resource, resourceType) => {
shareModal.open(resource, /** @type {'file'|'folder'} */ (resourceType), () => {
this._nextCursor = null;
this._component?.clear();
this._loadPage();
});
}
});
}
}
await this._loadPage();
},
hide() {
const w = document.getElementById(LOAD_MORE_ID);
if (w) w.classList.add('hidden');
const filesList = document.getElementById('files-list');
if (filesList) itemTooltip.destroy(filesList);
},
// ── Internal helpers ──────────────────────────────────────────────────────
async _loadPage() {
if (this._loading) return;
this._loading = true;
const isFirstPage = this._nextCursor === null;
try {
const mode = MODE_MAP[this._groupBy] ?? MODE_MAP[''];
const data = await grants.fetchMySharesPage({
limit: 50,
cursor: this._nextCursor ?? undefined,
orderBy: mode.orderBy,
reverse: this._reversed
});
this._nextCursor = data.next_cursor ?? null;
if (data.items.length === 0 && isFirstPage) {
ui.showError(`
<i class="fas fa-share-alt empty-state-icon"></i>
<p>${i18n.t('myshares.emptyStateTitle', "You haven't shared anything yet")}</p>
<p>${i18n.t('myshares.emptyStateDesc', 'Items you share with others will appear here')}</p>
`);
this._setLoadMoreVisible(false);
return;
}
if (isFirstPage) {
this._component?.render(data.items, mode.viewMode);
} else {
this._component?.append(data.items, mode.viewMode);
}
const filesList = document.getElementById('files-list');
if (filesList) itemTooltip.init(filesList);
this._setLoadMoreVisible(!!this._nextCursor);
} catch (err) {
ui.showError(`
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>${i18n.t('errors_loadFailed', 'Failed to load items')}</p>
`);
console.error('mySharesView: load error', err);
} finally {
this._loading = false;
}
},
// ── "Load more" button ────────────────────────────────────────────────────
_ensureLoadMoreButton() {
if (document.getElementById(LOAD_MORE_ID)) return;
const filesContainer = document.querySelector('.files-container');
if (!filesContainer) return;
const wrapper = document.createElement('div');
wrapper.id = LOAD_MORE_ID;
wrapper.className = 'ms-load-more-wrapper hidden';
const btn = document.createElement('button');
btn.id = 'ms-load-more';
btn.className = 'button secondary';
btn.textContent = i18n.t('myshares.loadMore', 'Load more');
btn.addEventListener('click', () => this._loadPage());
wrapper.appendChild(btn);
filesContainer.after(wrapper);
},
/** @param {boolean} visible */
_setLoadMoreVisible(visible) {
const w = document.getElementById(LOAD_MORE_ID);
if (w) w.classList.toggle('hidden', !visible);
}
};
export { mySharesView };
-672
View File
@@ -1,672 +0,0 @@
/**
* OxiCloud - Shared View Component
* In-app shared files view. All operations go through the backend API.
*/
import { switchToFilesSection } from '../../app/navigation.js';
import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateShort, isEmailValid } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { fileSharing } from '../../features/sharing/fileSharing.js';
/** @import {ShareItem} from '../../core/types.js' */
const TTL = 5 * 60 * 1000; // 5 min
const sharedView = {
// State
/** @type {Array<ShareItem>} */
items: [],
_expires: 0,
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
_knownItemsId: new Map(),
/** @type {Array<ShareItem>} */
filteredItems: [],
/** @type {ShareItem | null} */
currentItem: null,
/** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = false) {
const h = { ...getCsrfHeaders() };
if (json) h['Content-Type'] = 'application/json';
return h;
},
async init() {
console.log('Initializing shared view component (API-backed)');
await this.loadItems();
},
show() {
this.displayUI();
this.attachEventListeners();
this.filterAndSortItems();
const c = document.getElementById('shared-container');
if (c) c.classList.remove('hidden');
},
hide() {
const c = document.getElementById('shared-container');
if (c) c.classList.add('hidden');
},
/**
* tells if item_id is shared
*
* @param {string} id the item_id
* @param {string} type folder|file
* @returns {boolean} true if this item is shared
*/
isShared(id, type) {
return this._knownItemsId.has(`${type}:${id}`);
},
// Load shared items from backend API,
// TODO cache entries to minimize calls
/**
* load shared items
*
* @param {boolean} force ignore cache
*/
async loadItems(force = false) {
if (this._expires > Date.now() && !force) return;
try {
const res = await fetch('/api/shares?page=1&per_page=1000', {
headers: this._headers()
});
if (res.ok) {
const data = await res.json();
this.items = data.items || [];
} else {
this.items = [];
}
this.filteredItems = { ...this.items };
this._knownItemsId.clear();
this.items.forEach((item) => {
this._knownItemsId.set(`${item.item_type}:${item.item_id}`, true);
});
this._expires = Date.now() + TTL;
} catch (err) {
console.error('Error loading shared items:', err);
this.items = [];
}
},
// Create and display the shared view UI
displayUI() {
const contentArea = document.querySelector('.content-area');
let container = document.getElementById('shared-container');
if (!container) {
container = document.createElement('div');
container.id = 'shared-container';
container.className = 'shared-view-container';
if (contentArea) contentArea.appendChild(container);
}
container.innerHTML = `
<div class="shared-header">
<div class="shared-filters">
<div class="shared-custom-select" id="filter-type-wrapper">
<button class="shared-select-toggle" id="filter-type-toggle">
<span class="shared-select-label" data-i18n="shared_filterAll">All</span>
<i class="fas fa-chevron-down shared-select-arrow"></i>
</button>
<div class="shared-select-dropdown" id="filter-type-dropdown">
<div class="shared-select-option active" data-value="all" data-i18n="shared_filterAll">All</div>
<div class="shared-select-option" data-value="file" data-i18n="shared_filterFiles">Files</div>
<div class="shared-select-option" data-value="folder" data-i18n="shared_filterFolders">Folders</div>
</div>
</div>
<div class="shared-custom-select" id="sort-by-wrapper">
<button class="shared-select-toggle" id="sort-by-toggle">
<span class="shared-select-label" data-i18n="shared_sortByDate">Sort by date</span>
<i class="fas fa-chevron-down shared-select-arrow"></i>
</button>
<div class="shared-select-dropdown" id="sort-by-dropdown">
<div class="shared-select-option active" data-value="date" data-i18n="shared_sortByDate">Sort by date</div>
<div class="shared-select-option" data-value="name" data-i18n="shared_sortByName">Sort by name</div>
<div class="shared-select-option" data-value="expiration" data-i18n="shared_sortByExpiration">Sort by expiration</div>
</div>
</div>
</div>
</div>
<div id="empty-shared-state" class="empty-state hidden">
<i class="fas fa-share-alt empty-state-icon"></i>
<p data-i18n="shared_emptyStateTitle">No shared items</p>
<p data-i18n="shared_emptyStateDesc">Items you share will appear here</p>
<button id="go-to-files-btn" class="button primary" data-i18n="shared.goToFiles">Go to Files</button>
</div>
<div class="shared-list-container hidden">
<table class="shared-table">
<thead>
<tr>
<th data-i18n="shared_colName">Name</th>
<th data-i18n="shared_colType">Type</th>
<th data-i18n="shared_colDateShared">Date</th>
<th data-i18n="shared_colExpiration">Expiration</th>
<th data-i18n="shared_colPermissions">Permissions</th>
<th data-i18n="shared_colPassword">Password</th>
<th data-i18n="shared_colActions">Actions</th>
</tr>
</thead>
<tbody id="shared-items-list"></tbody>
</table>
</div>
<!-- Share Edit Dialog (sharedView-specific) -->
<div id="shared-view-edit-dialog" class="shared-dialog hidden">
<div class="shared-dialog-content">
<div class="shared-dialog-header">
<span id="sv-dialog-icon">📄</span>
<span id="sv-dialog-name">Item</span>
<button class="close-dialog-btn">&times;</button>
</div>
<div class="share-link-section">
<label data-i18n="share.linkLabel">Share Link:</label>
<div class="share-link-input">
<input type="text" id="sv-share-link-url" readonly>
<button id="sv-copy-link-btn" class="button" data-i18n="share.copyLink">Copy</button>
</div>
</div>
<div class="share-permissions-section">
<h4 data-i18n="share.permissions">Permissions</h4>
<label><input type="checkbox" id="sv-permission-read" checked> <span data-i18n="share.permissionRead">Read</span></label>
<label><input type="checkbox" id="sv-permission-write"> <span data-i18n="share.permissionWrite">Write</span></label>
<label><input type="checkbox" id="sv-permission-reshare"> <span data-i18n="share.permissionReshare">Reshare</span></label>
</div>
<div class="share-password-section">
<label><input type="checkbox" id="sv-enable-password"> <span data-i18n="share.password">Password protection</span></label>
<div class="password-input-group">
<input type="text" id="sv-share-password" disabled placeholder="Enter password">
<button id="sv-generate-password" class="button small" data-i18n="share.generatePassword">Generate</button>
</div>
</div>
<div class="share-expiration-section">
<label><input type="checkbox" id="sv-enable-expiration"> <span data-i18n="share.expiration">Set expiration</span></label>
<input type="date" id="sv-share-expiration" disabled>
</div>
<div class="share-actions">
<button id="sv-update-share-btn" class="button primary" data-i18n="share.update">Update</button>
<button id="sv-remove-share-btn" class="button danger" data-i18n="share.remove">Remove Share</button>
</div>
</div>
</div>
<!-- Notification Dialog (sharedView-specific) -->
<div id="sv-notification-dialog" class="shared-dialog hidden">
<div class="shared-dialog-content">
<div class="shared-dialog-header">
<span id="sv-notify-dialog-icon">📧</span>
<span id="sv-notify-dialog-name">Item</span>
<button class="close-dialog-btn">&times;</button>
</div>
<div class="notification-form">
<div class="form-group">
<label data-i18n="share.notifyEmailLabel">Email:</label>
<input type="email" id="sv-notification-email" placeholder="recipient@example.com">
</div>
<div class="form-group">
<label data-i18n="share.notifyMessageLabel">Message (optional):</label>
<textarea id="sv-notification-message" rows="3"></textarea>
</div>
</div>
<div class="notification-actions">
<button id="sv-send-notification-btn" class="button primary" data-i18n="share.notifySend">Send Notification</button>
</div>
</div>
</div>
`;
i18n.translateElement(container);
},
// Attach event listeners
attachEventListeners() {
// Custom dropdown logic for filter-type
this._initCustomSelect('filter-type-wrapper', 'filter-type-toggle', 'filter-type-dropdown');
// Custom dropdown logic for sort-by
this._initCustomSelect('sort-by-wrapper', 'sort-by-toggle', 'sort-by-dropdown');
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
document.querySelectorAll('.shared-custom-select.open').forEach((sel) => {
if (!(e.target instanceof Node)) return;
if (!sel.contains(e.target)) sel.classList.remove('open');
});
});
// Share dialog (sharedView-specific IDs)
const shareDialog = document.getElementById('shared-view-edit-dialog');
if (shareDialog) {
const closeBtn = shareDialog.querySelector('.close-dialog-btn');
if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog());
const copyLinkBtn = document.getElementById('sv-copy-link-btn');
if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink());
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
if (enablePw)
enablePw.addEventListener('change', () => {
if (pwField) {
pwField.disabled = !enablePw.checked;
if (enablePw.checked) pwField.focus();
}
});
const genPwBtn = document.getElementById('sv-generate-password');
if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword());
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
if (enableExp)
enableExp.addEventListener('change', () => {
if (expField) {
expField.disabled = !enableExp.checked;
if (enableExp.checked) expField.focus();
}
});
const updateBtn = document.getElementById('sv-update-share-btn');
if (updateBtn) updateBtn.addEventListener('click', () => this.updateSharedItem());
const removeBtn = document.getElementById('sv-remove-share-btn');
if (removeBtn) removeBtn.addEventListener('click', () => this.removeSharedItem());
}
// Notification dialog (sharedView-specific IDs)
const notifDialog = document.getElementById('sv-notification-dialog');
if (notifDialog) {
const closeBtn = notifDialog.querySelector('.close-dialog-btn');
if (closeBtn) closeBtn.addEventListener('click', () => this.closeNotificationDialog());
const sendBtn = document.getElementById('sv-send-notification-btn');
if (sendBtn) sendBtn.addEventListener('click', () => this.sendNotification());
}
// "Go to Files" button in empty state
const goToFilesBtn = document.getElementById('go-to-files-btn');
if (goToFilesBtn) {
goToFilesBtn.addEventListener('click', () => {
if (switchToFilesSection) switchToFilesSection();
});
}
},
// Initialize a custom select dropdown
/**
*
* @param {string} wrapperId
* @param {string} toggleId
* @param {string} dropdownId
* @returns
*/
_initCustomSelect(wrapperId, toggleId, dropdownId) {
const wrapper = document.getElementById(wrapperId);
const toggle = document.getElementById(toggleId);
const dropdown = document.getElementById(dropdownId);
if (!wrapper || !toggle || !dropdown) return;
toggle.addEventListener('click', (e) => {
e.stopPropagation();
// Close other open selects
document.querySelectorAll('.shared-custom-select.open').forEach((sel) => {
if (sel !== wrapper) sel.classList.remove('open');
});
wrapper.classList.toggle('open');
});
dropdown.querySelectorAll('.shared-select-option').forEach((option) => {
option.addEventListener('click', (e) => {
e.stopPropagation();
// Update active state
dropdown.querySelectorAll('.shared-select-option').forEach((o) => {
o.classList.remove('active');
});
option.classList.add('active');
// Update label
const label = toggle.querySelector('.shared-select-label');
if (label) label.textContent = option.textContent;
// Close dropdown
wrapper.classList.remove('open');
// Trigger filter
this.filterAndSortItems();
});
});
},
// Filter and sort items
filterAndSortItems() {
const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-dropdown .shared-select-option.active'));
const sortByActive = /** @type {HTMLDivElement} */ (document.querySelector('#sort-by-dropdown .shared-select-option.active'));
const type = filterTypeActive ? filterTypeActive.dataset.value : 'all';
const sort = sortByActive ? sortByActive.dataset.value : 'date';
// Use the main top-bar search input
const searchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input'));
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
this.filteredItems = this.items.filter((item) => {
if (type !== 'all' && item.item_type !== type) return false;
const name = (item.item_name || item.item_id || '').toLowerCase();
return name.includes(searchTerm);
});
this.filteredItems.sort((a, b) => {
if (sort === 'name') {
return (a.item_name || a.item_id || '').localeCompare(b.item_name || b.item_id || '');
} else if (sort === 'date') {
return (b.created_at || 0) - (a.created_at || 0);
} else if (sort === 'expiration') {
if (!a.expires_at && !b.expires_at) return 0;
if (!a.expires_at) return 1;
if (!b.expires_at) return -1;
return a.expires_at - b.expires_at;
}
return 0;
});
this.displaySharedItems();
},
// Display items in the table
displaySharedItems() {
const sharedItemsList = document.getElementById('shared-items-list');
const emptyState = document.getElementById('empty-shared-state');
const listContainer = document.querySelector('.shared-list-container');
if (!sharedItemsList || !emptyState || !listContainer) return;
sharedItemsList.innerHTML = '';
if (this.filteredItems.length === 0) {
emptyState.classList.remove('hidden');
listContainer.classList.add('hidden');
return;
}
emptyState.classList.add('hidden');
listContainer.classList.remove('hidden');
this.filteredItems.forEach((item) => {
const row = document.createElement('tr');
const displayName = item.item_name || item.item_id || 'Unknown';
const nameCell = document.createElement('td');
nameCell.className = 'shared-item-name';
const iconSpan = document.createElement('span');
iconSpan.className = 'item-icon';
iconSpan.textContent = item.item_type === 'file' ? '📄' : '📁';
const nameSpan = document.createElement('span');
nameSpan.textContent = displayName;
nameCell.appendChild(iconSpan);
nameCell.appendChild(nameSpan);
const typeCell = document.createElement('td');
typeCell.textContent = item.item_type === 'file' ? i18n.t('shared_typeFile', 'File') : i18n.t('shared_typeFolder', 'Folder');
const dateCell = document.createElement('td');
dateCell.textContent = formatDateShort(item.created_at);
const expCell = document.createElement('td');
expCell.textContent = item.expires_at ? formatDateShort(item.expires_at) : i18n.t('shared_noExpiration', 'No expiration');
const permCell = document.createElement('td');
const perms = [];
if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read'));
if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write'));
if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare'));
permCell.textContent = perms.join(', ') || 'Read';
const pwCell = document.createElement('td');
pwCell.textContent = item.has_password ? i18n.t('shared_hasPassword', 'Yes') : i18n.t('shared_noPassword', 'No');
const actionsCell = document.createElement('td');
actionsCell.className = 'shared-item-actions';
const editBtn = document.createElement('button');
editBtn.className = 'action-btn edit-btn';
editBtn.innerHTML = '<span class="action-icon">✏️</span>';
editBtn.title = i18n.t('shared_editShare', 'Edit Share');
editBtn.addEventListener('click', () => this.openShareDialog(item));
const notifyBtn = document.createElement('button');
notifyBtn.className = 'action-btn notify-btn';
notifyBtn.innerHTML = '<span class="action-icon">📧</span>';
notifyBtn.title = i18n.t('shared_notifyShare', 'Notify Someone');
notifyBtn.addEventListener('click', () => this.openNotificationDialog(item));
const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn copy-btn';
copyBtn.innerHTML = '<span class="action-icon">📋</span>';
copyBtn.title = i18n.t('shared_copyLink', 'Copy Link');
copyBtn.addEventListener('click', () => {
navigator.clipboard
.writeText(item.url)
.then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
});
const rmBtn = document.createElement('button');
rmBtn.className = 'action-btn remove-btn';
rmBtn.innerHTML = '<span class="action-icon">🗑️</span>';
rmBtn.title = i18n.t('shared_removeShare', 'Remove Share');
rmBtn.addEventListener('click', () => {
this.currentItem = item;
this.removeSharedItem();
});
actionsCell.append(editBtn, notifyBtn, copyBtn, rmBtn);
row.append(nameCell, typeCell, dateCell, expCell, permCell, pwCell, actionsCell);
sharedItemsList.appendChild(row);
});
},
// Open share dialog
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openShareDialog(item) {
this.currentItem = item;
const shareDialog = document.getElementById('shared-view-edit-dialog');
const dn = item.item_name || item.item_id || 'Unknown';
const iconEl = document.getElementById('sv-dialog-icon');
const nameEl = document.getElementById('sv-dialog-name');
const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
if (!shareDialog) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
if (nameEl) nameEl.textContent = dn;
if (urlEl) urlEl.value = item.url || '';
if (permRead) permRead.checked = item.permissions?.read !== false;
if (permWrite) permWrite.checked = !!item.permissions?.write;
if (permReshare) permReshare.checked = !!item.permissions?.reshare;
if (enablePw) {
enablePw.checked = item.has_password;
if (pwField) {
pwField.disabled = !enablePw.checked;
pwField.value = '';
}
}
if (enableExp) {
enableExp.checked = !!item.expires_at;
if (expField) {
expField.disabled = !enableExp.checked;
expField.value = item.expires_at ? new Date(item.expires_at * 1000).toISOString().split('T')[0] : '';
}
}
shareDialog.classList.remove('hidden');
},
closeShareDialog() {
const d = document.getElementById('shared-view-edit-dialog');
if (d) d.classList.add('hidden');
this.currentItem = null;
},
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openNotificationDialog(item) {
this.currentItem = item;
const dn = item.item_name || item.item_id || 'Unknown';
const d = document.getElementById('sv-notification-dialog');
const iconEl = document.getElementById('sv-notify-dialog-icon');
const nameEl = document.getElementById('sv-notify-dialog-name');
const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
if (!d) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
if (nameEl) nameEl.textContent = dn;
if (emailEl) emailEl.value = '';
if (msgEl) msgEl.value = '';
d.classList.remove('hidden');
},
closeNotificationDialog() {
const d = document.getElementById('sv-notification-dialog');
if (d) d.classList.add('hidden');
this.currentItem = null;
},
copyShareLink() {
const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
if (!el) return;
navigator.clipboard
.writeText(el.value)
.then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
},
// Generate secure password with crypto API
generatePassword() {
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
if (!pwField || !enablePw) return;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
const array = new Uint32Array(16);
crypto.getRandomValues(array);
let password = '';
for (let i = 0; i < 16; i++) {
password += chars[array[i] % chars.length];
}
pwField.value = password;
enablePw.checked = true;
pwField.disabled = false;
},
// Update share via API
async updateSharedItem() {
if (!this.currentItem) return;
const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const body = {
permissions: {
read: permRead ? permRead.checked : true,
write: permWrite ? permWrite.checked : false,
reshare: permReshare ? permReshare.checked : false
},
password: enablePw?.checked && pwField?.value ? pwField.value : null,
expires_at: enableExp?.checked && expField?.value ? Math.floor(new Date(expField.value).getTime() / 1000) : null
};
try {
// FIXME: redundance with fileSharing
const res = await fetch(`/api/shares/${this.currentItem.id}`, {
method: 'PUT',
headers: this._headers(true),
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Server error ${res.status}`);
}
ui.showNotification(i18n.t('shared_itemUpdated', 'Share settings updated'), 'success');
} catch (err) {
console.error('Error updating share:', err);
ui.showNotification(/** @type {Error} */ (err).message || 'Error updating share', 'error');
}
// update UI
ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true);
this.closeShareDialog();
await this.loadItems(true);
this.filterAndSortItems();
},
// Remove share via API
async removeSharedItem() {
if (!this.currentItem) return;
try {
// FIXME: redundance with fileSharing
const res = await fetch(`/api/shares/${this.currentItem.id}`, {
method: 'DELETE',
headers: this._headers()
});
if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`);
ui.showNotification(i18n.t('shared_itemRemoved', 'Share removed'), 'success');
} catch (err) {
console.error('Error removing share:', err);
ui.showNotification('Error removing share', 'error');
}
this.closeShareDialog();
await this.loadItems(true);
this.filterAndSortItems();
// update UI
ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, this.isShared(this.currentItem.item_id, this.currentItem.item_type));
},
// Send notification (stub)
sendNotification() {
if (!this.currentItem) return;
const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
const email = emailEl ? emailEl.value.trim() : '';
const message = msgEl ? msgEl.value.trim() : '';
if (!email || !isEmailValid(email)) {
ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error');
return;
}
if (fileSharing?.sendShareNotification) {
fileSharing
.sendShareNotification(this.currentItem.url, email, message)
.then(() => {
this.closeNotificationDialog();
ui.showNotification(i18n.t('shared_notificationSent', 'Notification sent'), 'success');
})
.catch(() => ui.showNotification(i18n.t('shared_notificationFailed', 'Failed to send notification'), 'error'));
}
}
};
export { sharedView };
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "تاريخ التعديل",
"createdAt": "تاريخ الإنشاء",
"size": "الحجم",
"favoriteDate": "تاريخ المفضلة"
"favoriteDate": "تاريخ المفضلة",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "اليوم",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Änderungsdatum",
"createdAt": "Erstellungsdatum",
"size": "Größe",
"favoriteDate": "Datum der Markierung"
"favoriteDate": "Datum der Markierung",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Heute",
+2
View File
@@ -714,6 +714,8 @@
},
"groupby": {
"none": "None",
"byFiles": "By files",
"sharedWith": "Shared with",
"title": "Group by",
"type": "Type",
"type.folders": "Folders",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Fecha de modificación",
"createdAt": "Fecha de creación",
"size": "Tamaño",
"favoriteDate": "Fecha de favorito"
"favoriteDate": "Fecha de favorito",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Hoy",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "تاریخ تغییر",
"createdAt": "تاریخ ایجاد",
"size": "اندازه",
"favoriteDate": "تاریخ مورد علاقه"
"favoriteDate": "تاریخ مورد علاقه",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "امروز",
+3 -1
View File
@@ -723,7 +723,9 @@
"accessedAt": "Date d'accès",
"modifiedAt": "Date de modification",
"createdAt": "Date de création",
"size": "Taille"
"size": "Taille",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Aujourd'hui",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "संशोधन की तारीख",
"createdAt": "बनाने की तारीख",
"size": "आकार",
"favoriteDate": "पसंदीदा की तारीख"
"favoriteDate": "पसंदीदा की तारीख",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "आज",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Data di modifica",
"createdAt": "Data di creazione",
"size": "Dimensione",
"favoriteDate": "Data preferito"
"favoriteDate": "Data preferito",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Oggi",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "更新日",
"createdAt": "作成日",
"size": "サイズ",
"favoriteDate": "お気に入り登録日"
"favoriteDate": "お気に入り登録日",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "今日",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "수정 날짜",
"createdAt": "생성 날짜",
"size": "크기",
"favoriteDate": "즐겨찾기 날짜"
"favoriteDate": "즐겨찾기 날짜",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "오늘",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Wijzigingsdatum",
"createdAt": "Aanmaakdatum",
"size": "Grootte",
"favoriteDate": "Favoritendatum"
"favoriteDate": "Favoritendatum",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Vandaag",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Data modyfikacji",
"createdAt": "Data utworzenia",
"size": "Rozmiar",
"favoriteDate": "Data dodania do ulubionych"
"favoriteDate": "Data dodania do ulubionych",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Dzisiaj",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Data de modificação",
"createdAt": "Data de criação",
"size": "Tamanho",
"favoriteDate": "Data de favorito"
"favoriteDate": "Data de favorito",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Hoje",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "Дата изменения",
"createdAt": "Дата создания",
"size": "Размер",
"favoriteDate": "Дата добавления в избранное"
"favoriteDate": "Дата добавления в избранное",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "Сегодня",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "修改日期",
"createdAt": "建立日期",
"size": "大小",
"favoriteDate": "收藏日期"
"favoriteDate": "收藏日期",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "今天",
+3 -1
View File
@@ -723,7 +723,9 @@
"modifiedAt": "修改日期",
"createdAt": "创建日期",
"size": "大小",
"favoriteDate": "收藏日期"
"favoriteDate": "收藏日期",
"byFiles": "By files",
"sharedWith": "Shared with"
},
"dateBucket": {
"today": "今天",
+1 -1
View File
@@ -1,6 +1,6 @@
// OxiCloud Service Worker
// FIXME: generate cache name according build ?
const CACHE_NAME = 'oxicloud-cache-v21';
const CACHE_NAME = 'oxicloud-cache-v22';
// Only cache static assets — NOT HTML files.
// HTML files are served network-first so browsers always get the latest
+19 -19
View File
@@ -30,13 +30,13 @@ jsonpath "$.access_token" isString
# ─────────────────────────────────────────────────────────────
# Step 2 – No favorites yet
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/favorites
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
jsonpath "$" count == 0
jsonpath "$.items" isCollection
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
@@ -85,15 +85,15 @@ HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 5 – Favorites contains only hello-renamed.txt
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/favorites
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].item_id" == {{file_id}}
jsonpath "$[0].item_type" == "file"
jsonpath "$[0].item_name" == "hello-renamed.txt"
jsonpath "$.items" count == 1
jsonpath "$.items[0].resource.id" == {{file_id}}
jsonpath "$.items[0].resource_type" == "file"
jsonpath "$.items[0].resource.name" == "hello-renamed.txt"
# ─────────────────────────────────────────────────────────────
@@ -108,14 +108,14 @@ HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 7 – Favorites contains both items (order-independent)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/favorites
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 2
jsonpath "$[*].item_id" contains {{file_id}}
jsonpath "$[*].item_id" contains {{test1_id}}
jsonpath "$.items" count == 2
jsonpath "$.items[*].resource.id" contains {{file_id}}
jsonpath "$.items[*].resource.id" contains {{test1_id}}
# ─────────────────────────────────────────────────────────────
@@ -130,15 +130,15 @@ HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 9 – Favorites contains only test1 folder
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/favorites
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].item_id" == {{test1_id}}
jsonpath "$[0].item_type" == "folder"
jsonpath "$[0].item_name" == "test1"
jsonpath "$.items" count == 1
jsonpath "$.items[0].resource.id" == {{test1_id}}
jsonpath "$.items[0].resource_type" == "folder"
jsonpath "$.items[0].resource.name" == "test1"
# ─────────────────────────────────────────────────────────────
@@ -153,9 +153,9 @@ HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 11 – Favorites is empty again
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/favorites
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
jsonpath "$.items" count == 0
+2 -2
View File
@@ -391,7 +391,7 @@ Authorization: Bearer {{adam_token}}
HTTP 404
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{adam_token}}
HTTP 404
@@ -514,7 +514,7 @@ HTTP 200
jsonpath "$" count == 1
jsonpath "$[0].id" == "{{perm_child_id}}"
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{adam_token}}
HTTP 200
+7 -7
View File
@@ -71,14 +71,14 @@ HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 4 – Recent list contains hello-renamed.txt
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/recent
GET {{base_url}}/api/recent/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].item_type" == "file"
jsonpath "$[0].item_name" == "hello-renamed.txt"
jsonpath "$.items" count == 1
jsonpath "$.items[0].resource_type" == "file"
jsonpath "$.items[0].resource.name" == "hello-renamed.txt"
# ─────────────────────────────────────────────────────────────
@@ -93,10 +93,10 @@ HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 6 – Recent list is empty after clear
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/recent
GET {{base_url}}/api/recent/resources
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
jsonpath "$" count == 0
jsonpath "$.items" isCollection
jsonpath "$.items" count == 0