From a1d027e4a19732a912631e53848517464e526a6e Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 26 Aug 2026 10:56:10 -0400 Subject: [PATCH 1/5] Mark shared items in the file listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared file looked exactly like a regular one. The data to tell them apart arrived with the readdir/stat share flag; nothing rendered it. Adds the badge to both listings — UIItem (desktop, explorer windows, file dialogs) and the dashboard's Files rows — fed from is_shared, and keeps it in step with the share dialogs: both funnel every grant, mode change and revoke through one render, so the badge follows without waiting for a re-listing. Inherited access is deliberately not badged. It is a state of the folder that was shared, so marking every file inside would repeat one fact on hundreds of items; the backend flag is direct-only for the same reason. The icon (owner-shared.svg) and the strings (item_shared_by_you, in 40 locales) were already in the tree, unused — only the wiring was missing. The blue ring is doing the work: list view shrinks badges to 8px, where a glyph is illegible and the white circle the sibling badges use disappears into the row. --- src/gui/src/UI/Dashboard/TabFiles.js | 13 ++++-- src/gui/src/UI/Dashboard/UIShareModal.js | 8 ++++ src/gui/src/UI/UIItem.js | 12 +++++- src/gui/src/UI/UIWindowShare.js | 3 ++ src/gui/src/css/style.css | 14 ++++++ .../helpers/apply_item_added_to_containers.js | 1 + src/gui/src/helpers/refresh_item_container.js | 1 + src/gui/src/helpers/sharedBadge.js | 42 ++++++++++++++++++ src/gui/src/helpers/sharedBadge.test.js | 43 +++++++++++++++++++ 9 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/gui/src/helpers/sharedBadge.js create mode 100644 src/gui/src/helpers/sharedBadge.test.js diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 9a520a009..5c712078a 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -2487,6 +2487,7 @@ const TabFiles = { row.setAttribute("data-is_dir", file.is_dir ? "1" : "0"); row.setAttribute("data-is_trash", file.is_trash ? "1" : "0"); row.setAttribute("data-shared_with_me", file.shared_with_me ? "1" : "0"); + row.setAttribute("data-is_shared", file.is_shared === true ? "1" : "0"); row.setAttribute("data-share_mode", file.share_mode ?? ''); row.setAttribute("data-shared_by", file.shared_by ?? ''); row.setAttribute("data-has_website", file.has_website ? "1" : "0"); @@ -2530,11 +2531,17 @@ const TabFiles = { data-item-id="${item_id}" title="Shortcut" > - +
${html_encode(displayName)}
diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index 94f42099f..65e4217b8 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -25,6 +25,10 @@ import { icons } from '../../helpers/actionIcons.js'; import { mode_label, options_for } from '../../helpers/share_modes.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { avatarHue, avatarInitial } from './shareAvatar.js'; +import { + has_direct_share, + mark_item_shared, +} from '../../helpers/sharedBadge.js'; import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js'; const { html_encode } = window; @@ -391,6 +395,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse show_error(error_html(failure)); return; } + // Each listing is authoritative for its own item. + for ( const [target, shares] of by_path ) { + mark_item_shared(target, has_direct_share(shares)); + } render(aggregateShares(target_paths, by_path)); if ( failure ) show_error(i18n('share_load_partial_failed')); }; diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 7e6b39621..599667008 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -133,6 +133,8 @@ async function UIItem (options) { options.is_shortcut = options.is_shortcut ?? 0; options.is_trash = options.is_trash ?? false; options.shared_with_me = options.shared_with_me ?? false; + // `=== true` because null — someone else's item — must not badge. + options.is_shared = options.is_shared === true; options.share_mode = options.share_mode ?? ''; options.shared_by = options.shared_by ?? ''; options.owner = options.owner ?? ''; @@ -169,6 +171,7 @@ async function UIItem (options) { data-is_dir="${options.is_dir ? 1 : 0}" data-is_trash="${options.is_trash ? 1 : 0}" data-shared_with_me="${options.shared_with_me ? 1 : 0}" + data-is_shared="${options.is_shared ? 1 : 0}" data-share_mode="${html_encode(options.share_mode)}" data-shared_by="${html_encode(options.shared_by)}" data-owner="${html_encode(options.owner)}" @@ -237,8 +240,15 @@ async function UIItem (options) { data-item-id="${item_id}" title="${i18n('item_shortcut')}" >`; + // shared badge + h += ``; // worker badge - h += `${i18n('share_no_one')}

`; } $list.html(rows); + // Every share, mode change and revoke lands here. + mark_item_shared(item_path, has_direct_share(shares)); }; const refresh = async () => { diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 8ba76589b..6a0a7491f 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -674,6 +674,20 @@ span.header-sort-icon img { cursor: pointer; } +/* Shared, owner-side. The ring is what reads at the 8px list-view size. */ +.item-badge.item-is-shared-badge { + border-radius: 50%; + background: white; + padding: 2px; + box-shadow: 0 0 0 1.5px #3b82f6; +} + +.item-container-details .item-badges .item-badge.item-is-shared-badge { + /* Small enough that only the ring reads, so fill it in. */ + background: #3b82f6; + box-shadow: none; +} + .item-name, .item-name-editor, .item-name-shadow { font-size: 12px; color: white; diff --git a/src/gui/src/helpers/apply_item_added_to_containers.js b/src/gui/src/helpers/apply_item_added_to_containers.js index d713117ad..16dfd037d 100644 --- a/src/gui/src/helpers/apply_item_added_to_containers.js +++ b/src/gui/src/helpers/apply_item_added_to_containers.js @@ -70,6 +70,7 @@ const apply_item_added_to_containers = async function (item) { is_shortcut: item.is_shortcut, shortcut_to: item.shortcut_to, shortcut_to_path: item.shortcut_to_path, + is_shared: item.is_shared, }); } diff --git a/src/gui/src/helpers/refresh_item_container.js b/src/gui/src/helpers/refresh_item_container.js index e3c6fff41..15a55fe26 100644 --- a/src/gui/src/helpers/refresh_item_container.js +++ b/src/gui/src/helpers/refresh_item_container.js @@ -254,6 +254,7 @@ const refresh_item_container = function (el_item_container, options) { shared_with_me: fsentry.shared_with_me, share_mode: fsentry.share_mode, shared_by: fsentry.shared_by, + is_shared: fsentry.is_shared, owner: fsentry.owner?.username ?? fsentry.owner, }); } diff --git a/src/gui/src/helpers/sharedBadge.js b/src/gui/src/helpers/sharedBadge.js new file mode 100644 index 000000000..7444a36cc --- /dev/null +++ b/src/gui/src/helpers/sharedBadge.js @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Whether a `getShares()` list means this item is shared. + * + * Inherited rows are access granted on a folder above, which is a state of that + * folder — badging every file inside one would say the same thing on hundreds + * of items. Matches `is_shared` from the backend, which is also direct-only. + */ +export const has_direct_share = (shares) => + Array.isArray(shares) && + shares.some((share) => share && ! share.inheritedFrom); + +/** + * Turn the badge on or off for every rendered copy of `path` — the same item + * can be on the desktop and in any number of open windows. + */ +export const mark_item_shared = (path, is_shared) => { + if ( ! path ) return; + const $items = $(`.item[data-path="${html_encode(path)}" i]`); + $items.attr('data-is_shared', is_shared ? 1 : 0); + $items + .find('.item-is-shared-badge') + .css('display', is_shared ? 'block' : 'none'); +}; diff --git a/src/gui/src/helpers/sharedBadge.test.js b/src/gui/src/helpers/sharedBadge.test.js new file mode 100644 index 000000000..3708e43f0 --- /dev/null +++ b/src/gui/src/helpers/sharedBadge.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { has_direct_share } from './sharedBadge.js'; + +describe('has_direct_share', () => { + it('is true for a share on the item itself', () => { + expect(has_direct_share([{ holder: 'alice', inheritedFrom: null }])) + .toBe(true); + }); + + it('is false when every share is inherited from a folder above', () => { + // The share belongs to the folder, so badging the file would repeat it + // on everything inside. + expect( + has_direct_share([ + { holder: 'alice', inheritedFrom: '/bob/Docs' }, + { holder: 'carol', inheritedFrom: '/bob/Docs' }, + ]), + ).toBe(false); + }); + + it('is true when a direct share sits alongside an inherited one', () => { + expect( + has_direct_share([ + { holder: 'alice', inheritedFrom: '/bob/Docs' }, + { holder: 'carol', inheritedFrom: null }, + ]), + ).toBe(true); + }); + + it('counts an unclaimed invite — the owner did share it', () => { + expect( + has_direct_share([ + { recipientEmail: 'x@example.com', pending: true, inheritedFrom: null }, + ]), + ).toBe(true); + }); + + it('is false for nothing at all', () => { + expect(has_direct_share([])).toBe(false); + expect(has_direct_share(undefined)).toBe(false); + expect(has_direct_share(null)).toBe(false); + }); +}); From b08dc70654f596efcc10a267b3a67c40123651f5 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 26 Aug 2026 11:07:48 -0400 Subject: [PATCH 2/5] Move the shared marker onto the icon's corner and shrink it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: too big, and in the wrong place. It sat in the badge cluster, which the dashboard pins to the row's top-left corner rather than to the icon — and at 12px it dominated a 24px row icon. Now a dot on the icon itself, lower-right: 9px on the desktop's 45px icons, 7px on the dashboard's 24px rows. Anchoring to .item-icon rather than to the badge cluster is what keeps it on the corner at both sizes. Dropping the people glyph with it — unreadable at either size, and colour was the signal the ticket asked for. --- src/gui/src/UI/Dashboard/TabFiles.js | 10 ++++------ src/gui/src/UI/UIItem.js | 13 ++++++------- src/gui/src/css/dashboard.css | 9 +++++++++ src/gui/src/css/style.css | 22 ++++++++++++---------- src/gui/src/helpers/sharedBadge.js | 4 ++-- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 5c712078a..c6fe9cd82 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -2513,6 +2513,10 @@ const TabFiles = {
${icon} +
-
${html_encode(displayName)}
diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 599667008..7ea257b46 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -217,6 +217,12 @@ async function UIItem (options) { // icon h += '
'; h += ``; + // Shared marker: on the icon rather than in the badge cluster, so it stays + // on the item's own corner at every icon size. + h += `
`; h += '
'; // badges h += '
'; @@ -240,13 +246,6 @@ async function UIItem (options) { data-item-id="${item_id}" title="${i18n('item_shortcut')}" >`; - // shared badge - h += ``; // worker badge h += ` { const $items = $(`.item[data-path="${html_encode(path)}" i]`); $items.attr('data-is_shared', is_shared ? 1 : 0); $items - .find('.item-is-shared-badge') - .css('display', is_shared ? 'block' : 'none'); + .find('.item-shared-marker') + .css('display', is_shared ? '' : 'none'); }; From 519b132355a7abc16994620d4df5fa09a7f6722f Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 26 Aug 2026 11:16:49 -0400 Subject: [PATCH 3/5] Inset the shared marker onto the icon glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It sat on the icon box's corner, which is 5px outside the artwork on every side, so the dot read as clipped — half of it hanging over empty padding with the icon's own drop-shadow falling across it. Nudged in on both axes: 7px on the desktop, 2px on the dashboard rows. --- src/gui/src/css/dashboard.css | 4 ++-- src/gui/src/css/style.css | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 93ba4b950..35618f525 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -4048,8 +4048,8 @@ body.myapps-reordering .myapps-tile { .dashboard-section-files .files-tab .files .row .item-shared-marker { width: 7px; height: 7px; - right: 0; - bottom: 0; + right: 2px; + bottom: 2px; box-shadow: 0 0 0 1px white; } diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 20482cb7a..7bf1d266f 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -680,8 +680,10 @@ span.header-sort-icon img { at the size a row icon allows, and colour is the signal that survives it. */ .item-shared-marker { position: absolute; - right: 3px; - bottom: 3px; + /* Inset onto the glyph. Sitting on the icon box's corner reads as clipped, + because the box is 5px wider than the artwork on every side. */ + right: 7px; + bottom: 7px; width: 9px; height: 9px; border-radius: 50%; From 70edef561fd3e4255721cad8670e649ebe6f67ff Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 26 Aug 2026 11:47:22 -0400 Subject: [PATCH 4/5] Stop offering an access level a delegate cannot grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recipient given "can edit & share" could not pass that level on: the dialog offered it, the server refused it, and the refusal was a bare 403 Forbidden that reads as a bug. Handing out manage needs authority over manage, which only the owner has — the refusal is right, the dead end and the silence were not. The dropdown now withholds it from anyone who does not own the item; a row already set to it keeps it, so opening the dialog cannot downgrade the owner's own grant, and a mixed selection follows its strictest item. The server says why, and only to someone who can already share the item — a stranger still gets the ACL's own safe error, which does not admit the node exists. Verified against a running server: a delegate grants read and write as before, and manage now answers cannot_delegate_manage with a sentence naming the owner as the one who can. --- .../services/share/ShareService.test.ts | 30 ++++++++++++++++++- src/backend/services/share/ShareService.ts | 16 ++++++++++ src/gui/src/UI/Dashboard/UIShareModal.js | 8 +++-- src/gui/src/UI/UIWindowShare.js | 8 +++-- src/gui/src/helpers/share_modes.js | 10 +++++-- src/gui/src/helpers/share_modes.test.js | 13 ++++++++ 6 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 759278c24..5a6089f01 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -806,7 +806,35 @@ describe('ShareService', () => { recipient: { email: third.email }, mode: 'manage', }), - ).rejects.toMatchObject({ statusCode: 403 }); + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'cannot_delegate_manage', + }); + + // What they can do is unchanged. + await expect( + share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'write', + }), + ).resolves.toMatchObject({ mode: 'write' }); + }); + + it('tells a stranger nothing when they ask to grant `manage`', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + // No access at all, so the refusal must not confirm the file exists. + await expect( + share(stranger.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'manage', + }), + ).rejects.not.toMatchObject({ legacyCode: 'cannot_delegate_manage' }); }); it('leaves a delegate alone when their authority survives another issuer', async () => { diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 9e6ce17da..6327ae06d 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -1720,6 +1720,22 @@ export class ShareService extends PuterService { // given, rather than everything its user owns. if (allowed && (await this.#hasOwnReach(actor, entry, mode))) return; + // Only for someone who can already share here, so it leaks nothing. + if (mode === MANAGE_PERM_PREFIX) { + const canDelegateAccess = + await this.services.permission.canManagePermission( + userRelatedActor(actor), + entryPermissionForMode(entry.uuid, 'write'), + ); + if (canDelegateAccess) { + throw new HttpError( + 403, + 'Only the owner can grant edit & share access', + { legacyCode: 'cannot_delegate_manage' }, + ); + } + } + const safe = await this.services.acl.getSafeAclError( actor, this.#descriptorFor(entry), diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index 65e4217b8..9e84a55b2 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -19,7 +19,7 @@ import path from '../../lib/path.js'; import item_icon from '../../helpers/item_icon.js'; -import { owner_of_path } from '../../helpers/path_owner.js'; +import { is_owned_by_me, owner_of_path } from '../../helpers/path_owner.js'; import { invalidate_shared_roots } from '../../helpers/shared_access.js'; import { icons } from '../../helpers/actionIcons.js'; import { mode_label, options_for } from '../../helpers/share_modes.js'; @@ -115,6 +115,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse })); const target_paths = targets.map((item) => item.path); const total = targets.length; + // Strictest item decides: one borrowed item withholds it for the rest. + const allow_manage = target_paths.every((p) => is_owned_by_me(p)); const is_multi = total > 1; // Nothing to share: an empty selection is a caller's mistake, not a dialog. if ( total === 0 ) return { close: () => {} }; @@ -159,7 +161,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse `; @@ -143,7 +145,7 @@ async function UIWindowShare (options) { } rows += ''; } diff --git a/src/gui/src/helpers/share_modes.js b/src/gui/src/helpers/share_modes.js index 8415da8df..1c4d7f1f8 100644 --- a/src/gui/src/helpers/share_modes.js +++ b/src/gui/src/helpers/share_modes.js @@ -48,11 +48,17 @@ export const mode_label = (mode) => { * unselectable placeholder, so a batch of mixed modes can't be read as one of * them, and picking a real mode is what levels them. * + * `allow_manage: false` drops "Can edit & share", bar a row already on it. + * * @param {string|null} current + * @param {{ allow_manage?: boolean }} [options] * @returns {string} HTML-safe markup */ -export const options_for = (current) => { - const listed = MODES +export const options_for = (current, { allow_manage = true } = {}) => { + const offered = MODES.filter( + (mode) => allow_manage || mode !== 'manage' || mode === current, + ); + const listed = offered .map( (mode) => ``, diff --git a/src/gui/src/helpers/share_modes.test.js b/src/gui/src/helpers/share_modes.test.js index a55fc554b..b693b356f 100644 --- a/src/gui/src/helpers/share_modes.test.js +++ b/src/gui/src/helpers/share_modes.test.js @@ -57,6 +57,19 @@ describe('options_for', () => { expect(html).not.toContain('