Merge pull request #402 from EdouardVanbelle/feat/my-shares
This commit is contained in:
@@ -27,18 +27,6 @@ const HTML_INCLUDE: &[&str] = &[
|
|||||||
"share.html",
|
"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
|
// 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");
|
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 ─────────────────────────────────────────
|
// ── 2. Resolve main.css @imports ─────────────────────────────────────────
|
||||||
let resolved_main = resolve_css_imports(&css_dir.join("main.css"), &css_dir);
|
let resolved_main = resolve_css_imports(&css_dir.join("main.css"), &css_dir);
|
||||||
let minified_main = css_minify_safe(&resolved_main);
|
let minified_main = css_minify_safe(&resolved_main);
|
||||||
fs::write(dist_dir.join("css/main.css"), &minified_main).expect("write main.css");
|
fs::write(dist_dir.join("css/main.css"), &minified_main).expect("write main.css");
|
||||||
|
|
||||||
// ── 3. Build CSS bundle for index.html ───────────────────────────────────
|
// ── 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;
|
let mut css_all = resolved_main;
|
||||||
for view in INDEX_VIEW_CSS {
|
for view in extract_css_links(&index_html) {
|
||||||
let p = css_dir.join(view);
|
let p = css_dir.join(&view);
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
css_all.push_str(&fs::read_to_string(&p).unwrap_or_default());
|
css_all.push_str(&fs::read_to_string(&p).unwrap_or_default());
|
||||||
css_all.push('\n');
|
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);
|
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 ───────────────────────────────
|
// ── 5. Bundle all ES modules into one IIFE ───────────────────────────────
|
||||||
// Walk the import graph starting from every <script type="module"> in index.html,
|
// 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.
|
// 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 module_scripts = extract_module_scripts(&index_html);
|
||||||
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
|
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
|
||||||
// Validate the raw bundle with OXC before minifying — catches re-declaration
|
// 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)
|
// 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.
|
/// Collect `<script type="module" src="…">` paths from HTML.
|
||||||
fn extract_module_scripts(html: &str) -> Vec<String> {
|
fn extract_module_scripts(html: &str) -> Vec<String> {
|
||||||
html.lines()
|
html.lines()
|
||||||
@@ -953,7 +973,6 @@ fn minify_tree_js(dir: &Path) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(src) = fs::read_to_string(&p) {
|
if let Ok(src) = fs::read_to_string(&p) {
|
||||||
println!("cargo:warning=minify-js: {}", p.display());
|
|
||||||
let _ = fs::write(&p, js_minify_safe(&src));
|
let _ = fs::write(&p, js_minify_safe(&src));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ build:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
# check that app is clean
|
||||||
|
node --check static-dist/js/app.*.js
|
||||||
|
|
||||||
run:
|
run:
|
||||||
cargo 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}$';
|
||||||
@@ -191,6 +191,9 @@ pub struct CreateGrantDto {
|
|||||||
pub permissions: Option<Vec<PermissionDto>>,
|
pub permissions: Option<Vec<PermissionDto>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub role: Option<Role>,
|
pub role: Option<Role>,
|
||||||
|
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
||||||
@@ -199,6 +202,9 @@ pub struct UpdateRoleDto {
|
|||||||
pub subject: SubjectDto,
|
pub subject: SubjectDto,
|
||||||
pub resource: ResourceDto,
|
pub resource: ResourceDto,
|
||||||
pub role: Role,
|
pub role: Role,
|
||||||
|
/// Optional expiry applied to every grant written or updated by this call.
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -213,6 +219,8 @@ pub struct GrantDto {
|
|||||||
pub permission: PermissionDto,
|
pub permission: PermissionDto,
|
||||||
pub granted_by: Uuid,
|
pub granted_by: Uuid,
|
||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Grant> for GrantDto {
|
impl From<Grant> for GrantDto {
|
||||||
@@ -224,6 +232,7 @@ impl From<Grant> for GrantDto {
|
|||||||
permission: g.permission.into(),
|
permission: g.permission.into(),
|
||||||
granted_by: g.granted_by,
|
granted_by: g.granted_by,
|
||||||
granted_at: g.granted_at,
|
granted_at: g.granted_at,
|
||||||
|
expires_at: g.expires_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,5 +312,54 @@ pub struct SharedWithMeItemDto {
|
|||||||
pub resource: ResourceContentDto,
|
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`.
|
/// Response for `GET /api/grants/incoming/resources`.
|
||||||
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
|
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,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::domain::entities::share::{Share, SharePermissions};
|
use crate::domain::entities::share::Share;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ShareDto {
|
pub struct ShareDto {
|
||||||
@@ -13,19 +13,11 @@ pub struct ShareDto {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub permissions: SharePermissionsDto,
|
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
pub access_count: u64,
|
pub access_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct SharePermissionsDto {
|
|
||||||
pub read: bool,
|
|
||||||
pub write: bool,
|
|
||||||
pub reshare: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CreateShareDto {
|
pub struct CreateShareDto {
|
||||||
pub item_id: String,
|
pub item_id: String,
|
||||||
@@ -33,17 +25,14 @@ pub struct CreateShareDto {
|
|||||||
pub item_type: String,
|
pub item_type: String,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub permissions: Option<SharePermissionsDto>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateShareDto {
|
pub struct UpdateShareDto {
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub permissions: Option<SharePermissionsDto>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension methods to convert between DTOs and domain entities
|
|
||||||
impl ShareDto {
|
impl ShareDto {
|
||||||
pub fn from_entity(share: &Share, base_url: &str) -> Self {
|
pub fn from_entity(share: &Share, base_url: &str) -> Self {
|
||||||
let url = format!("{}/s/{}", base_url, share.token());
|
let url = format!("{}/s/{}", base_url, share.token());
|
||||||
@@ -57,24 +46,9 @@ impl ShareDto {
|
|||||||
url,
|
url,
|
||||||
has_password: share.has_password(),
|
has_password: share.has_password(),
|
||||||
expires_at: share.expires_at(),
|
expires_at: share.expires_at(),
|
||||||
permissions: SharePermissionsDto::from_entity(share.permissions()),
|
|
||||||
created_at: share.created_at(),
|
created_at: share.created_at(),
|
||||||
created_by: share.created_by().to_string(),
|
created_by: share.created_by().to_string(),
|
||||||
access_count: share.access_count(),
|
access_count: share.access_count(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharePermissionsDto {
|
|
||||||
pub fn from_entity(permissions: &SharePermissions) -> Self {
|
|
||||||
Self {
|
|
||||||
read: permissions.read(),
|
|
||||||
write: permissions.write(),
|
|
||||||
reshare: permissions.reshare(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_entity(&self) -> SharePermissions {
|
|
||||||
SharePermissions::new(self.read, self.write, self.reshare)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::services::authorization::{
|
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 {
|
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").
|
/// `GET /api/grants/outgoing` ("things I've shared with others").
|
||||||
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
|
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
|
||||||
|
|
||||||
|
/// 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
|
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
|
||||||
/// constraint and the existing row is returned.
|
/// constraint; if the row already exists its `expires_at` is updated.
|
||||||
async fn grant(
|
async fn grant(
|
||||||
&self,
|
&self,
|
||||||
granted_by: Uuid,
|
granted_by: Uuid,
|
||||||
subject: Subject,
|
subject: Subject,
|
||||||
permission: Permission,
|
permission: Permission,
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
) -> Result<Grant, DomainError>;
|
) -> Result<Grant, DomainError>;
|
||||||
|
|
||||||
|
/// Update `expires_at` on every grant row for the given subject.
|
||||||
|
/// Used when a share's expiry is changed — one call updates all
|
||||||
|
/// permission rows for that token in a single UPDATE.
|
||||||
|
async fn set_expiry_for_subject(
|
||||||
|
&self,
|
||||||
|
subject: Subject,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Update `expires_at` on every grant row for the given `(subject, resource)`
|
||||||
|
/// pair. Used by `set_role` to sync the expiry of retained grants when the
|
||||||
|
/// caller changes expiry without changing permissions.
|
||||||
|
async fn set_expiry_on_resource(
|
||||||
|
&self,
|
||||||
|
subject: Subject,
|
||||||
|
resource: Resource,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
|
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
|
||||||
/// the row existed (idempotent revoke).
|
/// the row existed (idempotent revoke).
|
||||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
|
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use crate::{
|
|||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
errors::{DomainError, ErrorKind},
|
errors::{DomainError, ErrorKind},
|
||||||
},
|
},
|
||||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
domain::entities::share::{Share, ShareItemType},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -229,87 +229,59 @@ impl ShareUseCase for ShareService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
dto: CreateShareDto,
|
dto: CreateShareDto,
|
||||||
) -> Result<ShareDto, DomainError> {
|
) -> Result<ShareDto, DomainError> {
|
||||||
// Convert the item type
|
|
||||||
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
||||||
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
||||||
|
|
||||||
// Verify that the item exists
|
|
||||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||||
|
|
||||||
// Convert the permissions DTO if it exists
|
|
||||||
let permissions = dto.permissions.map(|p| p.to_entity());
|
|
||||||
|
|
||||||
// Hash the password if provided (async, semaphore-bounded)
|
|
||||||
let password_hash = match dto.password {
|
let password_hash = match dto.password {
|
||||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the Share entity
|
|
||||||
let share = Share::new(
|
let share = Share::new(
|
||||||
dto.item_id.clone(),
|
dto.item_id.clone(),
|
||||||
dto.item_name.clone(),
|
dto.item_name.clone(),
|
||||||
item_type,
|
item_type,
|
||||||
user_id,
|
user_id,
|
||||||
permissions,
|
|
||||||
password_hash,
|
password_hash,
|
||||||
dto.expires_at,
|
|
||||||
)
|
)
|
||||||
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
// Save to the repository
|
|
||||||
let saved_share = self
|
let saved_share = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.save_share(&share)
|
.save_share(&share)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||||
|
|
||||||
// Mirror the share permissions as ReBAC token grants so that
|
// Create one Read-only grant for the token subject, carrying expires_at.
|
||||||
// `GET /api/grants/outgoing` picks them up and the UI can show the
|
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
|
||||||
// share badge without a separate `/api/shares` round-trip.
|
// cleans up this grant when the share is later deleted.
|
||||||
// The DELETE trigger `trg_cleanup_grants_token` handles cleanup when
|
|
||||||
// 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())
|
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
|
||||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||||
|
|
||||||
let resource = match saved_share.item_type() {
|
let resource = match saved_share.item_type() {
|
||||||
ShareItemType::File => Resource::File(item_id_uuid),
|
ShareItemType::File => Resource::File(item_id_uuid),
|
||||||
ShareItemType::Folder => Resource::Folder(item_id_uuid),
|
ShareItemType::Folder => Resource::Folder(item_id_uuid),
|
||||||
};
|
};
|
||||||
let subject = Subject::Token(share_id);
|
let expires_dt = dto
|
||||||
let perms = saved_share.permissions();
|
.expires_at
|
||||||
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
|
||||||
// Read is always granted
|
|
||||||
self.authorization
|
self.authorization
|
||||||
.grant(user_id, subject, Permission::Read, resource)
|
.grant(
|
||||||
|
user_id,
|
||||||
|
Subject::Token(saved_share.id()),
|
||||||
|
Permission::Read,
|
||||||
|
resource,
|
||||||
|
expires_dt,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||||
|
|
||||||
// Write permission → Create + Update
|
// Return DTO with the requested expires_at (grant subquery on the share
|
||||||
if perms.write() {
|
// row would return NULL at this point since INSERT ran before the grant).
|
||||||
self.authorization
|
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
|
||||||
.grant(user_id, subject, Permission::Create, resource)
|
response.expires_at = dto.expires_at;
|
||||||
.await
|
Ok(response)
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
|
||||||
self.authorization
|
|
||||||
.grant(user_id, subject, Permission::Update, resource)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reshare permission → Share
|
|
||||||
if perms.reshare() {
|
|
||||||
self.authorization
|
|
||||||
.grant(user_id, subject, Permission::Share, resource)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the entity to DTO for the response
|
|
||||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
|
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
|
||||||
@@ -364,16 +336,6 @@ impl ShareUseCase for ShareService {
|
|||||||
// SECURITY: ownership-verified lookup — prevents IDOR
|
// SECURITY: ownership-verified lookup — prevents IDOR
|
||||||
let mut share = self.fetch_owned_share(id, requester_id).await?;
|
let mut share = self.fetch_owned_share(id, requester_id).await?;
|
||||||
|
|
||||||
// Update permissions if provided
|
|
||||||
if let Some(permissions_dto) = dto.permissions {
|
|
||||||
let permissions = SharePermissions::new(
|
|
||||||
permissions_dto.read,
|
|
||||||
permissions_dto.write,
|
|
||||||
permissions_dto.reshare,
|
|
||||||
);
|
|
||||||
share = share.with_permissions(permissions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update password if provided (async, semaphore-bounded)
|
// Update password if provided (async, semaphore-bounded)
|
||||||
if let Some(password) = dto.password {
|
if let Some(password) = dto.password {
|
||||||
let password_hash = if password.is_empty() {
|
let password_hash = if password.is_empty() {
|
||||||
@@ -384,23 +346,33 @@ impl ShareUseCase for ShareService {
|
|||||||
share = share.with_password(password_hash);
|
share = share.with_password(password_hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update expiration date if provided
|
// Expiry is managed at the grant level; update all grants for this token.
|
||||||
|
let new_expires_at = if dto.expires_at.is_some() {
|
||||||
|
dto.expires_at
|
||||||
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
if dto.expires_at.is_some() {
|
if dto.expires_at.is_some() {
|
||||||
share = share.with_expiration(dto.expires_at);
|
self.authorization
|
||||||
|
.set_expiry_for_subject(Subject::Token(share.id()), new_expires_at)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the changes
|
|
||||||
let updated_share = self
|
let updated_share = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.update_share(&share)
|
.update_share(&share)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||||
|
|
||||||
// Convert the entity to DTO for the response
|
// Use the requested expires_at for the response (subquery in update_share
|
||||||
Ok(ShareDto::from_entity(
|
// runs before set_expiry_for_subject committed, so entity may lag).
|
||||||
&updated_share,
|
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
|
||||||
&self.config.base_url(),
|
if dto.expires_at.is_some() {
|
||||||
))
|
response.expires_at = dto.expires_at;
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> {
|
async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> {
|
||||||
@@ -510,8 +482,6 @@ impl ShareUseCase for ShareService {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[allow(unused_imports)]
|
|
||||||
use crate::application::dtos::share_dto::SharePermissionsDto;
|
|
||||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||||
use crate::application::ports::share_ports::ShareStoragePort;
|
use crate::application::ports::share_ports::ShareStoragePort;
|
||||||
use crate::application::ports::storage_ports::FileReadPort;
|
use crate::application::ports::storage_ports::FileReadPort;
|
||||||
@@ -606,7 +576,6 @@ mod tests {
|
|||||||
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
||||||
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
||||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||||
let permissions = dto.permissions.map(|p| p.to_entity());
|
|
||||||
let password_hash = match dto.password {
|
let password_hash = match dto.password {
|
||||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||||
None => None,
|
None => None,
|
||||||
@@ -616,9 +585,7 @@ mod tests {
|
|||||||
dto.item_name.clone(),
|
dto.item_name.clone(),
|
||||||
item_type,
|
item_type,
|
||||||
user_id,
|
user_id,
|
||||||
permissions,
|
|
||||||
password_hash,
|
password_hash,
|
||||||
dto.expires_at,
|
|
||||||
)
|
)
|
||||||
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
||||||
let saved_share = self
|
let saved_share = self
|
||||||
@@ -692,9 +659,6 @@ mod tests {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
|
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
|
||||||
})?;
|
})?;
|
||||||
if let Some(p) = dto.permissions {
|
|
||||||
share = share.with_permissions(SharePermissions::new(p.read, p.write, p.reshare));
|
|
||||||
}
|
|
||||||
if let Some(password) = dto.password {
|
if let Some(password) = dto.password {
|
||||||
let hash = if password.is_empty() {
|
let hash = if password.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -703,9 +667,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
share = share.with_password(hash);
|
share = share.with_password(hash);
|
||||||
}
|
}
|
||||||
if dto.expires_at.is_some() {
|
|
||||||
share = share.with_expiration(dto.expires_at);
|
|
||||||
}
|
|
||||||
let updated = self
|
let updated = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.update_share(&share)
|
.update_share(&share)
|
||||||
@@ -1208,11 +1169,6 @@ mod tests {
|
|||||||
item_type: "file".to_string(),
|
item_type: "file".to_string(),
|
||||||
password: Some("secret".to_string()),
|
password: Some("secret".to_string()),
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
permissions: Some(SharePermissionsDto {
|
|
||||||
read: true,
|
|
||||||
write: false,
|
|
||||||
reshare: false,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
|
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
|
||||||
|
|||||||
@@ -12,20 +12,13 @@ pub struct Share {
|
|||||||
item_type: ShareItemType,
|
item_type: ShareItemType,
|
||||||
token: String,
|
token: String,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
|
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
|
||||||
expires_at: Option<u64>,
|
expires_at: Option<u64>,
|
||||||
permissions: SharePermissions,
|
|
||||||
created_at: u64,
|
created_at: u64,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
access_count: u64,
|
access_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct SharePermissions {
|
|
||||||
read: bool,
|
|
||||||
write: bool,
|
|
||||||
reshare: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ShareItemType {
|
pub enum ShareItemType {
|
||||||
File,
|
File,
|
||||||
@@ -38,31 +31,14 @@ impl Share {
|
|||||||
item_name: Option<String>,
|
item_name: Option<String>,
|
||||||
item_type: ShareItemType,
|
item_type: ShareItemType,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
permissions: Option<SharePermissions>,
|
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
expires_at: Option<u64>,
|
|
||||||
) -> Result<Self, ShareError> {
|
) -> Result<Self, ShareError> {
|
||||||
// Validate item_id
|
|
||||||
if item_id.is_empty() {
|
if item_id.is_empty() {
|
||||||
return Err(ShareError::ValidationError(
|
return Err(ShareError::ValidationError(
|
||||||
"Item ID cannot be empty".to_string(),
|
"Item ID cannot be empty".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate expiration date if provided
|
|
||||||
if let Some(expires) = expires_at {
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("Time went backwards")
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
if expires <= now {
|
|
||||||
return Err(ShareError::InvalidExpiration(
|
|
||||||
"Expiration date must be in the future".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.expect("Time went backwards")
|
.expect("Time went backwards")
|
||||||
@@ -75,12 +51,7 @@ impl Share {
|
|||||||
item_type,
|
item_type,
|
||||||
token: Uuid::new_v4().to_string(),
|
token: Uuid::new_v4().to_string(),
|
||||||
password_hash,
|
password_hash,
|
||||||
expires_at,
|
expires_at: None,
|
||||||
permissions: permissions.unwrap_or(SharePermissions {
|
|
||||||
read: true,
|
|
||||||
write: false,
|
|
||||||
reshare: false,
|
|
||||||
}),
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
created_by,
|
created_by,
|
||||||
access_count: 0,
|
access_count: 0,
|
||||||
@@ -96,7 +67,6 @@ impl Share {
|
|||||||
token: String,
|
token: String,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
expires_at: Option<u64>,
|
expires_at: Option<u64>,
|
||||||
permissions: SharePermissions,
|
|
||||||
created_at: u64,
|
created_at: u64,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
access_count: u64,
|
access_count: u64,
|
||||||
@@ -109,7 +79,6 @@ impl Share {
|
|||||||
token,
|
token,
|
||||||
password_hash,
|
password_hash,
|
||||||
expires_at,
|
expires_at,
|
||||||
permissions,
|
|
||||||
created_at,
|
created_at,
|
||||||
created_by,
|
created_by,
|
||||||
access_count,
|
access_count,
|
||||||
@@ -142,10 +111,6 @@ impl Share {
|
|||||||
self.expires_at
|
self.expires_at
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn permissions(&self) -> &SharePermissions {
|
|
||||||
&self.permissions
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn created_at(&self) -> u64 {
|
pub fn created_at(&self) -> u64 {
|
||||||
self.created_at
|
self.created_at
|
||||||
}
|
}
|
||||||
@@ -160,21 +125,11 @@ impl Share {
|
|||||||
|
|
||||||
// ── Builder-style modifiers (immutable) ──
|
// ── Builder-style modifiers (immutable) ──
|
||||||
|
|
||||||
pub fn with_permissions(mut self, permissions: SharePermissions) -> Self {
|
|
||||||
self.permissions = permissions;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
|
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
|
||||||
self.password_hash = password_hash;
|
self.password_hash = password_hash;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_expiration(mut self, expires_at: Option<u64>) -> Self {
|
|
||||||
self.expires_at = expires_at;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_token(mut self, token: String) -> Self {
|
pub fn with_token(mut self, token: String) -> Self {
|
||||||
self.token = token;
|
self.token = token;
|
||||||
self
|
self
|
||||||
@@ -212,28 +167,6 @@ impl Share {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharePermissions {
|
|
||||||
pub fn new(read: bool, write: bool, reshare: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
read,
|
|
||||||
write,
|
|
||||||
reshare,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read(&self) -> bool {
|
|
||||||
self.read
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write(&self) -> bool {
|
|
||||||
self.write
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reshare(&self) -> bool {
|
|
||||||
self.reshare
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for ShareItemType {
|
impl std::fmt::Display for ShareItemType {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -275,59 +208,17 @@ mod tests {
|
|||||||
ShareItemType::File,
|
ShareItemType::File,
|
||||||
uid,
|
uid,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(share.item_id(), "test_file_id");
|
assert_eq!(share.item_id(), "test_file_id");
|
||||||
assert_eq!(*share.item_type(), ShareItemType::File);
|
assert_eq!(*share.item_type(), ShareItemType::File);
|
||||||
assert_eq!(share.created_by(), uid);
|
assert_eq!(share.created_by(), uid);
|
||||||
assert!(share.permissions().read());
|
|
||||||
assert!(!share.permissions().write());
|
|
||||||
assert!(!share.permissions().reshare());
|
|
||||||
assert!(!share.has_password());
|
assert!(!share.has_password());
|
||||||
assert!(share.expires_at().is_none());
|
assert!(share.expires_at().is_none());
|
||||||
assert_eq!(share.access_count(), 0);
|
assert_eq!(share.access_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_share_is_expired() {
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("Time went backwards")
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Create a share that expires in the future
|
|
||||||
let future = now + 3600; // 1 hour in the future
|
|
||||||
let share = Share::new(
|
|
||||||
"test_file_id".to_string(),
|
|
||||||
None,
|
|
||||||
ShareItemType::File,
|
|
||||||
test_user_id(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(future),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(!share.is_expired());
|
|
||||||
|
|
||||||
// Test with past expiration (should fail during creation)
|
|
||||||
let past = now - 3600; // 1 hour in the past
|
|
||||||
let share_result = Share::new(
|
|
||||||
"test_file_id".to_string(),
|
|
||||||
None,
|
|
||||||
ShareItemType::File,
|
|
||||||
test_user_id(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(past),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(share_result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_share_item_type_conversion() {
|
fn test_share_item_type_conversion() {
|
||||||
assert_eq!(ShareItemType::File.to_string(), "file");
|
assert_eq!(ShareItemType::File.to_string(), "file");
|
||||||
@@ -355,9 +246,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
ShareItemType::File,
|
ShareItemType::File,
|
||||||
test_user_id(),
|
test_user_id(),
|
||||||
None,
|
|
||||||
Some("some_hash_value".to_string()),
|
Some("some_hash_value".to_string()),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -373,8 +262,6 @@ mod tests {
|
|||||||
ShareItemType::File,
|
ShareItemType::File,
|
||||||
test_user_id(),
|
test_user_id(),
|
||||||
None,
|
None,
|
||||||
None, // No password
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,13 @@ pub struct Grant {
|
|||||||
pub permission: Permission,
|
pub permission: Permission,
|
||||||
pub granted_by: Uuid,
|
pub granted_by: Uuid,
|
||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grant {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
self.expires_at.is_some_and(|exp| exp < chrono::Utc::now())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -252,6 +259,42 @@ pub struct IncomingGrantSummary {
|
|||||||
pub granted_by: Uuid,
|
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
|
// GrantCursor — opaque pagination cursor for list_incoming_resources_paged
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
application::ports::share_ports::ShareStoragePort,
|
application::ports::share_ports::ShareStoragePort,
|
||||||
common::errors::DomainError,
|
common::errors::DomainError,
|
||||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
domain::entities::share::{Share, ShareItemType},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// PostgreSQL implementation of [`ShareStoragePort`].
|
/// PostgreSQL implementation of [`ShareStoragePort`].
|
||||||
@@ -37,6 +37,8 @@ impl SharePgRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
||||||
|
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
|
||||||
|
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
|
||||||
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
||||||
let id: Uuid = row
|
let id: Uuid = row
|
||||||
.try_get("id")
|
.try_get("id")
|
||||||
@@ -52,10 +54,8 @@ impl SharePgRepository {
|
|||||||
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
|
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
|
||||||
})?;
|
})?;
|
||||||
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
|
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
|
||||||
|
// expires_at derived from access_grants subquery (unix seconds as i64)
|
||||||
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
|
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
|
||||||
let permissions_read: bool = row.try_get("permissions_read").unwrap_or(true);
|
|
||||||
let permissions_write: bool = row.try_get("permissions_write").unwrap_or(false);
|
|
||||||
let permissions_reshare: bool = row.try_get("permissions_reshare").unwrap_or(false);
|
|
||||||
let created_at: i64 = row.try_get("created_at").map_err(|e| {
|
let created_at: i64 = row.try_get("created_at").map_err(|e| {
|
||||||
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
|
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
|
||||||
})?;
|
})?;
|
||||||
@@ -66,8 +66,6 @@ impl SharePgRepository {
|
|||||||
|
|
||||||
let item_type =
|
let item_type =
|
||||||
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
|
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
|
||||||
let permissions =
|
|
||||||
SharePermissions::new(permissions_read, permissions_write, permissions_reshare);
|
|
||||||
|
|
||||||
Ok(Share::from_raw(
|
Ok(Share::from_raw(
|
||||||
id,
|
id,
|
||||||
@@ -77,7 +75,6 @@ impl SharePgRepository {
|
|||||||
token,
|
token,
|
||||||
password_hash,
|
password_hash,
|
||||||
expires_at.map(|v| v as u64),
|
expires_at.map(|v| v as u64),
|
||||||
permissions,
|
|
||||||
created_at as u64,
|
created_at as u64,
|
||||||
created_by,
|
created_by,
|
||||||
access_count as u64,
|
access_count as u64,
|
||||||
@@ -91,23 +88,18 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.shares
|
INSERT INTO storage.shares
|
||||||
(id, item_id, item_name, item_type, token, password_hash,
|
(id, item_id, item_name, item_type, token, password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
|
||||||
created_at, created_by, access_count)
|
created_at, created_by, access_count)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6,
|
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
$7, $8, $9, $10,
|
|
||||||
$11, $12, $13)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
item_name = EXCLUDED.item_name,
|
item_name = EXCLUDED.item_name,
|
||||||
password_hash = EXCLUDED.password_hash,
|
password_hash = EXCLUDED.password_hash,
|
||||||
expires_at = EXCLUDED.expires_at,
|
|
||||||
permissions_read = EXCLUDED.permissions_read,
|
|
||||||
permissions_write = EXCLUDED.permissions_write,
|
|
||||||
permissions_reshare = EXCLUDED.permissions_reshare,
|
|
||||||
access_count = EXCLUDED.access_count
|
access_count = EXCLUDED.access_count
|
||||||
RETURNING
|
RETURNING
|
||||||
id, item_id, item_name, item_type, token, password_hash,
|
id, item_id, item_name, item_type, token, password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
|
FROM storage.access_grants ag
|
||||||
|
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
|
||||||
created_at, created_by, access_count
|
created_at, created_by, access_count
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -117,10 +109,6 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
.bind(share.item_type().to_string())
|
.bind(share.item_type().to_string())
|
||||||
.bind(share.token())
|
.bind(share.token())
|
||||||
.bind(share.password_hash())
|
.bind(share.password_hash())
|
||||||
.bind(share.expires_at().map(|v| v as i64))
|
|
||||||
.bind(share.permissions().read())
|
|
||||||
.bind(share.permissions().write())
|
|
||||||
.bind(share.permissions().reshare())
|
|
||||||
.bind(share.created_at() as i64)
|
.bind(share.created_at() as i64)
|
||||||
.bind(share.created_by())
|
.bind(share.created_by())
|
||||||
.bind(share.access_count() as i64)
|
.bind(share.access_count() as i64)
|
||||||
@@ -137,11 +125,13 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count
|
FROM storage.access_grants ag
|
||||||
FROM storage.shares
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
WHERE token = $1
|
s.created_at, s.created_by, s.access_count
|
||||||
|
FROM storage.shares s
|
||||||
|
WHERE s.token = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
@@ -168,11 +158,13 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
) -> Result<Share, DomainError> {
|
) -> Result<Share, DomainError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count
|
FROM storage.access_grants ag
|
||||||
FROM storage.shares
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
WHERE id = $1 AND created_by = $2
|
s.created_at, s.created_by, s.access_count
|
||||||
|
FROM storage.shares s
|
||||||
|
WHERE s.id = $1 AND s.created_by = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -224,12 +216,14 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
) -> Result<Vec<Share>, DomainError> {
|
) -> Result<Vec<Share>, DomainError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count
|
FROM storage.access_grants ag
|
||||||
FROM storage.shares
|
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||||
WHERE item_id = $1 AND item_type = $2 AND created_by = $3
|
s.created_at, s.created_by, s.access_count
|
||||||
ORDER BY created_at DESC
|
FROM storage.shares s
|
||||||
|
WHERE s.item_id = $1 AND s.item_type = $2 AND s.created_by = $3
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(item_id)
|
.bind(item_id)
|
||||||
@@ -251,25 +245,19 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
UPDATE storage.shares SET
|
UPDATE storage.shares SET
|
||||||
item_name = $2,
|
item_name = $2,
|
||||||
password_hash = $3,
|
password_hash = $3,
|
||||||
expires_at = $4,
|
access_count = $4
|
||||||
permissions_read = $5,
|
|
||||||
permissions_write = $6,
|
|
||||||
permissions_reshare = $7,
|
|
||||||
access_count = $8
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
id, item_id, item_name, item_type, token, password_hash,
|
id, item_id, item_name, item_type, token, password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
|
FROM storage.access_grants ag
|
||||||
|
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
|
||||||
created_at, created_by, access_count
|
created_at, created_by, access_count
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(share.id())
|
.bind(share.id())
|
||||||
.bind(share.item_name())
|
.bind(share.item_name())
|
||||||
.bind(share.password_hash())
|
.bind(share.password_hash())
|
||||||
.bind(share.expires_at().map(|v| v as i64))
|
|
||||||
.bind(share.permissions().read())
|
|
||||||
.bind(share.permissions().write())
|
|
||||||
.bind(share.permissions().reshare())
|
|
||||||
.bind(share.access_count() as i64)
|
.bind(share.access_count() as i64)
|
||||||
.fetch_optional(&*self.db_pool)
|
.fetch_optional(&*self.db_pool)
|
||||||
.await
|
.await
|
||||||
@@ -296,13 +284,15 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
// Single query with window function — count + rows in one roundtrip
|
// Single query with window function — count + rows in one roundtrip
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, item_id, item_name, item_type, token, password_hash,
|
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||||
expires_at, permissions_read, permissions_write, permissions_reshare,
|
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||||
created_at, created_by, access_count,
|
FROM storage.access_grants ag
|
||||||
|
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
|
COUNT(*) OVER() AS total_count
|
||||||
FROM storage.shares
|
FROM storage.shares s
|
||||||
WHERE created_by = $1
|
WHERE s.created_by = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ use sqlx::PgPool;
|
|||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::services::authorization::{
|
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::file_blob_read_repository::FileBlobReadRepository;
|
||||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||||
@@ -103,6 +104,7 @@ impl PgAclEngine {
|
|||||||
AND g.subject_id = $2
|
AND g.subject_id = $2
|
||||||
AND g.permission = $3
|
AND g.permission = $3
|
||||||
AND g.resource_type = 'folder'
|
AND g.resource_type = 'folder'
|
||||||
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||||
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
|
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
@@ -135,6 +137,7 @@ impl PgAclEngine {
|
|||||||
FROM storage.access_grants
|
FROM storage.access_grants
|
||||||
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
|
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
|
||||||
AND resource_type = 'file' AND resource_id = $4
|
AND resource_type = 'file' AND resource_id = $4
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
UNION ALL
|
UNION ALL
|
||||||
-- cascading from any ancestor folder of the file's containing folder
|
-- cascading from any ancestor folder of the file's containing folder
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -145,6 +148,7 @@ impl PgAclEngine {
|
|||||||
AND g.subject_id = $2
|
AND g.subject_id = $2
|
||||||
AND g.permission = $3
|
AND g.permission = $3
|
||||||
AND g.resource_type = 'folder'
|
AND g.resource_type = 'folder'
|
||||||
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||||
AND target_f.folder_id IS NOT NULL
|
AND target_f.folder_id IS NOT NULL
|
||||||
AND gf.lpath @> (SELECT lpath FROM storage.folders
|
AND gf.lpath @> (SELECT lpath FROM storage.folders
|
||||||
WHERE id = target_f.folder_id)
|
WHERE id = target_f.folder_id)
|
||||||
@@ -186,8 +190,9 @@ impl PgAclEngine {
|
|||||||
Ok(Some((res, granter)))
|
Ok(Some((res, granter)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a (id, subject_type, subject_id, resource_type, resource_id,
|
/// Row type for all full-grant SELECT queries:
|
||||||
/// permission, granted_by, granted_at) row into a `Grant`.
|
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn row_to_grant(
|
fn row_to_grant(
|
||||||
row: (
|
row: (
|
||||||
Uuid,
|
Uuid,
|
||||||
@@ -198,6 +203,7 @@ impl PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
) -> Result<Grant, DomainError> {
|
) -> Result<Grant, DomainError> {
|
||||||
let subject = Subject::from_parts(&row.1, row.2)
|
let subject = Subject::from_parts(&row.1, row.2)
|
||||||
@@ -213,6 +219,7 @@ impl PgAclEngine {
|
|||||||
permission,
|
permission,
|
||||||
granted_by: row.6,
|
granted_by: row.6,
|
||||||
granted_at: row.7,
|
granted_at: row.7,
|
||||||
|
expires_at: row.8,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,11 +278,12 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||||
permission, granted_by, granted_at
|
permission, granted_by, granted_at, expires_at
|
||||||
FROM storage.access_grants
|
FROM storage.access_grants
|
||||||
WHERE subject_type = $1
|
WHERE subject_type = $1
|
||||||
AND subject_id = $2
|
AND subject_id = $2
|
||||||
@@ -461,42 +469,6 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
LIMIT $8"#
|
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.
|
// Default: sort by grant date.
|
||||||
// Normal = DESC (newest first); reversed = ASC (oldest first).
|
// Normal = DESC (newest first); reversed = ASC (oldest first).
|
||||||
@@ -580,14 +552,6 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
sort_int: None,
|
sort_int: None,
|
||||||
reverse,
|
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 {
|
_ => GrantCursor {
|
||||||
sort_by: "granted_at".to_owned(),
|
sort_by: "granted_at".to_owned(),
|
||||||
granted_at: r.3,
|
granted_at: r.3,
|
||||||
@@ -636,11 +600,12 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||||
permission, granted_by, granted_at
|
permission, granted_by, granted_at, expires_at
|
||||||
FROM storage.access_grants
|
FROM storage.access_grants
|
||||||
WHERE resource_type = $1
|
WHERE resource_type = $1
|
||||||
AND resource_id = $2
|
AND resource_id = $2
|
||||||
@@ -656,6 +621,619 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
rows.into_iter().map(Self::row_to_grant).collect()
|
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> {
|
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
|
||||||
let rows = sqlx::query_as::<
|
let rows = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
@@ -668,11 +1246,12 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||||
permission, granted_by, granted_at
|
permission, granted_by, granted_at, expires_at
|
||||||
FROM storage.access_grants
|
FROM storage.access_grants
|
||||||
WHERE granted_by = $1
|
WHERE granted_by = $1
|
||||||
ORDER BY granted_at DESC
|
ORDER BY granted_at DESC
|
||||||
@@ -692,10 +1271,8 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
subject: Subject,
|
subject: Subject,
|
||||||
permission: Permission,
|
permission: Permission,
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
) -> Result<Grant, DomainError> {
|
) -> Result<Grant, DomainError> {
|
||||||
// Idempotent: ON CONFLICT DO UPDATE so we always return the row
|
|
||||||
// (whether newly inserted or pre-existing). The "update" is a no-op
|
|
||||||
// (granted_by/granted_at preserved from the existing row).
|
|
||||||
let row = sqlx::query_as::<
|
let row = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
(
|
(
|
||||||
@@ -707,16 +1284,17 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.access_grants
|
INSERT INTO storage.access_grants
|
||||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
|
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
|
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
|
||||||
DO UPDATE SET subject_type = EXCLUDED.subject_type
|
DO UPDATE SET expires_at = EXCLUDED.expires_at
|
||||||
RETURNING id, subject_type, subject_id, resource_type, resource_id,
|
RETURNING id, subject_type, subject_id, resource_type, resource_id,
|
||||||
permission, granted_by, granted_at
|
permission, granted_by, granted_at, expires_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(subject.type_str())
|
.bind(subject.type_str())
|
||||||
@@ -725,6 +1303,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
.bind(resource.id())
|
.bind(resource.id())
|
||||||
.bind(permission.as_str())
|
.bind(permission.as_str())
|
||||||
.bind(granted_by)
|
.bind(granted_by)
|
||||||
|
.bind(expires_at)
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
|
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
|
||||||
@@ -732,6 +1311,47 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
Self::row_to_grant(row)
|
Self::row_to_grant(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_expiry_for_subject(
|
||||||
|
&self,
|
||||||
|
subject: Subject,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2",
|
||||||
|
)
|
||||||
|
.bind(subject.type_str())
|
||||||
|
.bind(subject.id())
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_expiry_on_resource(
|
||||||
|
&self,
|
||||||
|
subject: Subject,
|
||||||
|
resource: Resource,
|
||||||
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE storage.access_grants SET expires_at = $3 \
|
||||||
|
WHERE subject_type = $1 AND subject_id = $2 \
|
||||||
|
AND resource_type = $4 AND resource_id = $5",
|
||||||
|
)
|
||||||
|
.bind(subject.type_str())
|
||||||
|
.bind(subject.id())
|
||||||
|
.bind(expires_at)
|
||||||
|
.bind(resource.type_str())
|
||||||
|
.bind(resource.id())
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}"))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||||
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
|
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
|
||||||
.bind(grant_id)
|
.bind(grant_id)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::application::dtos::display_helpers::{
|
use crate::application::dtos::display_helpers::{
|
||||||
@@ -56,6 +56,9 @@ pub async fn get_favorites(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_id = auth_user.id;
|
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 {
|
match favorites_service.get_favorites(user_id).await {
|
||||||
Ok(favorites) => {
|
Ok(favorites) => {
|
||||||
|
|||||||
@@ -490,6 +490,9 @@ pub async fn list_folder_contents(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
path: Path<String>,
|
path: Path<String>,
|
||||||
) -> axum::response::Response {
|
) -> 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
|
FolderHandler::list_folder_contents_impl(state, auth_user, path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +536,9 @@ pub async fn list_folder_contents_paginated(
|
|||||||
path: Path<String>,
|
path: Path<String>,
|
||||||
pagination: Query<PaginationRequestDto>,
|
pagination: Query<PaginationRequestDto>,
|
||||||
) -> axum::response::Response {
|
) -> 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
|
FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::application::dtos::cursor::PageCursor;
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::grant_dto::{
|
use crate::application::dtos::grant_dto::{
|
||||||
CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto,
|
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||||
SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||||
|
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
|
||||||
};
|
};
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||||
@@ -31,7 +32,8 @@ use crate::common::di::AppState;
|
|||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::errors::ErrorKind;
|
use crate::domain::errors::ErrorKind;
|
||||||
use crate::domain::services::authorization::{
|
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::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
@@ -86,6 +88,7 @@ pub async fn create_grant(
|
|||||||
|
|
||||||
let subject: Subject = dto.subject.into();
|
let subject: Subject = dto.subject.into();
|
||||||
let resource: Resource = dto.resource.into();
|
let resource: Resource = dto.resource.into();
|
||||||
|
let expires_at = dto.expires_at;
|
||||||
|
|
||||||
// Caller must have Share on the resource (owners pass via short-circuit).
|
// Caller must have Share on the resource (owners pass via short-circuit).
|
||||||
if let Err(e) = authz
|
if let Err(e) = authz
|
||||||
@@ -97,7 +100,10 @@ pub async fn create_grant(
|
|||||||
|
|
||||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||||
for perm in permissions {
|
for perm in permissions {
|
||||||
match authz.grant(caller_id, subject, perm, resource).await {
|
match authz
|
||||||
|
.grant(caller_id, subject, perm, resource, expires_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(grant) => results.push(grant.into()),
|
Ok(grant) => results.push(grant.into()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("grant insert failed for {perm:?}: {err}");
|
error!("grant insert failed for {perm:?}: {err}");
|
||||||
@@ -189,6 +195,7 @@ pub async fn set_role(
|
|||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let subject: Subject = dto.subject.into();
|
let subject: Subject = dto.subject.into();
|
||||||
let resource: Resource = dto.resource.into();
|
let resource: Resource = dto.resource.into();
|
||||||
|
let expires_at = dto.expires_at;
|
||||||
let target_perms: std::collections::HashSet<Permission> =
|
let target_perms: std::collections::HashSet<Permission> =
|
||||||
dto.role.expand().iter().copied().collect();
|
dto.role.expand().iter().copied().collect();
|
||||||
|
|
||||||
@@ -225,11 +232,25 @@ pub async fn set_role(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for perm in &to_add {
|
for perm in &to_add {
|
||||||
if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await {
|
if let Err(e) = authz
|
||||||
|
.grant(caller_id, subject, *perm, resource, expires_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
return AppError::from(e).into_response();
|
return AppError::from(e).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync expiry on all remaining grants for this (subject, resource) pair —
|
||||||
|
// includes newly added ones and any that were already present (retained).
|
||||||
|
// Callers that omit expires_at will clear any existing expiry; this is
|
||||||
|
// intentional: it keeps all permission rows for the pair consistent.
|
||||||
|
if let Err(e) = authz
|
||||||
|
.set_expiry_on_resource(subject, resource, expires_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return AppError::from(e).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Return the new full set.
|
// Return the new full set.
|
||||||
let after = match authz.list_grants_on_resource(resource).await {
|
let after = match authz.list_grants_on_resource(resource).await {
|
||||||
Ok(g) => g,
|
Ok(g) => g,
|
||||||
@@ -330,13 +351,10 @@ pub async fn list_shared_with_me(
|
|||||||
|
|
||||||
// Validate sort_by (defaults to "granted_at").
|
// Validate sort_by (defaults to "granted_at").
|
||||||
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
|
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
|
||||||
if !matches!(
|
if !matches!(sort_by, "granted_at" | "granted_by" | "name" | "type") {
|
||||||
sort_by,
|
|
||||||
"granted_at" | "granted_by" | "name" | "type" | "size"
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
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();
|
.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
|
// Silence unused-import warnings for SubjectDto when only certain endpoints
|
||||||
// touch it directly.
|
// touch it directly.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::application::dtos::display_helpers::{
|
use crate::application::dtos::display_helpers::{
|
||||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
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::application::services::recent_service::RecentService;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Query parameters for getting recent items
|
/// Query parameters for getting recent items
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -46,6 +47,7 @@ pub async fn get_recent_items(
|
|||||||
Query(params): Query<GetRecentParams>,
|
Query(params): Query<GetRecentParams>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_id = auth_user.id;
|
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 {
|
match recent_service.get_recent_items(user_id, params.limit).await {
|
||||||
Ok(items) => {
|
Ok(items) => {
|
||||||
@@ -83,7 +85,7 @@ pub async fn get_recent_items(
|
|||||||
pub async fn record_item_access(
|
pub async fn record_item_access(
|
||||||
State(recent_service): State<Arc<RecentService>>,
|
State(recent_service): State<Arc<RecentService>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path((item_type, item_id)): Path<(String, String)>,
|
Path((item_type, item_id)): Path<(String, Uuid)>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_id = auth_user.id;
|
let user_id = auth_user.id;
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ pub async fn record_item_access(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match recent_service
|
match recent_service
|
||||||
.record_item_access(user_id, &item_id, &item_type)
|
.record_item_access(user_id, &item_id.to_string(), &item_type)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -143,12 +145,12 @@ pub async fn record_item_access(
|
|||||||
pub async fn remove_from_recent(
|
pub async fn remove_from_recent(
|
||||||
State(recent_service): State<Arc<RecentService>>,
|
State(recent_service): State<Arc<RecentService>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path((item_type, item_id)): Path<(String, String)>,
|
Path((item_type, item_id)): Path<(String, Uuid)>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_id = auth_user.id;
|
let user_id = auth_user.id;
|
||||||
|
|
||||||
match recent_service
|
match recent_service
|
||||||
.remove_from_recent(user_id, &item_id, &item_type)
|
.remove_from_recent(user_id, &item_id.to_string(), &item_type)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(removed) => {
|
Ok(removed) => {
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ use crate::application::dtos::search_dto::{
|
|||||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
||||||
SearchSuggestionItem, SearchSuggestionsDto,
|
SearchSuggestionItem, SearchSuggestionsDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::share_dto::{
|
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
|
||||||
CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto,
|
|
||||||
};
|
|
||||||
use crate::application::dtos::trash_dto::{
|
use crate::application::dtos::trash_dto::{
|
||||||
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
|
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
|
||||||
};
|
};
|
||||||
@@ -257,7 +255,6 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
OidcExchangeDto,
|
OidcExchangeDto,
|
||||||
// Share schemas
|
// Share schemas
|
||||||
ShareDto,
|
ShareDto,
|
||||||
SharePermissionsDto,
|
|
||||||
CreateShareDto,
|
CreateShareDto,
|
||||||
UpdateShareDto,
|
UpdateShareDto,
|
||||||
// Trash schemas
|
// Trash schemas
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
get(grant_handler::list_shared_with_me),
|
get(grant_handler::list_shared_with_me),
|
||||||
)
|
)
|
||||||
.route("/outgoing", get(grant_handler::list_outgoing))
|
.route("/outgoing", get(grant_handler::list_outgoing))
|
||||||
|
.route("/outgoing/resources", get(grant_handler::list_my_shares))
|
||||||
.with_state(app_state.clone())
|
.with_state(app_state.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,8 +338,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(get_favorites)) // deprecated, kept for compat
|
.route("/", get(get_favorites)) // deprecated — kept for external compat
|
||||||
.route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint
|
.route("/resources", get(list_favorites_resources))
|
||||||
.route("/batch", post(favorites_handler::batch_add_favorites))
|
.route("/batch", post(favorites_handler::batch_add_favorites))
|
||||||
.route(
|
.route(
|
||||||
"/{item_type}/{item_id}",
|
"/{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;
|
use crate::interfaces::api::handlers::recent_handler;
|
||||||
|
|
||||||
Router::new()
|
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("/resources", get(recent_handler::list_recent_resources))
|
||||||
.route(
|
.route(
|
||||||
"/{item_type}/{item_id}",
|
"/{item_type}/{item_id}",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
/* Wider, taller container; body becomes a zero-padding scrollable slot. */
|
/* Wider, taller container; body becomes a zero-padding scrollable slot. */
|
||||||
|
|
||||||
.modal-container--panel {
|
.modal-container--panel {
|
||||||
width: 520px;
|
width: 620px;
|
||||||
max-width: 96vw;
|
max-width: 96vw;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -328,6 +328,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.smd-link-name {
|
.smd-link-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -431,32 +433,6 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.smd-new-link-form {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
border: 0.5px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smd-new-link-form label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smd-new-link-form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Password toggle row ─────────────────────────────────────────────────────── */
|
/* ── Password toggle row ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.smd-pw-toggle {
|
.smd-pw-toggle {
|
||||||
@@ -488,3 +464,89 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Expiry chip toggle ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The wrapper reserves a fixed width equal to the date input so that toggling
|
||||||
|
* between chip and input never shifts the surrounding flex row.
|
||||||
|
*/
|
||||||
|
.smd-expiry-chip-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px dashed var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-chip:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-chip--set {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-border);
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-chip--set:hover {
|
||||||
|
border-color: var(--color-border-medium);
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-chip-clear {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-chip-clear:hover {
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-expiry-date-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the search row the chip/input must match the height of the role select and
|
||||||
|
Add button (both use padding: 9px, font-size: 13px). */
|
||||||
|
.smd-search-row .smd-expiry-chip,
|
||||||
|
.smd-search-row .smd-expiry-date-input {
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
@import url("./components/shareDialog.css");
|
@import url("./components/shareDialog.css");
|
||||||
@import url("./components/shareModal.css");
|
@import url("./components/shareModal.css");
|
||||||
@import url("./components/userVignette.css");
|
@import url("./components/userVignette.css");
|
||||||
|
@import url("./components/linkChip.css");
|
||||||
@import url("./components/uploadDropdown.css");
|
@import url("./components/uploadDropdown.css");
|
||||||
@import url("./components/notifications.css");
|
@import url("./components/notifications.css");
|
||||||
@import url("./components/userMenu.css");
|
@import url("./components/userMenu.css");
|
||||||
|
|||||||
@@ -120,3 +120,7 @@
|
|||||||
background: var(--color-bg-alt);
|
background: var(--color-bg-alt);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .smd-expiry-date-input::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -13,8 +13,8 @@
|
|||||||
<link rel="stylesheet" href="/css/views/inlineViewer.css">
|
<link rel="stylesheet" href="/css/views/inlineViewer.css">
|
||||||
<link rel="stylesheet" href="/css/views/favorites.css">
|
<link rel="stylesheet" href="/css/views/favorites.css">
|
||||||
<link rel="stylesheet" href="/css/views/recent.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/sharedWithMe.css">
|
||||||
|
<link rel="stylesheet" href="/css/views/mySharesView.css">
|
||||||
<link rel="stylesheet" href="/css/views/trash.css">
|
<link rel="stylesheet" href="/css/views/trash.css">
|
||||||
<link rel="stylesheet" href="/css/views/photos.css">
|
<link rel="stylesheet" href="/css/views/photos.css">
|
||||||
<link rel="stylesheet" href="/css/views/photosLightbox.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/photos.js"></script>
|
||||||
<script defer type="module" src="/js/features/library/music.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/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/model/recentModel.js"></script>
|
||||||
<script defer type="module" src="/js/views/recent/recentView.js"></script>
|
<script defer type="module" src="/js/views/recent/recentView.js"></script>
|
||||||
<script defer type="module" src="/js/features/files/inlineViewer.js"></script>
|
<script defer type="module" src="/js/features/files/inlineViewer.js"></script>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ResourceListComponent } from '../components/resourceList.js';
|
import { ResourceListComponent } from '../components/resourceList.js';
|
||||||
|
import { shareModal } from '../components/shareModal.js';
|
||||||
import { normalizeDateBucket, sizeBucket } from '../core/formatters.js';
|
import { normalizeDateBucket, sizeBucket } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import * as viewPrefs from '../core/viewPrefs.js';
|
import * as viewPrefs from '../core/viewPrefs.js';
|
||||||
@@ -218,6 +219,12 @@ function _ensureComponent() {
|
|||||||
_component?.setFavoriteVisualState(item.id, type, true);
|
_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),
|
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||||
onSelectionChange: (selectedItems) => {
|
onSelectionChange: (selectedItems) => {
|
||||||
batchToolbar._selected.clear();
|
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 };
|
||||||
|
|||||||
+34
-11
@@ -18,7 +18,6 @@ import { recent } from '../features/library/recent.js';
|
|||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
import { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
import { recentView } from '../views/recent/recentView.js';
|
import { recentView } from '../views/recent/recentView.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
|
||||||
import { checkAuthentication } from './authSession.js';
|
import { checkAuthentication } from './authSession.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import {
|
import {
|
||||||
@@ -162,12 +161,30 @@ const ACTIONS_BAR_TEMPLATES = {
|
|||||||
<div class="action-buttons" id="default-buttons"></div>
|
<div class="action-buttons" id="default-buttons"></div>
|
||||||
${_batchToolbarButons}
|
${_batchToolbarButons}
|
||||||
${_toggleButtons}
|
${_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]
|
* @param {boolean} [force=false]
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
@@ -259,8 +276,12 @@ function syncGroupByMenu(defs = []) {
|
|||||||
|
|
||||||
// Rebuild menu options — call i18n.t() directly so each label is resolved
|
// 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).
|
// 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) {
|
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>`);
|
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 () => {
|
window.addEventListener('authenticationDone', async () => {
|
||||||
// Check if a context was provided in the URL
|
// Check if a context was provided in the URL
|
||||||
const hashContext = deserializeHash();
|
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);
|
switchSectionTo(hashContext.section);
|
||||||
if (hashContext.section === 'files') {
|
if (hashContext.section === 'files') {
|
||||||
if (hashContext.path) {
|
if (hashContext.path) {
|
||||||
@@ -519,9 +546,6 @@ function initApp() {
|
|||||||
app.viewFile = hashContext.file;
|
app.viewFile = hashContext.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get grants (xxx: async methods)
|
|
||||||
await grants.fetchIncomingGrants();
|
|
||||||
await grants.fetchOutgoingGrants();
|
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -640,9 +664,11 @@ function setupEventListeners() {
|
|||||||
} else {
|
} else {
|
||||||
// change is from history, data provided in event
|
// change is from history, data provided in event
|
||||||
switchSectionTo(e.state.section);
|
switchSectionTo(e.state.section);
|
||||||
|
if (e.state.section === 'files') {
|
||||||
app.currentPath = e.state.id;
|
app.currentPath = e.state.id;
|
||||||
loadFiles({ insertHistory: false });
|
loadFiles({ insertHistory: false });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile search toggle
|
// Mobile search toggle
|
||||||
@@ -676,11 +702,8 @@ function setupEventListeners() {
|
|||||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||||
const query = elements.searchInput?.value.trim();
|
const query = elements.searchInput?.value.trim();
|
||||||
|
|
||||||
// In shared section, filter locally
|
// My Shares section does not support in-page search
|
||||||
if (app.currentSection === 'shared' && sharedView) {
|
if (app.currentSection === 'shared') return;
|
||||||
sharedView.filterAndSortItems();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
performSearch(query);
|
performSearch(query);
|
||||||
|
|||||||
+28
-18
@@ -10,11 +10,12 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
|
|||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { musicView } from '../features/library/music.js';
|
import { musicView } from '../features/library/music.js';
|
||||||
import { photosView } from '../features/library/photos.js';
|
import { photosView } from '../features/library/photos.js';
|
||||||
|
import { grants } from '../model/grants.js';
|
||||||
import { favoritesView } from '../views/favorites/favoritesView.js';
|
import { favoritesView } from '../views/favorites/favoritesView.js';
|
||||||
|
import { mySharesView } from '../views/myShares/mySharesView.js';
|
||||||
import { recentView } from '../views/recent/recentView.js';
|
import { recentView } from '../views/recent/recentView.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
|
||||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.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 { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||||
import { app, appElements } from './state.js';
|
import { app, appElements } from './state.js';
|
||||||
import { loadTrashItems } from './trashView.js';
|
import { loadTrashItems } from './trashView.js';
|
||||||
@@ -165,9 +166,9 @@ function setCurrentSection(section) {
|
|||||||
appElements.pageTitle.setAttribute('data-i18n', titleKey);
|
appElements.pageTitle.setAttribute('data-i18n', titleKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide sharedView when switching to any other section
|
// Hide mySharesView when switching to any other section
|
||||||
if (section !== 'shared' && sharedView) {
|
if (section !== 'shared') {
|
||||||
sharedView.hide();
|
mySharesView.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide "Load more" button when leaving the sharedwithme section
|
// Hide "Load more" button when leaving the sharedwithme section
|
||||||
@@ -208,21 +209,27 @@ function switchToSharedSection() {
|
|||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
breadcrumb?.classList.add('hidden');
|
breadcrumb?.classList.add('hidden');
|
||||||
|
|
||||||
// Hide actions-bar for shared view
|
// Show actions-bar with group-by controls only (no grid/list toggle —
|
||||||
setActionsBarMode('hidden');
|
// MySharesList is always in list mode).
|
||||||
|
setActionsBarMode('shared');
|
||||||
|
|
||||||
//reset files view + remove any error
|
// Populate the group-by dropdown with this section's dimensions.
|
||||||
ui.resetFilesList();
|
setGroupByView(mySharesView);
|
||||||
|
syncGroupByMenu(mySharesView.groupByDefs);
|
||||||
|
|
||||||
// Hide file containers
|
// Restore the saved group-by selection in the dropdown.
|
||||||
toggleFileContainer(false);
|
const msPrefs = viewPrefs.load('shared');
|
||||||
|
applyGroupByMenuState(msPrefs.groupBy, msPrefs.reversed);
|
||||||
|
|
||||||
// Show shared view
|
// Show the files container always in list view — grid is not applicable here.
|
||||||
sharedView.init().then(() => {
|
toggleFileContainer(true);
|
||||||
sharedView.show();
|
app.currentView = 'list';
|
||||||
});
|
syncViewContainers();
|
||||||
|
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
|
// Load and render items into the files container
|
||||||
|
mySharesView.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToSharedWithMeSection() {
|
function switchToSharedWithMeSection() {
|
||||||
@@ -293,10 +300,13 @@ function switchToFilesSection() {
|
|||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
if (batchToolbar) batchToolbar.clear();
|
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() {
|
function switchToFavoritesSection() {
|
||||||
|
|||||||
+1
-1
@@ -882,7 +882,7 @@ const ui = {
|
|||||||
loadFiles();
|
loadFiles();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (app.currentSection === 'sharedwithme') {
|
if (app.currentSection === 'sharedwithme' || app.currentSection === 'shared') {
|
||||||
// Activate Files UI (nav, breadcrumb, actions bar) without
|
// Activate Files UI (nav, breadcrumb, actions bar) without
|
||||||
// resetting the path — the shared folder becomes the entry point.
|
// resetting the path — the shared folder becomes the entry point.
|
||||||
activateFilesUI();
|
activateFilesUI();
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -350,10 +350,11 @@ const Modal = {
|
|||||||
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
|
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
|
||||||
* @param {string} [options.confirmText] - Confirm button label
|
* @param {string} [options.confirmText] - Confirm button label
|
||||||
* @param {string} [options.cancelText] - Cancel button label
|
* @param {string} [options.cancelText] - Cancel button label
|
||||||
|
* @param {boolean} [options.confirmDisabled] - Initial disabled state of the confirm button
|
||||||
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
|
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
|
||||||
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
|
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
|
||||||
*/
|
*/
|
||||||
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) {
|
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, confirmDisabled = false, onConfirm = null, onCancel = null }) {
|
||||||
if (!this.overlay) return;
|
if (!this.overlay) return;
|
||||||
|
|
||||||
this._panelMode = true;
|
this._panelMode = true;
|
||||||
@@ -379,7 +380,7 @@ const Modal = {
|
|||||||
// ── Footer buttons ──────────────────────────────────────────────────
|
// ── Footer buttons ──────────────────────────────────────────────────
|
||||||
if (this.confirmBtn) {
|
if (this.confirmBtn) {
|
||||||
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
|
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
|
||||||
this.confirmBtn.disabled = false;
|
this.confirmBtn.disabled = confirmDisabled;
|
||||||
}
|
}
|
||||||
if (this.cancelBtn) {
|
if (this.cancelBtn) {
|
||||||
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
|
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
|
|
||||||
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { thumbnail } from '../features/thumbnail.js';
|
|
||||||
import { systemUsers } from '../model/systemUsers.js';
|
import { systemUsers } from '../model/systemUsers.js';
|
||||||
|
import { buildResourceIcon } from './resourceIcon.js';
|
||||||
import { createUserVignette } from './userVignette.js';
|
import { createUserVignette } from './userVignette.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +55,9 @@ import { createUserVignette } from './userVignette.js';
|
|||||||
* @property {(item: FileItem|FolderItem) => Promise<void>} [onFavoriteToggle]
|
* @property {(item: FileItem|FolderItem) => Promise<void>} [onFavoriteToggle]
|
||||||
* Called when the user clicks the favorite-star button.
|
* Called when the user clicks the favorite-star button.
|
||||||
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu]
|
* @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]
|
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange]
|
||||||
* Called whenever the selection set changes.
|
* Called whenever the selection set changes.
|
||||||
*/
|
*/
|
||||||
@@ -333,6 +335,19 @@ export class ResourceListComponent {
|
|||||||
item.querySelector('.file-badge-shared')?.classList.toggle('hidden', !isShared);
|
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 ─────────────────────────────────────────────────────
|
// ── Private helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,9 +459,7 @@ export class ResourceListComponent {
|
|||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon folder-icon">
|
<div class="resource-icon-slot"></div>
|
||||||
<i class="fas fa-folder"></i>
|
|
||||||
</div>
|
|
||||||
<span>${escapeHtml(folder.name)}</span>
|
<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.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>` : ''}
|
${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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
|
||||||
this._bindItemEvents(el, folder);
|
this._bindItemEvents(el, folder);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -472,8 +486,6 @@ export class ResourceListComponent {
|
|||||||
*/
|
*/
|
||||||
_createFileItem(file) {
|
_createFileItem(file) {
|
||||||
const cfg = this._cfg;
|
const cfg = this._cfg;
|
||||||
const iconClass = file.icon_class || 'fas fa-file';
|
|
||||||
const iconSpecialClass = file.icon_special_class || '';
|
|
||||||
const cat = file.category || '';
|
const cat = file.category || '';
|
||||||
const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document');
|
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);
|
const fileSize = file.size_formatted || formatFileSize(file.size);
|
||||||
@@ -481,7 +493,6 @@ export class ResourceListComponent {
|
|||||||
const formattedDate = formatDateTime(new Date(dateVal));
|
const formattedDate = formatDateTime(new Date(dateVal));
|
||||||
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
|
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
|
||||||
const isShared = cfg.isShared ? cfg.isShared(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 el = document.createElement('div');
|
||||||
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
|
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
|
||||||
@@ -496,10 +507,7 @@ export class ResourceListComponent {
|
|||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon ${iconSpecialClass}">
|
<div class="resource-icon-slot"></div>
|
||||||
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
|
||||||
<i class="${iconClass}"></i>
|
|
||||||
</div>
|
|
||||||
<span>${escapeHtml(file.name)}</span>
|
<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.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>` : ''}
|
${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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
|
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this._bindItemEvents(el, file);
|
this._bindItemEvents(el, file);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -550,14 +547,18 @@ export class ResourceListComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared-badge click → treat as context-menu trigger (e.g. open share modal)
|
// Shared-badge click → open share modal (or fall back to context menu)
|
||||||
if (cfg.showShareBadge && cfg.onContextMenu) {
|
if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) {
|
||||||
const badge = el.querySelector('.file-badge-shared');
|
const badge = el.querySelector('.file-badge-shared');
|
||||||
badge?.addEventListener('click', (e) => {
|
badge?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (cfg.onShareBadgeClick) {
|
||||||
|
cfg.onShareBadgeClick(item);
|
||||||
|
} else {
|
||||||
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
|
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-266
@@ -20,13 +20,13 @@ import { fileSharing } from '../features/sharing/fileSharing.js';
|
|||||||
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
|
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
|
||||||
import { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
import { systemUsers } from '../model/systemUsers.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 { Modal } from './modal.js';
|
||||||
import { createUserVignette } from './userVignette.js';
|
import { createUserVignette } from './userVignette.js';
|
||||||
|
|
||||||
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.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). */
|
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||||
const ROLE_PERMISSIONS = {
|
const ROLE_PERMISSIONS = {
|
||||||
viewer: ['read'],
|
viewer: ['read'],
|
||||||
@@ -101,24 +101,33 @@ const shareModal = {
|
|||||||
/** @type {ShareRoleEnum} */
|
/** @type {ShareRoleEnum} */
|
||||||
_stagedRole: 'viewer',
|
_stagedRole: 'viewer',
|
||||||
|
|
||||||
|
/** @type {string|null} — YYYY-MM-DD expiry for the next staged users batch */
|
||||||
|
_stagedExpiry: null,
|
||||||
|
|
||||||
/** @type {HTMLElement|null} — body node injected into Modal */
|
/** @type {HTMLElement|null} — body node injected into Modal */
|
||||||
_bodyEl: null,
|
_bodyEl: null,
|
||||||
|
|
||||||
|
/** @type {(() => void)|null} — called after changes are successfully committed */
|
||||||
|
_onApplied: null,
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the share modal for a file or folder.
|
* Open the share modal for a file or folder.
|
||||||
* @param {FileItem|FolderItem} item
|
* @param {FileItem|FolderItem} item
|
||||||
* @param {'file'|'folder'} itemType
|
* @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._item = item;
|
||||||
this._itemType = itemType;
|
this._itemType = itemType;
|
||||||
|
this._onApplied = onApplied ?? null;
|
||||||
this._localMembers = [];
|
this._localMembers = [];
|
||||||
this._localLinks = [];
|
this._localLinks = [];
|
||||||
this._newLinks = [];
|
this._newLinks = [];
|
||||||
this._stagedUsers = [];
|
this._stagedUsers = [];
|
||||||
this._stagedRole = 'viewer';
|
this._stagedRole = 'viewer';
|
||||||
|
this._stagedExpiry = null;
|
||||||
|
|
||||||
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
|
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ const shareModal = {
|
|||||||
icon: 'fa-share-alt',
|
icon: 'fa-share-alt',
|
||||||
content: this._bodyEl,
|
content: this._bodyEl,
|
||||||
confirmText: i18n.t('actions.apply', 'Apply'),
|
confirmText: i18n.t('actions.apply', 'Apply'),
|
||||||
|
confirmDisabled: true,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
this._applyAll();
|
this._applyAll();
|
||||||
} // intentionally discard Promise
|
} // intentionally discard Promise
|
||||||
@@ -164,6 +174,17 @@ const shareModal = {
|
|||||||
Modal.close(false);
|
Modal.close(false);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Apply-button state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
_hasPendingChanges() {
|
||||||
|
return this._localMembers.some((m) => m._op !== 'keep') || this._localLinks.some((e) => e._op !== 'keep') || this._newLinks.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
_syncApplyBtn() {
|
||||||
|
if (Modal.confirmBtn) Modal.confirmBtn.disabled = !this._hasPendingChanges();
|
||||||
|
},
|
||||||
|
|
||||||
// ── Skeleton ───────────────────────────────────────────────────────────────
|
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,9 +269,9 @@ const shareModal = {
|
|||||||
const roleSelect = document.createElement('select');
|
const roleSelect = document.createElement('select');
|
||||||
roleSelect.className = 'smd-role-select';
|
roleSelect.className = 'smd-role-select';
|
||||||
for (const [val, label] of [
|
for (const [val, label] of [
|
||||||
['viewer', i18n.t('share.role.viewer', 'Viewer')],
|
['viewer', i18n.t('share.role.canView', 'Can view')],
|
||||||
['editor', i18n.t('share.role.editor', 'Editor')],
|
['editor', i18n.t('share.role.canEdit', 'Can edit')],
|
||||||
['admin', i18n.t('share.role.admin', 'Admin')]
|
['admin', i18n.t('share.role.canManage', 'Can manage')]
|
||||||
]) {
|
]) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = val;
|
opt.value = val;
|
||||||
@@ -262,6 +283,11 @@ const shareModal = {
|
|||||||
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
|
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Expiry chip ──────────────────────────────────────────────────────
|
||||||
|
const expiryChip = this._buildExpiryChip(null, (v) => {
|
||||||
|
this._stagedExpiry = v;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Add button ───────────────────────────────────────────────────────
|
// ── Add button ───────────────────────────────────────────────────────
|
||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'smd-add-btn btn btn-secondary';
|
addBtn.className = 'smd-add-btn btn btn-secondary';
|
||||||
@@ -316,6 +342,7 @@ const shareModal = {
|
|||||||
|
|
||||||
row.appendChild(wrap);
|
row.appendChild(wrap);
|
||||||
row.appendChild(roleSelect);
|
row.appendChild(roleSelect);
|
||||||
|
row.appendChild(expiryChip);
|
||||||
row.appendChild(addBtn);
|
row.appendChild(addBtn);
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -419,7 +446,7 @@ const shareModal = {
|
|||||||
/** @type {Grant} */
|
/** @type {Grant} */
|
||||||
const placeholderGrant = {
|
const placeholderGrant = {
|
||||||
id: '', // not yet persisted
|
id: '', // not yet persisted
|
||||||
granted_at: 0,
|
granted_at: '',
|
||||||
granted_by: '',
|
granted_by: '',
|
||||||
subject: { type: 'user', id: contact.id },
|
subject: { type: 'user', id: contact.id },
|
||||||
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
||||||
@@ -429,7 +456,8 @@ const shareModal = {
|
|||||||
grant: placeholderGrant,
|
grant: placeholderGrant,
|
||||||
_grants: [], // no server grants yet — nothing to revoke on remove
|
_grants: [], // no server grants yet — nothing to revoke on remove
|
||||||
role: this._stagedRole,
|
role: this._stagedRole,
|
||||||
_op: 'new'
|
_op: 'new',
|
||||||
|
expires_at: this._stagedExpiry
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._stagedUsers = [];
|
this._stagedUsers = [];
|
||||||
@@ -450,6 +478,7 @@ const shareModal = {
|
|||||||
_refreshMemberGroups() {
|
_refreshMemberGroups() {
|
||||||
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
|
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
|
||||||
if (container) this._renderMemberGroupsInto(container);
|
if (container) this._renderMemberGroupsInto(container);
|
||||||
|
this._syncApplyBtn();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -471,9 +500,9 @@ const shareModal = {
|
|||||||
header.className = 'smd-group-header';
|
header.className = 'smd-group-header';
|
||||||
|
|
||||||
const labelMap = {
|
const labelMap = {
|
||||||
admin: i18n.t('share.role.admin', 'Admin'),
|
admin: i18n.t('share.role.canManage', 'Can manage'),
|
||||||
editor: i18n.t('share.role.editor', 'Editor'),
|
editor: i18n.t('share.role.canEdit', 'Can edit'),
|
||||||
viewer: i18n.t('share.role.viewer', 'Viewer')
|
viewer: i18n.t('share.role.canView', 'Can view')
|
||||||
};
|
};
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'smd-group-badge';
|
badge.className = 'smd-group-badge';
|
||||||
@@ -504,9 +533,9 @@ const shareModal = {
|
|||||||
const roleSelect = document.createElement('select');
|
const roleSelect = document.createElement('select');
|
||||||
roleSelect.className = 'smd-member-role-select';
|
roleSelect.className = 'smd-member-role-select';
|
||||||
for (const [val, label] of [
|
for (const [val, label] of [
|
||||||
['viewer', i18n.t('share.role.viewer', 'Viewer')],
|
['viewer', i18n.t('share.role.canView', 'Can view')],
|
||||||
['editor', i18n.t('share.role.editor', 'Editor')],
|
['editor', i18n.t('share.role.canEdit', 'Can edit')],
|
||||||
['admin', i18n.t('share.role.admin', 'Admin')]
|
['admin', i18n.t('share.role.canManage', 'Can manage')]
|
||||||
]) {
|
]) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = val;
|
opt.value = val;
|
||||||
@@ -521,6 +550,19 @@ const shareModal = {
|
|||||||
this._refreshMemberGroups();
|
this._refreshMemberGroups();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Expiry chip ──────────────────────────────────────────────────────
|
||||||
|
// Initialise entry.expires_at once from the representative grant so that
|
||||||
|
// role-only changes preserve the current expiry across row rebuilds.
|
||||||
|
if (!Object.hasOwn(entry, 'expires_at')) {
|
||||||
|
const raw = entry.grant.expires_at ?? null;
|
||||||
|
entry.expires_at = raw ? String(raw).slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
const expiryChip = this._buildExpiryChip(entry.expires_at, (v) => {
|
||||||
|
entry.expires_at = v;
|
||||||
|
if (entry._op !== 'new') entry._op = 'change';
|
||||||
|
this._syncApplyBtn();
|
||||||
|
});
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'smd-row-action';
|
removeBtn.className = 'smd-row-action';
|
||||||
removeBtn.title = i18n.t('actions.remove', 'Remove');
|
removeBtn.title = i18n.t('actions.remove', 'Remove');
|
||||||
@@ -532,10 +574,31 @@ const shareModal = {
|
|||||||
|
|
||||||
row.appendChild(vignette);
|
row.appendChild(vignette);
|
||||||
row.appendChild(roleSelect);
|
row.appendChild(roleSelect);
|
||||||
|
row.appendChild(expiryChip);
|
||||||
row.appendChild(removeBtn);
|
row.appendChild(removeBtn);
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Expiry chip toggle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 ──────────────────────────────────────────────────────────
|
// ── Links section ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,29 +613,72 @@ const shareModal = {
|
|||||||
title.textContent = i18n.t('share.publicLinks', 'Public links');
|
title.textContent = i18n.t('share.publicLinks', 'Public links');
|
||||||
section.appendChild(title);
|
section.appendChild(title);
|
||||||
|
|
||||||
|
section.appendChild(this._buildAddLinkRow());
|
||||||
|
|
||||||
const listEl = document.createElement('div');
|
const listEl = document.createElement('div');
|
||||||
listEl.id = 'smd-links-list';
|
listEl.id = 'smd-links-list';
|
||||||
this._renderLinksInto(listEl);
|
this._renderLinksInto(listEl);
|
||||||
section.appendChild(listEl);
|
section.appendChild(listEl);
|
||||||
|
|
||||||
const newLinkBtn = document.createElement('button');
|
return section;
|
||||||
newLinkBtn.className = 'smd-new-link-btn';
|
},
|
||||||
newLinkBtn.innerHTML = `<i class="fas fa-plus"></i> ${i18n.t('share.createLink', 'Create new public link')}`;
|
|
||||||
newLinkBtn.id = 'smd-new-link-btn';
|
|
||||||
|
|
||||||
const newLinkForm = document.createElement('div');
|
/**
|
||||||
newLinkForm.id = 'smd-new-link-form';
|
* Always-visible add-link row — mirrors the People search row layout.
|
||||||
newLinkForm.className = 'smd-new-link-form hidden';
|
* Rebuilds itself after each Add to reset chip state.
|
||||||
newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm));
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_buildAddLinkRow() {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'smd-search-row';
|
||||||
|
row.id = 'smd-add-link-row';
|
||||||
|
|
||||||
newLinkBtn.addEventListener('click', () => {
|
// Name input — wrapped in smd-search-wrap so it inherits flex:1
|
||||||
newLinkBtn.classList.add('hidden');
|
const wrap = document.createElement('div');
|
||||||
newLinkForm.classList.remove('hidden');
|
wrap.className = 'smd-search-wrap';
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.className = 'smd-search-input';
|
||||||
|
nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Link name (optional)');
|
||||||
|
wrap.appendChild(nameInput);
|
||||||
|
|
||||||
|
/** @type {string|null} */
|
||||||
|
let stagedPassword = null;
|
||||||
|
/** @type {string|null} */
|
||||||
|
let stagedExpiry = null;
|
||||||
|
|
||||||
|
const pwChip = this._buildPasswordChip(false, (v) => {
|
||||||
|
stagedPassword = v || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
section.appendChild(newLinkBtn);
|
const expChip = this._buildExpiryChip(null, (v) => {
|
||||||
section.appendChild(newLinkForm);
|
stagedExpiry = v;
|
||||||
return section;
|
});
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'smd-add-btn btn btn-secondary';
|
||||||
|
addBtn.textContent = i18n.t('actions.add', 'Add');
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
/** @type {DraftLink} */
|
||||||
|
const draft = {
|
||||||
|
name: nameInput.value.trim(),
|
||||||
|
password: stagedPassword,
|
||||||
|
expires_at: stagedExpiry
|
||||||
|
};
|
||||||
|
this._newLinks.push(draft);
|
||||||
|
this._refreshLinks();
|
||||||
|
// Reset row (also resets chips via closure state)
|
||||||
|
const fresh = this._buildAddLinkRow();
|
||||||
|
row.replaceWith(fresh);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(wrap);
|
||||||
|
row.appendChild(pwChip);
|
||||||
|
row.appendChild(expChip);
|
||||||
|
row.appendChild(addBtn);
|
||||||
|
|
||||||
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -595,6 +701,7 @@ const shareModal = {
|
|||||||
_refreshLinks() {
|
_refreshLinks() {
|
||||||
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
|
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
|
||||||
if (container) this._renderLinksInto(container);
|
if (container) this._renderLinksInto(container);
|
||||||
|
this._syncApplyBtn();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -603,87 +710,65 @@ const shareModal = {
|
|||||||
*/
|
*/
|
||||||
_buildLinkRow(entry) {
|
_buildLinkRow(entry) {
|
||||||
const share = entry.share;
|
const share = entry.share;
|
||||||
const draft = entry._op === 'edit' ? entry._draft : null;
|
|
||||||
|
|
||||||
// Display values: prefer draft overrides when in edit-pending state
|
const ensureDraft = () => {
|
||||||
const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link');
|
if (!entry._draft) {
|
||||||
const displayPw = draft ? draft.password !== null : share.has_password;
|
entry._draft = {
|
||||||
const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null;
|
name: share.item_name || '',
|
||||||
|
password: null,
|
||||||
|
expires_at: share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null
|
||||||
|
};
|
||||||
|
entry._op = 'edit';
|
||||||
|
this._syncApplyBtn();
|
||||||
|
}
|
||||||
|
return entry._draft;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive current display values from draft if present, otherwise from share
|
||||||
|
const currentHasPassword = entry._draft
|
||||||
|
? entry._draft.password === ''
|
||||||
|
? false
|
||||||
|
: entry._draft.password
|
||||||
|
? true
|
||||||
|
: share.has_password
|
||||||
|
: share.has_password;
|
||||||
|
const currentExpiry = entry._draft ? entry._draft.expires_at : share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null;
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'smd-link-row';
|
row.className = 'smd-link-row';
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.className = 'smd-link-icon';
|
|
||||||
icon.innerHTML = '<i class="fas fa-link"></i>';
|
|
||||||
|
|
||||||
const info = document.createElement('div');
|
|
||||||
info.className = 'smd-link-info';
|
|
||||||
|
|
||||||
const name = document.createElement('div');
|
const name = document.createElement('div');
|
||||||
name.className = 'smd-link-name';
|
name.className = 'smd-link-name';
|
||||||
name.textContent = displayName;
|
name.textContent = entry._draft?.name || share.item_name || i18n.t('share.sharedLink', 'Shared link');
|
||||||
|
|
||||||
const tags = document.createElement('div');
|
|
||||||
tags.className = 'smd-link-tags';
|
|
||||||
if (displayPw) {
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.className = 'smd-link-tag';
|
|
||||||
t.innerHTML = `<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
if (displayExp) {
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.className = 'smd-link-tag';
|
|
||||||
t.innerHTML = `<i class="fas fa-clock"></i> ${displayExp}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
info.appendChild(name);
|
|
||||||
if (tags.children.length) info.appendChild(tags);
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'smd-link-actions';
|
|
||||||
|
|
||||||
// Copy
|
|
||||||
const copyBtn = document.createElement('button');
|
const copyBtn = document.createElement('button');
|
||||||
copyBtn.className = 'smd-row-action';
|
copyBtn.className = 'smd-row-action';
|
||||||
copyBtn.title = i18n.t('actions.copy', 'Copy');
|
copyBtn.title = i18n.t('actions.copy', 'Copy link');
|
||||||
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||||
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
|
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
|
||||||
|
|
||||||
// Edit
|
const pwChip = this._buildPasswordChip(currentHasPassword, (v) => {
|
||||||
const editBtn = document.createElement('button');
|
ensureDraft().password = v;
|
||||||
editBtn.className = 'smd-row-action';
|
});
|
||||||
editBtn.title = i18n.t('actions.edit', 'Edit');
|
|
||||||
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
const expChip = this._buildExpiryChip(currentExpiry, (v) => {
|
||||||
editBtn.addEventListener('click', () => {
|
ensureDraft().expires_at = v;
|
||||||
const panel = row.nextElementSibling;
|
|
||||||
if (panel?.classList.contains('smd-edit-panel')) {
|
|
||||||
panel.classList.toggle('hidden');
|
|
||||||
} else {
|
|
||||||
const editPanel = this._buildEditPanel(entry, row);
|
|
||||||
row.after(editPanel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'smd-row-action';
|
delBtn.className = 'smd-row-action';
|
||||||
delBtn.title = i18n.t('actions.delete', 'Delete');
|
delBtn.title = i18n.t('actions.delete', 'Delete');
|
||||||
delBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
delBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
delBtn.addEventListener('click', () => {
|
delBtn.addEventListener('click', () => {
|
||||||
entry._op = 'remove';
|
entry._op = 'remove';
|
||||||
this._refreshLinks();
|
this._refreshLinks();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.appendChild(copyBtn);
|
row.appendChild(name);
|
||||||
actions.appendChild(editBtn);
|
row.appendChild(copyBtn);
|
||||||
actions.appendChild(delBtn);
|
row.appendChild(pwChip);
|
||||||
|
row.appendChild(expChip);
|
||||||
row.appendChild(icon);
|
row.appendChild(delBtn);
|
||||||
row.appendChild(info);
|
|
||||||
row.appendChild(actions);
|
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -695,42 +780,21 @@ const shareModal = {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'smd-link-row';
|
row.className = 'smd-link-row';
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.className = 'smd-link-icon';
|
|
||||||
icon.innerHTML = '<i class="fas fa-link"></i>';
|
|
||||||
|
|
||||||
const info = document.createElement('div');
|
|
||||||
info.className = 'smd-link-info';
|
|
||||||
|
|
||||||
const name = document.createElement('div');
|
const name = document.createElement('div');
|
||||||
name.className = 'smd-link-name';
|
name.className = 'smd-link-name';
|
||||||
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
|
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
|
||||||
|
|
||||||
const tags = document.createElement('div');
|
|
||||||
tags.className = 'smd-link-tags';
|
|
||||||
if (draft.password) {
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.className = 'smd-link-tag';
|
|
||||||
t.innerHTML = `<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
if (draft.expires_at) {
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.className = 'smd-link-tag';
|
|
||||||
t.innerHTML = `<i class="fas fa-clock"></i> ${draft.expires_at}`;
|
|
||||||
tags.appendChild(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = document.createElement('span');
|
const pending = document.createElement('span');
|
||||||
pending.className = 'smd-link-tag';
|
pending.className = 'smd-link-tag';
|
||||||
pending.textContent = i18n.t('share.pending', 'Pending');
|
pending.textContent = i18n.t('share.pending', 'Pending');
|
||||||
tags.appendChild(pending);
|
|
||||||
|
|
||||||
info.appendChild(name);
|
const pwChip = this._buildPasswordChip(!!draft.password, (v) => {
|
||||||
if (tags.children.length) info.appendChild(tags);
|
draft.password = v || null;
|
||||||
|
});
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const expChip = this._buildExpiryChip(draft.expires_at, (v) => {
|
||||||
actions.className = 'smd-link-actions';
|
draft.expires_at = v;
|
||||||
|
});
|
||||||
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'smd-row-action';
|
delBtn.className = 'smd-row-action';
|
||||||
@@ -741,158 +805,14 @@ const shareModal = {
|
|||||||
this._refreshLinks();
|
this._refreshLinks();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.appendChild(delBtn);
|
row.appendChild(name);
|
||||||
row.appendChild(icon);
|
row.appendChild(pending);
|
||||||
row.appendChild(info);
|
row.appendChild(pwChip);
|
||||||
row.appendChild(actions);
|
row.appendChild(expChip);
|
||||||
|
row.appendChild(delBtn);
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {LinkEntry} entry
|
|
||||||
* @param {HTMLElement} row
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
_buildEditPanel(entry, row) {
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.className = 'smd-edit-panel';
|
|
||||||
|
|
||||||
const pwLabel = document.createElement('label');
|
|
||||||
pwLabel.textContent = i18n.t('dialogs.password', 'Password');
|
|
||||||
const pwInput = document.createElement('input');
|
|
||||||
pwInput.type = 'password';
|
|
||||||
pwInput.className = 'smd-edit-input';
|
|
||||||
pwInput.placeholder = i18n.t('share.passwordPlaceholder', 'Leave empty to keep unchanged');
|
|
||||||
|
|
||||||
const expLabel = document.createElement('label');
|
|
||||||
expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
|
|
||||||
const expInput = document.createElement('input');
|
|
||||||
expInput.type = 'date';
|
|
||||||
expInput.className = 'smd-edit-input';
|
|
||||||
if (entry.share.expires_at) {
|
|
||||||
expInput.value = new Date(entry.share.expires_at * 1000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionsDiv = document.createElement('div');
|
|
||||||
actionsDiv.className = 'smd-edit-panel-actions';
|
|
||||||
|
|
||||||
const cancelBtn = document.createElement('button');
|
|
||||||
cancelBtn.className = 'btn btn-secondary';
|
|
||||||
cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
|
|
||||||
cancelBtn.addEventListener('click', () => panel.remove());
|
|
||||||
|
|
||||||
const saveBtn = document.createElement('button');
|
|
||||||
saveBtn.className = 'btn btn-primary';
|
|
||||||
saveBtn.textContent = i18n.t('actions.save', 'Save');
|
|
||||||
saveBtn.addEventListener('click', () => {
|
|
||||||
entry._op = 'edit';
|
|
||||||
entry._draft = {
|
|
||||||
name: entry.share.item_name || '',
|
|
||||||
password: pwInput.value || null,
|
|
||||||
expires_at: expInput.value || null
|
|
||||||
};
|
|
||||||
panel.remove();
|
|
||||||
this._refreshLinks();
|
|
||||||
});
|
|
||||||
|
|
||||||
actionsDiv.appendChild(cancelBtn);
|
|
||||||
actionsDiv.appendChild(saveBtn);
|
|
||||||
|
|
||||||
panel.appendChild(pwLabel);
|
|
||||||
panel.appendChild(pwInput);
|
|
||||||
panel.appendChild(expLabel);
|
|
||||||
panel.appendChild(expInput);
|
|
||||||
panel.appendChild(actionsDiv);
|
|
||||||
|
|
||||||
void row; // row is unused — panel is inserted via row.after() in caller
|
|
||||||
return panel;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {HTMLButtonElement} newLinkBtn
|
|
||||||
* @param {HTMLElement} formWrapper
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
_buildNewLinkForm(newLinkBtn, formWrapper) {
|
|
||||||
const inner = document.createElement('div');
|
|
||||||
|
|
||||||
const nameLabel = document.createElement('label');
|
|
||||||
nameLabel.textContent = i18n.t('share.linkName', 'Link name');
|
|
||||||
const nameInput = document.createElement('input');
|
|
||||||
nameInput.type = 'text';
|
|
||||||
nameInput.className = 'smd-edit-input';
|
|
||||||
nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Optional name');
|
|
||||||
|
|
||||||
const pwToggleLabel = document.createElement('label');
|
|
||||||
pwToggleLabel.className = 'smd-pw-toggle';
|
|
||||||
const pwCheckbox = document.createElement('input');
|
|
||||||
pwCheckbox.type = 'checkbox';
|
|
||||||
pwToggleLabel.appendChild(pwCheckbox);
|
|
||||||
pwToggleLabel.appendChild(document.createTextNode(` ${i18n.t('share.addPassword', 'Add password')}`));
|
|
||||||
|
|
||||||
const pwInput = document.createElement('input');
|
|
||||||
pwInput.type = 'password';
|
|
||||||
pwInput.className = 'smd-edit-input hidden';
|
|
||||||
pwInput.placeholder = i18n.t('dialogs.password', 'Password');
|
|
||||||
pwCheckbox.addEventListener('change', () => {
|
|
||||||
pwInput.classList.toggle('hidden', !pwCheckbox.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
const expLabel = document.createElement('label');
|
|
||||||
expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
|
|
||||||
const expInput = document.createElement('input');
|
|
||||||
expInput.type = 'date';
|
|
||||||
expInput.className = 'smd-edit-input';
|
|
||||||
|
|
||||||
const actionsDiv = document.createElement('div');
|
|
||||||
actionsDiv.className = 'smd-new-link-form-actions';
|
|
||||||
|
|
||||||
const cancelBtn = document.createElement('button');
|
|
||||||
cancelBtn.className = 'btn btn-secondary';
|
|
||||||
cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
|
|
||||||
cancelBtn.addEventListener('click', () => {
|
|
||||||
formWrapper.classList.add('hidden');
|
|
||||||
newLinkBtn.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
const addBtn = document.createElement('button');
|
|
||||||
addBtn.className = 'btn btn-primary';
|
|
||||||
addBtn.textContent = i18n.t('share.addLink', 'Add link');
|
|
||||||
addBtn.addEventListener('click', () => {
|
|
||||||
/** @type {DraftLink} */
|
|
||||||
const draft = {
|
|
||||||
name: nameInput.value.trim(),
|
|
||||||
password: pwCheckbox.checked ? pwInput.value || null : null,
|
|
||||||
expires_at: expInput.value || null
|
|
||||||
};
|
|
||||||
this._newLinks.push(draft);
|
|
||||||
this._refreshLinks();
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
nameInput.value = '';
|
|
||||||
pwCheckbox.checked = false;
|
|
||||||
pwInput.value = '';
|
|
||||||
pwInput.classList.add('hidden');
|
|
||||||
expInput.value = '';
|
|
||||||
|
|
||||||
formWrapper.classList.add('hidden');
|
|
||||||
newLinkBtn.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
actionsDiv.appendChild(cancelBtn);
|
|
||||||
actionsDiv.appendChild(addBtn);
|
|
||||||
|
|
||||||
inner.appendChild(nameLabel);
|
|
||||||
inner.appendChild(nameInput);
|
|
||||||
inner.appendChild(pwToggleLabel);
|
|
||||||
inner.appendChild(pwInput);
|
|
||||||
inner.appendChild(expLabel);
|
|
||||||
inner.appendChild(expInput);
|
|
||||||
inner.appendChild(actionsDiv);
|
|
||||||
|
|
||||||
return inner;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Apply ──────────────────────────────────────────────────────────────────
|
// ── Apply ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -911,6 +831,8 @@ const shareModal = {
|
|||||||
try {
|
try {
|
||||||
// ── Grants ─────────────────────────────────────────────────────────
|
// ── Grants ─────────────────────────────────────────────────────────
|
||||||
for (const m of this._localMembers) {
|
for (const m of this._localMembers) {
|
||||||
|
// Convert YYYY-MM-DD from date input to ISO-8601 datetime (midnight UTC).
|
||||||
|
const expiresIso = m.expires_at ? new Date(`${m.expires_at}T00:00:00Z`).toISOString() : null;
|
||||||
if (m._op === 'remove') {
|
if (m._op === 'remove') {
|
||||||
// Revoke every individual grant for this subject (one per permission).
|
// Revoke every individual grant for this subject (one per permission).
|
||||||
for (const g of m._grants) {
|
for (const g of m._grants) {
|
||||||
@@ -920,13 +842,15 @@ const shareModal = {
|
|||||||
await grants.updateRole({
|
await grants.updateRole({
|
||||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||||
resource: { type: itemType, id: item.id },
|
resource: { type: itemType, id: item.id },
|
||||||
role: m.role
|
role: m.role,
|
||||||
|
expires_at: expiresIso
|
||||||
});
|
});
|
||||||
} else if (m._op === 'new') {
|
} else if (m._op === 'new') {
|
||||||
await grants.createGrant({
|
await grants.createGrant({
|
||||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||||
resource: { type: itemType, id: item.id },
|
resource: { type: itemType, id: item.id },
|
||||||
role: m.role
|
role: m.role,
|
||||||
|
expires_at: expiresIso
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -939,8 +863,7 @@ const shareModal = {
|
|||||||
const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null;
|
const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null;
|
||||||
await fileSharing.updateSharedLink(e.share.id, {
|
await fileSharing.updateSharedLink(e.share.id, {
|
||||||
password: e._draft.password,
|
password: e._draft.password,
|
||||||
expires_at: expiresTs,
|
expires_at: expiresTs
|
||||||
permissions: null
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,6 +893,7 @@ const shareModal = {
|
|||||||
ui.setSharedVisualState(item.id, itemType, hasAnyShare);
|
ui.setSharedVisualState(item.id, itemType, hasAnyShare);
|
||||||
|
|
||||||
Modal.close(true);
|
Modal.close(true);
|
||||||
|
this._onApplied?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('shareModal._applyAll error:', err);
|
console.error('shareModal._applyAll error:', err);
|
||||||
if (Modal.confirmBtn) Modal.confirmBtn.disabled = false;
|
if (Modal.confirmBtn) Modal.confirmBtn.disabled = false;
|
||||||
|
|||||||
@@ -138,6 +138,36 @@ function normalizeDateBucket(value) {
|
|||||||
return String(date.getFullYear());
|
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.
|
* 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');
|
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
|
||||||
|
};
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ const OxiIcons = {
|
|||||||
576,
|
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'
|
'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': [
|
'info-circle': [
|
||||||
512,
|
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'
|
'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
@@ -46,13 +46,6 @@
|
|||||||
* @property {number} sort_date
|
* @property {number} sort_date
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} SharePermissions
|
|
||||||
* @property {boolean} read
|
|
||||||
* @property {boolean} reshare
|
|
||||||
* @property {boolean} write
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ShareItem
|
* @typedef {Object} ShareItem
|
||||||
* @property {number} access_count
|
* @property {number} access_count
|
||||||
@@ -64,7 +57,6 @@
|
|||||||
* @property {string} item_id
|
* @property {string} item_id
|
||||||
* @property {string} item_name
|
* @property {string} item_name
|
||||||
* @property {ItemTypeEnum} item_type
|
* @property {ItemTypeEnum} item_type
|
||||||
* @property {SharePermissions} permissions
|
|
||||||
* @property {string | null} token
|
* @property {string | null} token
|
||||||
* @property {string} url
|
* @property {string} url
|
||||||
*/
|
*/
|
||||||
@@ -76,14 +68,12 @@
|
|||||||
* @property {ItemTypeEnum} item_type
|
* @property {ItemTypeEnum} item_type
|
||||||
* @property {string|null} password
|
* @property {string|null} password
|
||||||
* @property {number|null} expires_at - timestamp
|
* @property {number|null} expires_at - timestamp
|
||||||
* @property {SharePermissions|null} permissions
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} UpdateShare
|
* @typedef {Object} UpdateShare
|
||||||
* @property {string|null} password
|
* @property {string|null} [password]
|
||||||
* @property {number|null} expires_at - timestamp
|
* @property {number|null} [expires_at]
|
||||||
* @property {SharePermissions|null} permissions
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,11 +274,12 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {Object} Grant
|
* @typedef {Object} Grant
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
* @property {number} granted_at
|
* @property {string} granted_at - ISO-8601 datetime string.
|
||||||
* @property {string} granted_by
|
* @property {string} granted_by
|
||||||
* @property {Subject} subject
|
* @property {Subject} subject
|
||||||
* @property {PermissionTypeEnum} permission
|
* @property {PermissionTypeEnum} permission
|
||||||
* @property {Resource} resource
|
* @property {Resource} resource
|
||||||
|
* @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,6 +324,35 @@
|
|||||||
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
* @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`.
|
* One item returned by `GET /api/favorites/resources`.
|
||||||
* `resource_type` discriminates the shape of `resource`.
|
* `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 {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
|
||||||
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
||||||
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
|
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
|
||||||
|
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -489,24 +489,20 @@ const contextMenus = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the contents endpoint to get children
|
/** @type {FolderItem[]} */
|
||||||
const url = `/api/folders/${effectiveParentId}/contents`;
|
const folders = [];
|
||||||
|
let cursor = /** @type {string|null} */ (null);
|
||||||
console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId);
|
do {
|
||||||
const response = await fetch(url, { credentials: 'same-origin' });
|
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) {
|
if (!response.ok) {
|
||||||
console.error('Failed to load folders:', response.status);
|
console.error('Failed to load folders:', response.status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('[Move Dialog] API response:', data);
|
folders.push(...(data.items || []));
|
||||||
|
cursor = data.next_cursor ?? null;
|
||||||
// The contents endpoint returns an array of child folders
|
} while (cursor);
|
||||||
// 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 folderSelectContainer = document.getElementById('folder-select-container');
|
const folderSelectContainer = document.getElementById('folder-select-container');
|
||||||
const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb');
|
const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb');
|
||||||
@@ -719,7 +715,6 @@ const contextMenus = {
|
|||||||
app.moveDialogBreadcrumb = [];
|
app.moveDialogBreadcrumb = [];
|
||||||
app.moveDialogCurrentFolderId = app.userHomeFolderId || null;
|
app.moveDialogCurrentFolderId = app.userHomeFolderId || null;
|
||||||
|
|
||||||
// Use loadMoveDialogFolders which uses /api/folders/{id}/contents
|
|
||||||
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
|
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,7 @@ const fileSharing = {
|
|||||||
item_name: options.item_name || null,
|
item_name: options.item_name || null,
|
||||||
item_type: itemType,
|
item_type: itemType,
|
||||||
password: options.password || null,
|
password: options.password || null,
|
||||||
expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : 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
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch('/api/shares', {
|
const res = await fetch('/api/shares', {
|
||||||
@@ -113,12 +108,11 @@ const fileSharing = {
|
|||||||
/**
|
/**
|
||||||
* Update a shared link
|
* Update a shared link
|
||||||
* @param {string} shareId
|
* @param {string} shareId
|
||||||
* @param {UpdateShare} updateData - { permissions, password, expires_at }
|
* @param {UpdateShare} updateData - { password, expires_at }
|
||||||
* @returns {Promise<Object>} Updated ShareDto
|
* @returns {Promise<Object>} Updated ShareDto
|
||||||
*/
|
*/
|
||||||
async updateSharedLink(shareId, updateData) {
|
async updateSharedLink(shareId, updateData) {
|
||||||
const body = {};
|
const body = {};
|
||||||
if (updateData.permissions) body.permissions = updateData.permissions;
|
|
||||||
if (updateData.password !== undefined) body.password = updateData.password;
|
if (updateData.password !== undefined) body.password = updateData.password;
|
||||||
if (updateData.expires_at !== undefined) body.expires_at = updateData.expires_at;
|
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
|
* Copy a shared link to clipboard
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
|
|||||||
@@ -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';
|
import { getCsrfHeaders } from '../core/csrf.js';
|
||||||
@@ -110,6 +110,32 @@ const grants = {
|
|||||||
return response.json();
|
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).
|
* Fetch all grants on a specific resource (for the "Manage sharing" panel).
|
||||||
* Refreshes the outgoingGrants cache for this resource.
|
* Refreshes the outgoingGrants cache for this resource.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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">×</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">×</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 };
|
|
||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "تاريخ التعديل",
|
"modifiedAt": "تاريخ التعديل",
|
||||||
"createdAt": "تاريخ الإنشاء",
|
"createdAt": "تاريخ الإنشاء",
|
||||||
"size": "الحجم",
|
"size": "الحجم",
|
||||||
"favoriteDate": "تاريخ المفضلة"
|
"favoriteDate": "تاريخ المفضلة",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "اليوم",
|
"today": "اليوم",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Änderungsdatum",
|
"modifiedAt": "Änderungsdatum",
|
||||||
"createdAt": "Erstellungsdatum",
|
"createdAt": "Erstellungsdatum",
|
||||||
"size": "Größe",
|
"size": "Größe",
|
||||||
"favoriteDate": "Datum der Markierung"
|
"favoriteDate": "Datum der Markierung",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Heute",
|
"today": "Heute",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
},
|
},
|
||||||
"groupby": {
|
"groupby": {
|
||||||
"none": "None",
|
"none": "None",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with",
|
||||||
"title": "Group by",
|
"title": "Group by",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"type.folders": "Folders",
|
"type.folders": "Folders",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Fecha de modificación",
|
"modifiedAt": "Fecha de modificación",
|
||||||
"createdAt": "Fecha de creación",
|
"createdAt": "Fecha de creación",
|
||||||
"size": "Tamaño",
|
"size": "Tamaño",
|
||||||
"favoriteDate": "Fecha de favorito"
|
"favoriteDate": "Fecha de favorito",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "تاریخ تغییر",
|
"modifiedAt": "تاریخ تغییر",
|
||||||
"createdAt": "تاریخ ایجاد",
|
"createdAt": "تاریخ ایجاد",
|
||||||
"size": "اندازه",
|
"size": "اندازه",
|
||||||
"favoriteDate": "تاریخ مورد علاقه"
|
"favoriteDate": "تاریخ مورد علاقه",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "امروز",
|
"today": "امروز",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"accessedAt": "Date d'accès",
|
"accessedAt": "Date d'accès",
|
||||||
"modifiedAt": "Date de modification",
|
"modifiedAt": "Date de modification",
|
||||||
"createdAt": "Date de création",
|
"createdAt": "Date de création",
|
||||||
"size": "Taille"
|
"size": "Taille",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "संशोधन की तारीख",
|
"modifiedAt": "संशोधन की तारीख",
|
||||||
"createdAt": "बनाने की तारीख",
|
"createdAt": "बनाने की तारीख",
|
||||||
"size": "आकार",
|
"size": "आकार",
|
||||||
"favoriteDate": "पसंदीदा की तारीख"
|
"favoriteDate": "पसंदीदा की तारीख",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "आज",
|
"today": "आज",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Data di modifica",
|
"modifiedAt": "Data di modifica",
|
||||||
"createdAt": "Data di creazione",
|
"createdAt": "Data di creazione",
|
||||||
"size": "Dimensione",
|
"size": "Dimensione",
|
||||||
"favoriteDate": "Data preferito"
|
"favoriteDate": "Data preferito",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Oggi",
|
"today": "Oggi",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "更新日",
|
"modifiedAt": "更新日",
|
||||||
"createdAt": "作成日",
|
"createdAt": "作成日",
|
||||||
"size": "サイズ",
|
"size": "サイズ",
|
||||||
"favoriteDate": "お気に入り登録日"
|
"favoriteDate": "お気に入り登録日",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "수정 날짜",
|
"modifiedAt": "수정 날짜",
|
||||||
"createdAt": "생성 날짜",
|
"createdAt": "생성 날짜",
|
||||||
"size": "크기",
|
"size": "크기",
|
||||||
"favoriteDate": "즐겨찾기 날짜"
|
"favoriteDate": "즐겨찾기 날짜",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "오늘",
|
"today": "오늘",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Wijzigingsdatum",
|
"modifiedAt": "Wijzigingsdatum",
|
||||||
"createdAt": "Aanmaakdatum",
|
"createdAt": "Aanmaakdatum",
|
||||||
"size": "Grootte",
|
"size": "Grootte",
|
||||||
"favoriteDate": "Favoritendatum"
|
"favoriteDate": "Favoritendatum",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Vandaag",
|
"today": "Vandaag",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Data modyfikacji",
|
"modifiedAt": "Data modyfikacji",
|
||||||
"createdAt": "Data utworzenia",
|
"createdAt": "Data utworzenia",
|
||||||
"size": "Rozmiar",
|
"size": "Rozmiar",
|
||||||
"favoriteDate": "Data dodania do ulubionych"
|
"favoriteDate": "Data dodania do ulubionych",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Dzisiaj",
|
"today": "Dzisiaj",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Data de modificação",
|
"modifiedAt": "Data de modificação",
|
||||||
"createdAt": "Data de criação",
|
"createdAt": "Data de criação",
|
||||||
"size": "Tamanho",
|
"size": "Tamanho",
|
||||||
"favoriteDate": "Data de favorito"
|
"favoriteDate": "Data de favorito",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "Дата изменения",
|
"modifiedAt": "Дата изменения",
|
||||||
"createdAt": "Дата создания",
|
"createdAt": "Дата создания",
|
||||||
"size": "Размер",
|
"size": "Размер",
|
||||||
"favoriteDate": "Дата добавления в избранное"
|
"favoriteDate": "Дата добавления в избранное",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Сегодня",
|
"today": "Сегодня",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "修改日期",
|
"modifiedAt": "修改日期",
|
||||||
"createdAt": "建立日期",
|
"createdAt": "建立日期",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"favoriteDate": "收藏日期"
|
"favoriteDate": "收藏日期",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
|
|||||||
@@ -723,7 +723,9 @@
|
|||||||
"modifiedAt": "修改日期",
|
"modifiedAt": "修改日期",
|
||||||
"createdAt": "创建日期",
|
"createdAt": "创建日期",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"favoriteDate": "收藏日期"
|
"favoriteDate": "收藏日期",
|
||||||
|
"byFiles": "By files",
|
||||||
|
"sharedWith": "Shared with"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// OxiCloud Service Worker
|
// OxiCloud Service Worker
|
||||||
// FIXME: generate cache name according build ?
|
// 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.
|
// Only cache static assets — NOT HTML files.
|
||||||
// HTML files are served network-first so browsers always get the latest
|
// HTML files are served network-first so browsers always get the latest
|
||||||
|
|||||||
+19
-19
@@ -30,13 +30,13 @@ jsonpath "$.access_token" isString
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 2 – No favorites yet
|
# Step 2 – No favorites yet
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/favorites
|
GET {{base_url}}/api/favorites/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" isCollection
|
jsonpath "$.items" isCollection
|
||||||
jsonpath "$" count == 0
|
jsonpath "$.items" count == 0
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -85,15 +85,15 @@ HTTP 201
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 5 – Favorites contains only hello-renamed.txt
|
# Step 5 – Favorites contains only hello-renamed.txt
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/favorites
|
GET {{base_url}}/api/favorites/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 1
|
jsonpath "$.items" count == 1
|
||||||
jsonpath "$[0].item_id" == {{file_id}}
|
jsonpath "$.items[0].resource.id" == {{file_id}}
|
||||||
jsonpath "$[0].item_type" == "file"
|
jsonpath "$.items[0].resource_type" == "file"
|
||||||
jsonpath "$[0].item_name" == "hello-renamed.txt"
|
jsonpath "$.items[0].resource.name" == "hello-renamed.txt"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -108,14 +108,14 @@ HTTP 201
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 7 – Favorites contains both items (order-independent)
|
# Step 7 – Favorites contains both items (order-independent)
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/favorites
|
GET {{base_url}}/api/favorites/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 2
|
jsonpath "$.items" count == 2
|
||||||
jsonpath "$[*].item_id" contains {{file_id}}
|
jsonpath "$.items[*].resource.id" contains {{file_id}}
|
||||||
jsonpath "$[*].item_id" contains {{test1_id}}
|
jsonpath "$.items[*].resource.id" contains {{test1_id}}
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -130,15 +130,15 @@ HTTP 200
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 9 – Favorites contains only test1 folder
|
# Step 9 – Favorites contains only test1 folder
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/favorites
|
GET {{base_url}}/api/favorites/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 1
|
jsonpath "$.items" count == 1
|
||||||
jsonpath "$[0].item_id" == {{test1_id}}
|
jsonpath "$.items[0].resource.id" == {{test1_id}}
|
||||||
jsonpath "$[0].item_type" == "folder"
|
jsonpath "$.items[0].resource_type" == "folder"
|
||||||
jsonpath "$[0].item_name" == "test1"
|
jsonpath "$.items[0].resource.name" == "test1"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -153,9 +153,9 @@ HTTP 200
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 11 – Favorites is empty again
|
# Step 11 – Favorites is empty again
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/favorites
|
GET {{base_url}}/api/favorites/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 0
|
jsonpath "$.items" count == 0
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ Authorization: Bearer {{adam_token}}
|
|||||||
|
|
||||||
HTTP 404
|
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}}
|
Authorization: Bearer {{adam_token}}
|
||||||
|
|
||||||
HTTP 404
|
HTTP 404
|
||||||
@@ -514,7 +514,7 @@ HTTP 200
|
|||||||
jsonpath "$" count == 1
|
jsonpath "$" count == 1
|
||||||
jsonpath "$[0].id" == "{{perm_child_id}}"
|
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}}
|
Authorization: Bearer {{adam_token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ HTTP 200
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 4 – Recent list contains hello-renamed.txt
|
# Step 4 – Recent list contains hello-renamed.txt
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/recent
|
GET {{base_url}}/api/recent/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 1
|
jsonpath "$.items" count == 1
|
||||||
jsonpath "$[0].item_type" == "file"
|
jsonpath "$.items[0].resource_type" == "file"
|
||||||
jsonpath "$[0].item_name" == "hello-renamed.txt"
|
jsonpath "$.items[0].resource.name" == "hello-renamed.txt"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -93,10 +93,10 @@ HTTP 200
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 6 – Recent list is empty after clear
|
# Step 6 – Recent list is empty after clear
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/recent
|
GET {{base_url}}/api/recent/resources
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" isCollection
|
jsonpath "$.items" isCollection
|
||||||
jsonpath "$" count == 0
|
jsonpath "$.items" count == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user