From 18c92ac05a1ef46f22932db491f5c1bbbf5d16fc Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 15 Oct 2026 14:57:37 -0400 Subject: [PATCH] refactor: take the teams console out of the built-in tabs `TabTeams`, `teamsConsole` and `teamActionIcons` are no longer built in. The dashboard already invites a tab through its `dashboard-will-open` event, so whatever provides Teams contributes the console the same way. Its 113 strings go with it and register into the shipped English dictionary at load, so `i18n('teams_*')` resolves as it did. Only `en.js` ever carried them, so no translation is lost. Three modules stay, because things other than the console use them: `teamBadge` (the dashboard shell), `orgSeat` (Home, Account, Security) and `shareTeams` (the share picker). They already render nothing when the account has no team. `teams_account_of` stays with `teamBadge` for the same reason, and the `share_*` and `plan_team_*` strings stay with their callers. The GUI builds clean with no console at all, and builds it in when the tab is contributed. --- src/backend/TeamsOptional.test.ts | 8 +- .../webdav/WebDAVController.test.ts | 7 +- src/backend/core/teams.ts | 6 +- .../services/share/ShareService.test.ts | 222 +++-- src/backend/testFixtures/seatStub.ts | 18 +- src/backend/util/userDetails.ts | 4 + src/gui/src/UI/Dashboard/TabTeams.js | 910 ------------------ .../src/UI/Dashboard/TabTeams.render.test.js | 190 ---- src/gui/src/UI/Dashboard/TabTeams.test.js | 54 -- src/gui/src/UI/Dashboard/UIDashboard.js | 2 - src/gui/src/UI/Dashboard/teamActionIcons.js | 56 -- .../src/UI/Dashboard/teamActionIcons.test.js | 66 -- src/gui/src/UI/Dashboard/teamsConsole.js | 273 ------ src/gui/src/UI/Dashboard/teamsConsole.test.js | 321 ------ src/gui/src/i18n/translations/en.js | 126 +-- .../tests/api/harness/capabilities.ts | 19 + src/puter-js/tests/api/suites/teams.suite.ts | 29 +- tools/typecheck-baseline.json | 39 +- 18 files changed, 198 insertions(+), 2152 deletions(-) delete mode 100644 src/gui/src/UI/Dashboard/TabTeams.js delete mode 100644 src/gui/src/UI/Dashboard/TabTeams.render.test.js delete mode 100644 src/gui/src/UI/Dashboard/TabTeams.test.js delete mode 100644 src/gui/src/UI/Dashboard/teamActionIcons.js delete mode 100644 src/gui/src/UI/Dashboard/teamActionIcons.test.js delete mode 100644 src/gui/src/UI/Dashboard/teamsConsole.js delete mode 100644 src/gui/src/UI/Dashboard/teamsConsole.test.js 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 ``; -}; - -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 += '
'; - const picker = renderTeamPicker(); - const rename = team.isOwner ? `` : ''; - if ( picker || rename ) h += `
${picker}${rename}
`; - h += '
'; - return h; -}; - -const renderCardHead = (title, hint, count = null) => { - let h = '
'; - h += `

${title}`; - if ( count !== null ) h += `${count}`; - h += '

'; - if ( hint ) h += `

${hint}

`; - h += '
'; - return h; -}; - -const planCell = (member) => { - const label = memberPlanLabel(member, state.plan); - if ( label.kind === 'tier' ) return html_encode(label.name); - return i18n(`teams_member_plan_${label.kind}`); -}; - -const renderStatus = (member) => { - const kind = member.disabled ? 'disabled' : 'active'; - return `${i18n(`teams_member_state_${kind}`)}`; -}; - -const renderIdentity = (username, subline) => { - const you = username === window.user?.username; - let h = '
'; - h += renderAvatar(username); - h += '
'; - h += `${html_encode(username)}`; - if ( you ) h += `${i18n('share_you')}`; - h += ''; - if ( subline ) h += `${subline}`; - h += '
'; - return h; -}; - -const renderMemberActions = (member) => { - if ( ! member.orgOwned ) return ''; - const forMember = { 'data-username': member.username }; - let h = '
'; - if ( window.team_billing_ui && state.plan?.status === 'ready' ) { - h += teamActionButton({ - className: 'teams-plan-change', - icon: 'plan', - label: i18n('teams_plan_change'), - attrs: { ...forMember, 'data-uuid': member.uuid ?? '' }, - }); - } - h += teamActionButton({ - className: 'teams-reset', - icon: 'credential', - label: i18n('teams_reissue_credential'), - attrs: forMember, - }); - // Only while the rule is on: otherwise a member can clear it themselves. - if ( state.selected?.require2fa ) { - h += teamActionButton({ - className: 'teams-reset-2fa', - icon: 'shield', - label: i18n('teams_reset_2fa'), - attrs: forMember, - }); - } - h += member.disabled - ? teamActionButton({ - className: 'teams-enable', - icon: 'enable', - label: i18n('teams_enable_account'), - attrs: forMember, - }) - : teamActionButton({ - className: 'teams-disable', - icon: 'suspend', - label: i18n('teams_disable_account'), - danger: true, - attrs: forMember, - }); - if ( canDeleteAccount(member) ) { - h += teamActionButton({ - className: 'teams-delete-account', - icon: 'remove', - label: i18n('teams_delete_account'), - danger: true, - attrs: forMember, - }); - } - h += '
'; - return h; -}; - -// `data-label` repeats the column heading so the phone layout, which drops -// the header row, can print it beside each value. -const labelled = (key) => `data-label="${html_encode(i18n(key, [], false))}"`; - -const renderMemberRow = (member) => { - const username = html_encode(member.username); - let h = ``; - h += `${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}`; - h += `${renderStatus(member)}`; - h += `${html_encode(dateText(member.createdAt))}`; - h += `${planCell(member)}`; - h += `${renderMemberActions(member)}`; - h += ''; - return h; -}; - -const renderMembers = () => { - const annotated = annotateMembers(state.members, state.audit); - const summary = membersBillingSummary(annotated); - - let h = '
'; - h += renderCardHead( - i18n('teams_accounts'), - i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }), - annotated.length, - ); - - if ( annotated.length === 0 ) { - h += `

${i18n('teams_no_accounts')}

`; - } else { - h += '
'; - h += ``; - h += ``; - h += ``; - h += ``; - h += ``; - h += ''; - for ( const member of sortMembers(annotated) ) h += renderMemberRow(member); - h += '
${i18n('teams_member_account')}${i18n('teams_member_state')}${i18n('teams_member_since')}${i18n('teams_member_plan')}${i18n('teams_member_actions')}
'; - } - h += '
'; - return h; -}; - -const renderAddAccount = () => { - let h = '
'; - h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint')); - h += '
'; - h += '
'; - h += ``; - h += ''; - h += '
'; - h += '
'; - h += ``; - h += ''; - h += '
'; - h += ``; - h += '
'; - h += `

${i18n('teams_add_account_email_hint')}

`; - h += ''; - h += '
'; - return h; -}; - -const renderAudit = ({ title, hint }) => { - let h = '
'; - h += renderCardHead(title, hint, state.audit.length || null); - - if ( state.audit.length === 0 ) { - h += `

${i18n('teams_no_audit')}

`; - } else { - const { items, page, pages, from, to, total } = auditSlice( - state.audit, - state.auditPage, - AUDIT_ROWS_PER_PAGE, - ); - h += '
'; - h += ``; - h += ``; - h += ``; - h += ``; - h += ''; - for ( const entry of items ) { - const actionKey = auditActionKey(entry.action); - const reasonKey = auditReasonKey(entry.reason); - h += ''; - h += `'; - h += ``; - h += ``; - h += ``; - h += ''; - } - h += '
${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}${i18n('teams_audit_when')}
${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; - if ( reasonKey ) h += ` ${i18n(reasonKey)}`; - h += '${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(dateTimeText(entry.createdAt))}
'; - if ( pages > 1 ) { - h += '
'; - h += `${i18n('teams_audit_range', { from, to, total })}`; - h += ''; - h += ``; - h += ``; - h += '
'; - } - } - h += '
'; - return h; -}; - -/** Colleagues, by name. No state, no dates, no actions — none are theirs. */ -const renderRoster = () => { - let h = '
'; - h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null); - if ( state.members.length === 0 ) { - h += `

${i18n('teams_roster_empty')}

`; - } else { - h += '
    '; - for ( const member of sortMembers(state.members) ) { - h += `
  • ${renderIdentity(member.username)}
  • `; - } - h += '
'; - } - h += '
'; - return h; -}; - -/** What a member sees: who else is here, their own entries, nothing admin. */ -const renderMemberView = () => { - return renderHero() - + renderRoster() - + renderAudit({ - title: i18n('teams_your_record'), - hint: i18n('teams_your_record_hint', { team: teamName(state.selected) }), - }); -}; - -const renderDirectory = () => { - const on = state.selected?.directoryEnabled === true; - let h = '
'; - h += '
'; - h += `
${ICONS.directory}
`; - h += '
'; - h += `${i18n('teams_directory_label')}`; - h += `${i18n(on ? 'teams_directory_on_note' : 'teams_directory_off_note')}`; - h += '
'; - h += ''; - h += '
'; - return h; -}; - -const renderRequire2fa = () => { - const on = state.selected?.require2fa === true; - let h = '
'; - h += '
'; - h += `
${ICONS.shield}
`; - h += '
'; - h += `${i18n('teams_require_2fa_label')}`; - h += `${i18n(on ? 'teams_require_2fa_on_note' : 'teams_require_2fa_off_note')}`; - h += '
'; - h += ''; - h += '
'; - return h; -}; - -const renderOwnerView = () => { - let h = renderHero(); - h += renderDirectory(); - h += renderRequire2fa(); - h += renderAddAccount(); - h += renderMembers(); - h += renderAudit({ title: i18n('teams_audit'), hint: i18n('teams_audit_hint') }); - - h += '
'; - h += `

${i18n('teams_danger_zone')}

`; - h += '
'; - h += '
'; - h += `${i18n('teams_delete_team')}`; - h += `${i18n('teams_delete_team_hint')}`; - h += '
'; - h += ``; - h += '
'; - return h; -}; - -/** Placeholder cards the size of the ones about to arrive, so nothing jumps. */ -const renderSkeleton = () => { - let h = `
${i18n('teams_loading')}`; - h += '
'; - for ( let i = 0; i < 2; i++ ) { - h += '
'; - } - h += '
'; - return h; -}; - -const renderState = ({ icon, title, hint, action, tone = '' }) => { - let h = `
`; - h += `
${icon}
`; - h += `

${title}

`; - if ( hint ) h += `

${hint}

`; - if ( action ) h += action; - h += '
'; - return h; -}; - -const renderBody = () => { - // Nothing at all where teams are off: an empty panel is the honest - // rendering of a feature this deployment does not have. - if ( state.status === 'unavailable' ) return ''; - if ( state.status === 'loading' ) return renderSkeleton(); - if ( state.status === 'error' ) { - return renderState({ - icon: ICONS.warning, - tone: 'warning', - title: i18n('teams_load_failed'), - action: ``, - }); - } - if ( state.teams.length === 0 ) { - return renderState({ - icon: ICONS.team, - title: i18n('teams_create_team'), - hint: i18n('teams_create_team_hint'), - action: ``, - }); - } - return state.selected?.isOwner ? renderOwnerView() : renderMemberView(); -}; - -const paint = ($el_window) => { - $el_window.find(`${SECTION} .teams-body`).html(renderBody()); -}; - -// -- Loading -------------------------------------------------------------- - -const loadSelected = async () => { - if ( ! state.selected ) return; - // A fresh record starts at the top; auditSlice clamps a stale page anyway. - state.auditPage = 0; - state.audit = state.selected.isOwner - ? await puter.teams.listAudit(state.selected.uid) - : await puter.teams.listOwnAudit(state.selected.uid); - // Members too: the roster is theirs to see, and the controller already - // withholds from them what is not. - state.members = await puter.teams.listMembers(state.selected.uid); - state.plan = state.selected.isOwner ? await loadPlan(state.selected.uid) : null; -}; - -/** Served by a billing extension; absent is a normal answer. */ -const loadPlan = async (teamUid) => { - const get = async (path) => { - const resp = await fetch(`${window.api_origin}${path}`, { - headers: { Authorization: `Bearer ${puter.authToken}` }, - }); - return resp.ok ? resp.json() : null; - }; - try { - const [cat, sub] = await Promise.all([ - get('/marketplace/subscriptions/team-offerings'), - get(`/marketplace/teams/${encodeURIComponent(teamUid)}/subscription`), - ]); - if ( ! cat ) return { status: 'unavailable', offerings: [], seatTiers: {} }; - const entry = sub?.subscription ?? null; - return { - status: 'ready', - offerings: Array.isArray(cat.offerings) ? cat.offerings : [], - seatTiers: entry?.seatTiers ?? {}, - seatStatuses: entry?.seatStatuses ?? {}, - tierQuantities: entry?.tierQuantities ?? {}, - subStatus: entry?.status ?? null, - }; - } catch { - return { status: 'unavailable', offerings: [], seatTiers: {} }; - } -}; - -/** The billing extension owns the picker, so the look matches personal plans. */ -const changeSeatPlan = ($el_window, username, uuid) => { - if ( state.plan?.status !== 'ready' || ! uuid ) return; - window.dispatchEvent(new CustomEvent('team-plan-purchase', { - detail: { - teamUid: state.selected.uid, - seatUuid: uuid, - username, - currentTier: state.plan.seatTiers?.[uuid] ?? null, - currentStatus: state.plan.seatStatuses?.[uuid] ?? null, - onDone: () => refresh($el_window), - }, - })); -}; - -const load = async ($el_window) => { - // The API can be on while the interface is not; same effect as no route. - if ( ! window.teams_ui ) { - state.status = 'unavailable'; - setTabVisible($el_window, false); - return paint($el_window); - } - try { - const teams = await puter.teams.list(); - state.teams = teams; - state.selected = teams.find(t => t.uid === state.selected?.uid) ?? teams[0] ?? null; - await loadSelected(); - state.status = 'ready'; - setTabVisible($el_window, true); - } catch (e) { - // A deployment with teams off registers no `/teams` route, so the - // 404 is the feature gate rather than a failure worth reporting. - state.status = e?.code === 'not_found' ? 'unavailable' : 'error'; - state.teams = []; - state.selected = null; - setTabVisible($el_window, false); - } - paint($el_window); -}; - -const refresh = ($el_window) => { - if ( ! loadPromise ) { - loadPromise = load($el_window).finally(() => { loadPromise = null; }); - } - return loadPromise; -}; - -// -- Actions -------------------------------------------------------------- - -// The plain error says what happened, not that a plan is what raises the cap. -// The button appears only where a billing extension put the modal on the page. -const showSeatLimit = async ($el_window, e) => { - const limit = e?.limit ?? e?.fields?.limit; - const canUpgrade = typeof window.UIUpgradeAccount === 'function'; - let message = `

${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}

`; - message += `

${i18n('teams_seat_limit_upgrade')}

`; - const answer = await dialog($el_window, { - title: i18n('teams_add_account', [], false), - tone: 'warning', - message, - buttons: canUpgrade - ? [ - { label: i18n('cancel', [], false), value: 'no' }, - { label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' }, - ] - : [{ label: i18n('ok', [], false), value: 'no', type: 'primary' }], - }); - if ( answer === 'upgrade' ) new window.UIUpgradeAccount().open_as_window(); -}; - -const showError = ($el_window, e) => { - if ( e?.code === 'seat_limit_reached' ) return showSeatLimit($el_window, e); - // A rule answering, not a fault. - if ( e?.code === 'too_many_requests' ) return dialog($el_window, { - title: i18n('teams_add_account', [], false), - tone: 'warning', - message: html_encode(e?.message ?? ''), - }); - return dialog($el_window, { - title: i18n('something_went_wrong', [], false), - tone: 'error', - message: html_encode(e?.message ?? i18n('error_unknown_cause', [], false)), - }); -}; - -/** `label` names the action and titles the dialog; `kind` colours its button. */ -const confirm = async ($el_window, message, label, kind = 'danger') => { - const answer = await dialog($el_window, { - title: label, - tone: kind === 'danger' ? 'danger' : undefined, - message, - buttons: [ - { label: i18n('cancel', [], false), value: 'no' }, - { label, value: 'yes', type: kind }, - ], - }); - return answer === 'yes'; -}; - -/** Shown once and never retrievable, so it stays on screen until dismissed. */ -const showCredential = ($el_window, username, temporaryPassword) => { - const $box = $el_window.find(`${SECTION} .teams-credential`); - let h = '
'; - h += `${i18n('teams_credential_heading', { username })}`; - h += ``; - h += '
'; - h += '
'; - h += `${html_encode(temporaryPassword)}`; - h += ``; - h += '
'; - h += `${i18n('teams_credential_once')}`; - $box.html(h).show(); - $box.find('.teams-credential-value').trigger('focus'); -}; - -/** Confirms on the button itself; a toast would appear away from the value. */ -const copyCredential = async ($button) => { - try { - await navigator.clipboard.writeText($button.attr('data-value') ?? ''); - } catch { - // Selecting the value is the fallback: it is one keystroke from copied. - const node = $button.closest('.teams-credential').find('.teams-credential-value')[0]; - if ( node ) window.getSelection()?.selectAllChildren(node); - return; - } - $button.addClass('teams-copied').html(`${ICONS.check}${i18n('teams_credential_copied')}`); - setTimeout(() => { - $button.removeClass('teams-copied').html(`${ICONS.copy}${i18n('copy')}`); - }, 1800); -}; - -const addAccount = async ($el_window) => { - const username = $el_window.find(`${SECTION} .teams-new-username`).val().trim(); - if ( ! username ) return; - // With an address the credential is emailed too; without it, only shown here. - const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim(); - - const $button = $el_window.find(`${SECTION} .teams-add-btn`); - $button.prop('disabled', true).addClass('teams-busy'); - try { - const created = await puter.teams.createMember(state.selected.uid, { - username, - ...(email ? { email } : {}), - }); - await refresh($el_window); - showCredential($el_window, created.username, created.temporaryPassword); - } catch (e) { - await showError($el_window, e); - } finally { - $el_window.find(`${SECTION} .teams-add-btn`).prop('disabled', false).removeClass('teams-busy'); - } -}; - -const resetTwoFactor = async ($el_window, username) => { - const ok = await confirm( - $el_window, - `

${i18n('teams_reset_2fa_confirm', { username })}

`, - i18n('teams_reset_2fa_confirm_action', [], false), - ); - if ( ! ok ) return; - try { - await puter.teams.resetTwoFactor(state.selected.uid, username); - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - } -}; - -const reissueCredential = async ($el_window, username) => { - const ok = await confirm( - $el_window, - `

${i18n('teams_confirm_reissue', { username })}

`, - i18n('teams_reissue_credential', [], false), - ); - if ( ! ok ) return; - try { - const result = await puter.teams.resendActivation(state.selected.uid, username); - await refresh($el_window); - showCredential($el_window, result.username, result.temporaryPassword); - } catch (e) { - await showError($el_window, e); - } -}; - -const setMemberEnabled = async ($el_window, username, enabled) => { - if ( ! enabled ) { - const ok = await confirm( - $el_window, - `

${i18n('teams_confirm_disable', { username })}

`, - i18n('teams_disable_account', [], false), - ); - if ( ! ok ) return; - } - try { - await (enabled - ? puter.teams.enableMember(state.selected.uid, username) - : puter.teams.disableMember(state.selected.uid, username)); - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - } -}; - -const renameTeam = async ($el_window) => { - const name = await dialog($el_window, { - title: i18n('teams_rename', [], false), - message: i18n('teams_rename_prompt'), - input: { placeholder: i18n('teams_team_name', [], false), value: state.selected.name ?? '' }, - buttons: [ - { label: i18n('cancel', [], false), value: false }, - { label: i18n('teams_rename', [], false), value: 'ok', type: 'primary' }, - ], - }); - if ( name === false || name.trim() === '' ) return; - try { - await puter.teams.update(state.selected.uid, { name: name.trim() }); - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - } -}; - -const deleteMemberAccount = async ($el_window, username) => { - const ok = await confirm( - $el_window, - i18n('teams_confirm_delete_account', { username }), - i18n('teams_delete_account', [], false), - ); - if ( ! ok ) return; - try { - await puter.teams.deleteMemberAccount(state.selected.uid, username); - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - } -}; - -const setRequire2fa = async ($el_window, enabled) => { - // Turning it on locks out every account without 2FA until they enrol, so - // it is confirmed; turning it off only lifts a requirement. - if ( enabled ) { - const ok = await confirm( - $el_window, - i18n('teams_require_2fa_confirm'), - i18n('teams_require_2fa_confirm_action', [], false), - 'primary', - ); - if ( ! ok ) return paint($el_window); - } - try { - await puter.teams.update(state.selected.uid, { require2fa: enabled }); - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - await refresh($el_window); - } -}; - -const setDirectoryEnabled = async ($el_window, enabled) => { - // Turning it on is a disclosure, so it is confirmed; turning it off only - // takes something away and does not need to interrupt anyone. - if ( enabled ) { - const ok = await confirm( - $el_window, - i18n('teams_directory_confirm'), - i18n('teams_directory_confirm_action', [], false), - // Reversible, and the wording says so — red would overstate it. - 'primary', - ); - // Repaint so the checkbox does not sit checked after a refusal. - if ( ! ok ) return paint($el_window); - } - try { - await puter.teams.update(state.selected.uid, { directoryEnabled: enabled }); - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - await refresh($el_window); - } -}; - -const createTeam = async ($el_window) => { - const name = await dialog($el_window, { - title: i18n('teams_create_team', [], false), - message: i18n('teams_create_team_prompt'), - input: { placeholder: i18n('teams_team_name', [], false) }, - buttons: [ - { label: i18n('cancel', [], false), value: false }, - { label: i18n('teams_create_team', [], false), value: 'ok', type: 'primary' }, - ], - }); - if ( name === false || name.trim() === '' ) return; - try { - const created = await puter.teams.create({ name: name.trim() }); - state.selected = created; - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - } -}; - -const deleteTeam = async ($el_window) => { - const ok = await confirm( - $el_window, - `

${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}

` - + `

${i18n('teams_confirm_delete_billing')}

`, - i18n('teams_delete_team', [], false), - ); - if ( ! ok ) return; - try { - await puter.teams.delete(state.selected.uid); - state.selected = null; - await refresh($el_window); - } catch (e) { - await showError($el_window, e); - } -}; - -// -- Tab ------------------------------------------------------------------ - -const TabTeams = { - id: 'teams', - label: i18n('teams'), - icon: '', - - html () { - let h = '
'; - h += '
'; - h += `

${i18n('teams')}

`; - h += `

${i18n('teams_subtitle')}

`; - h += '
'; - h += '
'; - h += '
'; - return h; - }, - - init ($el_window) { - // Hidden until the first load says teams exist here, so a - // deployment with them off never shows an entry that cannot work. - setTabVisible($el_window, false); - - // Delegated, because every action redraws the panel underneath them. - $el_window.on('click', `${SECTION} .teams-audit-prev`, () => { - state.auditPage = Math.max(0, state.auditPage - 1); - paint($el_window); - }); - $el_window.on('click', `${SECTION} .teams-audit-next`, () => { - state.auditPage += 1; - paint($el_window); - }); - // Submit rather than click, so Enter in either field adds the account too. - $el_window.on('submit', `${SECTION} .teams-form`, (e) => { - e.preventDefault(); - addAccount($el_window); - }); - $el_window.on('click', `${SECTION} .teams-credential-copy`, function () { - copyCredential($(this)); - }); - $el_window.on('click', `${SECTION} .teams-credential-dismiss`, () => { - $el_window.find(`${SECTION} .teams-credential`).hide().empty(); - }); - $el_window.on('click', `${SECTION} .teams-retry`, () => { - state.status = 'loading'; - paint($el_window); - refresh($el_window); - }); - $el_window.on('click', `${SECTION} .teams-create`, () => createTeam($el_window)); - $el_window.on('click', `${SECTION} .teams-rename`, () => renameTeam($el_window)); - $el_window.on('click', `${SECTION} .teams-delete`, () => deleteTeam($el_window)); - // Checkout is the billing extension's job; this only says what was asked for. - $el_window.on('click', `${SECTION} .teams-plan-change`, function () { - changeSeatPlan($el_window, $(this).attr('data-username'), $(this).attr('data-uuid')); - }); - $el_window.on('click', `${SECTION} .teams-plan-buy`, function () { - if ( ! state.selected ) return; - window.dispatchEvent(new CustomEvent('team-plan-purchase', { - detail: { - teamUid: state.selected.uid, - itemId: $(this).attr('data-item-id'), - onDone: () => refresh($el_window), - }, - })); - }); - $el_window.on('click', `${SECTION} .teams-reset`, function () { - reissueCredential($el_window, $(this).attr('data-username')); - }); - $el_window.on('click', `${SECTION} .teams-disable`, function () { - setMemberEnabled($el_window, $(this).attr('data-username'), false); - }); - $el_window.on('click', `${SECTION} .teams-enable`, function () { - setMemberEnabled($el_window, $(this).attr('data-username'), true); - }); - $el_window.on('click', `${SECTION} .teams-delete-account`, function () { - deleteMemberAccount($el_window, $(this).attr('data-username')); - }); - $el_window.on('change', `${SECTION} .teams-directory-check`, function () { - setDirectoryEnabled($el_window, $(this).is(':checked')); - }); - $el_window.on('click', `${SECTION} .teams-reset-2fa`, function () { - resetTwoFactor($el_window, $(this).attr('data-username')); - }); - $el_window.on('change', `${SECTION} .teams-require-2fa-check`, function () { - setRequire2fa($el_window, $(this).is(':checked')); - }); - $el_window.on('change', `${SECTION} .teams-picker-select`, async function () { - state.selected = state.teams.find(t => t.uid === $(this).val()) ?? state.selected; - await refresh($el_window); - }); - - refresh($el_window); - }, - - onActivate ($el_window) { - refresh($el_window); - }, -}; - -export default TabTeams; diff --git a/src/gui/src/UI/Dashboard/TabTeams.render.test.js b/src/gui/src/UI/Dashboard/TabTeams.render.test.js deleted file mode 100644 index 6e30e5b6b..000000000 --- a/src/gui/src/UI/Dashboard/TabTeams.render.test.js +++ /dev/null @@ -1,190 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; - -vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() })); - -globalThis.$ = jQuery; -globalThis.jQuery = jQuery; -globalThis.i18n = (key, replacements) => { - if ( replacements && typeof replacements === 'object' && ! Array.isArray(replacements) ) { - return `${key}(${Object.entries(replacements).map(([k, v]) => `${k}=${v}`).join(',')})`; - } - return key; -}; -globalThis.html_encode = (value) => String(value ?? '') - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -window.html_encode = globalThis.html_encode; - -const { default: TabTeams } = await import('./TabTeams.js'); - -const flush = () => new Promise(resolve => setTimeout(resolve, 0)); - -const team = { uid: 't1', name: 'Acme Design', handle: 'acme', isOwner: true, directoryEnabled: false }; -const members = [ - { username: 'dana', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-dana' }, - { username: 'boss', orgOwned: false, createdAt: '2026-09-01T00:00:00Z' }, - { username: 'elliot', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-elliot' }, -]; -// Newest first, as the API sends it; unix seconds, as the audit routes send it. -const audit = [ - { action: 'disable', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789672096 }, - { action: 'provision', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789600000 }, - { action: 'provision', username: 'dana', reason: null, actorUsername: 'boss', createdAt: 1789600000 }, -]; - -const mount = async ({ teams = [team], plan = null } = {}) => { - window.teams_ui = true; - window.team_billing_ui = false; - window.api_origin = 'http://api.test'; - window.user = { username: 'boss' }; - globalThis.puter = { - authToken: 'tok', - teams: { - list: vi.fn(async () => teams), - listAudit: vi.fn(async () => audit), - listOwnAudit: vi.fn(async () => audit.filter(e => e.username === 'boss')), - listMembers: vi.fn(async () => members), - }, - }; - globalThis.fetch = vi.fn(async () => ({ ok: false })); - if ( plan ) globalThis.fetch = plan; - - document.body.innerHTML = `
- -
${TabTeams.html()}
-
`; - const $el_window = $('.dashboard'); - TabTeams.init($el_window); - await flush(); - await flush(); - return $el_window; -}; - -describe('the owner view', () => { - let $w; - beforeEach(async () => { $w = await mount(); }); - - it('names the team, its role and headcount in the hero', () => { - const hero = $w.find('.teams-hero'); - expect(hero.find('strong').text()).toBe('Acme Design'); - expect(hero.find('.teams-role').text()).toBe('teams_role_owner'); - expect(hero.find('.teams-info-meta').text()).toContain('@acme'); - expect(hero.find('.teams-info-meta').text()).toContain('teams_member_count(count=3)'); - expect(hero.find('.teams-rename')).toHaveLength(1); - // One team: no picker to choose it with. - expect(hero.find('.teams-picker-select')).toHaveLength(0); - }); - - it('renders the directory as a switch that still carries the wired class', () => { - const input = $w.find('.dashboard-switch input.teams-directory-check'); - expect(input).toHaveLength(1); - expect(input.is(':checked')).toBe(false); - expect($w.find('.teams-directory-note').text()).toBe('teams_directory_off_note'); - }); - - it('lists accounts with a status pill and folds the audit state in', () => { - const rows = $w.find('.teams-members-table tbody tr'); - expect(rows).toHaveLength(3); - // Suspended last, provisioned before joined. - expect(rows.map((_, r) => $(r).attr('data-username')).get()).toEqual(['dana', 'boss', 'elliot']); - expect(rows.eq(2).hasClass('teams-member-disabled')).toBe(true); - expect(rows.eq(2).find('.teams-status').hasClass('teams-status-disabled')).toBe(true); - expect(rows.eq(0).find('.teams-status').hasClass('teams-status-active')).toBe(true); - }); - - it('marks the signed-in account and gives it no actions', () => { - const you = $w.find('.teams-member-row[data-username="boss"]'); - expect(you.find('.teams-you')).toHaveLength(1); - expect(you.find('.teams-member-actions')).toHaveLength(0); - expect($w.find('.teams-member-row[data-username="dana"] .teams-you')).toHaveLength(0); - }); - - it('offers restore and delete only for a suspended account', () => { - const dana = $w.find('.teams-member-row[data-username="dana"]'); - const elliot = $w.find('.teams-member-row[data-username="elliot"]'); - expect(dana.find('.teams-disable')).toHaveLength(1); - expect(dana.find('.teams-delete-account')).toHaveLength(0); - expect(elliot.find('.teams-enable')).toHaveLength(1); - expect(elliot.find('.teams-delete-account')).toHaveLength(1); - }); - - it('labels every data cell for the phone layout, which drops the header row', () => { - const cells = $w.find('.teams-members-table tbody tr').first().find('td[data-label]'); - expect(cells.map((_, c) => $(c).attr('data-label')).get()).toEqual([ - 'teams_member_state', 'teams_member_since', 'teams_member_plan', - ]); - }); - - it('keeps the actions in a block inside the cell, not on the cell itself', () => { - const cell = $w.find('.teams-member-row[data-username="dana"] td.teams-cell-actions'); - expect(cell.children('.teams-member-actions')).toHaveLength(1); - }); - - it('prints audit seconds as a real date, not 1970', () => { - const when = $w.find('.teams-audit-table tbody tr').first().find('.teams-cell-when').text(); - expect(when).toContain('2026'); - expect(when).not.toContain('1970'); - }); - - it('skips the suspended clause of the billing note when there is nothing suspended', async () => { - expect($w.find('.teams-panel-hint').filter((_, el) => $(el).text().startsWith('teams_billing_summary')).text()) - .toBe('teams_billing_summary_one(billed=1,disabled=1)'); - }); - - it('adds an account on form submit, so Enter works, and shows the credential with a copy button', async () => { - globalThis.puter.teams.createMember = vi.fn(async () => ({ username: 'gil', temporaryPassword: 'p4ss' })); - $w.find('.teams-new-username').val('gil'); - $w.find('.teams-form').trigger('submit'); - await flush(); - await flush(); - await flush(); - expect(globalThis.puter.teams.createMember).toHaveBeenCalledWith('t1', { username: 'gil' }); - const box = $w.find('.teams-credential'); - expect(box.is(':visible') || box.css('display') !== 'none').toBe(true); - expect(box.find('.teams-credential-value').text()).toBe('p4ss'); - expect(box.find('.teams-credential-copy').attr('data-value')).toBe('p4ss'); - box.find('.teams-credential-dismiss').trigger('click'); - expect(box.children()).toHaveLength(0); - }); -}); - -describe('the member view', () => { - it('shows the roster and own record, and nothing an administrator would use', async () => { - const $w = await mount({ teams: [{ ...team, isOwner: false }] }); - expect($w.find('.teams-role').text()).toBe('teams_role_member'); - expect($w.find('.teams-rename')).toHaveLength(0); - expect($w.find('.teams-roster .teams-identity')).toHaveLength(3); - expect($w.find('.teams-directory-check')).toHaveLength(0); - expect($w.find('.teams-form')).toHaveLength(0); - expect($w.find('.dashboard-danger-zone')).toHaveLength(0); - expect($w.find('.teams-panel h3').first().text()).toContain('teams_roster'); - }); -}); - -describe('states', () => { - it('shows a picker when there is more than one team', async () => { - const $w = await mount({ teams: [team, { ...team, uid: 't2', name: 'Other' }] }); - expect($w.find('.teams-hero .teams-picker-select option')).toHaveLength(2); - }); - - it('offers to create a team when there is none', async () => { - const $w = await mount({ teams: [] }); - expect($w.find('.teams-state .teams-create')).toHaveLength(1); - }); - - it('offers a retry when the load fails', async () => { - window.teams_ui = true; - const $w = await mount(); - globalThis.puter.teams.list = vi.fn(async () => { throw new Error('boom'); }); - await TabTeams.onActivate($w); - await flush(); - expect($w.find('.teams-state .teams-retry')).toHaveLength(1); - globalThis.puter.teams.list = vi.fn(async () => [team]); - $w.find('.teams-retry').trigger('click'); - expect($w.find('.teams-skeleton')).toHaveLength(1); - await flush(); - await flush(); - expect($w.find('.teams-hero')).toHaveLength(1); - }); -}); diff --git a/src/gui/src/UI/Dashboard/TabTeams.test.js b/src/gui/src/UI/Dashboard/TabTeams.test.js deleted file mode 100644 index 97ca802fa..000000000 --- a/src/gui/src/UI/Dashboard/TabTeams.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -// The tab's dialogs mount into the window; only the tab-object contract is under test here. -vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() })); - -globalThis.i18n = (key) => key; -globalThis.html_encode = (value) => String(value); - -const { default: TabTeams } = await import('./TabTeams.js'); - -/** - * UIDashboard reads exactly these keys off a tab, wraps `html()` in its own - * `.dashboard-section-` div, and calls `html()` synchronously before the - * window exists. - */ -describe('the TabTeams tab object', () => { - it('carries the id UIDashboard turns into the hash and the section class', () => { - expect(TabTeams.id).toBe('teams'); - }); - - it('resolves its label at import time, before any render', () => { - expect(TabTeams.label).toBe('teams'); - }); - - it('exposes an inline svg icon, which is injected unescaped', () => { - expect(TabTeams.icon.startsWith(' { - expect(typeof TabTeams.html).toBe('function'); - expect(typeof TabTeams.init).toBe('function'); - expect(typeof TabTeams.onActivate).toBe('function'); - }); -}); - -describe('TabTeams.html()', () => { - it('returns a string synchronously, since it runs before the window is built', () => { - expect(typeof TabTeams.html()).toBe('string'); - }); - - it('renders the section header and the container the tab paints into', () => { - const h = TabTeams.html(); - expect(h).toContain('dashboard-section-header'); - expect(h).toContain('teams-body'); - }); - - it('does not emit its own section wrapper, which UIDashboard already adds', () => { - expect(TabTeams.html()).not.toContain('dashboard-section-teams'); - }); - - it('paints nothing until the first load, so a deployment without teams shows no content', () => { - expect(TabTeams.html()).toContain('
'); - }); -}); diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index f319ca103..9192cece1 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -52,7 +52,6 @@ import TabApps from './TabApps.js'; import TabUsage from './TabUsage.js'; import TabAccount from './TabAccount.js'; import TabSecurity from './TabSecurity.js'; -import TabTeams from './TabTeams.js'; import teamBadgeHtml from './teamBadge.js'; // Registry of built-in tabs @@ -60,7 +59,6 @@ const builtinTabs = [ TabHome, TabApps, TabFiles, - TabTeams, '-', TabUsage, TabAccount, diff --git a/src/gui/src/UI/Dashboard/teamActionIcons.js b/src/gui/src/UI/Dashboard/teamActionIcons.js deleted file mode 100644 index 79ba78daf..000000000 --- a/src/gui/src/UI/Dashboard/teamActionIcons.js +++ /dev/null @@ -1,56 +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 . - */ - -// A row carries up to five actions, and five labelled buttons do not fit. -// Inline like the sidebar chevron: one-place glyphs, not shared assets. - -const SVG = (body) => - ``; - -export const TEAM_ACTION_ICONS = { - /** Change plan — a card. */ - plan: SVG(''), - /** Reissue credential — a key. */ - credential: SVG(''), - /** Suspend — pause. */ - suspend: SVG(''), - /** Re-enable — play. */ - enable: SVG(''), - /** Delete the account — a bin. */ - remove: SVG(''), - /** Edit the account — a pencil. */ - edit: SVG(''), -}; - -/** The label stays as tooltip and screen-reader text, so nothing is lost. */ -export const teamActionButton = ({ className, icon, label, danger, attrs }) => { - const safeLabel = window.html_encode(label); - const extra = Object.entries(attrs ?? {}) - .map(([k, v]) => ` ${k}="${window.html_encode(v ?? '')}"`) - .join(''); - return ( - `` - ); -}; - -export default teamActionButton; diff --git a/src/gui/src/UI/Dashboard/teamActionIcons.test.js b/src/gui/src/UI/Dashboard/teamActionIcons.test.js deleted file mode 100644 index 6da17221c..000000000 --- a/src/gui/src/UI/Dashboard/teamActionIcons.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -globalThis.window = { html_encode: (v) => String(v).replace(/[<"]/g, (c) => (c === '<' ? '<' : '"')) }; - -const { teamActionButton, TEAM_ACTION_ICONS } = await import('./teamActionIcons.js'); - -describe('an icon action button', () => { - it('keeps the label reachable without showing it', () => { - // Dropping the visible text must not drop it for a screen reader. - const h = teamActionButton({ - className: 'teams-reset', - icon: 'credential', - label: 'Reissue password', - }); - expect(h).toContain('title="Reissue password"'); - expect(h).toContain('aria-label="Reissue password"'); - expect(h).toContain('Reissue password'); - }); - - it('carries the class the click handler binds to', () => { - const h = teamActionButton({ className: 'teams-disable', icon: 'suspend', label: 'x' }); - expect(h).toContain('teams-disable'); - }); - - it('passes through the data attributes an action needs', () => { - const h = teamActionButton({ - className: 'teams-plan-change', - icon: 'plan', - label: 'Change plan', - attrs: { 'data-username': 'ana', 'data-uuid': 'u-1' }, - }); - expect(h).toContain('data-username="ana"'); - expect(h).toContain('data-uuid="u-1"'); - }); - - it('marks only the destructive ones', () => { - expect(teamActionButton({ className: 'a', icon: 'remove', label: 'x', danger: true })) - .toContain('button-danger'); - expect(teamActionButton({ className: 'a', icon: 'plan', label: 'x' })) - .not.toContain('button-danger'); - }); - - it('encodes a label and an attribute value', () => { - const h = teamActionButton({ - className: 'a', - icon: 'plan', - label: '