mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-27 08:27:43 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}} — they’ll 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');
|
||||
|
||||
Reference in New Issue
Block a user