Merge pull request #390 from EdouardVanbelle/feat/shared-with-me

This commit is contained in:
Dionisio Pozo
2026-05-26 00:00:37 +02:00
committed by GitHub
58 changed files with 4004 additions and 493 deletions
+3
View File
@@ -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/
+5
View File
@@ -51,6 +51,11 @@ pub struct FavoriteItemDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub item_path: Option<String>,
/// 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<String>,
// ── Pre-computed display fields ──
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
pub icon_class: String,
+15
View File
@@ -119,6 +119,21 @@ impl From<FileDto> 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 {
+15
View File
@@ -107,6 +107,21 @@ impl From<FolderDto> 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 {
+58 -3
View File
@@ -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<Permission> 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<Grant> 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<String>,
/// Opaque cursor returned by a previous call. Omit to start from the
/// most-recently-granted item.
pub cursor: Option<String>,
}
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<PermissionDto>,
/// Earliest grant date for this resource.
pub granted_at: chrono::DateTime<chrono::Utc>,
/// UUID of the user who created the (earliest) grant.
pub granted_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<FileDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub folder: Option<FolderDto>,
}
/// Response for `GET /api/grants/incoming/resources`.
#[derive(Debug, Serialize, ToSchema)]
pub struct SharedWithMeDto {
pub items: Vec<SharedWithMeItemDto>,
/// Opaque cursor for the next page. Absent when the last page is reached.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
+21 -1
View File
@@ -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<Permission>,
) -> Result<Vec<Grant>, 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<GrantCursor>,
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), 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<Vec<Grant>, DomainError>;
+52
View File
@@ -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<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
/// ReBAC engine — used to create/revoke token grants that mirror public
/// share links so that `GET /api/grants/outgoing` reflects them.
authorization: Arc<PgAclEngine>,
/// Bounds the number of in-flight Argon2 password hashes to avoid
/// saturating the blocking thread pool and consuming excessive RAM.
hash_semaphore: Arc<Semaphore>,
@@ -90,6 +96,7 @@ impl ShareService {
file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
authorization: Arc<PgAclEngine>,
) -> 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()))
}
+3 -1
View File
@@ -514,6 +514,7 @@ impl AppServiceFactory {
&self,
repos: &RepositoryServices,
db_pool: &Arc<PgPool>,
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
) -> Option<Arc<ShareService>> {
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| {
+79
View File
@@ -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<chrono::Utc>,
}
// ════════════════════════════════════════════════════════════════════════════
// 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<Self> {
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<Permission>,
/// Earliest `granted_at` across all permission rows.
pub granted_at: chrono::DateTime<chrono::Utc>,
/// 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<chrono::Utc>,
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<Self> {
let bytes = URL_SAFE_NO_PAD.decode(s).ok()?;
serde_json::from_slice(&bytes).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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(),
+104 -1
View File
@@ -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<GrantCursor>,
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
// Build kind filter array — NULL means "all kinds".
let kind_strs: Option<Vec<&str>> = 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<String>,
chrono::DateTime<chrono::Utc>,
Uuid,
);
let rows: Vec<Row> = 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<Row> = 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<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
+192 -24
View File
@@ -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<AppState>;
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
@@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser;
tag = "grants"
)]
pub async fn create_grant(
State(authz): State<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Json(dto): Json<CreateGrantDto>,
) -> 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> 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<Option<(Resource, Uuid)>, 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Json(dto): Json<UpdateRoleDto>,
) -> 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> 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<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<SharedWithMeQuery>,
) -> 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<ResourceKind> = 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<String> = file_summaries
.iter()
.map(|s| s.resource_id.to_string())
.collect();
let folder_ids: Vec<String> = folder_summaries
.iter()
.map(|s| s.resource_id.to_string())
.collect();
// 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<SharedWithMeItemDto> = 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
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<GrantDto> = 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<OnResourceQuery>,
) -> impl IntoResponse {
let authz = &state.authorization;
let caller_id = auth_user.id;
let resource: Resource = ResourceDto {
kind: q.resource_type,
+25
View File
@@ -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",
+11 -3
View File
@@ -165,7 +165,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
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<AppState>) -> Router<Arc<AppState>> {
Router::new()
};
// Create routes for ReBAC grants (/api/grants) — single state: the authz engine.
// Create routes for ReBAC grants (/api/grants).
// State is Arc<AppState> so that the new list_shared_with_me handler can
// access file/folder services. Existing handlers still extract
// State<Arc<PgAclEngine>> 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<AppState>) -> Router<Arc<AppState>> {
.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
+8
View File
@@ -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;
+23 -2
View File
@@ -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 */
+20
View File
@@ -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;
}
+537
View File
@@ -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);
}
}
+97
View File
@@ -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);
}
+2
View File
@@ -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");
+13
View File
@@ -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;
}
+7 -2
View File
@@ -14,6 +14,7 @@
<link rel="stylesheet" href="/css/views/favorites.css">
<link rel="stylesheet" href="/css/views/recent.css">
<link rel="stylesheet" href="/css/views/shared.css">
<link rel="stylesheet" href="/css/views/sharedWithMe.css">
<link rel="stylesheet" href="/css/views/trash.css">
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css">
@@ -24,7 +25,7 @@
<script defer type="module" src="/js/core/csrf.js"></script>
<script defer type="module" src="/js/core/languageSelector.js"></script>
<script defer type="module" src="/js/core/notifications.js"></script>
<script defer type="module" src="/js/core/modal.js"></script>
<script defer type="module" src="/js/components/modal.js"></script>
<script defer type="module" src="/js/core/formatters.js"></script>
<script defer type="module" src="/js/app/state.js"></script>
<script defer type="module" src="/js/app/uiFileTypes.js"></script>
@@ -76,9 +77,13 @@
<span data-i18n="nav.files">Files</span>
</div>
<div class="nav-item" id="nav-shared">
<i class="fas fa-share-alt"></i>
<i class="fas fa-oxiexport"></i>
<span data-i18n="nav.shared">Shared</span>
</div>
<div class="nav-item" id="nav-sharedwithme">
<i class="fas fa-oxiimport"></i>
<span data-i18n="nav.sharedwithme">Shared with me</span>
</div>
<div class="nav-item">
<i class="fas fa-clock"></i>
<span data-i18n="nav.recent">Recent</span>
+31 -14
View File
@@ -42,7 +42,17 @@ async function getFolder(id) {
}
/**
* rebuild breadcrumb from selected folder (iterate up to root)
* Rebuild breadcrumb from selected folder (iterate up to root).
*
* Stops traversal gracefully when a parent folder is not accessible
* (e.g. the user entered via a "Shared with me" grant whose parent
* folder they have no permission on). In that case the partial
* breadcrumb built so far is kept — the deepest reachable ancestor
* acts as the visual root, matching how Google Drive / Dropbox handle
* shared subtrees.
*
* An error on the *target folder itself* (first iteration) is still
* treated as a real error and redirects to the home folder.
*/
async function rebuildBreadCrumb() {
/**
@@ -69,23 +79,29 @@ async function rebuildBreadCrumb() {
currentFolderInfo = folderInfo;
}
// XXX do not enter root into bread crumb updateBreadcrumb() method always display it
if (!folderInfo.is_root) {
app.breadcrumbPath.unshift({
id: folderInfo.id,
name: folderInfo.name
});
}
// Add every folder to the breadcrumb, including the root (home folder).
// updateBreadcrumb() no longer auto-prepends home — it's our responsibility here.
app.breadcrumbPath.unshift({
id: folderInfo.id,
name: folderInfo.name
});
// iterate to parent folder
id = folderInfo.parent_id;
} catch (_e) {
console.log(`Error loading information from folder ${app.currentPath}, falling back to ${app.userHomeFolderId}`);
// fallback of root
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
app.breadcrumbPath = [];
id = app.userHomeFolderId;
if (id) app.currentPath = id;
if (currentFolderInfo === null) {
// Failed on the target folder itself — real error, fall back to home.
console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`);
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
app.breadcrumbPath = [];
id = app.userHomeFolderId;
if (id) app.currentPath = id;
} else {
// Failed on a parent — hit the permission boundary of a shared subtree.
// Stop traversal; the partial breadcrumb is the best we can show.
console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`);
break;
}
}
}
@@ -217,6 +233,7 @@ async function loadFiles(options = { insertHistory: true }) {
} else {
ui.renderFolders(folderList);
ui.renderFiles(fileList);
ui.resolveOwnerCells();
// check if a file was provided
if (app.viewFile) {
+22 -2
View File
@@ -7,19 +7,21 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
installFetchInterceptor();
import { Modal } from '../components/modal.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { oxiIconsInit } from '../core/icons.js';
import { Modal } from '../core/modal.js';
import { fileOps } from '../features/files/fileOperations.js';
import { multiSelect } from '../features/files/multiSelect.js';
import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { grants } from '../model/grants.js';
import { sharedView } from '../views/shared/sharedView.js';
import { checkAuthentication } from './authSession.js';
import { loadFiles } from './filesView.js';
import {
activateFilesUI,
SECTIONS_MAPPER,
switchToFavoritesSection,
switchToFilesSection,
@@ -27,6 +29,7 @@ import {
switchToPhotosSection,
switchToRecentFilesSection,
switchToSharedSection,
switchToSharedWithMeSection,
switchToTrashSection
} from './navigation.js';
import { performSearch } from './searchView.js';
@@ -140,12 +143,16 @@ const ACTIONS_BAR_TEMPLATES = {
</div>
${_multiSelectButons}
${_toggleButtons}
`,
sharedwithme: `
<div class="action-buttons" id="default-buttons"></div>
${_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();
+60
View File
@@ -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
};
+79 -137
View File
@@ -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 = {
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
</div>
<div class="context-menu-item" id="share-folder-option">
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Share</span>
<i class="fas fa-oxiexport"></i> <span data-i18n="actions.share">Share</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" id="rename-folder-option">
@@ -98,7 +106,7 @@ const ui = {
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
</div>
<div class="context-menu-item" id="share-file-option">
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Share</span>
<i class="fas fa-oxiexport"></i> <span data-i18n="actions.share">Share</span>
</div>
<div class="context-menu-item hidden" id="add-to-playlist-option">
<i class="fas fa-compact-disc"></i> <span data-i18n="music.add_to_playlist">Add to Playlist</span>
@@ -146,115 +154,7 @@ const ui = {
document.body.appendChild(moveDialog);
}
// Share dialog
if (!document.getElementById('share-dialog')) {
const shareDialog = document.createElement('div');
shareDialog.classList.add('share-dialog', 'hidden');
shareDialog.id = 'share-dialog';
shareDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-share-alt dialog-header-icon"></i>
<span data-i18n="dialogs.share_file">Share file</span>
</div>
<div class="shared-item-info">
<strong>Item:</strong> <span id="shared-item-name"></span>
</div>
<div id="existing-shares-section" class="share-section hidden">
<h3 data-i18n="dialogs.existing_shares">Existing shared links</h3>
<div id="existing-shares-container"></div>
</div>
<div class="share-options">
<h3 data-i18n="dialogs.share_options">Share options</h3>
<div class="form-group">
<label for="share-password" data-i18n="dialogs.password">Password (optional):</label>
<input type="password" id="share-password" placeholder="Protect with password">
</div>
<div class="form-group">
<label for="share-expiration" data-i18n="dialogs.expiration">Expiration date (optional):</label>
<input type="date" id="share-expiration">
</div>
<div class="form-group">
<label data-i18n="dialogs.permissions">Permissions:</label>
<div class="permission-options">
<div class="permission-option">
<input type="checkbox" id="share-permission-read" checked>
<label for="share-permission-read" data-i18n="permissions.read">Read</label>
</div>
<div class="permission-option">
<input type="checkbox" id="share-permission-write">
<label for="share-permission-write" data-i18n="permissions.write">Write</label>
</div>
<div class="permission-option">
<input type="checkbox" id="share-permission-reshare">
<label for="share-permission-reshare" data-i18n="permissions.reshare">Allow sharing</label>
</div>
</div>
</div>
<button class="btn btn-primary btn-small" id="share-confirm-btn" data-i18n="actions.share">Share</button>
</div>
<div id="new-share-section" class="share-section hidden">
<h3 data-i18n="dialogs.generated_link">Generated link</h3>
<div class="form-group">
<input type="text" id="generated-share-url" readonly>
<div class="share-link-actions">
<button class="btn btn-small" id="copy-share-btn">
<i class="fas fa-copy"></i> <span data-i18n="actions.copy">Copy</span>
</button>
<button class="btn btn-small" id="notify-share-btn">
<i class="fas fa-envelope"></i> <span data-i18n="actions.notify">Notify</span>
</button>
</div>
</div>
</div>
<div class="share-dialog-buttons">
<button class="btn btn-secondary" id="share-close-btn" data-i18n="actions.close">Close</button>
</div>
</div>
`;
i18n.translateElement(shareDialog);
document.body.appendChild(shareDialog);
// Add event listeners for share dialog
document.getElementById('share-close-btn')?.addEventListener('click', () => {
contextMenus.closeShareDialog();
});
document.getElementById('share-confirm-btn')?.addEventListener('click', async () => {
await contextMenus.createSharedLink();
});
document.getElementById('copy-share-btn')?.addEventListener('click', async () => {
const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
if (shareUrl) await fileSharing.copyLinkToClipboard(shareUrl);
});
document.getElementById('notify-share-btn')?.addEventListener('click', () => {
const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
if (shareUrl) contextMenus.showEmailNotificationDialog(shareUrl);
});
// FIXME make generic function (close all dialog / etc)
document.addEventListener('keydown', (e) => {
const dialog = document.getElementById('share-dialog');
if (e.key === 'Escape' && !dialog?.classList.contains('hidden')) {
contextMenus.closeShareDialog();
}
});
shareDialog.addEventListener('click', (e) => {
if (e.target === shareDialog) {
contextMenus.closeShareDialog();
}
});
}
// Share dialog is now handled by shareModal (components/shareModal.js)
// Notification dialog
if (!document.getElementById('notification-dialog')) {
@@ -544,6 +444,47 @@ const ui = {
syncViewContainers();
},
/**
* Show or hide the Owner column. When hidden, no name-resolution calls are made.
* Sections that show owner (SharedWithMe, Favorites) pass `true`; all others `false`.
* @param {boolean} visible
*/
setOwnerColumnVisible(visible) {
this._ownerVisible = visible;
document
.getElementById('files-list')
?.querySelectorAll('.owner-cell')
.forEach((cell) => {
cell.classList.toggle('hidden', !visible);
});
},
/**
* Asynchronously fill every un-resolved `.owner-cell` in the current list with
* the display name for its `data-owner-id` attribute.
*
* Call this after `renderFiles()` / `renderFolders()` in sections where the owner
* column is visible. Idempotent: cells already stamped with `data-owner-resolved`
* are skipped (safe to call on each "Load more" page append).
*
* When the column is hidden nothing calls this function, so `systemUsers` is never
* touched and no address-book requests are issued.
*
* @returns {Promise<void>}
*/
async resolveOwnerCells() {
const filesList = document.getElementById('files-list');
const cells = /** @type {NodeListOf<HTMLElement>} */ (filesList?.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])'));
if (!cells?.length) return;
systemUsers.prefetch(); // warm cache once (idempotent, fire-and-forget)
for (const cell of cells) {
const id = cell.dataset.ownerId;
cell.dataset.ownerResolved = '1';
if (!id) continue;
cell.replaceChildren(createUserVignette(id, 'sm'));
}
},
/**
* Update breadcrumb navigation from the breadcrumbPath array.
* Renders: Home > folder1 > folder2 > ...
@@ -574,18 +515,11 @@ const ui = {
}
breadcrumb?.appendChild(homeIcon);
// -- Root/Home folder name (if available) is always the first element of the breadcrumb --
// TODO clarify the difference between homeIcon & this first element
if (app.userHomeFolderName) {
if (path.length === 0 || path[0].id !== app.userHomeFolderId) {
path.unshift({
name: app.userHomeFolderName,
id: app.userHomeFolderId
});
}
}
// -- Root/Home + Intermediate + current segments --
// NOTE: The home folder entry is added by rebuildBreadCrumb() (filesView.js) when it
// reaches the root folder during traversal. updateBreadcrumb() just renders app.breadcrumbPath
// as-is — no implicit mutation. This allows shared-folder navigation to show only the
// reachable subtree without the home prefix leaking in.
path.forEach((segment, index) => {
const isLast = index === path.length - 1;
@@ -937,6 +871,11 @@ const ui = {
loadFiles();
return;
}
if (app.currentSection === 'sharedwithme') {
// Activate Files UI (nav, breadcrumb, actions bar) without
// resetting the path — the shared folder becomes the entry point.
activateFilesUI();
}
app.breadcrumbPath.push({ id: folderId, name: folderName });
app.currentPath = folderId;
this.updateBreadcrumb();
@@ -1247,15 +1186,14 @@ const ui = {
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
// TODO corrently dirty
const item = /** @type {unknown} */ ({
id: itemId,
item_id: itemId,
item_type: itemType,
item_name: itemName
});
const item = /** @type {FileItem|FolderItem} */ (
/** @type {unknown} */ ({
id: itemId,
name: itemName
})
);
contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
shareModal.open(item, /** @type {'file'|'folder'} */ (itemType));
});
},
@@ -1334,7 +1272,7 @@ const ui = {
if (folder.path) el.dataset.path = folder.path;
const isFav = favorites?.isFavorite(folder.id, 'folder');
const isShared = sharedView.isShared(folder.id, 'folder');
const isShared = grants.getOutgoingGrantsFor('folder', folder.id).length > 0; //sharedView.isShared(folder.id, 'folder');
const formattedDate = formatDateTime(folder.modified_at);
el.innerHTML = `
@@ -1345,8 +1283,9 @@ const ui = {
</div>
<span>${escapeHtml(folder.name)}</span>
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-share-alt"></i></div>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
<div class="type-cell">${i18n.t('files.file_types.folder')}</div>
<div class="size-cell">--</div>
<div class="date-cell">${formattedDate}</div>
@@ -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 = {
</div>
<span>${escapeHtml(file.name)}</span>
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-share-alt"></i></div>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
<div class="type-cell">${typeLabel}</div>
<div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</div>
@@ -1439,6 +1380,7 @@ const ui = {
<div class="list-header">
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
<div data-i18n="files.name">Name</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-i18n="files.owner">Owner</div>
<div data-i18n="files.type">Type</div>
<div data-i18n="files.size">Size</div>
<div data-i18n="files.modified">Modified</div>
@@ -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 = `<i class="fas ${icon}"></i>`;
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');
});
}
};
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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;
}
+12
View File
@@ -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'
+135
View File
@@ -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.
*/
+5 -237
View File
@@ -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 <span> inside header to preserve <i> 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 = '<i class="fas fa-lock"></i> 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 = '<i class="fas fa-copy"></i> 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 = '<i class="fas fa-trash"></i> 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
*/
+5 -3
View File
@@ -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) {
+1 -1
View File
@@ -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' */
+121
View File
@@ -0,0 +1,121 @@
// @ts-check
/**
* Owner tooltip — shows "Shared by: <display name>" 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<HTMLElement, Handlers>} */
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 };
+179
View File
@@ -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<string, ContactItem[]>} bookId → contacts (loaded books) */
const _contactCache = new Map();
/** @type {Map<string, Promise<ContactItem[]>>} 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<AddressBookItem[]>}
*/
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<ContactItem[]>}
*/
async function listContacts(bookId, opts = {}) {
if (_contactCache.has(bookId)) {
return /** @type {ContactItem[]} */ (_contactCache.get(bookId));
}
if (_inflight.has(bookId)) {
return /** @type {Promise<ContactItem[]>} */ (_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<ContactItem[]>}
*/
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
};
+200
View File
@@ -0,0 +1,200 @@
/**
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
*/
import { getCsrfHeaders } from '../core/csrf.js';
const grants = {
/** @type {Record<String, Record<String, Grant[]>>} */
outgoingGrants: {},
/** @type {Record<String, Record<String, Grant[]>>} */
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<SharedWithMeResponse>}
*/
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<Grant[]>}
*/
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<Grant[]>}
*/
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<Grant[]>}
*/
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<void>}
*/
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 };
+97
View File
@@ -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<string, string> | 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<void>}
*/
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<string>}
*/
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 };
@@ -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<void>}
*/
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(`
<i class="fas fa-share-alt empty-state-icon"></i>
<p>${i18n.t('sharedwithme_emptyStateTitle', 'Nothing shared with you yet')}</p>
<p>${i18n.t('sharedwithme_emptyStateDesc', 'Items shared with you by other users will appear here')}</p>
`);
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(`
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>${i18n.t('errors_loadFailed', 'Failed to load items')}</p>
`);
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<string,string> }}
*/
_mapItems(items) {
/** @type {FolderItem[]} */
const folders = [];
/** @type {FileItem[]} */
const files = [];
/** @type {Map<string, string>} 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<string,string>} 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 };
+1
View File
@@ -0,0 +1 @@
Put here all workers you don't want to be bundled into tha main application js
+19 -4
View File
@@ -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": "الصلاحيات"
}
}
+19 -4
View File
@@ -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"
}
}
+18 -3
View File
@@ -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"
}
}
+18 -3
View File
@@ -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"
}
}
+19 -4
View File
@@ -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": "مجوزها"
}
}
+19 -4
View File
@@ -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"
}
}
+18 -3
View File
@@ -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": "अनुमतियाँ"
}
}
+19 -4
View File
@@ -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"
}
}
+18 -3
View File
@@ -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": "権限"
}
}
+18 -3
View File
@@ -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": "권한"
}
}
+18 -3
View File
@@ -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"
}
}
+18 -3
View File
@@ -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"
}
}
+19 -4
View File
@@ -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"
}
}
+18 -3
View File
@@ -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": "Права"
}
}
+18 -3
View File
@@ -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": "權限"
}
}
+18 -3
View File
@@ -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": "权限"
}
}
+5 -5
View File
@@ -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}}
+11
View File
@@ -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) ?