Merge pull request #3646 from HeyPuter/juancastro/put-1590-sharing-shared-files-are-not-different-than-regular-files

🔧 PUT-1590: Mark shared items in the file listings
This commit is contained in:
Juan Fernando Castro
2026-08-26 17:55:19 -04:00
committed by GitHub
23 changed files with 416 additions and 24 deletions
@@ -109,6 +109,33 @@ describe('share endpoints over HTTP', () => {
}
});
it('says whether a share created access or the recipient already had it', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const file = await makeFile(owner);
const share = (mode: string) =>
post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: file.uid }],
mode,
}).then((r) => r.json() as Promise<{
results: Array<{ is_new?: boolean }>;
}>);
expect((await share('read')).results[0].is_new).toBe(true);
// Without this the dialog cannot tell a repeat from a first share.
expect((await share('read')).results[0].is_new).toBe(false);
expect((await share('write')).results[0].is_new).toBe(false);
// A listing describes standing access, so it says nothing about it.
const listed = await get('/share/shares', owner.token, {
uid: file.uid,
}).then((r) => r.json() as Promise<{
items: Array<Record<string, unknown>>;
}>);
expect(listed.items[0]).not.toHaveProperty('is_new');
});
it('shares an item, lists it for the recipient, then revokes it', async () => {
const owner = env.users.user;
const recipient = env.users.other;
@@ -57,6 +57,8 @@ export async function toClientShare(
...(share.pending
? { pending: true, recipient_email: share.recipientEmail }
: {}),
// Set on a share call only, so a listing stays silent about it.
...(share.isNew === undefined ? {} : { is_new: share.isNew }),
uid_entry: share.entryUid,
is_dir: share.isDir,
issuer: share.issuer.username,
@@ -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 () => {
+18 -4
View File
@@ -94,11 +94,9 @@ export interface ResolvedShare {
issuedByApp?: string | null;
modified: number;
size: number | null;
/**
* Set by `share()` only, and never sent to a client: who to notify, and
* whether this call created reach that didn't exist before.
*/
/** Set by `share()` only: who to notify. Never sent to a client. */
holderId?: number;
/** Whether this call created reach that didn't exist before. */
isNew?: boolean;
/**
* An invite to an address with no confirmed account. No grant exists yet
@@ -1720,6 +1718,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),
+1
View File
@@ -69,6 +69,7 @@ A `Promise` that resolves to an array of share objects, one per recipient/item p
- `recipientEmail` (String) - Address a pending share was sent to. Only set when `pending`.
- `modified` (Number) - Last-modified time of the item, in unix seconds.
- `size` (Number) - Size of the item in bytes; `null` for a directory.
- `isNew` (Boolean) - Whether this call created access that did not exist before. `false` means the recipient already had it, possibly at a different mode — sharing again is not an error, so this is how you tell the two apart. Only `share()` reports it; a listing leaves it undefined.
Sharing the same item with the same person again **replaces** their access rather than adding a second share, so raising someone from `read` to `write` is just another call.
+8 -3
View File
@@ -2579,6 +2579,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");
@@ -2604,6 +2605,10 @@ const TabFiles = {
<div class="item-checkbox"><span class="checkbox-icon"></span></div>
<div class="item-icon">
${icon}
<div class="item-shared-marker"
style="${file.is_shared === true ? '' : 'display:none;'}"
title="${html_encode(i18n('item_shared_by_you'))}"
></div>
</div>
<div class="item-badges">
<img class="item-badge item-has-website-badge long-hover"
@@ -2622,9 +2627,9 @@ const TabFiles = {
data-item-id="${item_id}"
title="Shortcut"
>
<img class="item-badge item-is-worker long-hover"
style="background-color: #ffffff; padding: 2px; ${is_worker ? 'display:block;' : ''}"
src="${html_encode(window.icons['worker.svg'])}"
<img class="item-badge item-is-worker long-hover"
style="background-color: #ffffff; padding: 2px; ${is_worker ? 'display:block;' : ''}"
src="${html_encode(window.icons['worker.svg'])}"
data-item-id="${item_id}"
>
</div>
+32 -6
View File
@@ -19,12 +19,17 @@
import path from '../../lib/path.js';
import item_icon from '../../helpers/itemIcon.js';
import { owner_of_path } from '../../helpers/pathOwner.js';
import { is_owned_by_me, owner_of_path } from '../../helpers/pathOwner.js';
import { invalidate_shared_roots } from '../../helpers/sharedAccess.js';
import { icons } from '../../helpers/actionIcons.js';
import { mode_label, options_for } from '../../helpers/shareModes.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 { share_outcome } from '../../helpers/shareOutcome.js';
import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js';
const { html_encode } = window;
@@ -35,6 +40,14 @@ const chevronIcon = `<svg class="share-modal-chevron" viewBox="0 0 24 24" width=
// How many item icons the header fans out before it stops adding to the pile.
const MAX_STACKED_ICONS = 3;
/** What each outcome of a share call is called on screen. */
const SHARE_MESSAGE = {
invited: 'share_invited',
shared: 'share_shared_with',
updated: 'share_access_updated',
unchanged: 'share_already_shared_with',
};
// What one /share, /share/revoke or listing pass may cover, matching the
// backend's documented cap (see doc: rate limits and quotas). Bigger
// selections are shared in several requests, and skip the access list rather
@@ -111,6 +124,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: () => {} };
@@ -155,7 +170,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
<div class="share-modal-add-row">
<input type="text" class="share-modal-recipient" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"
placeholder="${i18n('share_add_people')}" aria-label="${i18n('share_add_people')}" />
<select class="share-modal-mode" aria-label="${i18n('share_access_level')}">${options_for('read')}</select>
<select class="share-modal-mode" aria-label="${i18n('share_access_level')}">${options_for('read', { allow_manage })}</select>
</div>
<button type="submit" class="share-modal-submit" disabled>
<span class="share-modal-spinner" aria-hidden="true"></span>
@@ -314,7 +329,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// The accessible names carry the person: a list where every row
// reads as bare "Access level" / "Remove access" leaves a screen
// reader user unable to tell whose grant a control changes.
row += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">${options_for(group.mode)}</select>`;
row += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">${options_for(group.mode, { allow_manage })}</select>`;
} else {
const fixed_mode = group.pending ? group.pendingMode : group.inheritedMode;
row += `<span class="share-modal-row-tag">${fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed')}</span>`;
@@ -391,6 +406,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'));
};
@@ -525,9 +544,16 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// "Shared with" would claim access an invite does not grant.
const invited = created?.some((share) => share.pending);
if ( ! is_multi ) {
show_success(invited
? i18n('share_invited', { recipient })
: i18n('share_shared_with', { recipient }));
// One item, so the list on screen settles what changed.
const before = last_groups.map((group) => ({
holder: group.name,
mode: group.mode,
}));
show_success(
i18n(SHARE_MESSAGE[share_outcome(created, before)], {
recipient,
}),
);
} else if ( granted < total ) {
show_success(i18n('share_shared_with_partial', { recipient, count: granted, total }));
} else {
+10 -1
View File
@@ -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)}"
@@ -214,6 +217,12 @@ async function UIItem (options) {
// icon
h += '<div class="item-icon">';
h += `<img src="${html_encode(options.icon.image)}" class="item-icon-${options.icon.type}" data-item-id="${item_id}">`;
// 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 += `<div class="item-shared-marker"
style="${options.is_shared ? '' : 'display:none;'}"
title="${html_encode(i18n('item_shared_by_you'))}"
></div>`;
h += '</div>';
// badges
h += '<div class="item-badges">';
@@ -238,7 +247,7 @@ async function UIItem (options) {
title="${i18n('item_shortcut')}"
>`;
// worker badge
h += `<img class="item-badge item-is-worker long-hover"
h += `<img class="item-badge item-is-worker long-hover"
style="background-color: #ffffff; padding: 2px; ${is_worker ? 'display:block;' : ''}"
src="${html_encode(window.icons['worker.svg'])}"
data-item-id="${item_id}"
+24 -7
View File
@@ -20,10 +20,20 @@
import UIWindow from './UIWindow.js';
import UIAlert from './UIAlert.js';
import path from '../lib/path.js';
import { owner_of_path } from '../helpers/pathOwner.js';
import { is_owned_by_me, owner_of_path } from '../helpers/pathOwner.js';
import { invalidate_shared_roots } from '../helpers/sharedAccess.js';
import { icons } from '../helpers/actionIcons.js';
import { mode_label, options_for } from '../helpers/shareModes.js';
import { has_direct_share, mark_item_shared } from '../helpers/sharedBadge.js';
import { share_outcome } from '../helpers/shareOutcome.js';
/** What each outcome of a share call is called on screen. */
const SHARE_MESSAGE = {
invited: 'share_invited',
shared: 'share_shared_with',
updated: 'share_access_updated',
unchanged: 'share_already_shared_with',
};
/**
* Sharing dialog for one file or directory.
@@ -40,6 +50,8 @@ async function UIWindowShare (options) {
const item_name = options.name ?? path.basename(item_path);
const item_owner =
options.owner ?? owner_of_path(item_path) ?? window.user.username;
// A delegate passes on access, never the authority to pass it on.
const allow_manage = is_owned_by_me(item_path);
let h = '';
h += '<div class="share-dialog">';
@@ -50,7 +62,7 @@ async function UIWindowShare (options) {
h += '<div class="share-dialog-row">';
h += `<input class="share-recipient" id="share-recipient" type="text" autocomplete="off" spellcheck="false"
placeholder="${html_encode(i18n('share_add_people'))}" />`;
h += `<select class="share-mode">${options_for('read')}</select>`;
h += `<select class="share-mode">${options_for('read', { allow_manage })}</select>`;
h += '</div>';
h += `<button class="share-btn button button-primary button-block button-normal">${i18n('share')}</button>`;
@@ -111,7 +123,11 @@ async function UIWindowShare (options) {
$success.html(message).show();
};
/** The access list as last drawn, which is what a share call changes. */
let shown_shares = [];
const render = (shares) => {
shown_shares = Array.isArray(shares) ? shares : [];
let rows = '';
// The owner's access comes from owning the item, so it can't be revoked
rows += '<div class="share-row">';
@@ -142,7 +158,7 @@ async function UIWindowShare (options) {
}
rows += '<div class="share-row">';
rows += `<span class="share-row-who">${holder}</span>`;
rows += `<select class="share-row-mode-select" data-holder="${holder}">${options_for(share.mode)}</select>`;
rows += `<select class="share-row-mode-select" data-holder="${holder}">${options_for(share.mode, { allow_manage })}</select>`;
rows += `<button class="share-revoke" data-holder="${holder}" title="${html_encode(i18n('share_remove_access'))}" aria-label="${html_encode(i18n('share_remove_access'))}">${icons.trash}</button>`;
rows += '</div>';
}
@@ -150,6 +166,8 @@ async function UIWindowShare (options) {
rows += `<p class="share-dialog-empty">${i18n('share_no_one')}</p>`;
}
$list.html(rows);
// Every share, mode change and revoke lands here.
mark_item_shared(item_path, has_direct_share(shares));
};
const refresh = async () => {
@@ -173,13 +191,12 @@ async function UIWindowShare (options) {
});
$(el_window).find('.share-recipient').val('');
$error.hide();
// "Shared with" would claim access an invite does not grant.
// `i18n()` encodes its replacements; encoding first would show the
// entities to anyone whose address or username contains one.
show_success(
created.some((share) => share.pending)
? i18n('share_invited', { recipient })
: i18n('share_shared_with', { recipient }),
i18n(SHARE_MESSAGE[share_outcome(created, shown_shares)], {
recipient,
}),
);
invalidate_shared_roots();
await refresh();
+9
View File
@@ -4064,6 +4064,15 @@ body.myapps-reordering .myapps-tile {
border-radius: 2px;
}
/* Smaller than the desktop's, to match a 24px row icon. */
.dashboard-section-files .files-tab .files .row .item-shared-marker {
width: 7px;
height: 7px;
right: 2px;
bottom: 2px;
box-shadow: 0 0 0 1px white;
}
.dashboard-section-files .files-tab .files.files-list-view .row .item-icon img {
width: 18px;
height: 18px;
+18
View File
@@ -533,6 +533,8 @@ span.header-sort-icon img {
}
.item-icon {
/* Anchors the shared marker to the icon's corner. */
position: relative;
display: block;
margin: 0 auto;
padding: 5px;
@@ -674,6 +676,22 @@ span.header-sort-icon img {
cursor: pointer;
}
/* Shared, owner-side: a dot on the icon's lower-right. A glyph is unreadable
at the size a row icon allows, and colour is the signal that survives it. */
.item-shared-marker {
position: absolute;
/* 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%;
background: #3b82f6;
box-shadow: 0 0 0 1.5px white;
pointer-events: all;
}
.item-name, .item-name-editor, .item-name-shadow {
font-size: 12px;
color: white;
@@ -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,
});
}
@@ -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,
});
}
+8 -2
View File
@@ -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) =>
`<option value="${html_encode(mode)}"${mode === current ? ' selected' : ''}>${mode_label(mode)}</option>`,
+13
View File
@@ -57,6 +57,19 @@ describe('options_for', () => {
expect(html).not.toContain('<option value="read" selected>');
});
it('withholds `manage` from someone who cannot grant it', () => {
// Offering it to a delegate is a dead end the server refuses.
const html = options_for('read', { allow_manage: false });
expect(values(html)).toEqual(['read', 'write']);
expect(html).not.toContain('value="manage"');
});
it('still shows a row already set to `manage`, so opening the dialog does not downgrade it', () => {
const html = options_for('manage', { allow_manage: false });
expect(values(html)).toEqual(MODES);
expect(html).toContain('<option value="manage" selected>');
});
it('keeps an out-of-band mode instead of rounding it to read', () => {
const html = options_for('see');
expect(values(html)).toEqual(['see', ...MODES]);
+47
View File
@@ -0,0 +1,47 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* What a share call actually did, so the dialog can say so. A backend that
* omits `isNew` reads as `shared`, which is what these dialogs said before it.
*
* @param {Array<{ pending?: boolean, isNew?: boolean, mode?: string, holder?: string|null }>} created
* @param {Array<{ holder?: string|null, mode?: string, inheritedFrom?: string|null }>} [before]
* @returns {'invited' | 'shared' | 'updated' | 'unchanged'}
*/
export const share_outcome = (created, before = []) => {
const list = Array.isArray(created) ? created.filter(Boolean) : [];
if ( list.some((share) => share.pending) ) return 'invited';
if ( list.length === 0 || list.some((share) => share.isNew !== false) ) {
return 'shared';
}
// Matched on the resolved username rather than what was typed, so an email
// that belongs to a known account still finds their row.
const previous = (Array.isArray(before) ? before : []).find(
(share) =>
share?.holder &&
list.some((made) => made.holder === share.holder) &&
! share.inheritedFrom,
);
if ( ! previous ) return 'unchanged';
return list.some((made) => made.mode !== previous.mode)
? 'updated'
: 'unchanged';
};
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { share_outcome } from './shareOutcome.js';
describe('share_outcome', () => {
it('reports a first-time share', () => {
expect(share_outcome([{ holder: 'ann', mode: 'read', isNew: true }]))
.toBe('shared');
});
it('reports an invite ahead of anything else', () => {
expect(
share_outcome([
{ recipientEmail: 'x@example.com', pending: true, isNew: true },
]),
).toBe('invited');
});
it('says nothing changed when the same access is granted twice', () => {
// The bug: this used to read as a fresh share.
expect(
share_outcome(
[{ holder: 'ann', mode: 'read', isNew: false }],
[{ holder: 'ann', mode: 'read' }],
),
).toBe('unchanged');
});
it('says the level changed when the mode differs', () => {
expect(
share_outcome(
[{ holder: 'ann', mode: 'write', isNew: false }],
[{ holder: 'ann', mode: 'read' }],
),
).toBe('updated');
});
it('matches on the resolved username, not what was typed', () => {
// The recipient was entered as an email; their row is keyed on the
// username the server resolved it to.
expect(
share_outcome(
[{ holder: 'ann', mode: 'write', isNew: false }],
[{ holder: 'ann', mode: 'read' }, { holder: 'bob', mode: 'read' }],
),
).toBe('updated');
});
it('ignores an inherited row, which this call cannot have changed', () => {
expect(
share_outcome(
[{ holder: 'ann', mode: 'read', isNew: false }],
[{ holder: 'ann', mode: 'write', inheritedFrom: '/bob/Docs' }],
),
).toBe('unchanged');
});
it('falls back to `shared` when the backend does not report isNew', () => {
// An older server, or the CDN SDK before this shipped.
expect(share_outcome([{ holder: 'ann', mode: 'read' }])).toBe('shared');
expect(share_outcome([])).toBe('shared');
expect(share_outcome(undefined)).toBe('shared');
});
});
+42
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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-shared-marker')
.css('display', is_shared ? '' : 'none');
};
+43
View File
@@ -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);
});
});
+1
View File
@@ -399,6 +399,7 @@ const en = {
share_done: 'Done',
share_failed: 'Could not share this item.',
share_shared_with: 'Shared with {{recipient}}',
share_already_shared_with: '{{recipient}} already has this access',
share_access_updated: 'Updated access for {{recipient}}',
share_invited: 'Invited {{recipient}} — theyll get access once they join',
share_awaiting_signup: 'Invited',
@@ -84,6 +84,8 @@ export const toShare = (row) => ({
: {}),
modified: /** @type {number} */ (row.modified ?? 0),
size: /** @type {number | null} */ (row.size ?? null),
// Only a share call reports this; a listing leaves it undefined.
...(row.is_new === undefined ? {} : { isNew: Boolean(row.is_new) }),
});
/**
@@ -329,6 +329,8 @@
* to. Only set when `pending`.
* @property {number} modified Last-modified time of the item, unix seconds.
* @property {number | null} size Size of the item in bytes; null for a directory.
* @property {boolean} [isNew] Whether the call created access that did not exist
* before. Set by `share()` only; a listing leaves it undefined.
*/
/**
@@ -67,6 +67,21 @@ export default suite('sharing', {
t.assert.equal(shares[0].path, path);
},
'share says whether it created access or the recipient already had it': async (t) => {
const path = scratch(t, 'isnew');
await t.puter.fs.write(path, 'x');
const first = await t.puter.fs.share(path, t.env.users.other.username, 'read');
t.assert.equal(first[0].isNew, true);
const again = await t.puter.fs.share(path, t.env.users.other.username, 'read');
t.assert.equal(again[0].isNew, false);
// A listing describes standing access, so it does not carry it.
const listed = await t.puter.fs.getShares(path);
t.assert.equal(listed[0].isNew, undefined);
},
'getShares reports who can reach an item': async (t) => {
const path = scratch(t, 'getshares');
await t.puter.fs.write(path, 'x');