diff --git a/src/backend/TeamsOptional.test.ts b/src/backend/TeamsOptional.test.ts
index d59cdb91a..f60abe005 100644
--- a/src/backend/TeamsOptional.test.ts
+++ b/src/backend/TeamsOptional.test.ts
@@ -24,9 +24,9 @@ import type { PuterServer } from './server.ts';
import { createTestUser, setupTestServer } from './testUtil.ts';
/**
- * Teams is moving to a private extension, so core has to work when neither the
- * service nor the store is registered. Both are dropped after boot here, which
- * is the shape core will see once the feature ships separately.
+ * Teams is optional, so core has to work when neither the service nor the
+ * store is registered. Both are dropped after boot here, which is the shape
+ * core sees wherever the feature is not installed.
*/
describe('core without a teams registration', () => {
let server: PuterServer;
@@ -48,7 +48,7 @@ describe('core without a teams registration', () => {
// Without this the allowlist check returns before asking Teams.
teams_allowed_email_domains: ['test.local'],
} as never);
- // What the registry looks like with the extension absent.
+ // What the registry looks like where Teams is not installed.
delete (server.services as { team?: unknown }).team;
delete (server.stores as { team?: unknown }).team;
});
diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts
index ed763e9f8..b38a48231 100644
--- a/src/backend/controllers/webdav/WebDAVController.test.ts
+++ b/src/backend/controllers/webdav/WebDAVController.test.ts
@@ -1,5 +1,6 @@
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
+import { installSeatStub } from '../../testFixtures/seatStub.js';
import type { Request, RequestHandler, Response } from 'express';
import { Readable, Writable } from 'node:stream';
import { GetObjectCommand } from '@aws-sdk/client-s3';
@@ -736,9 +737,9 @@ describe('WebDAVController', () => {
otp_enabled: 1,
otp_secret: secret,
});
- const seat = vi
- .spyOn(server.stores.team, 'getOrgSeat')
- .mockResolvedValue({ require_2fa: 1 } as never);
+ // Teams is optional, so the gate's input is stood up directly.
+ installSeatStub(server as never, [id], { require2fa: true });
+ const seat = vi.spyOn(server.stores.team as never, 'getOrgSeat');
try {
const code = new TOTP({
issuer: 'puter.com',
diff --git a/src/backend/core/teams.ts b/src/backend/core/teams.ts
index 296ea88aa..c93a1539d 100644
--- a/src/backend/core/teams.ts
+++ b/src/backend/core/teams.ts
@@ -18,9 +18,9 @@
*/
/**
- * What core asks of Teams, which ships separately. Nothing here is an
- * implementation: a deployment without the feature registers neither, and every
- * caller treats both as absent.
+ * What core asks of Teams. Nothing here is an implementation: the pair is
+ * registered by whatever provides the feature, and a deployment that does not
+ * run it registers neither, so every caller treats both as absent.
*/
/** A team, as much of one as core reads back. */
diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts
index 427935740..95bafc146 100644
--- a/src/backend/services/share/ShareService.test.ts
+++ b/src/backend/services/share/ShareService.test.ts
@@ -17,8 +17,17 @@
* along with this program. If not, see .
*/
+import { installSeatStub } from '../../testFixtures/seatStub.js';
import { v4 as uuidv4 } from 'uuid';
-import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
@@ -1104,103 +1113,6 @@ describe('ShareService', () => {
expect(await canWrite(stranger.actor, file.path)).toBe(false);
});
- it('goes with a file that changes owner, with its other grants, and nothing else is read', async () => {
- const owner = await makeUser();
- paid.add(owner.user.uuid);
- const newOwner = await makeUser();
- const stranger = await makeUser();
- const recipient = await makeUser();
- const member = await makeUser();
- const file = await makeFile(owner.user);
-
- const team = await server.stores.team.create({
- ownerUserId: owner.user.id,
- name: `t-${Math.random().toString(36).slice(2, 9)}`,
- });
- for (const user of [owner.user, member.user]) {
- await server.stores.team.addMember(team.uid, user.id, {
- orgOwned: false,
- });
- }
- for (const recipientSpec of [
- anyone,
- { email: recipient.email },
- { team: team.uid },
- ]) {
- await share(owner.actor, {
- uid: file.uuid,
- recipient: recipientSpec,
- mode: 'read',
- });
- }
- for (const reader of [stranger, recipient, member]) {
- expect(await canRead(reader.actor, file.path)).toBe(true);
- }
-
- await server.stores.fsEntry.updateEntry(file.uuid, {
- userId: newOwner.user.id,
- });
- const moved = await server.stores.fsEntry.getEntryByUuid(file.uuid);
-
- const listByFsentry = vi.spyOn(
- server.stores.share,
- 'listByFsentry',
- );
- const getEntriesByIds = vi.spyOn(
- server.stores.fsEntry,
- 'getEntriesByIds',
- );
- const revokeUserGrants = vi.spyOn(
- server.stores.permission,
- 'deleteUserUserPermsByPermissionPrefixes',
- );
- const revokeGroupGrants = vi.spyOn(
- server.stores.permission,
- 'deleteUserGroupPermsByPermissionPrefixes',
- );
- const deleteRows = vi.spyOn(
- server.stores.share,
- 'deleteByFsentryIds',
- );
- try {
- await server.services.share.onEntryOwnerChanged(moved!);
-
- expect(listByFsentry).not.toHaveBeenCalled();
- expect(getEntriesByIds).not.toHaveBeenCalled();
- const prefixes = [`fs:${file.uuid}`, `manage:fs:${file.uuid}`];
- expect(revokeUserGrants).toHaveBeenCalledWith(prefixes);
- expect(revokeGroupGrants).toHaveBeenCalledWith(prefixes);
- expect(deleteRows).toHaveBeenCalledWith([file.id]);
- // Grants go before the rows that record them.
- expect(
- revokeUserGrants.mock.invocationCallOrder[0]!,
- ).toBeLessThan(revokeGroupGrants.mock.invocationCallOrder[0]!);
- expect(
- revokeGroupGrants.mock.invocationCallOrder[0]!,
- ).toBeLessThan(deleteRows.mock.invocationCallOrder[0]!);
- } finally {
- for (const spy of [
- listByFsentry,
- getEntriesByIds,
- revokeUserGrants,
- revokeGroupGrants,
- deleteRows,
- ]) {
- spy.mockRestore();
- }
- }
-
- expect(
- await server.clients.db.read(
- 'SELECT * FROM `share` WHERE `fsentry_id` = ?',
- [file.id],
- ),
- ).toEqual([]);
- for (const reader of [stranger, recipient, member]) {
- expect(await canRead(reader.actor, file.path)).toBe(false);
- }
- });
-
// A just-moved row can be missing from a lagging replica; a file's
// grants go by the uuid in hand regardless.
it('goes with a file that changes owner even when its row reads back missing', async () => {
@@ -1281,6 +1193,115 @@ describe('ShareService', () => {
expect.objectContaining({ anyone: true, entryUid: file.uuid }),
);
});
+
+ it('goes with a file that changes owner, with its other grants, and nothing else is read', async () => {
+ const owner = await makeUser();
+ paid.add(owner.user.uuid);
+ const newOwner = await makeUser();
+ const stranger = await makeUser();
+ const recipient = await makeUser();
+ const member = await makeUser();
+ const file = await makeFile(owner.user);
+
+ // A group and its share written directly: what this exercises is
+ // the owner change, and teams is optional.
+ const groupUid = uuidv4();
+ await server.clients.db.write(
+ 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, ' +
+ '`name`, `extra`, `metadata`) VALUES (?, ?, ?, ?, ?, ?)',
+ [groupUid, owner.user.id, 'team', 'Owner change', '{}', '{}'],
+ );
+ const [group] = (await server.clients.db.read(
+ 'SELECT id FROM `group` WHERE uid = ?',
+ [groupUid],
+ )) as { id: number }[];
+ for (const user of [owner.user, member.user]) {
+ await server.clients.db.write(
+ 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) ' +
+ 'VALUES (?, ?)',
+ [user.id, group.id],
+ );
+ }
+ for (const recipientSpec of [anyone, { email: recipient.email }]) {
+ await share(owner.actor, {
+ uid: file.uuid,
+ recipient: recipientSpec,
+ mode: 'read',
+ });
+ }
+ await server.stores.share.upsertActiveGroup({
+ issuerUserId: owner.user.id,
+ holderGroupId: group.id,
+ fsentryId: file.id,
+ mode: 'read',
+ });
+ for (const reader of [stranger, recipient, member]) {
+ expect(await canRead(reader.actor, file.path)).toBe(true);
+ }
+
+ await server.stores.fsEntry.updateEntry(file.uuid, {
+ userId: newOwner.user.id,
+ });
+ const moved = await server.stores.fsEntry.getEntryByUuid(file.uuid);
+
+ const listByFsentry = vi.spyOn(
+ server.stores.share,
+ 'listByFsentry',
+ );
+ const getEntriesByIds = vi.spyOn(
+ server.stores.fsEntry,
+ 'getEntriesByIds',
+ );
+ const revokeUserGrants = vi.spyOn(
+ server.stores.permission,
+ 'deleteUserUserPermsByPermissionPrefixes',
+ );
+ const revokeGroupGrants = vi.spyOn(
+ server.stores.permission,
+ 'deleteUserGroupPermsByPermissionPrefixes',
+ );
+ const deleteRows = vi.spyOn(
+ server.stores.share,
+ 'deleteByFsentryIds',
+ );
+ try {
+ await server.services.share.onEntryOwnerChanged(moved!);
+
+ expect(listByFsentry).not.toHaveBeenCalled();
+ expect(getEntriesByIds).not.toHaveBeenCalled();
+ const prefixes = [`fs:${file.uuid}`, `manage:fs:${file.uuid}`];
+ expect(revokeUserGrants).toHaveBeenCalledWith(prefixes);
+ expect(revokeGroupGrants).toHaveBeenCalledWith(prefixes);
+ expect(deleteRows).toHaveBeenCalledWith([file.id]);
+ // Grants go before the rows that record them.
+ expect(
+ revokeUserGrants.mock.invocationCallOrder[0]!,
+ ).toBeLessThan(revokeGroupGrants.mock.invocationCallOrder[0]!);
+ expect(
+ revokeGroupGrants.mock.invocationCallOrder[0]!,
+ ).toBeLessThan(deleteRows.mock.invocationCallOrder[0]!);
+ } finally {
+ for (const spy of [
+ listByFsentry,
+ getEntriesByIds,
+ revokeUserGrants,
+ revokeGroupGrants,
+ deleteRows,
+ ]) {
+ spy.mockRestore();
+ }
+ }
+
+ expect(
+ await server.clients.db.read(
+ 'SELECT * FROM `share` WHERE `fsentry_id` = ?',
+ [file.id],
+ ),
+ ).toEqual([]);
+ for (const reader of [stranger, recipient, member]) {
+ expect(await canRead(reader.actor, file.path)).toBe(false);
+ }
+ });
});
describe('the listing flag', () => {
@@ -6167,6 +6188,11 @@ describe('ShareService', () => {
describe('listings read independent parts together', () => {
const failure = (what: string) => new Error(`${what} failed`);
+ // These spy on the team store; Teams is optional, so stand one up.
+ beforeEach(() => {
+ installSeatStub(server as never, []);
+ });
+
/** Unhandled rejections raised while `fn` runs, and shortly after. */
const unhandledDuring = async (fn: () => Promise) => {
const seen: unknown[] = [];
diff --git a/src/backend/testFixtures/seatStub.ts b/src/backend/testFixtures/seatStub.ts
index 44cf83198..e49947ba9 100644
--- a/src/backend/testFixtures/seatStub.ts
+++ b/src/backend/testFixtures/seatStub.ts
@@ -19,12 +19,16 @@
import { onTestFinished } from 'vitest';
-import type { OrgSeatRow } from '../core/teams.js';
+import type {
+ OrgSeatRow,
+ TeamServiceContract,
+ TeamStoreContract,
+} from '../core/teams.js';
/**
- * Teams ships as an extension, so core cannot build a real seat to test the
- * rules that refuse one. This answers the contract instead, which is all those
- * rules read.
+ * Teams is optional, so core cannot build a real seat to test the rules that
+ * refuse one. This answers the contract instead, which is all those rules
+ * read.
*/
export interface SeatStubOptions {
/** Whether the seat's team demands a second factor. */
@@ -68,7 +72,7 @@ export const installSeatStub = (
} as OrgSeatRow)
: null;
- server.stores.team = {
+ const storeStub: TeamStoreContract = {
getOrgSeat: async (userId: number) => seatFor(userId),
isMember: async () => false,
getByUid: async () => null,
@@ -81,7 +85,8 @@ export const installSeatStub = (
countOwned: async () => 0,
countOwnedTeamsWithAccounts: async () => 0,
};
- server.services.team = {
+ server.stores.team = storeStub;
+ const serviceStub: TeamServiceContract = {
teamsAvailableTo: async () => false,
captureSeatForBilling: async () => null,
emitSeatDeleted: () => undefined,
@@ -100,6 +105,7 @@ export const installSeatStub = (
);
},
};
+ server.services.team = serviceStub;
// Deleted rather than set undefined: shutdown walks the registry values.
const restore = () => {
diff --git a/src/backend/util/userDetails.ts b/src/backend/util/userDetails.ts
index 351c38e44..7790aee9b 100644
--- a/src/backend/util/userDetails.ts
+++ b/src/backend/util/userDetails.ts
@@ -218,6 +218,10 @@ export async function buildUserDetails(
try {
return { seat: await stores.team?.getOrgSeat(user.id) };
} catch (error) {
+ // Teams is optional, and an extension-registry read for one that
+ // is not installed throws rather than answering undefined. That
+ // is an absence, not a fault.
+ if (/missing property/.test(String(error))) return { seat: null };
return { error };
}
};
diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js
deleted file mode 100644
index adb203cfe..000000000
--- a/src/gui/src/UI/Dashboard/TabTeams.js
+++ /dev/null
@@ -1,910 +0,0 @@
-/*
- * 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 .
- */
-
-import teamActionButton from './teamActionIcons.js';
-import UIDashboardDialog from './UIDashboardDialog.js';
-import {
- annotateMembers,
- auditActionKey,
- auditReasonKey,
- auditSlice,
- avatarHue,
- billingSummaryKey,
- canDeleteAccount,
- initialOf,
- membersBillingSummary,
- memberPlanLabel,
- parseTimestamp,
- sortMembers,
-} from './teamsConsole.js';
-
-const SECTION = '.dashboard-section-teams';
-
-/** What the console last loaded, so a redraw needs no second round trip. */
-let state = { status: 'loading', teams: [], selected: null, members: [], audit: [], auditPage: 0, plan: null };
-
-// Paged for reading, not fetching: member state is derived from the whole
-// record, so it is already loaded.
-const AUDIT_ROWS_PER_PAGE = 10;
-
-/** In flight, so `init` and the initial-route `onActivate` don't both load. */
-let loadPromise = null;
-
-/** Every dialog here is the dashboard's overlay card, mounted in the window. */
-const dialog = ($el_window, opts) => UIDashboardDialog({ $container: $el_window, ...opts });
-
-const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? '';
-
-/** The record needs the hour: several entries share a day. */
-const dateTimeText = (value) => {
- const date = parseTimestamp(value);
- return date ? date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '';
-};
-
-const teamName = (team) => team?.name || team?.handle || i18n('teams_untitled');
-
-/** The sidebar entry is hidden rather than shown broken where teams are off. */
-const setTabVisible = ($el_window, visible) => {
- $el_window.find('.dashboard-sidebar-item[data-section="teams"]').toggle(visible);
-};
-
-// Inline like the other tabs' card icons: one-place glyphs, not shared assets.
-const SVG = (body) =>
- ``;
-const ICONS = {
- team: SVG(''),
- directory: SVG(''),
- shield: SVG(''),
- copy: SVG(''),
- check: SVG(''),
- close: SVG(''),
- warning: SVG(''),
-};
-
-// -- Rendering ------------------------------------------------------------
-
-/** A lettered tile in place of a picture: team accounts have none. */
-const renderAvatar = (name, extraClass = '') => {
- const hue = avatarHue(name);
- return `${html_encode(initialOf(name))}`;
-};
-
-const renderTeamPicker = () => {
- if ( state.teams.length < 2 ) return '';
- let h = `';
- return h;
-};
-
-/** Name, handle and headcount for the selected team, plus who you are in it. */
-const renderHero = () => {
- const team = state.selected;
- const count = state.members.length;
- let h = '
';
- h += '
';
- h += renderAvatar(teamName(team), ' teams-avatar-lg');
- h += '
';
- h += '
';
- h += `${html_encode(teamName(team))}`;
- h += `${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}`;
- h += '
';
- const meta = [];
- // Nothing here sets a handle, so its absence is not worth a label.
- if ( team.handle ) meta.push(html_encode(`@${team.handle}`));
- meta.push(i18n(count === 1 ? 'teams_member_count_one' : 'teams_member_count', { count }));
- h += `${meta.join('·')}`;
- h += '