From e487af6e7ebcf95bcb5059a148d470d75d0b3afe Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 27 Jul 2026 00:14:12 +0200 Subject: [PATCH] feat(breadcrumb): show the granter (sharer) in the breadcrumb --- frontend/src/lib/api/types.ts | 50 ++++++++++- .../lib/components/FolderBreadcrumb.svelte | 88 ++++++++++++++++++- frontend/src/lib/components/MoveDialog.svelte | 9 +- .../routes/config/drive/[uuid]/+page.svelte | 4 - src/application/dtos/folder_dto.rs | 20 +++-- src/application/services/folder_service.rs | 61 +++++++++++-- .../repositories/pg/folder_db_repository.rs | 61 +++++++++++++ 7 files changed, 264 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 6841391f..38da7e5b 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -298,8 +298,34 @@ export interface SearchResourcesResponse { export type DriveKind = 'personal' | 'shared'; -/** Role-keyed share strength. Matches `Role` in the backend authz model. */ -export type DriveRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer'; +/** + * Full role set from `storage.grant_role` — every value that can appear + * on a `role_grants` row regardless of `resource_type` (drive, folder, + * file, playlist, calendar, address_book, …). Use this for folder-level + * and file-level `caller_role` fields where all five values are valid. + * Matches `RoleDto` in the backend. + */ +export type GrantRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer'; + +/** + * Role assignable at DRIVE scope — a strict subset of `GrantRole`. + * Drives only meaningfully take the three management-ladder tiers: + * - `owner` — full control (rename, delete, quota, membership). + * - `editor` — can create/modify content anywhere in the drive. + * - `viewer` — read-only access to the whole drive. + * + * `contributor` (create-in-folder-without-touching-siblings) and + * `commenter` (react without modifying) are folder/file-scope + * semantics: they describe fine-grained access to a specific item, + * not to a whole drive. Grants of those roles happen at folder or + * file scope via a separate `role_grants` row, not at the drive + * boundary. Do NOT widen this type without a matching backend + * check — the DB ENUM permits all 5 today, so the constraint is + * conventional. + * + * Use `GrantRole` for folder/file-level `caller_role` fields. + */ +export type DriveRole = 'owner' | 'editor' | 'viewer'; /** Subject of a grant. Mirrors `SubjectDto`. */ export type SubjectKind = 'user' | 'group' | 'token'; @@ -465,8 +491,26 @@ export interface AccessSource { kind: AccessSourceKind; /** Populated when `kind === 'drive'`. */ drive?: AccessSourceDrive; - /** Optional grantee info for shares / group grants. */ + /** + * SHARER — the user who created the grant that gave the caller + * access at the boundary (`role_grants.granted_by`). Kind is always + * `'user'` today (a group can't perform an action), but the type + * stays open in case a future model permits it. Null when the + * boundary can't be resolved to a single grant (e.g. `token`). + */ subject?: AccessSourceSubject; + /** + * Caller's role via the boundary grant (`role_grants.role` on the + * same row that carries `granted_by`). Lets the FE render permission- + * aware affordances at the ancestor scope. Reflects the boundary grant + * only — aggregate effective role via other channels may be stronger. + * Null on `token` access. + * + * Typed as `GrantRole` (not `DriveRole`): the boundary can be a + * folder-level share where all five role_grant values are valid, + * not just the drive-scoped subset. + */ + caller_role?: GrantRole | null; } /** diff --git a/frontend/src/lib/components/FolderBreadcrumb.svelte b/frontend/src/lib/components/FolderBreadcrumb.svelte index 975d805a..e0df3061 100644 --- a/frontend/src/lib/components/FolderBreadcrumb.svelte +++ b/frontend/src/lib/components/FolderBreadcrumb.svelte @@ -3,6 +3,7 @@ import { getFolderAncestors } from '$lib/api/endpoints/folders'; import type { AccessSource, FolderAncestor, FolderAncestorsResponse } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; import { t } from '$lib/i18n/index.svelte'; /** @@ -87,6 +88,26 @@ : [] ); + /** + * Index of the crumb that carries the ACTUAL grant giving the + * caller access — the "boundary" crumb. Rendered with an inline + * sharer avatar (or group chip) so the breadcrumb answers both + * "how did I get here?" (root chip icon) AND "who shared this?" + * (avatar on the specific granted folder) — Ed's 2026-07-27 UX call. + * + * - `direct_share`: always index 0 of `visibleCrumbs` (the + * endpoint's walk stops at the granted folder, so the topmost + * accessible ancestor IS the share boundary). + * - `drive`: null — the grant lives on the drive itself, not on + * any visible folder. The drive-root chip already carries the + * drive name; adding a subject chip to a sub-folder would be + * semantically misleading (that sub-folder wasn't the grant). + * - `token`: null — no user-facing subject to render. + */ + const boundaryCrumbIndex = $derived( + chain?.access_source.kind === 'direct_share' && visibleCrumbs.length > 0 ? 0 : null + ); + // ── Root-icon derivation ──────────────────────────────────────────── // One icon per `access_source.kind`. Personal drives use the home // glyph (they're the caller's own storage — signalling "home base"); @@ -285,10 +306,12 @@ `onDrop` handler. Absent everywhere except `/files`. --> {@const isLeaf = i === visibleCrumbs.length - 1} + {@const isBoundary = i === boundaryCrumbIndex} - {c.name} + + {#if isBoundary && chain?.access_source.subject} + {@const subj = chain.access_source.subject} + {#if subj.kind === 'user'} + + {:else} + + + + {/if} + {/if} + {c.name} {/each} @@ -335,9 +386,38 @@ anchor → highlight clears (Ed's 2026-07-26 report). Pointer-events off on children collapses the whole chip to a single drag target; drop still lands because the anchor's own handlers stay live. - Intermediate crumbs don't need this (they contain only a text - node — no child element to cross into). */ - .breadcrumb-home > * { + Boundary crumbs (with an inline avatar/group chip child) get the + same treatment — same drop-flicker mechanism, one child element to + cross into. Plain intermediate crumbs are pure text nodes and + don't need it, but the selector is harmless there. */ + .breadcrumb-home > *, + .breadcrumb-link > * { pointer-events: none; } + + /* Boundary crumbs align the inline sharer decoration (avatar or + group chip) with the folder-name text on the vertical midline — + inline-flex + baseline gap. Non-boundary crumbs stay inline (no + flex overhead) so wide breadcrumbs still wrap the same way. */ + .breadcrumb-boundary { + display: inline-flex; + align-items: center; + gap: var(--space-1); + } + + /* Group-subject chip — small `users` glyph in a subtle badge, sits + next to the folder name on the boundary crumb. Matches the size + footprint of the inline UserAvatar (18px) so user- vs group-shared + crumbs feel visually consistent. */ + .breadcrumb-group-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-bg-subtle); + color: var(--color-text-secondary); + font-size: 11px; + } diff --git a/frontend/src/lib/components/MoveDialog.svelte b/frontend/src/lib/components/MoveDialog.svelte index d9bfe240..53894e8d 100644 --- a/frontend/src/lib/components/MoveDialog.svelte +++ b/frontend/src/lib/components/MoveDialog.svelte @@ -10,9 +10,12 @@ import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; import { ui } from '$lib/stores/ui.svelte'; - // A drive accepts new items only if the caller can Create on its root. - // Owner / Editor / Contributor cover that; Commenter + Viewer cannot. - const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const; + // A drive accepts new items only if the caller can Create on its + // root. Drive-scope roles are the management-ladder subset — + // Owner / Editor / Viewer — so writability collapses to the top two; + // Viewer cannot. `contributor`/`commenter` don't appear at drive + // scope (folder/file-scope semantics), so they're not in `DriveRole`. + const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor'] as const; function isWritable(d: Drive): boolean { return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role); } diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index b72e8623..4bcbeed5 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -125,10 +125,6 @@ return t('drive.role.owner', 'Owner'); case 'editor': return t('drive.role.editor', 'Editor'); - case 'contributor': - return t('drive.role.contributor', 'Contributor'); - case 'commenter': - return t('drive.role.commenter', 'Commenter'); case 'viewer': return t('drive.role.viewer', 'Viewer'); } diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 2e482618..2d4be077 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; use crate::application::dtos::display_helpers::intern_display; -use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto, RoleDto}; use crate::domain::entities::folder::Folder; use crate::domain::services::authorization::ResourceKind; use chrono::{DateTime, Utc}; @@ -437,13 +437,21 @@ pub struct AccessSourceDto { /// Populated when `kind == Drive`. Null otherwise. #[serde(skip_serializing_if = "Option::is_none")] pub drive: Option, - /// Populated when a `role_grants` row identifies the grantee (self - /// or a group). MVP leaves this null — subject enrichment (grantor - /// name / group name lookup) is a follow-up. Once populated the FE - /// tooltip becomes "shared with **your team**" / "shared with **you - /// by X**" instead of the generic "shared with you". + /// SHARER — the user who created the grant that gave the caller + /// access at the boundary (`storage.role_grants.granted_by`). Kind + /// is always `User` today: `granted_by` references `auth.users` and + /// a group can't perform an action. Null when the boundary can't be + /// resolved to a single grant (e.g. `token` access). #[serde(skip_serializing_if = "Option::is_none")] pub subject: Option, + /// Caller's own role via the boundary grant (`role_grants.role` on + /// the same row that carries `granted_by`). Lets the FE render + /// permission-aware affordances — "you can Edit / Comment / + /// View this share" — without a second lookup. Reflects the boundary + /// grant only: aggregate effective role via other channels may be + /// stronger. Null on `token` access. + #[serde(skip_serializing_if = "Option::is_none")] + pub caller_role: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index ff0ac164..c2db8125 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -1,10 +1,11 @@ use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::drive_dto::DriveKindDto; use crate::application::dtos::folder_dto::{ - AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto, - FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions, - MoveFolderDto, RenameFolderDto, + AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto, + AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto, + FolderResourceCursor, FolderResourceRow, ListResourcesOptions, MoveFolderDto, RenameFolderDto, }; +use crate::application::dtos::grant_dto::RoleDto; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::external_mount_ports::MountEntry; use crate::application::ports::file_lifecycle::FileLifecycleHook; @@ -17,7 +18,7 @@ use crate::application::services::mount_dto::{ use crate::application::services::mount_registry::MountConfig; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject}; +use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Role, Subject}; use crate::domain::services::external_mount_id::NodeId; use crate::domain::services::path_service::{StoragePath, validate_storage_name}; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; @@ -1069,6 +1070,46 @@ impl FolderService { // The topmost surviving row is the root of the caller's view. // Its grant profile drives `AccessSource`. let top = &rows[0]; + + // Subject enrichment: identify the specific grant that gave the + // caller access to `top`, then resolve its subject's display + // name in the same query. Drives the tooltip on the breadcrumb + // root chip ("Shared with you by Alice" / "Shared with your + // team via Design"). No `expires_at` filter — the ancestor + // walk's guard already proved the caller is authorized to see + // this ancestor, so the follow-up name lookup is display-only + // (see `feedback_trust_grant_janitor_no_expires_at_read`). + let (grant_resource_type, grant_resource_id) = if top.has_drive_grant { + ("drive", top.drive_id) + } else { + ("folder", top.id) + }; + let grant_by = self + .folder_storage + .fetch_grant_by(caller_id, grant_resource_type, grant_resource_id) + .await?; + let subject = grant_by + .as_ref() + .map( + |(subject_type_str, subject_id, name, _role)| AccessSourceSubjectDto { + kind: match subject_type_str.as_str() { + "group" => AccessSourceSubjectKind::Group, + _ => AccessSourceSubjectKind::User, + }, + id: *subject_id, + name: name.clone(), + }, + ); + // Caller's role via the boundary grant. `Role::parse` returns + // None only if the SQL stored a role we don't understand — the + // ENUM constraint makes that a schema drift, not a runtime case + // to chase. Silent None keeps the endpoint working with an older + // deployment if a future role is added ahead of the code. + let caller_role = grant_by + .as_ref() + .and_then(|(_, _, _, role_str)| Role::parse(role_str)) + .map(RoleDto::from); + let access_source = if top.has_drive_grant { // Drive-membership Read — even if a direct folder grant also // exists, the drive channel is the more useful "how did I @@ -1092,16 +1133,18 @@ impl FolderService { AccessSourceDto { kind: AccessSourceKind::Drive, drive, - subject: None, + subject, + caller_role, } } else { - // Direct folder-level grant (share). Subject enrichment is a - // follow-up (see the DTO comment) — MVP surfaces the kind and - // lets the FE render a generic "shared with you" tooltip. + // Direct folder-level grant (share). Subject carries who + // shared it (user or group), enabling "shared with you by X" + // in the FE tooltip. AccessSourceDto { kind: AccessSourceKind::DirectShare, drive: None, - subject: None, + subject, + caller_role, } }; diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index ec256ca4..d407ea05 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1549,6 +1549,67 @@ impl FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}"))) } + /// Boundary-grant SHARER (`granted_by`) for `AccessSourceDto.subject`. + /// Given the resource (drive or folder) the caller reached the topmost + /// accessible ancestor through, find one active grant THAT CONCERNS + /// THE CALLER (user grant on caller, or group grant on one of the + /// caller's groups) and return `(kind, id, name)` for the user who + /// CREATED that grant — the sharer, not the grantee. The breadcrumb + /// consumer wants "who shared this with me?" not "who has permission?" + /// (Ed 2026-07-27). + /// + /// Kind is always `user`: `granted_by` references `auth.users` and + /// is never a group (a group can't perform an action). Name is + /// looked up from `auth.users.username` in the same round-trip. + /// + /// Grant selection prefers a user grant on the caller over a group + /// grant on one of their groups when both exist on the same resource + /// (the more specific one is likely the truer "who shared this with + /// me"). Only the OUTPUT pivots to the grantor. + /// + /// No `expires_at` filter — the ancestor-walk guard has already + /// established the caller is authorized to see this ancestor + /// (visibility decision made upstream). See + /// `feedback_trust_grant_janitor_no_expires_at_read` — this is the + /// narrow exception where skipping is safe. + pub async fn fetch_grant_by( + &self, + caller_id: Uuid, + resource_type: &str, + resource_id: Uuid, + ) -> Result, String)>, DomainError> { + // Same row also carries the caller's role — piggyback the lookup + // so consumers can render "who shared this" AND "what can I do + // with it" from one round-trip (Ed 2026-07-27). The role is the + // grant's own role, i.e. the caller's effective role via THIS + // specific boundary grant. If the caller has additional grants + // via other channels the aggregate effective role may differ; + // `caller_role` on the boundary DTO reflects the boundary grant + // only. + let sql = r#" + SELECT + 'user'::text AS kind, + g.granted_by AS id, + (SELECT u.username FROM auth.users u WHERE u.id = g.granted_by) AS name, + g.role::text AS role + FROM storage.role_grants g + WHERE g.resource_type = $2 + AND g.resource_id = $3::uuid + AND ( (g.subject_type = 'user' AND g.subject_id = $1) + OR (g.subject_type = 'group' AND g.subject_id IN + (SELECT storage.caller_group_ids($1))) ) + ORDER BY g.subject_type = 'user' DESC + LIMIT 1 + "#; + sqlx::query_as::<_, (String, Uuid, Option, String)>(sql) + .bind(caller_id) + .bind(resource_type) + .bind(resource_id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("grant by lookup: {e}"))) + } + /// Drive header (`id + name + kind`) for the drive-source arm of /// `AccessSourceDto`. Read-only; no authz gate — the caller already /// proved drive-membership via the ancestor walk before invoking.