mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589fbf26aa | ||
|
|
8d5eed1cb7 | ||
|
|
a80c748c15 | ||
|
|
d60526fa72 | ||
|
|
704649f0c3 | ||
|
|
87c1013986 | ||
|
|
c0abf69aad | ||
|
|
2c819cc158 | ||
|
|
96f3c7d058 | ||
|
|
3809f38e5f | ||
|
|
89124bb27d | ||
|
|
eaff5c5929 | ||
|
|
f452d8d8fa | ||
|
|
23e56f2ce2 | ||
|
|
095c18fdb0 | ||
|
|
8c2b834fd3 | ||
|
|
2217513589 | ||
|
|
6379260042 | ||
|
|
9fb31abad4 | ||
|
|
c369b55841 | ||
|
|
01da03437a | ||
|
|
f67a04f67e | ||
|
|
908dcbbd22 | ||
|
|
1002f628a7 | ||
|
|
01135fcee4 | ||
|
|
e7f2f55b79 | ||
|
|
d98f16f191 | ||
|
|
bdfcbebd31 | ||
|
|
af01727f0f | ||
|
|
a0b1f82b85 | ||
|
|
225eeba6ec | ||
|
|
fcf8bc53d1 | ||
|
|
8b3746cdca | ||
|
|
737fdd2148 | ||
|
|
fb6571e914 | ||
|
|
3512093143 | ||
|
|
0b48fff418 | ||
|
|
8d1656dff8 | ||
|
|
ed21d14385 | ||
|
|
b2b9db0205 | ||
|
|
804e9c35e9 | ||
|
|
f14fa619bf | ||
|
|
d689861fa2 | ||
|
|
3984ec724c | ||
|
|
95634784a0 | ||
|
|
a028f2ce08 | ||
|
|
dceda8aa2a | ||
|
|
63f6ed7c38 | ||
|
|
8a15f09f48 | ||
|
|
2084587885 | ||
|
|
222ffcfe39 | ||
|
|
7494aa1cbc | ||
|
|
1adca8a794 | ||
|
|
07b19888a4 |
No files matched your search
@@ -330,14 +330,11 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged app listing. Unlike `/query/app`, which answers only for apps named
|
||||
* by the caller and so must not confirm ones it may not see, this is the
|
||||
* listing surface a launcher reads: `protected` apps are filtered by the
|
||||
* permission check below, and everything else is listable by design. Narrow
|
||||
* it with `predicate: ['user-can-edit']` to the caller's own.
|
||||
* Paged listing of the caller's own apps. Apps owned by anyone else are
|
||||
* reachable only by name or uid, through `read` and `/query/app`.
|
||||
*/
|
||||
async select(args = {}) {
|
||||
const { predicate, params = {} } = args;
|
||||
const { params = {} } = args;
|
||||
const actor = requireContextActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
@@ -353,16 +350,12 @@ export class AppDriver extends PuterDriver {
|
||||
const includeTotal = args.includeTotal === true;
|
||||
const paginated = hasCursor || offset !== undefined || includeTotal;
|
||||
|
||||
const filters = {};
|
||||
// predicate: ['user-can-edit'] → scope to owner
|
||||
const ownerScoped =
|
||||
Array.isArray(predicate) && predicate[0] === 'user-can-edit';
|
||||
if (ownerScoped) {
|
||||
filters.ownerUserId = actor.user.id;
|
||||
}
|
||||
// Always the caller's own. `predicate: ['user-can-edit']` asks for the
|
||||
// same thing and is accepted for older clients.
|
||||
const ownerUserId = actor.user.id;
|
||||
|
||||
let apps = await this.appStore.list({
|
||||
...filters,
|
||||
ownerUserId,
|
||||
limit: paginated ? limit + 1 : limit,
|
||||
offset,
|
||||
afterId: payload?.id !== undefined ? Number(payload.id) : undefined,
|
||||
@@ -371,23 +364,18 @@ export class AppDriver extends PuterDriver {
|
||||
let cursor;
|
||||
if (paginated && apps.length > limit) {
|
||||
apps = apps.slice(0, limit);
|
||||
// The cursor tracks the last fetched row, not the last visible
|
||||
// one, so rows hidden by the permission filter below aren't
|
||||
// re-scanned on the next page.
|
||||
cursor = sealCursor(
|
||||
{ id: Number(apps[apps.length - 1].id) },
|
||||
this.config.jwt_secret_v2,
|
||||
);
|
||||
}
|
||||
|
||||
// Protected apps are filtered by one batched grant check; stats and
|
||||
// the shared views are each one batched lookup across the page.
|
||||
const visible = await this.services.app.filterReadable(apps, actor);
|
||||
// Stats and the shared views are each one batched lookup per page.
|
||||
const [statsByUid, views] = await Promise.all([
|
||||
this.appStore.getAppsStats(visible.map((a) => a.uid)),
|
||||
this.services.app.views(visible, actor),
|
||||
this.appStore.getAppsStats(apps.map((a) => a.uid)),
|
||||
this.services.app.views(apps, actor),
|
||||
]);
|
||||
const items = visible.map((app, i) =>
|
||||
const items = apps.map((app, i) =>
|
||||
toAppReadView(app, views[i], {
|
||||
viewer: actor.user,
|
||||
stats: statsByUid.get(app.uid),
|
||||
@@ -396,14 +384,9 @@ export class AppDriver extends PuterDriver {
|
||||
);
|
||||
if (!paginated) return items;
|
||||
|
||||
let total;
|
||||
if (includeTotal) {
|
||||
total = ownerScoped
|
||||
? await this.appStore.count({ ownerUserId: actor.user.id })
|
||||
: await this.appStore.count({
|
||||
visibleToUserId: actor.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
const total = includeTotal
|
||||
? await this.appStore.count({ ownerUserId })
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
items,
|
||||
|
||||
@@ -509,7 +509,7 @@ describe('AppDriver.read', () => {
|
||||
// ── select ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('AppDriver.select', () => {
|
||||
it('returns visible apps including those owned by other users', async () => {
|
||||
it("returns only the caller's apps when no predicate is sent", async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
const aName = uniqueName('a');
|
||||
@@ -538,7 +538,7 @@ describe('AppDriver.select', () => {
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const names = result.map((r) => r.name);
|
||||
expect(names).toContain(aName);
|
||||
expect(names).toContain(bName);
|
||||
expect(names).not.toContain(bName);
|
||||
});
|
||||
|
||||
it('predicate `user-can-edit` filters to actor-owned apps only', async () => {
|
||||
@@ -1670,11 +1670,10 @@ describe('AppDriver.select pagination', () => {
|
||||
expect(page.total).toBe(names.length);
|
||||
});
|
||||
|
||||
it("hides other users' protected apps from paginated catalog listings", async () => {
|
||||
it("pages through only the caller's apps, protected ones included", async () => {
|
||||
const a = await makeApps(3);
|
||||
const [visible1, hidden, visible2] = a.names;
|
||||
const created = await withActor(a.actor, () =>
|
||||
driver.read({ id: { name: hidden } }),
|
||||
driver.read({ id: { name: a.names[1] } }),
|
||||
);
|
||||
const row = await server.stores.app.getByUid(
|
||||
(created as Record<string, unknown>).uid as string,
|
||||
@@ -1685,20 +1684,29 @@ describe('AppDriver.select pagination', () => {
|
||||
);
|
||||
await server.stores.app.invalidateByUid(row!.uid as string);
|
||||
|
||||
const b = await makeUser();
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(b.actor, () =>
|
||||
driver.select({ limit: 50, cursor }),
|
||||
)) as { items: Array<{ name: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((r) => r.name));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
const listAll = async (actor: Actor) => {
|
||||
const seen: string[] = [];
|
||||
let total: number | undefined;
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 2, cursor, includeTotal: true }),
|
||||
)) as {
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
};
|
||||
seen.push(...page.items.map((r) => r.name));
|
||||
total ??= page.total;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
return { seen, total };
|
||||
};
|
||||
|
||||
expect(seen).toContain(visible1);
|
||||
expect(seen).toContain(visible2);
|
||||
expect(seen).not.toContain(hidden);
|
||||
expect(await listAll(a.actor)).toEqual({ seen: a.names, total: 3 });
|
||||
|
||||
const b = await makeUser();
|
||||
expect(await listAll(b.actor)).toEqual({ seen: [], total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2517,22 +2525,6 @@ describe('AppDriver.select query-count regression', () => {
|
||||
// -- select / read parity --
|
||||
|
||||
describe('AppDriver.select / read parity', () => {
|
||||
const findViaBroadSelect = async (
|
||||
actor: Actor,
|
||||
uid: string,
|
||||
): Promise<Record<string, unknown> | undefined> => {
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 50, cursor }),
|
||||
)) as { items: Array<Record<string, unknown>>; cursor?: string };
|
||||
const found = page.items.find((r) => r.uid === uid);
|
||||
if (found) return found;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
it('every select item (minus stats) deep-equals the corresponding read', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const names: string[] = [];
|
||||
@@ -2640,7 +2632,7 @@ describe('AppDriver.select / read parity', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('dangling hosted app via select: owner keeps index_url, others do not', async () => {
|
||||
it('dangling hosted app: owner keeps index_url in select, others do not in read', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const sub = uniqueName('qcdangsel');
|
||||
@@ -2672,13 +2664,12 @@ describe('AppDriver.select / read parity', () => {
|
||||
?.reason,
|
||||
).toBe('hosted_backing_unavailable');
|
||||
|
||||
const otherItem = await findViaBroadSelect(
|
||||
other.actor,
|
||||
created.uid as string,
|
||||
);
|
||||
expect(otherItem?.index_url).toBeUndefined();
|
||||
const otherItem = (await withActor(other.actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
)) as Record<string, unknown>;
|
||||
expect(otherItem.index_url).toBeUndefined();
|
||||
expect(
|
||||
(otherItem?.privateAccess as { hasAccess?: boolean } | undefined)
|
||||
(otherItem.privateAccess as { hasAccess?: boolean } | undefined)
|
||||
?.hasAccess,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTokenForAnotherUser } from '../../../gui/src/helpers/tokenUser.js';
|
||||
import { TokenService } from './TokenService.js';
|
||||
|
||||
const V2_SECRET = 'test-v2-secret';
|
||||
@@ -384,3 +385,21 @@ describe('TokenService payload key handling', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('session token read by the desktop', () => {
|
||||
// The desktop reads the user out of a token another tab stored, to tell
|
||||
// an account switch from a renewed token for the same account.
|
||||
it('names its user in the form the desktop decodes', () => {
|
||||
const svc = createTokenService();
|
||||
const userUid = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
const otherUid = 'a1b2c3d4-0000-4000-8000-00000000beef';
|
||||
const token = svc.sign('auth', {
|
||||
type: 'gui',
|
||||
version: '2',
|
||||
user_uid: userUid,
|
||||
});
|
||||
|
||||
expect(isTokenForAnotherUser(token, { uuid: userUid })).toBe(false);
|
||||
expect(isTokenForAnotherUser(token, { uuid: otherUid })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -686,12 +686,7 @@ export class AppStore extends PuterStore {
|
||||
return apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count apps matching the same equality filters as `list`.
|
||||
* `visibleToUserId` counts the public catalog for one caller —
|
||||
* non-protected apps plus their own; apps visible only via explicit
|
||||
* permission grants are not included.
|
||||
*/
|
||||
/** Count apps matching the same equality filters as `list`. */
|
||||
async count(filters = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
@@ -704,12 +699,6 @@ export class AppStore extends PuterStore {
|
||||
where.push('`app_owner` = ?');
|
||||
params.push(filters.appOwner);
|
||||
}
|
||||
if (filters.visibleToUserId !== undefined) {
|
||||
where.push(
|
||||
'(`protected` IS NULL OR `protected` = false OR `owner_user_id` = ?)',
|
||||
);
|
||||
params.push(filters.visibleToUserId);
|
||||
}
|
||||
|
||||
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const rows = await this.clients.db.read(
|
||||
|
||||
@@ -1292,31 +1292,14 @@ describe('AppStore CRUD and cache invalidation', () => {
|
||||
).toBe('Bumped');
|
||||
});
|
||||
|
||||
it('counts apps by owner, creator and per-caller visibility', async () => {
|
||||
it('counts apps by owner and creator', async () => {
|
||||
const countOwner = 4600;
|
||||
const viewer = 4601;
|
||||
const open = await createApp({}, { ownerUserId: countOwner });
|
||||
await createApp({}, { ownerUserId: countOwner, appOwner: open.id });
|
||||
const hidden = await createApp({}, { ownerUserId: countOwner });
|
||||
await db.write('UPDATE `apps` SET `protected` = 1 WHERE `id` = ?', [
|
||||
hidden.id,
|
||||
]);
|
||||
await createApp({}, { ownerUserId: countOwner });
|
||||
|
||||
expect(await appStore.count({ ownerUserId: countOwner })).toBe(3);
|
||||
expect(await appStore.count({ appOwner: open.id })).toBe(1);
|
||||
|
||||
// The protected app is invisible to a stranger but visible to its
|
||||
// owner.
|
||||
const strangerView = await appStore.count({
|
||||
ownerUserId: countOwner,
|
||||
visibleToUserId: viewer,
|
||||
});
|
||||
expect(strangerView).toBe(2);
|
||||
const ownerView = await appStore.count({
|
||||
ownerUserId: countOwner,
|
||||
visibleToUserId: countOwner,
|
||||
});
|
||||
expect(ownerView).toBe(3);
|
||||
});
|
||||
|
||||
// -- filetype association batch + recent opens ----------------------
|
||||
|
||||
@@ -1339,7 +1339,7 @@ async function saveEditedApp (mode) {
|
||||
// Update title
|
||||
$(`.app-title[data-uid="${uid}"]`).html(html_encode(app.title));
|
||||
// Update app link
|
||||
$(`.app-url[data-uid="${uid}"]`).html(applink(app));
|
||||
$(`.app-url[data-uid="${uid}"]`).html(html_encode(applink(app)));
|
||||
$(`.app-url[data-uid="${uid}"]`).attr('href', applink(app));
|
||||
// Update icons
|
||||
$(`.app-icon[data-uid="${uid}"]`).attr('src', html_encode(app.icon ? app.icon : './img/app.svg'));
|
||||
|
||||
@@ -38,22 +38,27 @@ window.user = null;
|
||||
}
|
||||
})();
|
||||
|
||||
// domain and APIOrigin
|
||||
if ( window.url_params.has('puter.domain') ) {
|
||||
window.domain = window.url_params.get('puter.domain');
|
||||
// domain and port are built into links and deploy URLs, so anything other
|
||||
// than a bare hostname and a numeric port is ignored
|
||||
const domainParam = window.url_params.get('puter.domain');
|
||||
if ( domainParam && /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(domainParam) ) {
|
||||
window.domain = domainParam;
|
||||
}
|
||||
|
||||
const portParam = window.url_params.get('puter.port');
|
||||
window.port = portParam && /^\d{1,5}$/.test(portParam) ? portParam : '';
|
||||
|
||||
// static hosting domain
|
||||
window.static_hosting_domain = 'puter.site';
|
||||
if ( window.domain === 'puter.localhost' ) {
|
||||
window.static_hosting_domain = 'site.puter.localhost';
|
||||
} else if ( window.domain && window.domain !== 'puter.com' ) {
|
||||
window.static_hosting_domain = `site.${window.domain}`;
|
||||
}
|
||||
if ( window.domain === 'puter.localhost' ) {
|
||||
window.static_hosting_domain = 'site.puter.localhost';
|
||||
} else if ( window.domain && window.domain !== 'puter.com' ) {
|
||||
window.static_hosting_domain = `site.${window.domain}`;
|
||||
}
|
||||
|
||||
// add port to static_hosting_domain if provided
|
||||
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
|
||||
window.static_hosting_domain = `${window.static_hosting_domain }:${ html_encode(window.url_params.get('puter.port'))}`;
|
||||
if ( window.port ) {
|
||||
window.static_hosting_domain = `${window.static_hosting_domain}:${window.port}`;
|
||||
}
|
||||
|
||||
// protocol
|
||||
@@ -63,12 +68,6 @@ if ( window.url_params.has('puter.protocol') && window.url_params.get('puter.pro
|
||||
window.protocol = 'http';
|
||||
}
|
||||
|
||||
// port
|
||||
window.port = '';
|
||||
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
|
||||
window.port = html_encode(window.url_params.get('puter.port'));
|
||||
}
|
||||
|
||||
// source_path
|
||||
if ( window.url_params.has('source_path') ) {
|
||||
window.source_path = window.url_params.get('source_path');
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@
|
||||
import download from './helpers/download.js';
|
||||
import ensureConfirmedEmail from './helpers/ensureConfirmedEmail.js';
|
||||
import item_icon from './helpers/itemIcon.js';
|
||||
import { atPath } from './helpers/pathElements.js';
|
||||
import socialLink from './helpers/socialLink.js';
|
||||
import update_mouse_position from './helpers/updateMousePosition.js';
|
||||
import path from './lib/path.js';
|
||||
@@ -1702,7 +1703,7 @@ const ipc_listener = async (event, handled) => {
|
||||
$(`.item[data-uid="${res.uid}"]`).removeItems();
|
||||
// now add new items
|
||||
UIItem({
|
||||
appendTo: $(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`),
|
||||
appendTo: atPath('.item-container', path.dirname(target_path)),
|
||||
immutable: res.immutable || res.writable === false,
|
||||
associated_app_name: res.associated_app?.name,
|
||||
path: target_path,
|
||||
@@ -1716,7 +1717,7 @@ const ipc_listener = async (event, handled) => {
|
||||
suggested_apps: res.suggested_apps,
|
||||
});
|
||||
// sort each window
|
||||
$(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`).each(function () {
|
||||
atPath('.item-container', path.dirname(target_path)).each(function () {
|
||||
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
|
||||
});
|
||||
$(el_filedialog_window).close();
|
||||
|
||||
@@ -50,7 +50,7 @@ import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from
|
||||
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
|
||||
import { is_owned_by_me } from '../../helpers/pathOwner.js';
|
||||
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, sharerOf, summarizeSharers } from './sharedBy.js';
|
||||
import { paintAvatars } from '../../helpers/profilePictures.js';
|
||||
|
||||
const { html_encode, SelectionArea } = window;
|
||||
@@ -2983,7 +2983,7 @@ const TabFiles = {
|
||||
// Rebuilding the chips drops focus and scroll; carry both across.
|
||||
const focused = $bar.find('.sharer-chip:focus').attr('data-sharer');
|
||||
const scrollLeft = bar.scrollLeft;
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter);
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter, rows.filter((row) => sharerOf(row)).length);
|
||||
$bar.html(html).prop('hidden', ! html);
|
||||
paintAvatars(bar);
|
||||
bar.scrollLeft = scrollLeft;
|
||||
@@ -6011,8 +6011,10 @@ const TabFiles = {
|
||||
shared_with_me: true,
|
||||
share_mode: share.mode,
|
||||
shared_by: share.issuer,
|
||||
shared_by_all: share.issuers,
|
||||
owner: share.owner,
|
||||
holder_team: share.holderTeam,
|
||||
also_team_share: share.alsoHeldByTeam,
|
||||
}));
|
||||
|
||||
const linked = this.pendingSharedLinks ?? [];
|
||||
|
||||
@@ -1667,6 +1667,34 @@ describe('TabFiles listSharedView', () => {
|
||||
});
|
||||
expect(rows[1].holder_team).toEqual(team);
|
||||
});
|
||||
|
||||
it('merges the grants on one item into a single row', async () => {
|
||||
const team = { uid: 't-1', name: 'Acme', handle: 'acme' };
|
||||
const grant = { entryUid: 'u-1', name: 'a.txt', path: '/bob/u-1/a.txt', isDir: false, owner: 'bob' };
|
||||
window.puter = {
|
||||
fs: {
|
||||
listShared: vi.fn(async () => ({
|
||||
items: [
|
||||
{ ...grant, mode: 'read', issuer: 'bob', holderTeam: team },
|
||||
{ ...grant, mode: 'manage', issuer: 'bob' },
|
||||
{ ...grant, mode: 'read', issuer: 'carol' },
|
||||
],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await TabFiles.listSharedView();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
uid: 'u-1',
|
||||
share_mode: 'manage',
|
||||
shared_by: 'bob',
|
||||
shared_by_all: ['bob', 'carol'],
|
||||
also_team_share: true,
|
||||
});
|
||||
expect(rows[0].holder_team).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles path field', () => {
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
|
||||
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
|
||||
import { avatarUserAttr } from '../../helpers/profilePictures.js';
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
/**
|
||||
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
|
||||
* @typedef {{ shared_by?: string | null, shared_by_all?: string[], owner?: string | null }} SharedRow
|
||||
* @typedef {{ name: string, count: number }} Sharer
|
||||
*/
|
||||
|
||||
@@ -37,6 +38,19 @@ import { avatarUserAttr } from '../../helpers/profilePictures.js';
|
||||
*/
|
||||
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
|
||||
/**
|
||||
* Everyone who shared a row with the user, `sharerOf` first. More than one
|
||||
* when the item was granted by several people.
|
||||
*
|
||||
* @param {SharedRow} [row]
|
||||
* @returns {string[]} usernames
|
||||
*/
|
||||
export const sharersOf = (row) => {
|
||||
const first = sharerOf(row);
|
||||
if ( ! row?.shared_by_all?.length ) return first ? [first] : [];
|
||||
return [...new Set([first, ...row.shared_by_all])].filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone who shared something in `rows`, with how many items each, most
|
||||
* items first and ties by name.
|
||||
@@ -47,8 +61,7 @@ export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
export const summarizeSharers = (rows) => {
|
||||
const counts = new Map();
|
||||
for ( const row of rows ) {
|
||||
const name = sharerOf(row);
|
||||
if ( name ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
for ( const name of sharersOf(row) ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
@@ -56,7 +69,8 @@ export const summarizeSharers = (rows) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The rows `sharer` shared; every row when `sharer` is empty.
|
||||
* The rows `sharer` shared, each shown as shared by them; every row when
|
||||
* `sharer` is empty.
|
||||
*
|
||||
* @template {SharedRow} T
|
||||
* @param {T[]} rows
|
||||
@@ -64,7 +78,9 @@ export const summarizeSharers = (rows) => {
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export const filterBySharer = (rows, sharer) => (sharer
|
||||
? rows.filter((row) => sharerOf(row) === sharer)
|
||||
? rows
|
||||
.filter((row) => sharersOf(row).includes(sharer))
|
||||
.map((row) => (sharerOf(row) === sharer ? row : { ...row, shared_by: sharer }))
|
||||
: rows);
|
||||
|
||||
/**
|
||||
@@ -80,6 +96,21 @@ export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
|
||||
// one per call, which made sorting a large Shared listing over 20x slower.
|
||||
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
|
||||
|
||||
/**
|
||||
* Names joined the way the user's language lists them ("ann and bob").
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
const listNames = (names) => {
|
||||
if ( names.length === 1 ) return names[0];
|
||||
try {
|
||||
return new Intl.ListFormat(intlLocale(window.locale ?? 'en'), { type: 'conjunction' }).format(names);
|
||||
} catch {
|
||||
return names.join(', ');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Shared by" column's width beside an auto-width name column: `width`,
|
||||
* held to 40% of the `room` the two split so the name keeps the larger part,
|
||||
@@ -102,15 +133,16 @@ export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Ma
|
||||
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}"${avatarUserAttr(name)} aria-hidden="true">${window.html_encode(avatarInitial(name))}</span>`;
|
||||
|
||||
/**
|
||||
* Tooltip for a row's sharer, naming the owner too when someone else re-shared it.
|
||||
* Tooltip for a row's sharers, naming the owner too when none of them owns it.
|
||||
*
|
||||
* @param {SharedRow} row
|
||||
* @returns {string} plain text
|
||||
*/
|
||||
export const sharedByTitle = (row) => {
|
||||
const username = sharerOf(row);
|
||||
if ( ! username ) return '';
|
||||
return row.owner && row.owner !== username
|
||||
const sharers = sharersOf(row);
|
||||
if ( sharers.length === 0 ) return '';
|
||||
const username = listNames(sharers);
|
||||
return row.owner && ! sharers.includes(row.owner)
|
||||
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
|
||||
: i18n('shared_by_user', { username }, false);
|
||||
};
|
||||
@@ -137,11 +169,11 @@ export const sharedByCellHtml = (row) => {
|
||||
*
|
||||
* @param {Sharer[]} sharers - From summarizeSharers
|
||||
* @param {string | null} active - The sharer filtered to, or null for everyone
|
||||
* @param {number} total - Items anyone shared; an item counts once however many shared it
|
||||
* @returns {string} markup
|
||||
*/
|
||||
export const sharerFilterHtml = (sharers, active) => {
|
||||
export const sharerFilterHtml = (sharers, active, total) => {
|
||||
if ( sharers.length < 2 ) return '';
|
||||
const total = sharers.reduce((sum, sharer) => sum + sharer.count, 0);
|
||||
const chip = (value, inner, count, title) => {
|
||||
const pressed = (active ?? '') === value;
|
||||
return `<button type="button" class="sharer-chip${pressed ? ' active' : ''}" data-sharer="${window.html_encode(value)}" aria-pressed="${pressed}"${title ? ` title="${window.html_encode(title)}"` : ''}>`
|
||||
|
||||
@@ -40,6 +40,13 @@ const {
|
||||
} = await import('./sharedBy.js');
|
||||
|
||||
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
|
||||
// An item granted by more than one person: `shared_by` holds the strongest grant's.
|
||||
const multiRow = (shared_by_all, owner = shared_by_all[0], name = 'f') => ({
|
||||
shared_by: shared_by_all[0],
|
||||
shared_by_all,
|
||||
owner,
|
||||
name,
|
||||
});
|
||||
|
||||
describe('sharerOf', () => {
|
||||
it('names whoever granted the share', () => {
|
||||
@@ -74,6 +81,13 @@ describe('summarizeSharers', () => {
|
||||
it('skips rows that name nobody', () => {
|
||||
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
|
||||
});
|
||||
|
||||
it('counts an item for everyone who shared it', () => {
|
||||
expect(summarizeSharers([multiRow(['bob', 'ann']), row('ann')])).toEqual([
|
||||
{ name: 'ann', count: 2 },
|
||||
{ name: 'bob', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterBySharer', () => {
|
||||
@@ -83,6 +97,20 @@ describe('filterBySharer', () => {
|
||||
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('keeps an item for each person who shared it', () => {
|
||||
const shared = [multiRow(['bob', 'ann'], 'ann', 'both'), row('cy', 'cy', 'c')];
|
||||
expect(filterBySharer(shared, 'ann').map((r) => r.name)).toEqual(['both']);
|
||||
expect(filterBySharer(shared, 'bob').map((r) => r.name)).toEqual(['both']);
|
||||
});
|
||||
|
||||
it('shows a kept item as shared by the person filtered to', () => {
|
||||
const [both, own] = filterBySharer([multiRow(['bob', 'ann'], 'ann', 'both'), row('ann', 'ann', 'own')], 'ann');
|
||||
expect(sharerOf(both)).toBe('ann');
|
||||
expect(sharedByCellHtml(both)).toContain('<span class="item-shared-by-name">ann</span>');
|
||||
expect(sharedByTitle(both)).toBe('shared_by_user:ann and bob');
|
||||
expect(sharerOf(own)).toBe('ann');
|
||||
});
|
||||
|
||||
it('keeps everything without a filter', () => {
|
||||
expect(filterBySharer(rows, null)).toBe(rows);
|
||||
expect(filterBySharer(rows, '')).toBe(rows);
|
||||
@@ -123,6 +151,11 @@ describe('sharedByTitle', () => {
|
||||
it('names the owner too when someone else re-shared it', () => {
|
||||
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
|
||||
});
|
||||
|
||||
it('names everyone who shared the item', () => {
|
||||
expect(sharedByTitle(multiRow(['bob', 'ann'], 'ann'))).toBe('shared_by_user:bob and ann');
|
||||
expect(sharedByTitle(multiRow(['bob', 'cy'], 'ann'))).toBe('shared_by_user_owned_by:bob and cy,ann');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedByCellHtml', () => {
|
||||
@@ -155,7 +188,7 @@ describe('sharerFilterHtml', () => {
|
||||
});
|
||||
|
||||
it('leads with Everyone, counting every item', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null, 3);
|
||||
const chips = h.match(/<button[^>]*>/g);
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(chips[0]).toContain('data-sharer=""');
|
||||
@@ -165,6 +198,11 @@ describe('sharerFilterHtml', () => {
|
||||
expect(h).toContain('data-avatar-user="bob"');
|
||||
});
|
||||
|
||||
it('counts an item shared by two people once under Everyone', () => {
|
||||
const h = sharerFilterHtml([{ name: 'ann', count: 2 }, { name: 'bob', count: 1 }], null, 2);
|
||||
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">2</span>');
|
||||
});
|
||||
|
||||
it('presses the active sharer only', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], 'ann');
|
||||
const pressed = h.match(/<button[^>]*aria-pressed="true"[^>]*>/g);
|
||||
|
||||
+45
-48
@@ -34,6 +34,7 @@ import UIWindowProgress from './UIWindowProgress.js';
|
||||
import UITaskbar from './UITaskbar.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { atPath, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
|
||||
import changeLanguage from '../i18n/i18nChangeLanguage.js';
|
||||
import UIWindowTaskManager from './UIWindowTaskManager.js';
|
||||
import truncate_filename from '../helpers/truncateFilename.js';
|
||||
@@ -232,7 +233,12 @@ async function UIDesktop (options) {
|
||||
// empty trash windows if needed
|
||||
if ( msg.is_empty )
|
||||
{
|
||||
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.item-container').empty();
|
||||
// only the rows: the container also holds its empty, error and loading states
|
||||
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).each(function () {
|
||||
$(this).find('.item-container .item').removeItems();
|
||||
window.update_explorer_footer_item_count(this);
|
||||
window.update_explorer_footer_selected_items_count(this);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -415,14 +421,17 @@ async function UIDesktop (options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hide all UIItems with matching uids
|
||||
$(`.item[data-path='${item.path}']`).fadeOut(150, function () {
|
||||
// close all windows with matching uids
|
||||
// $('.window-' + item.uid).close();
|
||||
// close all windows that belong to a descendant of this item
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${item.path}/"]`).close();
|
||||
// remove all UIItems at the removed path
|
||||
atPath('.item', item.path).fadeOut(150, function () {
|
||||
const $parent_window = $(this).closest('.window');
|
||||
$(this).removeItems();
|
||||
window.update_explorer_footer_item_count($parent_window);
|
||||
window.update_explorer_footer_selected_items_count($parent_window);
|
||||
});
|
||||
// close the windows showing the removed folder or anything inside it,
|
||||
// whether or not its own row is displayed anywhere
|
||||
// todo this has to be case-insensitive
|
||||
atPath('.window[data-app="explorer"]', item.path).add(underPath('.window', item.path)).close();
|
||||
});
|
||||
|
||||
window.socket.on('item.updated', async (item) => {
|
||||
@@ -454,25 +463,20 @@ async function UIDesktop (options) {
|
||||
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
|
||||
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
|
||||
|
||||
// An overwrite arrives as an update, with a new size and date
|
||||
if ( item.size !== undefined && item.modified !== undefined ) {
|
||||
const $updated_items = $(`.item[data-uid='${item.uid}']`);
|
||||
$updated_items.attr({ 'data-size': item.size ?? '', 'data-modified': item.modified ?? '' });
|
||||
$updated_items.find('.item-attr--size > span').text(item.size ? window.byte_format(item.size) : '-');
|
||||
$updated_items.find('.item-attr--modified > span').text(item.modified === 0 ? '-' : timeago.format(item.modified * 1000));
|
||||
}
|
||||
|
||||
// Set new data-path
|
||||
const new_path = item.path;
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
|
||||
$(`.window-${item.uid}`).attr('data-path', new_path);
|
||||
|
||||
// Update all elements that have matching paths
|
||||
$(`[data-path="${html_encode(item.old_path)}" i]`).each(function () {
|
||||
$(this).attr('data-path', new_path);
|
||||
if ( $(this).hasClass('window-navbar-path-dirname') )
|
||||
{
|
||||
$(this).text(item.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Update all elements whose paths start with old_path
|
||||
$(`[data-path^="${`${html_encode(item.old_path) }/`}"]`).each(function () {
|
||||
const new_el_path = $(this).attr('data-path').replace(`${item.old_path }/`, `${new_path }/`);
|
||||
$(this).attr('data-path', new_el_path);
|
||||
});
|
||||
retargetPaths(item.old_path, new_path, item.name);
|
||||
|
||||
// Update all exact-matching windows
|
||||
$(`.window-${item.uid}`).each(function () {
|
||||
@@ -507,7 +511,7 @@ async function UIDesktop (options) {
|
||||
const metadata = parse_item_metadata(fsentry.metadata);
|
||||
|
||||
// update all shortcut_to_path
|
||||
$(`.item[data-shortcut_to_path="${html_encode(resp.old_path)}" i]`).attr('data-shortcut_to_path', html_encode(fsentry.path));
|
||||
atPath('.item', resp.old_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
|
||||
|
||||
// remove all items with matching uids
|
||||
$(`.item[data-uid='${fsentry.uid}']`).fadeOut(150, function () {
|
||||
@@ -524,17 +528,18 @@ async function UIDesktop (options) {
|
||||
|
||||
// if trashing, close windows of trashed items and its descendants
|
||||
if ( dest_path === window.trash_path ) {
|
||||
$(`.window[data-path="${html_encode(resp.old_path)}" i]`).close();
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${html_encode(resp.old_path)}/"]`).close();
|
||||
atPath('.window', resp.old_path).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', resp.old_path).close();
|
||||
}
|
||||
|
||||
// update all paths of its and its descendants' open windows
|
||||
else {
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${html_encode(resp.old_path)}/"], .window[data-path="${html_encode(resp.old_path)}" i]`).each(function () {
|
||||
window.update_window_path(this, $(this).attr('data-path').replace(resp.old_path, fsentry.path));
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
|
||||
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
|
||||
});
|
||||
retargetNavHistory(resp.old_path, fsentry.path);
|
||||
}
|
||||
|
||||
if ( dest_path === window.trash_path ) {
|
||||
@@ -558,13 +563,18 @@ async function UIDesktop (options) {
|
||||
// Trashing renames the entry to its uid; the real name is in metadata.
|
||||
fsentry.name = metadata.original_name ?? fsentry.name;
|
||||
|
||||
const moved_item_icon = await item_icon(fsentry);
|
||||
const $dest_containers = atPath('.item-container', dest_path);
|
||||
// the tab that made this move renders the row itself, in either order
|
||||
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
|
||||
|
||||
// create new item on matching containers
|
||||
UIItem({
|
||||
appendTo: $(`.item-container[data-path='${html_encode(dest_path)}' i]`),
|
||||
appendTo: $dest_containers,
|
||||
immutable: fsentry.immutable || fsentry.writable === false,
|
||||
uid: fsentry.uid,
|
||||
path: fsentry.path,
|
||||
icon: await item_icon(fsentry),
|
||||
icon: moved_item_icon,
|
||||
name: fsentry.name,
|
||||
is_dir: fsentry.is_dir,
|
||||
size: fsentry.size,
|
||||
@@ -585,8 +595,8 @@ async function UIDesktop (options) {
|
||||
// if these new path have any open parents that need to be updated
|
||||
|
||||
fsentry.parent_dirs_created.forEach(async dir => {
|
||||
let item_container = $(`.item-container[data-path='${html_encode(path.dirname(dir.path))}' i]`);
|
||||
if ( item_container.length > 0 && $(`.item[data-path="${html_encode(dir.path)}" i]`).length === 0 ) {
|
||||
let item_container = atPath('.item-container', path.dirname(dir.path));
|
||||
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
|
||||
UIItem({
|
||||
appendTo: item_container,
|
||||
immutable: false,
|
||||
@@ -606,7 +616,7 @@ async function UIDesktop (options) {
|
||||
});
|
||||
}
|
||||
//sort each container
|
||||
$(`.item-container[data-path='${html_encode(dest_path)}' i]`).each(function () {
|
||||
atPath('.item-container', dest_path).each(function () {
|
||||
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
|
||||
});
|
||||
});
|
||||
@@ -693,20 +703,7 @@ async function UIDesktop (options) {
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
|
||||
$(`.window-${item.uid}`).attr('data-path', new_path);
|
||||
|
||||
// Update all elements that have matching paths
|
||||
$(`[data-path="${html_encode(item.old_path)}" i]`).each(function () {
|
||||
$(this).attr('data-path', new_path);
|
||||
if ( $(this).hasClass('window-navbar-path-dirname') )
|
||||
{
|
||||
$(this).text(item.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Update all elements whose paths start with old_path
|
||||
$(`[data-path^="${`${html_encode(item.old_path) }/`}"]`).each(function () {
|
||||
const new_el_path = $(this).attr('data-path').replace(`${item.old_path }/`, `${new_path }/`);
|
||||
$(this).attr('data-path', new_el_path);
|
||||
});
|
||||
retargetPaths(item.old_path, new_path, item.name);
|
||||
|
||||
// Update all exact-matching windows
|
||||
$(`.window-${item.uid}`).each(function () {
|
||||
|
||||
+48
-17
@@ -35,6 +35,7 @@ import { is_owned_by_me } from '../helpers/pathOwner.js';
|
||||
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
|
||||
import { is_default_app } from '../helpers/defaultApp.js';
|
||||
import openWithAI from '../helpers/openWithAI.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
|
||||
async function UIItem (options) {
|
||||
const matching_appendto_count = $(options.appendTo).length;
|
||||
@@ -64,6 +65,7 @@ async function UIItem (options) {
|
||||
options.share_mode = options.share_mode ?? '';
|
||||
options.shared_by = options.shared_by ?? '';
|
||||
options.is_team_share = options.is_team_share ?? false;
|
||||
options.is_also_team_share = options.is_also_team_share ?? false;
|
||||
options.owner = options.owner ?? '';
|
||||
options.metadata = options.metadata ?? '';
|
||||
options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false;
|
||||
@@ -102,6 +104,7 @@ async function UIItem (options) {
|
||||
data-share_mode="${html_encode(options.share_mode)}"
|
||||
data-shared_by="${html_encode(options.shared_by)}"
|
||||
data-is_team_share="${options.is_team_share ? 1 : 0}"
|
||||
data-is_also_team_share="${options.is_also_team_share ? 1 : 0}"
|
||||
data-owner="${html_encode(options.owner)}"
|
||||
data-has_website="${show_website_badge ? 1 : 0 }"
|
||||
data-website_url = "${website_url ? html_encode(website_url) : ''}"
|
||||
@@ -393,7 +396,7 @@ async function UIItem (options) {
|
||||
// do not highlight if the target is the same as the item being moved
|
||||
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') &&
|
||||
// do not highlight if item is being moved to where it already is
|
||||
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') ) {
|
||||
path.dirname($(el_item).attr('data-path')) !== $(window.mouseover_item_container).attr('data-path') ) {
|
||||
|
||||
// highlight item container
|
||||
$(window.mouseover_item_container).addClass('item-container-active');
|
||||
@@ -413,7 +416,8 @@ async function UIItem (options) {
|
||||
},
|
||||
stop: function (event, ui) {
|
||||
// Allow rearranging only if item is on desktop, not trash container, auto arrange is disabled and item is not dropped into another item
|
||||
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path &&
|
||||
// the desktop itself, not a window showing the Desktop folder
|
||||
if ( $(el_item).closest('.item-container').hasClass('desktop') &&
|
||||
!window.is_auto_arrange_enabled && $(el_item).attr('data-path') !== window.trash_path && !ui.helper.data('dropped') &&
|
||||
// Item must be dropped on the Desktop and not on the taskbar
|
||||
window.mouseover_window === undefined && ui.position.top <= window.desktop_height - window.taskbar_height - 15 ) {
|
||||
@@ -431,7 +435,9 @@ async function UIItem (options) {
|
||||
// re-enable all droppable UIItems that are not a dir
|
||||
$('.item[data-is_dir=\'0\']:not(.item-selected)').droppable('enable');
|
||||
// remove active item-container border highlights
|
||||
$('.item-container').removeClass('item-container-active');
|
||||
$('.item-container').removeClass('item-container-active item-container-transparent-border');
|
||||
// a drop that bailed out early never re-enables the containers an item's hover disabled
|
||||
$('.item-container.ui-droppable').droppable('enable');
|
||||
// reset longer hover timeout and last window dragged over
|
||||
clearTimeout(longer_hover_timeout);
|
||||
last_window_dragged_over = null;
|
||||
@@ -545,11 +551,14 @@ async function UIItem (options) {
|
||||
}
|
||||
// Otherwise, move items
|
||||
else if ( options.is_dir ) {
|
||||
// a selected folder can be the drop target of its own selection; it stays where it is
|
||||
const items_to_drop = items_to_move.filter(item => $(item).attr('data-uid') !== $(el_item).attr('data-uid'));
|
||||
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
|
||||
delete window.desktop_item_positions[$(el_item).attr('data-uid')];
|
||||
// the items leaving the desktop lose their saved positions, not the folder they land in
|
||||
items_to_drop.forEach(item => delete window.desktop_item_positions[$(item).attr('data-uid')]);
|
||||
window.save_desktop_item_positions();
|
||||
}
|
||||
window.move_items(items_to_move, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
|
||||
window.move_items(items_to_drop, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,8 +569,9 @@ async function UIItem (options) {
|
||||
},
|
||||
over: function (event, ui) {
|
||||
// Check hovering over an item that is VISIBILE
|
||||
// A desktop item has no window, so it only counts while the pointer is over the desktop itself
|
||||
const $event_parent_win = $(event.target).closest('.window');
|
||||
if ( $event_parent_win.length > 0 && $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
|
||||
if ( $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -719,7 +729,7 @@ async function UIItem (options) {
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', function (e) {
|
||||
$(document).on(`click.item-${item_id}`, function (e) {
|
||||
if ( !$(e.target).hasClass('item') && !$(e.target).hasClass('item-name') && !$(e.target).hasClass('item-icon') ) {
|
||||
skip_a_rename_click = true;
|
||||
}
|
||||
@@ -728,6 +738,10 @@ async function UIItem (options) {
|
||||
skip_a_rename_click = true;
|
||||
}
|
||||
});
|
||||
// a document handler outlives the row unless it is released with it
|
||||
$(el_item).on('remove', function () {
|
||||
$(document).off(`click.item-${item_id}`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Rename
|
||||
@@ -735,6 +749,7 @@ async function UIItem (options) {
|
||||
function rename () {
|
||||
if ( rename_cancelled ) {
|
||||
rename_cancelled = false;
|
||||
$(el_item_name_editor).removeClass('item-name-editor-active');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -754,10 +769,12 @@ async function UIItem (options) {
|
||||
UIAlert('The name ".." is not allowed, because it is a reserved name. Please choose another name.');
|
||||
}
|
||||
|
||||
$(el_item_name).html(html_encode(truncate_filename(options.name)));
|
||||
$(el_item_name).html(html_encode(truncate_filename($(el_item).attr('data-name'))));
|
||||
$(el_item_name).show();
|
||||
$(el_item_name_editor).val($(el_item).attr('data-name'));
|
||||
$(el_item_name_editor).hide();
|
||||
// the Delete shortcut skips an item whose editor is marked active
|
||||
$(el_item_name_editor).removeClass('item-name-editor-active');
|
||||
return;
|
||||
}
|
||||
// deactivate item name editable
|
||||
@@ -803,8 +820,9 @@ async function UIItem (options) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
rename_cancelled = true;
|
||||
$(el_item_name_editor).removeClass('item-name-editor-active');
|
||||
$(el_item_name_editor).hide();
|
||||
$(el_item_name_editor).val(options.name);
|
||||
$(el_item_name_editor).val($(el_item).attr('data-name'));
|
||||
$(el_item_name).show();
|
||||
}
|
||||
});
|
||||
@@ -863,6 +881,11 @@ async function UIItem (options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The menu is built after lookups that take time, so it opens where the pointer was when it was asked for.
|
||||
const menu_position = (isMobile.phone || isMobile.tablet)
|
||||
? { left: window.last_touch_x, top: window.last_touch_y }
|
||||
: { left: window.mouseX, top: window.mouseY };
|
||||
|
||||
// if editing item name, preserve native context menu
|
||||
if ( event.target === el_item_name_editor )
|
||||
{
|
||||
@@ -1134,7 +1157,7 @@ async function UIItem (options) {
|
||||
// try to find suitable apps
|
||||
const suitable_apps = await window.suggest_apps_for_fsentry({
|
||||
uid: options.uid,
|
||||
path: options.path,
|
||||
path: $(el_item).attr('data-path'),
|
||||
});
|
||||
if ( suitable_apps && suitable_apps.length > 0 ) {
|
||||
options.suggested_apps = suitable_apps;
|
||||
@@ -1272,7 +1295,7 @@ async function UIItem (options) {
|
||||
file_path: $(el_item).attr('data-path'),
|
||||
file_uid: $(el_item).attr('data-uid'),
|
||||
params: {
|
||||
source_path: options.path,
|
||||
source_path: $(el_item).attr('data-path'),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -1319,7 +1342,7 @@ async function UIItem (options) {
|
||||
}
|
||||
else
|
||||
{
|
||||
window.trigger_download([options.path]);
|
||||
window.trigger_download([$(el_item).attr('data-path')]);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1434,12 +1457,13 @@ async function UIItem (options) {
|
||||
// -------------------------------------------
|
||||
// Cut
|
||||
// -------------------------------------------
|
||||
if ( $(el_item).attr('data-immutable') === '0' ) {
|
||||
// Trashed items leave the Trash through Restore; moved any other way, they'd keep their uid as their name.
|
||||
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
|
||||
menu_items.push({
|
||||
html: i18n('cut'),
|
||||
onClick: function () {
|
||||
window.clipboard_op = 'move';
|
||||
window.clipboard = [options.path];
|
||||
window.clipboard = [$(el_item).attr('data-path')];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1451,7 +1475,7 @@ async function UIItem (options) {
|
||||
html: i18n('copy'),
|
||||
onClick: function () {
|
||||
window.clipboard_op = 'copy';
|
||||
window.clipboard = [{ path: options.path }];
|
||||
window.clipboard = [{ path: $(el_item).attr('data-path') }];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1499,7 +1523,7 @@ async function UIItem (options) {
|
||||
base_dir,
|
||||
options.appendTo,
|
||||
options.shortcut_to === '' ? options.uid : options.shortcut_to,
|
||||
options.shortcut_to_path === '' ? options.path : options.shortcut_to_path,
|
||||
options.shortcut_to_path === '' ? $(el_item).attr('data-path') : options.shortcut_to_path,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1529,7 +1553,13 @@ async function UIItem (options) {
|
||||
// Or mode lookups keep answering for a share we
|
||||
// just walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).removeItems();
|
||||
// A team that still shares it keeps it listed,
|
||||
// as the team's share.
|
||||
if ( $(el_item).attr('data-is_also_team_share') === '1' ) {
|
||||
refresh_item_container($(el_item).closest('.item-container'));
|
||||
} else {
|
||||
$(el_item).removeItems();
|
||||
}
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
}
|
||||
@@ -1622,6 +1652,7 @@ async function UIItem (options) {
|
||||
// Create ContextMenu
|
||||
UIContextMenu({
|
||||
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
|
||||
position: menu_position,
|
||||
items: menu_items,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/refreshItemContainer.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
@@ -67,6 +68,8 @@ Object.assign(window, {
|
||||
|
||||
const { default: UIItem } = await import('./UIItem.js');
|
||||
const { default: UIContextMenu } = await import('./UIContextMenu.js');
|
||||
const { default: refresh_item_container } =
|
||||
await import('../helpers/refreshItemContainer.js');
|
||||
|
||||
const menuLabels = async (options) => {
|
||||
document.body.innerHTML =
|
||||
@@ -90,14 +93,25 @@ const menuLabels = async (options) => {
|
||||
return UIContextMenu.mock.calls[0][0].items.map((item) => item?.html);
|
||||
};
|
||||
|
||||
const removeFromShared = async (options) => {
|
||||
await menuLabels(options);
|
||||
await UIContextMenu.mock.calls[0][0].items
|
||||
.find((item) => item?.html === 'share_remove_from_shared')
|
||||
.onClick();
|
||||
};
|
||||
|
||||
describe('UIItem Remove from Shared', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice' };
|
||||
window.trash_path = '/alice/Trash';
|
||||
window.puter = {
|
||||
fs: { listShared: vi.fn(async () => ({ items: [] })) },
|
||||
fs: {
|
||||
listShared: vi.fn(async () => ({ items: [] })),
|
||||
unshare: vi.fn(async () => ({ revoked: 1 })),
|
||||
},
|
||||
};
|
||||
globalThis.puter = window.puter;
|
||||
refresh_item_container.mockClear();
|
||||
});
|
||||
|
||||
it('is offered on an item shared with the user directly', async () => {
|
||||
@@ -109,4 +123,19 @@ describe('UIItem Remove from Shared', () => {
|
||||
expect($('.item').attr('data-is_team_share')).toBe('1');
|
||||
expect(labels).not.toContain('share_remove_from_shared');
|
||||
});
|
||||
|
||||
it('takes the item away once the user has left it', async () => {
|
||||
await removeFromShared({});
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
|
||||
expect($('.item')).toHaveLength(0);
|
||||
expect(refresh_item_container).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists the item again when a team still shares it', async () => {
|
||||
await removeFromShared({ is_also_team_share: true });
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
|
||||
expect($('.item')).toHaveLength(1);
|
||||
expect(refresh_item_container).toHaveBeenCalledTimes(1);
|
||||
expect(refresh_item_container.mock.calls[0][0][0]).toBe($('.item-container')[0]);
|
||||
});
|
||||
});
|
||||
+143
-125
@@ -25,6 +25,8 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { atPath } from '../helpers/pathElements.js';
|
||||
import { resolve_typed_path } from '../helpers/expandHomePath.js';
|
||||
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
|
||||
import launch_app from '../helpers/launchApp.js';
|
||||
import publish_as_website from '../helpers/publishAsWebsite.js';
|
||||
@@ -346,7 +348,18 @@ async function UIWindow (options) {
|
||||
if ( ! items.some(item => item.path === window.home_path) ) {
|
||||
items.unshift({ path: window.home_path, name: i18n('home') });
|
||||
}
|
||||
// A saved order stores the label it was rendered with; system folders follow the current language instead.
|
||||
const system_folder_labels = {
|
||||
[window.home_path]: i18n('home'),
|
||||
[window.docs_path]: i18n('documents'),
|
||||
[window.public_path]: i18n('public'),
|
||||
[window.pictures_path]: i18n('pictures'),
|
||||
[window.desktop_path]: i18n('desktop'),
|
||||
[window.videos_path]: i18n('videos'),
|
||||
[window.shared_path]: i18n('shared'),
|
||||
};
|
||||
for ( let item of items ) {
|
||||
const label = system_folder_labels[item.path] ?? item.name;
|
||||
let icon;
|
||||
if ( item.path === window.home_path )
|
||||
{
|
||||
@@ -380,7 +393,7 @@ async function UIWindow (options) {
|
||||
{
|
||||
icon = window.icons['sidebar-folder.svg'];
|
||||
}
|
||||
h += `<div title="${html_encode(item.label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(item.name)}</div>`;
|
||||
h += `<div title="${html_encode(label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(label)}</div>`;
|
||||
}
|
||||
}
|
||||
h += '</div>';
|
||||
@@ -405,7 +418,7 @@ async function UIWindow (options) {
|
||||
// Path editor
|
||||
h += `<input class="window-navbar-path-input" data-path="${html_encode(options.path)}" value="${html_encode(options.path)}" spellcheck="false"/>`;
|
||||
// Layout settings
|
||||
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false">`;
|
||||
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false" title="${i18n('window_click_to_change_layout')}">`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
@@ -465,7 +478,7 @@ async function UIWindow (options) {
|
||||
|
||||
// Loading spinner
|
||||
h += '<div class="explorer-loading-spinner">';
|
||||
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
|
||||
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
|
||||
h += `<p class="explorer-loading-spinner-msg">${i18n('loading')}...</p>`;
|
||||
h += '</div>';
|
||||
}
|
||||
@@ -1113,7 +1126,7 @@ async function UIWindow (options) {
|
||||
|
||||
// build item for context menu
|
||||
items.push({
|
||||
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
|
||||
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
|
||||
val: index,
|
||||
onClick: async function (e) {
|
||||
let history_index = e.value;
|
||||
@@ -1186,7 +1199,7 @@ async function UIWindow (options) {
|
||||
|
||||
// build item for context menu
|
||||
items.push({
|
||||
html: `<span>${history_item === window.home_path ? 'Home' : html_encode(path.basename(history_item))}</span>`,
|
||||
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
|
||||
val: index,
|
||||
onClick: async function (e) {
|
||||
let history_index = e.value;
|
||||
@@ -1332,6 +1345,8 @@ async function UIWindow (options) {
|
||||
$(el_window).on('remove', function (e) {
|
||||
// if on_close callback is set, call it
|
||||
options.on_close?.();
|
||||
delete window.window_nav_history[win_id];
|
||||
delete window.window_nav_history_current_position[win_id];
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -1425,18 +1440,18 @@ async function UIWindow (options) {
|
||||
.on('beforedrag', evt => {
|
||||
})
|
||||
.on('start', ({ store, event }) => {
|
||||
if ( !event.ctrlKey && !event.metaKey ) {
|
||||
// Get the scroll position of the window body
|
||||
const scrollLeft = $(el_window_body).scrollLeft();
|
||||
const scrollTop = $(el_window_body).scrollTop();
|
||||
const scrollLeft = $(el_window_body).scrollLeft();
|
||||
const scrollTop = $(el_window_body).scrollTop();
|
||||
|
||||
// Get the window body's bounding rect relative to the viewport
|
||||
const windowBodyRect = el_window_body.getBoundingClientRect();
|
||||
// Get the window body's bounding rect relative to the viewport
|
||||
const windowBodyRect = el_window_body.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to the window body (accounting for scroll)
|
||||
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
|
||||
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
|
||||
// Calculate position relative to the window body (accounting for scroll)
|
||||
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
|
||||
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
|
||||
|
||||
if ( !event.ctrlKey && !event.metaKey ) {
|
||||
for ( const el of store.stored ) {
|
||||
el.classList.remove('item-selected');
|
||||
}
|
||||
@@ -1511,7 +1526,7 @@ async function UIWindow (options) {
|
||||
window.update_explorer_footer_selected_items_count(el_window);
|
||||
|
||||
// If this is openFileDialog, enable/disable the Open button accordingly
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
|
||||
{
|
||||
$(el_openfiledialog_open_btn).removeClass('disabled');
|
||||
}
|
||||
@@ -1522,7 +1537,7 @@ async function UIWindow (options) {
|
||||
})
|
||||
.on('stop', ({ store, event }) => {
|
||||
// If this is openFileDialog, enable/disable the Open button accordingly
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
|
||||
{
|
||||
$(el_openfiledialog_open_btn).removeClass('disabled');
|
||||
}
|
||||
@@ -1531,6 +1546,20 @@ async function UIWindow (options) {
|
||||
$(el_openfiledialog_open_btn).addClass('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on(`mouseup.window-selection-${win_id}`, function () {
|
||||
if ( selection_area ) {
|
||||
$(selection_area).hide();
|
||||
$(selection_area).remove();
|
||||
selection_area = null;
|
||||
}
|
||||
});
|
||||
|
||||
// viselect and the handler above listen on document, so they outlive the window unless released
|
||||
$(el_window).on('remove', function () {
|
||||
$(document).off(`mouseup.window-selection-${win_id}`);
|
||||
selection.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -2678,37 +2707,8 @@ async function UIWindow (options) {
|
||||
{
|
||||
html: i18n('empty_trash'),
|
||||
disabled: false,
|
||||
onClick: async function () {
|
||||
// TODO: Merge this with window.empty_trash()
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('empty_trash_confirmation'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('yes'),
|
||||
value: 'yes',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
label: i18n('no'),
|
||||
value: 'no',
|
||||
},
|
||||
],
|
||||
});
|
||||
if ( alert_resp === 'no' )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.item[data-path^="${html_encode(window.trash_path)}/"]`).each(function () {
|
||||
window.delete_item(this);
|
||||
});
|
||||
// update other clients
|
||||
if ( window.socket ) {
|
||||
window.socket.emit('trash.is_empty', { is_empty: true });
|
||||
}
|
||||
// use the 'empty trash' icon
|
||||
window.update_trash_icons(true);
|
||||
onClick: function () {
|
||||
window.empty_trash();
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2844,14 +2844,6 @@ async function UIWindow (options) {
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('mouseup', function (e) {
|
||||
if ( selection_area ) {
|
||||
$(selection_area).hide();
|
||||
$(selection_area).remove();
|
||||
selection_area = null;
|
||||
}
|
||||
});
|
||||
|
||||
//set styles
|
||||
$(el_window_body).css(options.body_css);
|
||||
|
||||
@@ -3071,7 +3063,21 @@ $(document).on('blur', '.window-navbar-path-input', function (e) {
|
||||
|
||||
$(document).on('keyup', '.window-navbar-path-input', function (e) {
|
||||
if ( e.key === 'Enter' || e.keyCode === 13 ) {
|
||||
window.update_window_path($(e.target).closest('.window'), $(e.target).val());
|
||||
const $el_window = $(e.target).closest('.window');
|
||||
const win_id = $el_window.attr('data-id');
|
||||
const current_path = $el_window.attr('data-path');
|
||||
// Other code matches containers by exact path, so navigate to the canonical form.
|
||||
const target_path = resolve_typed_path($(e.target).val(), {
|
||||
current_path,
|
||||
home_path: window.home_path,
|
||||
shared_path: window.shared_path,
|
||||
});
|
||||
if ( target_path !== current_path && window.window_nav_history[win_id] ) {
|
||||
window.window_nav_history[win_id] = window.window_nav_history[win_id].slice(0, window.window_nav_history_current_position[win_id] + 1);
|
||||
window.window_nav_history[win_id].push(target_path);
|
||||
window.window_nav_history_current_position[win_id] = window.window_nav_history[win_id].length - 1;
|
||||
}
|
||||
window.update_window_path($el_window, target_path);
|
||||
$(e.target).hide();
|
||||
$(e.target).siblings('.window-navbar-path').show().select();
|
||||
}
|
||||
@@ -3335,6 +3341,14 @@ window.navbar_path = (abs_path) => {
|
||||
return str;
|
||||
};
|
||||
|
||||
// The root and the Shared view have no basename to show in the Back/Forward menus.
|
||||
const nav_history_label = (history_path) => {
|
||||
if ( history_path === window.home_path ) return i18n('home');
|
||||
if ( history_path === '/' ) return window.root_dirname;
|
||||
if ( history_path === window.shared_path ) return i18n('shared');
|
||||
return path.basename(history_path);
|
||||
};
|
||||
|
||||
window.update_window_path = async function (el_window, target_path) {
|
||||
const win_id = $(el_window).attr('data-id');
|
||||
const el_window_navbar_forward_btn = $(el_window).find('.window-navbar-btn-forward');
|
||||
@@ -3348,7 +3362,7 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
|
||||
// update sidebar items' active status
|
||||
$(el_window).find('.window-sidebar-item').removeClass('window-sidebar-item-active');
|
||||
$(el_window).find(`.window-sidebar-item[data-path="${html_encode(target_path)}"]`).addClass('window-sidebar-item-active');
|
||||
atPath($(el_window).find('.window-sidebar-item'), target_path).addClass('window-sidebar-item-active');
|
||||
|
||||
// clean
|
||||
$(el_window).find('.explore-table-headers-th > .header-sort-icon').html('');
|
||||
@@ -3392,6 +3406,8 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
|
||||
// empty body to be filled with the results of /readdir
|
||||
$(el_window_body).find('.item').removeItems();
|
||||
// nothing is selected in the folder being opened
|
||||
$(el_window).find('.openfiledialog-open-btn').addClass('disabled');
|
||||
|
||||
// add the 'Detail View' table header
|
||||
if ( $(el_window).find('.explore-table-headers').length === 0 )
|
||||
@@ -3432,8 +3448,10 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
}
|
||||
}
|
||||
|
||||
$(el_window).attr('data-path', html_encode(target_path));
|
||||
$(el_window).attr('data-name', html_encode(path.basename(target_path)));
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window).attr('data-name', path.basename(target_path));
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
|
||||
// /stat
|
||||
if ( target_path === window.shared_path ) {
|
||||
@@ -3444,72 +3462,72 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).find('.window-head-icon').attr('src', window.icons['shared.svg']);
|
||||
}
|
||||
else if ( target_path !== '/' ) {
|
||||
try {
|
||||
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
|
||||
// the window may have moved on while this stat was in flight
|
||||
if ( $(el_window).attr('data-path') !== html_encode(target_path) ) {
|
||||
return;
|
||||
}
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('home'));
|
||||
}
|
||||
else if ( target_path === window.desktop_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('desktop'));
|
||||
}
|
||||
else if ( target_path === window.docs_path || target_path === window.documents_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('documents'));
|
||||
}
|
||||
else if ( target_path === window.pictures_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('pictures'));
|
||||
}
|
||||
else if ( target_path === window.videos_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('videos'));
|
||||
}
|
||||
else if ( target_path === window.public_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('public'));
|
||||
}
|
||||
else if ( target_path === window.trash_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('trash'));
|
||||
}
|
||||
else
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(fsentry.name);
|
||||
}
|
||||
// data-name
|
||||
$(el_window).attr('data-name', html_encode(fsentry.name));
|
||||
// data-path
|
||||
$(el_window).attr('data-path', html_encode(target_path));
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
});
|
||||
} catch ( err ) {
|
||||
UIAlert(err.responseText);
|
||||
|
||||
// todo optim: this is dumb because updating the window should only happen if this /readdir request is successful,
|
||||
// in that case there is no need for using update_window_path on error!!
|
||||
window.update_window_path(el_window, old_path);
|
||||
}
|
||||
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
|
||||
// the window may have moved on while this stat was in flight
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
}
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('home'));
|
||||
}
|
||||
else if ( target_path === window.desktop_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('desktop'));
|
||||
}
|
||||
else if ( target_path === window.docs_path || target_path === window.documents_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('documents'));
|
||||
}
|
||||
else if ( target_path === window.pictures_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('pictures'));
|
||||
}
|
||||
else if ( target_path === window.videos_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('videos'));
|
||||
}
|
||||
else if ( target_path === window.public_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('public'));
|
||||
}
|
||||
else if ( target_path === window.trash_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('trash'));
|
||||
}
|
||||
else
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(fsentry.name);
|
||||
}
|
||||
// data-name
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
}
|
||||
// The folder couldn't be read, so the window must stop identifying as the previous one.
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).attr('data-uid', 'null');
|
||||
$(el_window).find('.window-head-title').text(path.basename(target_path));
|
||||
});
|
||||
}
|
||||
// path is '/' (global root)
|
||||
else {
|
||||
@@ -3865,7 +3883,7 @@ window.scale_window = (el_window) => {
|
||||
|
||||
window.update_explorer_footer_item_count = function (el_window) {
|
||||
//update dir count in explorer footer
|
||||
let item_count = $(el_window).find('.item').length;
|
||||
let item_count = $(el_window).find('.item:not(.item-hidden)').length;
|
||||
$(el_window).find('.explorer-footer .explorer-footer-item-count').html(`${item_count } ${i18n('item')}${ item_count == 0 || item_count > 1 ? `${i18n('plural_suffix')}` : ''}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
|
||||
import { underPath } from '../helpers/pathElements.js';
|
||||
|
||||
async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_dir_path) {
|
||||
let h = '';
|
||||
@@ -110,7 +111,7 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d
|
||||
});
|
||||
|
||||
// find all items whose path starts with target_dir_path
|
||||
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
|
||||
underPath('.item', target_dir_path).each(function () {
|
||||
// show the link badge
|
||||
$(this).find('.item-has-website-url-badge').show();
|
||||
// update item's website_url attribute
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
|
||||
import { underPath } from '../helpers/pathElements.js';
|
||||
|
||||
async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_dir_path) {
|
||||
let h = '';
|
||||
@@ -117,7 +118,7 @@ async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_di
|
||||
});
|
||||
|
||||
// find all items whose path starts with target_dir_path
|
||||
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
|
||||
underPath('.item', target_dir_path).each(function () {
|
||||
// show the link badge
|
||||
// $(this).find('.item-has-website-url-badge').show();
|
||||
// update item's website_url attribute
|
||||
|
||||
@@ -500,8 +500,9 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel
|
||||
|
||||
.explore-table-headers {
|
||||
display: none;
|
||||
width: 100%;
|
||||
min-width: 795px;
|
||||
/* the negative margin below shifts it left by the body's padding */
|
||||
width: calc(100% + 10px);
|
||||
min-width: 805px;
|
||||
height: 25px;
|
||||
border-bottom: 1px solid rgb(226, 226, 226);
|
||||
background-color: #fff;
|
||||
@@ -534,6 +535,7 @@ span.header-sort-icon img {
|
||||
margin-left: 15px;
|
||||
color: #555c61;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explore-table-headers-th-active {
|
||||
@@ -842,6 +844,14 @@ span.header-sort-icon img {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.device-desktop .item-container-details .item-selected.item-blurred {
|
||||
background-color: #dbdada;
|
||||
}
|
||||
|
||||
.device-desktop .item-container-details .item-selected.item-blurred .item-attr {
|
||||
color: rgb(73, 73, 73);
|
||||
}
|
||||
|
||||
.item-container-details .item-attr--modified {
|
||||
left: 350px;
|
||||
}
|
||||
@@ -1199,6 +1209,22 @@ span.header-sort-icon img {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* A path too long for the bar is cut at the root end, so the current folder stays visible. */
|
||||
.window-navbar-path {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
min-height: 37px;
|
||||
}
|
||||
|
||||
.window-navbar-path > * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.window-navbar-path > :last-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.device-phone .window-navbar-path {
|
||||
display: none;
|
||||
}
|
||||
@@ -1208,6 +1234,7 @@ span.header-sort-icon img {
|
||||
height: 30px;
|
||||
margin-left: 10px;
|
||||
margin-top: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-phone .window-navbar-layout-settings {
|
||||
|
||||
+142
-119
@@ -23,6 +23,7 @@ import { archiveEntryPath } from './helpers/archiveEntryPath.js';
|
||||
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
|
||||
import globToRegExp from './helpers/globToRegExp.js';
|
||||
import item_icon from './helpers/itemIcon.js';
|
||||
import { atPath, retargetNavHistory, retargetPaths, underPath } from './helpers/pathElements.js';
|
||||
import { is_owned_by_me, trash_path_for } from './helpers/pathOwner.js';
|
||||
import { invalidate_shared_roots } from './helpers/sharedAccess.js';
|
||||
import truncate_filename from './helpers/truncateFilename.js';
|
||||
@@ -31,11 +32,13 @@ import update_username_in_gui from './helpers/updateUsernameInGui.js';
|
||||
import { notifyItemWatchers } from './helpers/watchItems.js';
|
||||
import { select_uploaded_items } from './helpers/uploadSelection.js';
|
||||
import { templateMenuItem } from './helpers/templateMenuItem.js';
|
||||
import { isTokenForAnotherUser } from './helpers/tokenUser.js';
|
||||
import { uuidv4 } from './helpers/uuidv4.js';
|
||||
import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
import UIItem from './UI/UIItem.js';
|
||||
import { compareEntries, compareNames } from './UI/Dashboard/sortEntries.js';
|
||||
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
|
||||
import UIWindowProgress from './UI/UIWindowProgress.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
@@ -624,10 +627,6 @@ window.refresh_user_data = async (auth_token) => {
|
||||
|
||||
window.update_auth_data = async (auth_token, user) => {
|
||||
window.auth_token = auth_token;
|
||||
// Write the v2 key going forward and clear any lingering v1 key so
|
||||
// a single localStorage source-of-truth is used.
|
||||
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
|
||||
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
|
||||
|
||||
// Set http-only session cookie when user is changing.
|
||||
// This ensures user-protected endpoints, which only refer to the http-only cookie,
|
||||
@@ -653,6 +652,13 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Stored only after the cookie sync: a peer tab reloads when it sees
|
||||
// another account's token, and its boot signs in from the cookie.
|
||||
// A token adopted from a peer tab during the sync must not outlive it.
|
||||
window.auth_token = auth_token;
|
||||
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
|
||||
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
|
||||
|
||||
// Has username changed?
|
||||
if ( window.user?.username !== user.username )
|
||||
{
|
||||
@@ -857,6 +863,12 @@ if ( typeof window !== 'undefined' && window.addEventListener ) {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if ( event.key !== window.AUTH_TOKEN_KEY_V2 ) return;
|
||||
if ( event.newValue ) {
|
||||
// A peer tab changed accounts. Reload as that account instead of
|
||||
// sending its token from a desktop still showing this one.
|
||||
if ( isTokenForAnotherUser(event.newValue, window.user) ) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
// A peer tab just (re)authed. Pick up the fresh token so
|
||||
// subsequent requests stop bouncing on reauth_required.
|
||||
window.auth_token = event.newValue;
|
||||
@@ -1109,48 +1121,19 @@ window.sort_items = (item_container, sort_by, sort_order) => {
|
||||
sort_order = 'asc';
|
||||
}
|
||||
|
||||
const direction = sort_order === 'asc' ? 1 : -1;
|
||||
$(item_container).find('.item[data-sortable="true"]').detach().sort(function (a, b) {
|
||||
// Name
|
||||
if ( !sort_by || sort_by === 'name' ) {
|
||||
if ( a.dataset.name.toLowerCase() < b.dataset.name.toLowerCase() ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( a.dataset.name.toLowerCase() > b.dataset.name.toLowerCase() ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
const name_a = a.dataset.name;
|
||||
const name_b = b.dataset.name;
|
||||
if ( sort_by === 'type' ) {
|
||||
const by_extension = compareNames(path.extname(name_a).toLowerCase(), path.extname(name_b).toLowerCase());
|
||||
return direction * (by_extension || compareNames(name_a, name_b));
|
||||
}
|
||||
// Size
|
||||
else if ( sort_by === 'size' ) {
|
||||
if ( parseInt(a.dataset.size) < parseInt(b.dataset.size) ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( parseInt(a.dataset.size) > parseInt(b.dataset.size) ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Modified
|
||||
else if ( sort_by === 'modified' ) {
|
||||
if ( parseInt(a.dataset.modified) < parseInt(b.dataset.modified) ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( parseInt(a.dataset.modified) > parseInt(b.dataset.modified) ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Type
|
||||
else if ( sort_by === 'type' ) {
|
||||
if ( path.extname(a.dataset.name.toLowerCase()) < path.extname(b.dataset.name.toLowerCase()) ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( path.extname(a.dataset.name.toLowerCase()) > path.extname(b.dataset.name.toLowerCase()) ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return direction * compareEntries(
|
||||
{ name: name_a, size: parseInt(a.dataset.size), modified: parseInt(a.dataset.modified) },
|
||||
{ name: name_b, size: parseInt(b.dataset.size), modified: parseInt(b.dataset.modified) },
|
||||
sort_by,
|
||||
);
|
||||
}).appendTo(item_container);
|
||||
};
|
||||
|
||||
@@ -1158,10 +1141,18 @@ window.show_or_hide_files = (item_containers) => {
|
||||
const show_hidden_files = window.user_preferences.show_hidden_files;
|
||||
const class_to_add = show_hidden_files ? 'item-revealed' : 'item-hidden';
|
||||
const class_to_remove = show_hidden_files ? 'item-hidden' : 'item-revealed';
|
||||
$(item_containers)
|
||||
const $dotfiles = $(item_containers)
|
||||
.find('.item')
|
||||
.filter((_, item) => item.dataset.name.startsWith('.'))
|
||||
.removeClass(class_to_remove).addClass(class_to_add);
|
||||
// an item the user can no longer see must not stay in the selection
|
||||
if ( ! show_hidden_files ) {
|
||||
$dotfiles.removeClass('item-selected');
|
||||
}
|
||||
$(item_containers).closest('.window').each(function () {
|
||||
window.update_explorer_footer_item_count(this);
|
||||
window.update_explorer_footer_selected_items_count(this);
|
||||
});
|
||||
};
|
||||
|
||||
window.create_folder = async (basedir, appendto_element) => {
|
||||
@@ -1178,7 +1169,7 @@ window.create_folder = async (basedir, appendto_element) => {
|
||||
rename: true,
|
||||
overwrite: false,
|
||||
success: function (data) {
|
||||
const el_created_dir = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
|
||||
const el_created_dir = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
|
||||
if ( el_created_dir.length > 0 ) {
|
||||
window.activate_item_name_editor(el_created_dir);
|
||||
|
||||
@@ -1211,7 +1202,7 @@ window.create_file = async (options) => {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
success: async function (data) {
|
||||
const created_file = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
|
||||
const created_file = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
|
||||
if ( created_file.length > 0 ) {
|
||||
window.activate_item_name_editor(created_file);
|
||||
|
||||
@@ -1264,6 +1255,9 @@ window.available_templates = () => {
|
||||
};
|
||||
|
||||
window.create_shortcut = async (filename, is_dir, basedir, appendto_element, shortcut_to, shortcut_to_path) => {
|
||||
// The Shared view is a query, not a directory — nothing can be created in it.
|
||||
if ( basedir === window.shared_path ) return;
|
||||
|
||||
const extname = path.extname(filename);
|
||||
const basename = `${path.basename(filename, extname) } - Shortcut`;
|
||||
filename = basename + extname;
|
||||
@@ -1298,6 +1292,8 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// re-armed after a dialog while the window from before it is still open
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
operation_id: copy_op_id,
|
||||
on_cancel: () => {
|
||||
@@ -1431,6 +1427,9 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
|
||||
* @param {string} dest_path - Destination path to copy items to
|
||||
*/
|
||||
window.copy_items = function (el_items, dest_path) {
|
||||
// The Shared view is a query, not a directory — nothing can be copied into it.
|
||||
if ( dest_path === window.shared_path ) return;
|
||||
|
||||
let copy_op_id = window.operation_id++;
|
||||
let overwrite_all = false;
|
||||
(async () => {
|
||||
@@ -1440,6 +1439,8 @@ window.copy_items = function (el_items, dest_path) {
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// re-armed after a dialog while the window from before it is still open
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
operation_id: copy_op_id,
|
||||
on_cancel: () => {
|
||||
@@ -1583,8 +1584,8 @@ window.delete_item = async function (el_item, descendants_only = false) {
|
||||
// close all windows with matching uids
|
||||
$(`.window-${ $(el_item).attr('data-uid')}`).close();
|
||||
// close all windows that belong to a descendant of this item
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${$(el_item).attr('data-path')}/"]`).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', $(el_item).attr('data-path')).close();
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -1605,7 +1606,7 @@ window.delete_item = async function (el_item, descendants_only = false) {
|
||||
window.update_explorer_footer_selected_items_count(this);
|
||||
});
|
||||
// update all shortcuts to this item
|
||||
$(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', '');
|
||||
atPath('.item', $(el_item).attr('data-path'), 'data-shortcut_to_path').attr('data-shortcut_to_path', '');
|
||||
});
|
||||
} catch ( err ) {
|
||||
// Nothing was deleted: bring back what was faded out above.
|
||||
@@ -1614,20 +1615,37 @@ window.delete_item = async function (el_item, descendants_only = false) {
|
||||
}
|
||||
};
|
||||
|
||||
window.move_clipboard_items = function (el_target_container, target_path) {
|
||||
window.move_clipboard_items = async function (el_target_container, target_path) {
|
||||
let dest_path = target_path === undefined ? $(el_target_container).attr('data-path') : target_path;
|
||||
let el_items = [];
|
||||
if ( window.clipboard.length > 0 ) {
|
||||
for ( let i = 0; i < window.clipboard.length; i++ ) {
|
||||
el_items.push($(`.item[data-path="${html_encode(window.clipboard[i])}" i]`));
|
||||
}
|
||||
if ( el_items.length > 0 )
|
||||
{
|
||||
window.move_items(el_items, dest_path);
|
||||
}
|
||||
}
|
||||
|
||||
const cut_paths = window.clipboard;
|
||||
window.clipboard = [];
|
||||
|
||||
// A cut item's row is gone once its folder is no longer displayed, so
|
||||
// move_items gets a stand-in carrying what it reads off a row.
|
||||
const el_items = (await Promise.all(cut_paths.map(async (cut_path) => {
|
||||
const $el_item = atPath('.item', cut_path);
|
||||
if ( $el_item.length > 0 ) {
|
||||
return $el_item;
|
||||
}
|
||||
try {
|
||||
const fsentry = await puter.fs.stat({ path: cut_path, consistency: 'eventual' });
|
||||
return $('<div>').attr({
|
||||
'data-uid': fsentry.uid,
|
||||
'data-path': cut_path,
|
||||
'data-name': fsentry.name,
|
||||
'data-is_dir': fsentry.is_dir ? '1' : '0',
|
||||
'data-immutable': (fsentry.immutable || fsentry.writable === false) ? '1' : '0',
|
||||
'data-metadata': fsentry.metadata ?? '',
|
||||
});
|
||||
} catch ( e ) {
|
||||
// the item no longer exists, so there is nothing to move
|
||||
return null;
|
||||
}
|
||||
}))).filter(Boolean);
|
||||
|
||||
if ( el_items.length > 0 ) {
|
||||
window.move_items(el_items, dest_path);
|
||||
}
|
||||
};
|
||||
|
||||
function downloadFile (url, postData = {}) {
|
||||
@@ -1685,7 +1703,7 @@ window.trigger_download = (paths) => {
|
||||
let urls = [];
|
||||
for ( let index = 0; index < paths.length; index++ ) {
|
||||
urls.push({
|
||||
download: `${window.origin }/down?path=${ paths[index]}`,
|
||||
download: `${window.origin }/down?path=${ encodeURIComponent(paths[index])}`,
|
||||
filename: path.basename(paths[index]),
|
||||
});
|
||||
}
|
||||
@@ -1791,6 +1809,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// re-armed after a dialog while the window from before it is still open
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
operation_id: move_op_id,
|
||||
on_cancel: () => {
|
||||
@@ -1838,7 +1858,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
if ( path.dirname($(el_item).attr('data-path')) === item_dest_path ) {
|
||||
// pause the progress-window timer while waiting for the user
|
||||
clearTimeout(progwin_timeout);
|
||||
await UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>Cannot move item to its current location.`);
|
||||
// UIAlert escapes the message itself
|
||||
await UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>Cannot move item to its current location.`);
|
||||
progwin_timeout = arm_progwin();
|
||||
|
||||
continue;
|
||||
@@ -1912,7 +1933,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
}
|
||||
|
||||
// moving an item into a trashed directory? deny.
|
||||
else if ( dest_path.startsWith(window.trash_path) ) {
|
||||
else if ( dest_path === window.trash_path || dest_path.startsWith(`${window.trash_path}/`) ) {
|
||||
// the pending timer would otherwise open an orphan
|
||||
// progress window after the operation already bailed
|
||||
clearTimeout(progwin_timeout);
|
||||
@@ -1961,24 +1982,18 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
// skip next loop iteration because this iteration was successful
|
||||
item_with_same_name_already_exists = false;
|
||||
|
||||
// update all shortcut_to_path — compare raw attribute values
|
||||
// (item rows store paths unencoded, so an html_encode()d
|
||||
// selector misses names containing & < > " ')
|
||||
const moved_from_path_lc = String($(el_item).attr('data-path') || '').toLowerCase();
|
||||
$('.item[data-shortcut_to_path]').filter(function () {
|
||||
return String($(this).attr('data-shortcut_to_path')).toLowerCase() === moved_from_path_lc;
|
||||
}).attr('data-shortcut_to_path', fsentry.path);
|
||||
const moved_from_path = $(el_item).attr('data-path');
|
||||
|
||||
// update all shortcut_to_path
|
||||
atPath('.item', moved_from_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
|
||||
|
||||
// Remove all items with matching uids from their OLD location(s).
|
||||
// Exclude any row already at the item's new path: a concurrent
|
||||
// item.moved socket handler may have just created a row at the
|
||||
// destination (e.g. the dashboard file view showing the target
|
||||
// directory), and removing by uid alone would delete it too.
|
||||
// Raw case-insensitive compare, for the same reason as above.
|
||||
const dest_item_path_lc = fsentry.path.toLowerCase();
|
||||
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(function () {
|
||||
return String($(this).attr('data-path') || '').toLowerCase() === dest_item_path_lc;
|
||||
}).fadeOut(150, function () {
|
||||
const $moved_items = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`);
|
||||
$moved_items.not(atPath($moved_items, fsentry.path)).fadeOut(150, function () {
|
||||
// find all parent windows that contain this item
|
||||
let parent_windows = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).closest('.window');
|
||||
// remove this item
|
||||
@@ -1992,23 +2007,24 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
|
||||
// if trashing, close windows of trashed items and its descendants
|
||||
if ( is_trashing ) {
|
||||
$(`.window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).close();
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"]`).close();
|
||||
atPath('.window', moved_from_path).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', moved_from_path).close();
|
||||
}
|
||||
|
||||
// update all paths of its and its descendants' open windows
|
||||
else {
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"], .window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).each(function () {
|
||||
window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(item_dest_path, fsentry.name)));
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', moved_from_path).add(atPath('.window', moved_from_path)).each(function () {
|
||||
window.update_window_path(this, path.join(item_dest_path, fsentry.name) + $(this).attr('data-path').slice(moved_from_path.length));
|
||||
});
|
||||
retargetNavHistory(moved_from_path, fsentry.path);
|
||||
}
|
||||
|
||||
// The dashboard's Back/Forward follows a moved folder, and
|
||||
// forgets a trashed one.
|
||||
if ( $(el_item).attr('data-is_dir') === '1' ) {
|
||||
window.dashboard_object?.folderMoved?.($(el_item).attr('data-path'), is_trashing ? null : fsentry.path);
|
||||
window.dashboard_object?.folderMoved?.(moved_from_path, is_trashing ? null : fsentry.path);
|
||||
}
|
||||
|
||||
if ( is_trashing ) {
|
||||
@@ -2030,14 +2046,19 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
// if this is trash, get original name from item metadata
|
||||
fsentry.name = metadata?.original_name || fsentry.name;
|
||||
|
||||
const moved_item_icon = await item_icon(fsentry);
|
||||
const $dest_containers = atPath('.item-container', item_dest_path);
|
||||
// the item.moved echo of this move can render the row first
|
||||
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
|
||||
|
||||
// create new item on matching containers
|
||||
const options = {
|
||||
appendTo: $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`),
|
||||
appendTo: $dest_containers,
|
||||
immutable: fsentry.immutable || (fsentry.writable === false),
|
||||
associated_app_name: fsentry.associated_app?.name,
|
||||
uid: fsentry.uid,
|
||||
path: fsentry.path,
|
||||
icon: await item_icon(fsentry),
|
||||
icon: moved_item_icon,
|
||||
name: is_trashing ? $(el_item).attr('data-name') : fsentry.name,
|
||||
is_dir: fsentry.is_dir,
|
||||
size: fsentry.size,
|
||||
@@ -2063,8 +2084,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
// see if any of the directories in the path of this file is new AND
|
||||
// if these new path have any open parents that need to be updated
|
||||
resp.parent_dirs_created?.forEach(async dir => {
|
||||
let item_container = $(`.item-container[data-path="${html_encode(path.dirname(dir.path))}" i]`);
|
||||
if ( item_container.length > 0 && $(`.item[data-path="${html_encode(dir.path)}" i]`).length === 0 ) {
|
||||
let item_container = atPath('.item-container', path.dirname(dir.path));
|
||||
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
|
||||
UIItem({
|
||||
appendTo: item_container,
|
||||
immutable: false,
|
||||
@@ -2090,7 +2111,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
});
|
||||
|
||||
//sort each container
|
||||
$(`.item-container[data-path="${html_encode(item_dest_path)}" i]`).each(function () {
|
||||
atPath('.item-container', item_dest_path).each(function () {
|
||||
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
|
||||
});
|
||||
} catch ( err ) {
|
||||
@@ -2132,7 +2153,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
item_with_same_name_already_exists = false;
|
||||
// error message after source item has reappeared
|
||||
$(el_item).show(0, function () {
|
||||
UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>${err.message ?? ''}`);
|
||||
UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>${err.message ?? ''}`);
|
||||
});
|
||||
|
||||
break;
|
||||
@@ -2285,9 +2306,7 @@ window.updateSubdomainsForItems = async function (fsentries, container) {
|
||||
|
||||
// If not found by uid, try path-based search
|
||||
if ( $item.length === 0 && fsentry.path ) {
|
||||
// Escape special characters in path for jQuery selector
|
||||
const escapedPath = fsentry.path.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
||||
$item = $(document).find(`.item[data-path="${escapedPath}"]`);
|
||||
$item = atPath('.item', fsentry.path);
|
||||
}
|
||||
|
||||
if ( $item.length > 0 ) {
|
||||
@@ -2397,6 +2416,9 @@ window.upload_items = async function (items, dest_path) {
|
||||
let upload_progress_window;
|
||||
let opid;
|
||||
|
||||
// The Shared view is a query, not a directory — nothing can be uploaded into it.
|
||||
if ( dest_path === window.shared_path ) return;
|
||||
|
||||
if ( dest_path == window.trash_path ) {
|
||||
UIAlert('Uploading to trash is not allowed!');
|
||||
return;
|
||||
@@ -2876,13 +2898,15 @@ window.unzipItem = async function (itemPath) {
|
||||
terminateOp = fflate.unzip(file, async (err, unzipped) => {
|
||||
currentProgress += window.zippingProgressConfig.ZIPPING;
|
||||
progwin?.set_progress(currentProgress.toPrecision(2));
|
||||
if ( err ) {
|
||||
UIAlert(e.message);
|
||||
// close progress window
|
||||
const close_progwin = (hide_delay) => {
|
||||
clearTimeout(progwin_timeout);
|
||||
setTimeout(() => {
|
||||
progwin?.close();
|
||||
}, Math.max(0, window.copy_progress_hide_delay - (Date.now() - start_ts)));
|
||||
}, Math.max(0, hide_delay - (Date.now() - start_ts)));
|
||||
};
|
||||
if ( err ) {
|
||||
UIAlert(err.message);
|
||||
close_progwin(window.copy_progress_hide_delay);
|
||||
} else {
|
||||
const rootdir = await puter.fs.mkdir(`${path.dirname(filePath) }/${ path.basename(filePath, '.zip')}`, { dedupeName: true });
|
||||
let perItemProgress = window.zippingProgressConfig.WRITING / Object.keys(unzipped).length;
|
||||
@@ -2900,6 +2924,10 @@ window.unzipItem = async function (itemPath) {
|
||||
UIAlert(e.message);
|
||||
}
|
||||
});
|
||||
// an archive with no files leaves nothing to upload, so nothing else would end the operation
|
||||
if ( queuedFileWrites.length === 0 ) {
|
||||
close_progwin(window.unzip_progress_hide_delay);
|
||||
}
|
||||
queuedFileWrites.length && puter.fs.upload(
|
||||
// what to upload
|
||||
queuedFileWrites,
|
||||
@@ -2911,7 +2939,7 @@ window.unzipItem = async function (itemPath) {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
progress: async function (operation_id, op_progress) {
|
||||
progwin.set_progress(op_progress);
|
||||
progwin?.set_progress(op_progress);
|
||||
// update title if window is not visible
|
||||
if ( document.visibilityState !== 'visible' ) {
|
||||
update_title_based_on_uploads();
|
||||
@@ -2919,11 +2947,10 @@ window.unzipItem = async function (itemPath) {
|
||||
},
|
||||
success: async function (items) {
|
||||
progwin?.set_progress(window.zippingProgressConfig.TOTAL.toPrecision(2));
|
||||
// close progress window
|
||||
clearTimeout(progwin_timeout);
|
||||
setTimeout(() => {
|
||||
progwin?.close();
|
||||
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
|
||||
close_progwin(window.unzip_progress_hide_delay);
|
||||
},
|
||||
error: function () {
|
||||
close_progwin(window.unzip_progress_hide_delay);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -3155,6 +3182,13 @@ window.untarItem = async function (itemPath) {
|
||||
}
|
||||
}
|
||||
|
||||
// an archive with no files leaves nothing to upload, so nothing else would end the operation
|
||||
if ( queuedFileWrites.length === 0 ) {
|
||||
clearTimeout(progwin_timeout);
|
||||
setTimeout(() => {
|
||||
progwin?.close();
|
||||
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
|
||||
}
|
||||
queuedFileWrites.length && puter.fs.upload(
|
||||
queuedFileWrites,
|
||||
`${rootdir.path }/`,
|
||||
@@ -3163,7 +3197,7 @@ window.untarItem = async function (itemPath) {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
progress: async function (operation_id, op_progress) {
|
||||
progwin.set_progress(op_progress);
|
||||
progwin?.set_progress(op_progress);
|
||||
if ( document.visibilityState !== 'visible' ) {
|
||||
update_title_based_on_uploads();
|
||||
}
|
||||
@@ -3301,20 +3335,7 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i
|
||||
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('data-path', new_path);
|
||||
$(`.window-${options.uid}`).attr('data-path', new_path);
|
||||
|
||||
// Update all elements that have matching paths
|
||||
$(`[data-path="${html_encode(old_path)}" i]`).each(function () {
|
||||
$(this).attr('data-path', new_path);
|
||||
if ( $(this).hasClass('window-navbar-path-dirname') )
|
||||
{
|
||||
$(this).text(new_name);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the paths of all elements whose paths start with `old_path`
|
||||
$(`[data-path^="${`${html_encode(old_path) }/`}"]`).each(function () {
|
||||
const new_el_path = $(this).attr('data-path').replace(`${old_path }/`, `${new_path}/`);
|
||||
$(this).attr('data-path', new_el_path);
|
||||
});
|
||||
retargetPaths(old_path, new_path, new_name);
|
||||
|
||||
// Update the 'Sites Cache'
|
||||
if ( $(el_item).attr('data-has_website') === '1' )
|
||||
@@ -3395,7 +3416,9 @@ window.undo_last_action = async () => {
|
||||
delete: () => window.undo_delete(data),
|
||||
rename: () => {
|
||||
const { options, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, onDone } = data;
|
||||
window.rename_file(options, old_name, new_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
|
||||
// the item now sits at its renamed path, which is what this rename moves away from
|
||||
const renamed_path = path.join(path.dirname(old_path), new_name);
|
||||
window.rename_file(options, old_name, new_name, renamed_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
globalThis.use = globalThis.use ?? (() => class {});
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
|
||||
await import('./helpers.js');
|
||||
const { default: apply_item_added_to_containers } = await import('./helpers/applyItemAddedToContainers.js');
|
||||
const { default: UIItem } = await import('./UI/UIItem.js');
|
||||
const { atPath, retargetNavHistory, retargetPaths, underPath } = await import('./helpers/pathElements.js');
|
||||
|
||||
// A plain name, then names holding characters a selector or HTML treats specially.
|
||||
const NAMES = ['Reports 2024', 'R&D', "Dan's files", 'the "big" one', 'a<b>c', 'back\\slash'];
|
||||
|
||||
const el = (className, attrs) => jQuery('<div>').addClass(className).attr(attrs).appendTo(document.body);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
jQuery.fx.off = true;
|
||||
jQuery.fn.close = vi.fn();
|
||||
jQuery.fn.removeItems = vi.fn();
|
||||
window.icons = { 'folder.svg': 'folder.svg' };
|
||||
window.actions_history = [];
|
||||
window.user = { username: 'alice' };
|
||||
window.update_window_path = vi.fn();
|
||||
window.sort_items = vi.fn();
|
||||
window.update_explorer_footer_item_count = vi.fn();
|
||||
window.update_explorer_footer_selected_items_count = vi.fn();
|
||||
window.determine_website_url = () => '';
|
||||
UIItem.mockClear();
|
||||
});
|
||||
|
||||
describe.each(NAMES)('a folder named %s', (name) => {
|
||||
const folderPath = `/alice/${name}`;
|
||||
|
||||
it('keeps its open subfolders and their items on the new path when renamed', async () => {
|
||||
const $item = el('item', { 'data-uid': 'u1', 'data-path': folderPath, 'data-name': name });
|
||||
const $crumb = el('window-navbar-path-dirname', { 'data-path': folderPath });
|
||||
const $subWindow = el('window', { 'data-path': `${folderPath}/sub` });
|
||||
const $subItem = el('item', { 'data-uid': 'u2', 'data-path': `${folderPath}/sub/a.txt` });
|
||||
globalThis.puter = { fs: { rename: ({ success }) => success({ uid: 'u1', name: 'Plain', is_dir: true }) } };
|
||||
|
||||
await new Promise(resolve => {
|
||||
window.rename_file({ uid: 'u1', name, path: folderPath, is_dir: true }, 'Plain', name, folderPath,
|
||||
$item[0], null, null, null, '', false, resolve);
|
||||
});
|
||||
|
||||
expect($item.attr('data-path')).toBe('/alice/Plain');
|
||||
expect($crumb.attr('data-path')).toBe('/alice/Plain');
|
||||
expect($crumb.text()).toBe('Plain');
|
||||
expect($subWindow.attr('data-path')).toBe('/alice/Plain/sub');
|
||||
expect($subItem.attr('data-path')).toBe('/alice/Plain/sub/a.txt');
|
||||
});
|
||||
|
||||
it('closes the windows of its subfolders when deleted', async () => {
|
||||
const $item = el('item', { 'data-uid': 'u1', 'data-path': folderPath });
|
||||
el('window', { 'data-path': `${folderPath}/sub` });
|
||||
el('window', { 'data-path': '/alice/Other/sub' });
|
||||
el('item', { 'data-uid': 'u3', 'data-shortcut_to_path': folderPath });
|
||||
globalThis.puter = { fs: { delete: vi.fn().mockResolvedValue() } };
|
||||
|
||||
await window.delete_item($item[0]);
|
||||
|
||||
const closed = jQuery.fn.close.mock.contexts.flatMap($set => $set.toArray().map(w => jQuery(w).attr('data-path')));
|
||||
expect(closed).toEqual([`${folderPath}/sub`]);
|
||||
expect(puter.fs.delete).toHaveBeenCalled();
|
||||
expect(jQuery('[data-uid="u3"]').attr('data-shortcut_to_path')).toBe('');
|
||||
});
|
||||
|
||||
it('shows an item another tab added to it', async () => {
|
||||
const $container = el('item-container', { 'data-path': folderPath });
|
||||
|
||||
await apply_item_added_to_containers({ uid: 'u9', dirpath: folderPath, path: `${folderPath}/new.txt`, name: 'new.txt', is_dir: true });
|
||||
|
||||
expect(UIItem).toHaveBeenCalledTimes(1);
|
||||
expect(UIItem.mock.calls[0][0].appendTo.toArray()).toEqual([$container[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retargetPaths', () => {
|
||||
it('ignores the case of the renamed path itself, and leaves its siblings alone', () => {
|
||||
const $window = el('window', { 'data-path': '/alice/docs' });
|
||||
const $sibling = el('item', { 'data-path': '/alice/Docs 2/a.txt' });
|
||||
|
||||
retargetPaths('/alice/Docs', '/alice/Papers', 'Papers');
|
||||
|
||||
expect($window.attr('data-path')).toBe('/alice/Papers');
|
||||
expect($sibling.attr('data-path')).toBe('/alice/Docs 2/a.txt');
|
||||
});
|
||||
|
||||
it('writes a new name holding a replacement pattern as it is', () => {
|
||||
const $item = el('item', { 'data-path': '/alice/Docs/a.txt' });
|
||||
|
||||
retargetPaths('/alice/Docs', '/alice/$& and $1', '$& and $1');
|
||||
|
||||
expect($item.attr('data-path')).toBe('/alice/$& and $1/a.txt');
|
||||
});
|
||||
|
||||
it('changes nothing when the old path is unknown', () => {
|
||||
const $item = el('item', { 'data-path': '/alice/Docs/a.txt' });
|
||||
|
||||
retargetPaths(undefined, '/alice/Papers', 'Papers');
|
||||
|
||||
expect($item.attr('data-path')).toBe('/alice/Docs/a.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([undefined, null, ''])('a lookup for the path %s', (missing) => {
|
||||
it('matches nothing, including elements that have no path', () => {
|
||||
el('item', { 'data-path': '/alice/a.txt', 'data-shortcut_to_path': '' });
|
||||
el('window', { 'data-path': '' });
|
||||
el('window', { 'data-path': '/alice/Docs' });
|
||||
|
||||
expect(atPath('.item', missing, 'data-shortcut_to_path')).toHaveLength(0);
|
||||
expect(atPath('.window', missing)).toHaveLength(0);
|
||||
expect(underPath('.window', missing)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retargetNavHistory', () => {
|
||||
it('rewrites entries at or inside the old path and leaves the rest', () => {
|
||||
window.window_nav_history = {
|
||||
1: ['/alice', '/alice/A', '/alice/A/sub', '/alice/AB', 'puter://shared'],
|
||||
2: ['/alice/Documents'],
|
||||
};
|
||||
|
||||
retargetNavHistory('/alice/A', '/alice/B');
|
||||
|
||||
expect(window.window_nav_history[1]).toEqual(['/alice', '/alice/B', '/alice/B/sub', '/alice/AB', 'puter://shared']);
|
||||
expect(window.window_nav_history[2]).toEqual(['/alice/Documents']);
|
||||
});
|
||||
|
||||
it('is applied by retargetPaths', () => {
|
||||
window.window_nav_history = { 1: ['/alice/A/sub'] };
|
||||
|
||||
retargetPaths('/alice/A', '/alice/B', 'B');
|
||||
|
||||
expect(window.window_nav_history[1]).toEqual(['/alice/B/sub']);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import UIItem from '../UI/UIItem.js';
|
||||
import item_icon from './itemIcon.js';
|
||||
import { atPath } from './pathElements.js';
|
||||
import { select_added_item_if_pending } from './uploadSelection.js';
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ import { select_added_item_if_pending } from './uploadSelection.js';
|
||||
* @param {object} item fsentry from the `item.added` event
|
||||
*/
|
||||
const apply_item_added_to_containers = async function (item) {
|
||||
const $containers = $(`.item-container[data-path='${html_encode(item.dirpath)}' i]`);
|
||||
const $containers = atPath('.item-container', item.dirpath);
|
||||
if ( $containers.length === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from '../lib/path.js';
|
||||
|
||||
/**
|
||||
* Expands a user-facing path into the absolute path the filesystem API takes:
|
||||
* `~` and `~/x` resolve under the user's home, a bare relative path is read as
|
||||
@@ -39,3 +41,20 @@ export const expand_home_path = (fspath, home_path) => {
|
||||
if ( fspath.startsWith('/') ) return fspath;
|
||||
return `${home}/${fspath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns what a user typed into an address bar into the canonical absolute
|
||||
* path: trimmed, home-expanded, with `.`/`..`, doubled and trailing slashes
|
||||
* resolved. An empty entry keeps the current path.
|
||||
*
|
||||
* @param {string} typed_path - the raw text the user entered
|
||||
* @param {{ current_path: string, home_path: string, shared_path: string }} context
|
||||
* @returns {string} the path to navigate to
|
||||
*/
|
||||
export const resolve_typed_path = (typed_path, { current_path, home_path, shared_path }) => {
|
||||
const trimmed = typeof typed_path === 'string' ? typed_path.trim() : '';
|
||||
if ( trimmed === '' ) return current_path;
|
||||
// The Shared view is a pseudo-path, not a filesystem one.
|
||||
if ( trimmed === shared_path ) return shared_path;
|
||||
return path.resolve('/', expand_home_path(trimmed, home_path));
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { expand_home_path } from './expandHomePath.js';
|
||||
import { expand_home_path, resolve_typed_path } from './expandHomePath.js';
|
||||
|
||||
describe('expand_home_path', () => {
|
||||
it('resolves a tilde path under the home path', () => {
|
||||
@@ -57,3 +57,28 @@ describe('expand_home_path', () => {
|
||||
expect(expand_home_path(null, '/alice')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve_typed_path', () => {
|
||||
const context = { current_path: '/alice/Videos', home_path: '/alice', shared_path: 'puter://shared' };
|
||||
|
||||
it('canonicalizes trailing, doubled and dot segments', () => {
|
||||
expect(resolve_typed_path('/alice/Documents/', context)).toBe('/alice/Documents');
|
||||
expect(resolve_typed_path('/alice//Documents', context)).toBe('/alice/Documents');
|
||||
expect(resolve_typed_path('/alice/Documents/../Pictures', context)).toBe('/alice/Pictures');
|
||||
expect(resolve_typed_path('/', context)).toBe('/');
|
||||
});
|
||||
|
||||
it('trims whitespace and expands home-relative entries', () => {
|
||||
expect(resolve_typed_path(' ~/Documents ', context)).toBe('/alice/Documents');
|
||||
expect(resolve_typed_path('Documents', context)).toBe('/alice/Documents');
|
||||
});
|
||||
|
||||
it('keeps the current path for an empty entry', () => {
|
||||
expect(resolve_typed_path('', context)).toBe('/alice/Videos');
|
||||
expect(resolve_typed_path(' ', context)).toBe('/alice/Videos');
|
||||
});
|
||||
|
||||
it('passes the Shared view through untouched', () => {
|
||||
expect(resolve_typed_path('puter://shared', context)).toBe('puter://shared');
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has dropped the user's own access (used by Dashboard)
|
||||
* @returns {Promise<Array>} Array of context menu items
|
||||
*/
|
||||
const generate_file_context_menu = async function (options) {
|
||||
@@ -389,7 +389,9 @@ const generate_file_context_menu = async function (options) {
|
||||
// Or mode lookups keep answering for a share we just
|
||||
// walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).remove();
|
||||
// A team that still shares it keeps it listed, as the
|
||||
// team's share; the refreshed listing shows it that way.
|
||||
if ( fsentry.also_team_share !== true ) $(el_item).remove();
|
||||
options.onRemovedFromShared?.();
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
|
||||
@@ -89,6 +89,38 @@ it('does not offer it on a share held through a team', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Remove from Shared', () => {
|
||||
const remove = async (fsentry) => {
|
||||
const row = sharedRow();
|
||||
document.body.append(row);
|
||||
const onRemovedFromShared = vi.fn();
|
||||
window.user = { username: 'me' };
|
||||
globalThis.puter = { fs: { unshare: vi.fn(async () => ({ revoked: 1 })) } };
|
||||
const items = await generate_file_context_menu({
|
||||
element: row,
|
||||
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt', ...fsentry },
|
||||
associated_app_name: 'editor',
|
||||
onRemovedFromShared,
|
||||
});
|
||||
await items.find((item) => item.html === 'share_remove_from_shared').onClick();
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/ann/uid-1/notes.txt', 'me');
|
||||
expect(onRemovedFromShared).toHaveBeenCalledTimes(1);
|
||||
return row;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('takes the row away once the user has left it', async () => {
|
||||
expect((await remove({})).isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the row for the listing to update when a team still shares it', async () => {
|
||||
expect((await remove({ also_team_share: true })).isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Open With', () => {
|
||||
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
|
||||
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { isAttestedOrigin } from './attestedOrigin.js';
|
||||
import { jwtPayload } from './jwtPayload.js';
|
||||
|
||||
/**
|
||||
* Godmode apps that run on their own full-access token instead of the
|
||||
@@ -73,14 +74,7 @@ export const shouldRenew = (expiresAt, nowMs) =>
|
||||
!Number.isFinite(expiresAt) || expiresAt * 1000 - nowMs < RENEW_WHEN_LEFT_MS;
|
||||
|
||||
/** The token row a JWT belongs to; renewals of a live row keep it. */
|
||||
const tokenRowOf = (token) => {
|
||||
try {
|
||||
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(atob(part))?.token_uid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const tokenRowOf = (token) => jwtPayload(token)?.token_uid ?? null;
|
||||
|
||||
/**
|
||||
* Whether two tokens are the same row, so the app already holds a working one.
|
||||
|
||||
@@ -100,7 +100,7 @@ const item_icon = async (fsentry) => {
|
||||
// --------------------------------------------------
|
||||
// .app files
|
||||
// --------------------------------------------------
|
||||
else if ( fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
|
||||
else if ( !fsentry.is_dir && fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
|
||||
try {
|
||||
const content = await puter.fs.read({ path: fsentry.path });
|
||||
const text = typeof content === 'string' ? content : await content.text();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The claims of a JWT, read without checking its signature, so they say what
|
||||
* the token claims and prove nothing.
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Record<string, unknown> | null} null when the token isn't a readable JWT
|
||||
*/
|
||||
export const jwtPayload = (token) => {
|
||||
try {
|
||||
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(part));
|
||||
return payload && typeof payload === 'object' ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -17,13 +17,50 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { strongerMode } from './shareModes.js';
|
||||
|
||||
// Largest page the backend will serve (ShareStore.MAX_HOLDER_PAGE_SIZE).
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Every share the current user holds, across all pages. A page can be short
|
||||
* once unreachable items are filtered out, so it pages on `cursor` rather than
|
||||
* on the item count.
|
||||
* One row per shared item. The API lists a row per grant and grants add up, so
|
||||
* an item granted twice keeps its strongest row. It stays a team share only
|
||||
* when no grant names the user directly, since a direct grant is theirs to leave.
|
||||
* A merged row lists everyone who granted it in `issuers`, strongest grant first,
|
||||
* and sets `alsoHeldByTeam` when leaving the direct grant leaves a team's in place.
|
||||
*
|
||||
* @param {Array<Object>} shares
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
const issuersOf = (share) => share.issuers ?? [share.issuer];
|
||||
const heldByTeam = (share) => Boolean(share.holderTeam) || share.alsoHeldByTeam === true;
|
||||
|
||||
export const mergeGrants = (shares) => {
|
||||
const merged = new Map();
|
||||
for ( const share of shares ) {
|
||||
const key = share.entryUid ?? share.path;
|
||||
const held = merged.get(key);
|
||||
if ( ! held ) {
|
||||
merged.set(key, share);
|
||||
continue;
|
||||
}
|
||||
const [best, other] = strongerMode(held.mode, share.mode) === held.mode ? [held, share] : [share, held];
|
||||
const teamOnly = held.holderTeam && share.holderTeam;
|
||||
const issuers = [...issuersOf(best), ...issuersOf(other)].filter(Boolean);
|
||||
merged.set(key, {
|
||||
...best,
|
||||
holderTeam: teamOnly ? best.holderTeam : undefined,
|
||||
alsoHeldByTeam: ! teamOnly && (heldByTeam(held) || heldByTeam(share)),
|
||||
issuers: [...new Set(issuers)],
|
||||
});
|
||||
}
|
||||
return [...merged.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* Every item shared with the current user, across all pages. A page can be
|
||||
* short once unreachable items are filtered out, so it pages on `cursor`
|
||||
* rather than on the item count.
|
||||
*
|
||||
* @returns {Promise<Array<Object>>}
|
||||
*/
|
||||
@@ -40,7 +77,7 @@ const list_all_shared = async () => {
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
|
||||
return shares;
|
||||
return mergeGrants(shares);
|
||||
};
|
||||
|
||||
export default list_all_shared;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import list_all_shared, { mergeGrants } from './listAllShared.js';
|
||||
|
||||
const BUDGET = '33333333-3333-3333-3333-333333333333';
|
||||
const REPORT = '44444444-4444-4444-4444-444444444444';
|
||||
const TEAM = { uid: 'team-1', name: 'Finance', handle: 'finance' };
|
||||
|
||||
const grant = (entryUid, name, extra) => ({
|
||||
entryUid,
|
||||
path: `/jf/${entryUid}/${name}`,
|
||||
name,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('mergeGrants', () => {
|
||||
it('lists an item once, with its strongest grant', () => {
|
||||
const merged = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'manage', issuer: 'jf' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'write', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(merged.map((share) => [share.entryUid, share.mode, share.issuer])).toEqual([
|
||||
[BUDGET, 'manage', 'jf'],
|
||||
[REPORT, 'write', 'delegate'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names everyone who granted the item, strongest grant first', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.issuer).toBe('delegate');
|
||||
expect(share.issuers).toEqual(['delegate', 'jf']);
|
||||
});
|
||||
|
||||
it('keeps the first of two equal grants', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(share.issuer).toBe('jf');
|
||||
});
|
||||
|
||||
it('is not a team share when one grant names the user directly', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'jf', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.mode).toBe('write');
|
||||
expect(share.holderTeam).toBeUndefined();
|
||||
expect(share.alsoHeldByTeam).toBe(true);
|
||||
});
|
||||
|
||||
it('marks only an item held both ways as also held by a team', () => {
|
||||
const [direct, team] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'read', holderTeam: TEAM }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'write', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(direct.alsoHeldByTeam).toBe(false);
|
||||
expect(team.alsoHeldByTeam).toBe(false);
|
||||
});
|
||||
|
||||
it('still marks it when a later grant is direct again', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: TEAM }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(share.holderTeam).toBeUndefined();
|
||||
expect(share.alsoHeldByTeam).toBe(true);
|
||||
});
|
||||
|
||||
it('stays a team share when every grant is to a team', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: { ...TEAM, uid: 'team-2' } }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.holderTeam).toEqual(TEAM);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_all_shared', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.window = { puter: { fs: { listShared: vi.fn() } } };
|
||||
});
|
||||
|
||||
it('merges grants for one item that arrive on different pages', async () => {
|
||||
window.puter.fs.listShared
|
||||
.mockResolvedValueOnce({
|
||||
items: [grant(BUDGET, 'Budget', { mode: 'manage' })],
|
||||
cursor: 'next',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [grant(BUDGET, 'Budget', { mode: 'read' })],
|
||||
});
|
||||
|
||||
const shares = await list_all_shared();
|
||||
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0].mode).toBe('manage');
|
||||
expect(window.puter.fs.listShared).toHaveBeenLastCalledWith({ limit: 200, cursor: 'next' });
|
||||
});
|
||||
});
|
||||
@@ -145,9 +145,14 @@ const new_context_menu_item = function (dirname, append_to_element) {
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
|
||||
// JPEG has no transparency, so the blank image needs a background
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
window.create_file({ dirname: dirname, append_to_element: append_to_element, name: 'New Image.jpg', content: blob });
|
||||
});
|
||||
}, 'image/jpeg');
|
||||
},
|
||||
},
|
||||
// Worker
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
import UIWindow from '../UI/UIWindow.js';
|
||||
import UIAlert from '../UI/UIAlert.js';
|
||||
import i18n from '../i18n/i18n.js';
|
||||
import launch_app from './launchApp.js';
|
||||
// installs the global i18n()
|
||||
import '../i18n/i18n.js';
|
||||
import path from '../lib/path.js';
|
||||
import item_icon from './itemIcon.js';
|
||||
import { resolve_default_app } from './defaultApp.js';
|
||||
|
||||
@@ -23,7 +23,6 @@ import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
|
||||
vi.mock('../i18n/i18n.js', () => ({ default: key => key }));
|
||||
vi.mock('./launchApp.js', () => ({ default: vi.fn(async () => ({ launchResult: { launched: true } })) }));
|
||||
vi.mock('./itemIcon.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./defaultApp.js', () => ({ resolve_default_app: vi.fn(async () => null) }));
|
||||
@@ -113,3 +112,18 @@ describe('open_item with a new tab', () => {
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('open_item with no app for the file', () => {
|
||||
it('offers to download it', async () => {
|
||||
window.is_dashboard_mode = false;
|
||||
window.trigger_download = vi.fn();
|
||||
UIAlert.mockResolvedValueOnce('download_file');
|
||||
|
||||
await open_item({ item: row({ name: 'data.bin', path: '/user/data.bin' }) });
|
||||
|
||||
expect(UIAlert).toHaveBeenCalledTimes(1);
|
||||
expect(UIAlert.mock.calls[0][1].map(button => button.value ?? button.label)).toEqual(['download_file', 'Cancel']);
|
||||
expect(window.trigger_download).toHaveBeenCalledWith(['/user/data.bin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrows `elements` to those whose `attr` is `path`, ignoring case. Reads the
|
||||
* attribute, because a path can hold any character a selector can't.
|
||||
*
|
||||
* @param {string | Element | JQuery} elements - a selector or a set of elements
|
||||
* @param {string} path
|
||||
* @param {string} [attr]
|
||||
* @returns {JQuery}
|
||||
*/
|
||||
export const atPath = (elements, path, attr = 'data-path') => {
|
||||
// Without this, a missing path matches every element whose attribute is empty.
|
||||
if ( ! path ) return $();
|
||||
const wanted = String(path).toLowerCase();
|
||||
return $(elements).filter(function () {
|
||||
const value = this.getAttribute(attr);
|
||||
return value !== null && value.toLowerCase() === wanted;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrows `elements` to those whose `data-path` is inside the folder at `path`.
|
||||
*
|
||||
* @param {string | Element | JQuery} elements - a selector or a set of elements
|
||||
* @param {string} path
|
||||
* @returns {JQuery}
|
||||
*/
|
||||
export const underPath = (elements, path) => {
|
||||
if ( ! path ) return $();
|
||||
const prefix = `${path}/`;
|
||||
return $(elements).filter(function () {
|
||||
return this.getAttribute('data-path')?.startsWith(prefix) === true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Points every element at `oldPath`, or inside it, to `newPath`.
|
||||
*
|
||||
* @param {string} oldPath
|
||||
* @param {string} newPath
|
||||
* @param {string} newName - shown by the breadcrumbs that pointed at `oldPath`
|
||||
*/
|
||||
export const retargetPaths = (oldPath, newPath, newName) => {
|
||||
if ( ! oldPath ) return;
|
||||
const $inside = underPath('[data-path]', oldPath);
|
||||
atPath('[data-path]', oldPath).each(function () {
|
||||
$(this).attr('data-path', newPath);
|
||||
if ( $(this).hasClass('window-navbar-path-dirname') ) {
|
||||
$(this).text(newName);
|
||||
}
|
||||
});
|
||||
$inside.each(function () {
|
||||
$(this).attr('data-path', newPath + $(this).attr('data-path').slice(oldPath.length));
|
||||
});
|
||||
retargetNavHistory(oldPath, newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Points every window's Back/Forward entries at `oldPath`, or inside it, to
|
||||
* `newPath`, so they still lead somewhere after a rename or move.
|
||||
*
|
||||
* @param {string} oldPath
|
||||
* @param {string} newPath
|
||||
*/
|
||||
export const retargetNavHistory = (oldPath, newPath) => {
|
||||
if ( ! oldPath ) return;
|
||||
const prefix = `${oldPath}/`;
|
||||
for ( const history of Object.values(window.window_nav_history ?? {}) ) {
|
||||
history.forEach((entry, index) => {
|
||||
if ( entry === oldPath ) history[index] = newPath;
|
||||
else if ( entry.startsWith(prefix) ) history[index] = newPath + entry.slice(oldPath.length);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -72,9 +72,13 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
}
|
||||
|
||||
// show the loading spinner
|
||||
$(loading_spinner).find('.explorer-loading-spinner-msg').text(`${i18n('loading')}...`);
|
||||
$(loading_spinner).show();
|
||||
setTimeout(function () {
|
||||
$(loading_spinner).find('.explorer-loading-spinner-msg').html('Taking a little longer than usual. Please wait...');
|
||||
// only a load that is still running has taken long
|
||||
if ( is_current() && $(loading_spinner).is(':visible') ) {
|
||||
$(loading_spinner).find('.explorer-loading-spinner-msg').text(i18n('taking_longer_than_usual'));
|
||||
}
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
|
||||
@@ -98,14 +102,16 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
return;
|
||||
}
|
||||
if ( el_window ) {
|
||||
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
|
||||
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
// data-name
|
||||
$(el_window).attr('data-name', html_encode(fsentry.name));
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
$(el_window).attr('data-path', html_encode(container_path));
|
||||
$(el_window).attr('data-path', container_path);
|
||||
$(el_window).find('.window-navbar-path-input').val(container_path);
|
||||
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
|
||||
}
|
||||
@@ -120,6 +126,8 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The listing request below reports the failure to the user.
|
||||
});
|
||||
|
||||
// is_directoryPicker
|
||||
@@ -135,6 +143,8 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
|
||||
// remove all existing items
|
||||
$(el_item_container).find('.item').removeItems();
|
||||
// removing the last row shows the empty message, which is wrong while the folder is loading
|
||||
$(empty_message).hide();
|
||||
|
||||
// get items with subdomains/workers included to avoid per-item stat calls
|
||||
const entries_promise = is_shared_view
|
||||
@@ -159,6 +169,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
shared_by: share.issuer,
|
||||
owner: share.owner,
|
||||
holder_team: share.holderTeam,
|
||||
also_team_share: share.alsoHeldByTeam,
|
||||
metadata: '',
|
||||
})))
|
||||
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
|
||||
@@ -264,6 +275,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
shared_by: fsentry.shared_by,
|
||||
is_shared: fsentry.is_shared,
|
||||
is_team_share: !!fsentry.holder_team,
|
||||
is_also_team_share: fsentry.also_team_share === true,
|
||||
owner: fsentry.owner?.username ?? fsentry.owner,
|
||||
});
|
||||
}
|
||||
@@ -350,6 +362,11 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
$(error_message).html(`Failed to load directory${ html_encode((e && e.message ? `: ${ e.message}` : ''))}`);
|
||||
$(error_message).show();
|
||||
|
||||
// the previous folder's items are gone, so its count must not linger
|
||||
if ( el_window ) {
|
||||
window.update_explorer_footer_item_count(el_window);
|
||||
}
|
||||
|
||||
// Call onComplete callback even in error case, since the "loading" is technically complete
|
||||
if ( options.onComplete && typeof options.onComplete === 'function' ) {
|
||||
options.onComplete();
|
||||
|
||||
@@ -107,6 +107,31 @@ describe('refresh_item_container in the Shared view', () => {
|
||||
is_team_share: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a direct share the team holds too', async () => {
|
||||
list_all_shared.mockResolvedValue([
|
||||
{
|
||||
entryUid: 'u-1',
|
||||
name: 'a.txt',
|
||||
path: '/bob/u-1/a.txt',
|
||||
isDir: false,
|
||||
mode: 'read',
|
||||
issuer: 'bob',
|
||||
owner: 'bob',
|
||||
alsoHeldByTeam: true,
|
||||
},
|
||||
]);
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container" data-path="/alice/Shared"></div></div>';
|
||||
|
||||
refresh_item_container($('.item-container'));
|
||||
|
||||
await vi.waitFor(() => expect(UIItem).toHaveBeenCalledTimes(1));
|
||||
expect(UIItem.mock.calls[0][0]).toMatchObject({
|
||||
is_team_share: false,
|
||||
is_also_team_share: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
@@ -167,6 +192,15 @@ describe('refresh_item_container after the window moves to another folder', () =
|
||||
expect($('.window').attr('data-uid')).toBe('pictures');
|
||||
});
|
||||
|
||||
it('stores a folder name holding HTML characters on the window as it is', async () => {
|
||||
navigate('/alice/R&D');
|
||||
|
||||
pending.stat['/alice/R&D'].resolve({ id: 'rd', name: 'R&D' });
|
||||
await vi.waitFor(() => expect($('.window').attr('data-uid')).toBe('rd'));
|
||||
|
||||
expect($('.window').attr('data-name')).toBe('R&D');
|
||||
});
|
||||
|
||||
it('drops a listing that arrives just before the window moves on', async () => {
|
||||
vi.useFakeTimers();
|
||||
navigate('/alice/Documents');
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
// place in these dialogs; a row already set to one is shown as-is.
|
||||
export const MODES = ['read', 'write', 'manage'];
|
||||
|
||||
// Every mode the API grants, weakest first. Each one implies those before it.
|
||||
const STRENGTH = ['see', 'list', 'read', 'write', 'manage'];
|
||||
|
||||
/**
|
||||
* The stronger of two access modes. `held` wins a tie, and a mode outside the
|
||||
* API's five never beats a known one.
|
||||
*
|
||||
* @param {string} held
|
||||
* @param {string} other
|
||||
* @returns {string}
|
||||
*/
|
||||
export const strongerMode = (held, other) =>
|
||||
STRENGTH.indexOf(other) > STRENGTH.indexOf(held) ? other : held;
|
||||
|
||||
/**
|
||||
* Human-readable label for an access mode, ready to drop into HTML.
|
||||
*
|
||||
|
||||
@@ -6,12 +6,12 @@ import { decode, encode } from 'html-entities';
|
||||
globalThis.window = globalThis.window ?? {};
|
||||
globalThis.html_encode = (str) => encode(str);
|
||||
|
||||
let MODES, mode_label, options_for;
|
||||
let MODES, mode_label, options_for, strongerMode;
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('../i18n/i18n.js'); // installs window.i18n
|
||||
globalThis.i18n = window.i18n;
|
||||
({ MODES, mode_label, options_for } = await import('./shareModes.js'));
|
||||
({ MODES, mode_label, options_for, strongerMode } = await import('./shareModes.js'));
|
||||
});
|
||||
|
||||
// What the browser renders for a fragment of HTML-safe markup.
|
||||
@@ -41,6 +41,21 @@ describe('mode_label', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('strongerMode', () => {
|
||||
it('ranks all five API modes', () => {
|
||||
const modes = ['see', 'list', 'read', 'write', 'manage'];
|
||||
for ( let i = 1; i < modes.length; i++ ) {
|
||||
expect(strongerMode(modes[i - 1], modes[i])).toBe(modes[i]);
|
||||
expect(strongerMode(modes[i], modes[i - 1])).toBe(modes[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('never lets an unrecognized mode beat a known one', () => {
|
||||
expect(strongerMode('see', 'owner')).toBe('see');
|
||||
expect(strongerMode('owner', 'see')).toBe('see');
|
||||
});
|
||||
});
|
||||
|
||||
describe('options_for', () => {
|
||||
const values = (html) => [...html.matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import list_all_shared from './listAllShared.js';
|
||||
import { is_owned_by_me } from './pathOwner.js';
|
||||
import { strongerMode } from './shareModes.js';
|
||||
import { is_share_root } from './sharePaths.js';
|
||||
|
||||
// What we hold on each shared root, by path: `{ mode, name }`. A `readdir`
|
||||
@@ -29,13 +30,16 @@ let inflight = null;
|
||||
|
||||
/**
|
||||
* Record what a Shared listing reported. Replaces rather than merges, so a
|
||||
* share withdrawn elsewhere does not linger.
|
||||
* share withdrawn elsewhere does not linger. Grants add up, so a path listed
|
||||
* more than once keeps its strongest mode.
|
||||
*/
|
||||
export const remember_shared_roots = (shares) => {
|
||||
roots.clear();
|
||||
for ( const share of shares ) {
|
||||
if ( ! share?.path || ! share?.mode ) continue;
|
||||
roots.set(share.path, { mode: share.mode, name: share.name });
|
||||
const held = roots.get(share.path);
|
||||
const mode = held ? strongerMode(held.mode, share.mode) : share.mode;
|
||||
roots.set(share.path, { mode, name: share.name });
|
||||
}
|
||||
loaded = true;
|
||||
};
|
||||
|
||||
@@ -64,6 +64,19 @@ describe('shared_access', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the strongest mode when a path is granted more than once', async () => {
|
||||
remember_shared_roots([
|
||||
{ path: `/jf/${BUDGET}/Budget`, mode: 'manage' },
|
||||
{ path: `/jf/${BUDGET}/Budget`, mode: 'read' },
|
||||
{ path: `/jf/${REPORT}/report.pdf`, mode: 'read' },
|
||||
{ path: `/jf/${REPORT}/report.pdf`, mode: 'write' },
|
||||
]);
|
||||
expect(await shared_mode_for(`/jf/${BUDGET}/Budget`)).toBe('manage');
|
||||
expect(await shared_mode_for(`/jf/${REPORT}/report.pdf`)).toBe('write');
|
||||
expect(await can_share(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
|
||||
expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing outside every shared root', async () => {
|
||||
expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { atPath } from './pathElements.js';
|
||||
|
||||
/**
|
||||
* Whether a `getShares()` list means this item is shared.
|
||||
*
|
||||
@@ -33,10 +35,7 @@ export const has_direct_share = (shares) =>
|
||||
* 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').filter(function() {
|
||||
return ($(this).attr('data-path') || '').toLowerCase() === path.toLowerCase();
|
||||
});
|
||||
const $items = atPath('.item', path);
|
||||
$items.attr('data-is_shared', is_shared ? 1 : 0);
|
||||
$items
|
||||
.find('.item-shared-marker')
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { jwtPayload } from './jwtPayload.js';
|
||||
|
||||
const UUID_HEX = /^[0-9a-f]{32}$/;
|
||||
|
||||
/** @param {unknown} uuid */
|
||||
const uuidHex = (uuid) => {
|
||||
if ( typeof uuid !== 'string' ) return null;
|
||||
const hex = uuid.replace(/-/g, '').toLowerCase();
|
||||
return UUID_HEX.test(hex) ? hex : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The uuid of the user a session token was issued to. The token carries it as
|
||||
* the `uu` claim: the uuid's 16 bytes in base64, or the dashed uuid itself.
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {string | null} dashless lowercase hex, or null when the token doesn't say
|
||||
*/
|
||||
const tokenUserHex = (token) => {
|
||||
const claim = jwtPayload(token)?.uu;
|
||||
if ( typeof claim !== 'string' || !claim ) return null;
|
||||
if ( claim.includes('-') ) return uuidHex(claim);
|
||||
try {
|
||||
const bytes = atob(claim);
|
||||
let hex = '';
|
||||
for ( let i = 0; i < bytes.length; i++ ) {
|
||||
hex += bytes.charCodeAt(i).toString(16).padStart(2, '0');
|
||||
}
|
||||
return UUID_HEX.test(hex) ? hex : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a session token was issued to a user other than `user`. False when
|
||||
* either side can't be read, so an unreadable token is never taken for a switch.
|
||||
*
|
||||
* @param {string} token
|
||||
* @param {{ uuid?: string } | null | undefined} user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTokenForAnotherUser = (token, user) => {
|
||||
const tokenUser = tokenUserHex(token);
|
||||
const currentUser = uuidHex(user?.uuid);
|
||||
return !!tokenUser && !!currentUser && tokenUser !== currentUser;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTokenForAnotherUser } from './tokenUser.js';
|
||||
|
||||
const ALICE = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
const BOB = 'a1b2c3d4-0000-4000-8000-00000000beef';
|
||||
|
||||
const jwtWith = (claims) =>
|
||||
`h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`;
|
||||
// The claim as the server writes it: the uuid's bytes in base64.
|
||||
const tokenFor = (uuid) =>
|
||||
jwtWith({ uu: Buffer.from(uuid.replace(/-/g, ''), 'hex').toString('base64') });
|
||||
|
||||
describe('isTokenForAnotherUser', () => {
|
||||
it('is true for a token issued to a different user', () => {
|
||||
expect(isTokenForAnotherUser(tokenFor(BOB), { uuid: ALICE })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a token issued to the same user', () => {
|
||||
expect(isTokenForAnotherUser(tokenFor(ALICE), { uuid: ALICE })).toBe(false);
|
||||
});
|
||||
|
||||
it('compares uuids regardless of letter case', () => {
|
||||
expect(isTokenForAnotherUser(tokenFor(ALICE), { uuid: ALICE.toUpperCase() })).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a claim that holds the dashed uuid itself', () => {
|
||||
expect(isTokenForAnotherUser(jwtWith({ uu: BOB }), { uuid: ALICE })).toBe(true);
|
||||
expect(isTokenForAnotherUser(jwtWith({ uu: ALICE }), { uuid: ALICE })).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when no user is signed in here', () => {
|
||||
expect(isTokenForAnotherUser(tokenFor(BOB), null)).toBe(false);
|
||||
expect(isTokenForAnotherUser(tokenFor(BOB), undefined)).toBe(false);
|
||||
expect(isTokenForAnotherUser(tokenFor(BOB), {})).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the token does not name a user', () => {
|
||||
for ( const token of [
|
||||
jwtWith({}),
|
||||
jwtWith({ uu: '' }),
|
||||
jwtWith({ uu: 42 }),
|
||||
jwtWith({ uu: 'AAAA' }),
|
||||
jwtWith({ uu: '!!not base64!!' }),
|
||||
'not-a-jwt',
|
||||
'',
|
||||
] ) {
|
||||
expect(isTokenForAnotherUser(token, { uuid: ALICE })).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1067,6 +1067,7 @@ const en = {
|
||||
'window_click_to_go_back': 'Click to go back.',
|
||||
'window_click_to_go_forward': 'Click to go forward.',
|
||||
'window_click_to_go_up': 'Click to go one directory up.',
|
||||
'window_click_to_change_layout': 'Click to change the layout.',
|
||||
'window_title_public': 'Public',
|
||||
'window_title_videos': 'Videos',
|
||||
'window_title_pictures': 'Pictures',
|
||||
|
||||
@@ -69,6 +69,51 @@ describe('the popup token hand-off in postAuthActions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the requests a sign-in popup makes after an account is picked', () => {
|
||||
const postAuthActions = src.slice(
|
||||
src.indexOf('const postAuthActions = async'),
|
||||
src.indexOf('window.initgui = async'),
|
||||
);
|
||||
|
||||
it('matches the early exchange before every exchange in postAuthActions', () => {
|
||||
const exchanges = postAuthActions.split('await window.getUserAppToken(origin)').length - 1;
|
||||
const matched = postAuthActions.split(
|
||||
'reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??',
|
||||
).length - 1;
|
||||
expect(exchanges).toBe(2);
|
||||
expect(matched).toBe(2);
|
||||
expect(postAuthActions.split('getUserAppToken(').length - 1).toBe(2);
|
||||
});
|
||||
|
||||
it('records every early exchange, so none is repeated unmatched', () => {
|
||||
const boot = src.slice(src.indexOf('window.initgui = async'));
|
||||
const sdkLogin = boot.slice(
|
||||
boot.indexOf('// show signup window'),
|
||||
boot.indexOf('window.popup_signin_consent = !!picked_a_user_for_sdk_login;'),
|
||||
);
|
||||
expect(sdkLogin.split('await exchangeUserAppTokenEarly();').length - 1).toBe(3);
|
||||
expect(sdkLogin).not.toContain('getUserAppToken(');
|
||||
});
|
||||
|
||||
it('skips the relationship check only for a picked account', () => {
|
||||
// Signup in the popup sets consent without a pick, and still needs
|
||||
// the check to keep a second account picker away.
|
||||
const check = src.indexOf('await window.checkUserSiteRelationship(');
|
||||
const guard = src.lastIndexOf('if (', check);
|
||||
expect(src.slice(guard, check)).toContain('if (!picked_a_user_for_sdk_login) {');
|
||||
expect(src.slice(guard, check)).not.toContain('popup_signin_consent');
|
||||
});
|
||||
|
||||
it('reuses the early whoami only through reusablePopupUser', () => {
|
||||
const reuse = src.indexOf('whoami = popup_whoami.user;');
|
||||
expect(reuse).toBeGreaterThan(-1);
|
||||
expect(src.split('popup_whoami.user').length - 1).toBe(1);
|
||||
const guard = src.slice(src.lastIndexOf('if (', reuse), reuse);
|
||||
expect(guard).toContain('picked_a_user_for_sdk_login &&');
|
||||
expect(guard).toContain('reusablePopupUser(popup_whoami, {');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the permission popup and browser push', () => {
|
||||
const postAuthActions = src.slice(
|
||||
src.indexOf('const postAuthActions = async'),
|
||||
|
||||
+59
-9
@@ -112,6 +112,8 @@ import {
|
||||
relationshipIsConsent,
|
||||
relaysTokenAtBoot,
|
||||
resolveOpenerOrigin,
|
||||
reusablePopupUser,
|
||||
reusableTokenExchange,
|
||||
runsUserAppTokenExchange,
|
||||
} from './util/popupAuth.js';
|
||||
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
|
||||
@@ -161,6 +163,21 @@ const follow_share_recipient = async (action) => {
|
||||
}));
|
||||
};
|
||||
|
||||
// A sign-in popup exchanges as soon as the user has chosen an account, and
|
||||
// `postAuthActions` hands over that result instead of exchanging again.
|
||||
let early_token_exchange = null;
|
||||
const exchangeUserAppTokenEarly = async () => {
|
||||
const authToken = window.auth_token;
|
||||
const data = await window.getUserAppToken(window.openerOrigin);
|
||||
let origin = null;
|
||||
try {
|
||||
origin = new URL(window.openerOrigin).origin;
|
||||
} catch (e) {
|
||||
// No origin to match on, so the result is not reused.
|
||||
}
|
||||
early_token_exchange = { origin, authToken, data };
|
||||
};
|
||||
|
||||
const postAuthActions = async (action) => {
|
||||
if ( await follow_share_recipient(action) ) return;
|
||||
// Set when a popup's user-app token exchange fails. The exchange is what
|
||||
@@ -440,7 +457,10 @@ const postAuthActions = async (action) => {
|
||||
|
||||
if (isolated) {
|
||||
try {
|
||||
const data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
|
||||
const origin = new URL(window.openerOrigin).origin;
|
||||
const data =
|
||||
reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??
|
||||
await window.getUserAppToken(origin);
|
||||
// Same two failure modes the postMessage path below guards
|
||||
// against: `getUserAppToken` reports a network failure by
|
||||
// returning null, and an HTTP failure (a blocked origin, an
|
||||
@@ -484,7 +504,10 @@ const postAuthActions = async (action) => {
|
||||
// Its failure carries `token: null`, which the SDK sets too.
|
||||
const deliver_token_to_opener = deliversTokenAtBoot(action);
|
||||
try {
|
||||
let data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
|
||||
const origin = new URL(window.openerOrigin).origin;
|
||||
let data =
|
||||
reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??
|
||||
await window.getUserAppToken(origin);
|
||||
// `getUserAppToken` reports a network failure by returning
|
||||
// null, and an HTTP failure (a blocked origin, a 5xx) by
|
||||
// returning the parsed *error* body — which is truthy but
|
||||
@@ -1514,6 +1537,8 @@ window.initgui = async function (options) {
|
||||
let picked_a_user_for_sdk_login = false;
|
||||
// Whether a popup put an account picker in front of the user.
|
||||
let showed_account_picker = false;
|
||||
// The `/whoami` a sign-in popup read before its account picker.
|
||||
let popup_whoami = null;
|
||||
|
||||
// update SDK if auth_token is different from the one in the SDK
|
||||
if (window.auth_token && puter.authToken !== window.auth_token) {
|
||||
@@ -1868,7 +1893,7 @@ window.initgui = async function (options) {
|
||||
// Completing signup in a sign-in popup is the user asking to
|
||||
// be signed in to the opener.
|
||||
window.popup_signin_consent = true;
|
||||
await window.getUserAppToken(window.openerOrigin);
|
||||
await exchangeUserAppTokenEarly();
|
||||
}
|
||||
} else if (
|
||||
// An action-less popup is a sign-in popup — `postAuthActions`
|
||||
@@ -1892,6 +1917,11 @@ window.initgui = async function (options) {
|
||||
whoami_popup.token || window.auth_token,
|
||||
whoami_popup,
|
||||
);
|
||||
popup_whoami = {
|
||||
user: whoami_popup,
|
||||
authToken: window.auth_token,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
// session/auth errors will be handled further ahead;
|
||||
// let's log the error for now in case a change in state occurred.
|
||||
@@ -1917,7 +1947,7 @@ window.initgui = async function (options) {
|
||||
String(currentUserUuid);
|
||||
if (proofMatchesCurrentUser) {
|
||||
picked_a_user_for_sdk_login = true;
|
||||
await window.getUserAppToken(window.openerOrigin);
|
||||
await exchangeUserAppTokenEarly();
|
||||
} else {
|
||||
// Show session list so user can pick which account to use
|
||||
showed_account_picker = true;
|
||||
@@ -1927,7 +1957,7 @@ window.initgui = async function (options) {
|
||||
});
|
||||
|
||||
if (picked_a_user_for_sdk_login) {
|
||||
await window.getUserAppToken(window.openerOrigin);
|
||||
await exchangeUserAppTokenEarly();
|
||||
}
|
||||
}
|
||||
// Picking an account here *is* the consent to sign the opener in.
|
||||
@@ -2050,10 +2080,14 @@ window.initgui = async function (options) {
|
||||
// if not, we need to ask the user for confirmation before proceeding BUT only if the action is a file-picker action
|
||||
// -------------------------------------------------------------------------------------
|
||||
if (window.embedded_in_popup && window.openerOrigin) {
|
||||
let response = await window.checkUserSiteRelationship(
|
||||
window.openerOrigin,
|
||||
);
|
||||
window.userAppToken = response?.token;
|
||||
// An account picked above already answers both questions below, the
|
||||
// picker and the consent. Signup in the popup is not a pick.
|
||||
if (!picked_a_user_for_sdk_login) {
|
||||
let response = await window.checkUserSiteRelationship(
|
||||
window.openerOrigin,
|
||||
);
|
||||
window.userAppToken = response?.token;
|
||||
}
|
||||
|
||||
// A framed picker or upgrade already holds the framing page's user,
|
||||
// so it doesn't offer to switch.
|
||||
@@ -2410,6 +2444,22 @@ window.initgui = async function (options) {
|
||||
// Authed
|
||||
// -------------------------------------------------------------------------------------
|
||||
if (window.is_auth()) {
|
||||
// A sign-in popup whose user kept the account it opened with, and whose
|
||||
// exchange just proved the session, already has this user's data.
|
||||
if (
|
||||
!whoami &&
|
||||
picked_a_user_for_sdk_login &&
|
||||
reusablePopupUser(popup_whoami, {
|
||||
userUuid: window.user?.uuid,
|
||||
authToken: window.auth_token,
|
||||
exchangedAuthToken: early_token_exchange?.data?.token
|
||||
? early_token_exchange.authToken
|
||||
: null,
|
||||
now: Date.now(),
|
||||
})
|
||||
) {
|
||||
whoami = popup_whoami.user;
|
||||
}
|
||||
// try to get user data using /whoami, only if that data is missing
|
||||
if (!whoami) {
|
||||
try {
|
||||
|
||||
+50
-31
@@ -23,6 +23,25 @@ import launch_app from './helpers/launchApp.js';
|
||||
import open_item from './helpers/openItem.js';
|
||||
import determine_active_container_parent from './helpers/determineActiveContainerParent.js';
|
||||
|
||||
// The item container the user is working in: around or inside the element they
|
||||
// last pressed, or elsewhere in its window. A pressed item that the window has
|
||||
// since navigated away from leaves only the container it was in.
|
||||
const selection_container = () => {
|
||||
const $active = $(window.active_element);
|
||||
let $container = $active.closest('.item-container');
|
||||
if ( $container.length === 0 ) {
|
||||
$container = $active.find('.item-container');
|
||||
}
|
||||
if ( $container.length === 0 ) {
|
||||
$container = $active.closest('.window').find('.item-container');
|
||||
}
|
||||
if ( $container.length === 0 && window.active_element && !document.contains(window.active_element)
|
||||
&& document.contains(window.active_item_container) ) {
|
||||
$container = $(window.active_item_container);
|
||||
}
|
||||
return $container;
|
||||
};
|
||||
|
||||
$(document).bind('keydown', async function (e) {
|
||||
const focused_el = document.activeElement;
|
||||
//-----------------------------------------------------------------------------
|
||||
@@ -290,13 +309,14 @@ $(document).bind('keydown', async function (e) {
|
||||
if ( selected.length === 1 ) {
|
||||
return selected.get(0);
|
||||
}
|
||||
if ( selected.length > 1 && window.latest_selected_item ) {
|
||||
// only an item that is still selected in this container can anchor the move
|
||||
if ( selected.is(window.latest_selected_item) ) {
|
||||
return window.latest_selected_item;
|
||||
}
|
||||
if ( window.active_element && $(window.active_element).hasClass('item') ) {
|
||||
if ( selected.is(window.active_element) ) {
|
||||
return window.active_element;
|
||||
}
|
||||
return $(window.active_item_container).find('.item').get(0);
|
||||
return selected.get(-1);
|
||||
}
|
||||
|
||||
function findNeighbor (current, direction) {
|
||||
@@ -363,18 +383,19 @@ $(document).bind('keydown', async function (e) {
|
||||
// select first item if none are already selected
|
||||
const selected = $(window.active_item_container).find('.item-selected');
|
||||
if ( selected.length === 0 ) {
|
||||
const first = $(window.active_item_container).find('.item').get(0);
|
||||
const first = $(window.active_item_container).find('.item:not(.item-hidden)').get(0);
|
||||
if ( first ) {
|
||||
$(first).addClass('item-selected');
|
||||
window.active_element = first;
|
||||
window.latest_selected_item = first;
|
||||
first.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// virtual grid layout to determine item layout and next items
|
||||
const items = Array.from($(window.active_item_container).find('.item'));
|
||||
const items = Array.from($(window.active_item_container).find('.item:not(.item-hidden)'));
|
||||
const grid = items.map(item => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
return {
|
||||
@@ -393,6 +414,7 @@ $(document).bind('keydown', async function (e) {
|
||||
|
||||
const currentEl = getActiveItem();
|
||||
const current = grid.find(i => i.el === currentEl);
|
||||
if ( ! current ) return;
|
||||
const next = findNeighbor(current, dir);
|
||||
|
||||
// apply new selection(s)
|
||||
@@ -409,6 +431,7 @@ $(document).bind('keydown', async function (e) {
|
||||
$(next.el).addClass('item-selected');
|
||||
}
|
||||
next.el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------
|
||||
@@ -500,14 +523,15 @@ $(document).bind('keydown', async function (e) {
|
||||
// Shift+Delete (win)/ option+command+delete (Mac) key pressed
|
||||
// Permanent delete bypassing trash after alert
|
||||
//-----------------------------------------------------------------------
|
||||
if ( (e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8) ) {
|
||||
let $selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
|
||||
if ( ((e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8)) && !$(focused_el).is('input, textarea') ) {
|
||||
let $selected_items = selection_container().find('.item-selected');
|
||||
if ( $selected_items.length > 0 ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('delete'),
|
||||
value: 'delete',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
@@ -515,7 +539,7 @@ $(document).bind('keydown', async function (e) {
|
||||
},
|
||||
],
|
||||
});
|
||||
if ( (alert_resp) === 'Delete' ) {
|
||||
if ( (alert_resp) === 'delete' ) {
|
||||
for ( let index = 0; index < $selected_items.length; index++ ) {
|
||||
const element = $selected_items[index];
|
||||
await window.delete_item(element);
|
||||
@@ -528,15 +552,16 @@ $(document).bind('keydown', async function (e) {
|
||||
// Delete (win)/ ctrl+delete (Mac) / cmd+delete (Mac) key pressed
|
||||
// Permanent delete from trash after alert or move to trash
|
||||
//-----------------------------------------------------------------------
|
||||
if ( e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey)) ) {
|
||||
if ( (e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey))) && !$(focused_el).is('input, textarea') ) {
|
||||
// permanent delete?
|
||||
let $selected_items = $(window.active_element).closest('.item-container').find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
|
||||
let $selected_items = selection_container().find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
|
||||
if ( $selected_items.length > 0 ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('delete'),
|
||||
value: 'delete',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
@@ -544,7 +569,7 @@ $(document).bind('keydown', async function (e) {
|
||||
},
|
||||
],
|
||||
});
|
||||
if ( (alert_resp) === 'Delete' ) {
|
||||
if ( (alert_resp) === 'delete' ) {
|
||||
for ( let index = 0; index < $selected_items.length; index++ ) {
|
||||
const element = $selected_items[index];
|
||||
await window.delete_item(element);
|
||||
@@ -554,7 +579,7 @@ $(document).bind('keydown', async function (e) {
|
||||
}
|
||||
// regular delete?
|
||||
else {
|
||||
$selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
|
||||
$selected_items = selection_container().find('.item-selected');
|
||||
if ( $selected_items.length > 0 ) {
|
||||
// Only delete the items if we're not renaming one.
|
||||
if ( $selected_items.children('.item-name-editor-active').length === 0 ) {
|
||||
@@ -569,6 +594,11 @@ $(document).bind('keydown', async function (e) {
|
||||
// A letter or number is pressed and there is no context menu open: search items by name
|
||||
//-----------------------------------------------------------------------
|
||||
if ( !e.ctrlKey && !e.metaKey && !$(focused_el).is('input') && !$(focused_el).is('textarea') && $('.context-menu').length === 0 ) {
|
||||
// Shift, arrows, Enter and other named keys are not part of a name
|
||||
if ( e.key?.length !== 1 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( window.keypress_item_seach_term !== '' )
|
||||
{
|
||||
clearTimeout(window.keypress_item_seach_buffer_timeout);
|
||||
@@ -589,7 +619,7 @@ $(document).bind('keydown', async function (e) {
|
||||
}
|
||||
|
||||
// search for matches
|
||||
let haystack = $(window.active_item_container).find('.item').not('.item-disabled');
|
||||
let haystack = $(window.active_item_container).find('.item').not('.item-disabled, .item-hidden');
|
||||
for ( let j = 0; j < haystack.length; j++ ) {
|
||||
if ( $(haystack[j]).attr('data-name').toLowerCase().startsWith(window.keypress_item_seach_term) ) {
|
||||
matches.push(haystack[j]);
|
||||
@@ -613,7 +643,7 @@ $(document).bind('keydown', async function (e) {
|
||||
// select matching item
|
||||
$(matches[0]).not('.item-disabled').addClass('item-selected');
|
||||
matches[0].scrollIntoView(false);
|
||||
window.update_explorer_footer_selected_items_count($(window.active_element).closest('.window'));
|
||||
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -687,11 +717,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
// ctrl/command + a, will select all items on desktop and windows
|
||||
//-----------------------------------------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 65 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
let $parent_container = $(window.active_element).closest('.item-container');
|
||||
if ( $parent_container.length === 0 )
|
||||
{
|
||||
$parent_container = $(window.active_element).find('.item-container');
|
||||
}
|
||||
const $parent_container = selection_container();
|
||||
|
||||
if ( $parent_container.attr('data-multiselectable') === 'false' )
|
||||
{
|
||||
@@ -699,7 +725,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
}
|
||||
|
||||
if ( $parent_container ) {
|
||||
$($parent_container).find('.item').not('.item-disabled').addClass('item-selected');
|
||||
$($parent_container).find('.item').not('.item-disabled, .item-hidden').addClass('item-selected');
|
||||
window.update_explorer_footer_selected_items_count($parent_container.closest('.window'));
|
||||
}
|
||||
|
||||
@@ -732,11 +758,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
!$(focused_el).is('textarea') ) {
|
||||
let $selected_items;
|
||||
|
||||
let parent_container = $(window.active_element).closest('.item-container');
|
||||
if ( parent_container.length === 0 )
|
||||
{
|
||||
parent_container = $(window.active_element).find('.item-container');
|
||||
}
|
||||
const parent_container = selection_container();
|
||||
|
||||
if ( parent_container !== null ) {
|
||||
$selected_items = $(parent_container).find('.item-selected');
|
||||
@@ -761,11 +783,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
//-----------------------------------------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 88 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
let $selected_items;
|
||||
let parent_container = $(window.active_element).closest('.item-container');
|
||||
if ( parent_container.length === 0 )
|
||||
{
|
||||
parent_container = $(window.active_element).find('.item-container');
|
||||
}
|
||||
const parent_container = selection_container();
|
||||
|
||||
if ( parent_container !== null ) {
|
||||
$selected_items = $(parent_container).find('.item-selected');
|
||||
@@ -863,7 +881,8 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
// Paste
|
||||
// ctrl/command + v, will paste items from the clipboard to the active element
|
||||
//----------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
// Once per press: releasing V with Ctrl still held would paste a second time.
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && e.type === 'keydown' && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
let target_path, target_el;
|
||||
|
||||
// continue only if there is something in the clipboard
|
||||
|
||||
@@ -135,6 +135,65 @@ export const resolveOpenerOrigin = async ({
|
||||
export const relationshipIsConsent = ({ hasRelationship, showedAccountPicker }) =>
|
||||
hasRelationship && !showedAccountPicker;
|
||||
|
||||
/**
|
||||
* The result of a popup's early user-app token exchange, when it stands in for
|
||||
* a second one: it returned a token, for the same opener and the same session.
|
||||
*
|
||||
* @param {{
|
||||
* origin: string|null;
|
||||
* authToken: string|null|undefined;
|
||||
* data: { token?: string; app_uid?: string }|null;
|
||||
* }|null} early - The early exchange, or null when none ran.
|
||||
* @param {string} origin - The opener origin the token is needed for.
|
||||
* @param {string|null|undefined} authToken - The session token in use.
|
||||
* @returns {{ token: string; app_uid?: string }|null} null means exchange again.
|
||||
*/
|
||||
export const reusableTokenExchange = (early, origin, authToken) => {
|
||||
if ( !early?.data?.token || !origin || !authToken ) return null;
|
||||
if ( early.origin !== origin || early.authToken !== authToken ) return null;
|
||||
return /** @type {{ token: string; app_uid?: string }} */ (early.data);
|
||||
};
|
||||
|
||||
/** How long a sign-in popup's first `/whoami` stays usable. */
|
||||
export const POPUP_WHOAMI_MAX_AGE_MS = 60_000;
|
||||
|
||||
const VERIFICATION_GATE_FLAGS = [
|
||||
'requires_email_confirmation',
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether the `/whoami` a sign-in popup read before its account picker still
|
||||
* describes the session, so boot can skip reading it again.
|
||||
*
|
||||
* A pending verification gate always gets a fresh read, and so does a session
|
||||
* the token exchange has not just accepted.
|
||||
*
|
||||
* @param {{
|
||||
* user: Record<string, unknown>;
|
||||
* authToken: string|null|undefined;
|
||||
* fetchedAt: number;
|
||||
* }|null} early - The early read, or null when none succeeded.
|
||||
* @param {{
|
||||
* userUuid: string|null|undefined;
|
||||
* authToken: string|null|undefined;
|
||||
* exchangedAuthToken: string|null|undefined;
|
||||
* now: number;
|
||||
* }} session - The signed-in user and token now, and the session token the
|
||||
* early token exchange succeeded with (null when it returned no token).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const reusablePopupUser = (early, { userUuid, authToken, exchangedAuthToken, now }) => {
|
||||
if ( !early?.user?.uuid || !authToken ) return false;
|
||||
if ( early.user.uuid !== userUuid ) return false;
|
||||
if ( early.authToken !== authToken || exchangedAuthToken !== authToken ) return false;
|
||||
if ( VERIFICATION_GATE_FLAGS.some(flag => early.user[flag]) ) return false;
|
||||
const age = now - early.fetchedAt;
|
||||
return age >= 0 && age <= POPUP_WHOAMI_MAX_AGE_MS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a popup running `action` runs the user-app token exchange.
|
||||
*
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
offersFederatedSignInInPopup,
|
||||
relationshipIsConsent,
|
||||
relaysTokenAtBoot,
|
||||
POPUP_WHOAMI_MAX_AGE_MS,
|
||||
resolveOpenerOrigin,
|
||||
reusablePopupUser,
|
||||
reusableTokenExchange,
|
||||
runsUserAppTokenExchange,
|
||||
} from './popupAuth.js';
|
||||
|
||||
@@ -315,3 +318,106 @@ describe('the retired opener_origin gate', () => {
|
||||
expect(popupAuth.trustsOpenerOriginParam).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reusableTokenExchange', () => {
|
||||
const origin = 'https://app.example';
|
||||
const early = {
|
||||
origin,
|
||||
authToken: 'session-a',
|
||||
data: { token: 'app-token', app_uid: 'app-1' },
|
||||
};
|
||||
|
||||
it('returns the early result for the same opener and session', () => {
|
||||
expect(reusableTokenExchange(early, origin, 'session-a')).toBe(early.data);
|
||||
});
|
||||
|
||||
it('exchanges again when no early exchange ran', () => {
|
||||
expect(reusableTokenExchange(null, origin, 'session-a')).toBeNull();
|
||||
});
|
||||
|
||||
it('exchanges again when the early one returned no token', () => {
|
||||
// A network failure is null; an HTTP failure is the error body.
|
||||
for ( const data of [null, { code: 'app_blocked', message: 'no' }, { token: '' }] ) {
|
||||
expect(reusableTokenExchange({ ...early, data }, origin, 'session-a')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('exchanges again after the user switched accounts', () => {
|
||||
expect(reusableTokenExchange(early, origin, 'session-b')).toBeNull();
|
||||
});
|
||||
|
||||
it('exchanges again for another opener', () => {
|
||||
expect(reusableTokenExchange(early, 'https://other.example', 'session-a')).toBeNull();
|
||||
expect(reusableTokenExchange({ ...early, origin: null }, origin, 'session-a')).toBeNull();
|
||||
});
|
||||
|
||||
it('exchanges again without a session token to match on', () => {
|
||||
const unkeyed = { ...early, authToken: undefined };
|
||||
expect(reusableTokenExchange(unkeyed, origin, undefined)).toBeNull();
|
||||
expect(reusableTokenExchange({ ...early, authToken: null }, origin, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reusablePopupUser', () => {
|
||||
const early = {
|
||||
user: { uuid: 'user-a', username: 'a' },
|
||||
authToken: 'session-a',
|
||||
fetchedAt: 1_000,
|
||||
};
|
||||
const session = {
|
||||
userUuid: 'user-a',
|
||||
authToken: 'session-a',
|
||||
exchangedAuthToken: 'session-a',
|
||||
now: 2_000,
|
||||
};
|
||||
|
||||
it('reuses the early read for the same user on a session the exchange accepted', () => {
|
||||
expect(reusablePopupUser(early, session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads again when the early read failed', () => {
|
||||
expect(reusablePopupUser(null, session)).toBe(false);
|
||||
expect(reusablePopupUser({ ...early, user: {} }, session)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads again after the user switched accounts', () => {
|
||||
expect(reusablePopupUser(early, { ...session, userUuid: 'user-b' })).toBe(false);
|
||||
expect(reusablePopupUser(early, {
|
||||
...session,
|
||||
authToken: 'session-b',
|
||||
exchangedAuthToken: 'session-b',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('reads again when the exchange did not accept this session', () => {
|
||||
// A dead session has to reach the read that signs the user out.
|
||||
expect(reusablePopupUser(early, { ...session, exchangedAuthToken: null })).toBe(false);
|
||||
expect(reusablePopupUser(early, { ...session, exchangedAuthToken: 'session-b' })).toBe(false);
|
||||
});
|
||||
|
||||
it('reads again when a verification gate is pending', () => {
|
||||
for ( const flag of [
|
||||
'requires_email_confirmation',
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
] ) {
|
||||
const gated = { ...early, user: { ...early.user, [flag]: true } };
|
||||
expect(reusablePopupUser(gated, session)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads again once the early read is too old', () => {
|
||||
const at = (now) => reusablePopupUser(early, { ...session, now });
|
||||
expect(at(early.fetchedAt + POPUP_WHOAMI_MAX_AGE_MS)).toBe(true);
|
||||
expect(at(early.fetchedAt + POPUP_WHOAMI_MAX_AGE_MS + 1)).toBe(false);
|
||||
expect(at(early.fetchedAt - 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads again without a session token', () => {
|
||||
expect(reusablePopupUser(
|
||||
{ ...early, authToken: undefined },
|
||||
{ ...session, authToken: undefined, exchangedAuthToken: undefined },
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,28 @@ export async function waitForPuterReady (page) {
|
||||
await page.waitForFunction(() => !!window.puter?.apps?.create, null, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a second account, the one a first-time visitor is given, and
|
||||
* returns its token and user. Sign-ups are rate limited, so create one per file.
|
||||
*
|
||||
* @param {import('@playwright/test').Browser} browser
|
||||
* @returns {Promise<{ token: string, user: { uuid: string, username: string } }>}
|
||||
*/
|
||||
export async function createVisitorAccount (browser) {
|
||||
const visitorContext = await browser.newContext({
|
||||
storageState: { cookies: [], origins: [] },
|
||||
});
|
||||
const visitorPage = await visitorContext.newPage();
|
||||
await visitorPage.goto('/');
|
||||
await waitForPuterReady(visitorPage);
|
||||
const account = await visitorPage.evaluate(() => ({
|
||||
token: localStorage.getItem('auth_token_v2'),
|
||||
user: JSON.parse(localStorage.getItem('user')),
|
||||
}));
|
||||
await visitorContext.close();
|
||||
return account;
|
||||
}
|
||||
|
||||
async function assertFixtureReachable (fixtureURL) {
|
||||
let res;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createVisitorAccount, waitForPuterReady } from '../helpers/testApp.js';
|
||||
|
||||
// A second account: what a first-time visitor is given. Created once for the
|
||||
// whole file, because sign-ups are rate limited.
|
||||
let visitor;
|
||||
const visitorAccount = (browser) => (visitor ??= createVisitorAccount(browser));
|
||||
|
||||
/**
|
||||
* Every tab shares one stored session, so switching accounts in one tab hands
|
||||
* the others a token for a different account. A tab that kept running would
|
||||
* show its old account's desktop while its requests acted as the new one.
|
||||
*/
|
||||
test.describe('an account switch in another tab', () => {
|
||||
test('reloads this tab as the new account instead of adopting its token', async ({ browser, context, page }) => {
|
||||
const other = await visitorAccount(browser);
|
||||
|
||||
const switchingTab = page;
|
||||
const otherTab = await context.newPage();
|
||||
for ( const tab of [switchingTab, otherTab] ) {
|
||||
await tab.goto('/');
|
||||
await waitForPuterReady(tab);
|
||||
}
|
||||
const firstUser = await otherTab.evaluate(() => window.user.uuid);
|
||||
expect(firstUser).not.toBe(other.user.uuid);
|
||||
|
||||
// Record what this tab holds once it has handled the new token.
|
||||
// Session storage outlives the reload; `__beforeSwitch` doesn't.
|
||||
await otherTab.evaluate(() => {
|
||||
window.__beforeSwitch = true;
|
||||
window.addEventListener('storage', (event) => {
|
||||
if ( event.key !== 'auth_token_v2' || !event.newValue ) return;
|
||||
sessionStorage.setItem('seenOnSwitch', JSON.stringify({
|
||||
adopted: window.auth_token === event.newValue,
|
||||
user: window.user?.uuid,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
await switchingTab.evaluate(
|
||||
({ token, user }) => window.update_auth_data(token, user),
|
||||
other,
|
||||
);
|
||||
|
||||
await otherTab.waitForFunction(
|
||||
(uuid) => !window.__beforeSwitch && window.user?.uuid === uuid,
|
||||
other.user.uuid,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForPuterReady(otherTab);
|
||||
expect(
|
||||
await otherTab.evaluate(() => JSON.parse(sessionStorage.getItem('seenOnSwitch'))),
|
||||
).toEqual({ adopted: false, user: firstUser });
|
||||
|
||||
// Booting renews the token for the same account, which the tab that
|
||||
// switched picks up without reloading.
|
||||
await expect.poll(() => switchingTab.evaluate(() => ({
|
||||
user: window.user?.uuid,
|
||||
current: window.auth_token === localStorage.getItem('auth_token_v2'),
|
||||
}))).toEqual({ user: other.user.uuid, current: true });
|
||||
});
|
||||
|
||||
test('reloads this tab only once the session cookie names the new account', async ({ browser, context, page }) => {
|
||||
const other = await visitorAccount(browser);
|
||||
|
||||
const switchingTab = page;
|
||||
const otherTab = await context.newPage();
|
||||
for ( const tab of [switchingTab, otherTab] ) {
|
||||
await tab.goto('/');
|
||||
await waitForPuterReady(tab);
|
||||
}
|
||||
|
||||
// A tab signs in from the cookie when it boots, so one that reloaded
|
||||
// while the cookie still named the first account would stay on it.
|
||||
await context.route('**/session/sync-cookie', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await route.continue();
|
||||
});
|
||||
await otherTab.evaluate(() => {
|
||||
window.__beforeSwitch = true;
|
||||
});
|
||||
|
||||
await switchingTab.evaluate(
|
||||
({ token, user }) => window.update_auth_data(token, user),
|
||||
other,
|
||||
);
|
||||
|
||||
await otherTab.waitForFunction(
|
||||
(uuid) => !window.__beforeSwitch && window.user?.uuid === uuid,
|
||||
other.user.uuid,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await waitForPuterReady(otherTab);
|
||||
await expect.poll(() => switchingTab.evaluate(() => ({
|
||||
user: window.user?.uuid,
|
||||
current: window.auth_token === localStorage.getItem('auth_token_v2'),
|
||||
storedFor: JSON.parse(localStorage.getItem('user')).uuid,
|
||||
}))).toEqual({ user: other.user.uuid, current: true, storedFor: other.user.uuid });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a token another tab stores during an account switch', () => {
|
||||
test('does not replace the token of the account being switched to', async ({ browser, context, page }) => {
|
||||
const other = await visitorAccount(browser);
|
||||
|
||||
const switchingTab = page;
|
||||
const otherTab = await context.newPage();
|
||||
for ( const tab of [switchingTab, otherTab] ) {
|
||||
await tab.goto('/');
|
||||
await waitForPuterReady(tab);
|
||||
}
|
||||
const firstToken = await switchingTab.evaluate(() => window.auth_token);
|
||||
|
||||
let releaseSync;
|
||||
const syncHeld = new Promise((resolve) => {
|
||||
releaseSync = resolve;
|
||||
});
|
||||
await context.route('**/session/sync-cookie', async (route) => {
|
||||
await syncHeld;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const switched = switchingTab.evaluate(
|
||||
({ token, user }) => window.update_auth_data(token, user),
|
||||
other,
|
||||
);
|
||||
|
||||
// Booting renews the first account's token while the switch waits.
|
||||
// A token issued in the same second as the stored one is identical.
|
||||
await otherTab.waitForTimeout(1100);
|
||||
await otherTab.reload();
|
||||
await expect.poll(() => switchingTab.evaluate(
|
||||
() => localStorage.getItem('auth_token_v2'),
|
||||
)).not.toBe(firstToken);
|
||||
|
||||
releaseSync();
|
||||
await switched;
|
||||
|
||||
expect(await switchingTab.evaluate(() => ({
|
||||
token: window.auth_token,
|
||||
saved: window.logged_in_users.find((known) => known.uuid === window.user.uuid).auth_token,
|
||||
}))).toEqual({ token: other.token, saved: other.token });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { FIXTURE_URL } from '../helpers/testApp.js';
|
||||
import { FIXTURE_URL, createVisitorAccount, waitForPuterReady } from '../helpers/testApp.js';
|
||||
|
||||
const FIXTURE = FIXTURE_URL.replace(
|
||||
'menubar-contextmenu.html',
|
||||
@@ -178,3 +178,116 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('popup sign-in after an account is picked', () => {
|
||||
// The popup's own `/whoami` read carries `icon_size`. puter.js sends a
|
||||
// bare one on every `setAuthToken()`, which the popup does not wait on.
|
||||
const COUNTED = ['/auth/get-user-app-token', '/auth/check-app', '/whoami?icon_size=64'];
|
||||
|
||||
/**
|
||||
* Open a sign-in popup from the fixture, pick the signed-in account, and
|
||||
* report the API requests the popup sent after the pick. With a `session`
|
||||
* the page waits on `/login/wait` for it, as an isolated opener does. With
|
||||
* an `other` account, that one is signed in on the browser too, and picked.
|
||||
*/
|
||||
const pickAccount = async (page, popupQuery, session = null, other = null) => {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => !!window.puter?.authToken, null, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
if ( other ) {
|
||||
await waitForPuterReady(page);
|
||||
await page.evaluate(({ token, user }) => {
|
||||
const known = JSON.parse(localStorage.getItem('logged_in_users'));
|
||||
known.push({ ...user, auth_token: token });
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(known));
|
||||
}, other);
|
||||
}
|
||||
await page.goto(FIXTURE);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent('popup'),
|
||||
page.evaluate(({ query, relaySession }) => {
|
||||
if ( relaySession ) {
|
||||
window.__relayed = fetch(`${puter.APIOrigin}/login/wait`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: relaySession }),
|
||||
}).then(async (response) => (await response.json())?.auth_token);
|
||||
}
|
||||
window.__delivered = null;
|
||||
window.addEventListener('message', (event) => {
|
||||
if ( event.data?.msg === 'puter.token' ) {
|
||||
window.__delivered = event.data;
|
||||
}
|
||||
});
|
||||
window.open(
|
||||
`${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&${query}`,
|
||||
'signin-pick',
|
||||
'width=600,height=700',
|
||||
);
|
||||
}, { query: popupQuery, relaySession: session }),
|
||||
]);
|
||||
|
||||
const accounts = popup.locator('.auth-dialog [data-screen="accounts"] .auth-account');
|
||||
await expect(accounts).toHaveCount(other ? 2 : 1, { timeout: 60_000 });
|
||||
const account = other ? accounts.and(popup.locator(`[data-uuid="${other.user.uuid}"]`)) : accounts;
|
||||
const requests = [];
|
||||
popup.on('request', (request) => {
|
||||
if ( request.method() === 'OPTIONS' ) return;
|
||||
const { pathname, search } = new URL(request.url());
|
||||
if ( COUNTED.includes(pathname + search) ) requests.push(pathname + search);
|
||||
});
|
||||
await Promise.all([popup.waitForEvent('close'), account.click()]);
|
||||
return requests;
|
||||
};
|
||||
|
||||
/** The username the API resolves `token` to, asked from the fixture page. */
|
||||
const usernameFor = (page, token) =>
|
||||
page.evaluate(async (bearer) => {
|
||||
const response = await fetch(`${puter.APIOrigin}/whoami`, {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
});
|
||||
return response.ok ? (await response.json()).username : null;
|
||||
}, token);
|
||||
|
||||
test('hands the opener a working token after one exchange', async ({ page }) => {
|
||||
const requests = await pickAccount(page, 'msg_id=92');
|
||||
|
||||
await page.waitForFunction(() => !!window.__delivered);
|
||||
const delivered = await page.evaluate(() => window.__delivered);
|
||||
expect(delivered.success).toBe(true);
|
||||
expect(delivered.msg_id).toBe('92');
|
||||
expect(delivered.app_uid).toMatch(/^app-/);
|
||||
expect(await usernameFor(page, delivered.token)).toBe(delivered.username);
|
||||
expect(requests).toEqual(['/auth/get-user-app-token']);
|
||||
});
|
||||
|
||||
test('relays a working token to an isolated opener after one exchange', async ({ page }) => {
|
||||
const session = '22222222-3333-4444-8555-aaaaaaaaaaaa';
|
||||
const requests = await pickAccount(
|
||||
page,
|
||||
`cross_origin_isolated=true&signin_session=${session}&msg_id=93`,
|
||||
session,
|
||||
);
|
||||
|
||||
const token = await page.evaluate(() => window.__relayed);
|
||||
expect(await usernameFor(page, token)).toBeTruthy();
|
||||
expect(requests).toEqual(['/auth/get-user-app-token']);
|
||||
});
|
||||
|
||||
test('hands over the picked account when it is not the one the popup opened with', async ({ browser, page }) => {
|
||||
const other = await createVisitorAccount(browser);
|
||||
|
||||
const requests = await pickAccount(page, 'msg_id=94', null, other);
|
||||
|
||||
await page.waitForFunction(() => !!window.__delivered);
|
||||
const delivered = await page.evaluate(() => window.__delivered);
|
||||
expect(delivered.success).toBe(true);
|
||||
expect(delivered.username).toBe(other.user.username);
|
||||
expect(await usernameFor(page, delivered.token)).toBe(other.user.username);
|
||||
// The popup has not read this account yet, so it reads it once.
|
||||
expect(requests).toEqual(['/auth/get-user-app-token', '/whoami?icon_size=64']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user