diff --git a/justfile b/justfile index 10a8528a..9e6a65c4 100644 --- a/justfile +++ b/justfile @@ -54,6 +54,9 @@ db-down: front-dev: PROFILE=dev cargo run +# front: check all (linter, format, type, ...) +front-check: front-fmt front-lint front-type front-rules + front-fmt: biome format static/ diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 545cef0a..9a6f4234 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -51,6 +51,11 @@ pub struct FavoriteItemDto { #[serde(skip_serializing_if = "Option::is_none")] pub item_path: Option, + /// UUID of the file/folder's actual owner (may differ from `user_id` when + /// the item was shared and then favourited by another user). + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: Option, + // ── Pre-computed display fields ── /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") pub icon_class: String, diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 6676ea34..bc6af74c 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -119,6 +119,21 @@ impl From for File { } impl FileDto { + /// Returns a copy of this DTO with the `path` field cleared. + /// + /// Used when a file is returned to a share recipient: `path` reveals the + /// full folder hierarchy above the file which the recipient may not have + /// access to. `folder_id` and `owner_id` are intentionally kept — the + /// former is needed for sub-folder navigation (covered by the cascade + /// grant), and the latter is harmless metadata. + #[must_use] + pub fn without_hierarchy_info(self) -> Self { + Self { + path: String::new(), + ..self + } + } + /// Creates an empty file DTO for stub implementations pub fn empty() -> Self { Self { diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index cedf2289..098f1739 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -107,6 +107,21 @@ impl From for Folder { } impl FolderDto { + /// Returns a copy of this DTO with the `path` field cleared. + /// + /// Used when a folder is returned to a share recipient: `path` reveals the + /// full folder hierarchy above the shared folder which the recipient may + /// not have access to. `parent_id` and `owner_id` are intentionally kept + /// — the former is needed for sub-folder navigation (covered by the + /// cascade grant), and the latter is harmless metadata. + #[must_use] + pub fn without_hierarchy_info(self) -> Self { + Self { + path: String::new(), + ..self + } + } + /// Creates an empty folder DTO for stub implementations pub fn empty() -> Self { Self { diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index d45f01ae..23553a0c 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -5,9 +5,11 @@ //! storage-agnostic and DTOs can evolve with the HTTP contract. use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; // ════════════════════════════════════════════════════════════════════════════ @@ -131,9 +133,9 @@ impl From for PermissionDto { #[serde(rename_all = "lowercase")] pub enum Role { Viewer, - Commenter, + //Commenter, Editor, - Manager, + //Manager, Admin, } @@ -144,13 +146,16 @@ impl Role { pub fn expand(self) -> &'static [Permission] { match self { Role::Viewer => &[Permission::Read], + /* reserved for future Role::Commenter => &[Permission::Read, Permission::Comment], + */ Role::Editor => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, ], + /* reserved for future Role::Manager => &[ Permission::Read, Permission::Comment, @@ -158,6 +163,7 @@ impl Role { Permission::Update, Permission::Share, ], + */ Role::Admin => &[ Permission::Read, Permission::Comment, @@ -220,3 +226,52 @@ impl From for GrantDto { } } } + +// ════════════════════════════════════════════════════════════════════════════ +// Shared-with-me DTOs (GET /api/grants/incoming/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Query parameters for `GET /api/grants/incoming/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct SharedWithMeQuery { + /// Maximum number of items to return (1–200, default 50). + #[serde(default = "shared_with_me_default_limit")] + pub limit: u32, + /// Comma-separated resource types to include, e.g. `file,folder`. + /// Omit to return all known types. + pub resource_types: Option, + /// Opaque cursor returned by a previous call. Omit to start from the + /// most-recently-granted item. + pub cursor: Option, +} + +fn shared_with_me_default_limit() -> u32 { + 50 +} + +/// One item in the shared-with-me list. Exactly one of `file` / `folder` is +/// populated, indicated by `resource_type`. Additional optional fields for +/// future resource types (playlist, addressbook, …) will be added here. +#[derive(Debug, Serialize, ToSchema)] +pub struct SharedWithMeItemDto { + pub resource_type: ResourceTypeDto, + /// All permissions the caller holds on this resource (aggregated). + pub permissions: Vec, + /// Earliest grant date for this resource. + pub granted_at: chrono::DateTime, + /// UUID of the user who created the (earliest) grant. + pub granted_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub folder: Option, +} + +/// Response for `GET /api/grants/incoming/resources`. +#[derive(Debug, Serialize, ToSchema)] +pub struct SharedWithMeDto { + pub items: Vec, + /// Opaque cursor for the next page. Absent when the last page is reached. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 941d4bd5..2e46dddb 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -11,7 +11,9 @@ use uuid::Uuid; use crate::common::errors::DomainError; -use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; +use crate::domain::services::authorization::{ + Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, +}; pub trait AuthorizationEngine: Send + Sync + 'static { /// Returns true if `subject` has `permission` on `resource`, considering @@ -67,6 +69,24 @@ pub trait AuthorizationEngine: Send + Sync + 'static { permission_filter: Option, ) -> Result, DomainError>; + /// Cursor-paginated list of resources explicitly granted to `subject`, + /// optionally filtered by resource kind. Multiple permission rows for the + /// same resource are collapsed into one `IncomingGrantSummary`. + /// + /// Ordered by `MIN(granted_at) DESC, resource_id DESC` — stable across + /// concurrent inserts because the cursor encodes both fields. + /// + /// Pass `kinds = &[]` to return all resource kinds. + /// Returns `(summaries, next_cursor)` — `next_cursor` is `None` when the + /// last page has been reached. + async fn list_incoming_resources_paged( + &self, + subject: Subject, + kinds: &[ResourceKind], + limit: u32, + cursor: Option, + ) -> Result<(Vec, Option), DomainError>; + /// All grants on a specific resource (for "Manage sharing" UI). Caller /// must verify the caller has `Share` on the resource before invoking. async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError>; diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 8217e4e4..26a41c7c 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -5,10 +5,12 @@ use tokio::sync::Semaphore; use uuid::Uuid; use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::{ application::{ dtos::{ @@ -17,6 +19,7 @@ use crate::{ }, ports::{ auth_ports::PasswordHasherPort, + authorization_ports::AuthorizationEngine, share_ports::{ShareStoragePort, ShareUseCase}, storage_ports::FileReadPort, }, @@ -78,6 +81,9 @@ pub struct ShareService { file_repository: Arc, folder_repository: Arc, password_hasher: Arc, + /// ReBAC engine — used to create/revoke token grants that mirror public + /// share links so that `GET /api/grants/outgoing` reflects them. + authorization: Arc, /// Bounds the number of in-flight Argon2 password hashes to avoid /// saturating the blocking thread pool and consuming excessive RAM. hash_semaphore: Arc, @@ -90,6 +96,7 @@ impl ShareService { file_repository: Arc, folder_repository: Arc, password_hasher: Arc, + authorization: Arc, ) -> Self { Self { config, @@ -97,6 +104,7 @@ impl ShareService { file_repository, folder_repository, password_hasher, + authorization, hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)), } } @@ -256,6 +264,50 @@ impl ShareUseCase for ShareService { .await .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + // Mirror the share permissions as ReBAC token grants so that + // `GET /api/grants/outgoing` picks them up and the UI can show the + // share badge without a separate `/api/shares` round-trip. + // The DELETE trigger `trg_cleanup_grants_token` handles cleanup when + // the share is later removed — no extra service-layer code needed there. + { + let share_id = saved_share.id(); + let item_id_uuid = Uuid::parse_str(saved_share.item_id()) + .map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?; + + let resource = match saved_share.item_type() { + ShareItemType::File => Resource::File(item_id_uuid), + ShareItemType::Folder => Resource::Folder(item_id_uuid), + }; + let subject = Subject::Token(share_id); + let perms = saved_share.permissions(); + + // Read is always granted + self.authorization + .grant(user_id, subject, Permission::Read, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + + // Write permission → Create + Update + if perms.write() { + self.authorization + .grant(user_id, subject, Permission::Create, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + self.authorization + .grant(user_id, subject, Permission::Update, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + } + + // Reshare permission → Share + if perms.reshare() { + self.authorization + .grant(user_id, subject, Permission::Share, resource) + .await + .map_err(|e| ShareServiceError::Repository(e.to_string()))?; + } + } + // Convert the entity to DTO for the response Ok(ShareDto::from_entity(&saved_share, &self.config.base_url())) } diff --git a/src/common/di.rs b/src/common/di.rs index 65d42c01..d107eb1b 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -514,6 +514,7 @@ impl AppServiceFactory { &self, repos: &RepositoryServices, db_pool: &Arc, + authorization: &Arc, ) -> Option> { if !self.config.features.enable_file_sharing { tracing::info!("File sharing service is disabled in configuration"); @@ -537,6 +538,7 @@ impl AppServiceFactory { repos.file_read_repository.clone(), repos.folder_repository.clone(), password_hasher, + authorization.clone(), )); tracing::info!("File sharing service initialized"); @@ -652,7 +654,7 @@ impl AppServiceFactory { self.create_application_services(&core, &repos, trash_service.clone(), &authorization); // 5. Share service - let share_service = self.create_share_service(&repos, &pool); + let share_service = self.create_share_service(&repos, &pool, &authorization); apps.share_service = share_service.clone(); let share_browse_service = share_service.as_ref().map(|s| { diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 42a03f02..939dfdeb 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -5,6 +5,7 @@ //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! maps them to / from `storage.access_grants` rows. +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use std::fmt; use uuid::Uuid; @@ -201,6 +202,84 @@ pub struct Grant { pub granted_at: chrono::DateTime, } +// ════════════════════════════════════════════════════════════════════════════ +// ResourceKind — type-only discriminator (no id), used for filtering queries +// ════════════════════════════════════════════════════════════════════════════ + +/// Resource type without an id — used to filter paginated grant queries by +/// type. Mirrors the `resource_type` column values in `storage.access_grants`. +/// Add new variants here when new resource types are supported. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ResourceKind { + File, + Folder, + // Future: Calendar, AddressBook, Playlist, … +} + +impl ResourceKind { + pub fn as_str(&self) -> &'static str { + match self { + ResourceKind::File => "file", + ResourceKind::Folder => "folder", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "file" => Some(ResourceKind::File), + "folder" => Some(ResourceKind::Folder), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// IncomingGrantSummary — aggregated across multiple permission rows +// ════════════════════════════════════════════════════════════════════════════ + +/// Multiple `access_grants` rows for the same `(subject, resource)` collapsed +/// into one record. Used by `list_incoming_resources_paged` to avoid sending +/// duplicate resource items to the caller. +#[derive(Debug, Clone)] +pub struct IncomingGrantSummary { + pub resource_type: ResourceKind, + pub resource_id: Uuid, + /// All permissions held on this resource (aggregated). + pub permissions: Vec, + /// Earliest `granted_at` across all permission rows. + pub granted_at: chrono::DateTime, + /// Granter of the earliest grant. + pub granted_by: Uuid, +} + +// ════════════════════════════════════════════════════════════════════════════ +// GrantCursor — opaque pagination cursor for list_incoming_resources_paged +// ════════════════════════════════════════════════════════════════════════════ + +/// Encodes the position of the last seen item in a cursor-paginated grant +/// listing. The encoding is opaque to API callers — only the backend +/// decodes it. Change the encoding algorithm in a major version bump. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GrantCursor { + pub granted_at: chrono::DateTime, + pub resource_id: Uuid, +} + +impl GrantCursor { + /// Encode as a URL-safe base64 JSON string (no padding). + pub fn encode(&self) -> String { + let json = serde_json::to_vec(self).unwrap_or_default(); + URL_SAFE_NO_PAD.encode(&json) + } + + /// Decode from a URL-safe base64 JSON string. Returns `None` on any + /// parse failure — callers treat a bad cursor as "start from the top". + pub fn decode(s: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(s).ok()?; + serde_json::from_slice(&bytes).ok() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index b705810e..220c9fdd 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -38,7 +38,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { WHEN uf.item_type = 'folder' THEN fld.path WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name) ELSE NULL - END AS "item_path" + END AS "item_path", + COALESCE(f.user_id, fld.user_id)::TEXT AS "owner_id" FROM auth.user_favorites uf LEFT JOIN storage.files f ON uf.item_type = 'file' AND f.id = uf.item_id::UUID @@ -78,6 +79,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { parent_id: row.try_get("parent_id").ok(), modified_at: row.try_get("modified_at").ok(), item_path: row.try_get("item_path").ok(), + owner_id: row.try_get("owner_id").ok(), // Temporary defaults; with_display_fields() computes the real values icon_class: String::new(), icon_special_class: String::new(), diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 6711b110..34d1be13 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -35,7 +35,9 @@ use sqlx::PgPool; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; -use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; +use crate::domain::services::authorization::{ + Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, +}; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; @@ -291,6 +293,107 @@ impl AuthorizationEngine for PgAclEngine { rows.into_iter().map(Self::row_to_grant).collect() } + async fn list_incoming_resources_paged( + &self, + subject: Subject, + kinds: &[ResourceKind], + limit: u32, + cursor: Option, + ) -> Result<(Vec, Option), DomainError> { + // Build kind filter array — NULL means "all kinds". + let kind_strs: Option> = if kinds.is_empty() { + None + } else { + Some(kinds.iter().map(|k| k.as_str()).collect()) + }; + + let cursor_at = cursor.as_ref().map(|c| c.granted_at); + let cursor_id = cursor.as_ref().map(|c| c.resource_id); + + // Fetch limit+1 rows so we can detect whether a next page exists. + let fetch_limit = (limit as i64) + 1; + + // Each row: (resource_type, resource_id, permissions_text_array, + // granted_at, granted_by) + type Row = ( + String, + Uuid, + Vec, + chrono::DateTime, + Uuid, + ); + + let rows: Vec = sqlx::query_as( + r#" + WITH agg AS ( + SELECT + resource_type, + resource_id, + array_agg(DISTINCT permission ORDER BY permission) AS permissions, + MIN(granted_at) AS granted_at, + (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by + FROM storage.access_grants + WHERE subject_type = $1 + AND subject_id = $2 + AND ($3::text[] IS NULL OR resource_type = ANY($3)) + GROUP BY resource_type, resource_id + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by + FROM agg + WHERE ( $4::timestamptz IS NULL + OR granted_at < $4 + OR (granted_at = $4 AND resource_id < $5::uuid)) + ORDER BY granted_at DESC, resource_id DESC + LIMIT $6 + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(kind_strs) + .bind(cursor_at) + .bind(cursor_id) + .bind(fetch_limit) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}")) + })?; + + let has_next = rows.len() > limit as usize; + let rows: Vec = rows.into_iter().take(limit as usize).collect(); + + // Determine the next cursor from the last item we're actually returning. + let next_cursor = if has_next { + rows.last().map(|r| GrantCursor { + granted_at: r.3, + resource_id: r.1, + }) + } else { + None + }; + + // Convert rows into domain summaries. + let summaries = rows + .into_iter() + .filter_map(|(rt, rid, perms_str, granted_at, granted_by)| { + let resource_type = ResourceKind::parse(&rt)?; + let permissions = perms_str + .iter() + .filter_map(|s| Permission::parse(s)) + .collect(); + Some(IncomingGrantSummary { + resource_type, + resource_id: rid, + permissions, + granted_at, + granted_by, + }) + }) + .collect(); + + Ok((summaries, next_cursor)) + } + async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError> { let rows = sqlx::query_as::< _, diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index b769fa7c..eb813ccb 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -11,23 +11,32 @@ use axum::{ http::StatusCode, response::IntoResponse, }; +use futures::future::join_all; use serde::Deserialize; use std::sync::Arc; -use tracing::{error, info}; +use tracing::{error, info, warn}; use utoipa::IntoParams; use uuid::Uuid; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto, - UpdateRoleDto, + CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, + SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::application::ports::folder_ports::FolderUseCase; +use crate::common::di::AppState; +#[allow(unused_imports)] use crate::common::errors::DomainError; -use crate::domain::services::authorization::{Permission, Resource, Subject}; -use crate::infrastructure::services::pg_acl_engine::PgAclEngine; +use crate::domain::errors::ErrorKind; +use crate::domain::services::authorization::{ + GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, +}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; +type AppStateRef = Arc; + // ════════════════════════════════════════════════════════════════════════════ // POST /api/grants // ════════════════════════════════════════════════════════════════════════════ @@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser; tag = "grants" )] pub async fn create_grant( - State(authz): State>, + State(state): State, auth_user: AuthUser, Json(dto): Json, ) -> impl IntoResponse { + let authz = &state.authorization; let caller_id = auth_user.id; // Validate: exactly one of permissions/role @@ -118,10 +128,11 @@ pub async fn create_grant( tag = "grants" )] pub async fn revoke_grant( - State(authz): State>, + State(state): State, auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { + let authz = &state.authorization; let caller_id = auth_user.id; let grant_id = match Uuid::parse_str(&id) { Ok(u) => u, @@ -129,7 +140,7 @@ pub async fn revoke_grant( }; // Look up the grant to find the underlying resource (and granter). - let on_resource = match find_grant_resource(&authz, grant_id).await { + let on_resource = match authz.find_grant_by_id(grant_id).await { Ok(Some((res, granter))) => (res, granter), Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent Err(e) => return AppError::from(e).into_response(), @@ -151,16 +162,6 @@ pub async fn revoke_grant( StatusCode::NO_CONTENT.into_response() } -/// Look up a grant by id and return (resource, granted_by) so the caller-auth -/// check in revoke_grant can determine if the caller is the granter or needs -/// the Share permission on the resource. Returns `Ok(None)` if no such grant. -async fn find_grant_resource( - authz: &PgAclEngine, - grant_id: Uuid, -) -> Result, DomainError> { - authz.find_grant_by_id(grant_id).await -} - // ════════════════════════════════════════════════════════════════════════════ // PUT /api/grants/role // ════════════════════════════════════════════════════════════════════════════ @@ -176,10 +177,11 @@ async fn find_grant_resource( tag = "grants" )] pub async fn set_role( - State(authz): State>, + State(state): State, auth_user: AuthUser, Json(dto): Json, ) -> impl IntoResponse { + let authz = &state.authorization; let caller_id = auth_user.id; let subject: Subject = dto.subject.into(); let resource: Resource = dto.resource.into(); @@ -262,12 +264,13 @@ pub struct IncomingQuery { tag = "grants" )] pub async fn list_incoming( - State(authz): State>, + State(state): State, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { let caller_id = auth_user.id; - match authz + match state + .authorization .list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into)) .await { @@ -279,6 +282,170 @@ pub async fn list_incoming( } } +// ════════════════════════════════════════════════════════════════════════════ +// GET /api/grants/incoming/resources +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + get, + path = "/api/grants/incoming/resources", + params(SharedWithMeQuery), + responses( + (status = 200, + description = "Cursor-paginated resources shared with the caller. \ + Each item carries the full file or folder details plus \ + aggregated permissions. `next_cursor` is absent on the \ + last page.", + body = SharedWithMeDto), + ), + tag = "grants" +)] +pub async fn list_shared_with_me( + State(state): State, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let caller_id = auth_user.id; + let subject = Subject::User(caller_id); + + // Parse resource_types filter (unknown values silently ignored). + let kinds: Vec = q + .resource_types + .as_deref() + .map(|s| { + s.split(',') + .filter_map(|t| ResourceKind::parse(t.trim())) + .collect() + }) + .unwrap_or_default(); + + // Clamp limit to 1–200. + let limit = q.limit.clamp(1, 200); + + // Decode cursor (treat invalid cursor as "start from top"). + let cursor = q.cursor.as_deref().and_then(GrantCursor::decode); + + // Fetch paged summaries from the ACL engine. + let (summaries, next_cursor) = match state + .authorization + .list_incoming_resources_paged(subject, &kinds, limit, cursor) + .await + { + Ok(r) => r, + Err(e) => return AppError::from(e).into_response(), + }; + + // Split summaries by resource kind for parallel resolution. + let file_summaries: Vec<&IncomingGrantSummary> = summaries + .iter() + .filter(|s| matches!(s.resource_type, ResourceKind::File)) + .collect(); + let folder_summaries: Vec<&IncomingGrantSummary> = summaries + .iter() + .filter(|s| matches!(s.resource_type, ResourceKind::Folder)) + .collect(); + + let file_service = &state.applications.file_retrieval_service; + let folder_service = &state.applications.folder_service_concrete; + + // Pre-compute ID strings to avoid temporaries inside async closures. + let file_ids: Vec = file_summaries + .iter() + .map(|s| s.resource_id.to_string()) + .collect(); + let folder_ids: Vec = folder_summaries + .iter() + .map(|s| s.resource_id.to_string()) + .collect(); + + // Resolve resource details concurrently (files and folders in parallel). + 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))) + ); + + // Build the unified item list in original grant order (newest first). + // We iterate summaries in order and pick the resolved result from the + // appropriate typed bucket. + let mut file_idx = 0usize; + let mut folder_idx = 0usize; + + let mut items: Vec = Vec::with_capacity(summaries.len()); + + for summary in &summaries { + match summary.resource_type { + ResourceKind::File => { + let result = &file_results[file_idx]; + file_idx += 1; + match result { + Ok(file_dto) => { + items.push(SharedWithMeItemDto { + resource_type: ResourceTypeDto::File, + permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), + granted_at: summary.granted_at, + granted_by: summary.granted_by, + file: Some(file_dto.clone().without_hierarchy_info()), + folder: None, + }); + } + Err(e) if e.kind == ErrorKind::NotFound => { + // Stale grant (file deleted, trigger not yet fired) — skip silently. + warn!( + "Skipping stale 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(SharedWithMeItemDto { + resource_type: ResourceTypeDto::Folder, + permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), + granted_at: summary.granted_at, + granted_by: summary.granted_by, + file: None, + folder: Some(folder_dto.clone().without_hierarchy_info()), + }); + } + Err(e) if e.kind == ErrorKind::NotFound => { + warn!( + "Skipping stale 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(SharedWithMeDto { + items, + next_cursor: next_cursor.map(|c| c.encode()), + }), + ) + .into_response() +} + // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants/outgoing // ════════════════════════════════════════════════════════════════════════════ @@ -292,11 +459,11 @@ pub async fn list_incoming( tag = "grants" )] pub async fn list_outgoing( - State(authz): State>, + State(state): State, auth_user: AuthUser, ) -> impl IntoResponse { let caller_id = auth_user.id; - match authz.list_outgoing_grants(caller_id).await { + match state.authorization.list_outgoing_grants(caller_id).await { Ok(grants) => { let dtos: Vec = grants.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(dtos)).into_response() @@ -327,10 +494,11 @@ pub struct OnResourceQuery { tag = "grants" )] pub async fn list_on_resource( - State(authz): State>, + State(state): State, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { + let authz = &state.authorization; let caller_id = auth_user.id; let resource: Resource = ResourceDto { kind: q.resource_type, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index eec43ead..11ed606e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -20,6 +20,10 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::folder_listing_dto::FolderListingDto; +use crate::application::dtos::grant_dto::{ + CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, + SharedWithMeItemDto, SubjectDto, SubjectTypeDto, UpdateRoleDto, +}; use crate::application::dtos::i18n_dto::{ LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto, }; @@ -202,6 +206,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::admin_handler::complete_migration, handlers::admin_handler::verify_migration, handlers::admin_handler::generate_encryption_key, + // Grant / ReBAC handlers (free functions) + handlers::grant_handler::create_grant, + handlers::grant_handler::revoke_grant, + handlers::grant_handler::set_role, + handlers::grant_handler::list_incoming, + handlers::grant_handler::list_shared_with_me, + handlers::grant_handler::list_outgoing, + handlers::grant_handler::list_on_resource, ), components( schemas( @@ -275,6 +287,18 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; UpdateContactRequest, GroupNameRequest, AddMemberRequest, + // Grant / ReBAC schemas + SubjectTypeDto, + SubjectDto, + ResourceTypeDto, + ResourceDto, + PermissionDto, + Role, + CreateGrantDto, + UpdateRoleDto, + GrantDto, + SharedWithMeDto, + SharedWithMeItemDto, ) ), tags( @@ -293,6 +317,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; (name = "playlists", description = "Music playlist endpoints"), (name = "contacts", description = "Address books, contacts, and groups endpoints"), (name = "admin", description = "Admin management endpoints"), + (name = "grants", description = "ReBAC grant management endpoints"), ), info( title = "OxiCloud API", diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 872ec605..ebf65ec5 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -165,7 +165,8 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); let favorites_service = app_state.favorites_service.clone(); let recent_service = app_state.recent_service.clone(); - let authorization = app_state.authorization.clone(); + // authorization is no longer extracted separately — the grants router now + // uses app_state directly so handlers can access all services. // Initialize the batch operations service let mut batch_service_builder = BatchOperationService::default( @@ -302,7 +303,10 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { Router::new() }; - // Create routes for ReBAC grants (/api/grants) — single state: the authz engine. + // Create routes for ReBAC grants (/api/grants). + // State is Arc so that the new list_shared_with_me handler can + // access file/folder services. Existing handlers still extract + // State> via the FromRef impl in di.rs. let grants_router = { use crate::interfaces::api::handlers::grant_handler; Router::new() @@ -311,8 +315,12 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/{id}", delete(grant_handler::revoke_grant)) .route("/role", put(grant_handler::set_role)) .route("/incoming", get(grant_handler::list_incoming)) + .route( + "/incoming/resources", + get(grant_handler::list_shared_with_me), + ) .route("/outgoing", get(grant_handler::list_outgoing)) - .with_state(authorization.clone()) + .with_state(app_state.clone()) }; // Create a router without the i18n routes diff --git a/static/css/base/variables.css b/static/css/base/variables.css index de55355f..212a5fce 100644 --- a/static/css/base/variables.css +++ b/static/css/base/variables.css @@ -388,6 +388,12 @@ --color-badge-success-fill: #047857; --color-badge-success-fill-dark: #064e27; --color-badge-success-fill-faint: #f0fdf4; + --color-badge-green-bg: #ecfdf5; + --color-badge-green-text: #065f46; + + /* Status badge — light mode (orange/coral) */ + --color-badge-orange-bg: #fff5f3; + --color-badge-orange-text: #ff5e3a; /* Status badge — light mode (error/red) */ --color-badge-error-border: #fecaca; @@ -395,6 +401,8 @@ /* Status badge — light mode (warning/amber) */ --color-badge-warning-text: #92400e; --color-badge-warning-border: #fde68a; + --color-badge-amber-bg: #fef3c7; + --color-badge-amber-text: #f59e0b; /* Status badge — light mode (indigo/purple) */ --color-badge-indigo-bg: #ede9fe; diff --git a/static/css/components/filesView.css b/static/css/components/filesView.css index 72472450..3a129f6d 100644 --- a/static/css/components/filesView.css +++ b/static/css/components/filesView.css @@ -100,12 +100,32 @@ min-width: 0; } -.list-header > div:nth-child(4), +/* Size column: always nth-child(5) because .owner-cell is always in the DOM + (even when hidden via display:none, it still occupies a child slot). */ +.list-header > div:nth-child(5), .files-list-view .file-item .size-cell { justify-self: end; text-align: right; } +/* ── Owner column ─────────────────────────────────────────── */ + +/* Styles applied whenever the cell is visible (hidden class absent). + The .hidden utility class (display:none !important) keeps it invisible + by default — it is stamped directly in the HTML templates. */ +.owner-cell { + color: var(--color-text-secondary); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Expand the grid track as soon as at least one owner cell is visible. */ +.files-list-view:has(.owner-cell:not(.hidden)) { + --files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 72px; +} + .files-list-view { --files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px; display: flex; @@ -271,7 +291,8 @@ /* element hidden on grid view */ .files-grid-view .file-item .date-cell, -.files-grid-view .file-item .size-cell { +.files-grid-view .file-item .size-cell, +.files-grid-view .file-item .owner-cell { display: none; } /* Selection checkbox */ diff --git a/static/css/components/modals.css b/static/css/components/modals.css index a69fdd58..d58b7829 100644 --- a/static/css/components/modals.css +++ b/static/css/components/modals.css @@ -190,6 +190,9 @@ color: var(--color-text-heading); margin: 0; flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .modal-close-btn { @@ -307,3 +310,20 @@ .modal-footer .btn-primary:active { transform: translateY(0); } + +/* ── Panel mode (ShareModal, etc.) ─────────────────────────────────────────── */ +/* Wider, taller container; body becomes a zero-padding scrollable slot. */ + +.modal-container--panel { + width: 520px; + max-width: 96vw; + max-height: 88vh; + display: flex; + flex-direction: column; +} + +.modal-container--panel .modal-body { + padding: 0; + overflow-y: auto; + flex: 1; +} diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css new file mode 100644 index 00000000..af314901 --- /dev/null +++ b/static/css/components/shareModal.css @@ -0,0 +1,537 @@ +/* ── Share Modal — content styles ────────────────────────────────────────────── + * + * The overlay, container, header, footer, and animations come from modals.css + * (via Modal.openPanel()). This file only covers the body content: sections, + * member rows, chips, role selects, link rows, and new-link form. + * + * All colours use CSS custom properties. No raw hex/rgb/named values outside + * of :root declarations. + * ─────────────────────────────────────────────────────────────────────────── */ + +/* ── Body wrapper ────────────────────────────────────────────────────────────── */ + +.smd-body { + display: flex; + flex-direction: column; +} + +/* ── Sections ────────────────────────────────────────────────────────────────── */ + +.smd-section { + border-top: 0.5px solid var(--color-border); + padding: 16px 20px; +} + +.smd-section:first-child { + border-top: none; +} + +.smd-section-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 12px; +} + +/* ── Loading skeleton ────────────────────────────────────────────────────────── */ + +.smd-skeleton { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; +} + +.smd-skeleton-line { + height: 14px; + background: var(--color-bg-muted); + border-radius: 6px; + animation: smdSkeletonPulse 1.4s ease-in-out infinite; +} + +.smd-skeleton-line--short { + width: 40%; +} + +.smd-skeleton-line--medium { + width: 65%; +} + +@keyframes smdSkeletonPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* ── Search row ──────────────────────────────────────────────────────────────── */ + +.smd-search-row { + display: flex; + gap: 8px; + align-items: flex-start; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.smd-search-wrap { + position: relative; + flex: 1; + min-width: 180px; +} + +.smd-search-input { + width: 100%; + padding: 9px 12px; + font-size: 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-hover); + color: var(--color-text-heading); + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; +} + +.smd-search-input:focus { + border-color: var(--color-accent); + background: var(--color-bg-surface); + box-shadow: 0 0 0 3px var(--color-accent-ring); +} + +/* Suggestion dropdown */ +.smd-suggestions { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--color-bg-surface); + border: 0.5px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 8px 24px var(--color-shadow-xl); + z-index: 100; + overflow: hidden; +} + +.smd-suggestion-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + cursor: pointer; + transition: background 0.1s; +} + +.smd-suggestion-item:hover, +.smd-suggestion-item:focus { + background: var(--color-bg-hover); +} + +.smd-suggestion-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +.smd-suggestion-name { + font-size: 14px; + color: var(--color-text-heading); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smd-suggestion-email { + font-size: 12px; + color: var(--color-text-faint); +} + +/* Role picker beside the search box */ +.smd-role-select { + padding: 9px 10px; + font-size: 13px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-hover); + color: var(--color-text-heading); + cursor: pointer; + max-width: 120px; +} + +/* Add button */ +.smd-add-btn { + min-height: 36px; + min-width: 44px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + border-radius: 8px; +} + +/* ── Staged chips ────────────────────────────────────────────────────────────── */ + +.smd-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.smd-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + border: 0.5px solid var(--color-border-medium); + border-radius: 20px; + background: var(--color-bg-hover); + font-size: 13px; + color: var(--color-text-heading); +} + +.smd-chip-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.smd-chip-remove { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: none; + cursor: pointer; + color: var(--color-text-faint); + padding: 0; + border-radius: 50%; + transition: + background 0.1s, + color 0.1s; +} + +.smd-chip-remove:hover { + background: var(--color-bg-muted); + color: var(--color-text-heading); +} + +/* ── Member group headings ───────────────────────────────────────────────────── */ + +.smd-group { + margin-top: 12px; +} + +.smd-group:first-child { + margin-top: 0; +} + +.smd-group-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; +} + +.smd-group-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 11px; + font-weight: 700; + background: var(--color-bg-muted); + color: var(--color-text-subtle); +} + +/* ── Member rows ─────────────────────────────────────────────────────────────── */ + +.smd-member-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 0; +} + +.smd-member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} + +.smd-member-name { + flex: 1; + font-size: 14px; + color: var(--color-text-heading); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smd-member-role-select { + font-size: 13px; + padding: 4px 8px; + border: 0.5px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-hover); + color: var(--color-text-heading); + cursor: pointer; + max-width: 33%; +} + +.smd-row-action { + display: flex; + align-items: center; + justify-content: center; + min-height: 32px; + min-width: 32px; + border: none; + background: none; + cursor: pointer; + color: var(--color-text-faint); + border-radius: 6px; + padding: 0; + transition: + background 0.1s, + color 0.1s; +} + +.smd-row-action:hover { + background: var(--color-bg-hover); + color: var(--color-error-text); +} + +/* ── Avatar colour palette ───────────────────────────────────────────────────── */ +/* Colours are provided by .uv-color-0..4 in userVignette.css (shared palette). */ + +/* ── Fallback when user-directory is unavailable ────────────────────────────── */ + +.smd-directory-unavailable { + font-size: 13px; + color: var(--color-text-faint); + font-style: italic; + padding: 4px 0 8px; +} + +/* ── Link rows ───────────────────────────────────────────────────────────────── */ + +.smd-link-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; +} + +.smd-link-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-bg-muted); + border: 0.5px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-subtle); + flex-shrink: 0; + font-size: 14px; +} + +.smd-link-info { + flex: 1; + overflow: hidden; +} + +.smd-link-name { + font-size: 14px; + color: var(--color-text-heading); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smd-link-tags { + display: flex; + gap: 6px; + margin-top: 3px; + flex-wrap: wrap; +} + +.smd-link-tag { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background: var(--color-bg-muted); + color: var(--color-text-subtle); + border: 0.5px solid var(--color-border); + white-space: nowrap; +} + +.smd-link-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +/* ── Inline edit sub-panel ───────────────────────────────────────────────────── */ + +.smd-edit-panel { + margin: 4px 0 8px 42px; + padding: 12px; + background: var(--color-bg-hover); + border: 0.5px solid var(--color-border); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.smd-edit-panel label { + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + display: block; + margin-bottom: 4px; +} + +.smd-edit-input { + width: 100%; + padding: 8px 10px; + font-size: 13px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-surface); + color: var(--color-text-heading); + outline: none; + box-sizing: border-box; + transition: border-color 0.15s; +} + +.smd-edit-input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-ring); +} + +.smd-edit-panel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ── New-link creation button + form ─────────────────────────────────────────── */ + +.smd-new-link-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px; + margin-top: 8px; + font-size: 13px; + color: var(--color-text-subtle); + background: none; + border: 1.5px dashed var(--color-border-medium); + border-radius: 8px; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} + +.smd-new-link-btn:hover { + background: var(--color-bg-hover); + border-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 ─────────────────────────────────────────────────────── */ + +.smd-pw-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +/* ── Apply spinner ────────────────────────────────────────────────────────────── */ + +.smd-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: smdSpin 0.6s linear infinite; + vertical-align: middle; + margin-left: 6px; +} + +@keyframes smdSpin { + to { + transform: rotate(360deg); + } +} diff --git a/static/css/components/userVignette.css b/static/css/components/userVignette.css new file mode 100644 index 00000000..af0e7f1c --- /dev/null +++ b/static/css/components/userVignette.css @@ -0,0 +1,97 @@ +/* ── User Vignette — avatar circle + name inline component ──────────────────── + * + * Reusable component that pairs a coloured initials circle with a display + * name resolved asynchronously. Used in: + * • Owner column (list view, SharedWithMe & Favorites sections) + * • ShareModal member rows, chips, suggestion items + * + * Sizes: --xs (20 px) · --sm (24 px) · --md (32 px) · --lg (40 px) + * Colours: .uv-color-0..4 (applied by JS via _colorIndex(userId) % 5) + * + * All colours use CSS custom properties — no raw hex / rgb / named values. + * ─────────────────────────────────────────────────────────────────────────── */ + +.user-vignette { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; +} + +.user-vignette__avatar { + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + flex-shrink: 0; + /* Default size = --sm; overridden by size modifier below */ + width: 24px; + height: 24px; + font-size: 10px; +} + +.user-vignette__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + color: var(--color-text-secondary); +} + +/* ── Size variants ─────────────────────────────────────────────────────────── */ + +.user-vignette--xs .user-vignette__avatar { + width: 20px; + height: 20px; + font-size: 9px; +} + +.user-vignette--sm .user-vignette__avatar { + width: 24px; + height: 24px; + font-size: 10px; +} + +.user-vignette--md .user-vignette__avatar { + width: 32px; + height: 32px; + font-size: 12px; +} + +.user-vignette--lg .user-vignette__avatar { + width: 40px; + height: 40px; + font-size: 15px; +} + +/* ── Colour palette ────────────────────────────────────────────────────────── */ + +/* Colours are shared with the ShareModal avatar palette. + Each class maps to a distinct hue via design-token variables. */ + +.uv-color-0 { + background: var(--color-badge-indigo-bg); + color: var(--color-badge-indigo-text); +} + +.uv-color-1 { + background: var(--color-badge-green-bg); + color: var(--color-badge-green-text); +} + +.uv-color-2 { + background: var(--color-badge-orange-bg); + color: var(--color-badge-orange-text); +} + +.uv-color-3 { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.uv-color-4 { + background: var(--color-badge-amber-bg); + color: var(--color-badge-amber-text); +} diff --git a/static/css/main.css b/static/css/main.css index 38c68ac7..de5d36bc 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -18,6 +18,8 @@ @import url("./components/dialogs.css"); @import url("./components/modals.css"); @import url("./components/shareDialog.css"); +@import url("./components/shareModal.css"); +@import url("./components/userVignette.css"); @import url("./components/uploadDropdown.css"); @import url("./components/notifications.css"); @import url("./components/userMenu.css"); diff --git a/static/css/views/sharedWithMe.css b/static/css/views/sharedWithMe.css new file mode 100644 index 00000000..d60aad0f --- /dev/null +++ b/static/css/views/sharedWithMe.css @@ -0,0 +1,13 @@ +/** + * Shared-with-me view styles. + * + * The grid/list rendering reuses the standard `.files-container` / `#files-list` + * styles from filesView.css. Only view-specific additions are defined here. + */ + +/* ── "Load more" button row ───────────────────────────────────────────────── */ +.swm-load-more-wrapper { + display: flex; + justify-content: center; + padding: 16px 0 24px; +} diff --git a/static/index.html b/static/index.html index 16692523..ac175e22 100644 --- a/static/index.html +++ b/static/index.html @@ -14,6 +14,7 @@ + @@ -24,7 +25,7 @@ - + @@ -76,9 +77,13 @@ Files + ${_multiSelectButons} ${_toggleButtons} + `, + sharedwithme: ` +
+ ${_toggleButtons} ` }; /** * - * @param {'files' | 'trash' | 'favorites' | 'recent' | 'hidden'} mode + * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'hidden'} mode * @param {boolean} [force=false] * @returns */ @@ -398,6 +405,9 @@ function initApp() { app.viewFile = hashContext.file; } + // get grants (xxx: async methods) + await grants.fetchIncomingGrants(); + await grants.fetchOutgoingGrants(); loadFiles(); } }); @@ -652,6 +662,10 @@ function setupEventListeners() { switchToSharedSection(); break; + case 'nav.sharedwithme': + switchToSharedWithMeSection(); + break; + case 'nav.favorites': // Switch to favorites view switchToFavoritesSection(); @@ -723,6 +737,12 @@ function setupEventListeners() { * @param {string} name */ export function selectFolder(id, name) { + // When entering from a non-files section (e.g. "Shared with me"), + // activate the Files UI (nav active state, breadcrumb, action bar, + // container) without resetting the current path. + if (app.currentSection !== 'files') { + activateFilesUI(); + } app.breadcrumbPath.push({ id, name }); app.currentPath = id; ui.updateBreadcrumb(); diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 4cfba60f..08ac0f33 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -10,6 +10,7 @@ import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; import { recent } from '../features/library/recent.js'; import { sharedView } from '../views/shared/sharedView.js'; +import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { loadFiles } from './filesView.js'; import { setActionsBarMode } from './main.js'; import { app, appElements } from './state.js'; @@ -122,6 +123,7 @@ function getSectionFromNavItem(navItem) { export const SECTIONS_MAPPER = { files: switchToFilesSection, shared: switchToSharedSection, + sharedwithme: switchToSharedWithMeSection, recent: switchToRecentFilesSection, favorites: switchToFavoritesSection, trash: switchToTrashSection, @@ -157,6 +159,14 @@ function setCurrentSection(section) { sharedView.hide(); } + // Hide "Load more" button when leaving the sharedwithme section + if (section !== 'sharedwithme' && sharedWithMeView) { + sharedWithMeView.hide(); + } + + // Reset owner column — sections that need it re-enable it explicitly below. + ui.setOwnerColumnVisible(false); + // Hide photosView when switching to any other section if (section !== 'photos' && photosView) { photosView.hide(); @@ -194,12 +204,38 @@ function switchToSharedSection() { if (multiSelect) multiSelect.clear(); } +function switchToSharedWithMeSection() { + if (!setCurrentSection('sharedwithme')) return; + + // Hide breadcrumb (only shown in Files view) + const breadcrumb = document.querySelector('.breadcrumb'); + breadcrumb?.classList.add('hidden'); + + // Show actions-bar with view toggle (no upload / new-folder in this view) + setActionsBarMode('sharedwithme'); + + // Show the Owner column — names are resolved async after render. + ui.setOwnerColumnVisible(true); + + // Show the standard files container and respect grid/list preference + toggleFileContainer(true); + syncViewContainers(); + + if (multiSelect) multiSelect.clear(); + + // Load and render items into the files container + sharedWithMeView.init(); +} + function switchToFilesSection() { if (!setCurrentSection('files')) return; // Set actions bar mode setActionsBarMode('files', true); + // Show owner column in the Files section + ui.setOwnerColumnVisible(true); + // Show breadcrumb (only in Files view) const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.remove('hidden'); @@ -231,6 +267,9 @@ function switchToFavoritesSection() { // Set actions bar mode setActionsBarMode('favorites'); + // Show the Owner column — names are resolved async after render. + ui.setOwnerColumnVisible(true); + // Hide breadcrumb (only shown in Files view) const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.add('hidden'); @@ -368,13 +407,34 @@ function switchToMusicSection() { if (multiSelect) multiSelect.clear(); } +/** + * Activate the Files section UI (nav state, breadcrumb, actions bar, + * files container, grid/list sync) WITHOUT resetting `app.currentPath` + * or `app.breadcrumbPath`. + * + * Used by `selectFolder` when the user clicks a folder from a + * non-files section (e.g. "Shared with me") so the Files view is + * fully set up before the folder content loads. + */ +function activateFilesUI() { + setCurrentSection('files'); + setActionsBarMode('files', true); + const breadcrumb = document.querySelector('.breadcrumb'); + breadcrumb?.classList.remove('hidden'); + toggleFileContainer(true); + syncViewContainers(); + if (multiSelect) multiSelect.clear(); +} + export { + activateFilesUI, switchToFavoritesSection, switchToFilesSection, switchToMusicSection, switchToPhotosSection, switchToRecentFilesSection, switchToSharedSection, + switchToSharedWithMeSection, switchToTrashSection, syncViewContainers }; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index a878f867..cb9edce0 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -5,6 +5,8 @@ // @ts-check +import { shareModal } from '../components/shareModal.js'; +import { createUserVignette } from '../components/userVignette.js'; import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; @@ -15,12 +17,12 @@ import { multiSelect } from '../features/files/multiSelect.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; -import { fileSharing } from '../features/sharing/fileSharing.js'; import { thumbnail } from '../features/thumbnail.js'; -import { sharedView } from '../views/shared/sharedView.js'; +import { grants } from '../model/grants.js'; +import { systemUsers } from '../model/systemUsers.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; -import { switchToFilesSection, syncViewContainers } from './navigation.js'; +import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js'; import { app } from './state.js'; import { uiFileTypes } from './uiFileTypes.js'; import { uiNotifications } from './uiNotifications.js'; @@ -37,6 +39,12 @@ const ui = { /** @type {HTMLDivElement | null} */ draggedItems: null, + /** + * Whether the Owner column is currently visible. + * Tracked so that newly rendered items can stamp the correct initial class. + */ + _ownerVisible: false, + /** * Initialize context menus and dialogs */ @@ -54,7 +62,7 @@ const ui = { Add to favorites
- Share + Share
@@ -98,7 +106,7 @@ const ui = { Add to favorites
- Share + Share
${escapeHtml(folder.name)}
-
+
+
${i18n.t('files.file_types.folder')}
--
${formattedDate}
@@ -1377,7 +1316,8 @@ const ui = { const fileSize = file.size_formatted || formatFileSize(file.size); const formattedDate = formatDateTime(file.modified_at); const isFav = favorites?.isFavorite(file.id, 'file'); - const isShared = sharedView.isShared(file.id, 'file'); + const isShared = grants.getOutgoingGrantsFor('file', file.id).length > 0; + //const isShared = sharedView.isShared(file.id, 'file'); const canThumbnail = thumbnail.canHandle(file); const el = document.createElement('div'); @@ -1398,8 +1338,9 @@ const ui = { ${escapeHtml(file.name)}
-
+
+
${typeLabel}
${fileSize}
${formattedDate}
@@ -1439,6 +1380,7 @@ const ui = {
Name
+
Owner
Type
Size
Modified
diff --git a/static/js/core/modal.js b/static/js/components/modal.js similarity index 67% rename from static/js/core/modal.js rename to static/js/components/modal.js index a837640c..79166e72 100644 --- a/static/js/core/modal.js +++ b/static/js/components/modal.js @@ -1,5 +1,5 @@ -import { i18n } from './i18n.js'; -import { replaceIconsInElement } from './icons.js'; +import { i18n } from '../core/i18n.js'; +import { replaceIconsInElement } from '../core/icons.js'; /** * Modal System for OxiCloud @@ -40,6 +40,14 @@ const Modal = { // Rename mode: select only name without extension _selectNameOnly: false, + // Panel mode — openPanel() sets this; skips input-focus logic + /** @private */ + _panelMode: false, + + // Saved modal-body innerHTML to restore when a panel closes + /** @private */ + _savedBodyHTML: '', + /** * Initialize modal system */ @@ -84,6 +92,13 @@ const Modal = { this.close(false); } }); + + // Escape in panel mode (input isn't focused so the above handler won't fire) + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this._panelMode && !this.overlay?.classList.contains('hidden')) { + this.close(false); + } + }); }, /** @param {string} message */ @@ -259,6 +274,8 @@ const Modal = { this._action = null; this.overlay.classList.remove('active'); + const wasPanel = this._panelMode; + setTimeout(() => { this.overlay.classList.add('hidden'); @@ -269,6 +286,15 @@ const Modal = { // Clear callbacks this.onConfirm = null; this.onCancel = null; + + // Restore original modal-body content after a panel closes + if (wasPanel) { + const bodyEl = this.overlay?.querySelector('.modal-body'); + if (bodyEl) bodyEl.innerHTML = this._savedBodyHTML; + this.overlay?.querySelector('.modal-container')?.classList.remove('modal-container--panel'); + this._panelMode = false; + this._savedBodyHTML = ''; + } }, 200); }, @@ -277,6 +303,13 @@ const Modal = { * until it resolves — closing only on success, showing the error inline on failure. */ async confirm() { + // Panel mode: delegate entirely to the caller-supplied onConfirm + if (this._panelMode) { + if (this.onConfirm) this.onConfirm(); + this.close(true); + return; + } + if (!this._action) { if (this.onConfirm) this.onConfirm(); this.close(true); @@ -298,6 +331,71 @@ const Modal = { this.confirmBtn.disabled = false; this.input.focus(); } + }, + + /** + * Open the modal with fully custom body content (panel mode). + * + * The caller supplies a pre-built HTMLElement as `content`; it is injected + * into `.modal-body`, replacing the default label/input/error elements for + * the lifetime of this panel. The overlay, header, animation, footer + * buttons, click-outside, and Escape handling all come from Modal. + * + * Original `.modal-body` innerHTML is restored automatically when the + * panel closes. + * + * @param {Object} options + * @param {string} options.title + * @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt' + * @param {HTMLElement} options.content - DOM node to inject into .modal-body + * @param {string} [options.confirmText] - Confirm button label + * @param {string} [options.cancelText] - Cancel button label + * @param {() => void} [options.onConfirm] - Called when Confirm is clicked + * @param {() => void} [options.onCancel] - Called when Cancel / close is triggered + */ + openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) { + if (!this.overlay) return; + + this._panelMode = true; + + // ── Header ────────────────────────────────────────────────────────── + const iconContainer = this.overlay.querySelector('.modal-icon'); + if (iconContainer) { + iconContainer.innerHTML = ``; + if (replaceIconsInElement) replaceIconsInElement(iconContainer); + } + if (this.title) this.title.textContent = title; + + // ── Body swap ─────────────────────────────────────────────────────── + const bodyEl = this.overlay.querySelector('.modal-body'); + if (bodyEl) { + this._savedBodyHTML = bodyEl.innerHTML; + bodyEl.replaceChildren(content); + } + + // ── Container size modifier ────────────────────────────────────────── + this.overlay.querySelector('.modal-container')?.classList.add('modal-container--panel'); + + // ── Footer buttons ────────────────────────────────────────────────── + if (this.confirmBtn) { + this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply'); + this.confirmBtn.disabled = false; + } + if (this.cancelBtn) { + this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel'); + } + + // ── Callbacks ─────────────────────────────────────────────────────── + this.onConfirm = onConfirm; + this.onCancel = onCancel; + this._action = null; + this.clearError(); + + // ── Show overlay (same animation as prompt, no input focus) ───────── + this.overlay.classList.remove('hidden'); + requestAnimationFrame(() => { + this.overlay.classList.add('active'); + }); } }; diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js new file mode 100644 index 00000000..344bf8bf --- /dev/null +++ b/static/js/components/shareModal.js @@ -0,0 +1,1019 @@ +// @ts-check + +/** + * ShareModal — unified sharing dialog for files and folders. + * + * Covers two areas: + * • People (user-to-user grants via `/api/grants`) + * • Public links (via `/api/shares`) + * + * All mutations are staged locally and committed only when the user clicks + * Apply. The only immediate action is Copy Link (clipboard). + * + * The dialog shell (overlay, animation, header, footer, Escape/click-outside + * handling) is delegated entirely to `Modal.openPanel()`. + */ + +import { ui } from '../app/ui.js'; +import { i18n } from '../core/i18n.js'; +import { fileSharing } from '../features/sharing/fileSharing.js'; +import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js'; +import { grants } from '../model/grants.js'; +import { systemUsers } from '../model/systemUsers.js'; +import { Modal } from './modal.js'; +import { _colorIndex, _initials } from './userVignette.js'; + +/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */ + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Permissions that belong to each role (must mirror the Rust DTO). */ +const ROLE_PERMISSIONS = { + viewer: ['read'], + editor: ['read', 'comment', 'create', 'update'], + admin: ['read', 'comment', 'create', 'update', 'share', 'delete'] +}; + +/** + * Derive the highest role a set of grants represents for one subject. + * @param {Grant[]} subjectGrants + * @returns {ShareRoleEnum} + */ +function _roleFromGrants(subjectGrants) { + const perms = new Set(subjectGrants.map((g) => g.permission)); + if (perms.has('delete') || perms.has('share')) return 'admin'; + if (perms.has('create') || perms.has('update')) return 'editor'; + return 'viewer'; +} + +/** + * Group grants by subject id and return one MemberEntry per unique subject. + * @param {Grant[]} grantList + * @returns {MemberEntry[]} + */ +function _buildMembers(grantList) { + /** @type {Map} */ + const bySubject = new Map(); + for (const g of grantList) { + // Token grants represent public-link access — they belong in the Links + // section, not the People section. + if (g.subject.type === 'token') continue; + const key = g.subject.id; + if (!bySubject.has(key)) bySubject.set(key, []); + bySubject.get(key).push(g); + } + /** @type {MemberEntry[]} */ + const members = []; + for (const subjectGrants of bySubject.values()) { + members.push({ + grant: subjectGrants[0], // representative grant (used for subject/resource info) + _grants: subjectGrants, // all grants — needed to revoke every permission on remove + role: _roleFromGrants(subjectGrants), + _op: 'keep' + }); + } + return members; +} + +// ── Component ────────────────────────────────────────────────────────────────── + +const shareModal = { + // ── State ────────────────────────────────────────────────────────────────── + + /** @type {FileItem|FolderItem|null} */ + _item: null, + + /** @type {'file'|'folder'} */ + _itemType: 'file', + + /** @type {MemberEntry[]} */ + _localMembers: [], + + /** @type {LinkEntry[]} */ + _localLinks: [], + + /** @type {DraftLink[]} */ + _newLinks: [], + + /** @type {ContactItem[]} */ + _stagedUsers: [], + + /** @type {ShareRoleEnum} */ + _stagedRole: 'viewer', + + /** @type {HTMLElement|null} — body node injected into Modal */ + _bodyEl: null, + + // ── Public API ───────────────────────────────────────────────────────────── + + /** + * Open the share modal for a file or folder. + * @param {FileItem|FolderItem} item + * @param {'file'|'folder'} itemType + */ + async open(item, itemType) { + this._item = item; + this._itemType = itemType; + this._localMembers = []; + this._localLinks = []; + this._newLinks = []; + this._stagedUsers = []; + this._stagedRole = 'viewer'; + + const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`; + + // Build body with loading skeleton + this._bodyEl = this._buildSkeleton(); + + Modal.openPanel({ + title, + icon: 'fa-share-alt', + content: this._bodyEl, + confirmText: i18n.t('actions.apply', 'Apply'), + onConfirm: () => { + this._applyAll(); + } // intentionally discard Promise + }); + + // Prefetch system users in background so tooltips resolve instantly. + systemUsers.prefetch(); + + // Load data + try { + const [grantList, linkList] = await Promise.all([ + grants.fetchGrantsForResource(itemType, item.id), + fileSharing.getSharedLinksForItem(item.id, itemType) + ]); + + this._localMembers = _buildMembers(grantList); + this._localLinks = linkList.map((share) => /** @type {LinkEntry} */ ({ share, _op: 'keep', _draft: null })); + } catch (err) { + console.error('shareModal: load error', err); + } + + // Swap skeleton → real content + if (this._bodyEl) { + this._bodyEl.replaceChildren(...this._buildContent()); + } + }, + + /** + * Close the modal (delegates to Modal.close). + */ + close() { + Modal.close(false); + }, + + // ── Skeleton ─────────────────────────────────────────────────────────────── + + /** + * @returns {HTMLElement} + */ + _buildSkeleton() { + const body = document.createElement('div'); + body.className = 'smd-body'; + const skel = document.createElement('div'); + skel.className = 'smd-skeleton'; + for (const cls of ['smd-skeleton-line smd-skeleton-line--short', 'smd-skeleton-line smd-skeleton-line--medium', 'smd-skeleton-line']) { + const line = document.createElement('div'); + line.className = cls; + skel.appendChild(line); + } + body.appendChild(skel); + return body; + }, + + // ── Content builder ──────────────────────────────────────────────────────── + + /** + * Build the two sections (People + Links) as an array of elements. + * @returns {HTMLElement[]} + */ + _buildContent() { + return [this._buildPeopleSection(), this._buildLinksSection()]; + }, + + // ── People section ───────────────────────────────────────────────────────── + + /** + * @returns {HTMLElement} + */ + _buildPeopleSection() { + const section = document.createElement('div'); + section.className = 'smd-section'; + + const title = document.createElement('div'); + title.className = 'smd-section-title'; + title.textContent = i18n.t('share.people', 'People'); + section.appendChild(title); + + if (addressBook.isSystemAvailable()) { + section.appendChild(this._buildSearchRow()); + section.appendChild(this._buildChipsRow()); + } else { + const note = document.createElement('p'); + note.className = 'smd-directory-unavailable'; + note.textContent = i18n.t('share.directoryUnavailable', 'User directory unavailable'); + section.appendChild(note); + } + + section.appendChild(this._buildMemberGroups()); + return section; + }, + + /** + * @returns {HTMLElement} + */ + _buildSearchRow() { + const row = document.createElement('div'); + row.className = 'smd-search-row'; + + // ── Search input + dropdown ────────────────────────────────────────── + const wrap = document.createElement('div'); + wrap.className = 'smd-search-wrap'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'smd-search-input'; + input.placeholder = i18n.t('share.searchPlaceholder', 'Search people…'); + input.autocomplete = 'off'; + + const dropdown = document.createElement('div'); + dropdown.className = 'smd-suggestions hidden'; + + wrap.appendChild(input); + wrap.appendChild(dropdown); + + // ── Role select ────────────────────────────────────────────────────── + const roleSelect = document.createElement('select'); + roleSelect.className = 'smd-role-select'; + for (const [val, label] of [ + ['viewer', i18n.t('share.role.viewer', 'Viewer')], + ['editor', i18n.t('share.role.editor', 'Editor')], + ['admin', i18n.t('share.role.admin', 'Admin')] + ]) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label; + if (val === this._stagedRole) opt.selected = true; + roleSelect.appendChild(opt); + } + roleSelect.addEventListener('change', () => { + this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value); + }); + + // ── Add button ─────────────────────────────────────────────────────── + const addBtn = document.createElement('button'); + addBtn.className = 'smd-add-btn btn btn-secondary'; + addBtn.textContent = i18n.t('actions.add', 'Add'); + addBtn.disabled = true; + + // Search debounce + /** @type {ReturnType|null} */ + let debounce = null; + + input.addEventListener('input', () => { + if (debounce) clearTimeout(debounce); + const q = input.value.trim(); + if (!q) { + dropdown.classList.add('hidden'); + dropdown.replaceChildren(); + return; + } + debounce = setTimeout(async () => { + const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]); + // Filter out the currently logged-in user — they cannot share with themselves + const currentUserId = (() => { + try { + return /** @type {{id?:string}} */ (JSON.parse(localStorage.getItem('oxicloud_user') ?? '{}'))?.id ?? null; + } catch { + return null; + } + })(); + const filtered = currentUserId ? results.filter((c) => c.id !== currentUserId) : results; + this._renderSuggestions(dropdown, filtered.slice(0, 8), (contact) => { + this._stageUser(contact, input, dropdown, addBtn); + }); + }, 200); + }); + + // Close dropdown on click outside + document.addEventListener( + 'click', + (e) => { + if (!wrap.contains(/** @type {Node} */ (e.target))) { + dropdown.classList.add('hidden'); + } + }, + { once: false } + ); + + addBtn.addEventListener('click', () => { + if (this._stagedUsers.length === 0) return; + this._commitStagedUsers(); + addBtn.disabled = true; + }); + + row.appendChild(wrap); + row.appendChild(roleSelect); + row.appendChild(addBtn); + + return row; + }, + + /** + * @param {HTMLElement} container + * @param {ContactItem[]} results + * @param {(c: ContactItem) => void} onSelect + */ + _renderSuggestions(container, results, onSelect) { + container.replaceChildren(); + if (results.length === 0) { + container.classList.add('hidden'); + return; + } + results.forEach((c) => { + const item = document.createElement('div'); + item.className = 'smd-suggestion-item'; + item.tabIndex = 0; + + const avatar = document.createElement('div'); + avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`; + const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8); + avatar.textContent = _initials(displayName); + + const nameEl = document.createElement('span'); + nameEl.className = 'smd-suggestion-name'; + nameEl.textContent = displayName; + + const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? ''; + if (primaryEmail) { + const emailEl = document.createElement('span'); + emailEl.className = 'smd-suggestion-email'; + emailEl.textContent = primaryEmail; + item.appendChild(avatar); + item.appendChild(nameEl); + item.appendChild(emailEl); + } else { + item.appendChild(avatar); + item.appendChild(nameEl); + } + + const select = () => onSelect(c); + item.addEventListener('click', select); + item.addEventListener('keydown', (e) => { + if (e.key === 'Enter') select(); + }); + container.appendChild(item); + }); + container.classList.remove('hidden'); + }, + + /** + * @param {ContactItem} contact + * @param {HTMLInputElement} inputEl + * @param {HTMLElement} dropdown + * @param {HTMLButtonElement} addBtn + */ + _stageUser(contact, inputEl, dropdown, addBtn) { + // Idempotent: skip duplicates and already-existing members + const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m._op !== 'remove'); + const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id); + if (alreadyMember || alreadyStaged) return; + + this._stagedUsers.push(contact); + this._refreshChips(); + addBtn.disabled = false; + + inputEl.value = ''; + dropdown.classList.add('hidden'); + dropdown.replaceChildren(); + }, + + /** + * @returns {HTMLElement} + */ + _buildChipsRow() { + const row = document.createElement('div'); + row.id = 'smd-chips-row'; + row.className = 'smd-chips'; + this._renderChipsInto(row); + return row; + }, + + _refreshChips() { + const row = /** @type {HTMLElement|null} */ (document.getElementById('smd-chips-row')); + if (row) this._renderChipsInto(row); + }, + + /** + * @param {HTMLElement} container + */ + _renderChipsInto(container) { + container.replaceChildren(); + this._stagedUsers.forEach((c) => { + const chip = document.createElement('div'); + chip.className = 'smd-chip'; + + const avatar = document.createElement('div'); + avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`; + const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8); + avatar.textContent = _initials(displayName); + + const nameEl = document.createElement('span'); + nameEl.textContent = displayName; + + const rm = document.createElement('button'); + rm.className = 'smd-chip-remove'; + rm.innerHTML = '×'; + rm.title = i18n.t('actions.remove', 'Remove'); + rm.addEventListener('click', () => { + this._stagedUsers = this._stagedUsers.filter((u) => u.id !== c.id); + this._refreshChips(); + const addBtn = /** @type {HTMLButtonElement|null} */ (document.querySelector('.smd-add-btn')); + if (addBtn) addBtn.disabled = this._stagedUsers.length === 0; + }); + + chip.appendChild(avatar); + chip.appendChild(nameEl); + chip.appendChild(rm); + container.appendChild(chip); + }); + }, + + _commitStagedUsers() { + for (const contact of this._stagedUsers) { + /** @type {Grant} */ + const placeholderGrant = { + id: '', // not yet persisted + granted_at: 0, + granted_by: '', + subject: { type: 'user', id: contact.id }, + permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]), + resource: { type: this._itemType, id: this._item?.id ?? '' } + }; + this._localMembers.push({ + grant: placeholderGrant, + _grants: [], // no server grants yet — nothing to revoke on remove + role: this._stagedRole, + _op: 'new' + }); + } + this._stagedUsers = []; + this._refreshChips(); + this._refreshMemberGroups(); + }, + + /** + * @returns {HTMLElement} + */ + _buildMemberGroups() { + const container = document.createElement('div'); + container.id = 'smd-member-groups'; + this._renderMemberGroupsInto(container); + return container; + }, + + _refreshMemberGroups() { + const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups')); + if (container) this._renderMemberGroupsInto(container); + }, + + /** + * @param {HTMLElement} container + */ + _renderMemberGroupsInto(container) { + container.replaceChildren(); + const groups = /** @type {ShareRoleEnum[]} */ (['viewer', 'editor', 'admin']); + let memberIndex = 0; + + for (const role of groups) { + const visible = this._localMembers.filter((m) => m.role === role && m._op !== 'remove'); + if (visible.length === 0) continue; + + const group = document.createElement('div'); + group.className = 'smd-group'; + + const header = document.createElement('div'); + header.className = 'smd-group-header'; + + const labelMap = { + admin: i18n.t('share.role.admin', 'Admin'), + editor: i18n.t('share.role.editor', 'Editor'), + viewer: i18n.t('share.role.viewer', 'Viewer') + }; + const badge = document.createElement('span'); + badge.className = 'smd-group-badge'; + badge.textContent = String(visible.length); + header.textContent = labelMap[role]; + header.appendChild(badge); + group.appendChild(header); + + for (const entry of visible) { + group.appendChild(this._buildMemberRow(entry, memberIndex)); + memberIndex++; + } + container.appendChild(group); + } + }, + + /** + * @param {MemberEntry} entry + * @param {number} _idx (unused — color is now derived deterministically from userId) + * @returns {HTMLElement} + */ + _buildMemberRow(entry, _idx) { + const row = document.createElement('div'); + row.className = 'smd-member-row'; + + const avatar = document.createElement('div'); + avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`; + + // Resolve display name async + systemUsers.getDisplayName(entry.grant.subject.id).then((name) => { + avatar.textContent = _initials(name); + nameEl.textContent = name; + }); + + const nameEl = document.createElement('span'); + nameEl.className = 'smd-member-name'; + nameEl.textContent = `${entry.grant.subject.id.slice(0, 8)}…`; + + const roleSelect = document.createElement('select'); + roleSelect.className = 'smd-member-role-select'; + for (const [val, label] of [ + ['viewer', i18n.t('share.role.viewer', 'Viewer')], + ['editor', i18n.t('share.role.editor', 'Editor')], + ['admin', i18n.t('share.role.admin', 'Admin')] + ]) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label; + if (val === entry.role) opt.selected = true; + roleSelect.appendChild(opt); + } + roleSelect.addEventListener('change', () => { + const newRole = /** @type {ShareRoleEnum} */ (roleSelect.value); + entry.role = newRole; + entry._op = entry._op === 'new' ? 'new' : 'change'; + this._refreshMemberGroups(); + }); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'smd-row-action'; + removeBtn.title = i18n.t('actions.remove', 'Remove'); + removeBtn.innerHTML = ''; + removeBtn.addEventListener('click', () => { + entry._op = 'remove'; + this._refreshMemberGroups(); + }); + + row.appendChild(avatar); + row.appendChild(nameEl); + row.appendChild(roleSelect); + row.appendChild(removeBtn); + return row; + }, + + // ── Links section ────────────────────────────────────────────────────────── + + /** + * @returns {HTMLElement} + */ + _buildLinksSection() { + const section = document.createElement('div'); + section.className = 'smd-section'; + + const title = document.createElement('div'); + title.className = 'smd-section-title'; + title.textContent = i18n.t('share.publicLinks', 'Public links'); + section.appendChild(title); + + const listEl = document.createElement('div'); + listEl.id = 'smd-links-list'; + this._renderLinksInto(listEl); + section.appendChild(listEl); + + const newLinkBtn = document.createElement('button'); + newLinkBtn.className = 'smd-new-link-btn'; + newLinkBtn.innerHTML = ` ${i18n.t('share.createLink', 'Create new public link')}`; + newLinkBtn.id = 'smd-new-link-btn'; + + const newLinkForm = document.createElement('div'); + newLinkForm.id = 'smd-new-link-form'; + newLinkForm.className = 'smd-new-link-form hidden'; + newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm)); + + newLinkBtn.addEventListener('click', () => { + newLinkBtn.classList.add('hidden'); + newLinkForm.classList.remove('hidden'); + }); + + section.appendChild(newLinkBtn); + section.appendChild(newLinkForm); + return section; + }, + + /** + * @param {HTMLElement} container + */ + _renderLinksInto(container) { + container.replaceChildren(); + + // Existing links + for (const entry of this._localLinks.filter((e) => e._op !== 'remove')) { + container.appendChild(this._buildLinkRow(entry)); + } + + // Draft (new) links + for (const draft of this._newLinks) { + container.appendChild(this._buildDraftLinkRow(draft)); + } + }, + + _refreshLinks() { + const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list')); + if (container) this._renderLinksInto(container); + }, + + /** + * @param {LinkEntry} entry + * @returns {HTMLElement} + */ + _buildLinkRow(entry) { + const share = entry.share; + const draft = entry._op === 'edit' ? entry._draft : null; + + // Display values: prefer draft overrides when in edit-pending state + const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link'); + const displayPw = draft ? draft.password !== null : share.has_password; + const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null; + + const row = document.createElement('div'); + row.className = 'smd-link-row'; + + const icon = document.createElement('div'); + icon.className = 'smd-link-icon'; + icon.innerHTML = ''; + + const info = document.createElement('div'); + info.className = 'smd-link-info'; + + const name = document.createElement('div'); + name.className = 'smd-link-name'; + name.textContent = displayName; + + const tags = document.createElement('div'); + tags.className = 'smd-link-tags'; + if (displayPw) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${i18n.t('share.passwordProtected', 'Password')}`; + tags.appendChild(t); + } + if (displayExp) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${displayExp}`; + tags.appendChild(t); + } + + info.appendChild(name); + if (tags.children.length) info.appendChild(tags); + + const actions = document.createElement('div'); + actions.className = 'smd-link-actions'; + + // Copy + const copyBtn = document.createElement('button'); + copyBtn.className = 'smd-row-action'; + copyBtn.title = i18n.t('actions.copy', 'Copy'); + copyBtn.innerHTML = ''; + copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url)); + + // Edit + const editBtn = document.createElement('button'); + editBtn.className = 'smd-row-action'; + editBtn.title = i18n.t('actions.edit', 'Edit'); + editBtn.innerHTML = ''; + editBtn.addEventListener('click', () => { + const panel = row.nextElementSibling; + if (panel?.classList.contains('smd-edit-panel')) { + panel.classList.toggle('hidden'); + } else { + const editPanel = this._buildEditPanel(entry, row); + row.after(editPanel); + } + }); + + // Delete + const delBtn = document.createElement('button'); + delBtn.className = 'smd-row-action'; + delBtn.title = i18n.t('actions.delete', 'Delete'); + delBtn.innerHTML = ''; + delBtn.addEventListener('click', () => { + entry._op = 'remove'; + this._refreshLinks(); + }); + + actions.appendChild(copyBtn); + actions.appendChild(editBtn); + actions.appendChild(delBtn); + + row.appendChild(icon); + row.appendChild(info); + row.appendChild(actions); + return row; + }, + + /** + * @param {DraftLink} draft + * @returns {HTMLElement} + */ + _buildDraftLinkRow(draft) { + const row = document.createElement('div'); + row.className = 'smd-link-row'; + + const icon = document.createElement('div'); + icon.className = 'smd-link-icon'; + icon.innerHTML = ''; + + const info = document.createElement('div'); + info.className = 'smd-link-info'; + + const name = document.createElement('div'); + name.className = 'smd-link-name'; + name.textContent = draft.name || i18n.t('share.newLink', 'New link'); + + const tags = document.createElement('div'); + tags.className = 'smd-link-tags'; + if (draft.password) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${i18n.t('share.passwordProtected', 'Password')}`; + tags.appendChild(t); + } + if (draft.expires_at) { + const t = document.createElement('span'); + t.className = 'smd-link-tag'; + t.innerHTML = ` ${draft.expires_at}`; + tags.appendChild(t); + } + + const pending = document.createElement('span'); + pending.className = 'smd-link-tag'; + pending.textContent = i18n.t('share.pending', 'Pending'); + tags.appendChild(pending); + + info.appendChild(name); + if (tags.children.length) info.appendChild(tags); + + const actions = document.createElement('div'); + actions.className = 'smd-link-actions'; + + const delBtn = document.createElement('button'); + delBtn.className = 'smd-row-action'; + delBtn.title = i18n.t('actions.remove', 'Remove'); + delBtn.innerHTML = ''; + delBtn.addEventListener('click', () => { + this._newLinks = this._newLinks.filter((d) => d !== draft); + this._refreshLinks(); + }); + + actions.appendChild(delBtn); + row.appendChild(icon); + row.appendChild(info); + row.appendChild(actions); + 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 ────────────────────────────────────────────────────────────────── + + /** + * Commit all pending local operations to the server, then close. + * @returns {Promise} + */ + async _applyAll() { + if (!this._item) return; + + // Disable the Apply button while working + if (Modal.confirmBtn) Modal.confirmBtn.disabled = true; + + const item = this._item; + const itemType = this._itemType; + + try { + // ── Grants ───────────────────────────────────────────────────────── + for (const m of this._localMembers) { + if (m._op === 'remove') { + // Revoke every individual grant for this subject (one per permission). + for (const g of m._grants) { + if (g.id) await grants.revokeGrant(g.id); + } + } else if (m._op === 'change' && m.grant.id) { + await grants.updateRole({ + subject: { type: m.grant.subject.type, id: m.grant.subject.id }, + resource: { type: itemType, id: item.id }, + role: m.role + }); + } else if (m._op === 'new') { + await grants.createGrant({ + subject: { type: m.grant.subject.type, id: m.grant.subject.id }, + resource: { type: itemType, id: item.id }, + role: m.role + }); + } + } + + // ── Links ────────────────────────────────────────────────────────── + for (const e of this._localLinks) { + if (e._op === 'remove') { + await fileSharing.removeSharedLink(e.share.id); + } else if (e._op === 'edit' && e._draft) { + const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null; + await fileSharing.updateSharedLink(e.share.id, { + password: e._draft.password, + expires_at: expiresTs, + permissions: null + }); + } + } + + for (const draft of this._newLinks) { + await fileSharing.createSharedLink( + item.id, + itemType, + /** @type {import('../core/types.js').CreateShare} */ ({ + item_id: item.id, + item_name: item.name ?? null, + item_type: itemType, + password: draft.password, + // Pass as ms timestamp so fileSharing's new Date(expires_at) works correctly + expires_at: draft.expires_at ? new Date(draft.expires_at).getTime() : null, + permissions: { read: true, write: false, reshare: false } + }) + ); + } + + // ── Refresh badge cache ──────────────────────────────────────────── + await grants.fetchOutgoingGrants(); + + const hasAnyShare = + this._localMembers.some((m) => m._op !== 'remove') || this._localLinks.some((e) => e._op !== 'remove') || this._newLinks.length > 0; + + ui.setSharedVisualState(item.id, itemType, hasAnyShare); + + Modal.close(true); + } catch (err) { + console.error('shareModal._applyAll error:', err); + if (Modal.confirmBtn) Modal.confirmBtn.disabled = false; + } + } +}; + +export { shareModal }; diff --git a/static/js/components/userVignette.js b/static/js/components/userVignette.js new file mode 100644 index 00000000..5ea47462 --- /dev/null +++ b/static/js/components/userVignette.js @@ -0,0 +1,85 @@ +// @ts-check + +/** + * UserVignette — reusable user avatar + name inline component. + * + * Renders a coloured circle with initials (or photo when available) alongside + * an asynchronously-resolved display name. Used in: + * • Owner column (list view) via `ui.resolveOwnerCells()` + * • ShareModal member rows / chips / suggestion items + * + * Usage: + * import { createUserVignette } from './userVignette.js'; + * cell.replaceChildren(createUserVignette(userId, 'sm')); + */ + +import { systemUsers } from '../model/systemUsers.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** + * Get initials for an avatar (1-2 characters). + * @param {string} name + * @returns {string} + */ +export function _initials(name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +/** + * Deterministic color index 0-4 derived from a userId string. + * Same userId always maps to the same color across all components. + * @param {string} userId + * @returns {number} + */ +export function _colorIndex(userId) { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash * 31 + userId.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 5; +} + +// ── Component ────────────────────────────────────────────────────────────────── + +/** + * @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize + */ + +/** + * Create a user vignette element: a coloured initials circle + async-resolved + * display name span. The element is returned immediately with a short-UUID + * placeholder; the name resolves in the background via `systemUsers`. + * + * @param {string} userId UUID of the user + * @param {VignetteSize} [size='sm'] + * @returns {HTMLElement} + */ +export function createUserVignette(userId, size = 'sm') { + const colorIdx = _colorIndex(userId); + + const wrapper = /** @type {HTMLElement} */ (document.createElement('span')); + wrapper.className = `user-vignette user-vignette--${size}`; + + const avatar = document.createElement('span'); + avatar.className = `user-vignette__avatar uv-color-${colorIdx}`; + // Temporary placeholder: first two chars of UUID + avatar.textContent = userId.slice(0, 2).toUpperCase(); + + const nameEl = document.createElement('span'); + nameEl.className = 'user-vignette__name'; + nameEl.textContent = `${userId.slice(0, 8)}…`; + + wrapper.appendChild(avatar); + wrapper.appendChild(nameEl); + + // Resolve full name asynchronously and update both avatar initials and name + systemUsers.getDisplayName(userId).then((name) => { + avatar.textContent = _initials(name); + nameEl.textContent = name; + }); + + return wrapper; +} diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 12d6441d..44c5333f 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -246,6 +246,14 @@ const OxiIcons = { 384, 'M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z' ], + oxiexport: [ + 576, + 'M384.5 24l0 72-64 0c-79.5 0-144 64.5-144 144 0 93.4 82.8 134.8 100.6 142.6 2.2 1 4.6 1.4 7.1 1.4l2.5 0c9.8 0 17.8-8 17.8-17.8 0-8.3-5.9-15.5-12.8-20.3-8.9-6.2-19.2-18.2-19.2-40.5 0-45 36.5-81.5 81.5-81.5l30.5 0 0 72c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2l136-136c9.4-9.4 9.4-24.6 0-33.9L425.5 7c-6.9-6.9-17.2-8.9-26.2-5.2S384.5 14.3 384.5 24zm-272 72c-44.2 0-80 35.8-80 80l0 256c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32c0 8.8-7.2 16-16 16l-256 0c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l16 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-16 0z' + ], + oxiimport: [ + 576, + 'm 360.55,24 v 72 h 64 c 79.5,0 144,64.5 144,144 0,93.4 -82.8,134.8 -100.6,142.6 -2.2,1 -4.6,1.4 -7.1,1.4 h -2.5 c -9.8,0 -17.8,-8 -17.8,-17.8 0,-8.3 5.9,-15.5 12.8,-20.3 8.9,-6.2 19.2,-18.2 19.2,-40.5 0,-45 -36.5,-81.5 -81.5,-81.5 h -30.5 v 72 c 0,9.7 -5.8,18.5 -14.8,22.2 -9,3.7 -19.3,1.7 -26.2,-5.2 l -136,-136 c -9.4,-9.4 -9.4,-24.6 0,-33.9 l 136,-136 c 6.9,-6.9 17.2,-8.9 26.2,-5.2 9,3.7 14.8,12.5 14.8,22.2 z M 112.5,96 c -44.2,0 -80,35.8 -80,80 v 256 c 0,44.2 35.8,80 80,80 h 256 c 44.2,0 80,-35.8 80,-80 v -32 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 32 c 0,8.8 -7.2,16 -16,16 h -256 c -8.8,0 -16,-7.2 -16,-16 V 176 c 0,-8.8 7.2,-16 16,-16 h 16 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z' + ], pen: [ 512, 'M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z' @@ -258,6 +266,10 @@ const OxiIcons = { 384, 'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z' ], + 'pencil-alt': [ + 512, + 'M36.4 353.2c4.1-14.6 11.8-27.9 22.6-38.7l181.2-181.2 33.9-33.9c16.6 16.6 51.3 51.3 104 104l33.9 33.9-33.9 33.9-181.2 181.2c-10.7 10.7-24.1 18.5-38.7 22.6L30.4 510.6c-8.3 2.3-17.3 0-23.4-6.2S-1.4 489.3 .9 481L36.4 353.2zm55.6-3.7c-4.4 4.7-7.6 10.4-9.3 16.6l-24.1 86.9 86.9-24.1c6.4-1.8 12.2-5.1 17-9.7L91.9 349.5zm354-146.1c-16.6-16.6-51.3-51.3-104-104L308 65.5C334.5 39 349.4 24.1 352.9 20.6 366.4 7 384.8-.6 404-.6S441.6 7 455.1 20.6l35.7 35.7C504.4 69.9 512 88.3 512 107.4s-7.6 37.6-21.2 51.1c-3.5 3.5-18.4 18.4-44.9 44.9z' + ], shuffle: [ 512, 'M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z' diff --git a/static/js/core/types.js b/static/js/core/types.js index a1670b80..2aafe956 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -103,6 +103,7 @@ * @property {String} icon_special_class * @property {String} category * @property {String} size_formatted + * @property {string|null} owner_id UUID of the file/folder's actual owner */ /** @@ -253,3 +254,137 @@ * @property {number|null} width * @property {number|null} height */ + +// ------------------- grants + +/** + * @typedef {'read'|'create'|'share'|'comment'|'delete'|'update'} PermissionTypeEnum + */ + +/** + * @typedef {'folder'|'file'} ResourceTypeEnum + */ + +/** + * @typedef {Object} Resource + * @property {ResourceTypeEnum} type + * @property {String} id + */ + +/** + * @typedef {'user'|'group'|'token'|'external'} SubjectTypeEnum + */ + +/** + * @typedef {Object} Subject + * @property {SubjectTypeEnum} type + * @property {String} id + */ + +/** + * @typedef {Object} Grant + * @property {string} id + * @property {number} granted_at + * @property {string} granted_by + * @property {Subject} subject + * @property {PermissionTypeEnum} permission + * @property {Resource} resource + */ + +/** + * Roles: `viewer`, `commenter`, `editor`, `manager`, `admin` + */ + +/** + * One item returned by `GET /api/grants/incoming/resources`. + * Exactly one of `file` / `folder` is populated (indicated by `resource_type`). + * @typedef {Object} SharedWithMeItem + * @property {ResourceTypeEnum} resource_type + * @property {PermissionTypeEnum[]} permissions - All permissions the caller holds on this resource. + * @property {string} granted_at - ISO-8601 timestamp of the earliest grant. + * @property {string} granted_by - UUID of the user who created the grant. + * @property {FileItem|undefined} [file] - Populated when resource_type === 'file'. + * @property {FolderItem|undefined} [folder] - Populated when resource_type === 'folder'. + */ + +/** + * Response for `GET /api/grants/incoming/resources`. + * @typedef {Object} SharedWithMeResponse + * @property {SharedWithMeItem[]} items + * @property {string|undefined} [next_cursor] - Absent when the last page is reached. + */ + +/** + * @typedef {Object} ContactEmail + * @property {string} email + * @property {string} type - e.g. "work", "home" + * @property {boolean} is_primary + */ + +/** + * Mirrors the backend `ContactDto`. + * `id` equals the OxiCloud user UUID for contacts from the system address book. + * @typedef {Object} ContactItem + * @property {string} id + * @property {string} address_book_id + * @property {string} uid - vCard UID + * @property {string|null} [full_name] + * @property {string|null} [first_name] + * @property {string|null} [last_name] + * @property {string|null} [nickname] + * @property {ContactEmail[]} email + * @property {string|null} [organization] + * @property {string|null} [title] + * @property {string|null} [photo_url] + * @property {string} created_at - ISO-8601 + * @property {string} updated_at - ISO-8601 + * @property {string} etag + */ + +/** + * Mirrors the backend `AddressBookResponse`. + * @typedef {Object} AddressBookItem + * @property {string} id + * @property {string} name + * @property {string} owner_id + * @property {string|null} [description] + * @property {string|null} [color] + * @property {boolean} is_public + * @property {boolean} is_readonly + * @property {boolean} is_system + * @property {string} created_at - ISO-8601 + * @property {string} updated_at - ISO-8601 + */ + +// ------------------- share modal + +/** + * Share roles (DTO-layer sugar for the ReBAC permission sets). + * @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum + */ + +/** + * One collaborator row in the share modal's People section. + * @typedef {Object} MemberEntry + * @property {Grant} grant - Representative grant (used for subject/resource info). + * @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1). + * @property {ShareRoleEnum} role - Derived role label shown in the UI. + * @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation. + */ + +/** + * Existing public link with a pending local operation. + * @typedef {Object} LinkEntry + * @property {ShareItem} share - The existing share object. + * @property {'keep'|'remove'|'edit'} _op - Pending local operation. + * @property {DraftLink|null} _draft - Updated fields when _op === 'edit'. + */ + +/** + * A public link staged for creation (not yet committed). + * @typedef {Object} DraftLink + * @property {string} name + * @property {string|null} password + * @property {string|null} expires_at - ISO-8601 date string or null. + */ + diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 12fd5e09..e4395fb1 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -7,11 +7,12 @@ import { resolveHomeFolder } from '../../app/authSession.js'; import { loadFiles } from '../../app/filesView.js'; import { switchToFilesSection } from '../../app/navigation.js'; import { app } from '../../app/state.js'; -import { showConfirmDialog, ui } from '../../app/ui.js'; +import { ui } from '../../app/ui.js'; +import { Modal } from '../../components/modal.js'; +import { shareModal } from '../../components/shareModal.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { escapeHtml } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; -import { Modal } from '../../core/modal.js'; import { favorites } from '../library/favorites.js'; import { musicView } from '../library/music.js'; import { fileSharing } from '../sharing/fileSharing.js'; @@ -157,7 +158,7 @@ const contextMenus = { document.getElementById('share-folder-option').addEventListener('click', () => { const folder = app.contextMenuTargetFolder; if (folder) { - this.showShareDialog(folder, 'folder'); + shareModal.open(folder, 'folder'); } ui.closeContextMenu(); }); @@ -279,7 +280,7 @@ const contextMenus = { document.getElementById('share-file-option').addEventListener('click', () => { const file = app.contextMenuTargetFile; if (file) { - this.showShareDialog(file, 'file'); + shareModal.open(file, 'file'); } ui.closeFileContextMenu(); }); @@ -722,229 +723,6 @@ const contextMenus = { await this.loadMoveDialogFolders(app.userHomeFolderId || null); }, - /** - * Show share dialog for files or folders - * @param {FileItem | FolderItem} item - File or folder object - * @param {ItemTypeEnum} itemType - */ - async showShareDialog(item, itemType) { - try { - const shareDialog = document.getElementById('share-dialog'); - if (!shareDialog) { - console.error('Share dialog element not found in DOM'); - ui.showNotification('Error', 'Share dialog not available'); - return; - } - - // Update dialog title — use the inside header to preserve icon - const dialogHeader = shareDialog.querySelector('.share-dialog-header'); - if (dialogHeader) { - const headerSpan = dialogHeader.querySelector('span'); - const titleText = itemType === 'file' ? i18n.t('dialogs.share_file') : i18n.t('dialogs.share_folder'); - if (headerSpan) { - headerSpan.textContent = titleText; - } else { - dialogHeader.textContent = titleText; - } - } - - const itemName = document.getElementById('shared-item-name'); - if (itemName) itemName.textContent = item.name; - - // Reset form - const pwField = /** @type HTMLInputElement */ (document.getElementById('share-password')); - const expField = /** @type HTMLInputElement */ (document.getElementById('share-expiration')); - if (pwField) pwField.value = ''; - if (expField) expField.value = ''; - const permRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read')); - const permWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write')); - const permReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare')); - if (permRead) permRead.checked = true; - if (permWrite) permWrite.checked = false; - if (permReshare) permReshare.checked = false; - - // Store the current item and type for use when creating the share - app.shareDialogItem = item; - app.shareDialogItemType = itemType; - - // Check if item already has shares (async API call) - const existingShares = await fileSharing.getSharedLinksForItem(item.id, itemType); - const existingSharesContainer = document.getElementById('existing-shares-container'); - - // Clear existing shares container - existingSharesContainer.innerHTML = ''; - - if (existingShares.length > 0) { - document.getElementById('existing-shares-section').classList.remove('hidden'); - - // Create elements for each existing share - existingShares.forEach((share) => { - const shareEl = document.createElement('div'); - shareEl.className = 'existing-share-item'; - - const expiresText = share.expires_at ? `Expires: ${fileSharing.formatExpirationDate(share.expires_at)}` : 'No expiration'; - - // Share URL - const urlDiv = document.createElement('div'); - urlDiv.className = 'share-url'; - urlDiv.textContent = share.url; - shareEl.appendChild(urlDiv); - - // Share info - const infoDiv = document.createElement('div'); - infoDiv.className = 'share-info'; - if (share.has_password) { - const protectedSpan = document.createElement('span'); - protectedSpan.className = 'share-protected'; - protectedSpan.innerHTML = ' Password protected'; - infoDiv.appendChild(protectedSpan); - } - const expirationSpan = document.createElement('span'); - expirationSpan.className = 'share-expiration'; - expirationSpan.textContent = expiresText; - infoDiv.appendChild(expirationSpan); - shareEl.appendChild(infoDiv); - - // Share actions - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'share-actions'; - - const copyBtn = document.createElement('button'); - copyBtn.className = 'btn btn-small copy-link-btn'; - copyBtn.dataset.shareUrl = share.url; - copyBtn.innerHTML = ' Copy'; - actionsDiv.appendChild(copyBtn); - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'btn btn-small btn-danger delete-link-btn'; - deleteBtn.dataset.shareId = share.id; - deleteBtn.innerHTML = ' Delete'; - actionsDiv.appendChild(deleteBtn); - - shareEl.appendChild(actionsDiv); - - existingSharesContainer.appendChild(shareEl); - }); - - // Add event listeners for copy and delete buttons - document.querySelectorAll('.copy-link-btn').forEach((btn) => { - btn.addEventListener('click', (e) => { - e.preventDefault(); - const url = btn.getAttribute('data-share-url'); - fileSharing.copyLinkToClipboard(url); - }); - }); - - document.querySelectorAll('.delete-link-btn').forEach((btn) => { - btn.addEventListener('click', (e) => { - e.preventDefault(); - const shareId = btn.getAttribute('data-share-id'); - - showConfirmDialog({ - title: i18n.t('dialogs.confirm_delete_share'), - message: i18n.t('dialogs.confirm_delete_share_msg'), - confirmText: i18n.t('actions.delete') - }).then(async (confirmed) => { - if (confirmed) { - await fileSharing.removeSharedLink(shareId); - btn.closest('.existing-share-item').remove(); - if (existingSharesContainer.children.length === 0) { - document.getElementById('existing-shares-section').classList.add('hidden'); - ui.setSharedVisualState(item.id, itemType, false); - } - } - }); - }); - }); - } else { - document.getElementById('existing-shares-section').classList.add('hidden'); - } - - // Hide new-share section from previous use - const newShareSection = document.getElementById('new-share-section'); - if (newShareSection) newShareSection.classList.add('hidden'); - - // Show dialog - shareDialog.classList.remove('hidden'); - console.log('Share dialog opened for', itemType, item.name); - } catch (error) { - console.error('Error opening share dialog:', error); - ui.showNotification('Error', 'Could not open share dialog'); - } - }, - - /** - * Create a shared link with the configured options - */ - async createSharedLink() { - if (!app.shareDialogItem || !app.shareDialogItemType) { - ui.showNotification('Error', 'Could not share the item'); - return; - } - - // Get values from form - const password = /** @type HTMLInputElement */ (document.getElementById('share-password')).value; - const expirationDate = /** @type HTMLInputElement */ (document.getElementById('share-expiration')).value; - const permissionRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read')).checked; - const permissionWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write')).checked; - const permissionReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare')).checked; - - const item = app.shareDialogItem; - const itemType = app.shareDialogItemType; - - // Build DTO for backend API - const createDto = { - item_id: item.id, - item_name: item.name || null, - item_type: itemType, - password: password || null, - expires_at: expirationDate ? Math.floor(new Date(expirationDate).getTime() / 1000) : null, - permissions: { - read: permissionRead, - write: permissionWrite, - reshare: permissionReshare - } - }; - - try { - const headers = { - 'Content-Type': 'application/json', - ...getCsrfHeaders() - }; - - const response = await fetch('/api/shares', { - method: 'POST', - headers, - body: JSON.stringify(createDto) - }); - - if (!response.ok) { - const errBody = await response.json().catch(() => ({})); - throw new Error(errBody.error || `Server error ${response.status}`); - } - - const shareInfo = await response.json(); - - // Update UI with new share - const shareUrl = /** @type HTMLInputElement */ (document.getElementById('generated-share-url')); - if (shareUrl) { - shareUrl.value = shareInfo.url; - document.getElementById('new-share-section').classList.remove('hidden'); - shareUrl.focus(); - shareUrl.select(); - } - - // Update Item's shared badge - ui.setSharedVisualState(item.id, itemType, true); - - // Show success message - ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success')); - } catch (error) { - console.error('Error creating shared link:', error); - ui.showNotification('Error', /** @type {Error} */ (error).message || 'Could not create shared link'); - } - }, - /** * Show email notification dialog * @param {string} shareUrl - URL to share @@ -991,16 +769,6 @@ const contextMenus = { } }, - /** - * Close share dialog - */ - closeShareDialog() { - const dialog = document.getElementById('share-dialog'); - if (dialog) dialog.classList.add('hidden'); - app.shareDialogItem = null; - app.shareDialogItemType = null; - }, - /** * Close notification dialog */ diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 4e12c9a2..86939cc7 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -201,7 +201,7 @@ const favorites = { const files = []; for (const item of this._cache.values()) { - // TODO: cast objects, but for that need to review user_id vs owner_id... + // owner_id comes from the backend JOIN (actual file/folder owner, not the favoriter) if (item.item_type === 'folder') { folders.push( // FIXME: better to grab the real values @@ -215,7 +215,7 @@ const favorites = { created_at: item.created_at, icon_class: item.icon_class, icon_special_class: item.icon_special_class, - owner_id: item.user_id, + owner_id: item.owner_id ?? '', is_root: false } ); @@ -234,7 +234,7 @@ const favorites = { size_formatted: item.size_formatted, modified_at: item.modified_at || item.created_at, path: item.item_path || '', - owner_id: item.user_id, + owner_id: item.owner_id ?? '', created_at: item.created_at, sort_date: item.created_at } @@ -246,6 +246,8 @@ const favorites = { const filesList = document.getElementById('files-list'); if (filesList) pathTooltip.init(filesList); + + await ui.resolveOwnerCells(); } catch (error) { console.error('Error displaying favorites:', error); if (ui?.showNotification) { diff --git a/static/js/features/library/music.js b/static/js/features/library/music.js index efd2742f..edb953c5 100644 --- a/static/js/features/library/music.js +++ b/static/js/features/library/music.js @@ -1,9 +1,9 @@ import { app } from '../../app/state.js'; +import { Modal } from '../../components/modal.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { formatFileSize } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import { oxiIcon } from '../../core/icons.js'; -import { Modal } from '../../core/modal.js'; import { notifications } from '../../core/notifications.js'; /** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */ diff --git a/static/js/features/ownerTooltip.js b/static/js/features/ownerTooltip.js new file mode 100644 index 00000000..3349374d --- /dev/null +++ b/static/js/features/ownerTooltip.js @@ -0,0 +1,121 @@ +// @ts-check + +/** + * Owner tooltip — shows "Shared by: " when hovering a + * `.file-item[data-owner-id]` element. + * + * Reuses the existing `#path-tooltip` DOM element (same position and style) + * so no extra CSS is needed. The tooltip is hidden immediately on mouseleave + * and the display-name resolution is async-but-usually-instant because + * `systemUsers` is pre-fetched when the Shared-with-me section is entered. + * + * Usage: + * ownerTooltip.init(containerEl) — call after rendering items + * ownerTooltip.destroy(containerEl) — call when leaving the section + */ + +import { i18n } from '../core/i18n.js'; +import { systemUsers } from '../model/systemUsers.js'; + +// ── Tooltip DOM ─────────────────────────────────────────────────────────────── + +/** @returns {HTMLElement} */ +function _getOrCreateTooltip() { + let el = document.getElementById('path-tooltip'); + if (!el) { + el = document.createElement('div'); + el.id = 'path-tooltip'; + el.className = 'path-tooltip hidden'; + document.querySelector('.main-content')?.appendChild(el); + } + return el; +} + +function _hide() { + document.getElementById('path-tooltip')?.classList.add('hidden'); +} + +// ── Event handlers ──────────────────────────────────────────────────────────── + +/** + * @param {MouseEvent} e + */ +async function _onEnter(e) { + const item = /** @type {HTMLElement} */ (e.currentTarget); + const ownerId = item.dataset.ownerId; + if (!ownerId) return; + + if (!systemUsers.isAvailable()) return; + + const tooltip = _getOrCreateTooltip(); + + // Show immediately with a placeholder so the tooltip appears without lag. + const label = i18n.t('sharedwithme_sharedBy', 'Shared by'); + tooltip.textContent = `${label}: …`; + tooltip.classList.remove('hidden'); + + // Resolve the name (usually instant from the pre-fetched cache). + const name = await systemUsers.getDisplayName(ownerId); + + // Guard: don't update if the user already moved away. + if (!tooltip.classList.contains('hidden')) { + tooltip.textContent = `${label}: ${name}`; + } +} + +function _onLeave() { + _hide(); +} + +// ── Listener registry (WeakMap for leak-free cleanup) ──────────────────────── + +/** + * @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers + */ + +/** @type {WeakMap} */ +const _registry = new WeakMap(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Attach owner-tooltip listeners to every `.file-item[data-owner-id]` + * inside `container`. + * @param {HTMLElement} container + */ +function init(container) { + for (const item of container.querySelectorAll('.file-item[data-owner-id]')) { + const el = /** @type {HTMLElement} */ (item); + if (_registry.has(el)) continue; // already wired + + /** @type {(e: MouseEvent) => void} */ + const enter = (e) => { + _onEnter(e); + }; // intentionally discard the Promise + const leave = () => _onLeave(); + + el.addEventListener('mouseenter', enter); + el.addEventListener('mouseleave', leave); + _registry.set(el, { enter, leave }); + } +} + +/** + * Remove owner-tooltip listeners from all `.file-item` elements inside + * `container` and hide any visible tooltip. + * @param {HTMLElement} container + */ +function destroy(container) { + for (const item of container.querySelectorAll('.file-item')) { + const el = /** @type {HTMLElement} */ (item); + const h = _registry.get(el); + if (h) { + el.removeEventListener('mouseenter', h.enter); + el.removeEventListener('mouseleave', h.leave); + _registry.delete(el); + } + } + _hide(); +} + +export const ownerTooltip = { init, destroy }; diff --git a/static/js/model/addressBook.js b/static/js/model/addressBook.js new file mode 100644 index 00000000..460201f1 --- /dev/null +++ b/static/js/model/addressBook.js @@ -0,0 +1,179 @@ +// @ts-check + +/** + * Address Book model. + * + * Provides access to all address books (user-owned + shared + the virtual + * system book) and their contacts. Serves as the single source of truth for + * contact data across the application — sharing dialogs, owner tooltips, etc. + * + * Caching strategy + * ───────────────── + * • System book — cached for the whole session (contacts = OxiCloud users, + * changes rarely and requires a page reload to pick up anyway). + * • User books — cached on first load; call `invalidate(bookId)` after a + * write (create/update/delete contact) to force a re-fetch. + * + * The system book returns 404 when `OXICLOUD_EXPOSE_SYSTEM_USERS` is disabled. + * In that case `isSystemAvailable()` returns false and all callers degrade + * gracefully. + */ + +/** @import {AddressBookItem, ContactItem} from '../core/types.js' */ + +/** Sentinel id for the virtual system address book. */ +export const SYSTEM_BOOK_ID = 'system'; + +/** @type {AddressBookItem[] | null} */ +let _books = null; + +/** @type {Map} bookId → contacts (loaded books) */ +const _contactCache = new Map(); + +/** @type {Map>} bookId → in-flight request */ +const _inflight = new Map(); + +/** + * `null` = not yet attempted + * `true` = loaded successfully at least once + * `false` = 404 / feature disabled + * @type {boolean | null} + */ +let _systemAvailable = null; + +// ── Address books ───────────────────────────────────────────────────────────── + +/** + * List all address books accessible to the current user. + * Result is cached for the session. + * @returns {Promise} + */ +async function listBooks() { + if (_books !== null) return _books; + const res = await fetch('/api/address-books', { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`addressBook.listBooks: HTTP ${res.status}`); + _books = /** @type {AddressBookItem[]} */ (await res.json()); + return _books; +} + +// ── Contacts ────────────────────────────────────────────────────────────────── + +/** + * List contacts in an address book. + * + * Results are cached per book id. For the system book, a 404 is treated as + * "feature disabled" — an empty array is returned and `isSystemAvailable()` + * will report false. + * + * @param {string} bookId + * @param {{ limit?: number, offset?: number }} [opts] + * @returns {Promise} + */ +async function listContacts(bookId, opts = {}) { + if (_contactCache.has(bookId)) { + return /** @type {ContactItem[]} */ (_contactCache.get(bookId)); + } + + if (_inflight.has(bookId)) { + return /** @type {Promise} */ (_inflight.get(bookId)); + } + + const p = (async () => { + try { + const params = new URLSearchParams(); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.size ? `?${params}` : ''; + + const res = await fetch(`/api/address-books/${encodeURIComponent(bookId)}/contacts${qs}`, { + credentials: 'same-origin', + cache: 'default' + }); + + if (res.status === 404 && bookId === SYSTEM_BOOK_ID) { + _systemAvailable = false; + _contactCache.set(bookId, []); + return /** @type {ContactItem[]} */ ([]); + } + + if (!res.ok) { + throw new Error(`addressBook.listContacts(${bookId}): HTTP ${res.status}`); + } + + const contacts = /** @type {ContactItem[]} */ (await res.json()); + _contactCache.set(bookId, contacts); + if (bookId === SYSTEM_BOOK_ID) _systemAvailable = true; + return contacts; + } finally { + _inflight.delete(bookId); + } + })(); + + _inflight.set(bookId, p); + return p; +} + +/** + * Invalidate the contact cache for a given book so the next `listContacts` + * call re-fetches from the server. Call after any write operation. + * @param {string} bookId + */ +function invalidate(bookId) { + _contactCache.delete(bookId); +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +/** + * Search contacts across one or more address books. + * + * Matching is case-insensitive against the full name, first+last name, and + * primary email. Books are fetched and cached on first use. + * + * @param {string} query + * @param {string[]} [bookIds] - Books to search. Defaults to all cached books. + * Pass `[SYSTEM_BOOK_ID]` to restrict to OxiCloud users. + * @returns {Promise} + */ +async function searchContacts(query, bookIds) { + const ids = bookIds ?? [..._contactCache.keys()]; + const q = query.toLowerCase().trim(); + if (!q) return []; + + /** @type {ContactItem[]} */ + const results = []; + + for (const id of ids) { + const contacts = await listContacts(id); + for (const c of contacts) { + const fullName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || ''; + const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? ''; + + if (fullName.toLowerCase().includes(q) || primaryEmail.toLowerCase().includes(q)) { + results.push(c); + } + } + } + + return results; +} + +// ── Status ──────────────────────────────────────────────────────────────────── + +/** + * Whether the system address book is (or may be) available. + * Returns `true` when status is unknown (not yet fetched). + * Returns `false` only after a confirmed 404. + * @returns {boolean} + */ +function isSystemAvailable() { + return _systemAvailable !== false; +} + +export const addressBook = { + listBooks, + listContacts, + invalidate, + searchContacts, + isSystemAvailable +}; diff --git a/static/js/model/grants.js b/static/js/model/grants.js new file mode 100644 index 00000000..0cdb3440 --- /dev/null +++ b/static/js/model/grants.js @@ -0,0 +1,200 @@ +/** + * @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js' + */ + +import { getCsrfHeaders } from '../core/csrf.js'; + +const grants = { + /** @type {Record>} */ + outgoingGrants: {}, + + /** @type {Record>} */ + incomingGrants: {}, + + async fetchOutgoingGrants() { + const response = await fetch('/api/grants/outgoing'); + + if (!response.ok) { + console.error(`error ${response.status} while fetching /api/grants/outgoing`); + return; + } + + /** @type {Grant[]} */ + const outgoingGrants = await response.json(); + + // Reset and rebuild cache + this.outgoingGrants = {}; + + // store grants by type, then by id + outgoingGrants.forEach((grant) => { + this.outgoingGrants[grant.resource.type] ??= {}; + this.outgoingGrants[grant.resource.type][grant.resource.id] ??= []; + this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant); + }); + }, + + /** + * get grant for a resource + * @param {ResourceTypeEnum} resourceType + * @param {String} id + * @returns {Grant[] | null} + */ + getOutgoingGrantsFor(resourceType, id) { + try { + return this.outgoingGrants[resourceType][id] ?? []; + } catch { + return []; + } + }, + + async fetchIncomingGrants() { + const response = await fetch('/api/grants/incoming'); + + if (!response.ok) { + console.error(`error ${response.status} while fetching /api/grants/incoming`); + return; + } + + /** @type {Grant[]} */ + const incomingGrants = await response.json(); + + // store grants by type, then by id + incomingGrants.forEach((grant) => { + this.incomingGrants[grant.resource.type] ??= {}; + this.incomingGrants[grant.resource.type][grant.resource.id] ??= []; + this.incomingGrants[grant.resource.type][grant.resource.id].push(grant); + }); + }, + + /** + * get grant for a resource + * @param {ResourceTypeEnum} resourceType + * @param {String} id + * @returns {Grant[] | null} + */ + getIncomingGrantsFor(resourceType, id) { + try { + return this.incomingGrants[resourceType][id] ?? []; + } catch { + return []; + } + }, + + /** + * Fetch a cursor-paginated list of resources shared with the current user, + * with full file / folder metadata resolved server-side. + * + * @param {object} [opts] + * @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']). + * @param {number} [opts.limit] - Max items per page (1–200, default 50). + * @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page. + * @returns {Promise} + */ + async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) { + const params = new URLSearchParams({ + limit: String(limit), + resource_types: resourceTypes.join(',') + }); + if (cursor) params.set('cursor', cursor); + + const response = await fetch(`/api/grants/incoming/resources?${params}`); + + if (!response.ok) { + throw new Error(`Failed to fetch shared-with-me items: HTTP ${response.status}`); + } + + return response.json(); + }, + + /** + * Fetch all grants on a specific resource (for the "Manage sharing" panel). + * Refreshes the outgoingGrants cache for this resource. + * + * @param {ResourceTypeEnum} resourceType + * @param {string} resourceId + * @returns {Promise} + */ + async fetchGrantsForResource(resourceType, resourceId) { + const params = new URLSearchParams({ resource_type: resourceType, resource_id: resourceId }); + const response = await fetch(`/api/grants?${params}`, { credentials: 'same-origin' }); + + if (!response.ok) { + throw new Error(`fetchGrantsForResource: HTTP ${response.status}`); + } + + /** @type {Grant[]} */ + const result = await response.json(); + + // Refresh the outgoing cache for this resource + this.outgoingGrants[resourceType] ??= {}; + this.outgoingGrants[resourceType][resourceId] = result; + + return result; + }, + + /** + * Create a new grant. + * Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`. + * + * @param {Object} dto - CreateGrantDto shape + * @returns {Promise} + */ + async createGrant(dto) { + const response = await fetch('/api/grants', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify(dto) + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `createGrant: HTTP ${response.status}`); + } + + return response.json(); + }, + + /** + * Reconcile a subject's role on a resource (replaces all their permissions). + * Body mirrors `UpdateRoleDto`: `{ subject, resource, role }`. + * + * @param {Object} dto - UpdateRoleDto shape + * @returns {Promise} + */ + async updateRole(dto) { + const response = await fetch('/api/grants/role', { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify(dto) + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `updateRole: HTTP ${response.status}`); + } + + return response.json(); + }, + + /** + * Revoke a single grant by its UUID. + * + * @param {string} grantId + * @returns {Promise} + */ + async revokeGrant(grantId) { + const response = await fetch(`/api/grants/${encodeURIComponent(grantId)}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: getCsrfHeaders() + }); + + if (!response.ok) { + throw new Error(`revokeGrant: HTTP ${response.status}`); + } + } +}; + +export { grants }; diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js new file mode 100644 index 00000000..ca34ec38 --- /dev/null +++ b/static/js/model/systemUsers.js @@ -0,0 +1,97 @@ +// @ts-check + +/** + * System-users convenience layer. + * + * Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that + * provides a userId → display-name index. Used wherever a grant's + * `granted_by` UUID needs to be shown as a human-readable name + * (owner tooltips, share dialogs, etc.). + * + * Falls back gracefully when the system address book is disabled + * server-side (`OXICLOUD_EXPOSE_SYSTEM_USERS` not set): `isAvailable()` + * returns false and `getDisplayName()` returns a shortened UUID. + */ + +/** @import {ContactItem} from '../core/types.js' */ + +import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js'; + +/** @type {Map | null} userId → display name, built lazily */ +let _index = null; + +/** + * Derive the best human-readable name from a contact. + * Priority: "First Last" → full_name → primary email → shortened id. + * @param {ContactItem} c + * @returns {string} + */ +function _nameFor(c) { + const parts = /** @type {string[]} */ ([c.first_name, c.last_name].filter(Boolean)); + if (parts.length) return parts.join(' '); + if (c.full_name) return c.full_name; + const mail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email; + if (mail) return mail; + return `${c.id.slice(0, 8)}…`; +} + +/** + * Ensure the index is built (idempotent). + * After loading contacts from the system address book, the current user + * (from localStorage) is injected so owner cells resolve correctly even + * when the server-side address book does not include the logged-in user. + * @returns {Promise} + */ +async function _ensureIndex() { + if (_index !== null) return; + const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID); + _index = new Map(contacts.map((c) => [c.id, _nameFor(c)])); + + // Inject the current user if they are not already in the index + try { + const raw = localStorage.getItem('oxicloud_user'); + if (raw) { + const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw)); + if (u?.id && !_index.has(u.id)) { + const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`; + _index.set(u.id, name); + } + } + } catch { + // localStorage not available or JSON is invalid — silently skip + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Start loading the system address book in the background. + * Safe to call multiple times — subsequent calls are no-ops once loaded. + */ +function prefetch() { + if (!addressBook.isSystemAvailable()) return; + _ensureIndex(); // intentionally fire-and-forget +} + +/** + * Resolve a user UUID to a display name. + * Awaits the first load if not yet cached; subsequent calls resolve instantly. + * + * @param {string} userId + * @returns {Promise} + */ +async function getDisplayName(userId) { + await _ensureIndex(); + return _index?.get(userId) ?? `${userId.slice(0, 8)}…`; +} + +/** + * Returns `false` only after a confirmed 404 from the server (feature + * disabled). Returns `true` when status is unknown or the book loaded OK. + * @returns {boolean} + */ +function isAvailable() { + return addressBook.isSystemAvailable(); +} + +export const systemUsers = { prefetch, getDisplayName, isAvailable }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js new file mode 100644 index 00000000..6cc68ad6 --- /dev/null +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -0,0 +1,249 @@ +/** + * OxiCloud – "Shared with me" view. + * + * Renders files and folders that other users have explicitly granted the + * current user access to, using the cursor-paginated + * `GET /api/grants/incoming/resources` endpoint. + * + * Reuses the existing `#files-list` container and `ui.renderFolders` / + * `ui.renderFiles` so the grid ↔ list toggle and all card components work + * out of the box. A "Load more" button is injected below the files container + * for cursor-based pagination. + * + * NOTE: the grid/list container will be extracted into a reusable component + * in a future refactor — this view is intentionally kept thin. + */ + +import { ui } from '../../app/ui.js'; +import { i18n } from '../../core/i18n.js'; +import { multiSelect } from '../../features/files/multiSelect.js'; +import { ownerTooltip } from '../../features/ownerTooltip.js'; +import { grants } from '../../model/grants.js'; +import { systemUsers } from '../../model/systemUsers.js'; + +/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'swm-load-more-wrapper'; + +const sharedWithMeView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * (Re-)load from page 1 and render into the existing files container. + * Called every time the user switches to this section. + */ + async init() { + this._nextCursor = null; + this._loading = false; + + this._ensureLoadMoreButton(); + + // Start fetching system users in background so tooltips resolve instantly + // by the time the user hovers over an item. + systemUsers.prefetch(); + + // Standard files-view setup: clear list, show container, init multiselect + ui.resetFilesList(); + multiSelect.init(); + ui.updateBreadcrumb(); + + await this._loadPage(); + }, + + /** + * Hide the "Load more" button when leaving this section. + * The files container itself is managed by navigation.js. + */ + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + + const filesList = document.getElementById('files-list'); + if (filesList) ownerTooltip.destroy(filesList); + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + /** + * Fetch one page, map items → FileItem / FolderItem, render them, then + * stamp `data-owner-id` and wire the owner tooltip. + * @returns {Promise} + */ + async _loadPage() { + if (this._loading) return; + this._loading = true; + + try { + const data = await grants.fetchSharedWithMe({ + resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), + limit: 50, + cursor: this._nextCursor ?? undefined + }); + + this._nextCursor = data.next_cursor ?? null; + + if (data.items.length === 0 && !this._nextCursor) { + // First page came back empty + ui.showError(` + +

${i18n.t('sharedwithme_emptyStateTitle', 'Nothing shared with you yet')}

+

${i18n.t('sharedwithme_emptyStateDesc', 'Items shared with you by other users will appear here')}

+ `); + this._setLoadMoreVisible(false); + return; + } + + const { folders, files, ownerMap } = this._mapItems(data.items); + if (folders.length) ui.renderFolders(folders); + if (files.length) ui.renderFiles(files); + + // Stamp data-owner-id on the freshly-rendered cards and attach tooltips. + const filesList = document.getElementById('files-list'); + if (filesList) { + this._stampOwnerIds(filesList, ownerMap); + ownerTooltip.init(filesList); + } + + // Fill the Owner column cells (idempotent: skips already-resolved rows). + await ui.resolveOwnerCells(); + + this._setLoadMoreVisible(!!this._nextCursor); + } catch (err) { + ui.showError(` + +

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

+ `); + console.error('sharedWithMeView: load error', err); + } finally { + this._loading = false; + } + }, + + /** + * Map `SharedWithMeItem[]` to separate arrays for rendering plus an + * `ownerMap` (itemId → grantedBy userId) used to stamp `data-owner-id` + * after the cards are in the DOM. + * + * The backend already includes all display fields (`icon_class`, + * `icon_special_class`, `category`, `size_formatted`) inside the nested + * `file` / `folder` objects, so no client-side enrichment is needed. + * + * @param {SharedWithMeItem[]} items + * @returns {{ folders: FolderItem[], files: FileItem[], ownerMap: Map }} + */ + _mapItems(items) { + /** @type {FolderItem[]} */ + const folders = []; + + /** @type {FileItem[]} */ + const files = []; + + /** @type {Map} itemId → grantedBy userId */ + const ownerMap = new Map(); + + for (const item of items) { + if (item.resource_type === 'folder' && item.folder) { + const f = item.folder; + folders.push( + /** @type {FolderItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + parent_id: f.parent_id ?? '', + owner_id: f.owner_id ?? '', + is_root: f.is_root ?? false, + created_at: f.created_at, + modified_at: f.modified_at, + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: 'folder' + }) + ); + ownerMap.set(f.id, item.granted_by); + } else if (item.resource_type === 'file' && item.file) { + const f = item.file; + files.push( + /** @type {FileItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + folder_id: f.folder_id ?? '', + owner_id: f.owner_id ?? '', + mime_type: f.mime_type, + size: f.size, + size_formatted: f.size_formatted, + created_at: f.created_at, + modified_at: f.modified_at, + sort_date: f.modified_at, + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: f.category + }) + ); + ownerMap.set(f.id, item.granted_by); + } + } + + return { folders, files, ownerMap }; + }, + + /** + * Walk `ownerMap` and set `data-owner-id` on matching `.file-item` cards + * inside `container`. Must be called after `renderFolders`/`renderFiles`. + * + * @param {HTMLElement} container + * @param {Map} ownerMap itemId → grantedBy userId + */ + _stampOwnerIds(container, ownerMap) { + for (const [itemId, ownerId] of ownerMap) { + const el = container.querySelector(`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`); + if (el instanceof HTMLElement) { + el.dataset.ownerId = ownerId; + } + } + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + /** + * Create the "Load more" wrapper once and attach it below `.files-container`. + * Subsequent calls are no-ops. + */ + _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 = 'swm-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'swm-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('sharedwithme_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 { sharedWithMeView }; diff --git a/static/js/workers/README.md b/static/js/workers/README.md new file mode 100644 index 00000000..8134d11a --- /dev/null +++ b/static/js/workers/README.md @@ -0,0 +1 @@ +Put here all workers you don't want to be bundled into tha main application js diff --git a/static/locales/ar.json b/static/locales/ar.json index aca16a60..42b9c9f3 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -5,12 +5,13 @@ }, "nav": { "files": "الملفات", - "shared": "المشترك", + "shared": "مشاركاتي", "recent": "الأخيرة", "favorites": "المفضلة", "photos": "الصور", "music": "الموسيقى", - "trash": "سلة المهملات" + "trash": "سلة المهملات", + "sharedwithme": "مشتركة معي" }, "photos": { "empty_state": "لا توجد صور بعد", @@ -269,7 +270,8 @@ "archive": "أرشيف", "installer": "مثبّت", "code": "كود" - } + }, + "owner": "المالك" }, "dialogs": { "rename_folder": "إعادة تسمية المجلد", @@ -682,5 +684,18 @@ "files": "ملفات", "complete": "{{count}} / {{total}} تم الرفع" }, - "storage_quota_exceeded": "تجاوز حصة التخزين" + "storage_quota_exceeded": "تجاوز حصة التخزين", + "sharedwithme": { + "pageTitle": "مشترك معي", + "pageDescription": "الملفات والمجلدات التي شاركها معك مستخدمون آخرون", + "emptyStateTitle": "لم يُشارك معك أي شيء بعد", + "emptyStateDesc": "ستظهر هنا العناصر التي يشاركها معك مستخدمون آخرون", + "loadMore": "تحميل المزيد", + "sharedBy": "مشترك من قِبل", + "colName": "الاسم", + "colType": "النوع", + "colSharedBy": "مشترك من قِبل", + "colDate": "تاريخ المشاركة", + "colPermissions": "الصلاحيات" + } } diff --git a/static/locales/de.json b/static/locales/de.json index cefca7db..c58a5cb8 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -5,12 +5,13 @@ }, "nav": { "files": "Dateien", - "shared": "Geteilt", + "shared": "Freigaben", "recent": "Zuletzt verwendet", "favorites": "Favoriten", "photos": "Fotos", "music": "Musik", - "trash": "Papierkorb" + "trash": "Papierkorb", + "sharedwithme": "Mit mir geteilt" }, "photos": { "empty_state": "Noch keine Fotos", @@ -269,7 +270,8 @@ "archive": "Archiv", "installer": "Installationsdatei", "code": "Code" - } + }, + "owner": "Eigentümer" }, "dialogs": { "rename_folder": "Ordner umbenennen", @@ -682,5 +684,18 @@ "files": "Dateien", "complete": "{{count}} / {{total}} hochgeladen" }, - "storage_quota_exceeded": "Speicherplatz erschöpft" + "storage_quota_exceeded": "Speicherplatz erschöpft", + "sharedwithme": { + "pageTitle": "Mit mir geteilt", + "pageDescription": "Dateien und Ordner, die andere Benutzer mit Ihnen geteilt haben", + "emptyStateTitle": "Noch nichts mit Ihnen geteilt", + "emptyStateDesc": "Elemente, die andere Benutzer mit Ihnen teilen, erscheinen hier", + "loadMore": "Mehr laden", + "sharedBy": "Geteilt von", + "colName": "Name", + "colType": "Typ", + "colSharedBy": "Geteilt von", + "colDate": "Datum der Freigabe", + "colPermissions": "Berechtigungen" + } } diff --git a/static/locales/en.json b/static/locales/en.json index 46b13b95..b587fd77 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -5,7 +5,8 @@ }, "nav": { "files": "Files", - "shared": "Shared", + "shared": "My shares", + "sharedwithme": "Shared with me", "recent": "Recent", "favorites": "Favorites", "photos": "Photos", @@ -269,7 +270,8 @@ "archive": "Archive", "installer": "Installer", "code": "Code" - } + }, + "owner": "Owner" }, "dialogs": { "rename_folder": "Rename folder", @@ -682,5 +684,18 @@ "files": "files", "complete": "{{count}} / {{total}} uploaded" }, - "storage_quota_exceeded": "Storage quota exceeded" + "storage_quota_exceeded": "Storage quota exceeded", + "sharedwithme": { + "pageTitle": "Shared with me", + "pageDescription": "Files and folders others have shared with you", + "emptyStateTitle": "Nothing shared with you yet", + "emptyStateDesc": "Items shared with you by other users will appear here", + "loadMore": "Load more", + "sharedBy": "Shared by", + "colName": "Name", + "colType": "Type", + "colSharedBy": "Shared by", + "colDate": "Date shared", + "colPermissions": "Permissions" + } } diff --git a/static/locales/es.json b/static/locales/es.json index 6c59eea2..34f0253a 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -10,7 +10,8 @@ "favorites": "Favoritos", "photos": "Fotos", "music": "Música", - "trash": "Papelera" + "trash": "Papelera", + "sharedwithme": "Compartidos conmigo" }, "photos": { "empty_state": "Aún no hay fotos", @@ -269,7 +270,8 @@ "archive": "Archivo comprimido", "installer": "Instalador", "code": "Código" - } + }, + "owner": "Propietario" }, "dialogs": { "rename_folder": "Renombrar carpeta", @@ -682,5 +684,18 @@ "files": "archivos", "complete": "{{count}} / {{total}} subidos" }, - "storage_quota_exceeded": "Cuota de almacenamiento superada" + "storage_quota_exceeded": "Cuota de almacenamiento superada", + "sharedwithme": { + "pageTitle": "Compartido conmigo", + "pageDescription": "Archivos y carpetas que otros usuarios han compartido contigo", + "emptyStateTitle": "Aún no hay nada compartido contigo", + "emptyStateDesc": "Los elementos que otros usuarios compartan contigo aparecerán aquí", + "loadMore": "Cargar más", + "sharedBy": "Compartido por", + "colName": "Nombre", + "colType": "Tipo", + "colSharedBy": "Compartido por", + "colDate": "Fecha de compartición", + "colPermissions": "Permisos" + } } diff --git a/static/locales/fa.json b/static/locales/fa.json index 34ea6b09..7fee57f6 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -5,12 +5,13 @@ }, "nav": { "files": "پرونده‌ها", - "shared": "هم‌رسانی شده", + "shared": "هم‌رسانی‌های من", "recent": "اخیر", "favorites": "موردعلاقه‌ها", "photos": "عکس‌ها", "music": "موسیقی", - "trash": "سطل زباله" + "trash": "سطل زباله", + "sharedwithme": "به اشتراک‌گذاشته شده با من" }, "photos": { "empty_state": "هنوز عکسی نیست", @@ -269,7 +270,8 @@ "archive": "بایگانی", "installer": "نصب‌کننده", "code": "کد" - } + }, + "owner": "مالک" }, "dialogs": { "rename_folder": "تغییر نام پوشه", @@ -682,5 +684,18 @@ "files": "فایل‌ها", "complete": "{{count}} / {{total}} آپلود شد" }, - "storage_quota_exceeded": "سهمیه فضای ذخیره‌سازی تجاوز کرده است" + "storage_quota_exceeded": "سهمیه فضای ذخیره‌سازی تجاوز کرده است", + "sharedwithme": { + "pageTitle": "به اشتراک‌گذاشته شده با من", + "pageDescription": "فایل‌ها و پوشه‌هایی که کاربران دیگر با شما به اشتراک گذاشته‌اند", + "emptyStateTitle": "هنوز چیزی با شما به اشتراک گذاشته نشده", + "emptyStateDesc": "مواردی که کاربران دیگر با شما به اشتراک می‌گذارند اینجا نمایش داده می‌شوند", + "loadMore": "بارگذاری بیشتر", + "sharedBy": "به اشتراک‌گذاشته توسط", + "colName": "نام", + "colType": "نوع", + "colSharedBy": "به اشتراک‌گذاشته توسط", + "colDate": "تاریخ اشتراک‌گذاری", + "colPermissions": "مجوزها" + } } diff --git a/static/locales/fr.json b/static/locales/fr.json index d06fcf81..311033ce 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -5,12 +5,13 @@ }, "nav": { "files": "Fichiers", - "shared": "Partagés", + "shared": "Partages", "recent": "Récents", "favorites": "Favoris", "photos": "Photos", "music": "Musique", - "trash": "Corbeille" + "trash": "Corbeille", + "sharedwithme": "Partages avec moi" }, "photos": { "empty_state": "Pas encore de photos", @@ -269,7 +270,8 @@ "archive": "Archive", "installer": "Installateur", "code": "Code" - } + }, + "owner": "Propriétaire" }, "dialogs": { "rename_folder": "Renommer le dossier", @@ -682,5 +684,18 @@ "files": "fichiers", "complete": "{{count}} / {{total}} téléchargés" }, - "storage_quota_exceeded": "Quota de stockage dépassé" + "storage_quota_exceeded": "Quota de stockage dépassé", + "sharedwithme": { + "pageTitle": "Partagé avec moi", + "pageDescription": "Fichiers et dossiers que d'autres utilisateurs ont partagés avec vous", + "emptyStateTitle": "Rien n'a encore été partagé avec vous", + "emptyStateDesc": "Les éléments partagés avec vous par d'autres utilisateurs apparaîtront ici", + "loadMore": "Charger plus", + "sharedBy": "Partagé par", + "colName": "Nom", + "colType": "Type", + "colSharedBy": "Partagé par", + "colDate": "Date de partage", + "colPermissions": "Permissions" + } } diff --git a/static/locales/hi.json b/static/locales/hi.json index dc85c2e2..983abc1d 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -10,7 +10,8 @@ "favorites": "पसंदीदा", "photos": "फ़ोटो", "music": "संगीत", - "trash": "रद्दी" + "trash": "रद्दी", + "sharedwithme": "मेरे साथ साझा किए गए" }, "photos": { "empty_state": "अभी कोई फ़ोटो नहीं", @@ -269,7 +270,8 @@ "archive": "संग्रह", "installer": "इंस्टॉलर", "code": "कोड" - } + }, + "owner": "स्वामी" }, "dialogs": { "rename_folder": "फ़ोल्डर का नाम बदलें", @@ -682,5 +684,18 @@ "files": "फ़ाइलें", "complete": "{{count}} / {{total}} अपलोड हुए" }, - "storage_quota_exceeded": "स्टोरेज कोटा पार हो गया" + "storage_quota_exceeded": "स्टोरेज कोटा पार हो गया", + "sharedwithme": { + "pageTitle": "मेरे साथ साझा किया", + "pageDescription": "फ़ाइलें और फ़ोल्डर जो अन्य उपयोगकर्ताओं ने आपके साथ साझा किए हैं", + "emptyStateTitle": "अभी तक आपके साथ कुछ भी साझा नहीं किया गया", + "emptyStateDesc": "अन्य उपयोगकर्ताओं द्वारा आपके साथ साझा किए गए आइटम यहाँ दिखाई देंगे", + "loadMore": "और लोड करें", + "sharedBy": "द्वारा साझा किया", + "colName": "नाम", + "colType": "प्रकार", + "colSharedBy": "द्वारा साझा किया", + "colDate": "साझाकरण तिथि", + "colPermissions": "अनुमतियाँ" + } } diff --git a/static/locales/it.json b/static/locales/it.json index 04c96988..5c507919 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -5,12 +5,13 @@ }, "nav": { "files": "File", - "shared": "Condivisi", + "shared": "Condivisioni", "recent": "Recenti", "favorites": "Preferiti", "photos": "Foto", "music": "Musica", - "trash": "Cestino" + "trash": "Cestino", + "sharedwithme": "Condivisi con me" }, "photos": { "empty_state": "Nessuna foto ancora", @@ -269,7 +270,8 @@ "archive": "Archivio", "installer": "Programma di installazione", "code": "Codice" - } + }, + "owner": "Proprietario" }, "dialogs": { "rename_folder": "Rinomina cartella", @@ -682,5 +684,18 @@ "files": "file", "complete": "{{count}} / {{total}} caricati" }, - "storage_quota_exceeded": "Quota di archiviazione superata" + "storage_quota_exceeded": "Quota di archiviazione superata", + "sharedwithme": { + "pageTitle": "Condiviso con me", + "pageDescription": "File e cartelle che altri utenti hanno condiviso con te", + "emptyStateTitle": "Niente è ancora condiviso con te", + "emptyStateDesc": "Gli elementi condivisi con te da altri utenti appariranno qui", + "loadMore": "Carica altri", + "sharedBy": "Condiviso da", + "colName": "Nome", + "colType": "Tipo", + "colSharedBy": "Condiviso da", + "colDate": "Data condivisione", + "colPermissions": "Permessi" + } } diff --git a/static/locales/ja.json b/static/locales/ja.json index eb7899b2..5d64cdfe 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -10,7 +10,8 @@ "favorites": "お気に入り", "photos": "写真", "music": "音楽", - "trash": "ゴミ箱" + "trash": "ゴミ箱", + "sharedwithme": "自分と共有" }, "photos": { "empty_state": "写真はまだありません", @@ -269,7 +270,8 @@ "archive": "アーカイブ", "installer": "インストーラー", "code": "コード" - } + }, + "owner": "オーナー" }, "dialogs": { "rename_folder": "フォルダ名を変更", @@ -682,5 +684,18 @@ "files": "ファイル", "complete": "{{count}} / {{total}} アップロード済み" }, - "storage_quota_exceeded": "ストレージ容量を超過しました" + "storage_quota_exceeded": "ストレージ容量を超過しました", + "sharedwithme": { + "pageTitle": "自分と共有", + "pageDescription": "他のユーザーがあなたと共有したファイルとフォルダー", + "emptyStateTitle": "まだ何も共有されていません", + "emptyStateDesc": "他のユーザーがあなたと共有したアイテムがここに表示されます", + "loadMore": "さらに読み込む", + "sharedBy": "共有者", + "colName": "名前", + "colType": "タイプ", + "colSharedBy": "共有者", + "colDate": "共有日", + "colPermissions": "権限" + } } diff --git a/static/locales/ko.json b/static/locales/ko.json index 663ab4a1..3aa86c9f 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -10,7 +10,8 @@ "favorites": "즐겨찾기", "photos": "사진", "music": "음악", - "trash": "휴지통" + "trash": "휴지통", + "sharedwithme": "나와 공유됨" }, "photos": { "empty_state": "아직 사진이 없습니다", @@ -269,7 +270,8 @@ "archive": "아카이브", "installer": "설치 프로그램", "code": "코드" - } + }, + "owner": "소유자" }, "dialogs": { "rename_folder": "폴더 이름 변경", @@ -682,5 +684,18 @@ "files": "파일", "complete": "{{count}} / {{total}} 업로드됨" }, - "storage_quota_exceeded": "저장 공간 할당량 초과" + "storage_quota_exceeded": "저장 공간 할당량 초과", + "sharedwithme": { + "pageTitle": "나와 공유됨", + "pageDescription": "다른 사용자가 나와 공유한 파일 및 폴더", + "emptyStateTitle": "아직 공유된 항목이 없습니다", + "emptyStateDesc": "다른 사용자가 공유한 항목이 여기에 표시됩니다", + "loadMore": "더 불러오기", + "sharedBy": "공유한 사람", + "colName": "이름", + "colType": "유형", + "colSharedBy": "공유한 사람", + "colDate": "공유 날짜", + "colPermissions": "권한" + } } diff --git a/static/locales/nl.json b/static/locales/nl.json index 582b43bd..a8a8f6f0 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -10,7 +10,8 @@ "favorites": "Favorieten", "photos": "Foto's", "music": "Muziek", - "trash": "Prullenbak" + "trash": "Prullenbak", + "sharedwithme": "Gedeeld met mij" }, "photos": { "empty_state": "Nog geen foto's", @@ -269,7 +270,8 @@ "archive": "Archief", "installer": "Installatiebestand", "code": "Code" - } + }, + "owner": "Eigenaar" }, "dialogs": { "rename_folder": "Map hernoemen", @@ -682,5 +684,18 @@ "files": "bestanden", "complete": "{{count}} / {{total}} geüpload" }, - "storage_quota_exceeded": "Opslagquotum overschreden" + "storage_quota_exceeded": "Opslagquotum overschreden", + "sharedwithme": { + "pageTitle": "Gedeeld met mij", + "pageDescription": "Bestanden en mappen die andere gebruikers met u hebben gedeeld", + "emptyStateTitle": "Er is nog niets met u gedeeld", + "emptyStateDesc": "Items die andere gebruikers met u delen, verschijnen hier", + "loadMore": "Meer laden", + "sharedBy": "Gedeeld door", + "colName": "Naam", + "colType": "Type", + "colSharedBy": "Gedeeld door", + "colDate": "Datum gedeeld", + "colPermissions": "Machtigingen" + } } diff --git a/static/locales/pl.json b/static/locales/pl.json index 1000580e..99165d60 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -10,7 +10,8 @@ "favorites": "Ulubione", "photos": "Zdjęcia", "music": "Muzyka", - "trash": "Kosz" + "trash": "Kosz", + "sharedwithme": "Udostępnione dla mnie" }, "photos": { "empty_state": "Brak zdjęć", @@ -269,7 +270,8 @@ "archive": "Archiwum", "installer": "Instalator", "code": "Kod" - } + }, + "owner": "Właściciel" }, "dialogs": { "rename_folder": "Zmień nazwę folderu", @@ -682,5 +684,18 @@ "files": "plików", "complete": "{{count}} / {{total}} przesłano" }, - "storage_quota_exceeded": "Przekroczono limit pamięci masowej" + "storage_quota_exceeded": "Przekroczono limit pamięci masowej", + "sharedwithme": { + "pageTitle": "Udostępnione dla mnie", + "pageDescription": "Pliki i foldery, które inni użytkownicy udostępnili Ci", + "emptyStateTitle": "Nic nie zostało Ci jeszcze udostępnione", + "emptyStateDesc": "Elementy udostępnione Ci przez innych użytkowników pojawią się tutaj", + "loadMore": "Załaduj więcej", + "sharedBy": "Udostępnione przez", + "colName": "Nazwa", + "colType": "Typ", + "colSharedBy": "Udostępnione przez", + "colDate": "Data udostępnienia", + "colPermissions": "Uprawnienia" + } } diff --git a/static/locales/pt.json b/static/locales/pt.json index 3629935b..048fd7a4 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -5,12 +5,13 @@ }, "nav": { "files": "Arquivos", - "shared": "Compartilhados", + "shared": "Compartilhamentos", "recent": "Recentes", "favorites": "Favoritos", "photos": "Fotos", "music": "Música", - "trash": "Lixeira" + "trash": "Lixeira", + "sharedwithme": "Compartilhados comigo" }, "photos": { "empty_state": "Nenhuma foto ainda", @@ -269,7 +270,8 @@ "archive": "Arquivo compactado", "installer": "Instalador", "code": "Código" - } + }, + "owner": "Proprietário" }, "dialogs": { "rename_folder": "Renomear pasta", @@ -682,5 +684,18 @@ "files": "ficheiros", "complete": "{{count}} / {{total}} carregados" }, - "storage_quota_exceeded": "Cota de armazenamento excedida" + "storage_quota_exceeded": "Cota de armazenamento excedida", + "sharedwithme": { + "pageTitle": "Compartilhado comigo", + "pageDescription": "Arquivos e pastas que outros usuários compartilharam com você", + "emptyStateTitle": "Nada compartilhado com você ainda", + "emptyStateDesc": "Itens compartilhados com você por outros usuários aparecerão aqui", + "loadMore": "Carregar mais", + "sharedBy": "Compartilhado por", + "colName": "Nome", + "colType": "Tipo", + "colSharedBy": "Compartilhado por", + "colDate": "Data de compartilhamento", + "colPermissions": "Permissões" + } } diff --git a/static/locales/ru.json b/static/locales/ru.json index 5451bad5..af9e1972 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -10,7 +10,8 @@ "favorites": "Избранное", "photos": "Фото", "music": "Музыка", - "trash": "Корзина" + "trash": "Корзина", + "sharedwithme": "Доступно мне" }, "photos": { "empty_state": "Фотографий пока нет", @@ -269,7 +270,8 @@ "archive": "Архив", "installer": "Установщик", "code": "Код" - } + }, + "owner": "Владелец" }, "dialogs": { "rename_folder": "Переименовать папку", @@ -682,5 +684,18 @@ "files": "файлов", "complete": "{{count}} / {{total}} загружено" }, - "storage_quota_exceeded": "Превышена квота хранилища" + "storage_quota_exceeded": "Превышена квота хранилища", + "sharedwithme": { + "pageTitle": "Доступно мне", + "pageDescription": "Файлы и папки, которые другие пользователи предоставили вам", + "emptyStateTitle": "Вам ещё ничего не предоставлено", + "emptyStateDesc": "Элементы, которые другие пользователи предоставят вам, появятся здесь", + "loadMore": "Загрузить ещё", + "sharedBy": "Предоставлено", + "colName": "Имя", + "colType": "Тип", + "colSharedBy": "Предоставлено", + "colDate": "Дата предоставления", + "colPermissions": "Права" + } } diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index bb12823b..7e2eb3ee 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -10,7 +10,8 @@ "favorites": "收藏", "photos": "照片", "music": "音樂", - "trash": "回收站" + "trash": "回收站", + "sharedwithme": "與我共享" }, "photos": { "empty_state": "還沒有照片", @@ -269,7 +270,8 @@ "archive": "壓縮檔案", "installer": "安裝程式", "code": "程式碼" - } + }, + "owner": "擁有者" }, "dialogs": { "rename_folder": "重新命名資料夾", @@ -682,5 +684,18 @@ "files": "個檔案", "complete": "已上傳 {{count}} / {{total}}" }, - "storage_quota_exceeded": "儲存配額已超限" + "storage_quota_exceeded": "儲存配額已超限", + "sharedwithme": { + "pageTitle": "與我共享", + "pageDescription": "其他使用者與您共享的檔案和資料夾", + "emptyStateTitle": "目前沒有內容與您共享", + "emptyStateDesc": "其他使用者與您共享的項目將顯示在這裡", + "loadMore": "載入更多", + "sharedBy": "共享者", + "colName": "名稱", + "colType": "類型", + "colSharedBy": "共享者", + "colDate": "共享日期", + "colPermissions": "權限" + } } diff --git a/static/locales/zh.json b/static/locales/zh.json index 70cef715..b24b2492 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -10,7 +10,8 @@ "favorites": "收藏", "photos": "照片", "music": "音乐", - "trash": "回收站" + "trash": "回收站", + "sharedwithme": "与我共享" }, "photos": { "empty_state": "还没有照片", @@ -269,7 +270,8 @@ "archive": "压缩文件", "installer": "安装程序", "code": "代码" - } + }, + "owner": "所有者" }, "dialogs": { "rename_folder": "重命名文件夹", @@ -682,5 +684,18 @@ "files": "个文件", "complete": "已上传 {{count}} / {{total}}" }, - "storage_quota_exceeded": "存储配额已超限" + "storage_quota_exceeded": "存储配额已超限", + "sharedwithme": { + "pageTitle": "与我共享", + "pageDescription": "其他用户与您共享的文件和文件夹", + "emptyStateTitle": "暂无内容与您共享", + "emptyStateDesc": "其他用户与您共享的项目将显示在此处", + "loadMore": "加载更多", + "sharedBy": "共享者", + "colName": "名称", + "colType": "类型", + "colSharedBy": "共享者", + "colDate": "共享日期", + "colPermissions": "权限" + } } diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 766d397d..a10ba6f1 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -150,7 +150,7 @@ jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" # ───────────────────────────────────────────────────────────── -# Step 9 — Promote Bob to Manager (adds comment, create, update, share). +# Step 9 — Promote Bob to Admin (adds comment, create, update, share, delete). # PUT /api/grants/role reconciles the row set in one call. # ───────────────────────────────────────────────────────────── PUT {{base_url}}/api/grants/role @@ -159,12 +159,12 @@ Content-Type: application/json { "subject": { "type": "user", "id": "{{dave_user_id}}" }, "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, - "role": "manager" + "role": "admin" } HTTP 200 [Asserts] -jsonpath "$" count == 5 +jsonpath "$" count == 6 # ───────────────────────────────────────────────────────────── @@ -173,7 +173,7 @@ jsonpath "$" count == 5 PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename Authorization: Bearer {{dave_token}} Content-Type: application/json -{ "name": "renamed-by-bob-as-manager" } +{ "name": "renamed-by-bob-as-admin" } HTTP 200 @@ -192,7 +192,7 @@ HTTP 200 # ───────────────────────────────────────────────────────────── -# Step 12 — Bob re-shares to Carol (he has Share via Manager). +# Step 12 — Bob re-shares to Carol (he has Share via Admin). # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/grants Authorization: Bearer {{dave_token}} diff --git a/tests/load/README.md b/tests/load/README.md new file mode 100644 index 00000000..529be1b5 --- /dev/null +++ b/tests/load/README.md @@ -0,0 +1,11 @@ +iPurpose of this directory: implement load test and identify response time under: +- heavy load +- many content +- many sub folders and sharing + +Goal is to identify inflections and regression when a new feature is added + +No accemtance criteria yet + +Load test via k6 ? +or drill (written in Rust) ?