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.
This commit is contained in:
Juan Castro committed 2026-10-18 13:51:23 -04:00
1 parent d5bb8ca0de
commit 18c92ac05a
18 files changed
+198 -2152

No files matched your search

+4 -4
View File
@@ -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;
});
@@ -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',
+3 -3
View File
@@ -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. */
+124 -98
View File
@@ -17,8 +17,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<void>) => {
const seen: unknown[] = [];
+12 -6
View File
@@ -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 = () => {
+4
View File
@@ -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 };
}
};
-910
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const ICONS = {
team: SVG('<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
directory: SVG('<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><circle cx="11" cy="9.5" r="2"/><path d="M7.5 15a4 4 0 0 1 7 0"/>'),
shield: SVG('<path d="M12 3l7 3v5.5c0 4.2-2.9 7.7-7 8.5-4.1-.8-7-4.3-7-8.5V6z"/><path d="M9.5 12l1.8 1.8 3.4-3.6"/>'),
copy: SVG('<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
check: SVG('<path d="M20 6L9 17l-5-5"/>'),
close: SVG('<path d="M18 6L6 18"/><path d="M6 6l12 12"/>'),
warning: SVG('<path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
};
// -- Rendering ------------------------------------------------------------
/** A lettered tile in place of a picture: team accounts have none. */
const renderAvatar = (name, extraClass = '') => {
const hue = avatarHue(name);
return `<span class="teams-avatar${extraClass}" style="--teams-avatar-hue: ${hue};" aria-hidden="true">${html_encode(initialOf(name))}</span>`;
};
const renderTeamPicker = () => {
if ( state.teams.length < 2 ) return '';
let h = `<select class="teams-picker-select" aria-label="${html_encode(i18n('teams_team', [], false))}">`;
for ( const team of state.teams ) {
const selected = team.uid === state.selected?.uid ? ' selected' : '';
h += `<option value="${html_encode(team.uid)}"${selected}>${html_encode(teamName(team))}</option>`;
}
h += '</select>';
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 = '<div class="dashboard-card teams-panel teams-hero">';
h += '<div class="teams-hero-main">';
h += renderAvatar(teamName(team), ' teams-avatar-lg');
h += '<div class="teams-info">';
h += '<div class="teams-info-title">';
h += `<strong>${html_encode(teamName(team))}</strong>`;
h += `<span class="teams-role">${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}</span>`;
h += '</div>';
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 += `<span class="teams-info-meta">${meta.join('<span class="teams-dot" aria-hidden="true">·</span>')}</span>`;
h += '</div></div>';
const picker = renderTeamPicker();
const rename = team.isOwner ? `<button class="button teams-rename">${i18n('teams_rename')}</button>` : '';
if ( picker || rename ) h += `<div class="teams-hero-actions">${picker}${rename}</div>`;
h += '</div>';
return h;
};
const renderCardHead = (title, hint, count = null) => {
let h = '<div class="teams-card-head">';
h += `<h3>${title}`;
if ( count !== null ) h += `<span class="teams-count">${count}</span>`;
h += '</h3>';
if ( hint ) h += `<p class="teams-panel-hint">${hint}</p>`;
h += '</div>';
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 `<span class="teams-status teams-status-${kind}">${i18n(`teams_member_state_${kind}`)}</span>`;
};
const renderIdentity = (username, subline) => {
const you = username === window.user?.username;
let h = '<div class="teams-identity">';
h += renderAvatar(username);
h += '<div class="teams-identity-text">';
h += `<span class="teams-identity-name">${html_encode(username)}`;
if ( you ) h += `<span class="teams-you">${i18n('share_you')}</span>`;
h += '</span>';
if ( subline ) h += `<span class="teams-identity-sub">${subline}</span>`;
h += '</div></div>';
return h;
};
const renderMemberActions = (member) => {
if ( ! member.orgOwned ) return '';
const forMember = { 'data-username': member.username };
let h = '<div class="teams-member-actions">';
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 += '</div>';
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 = `<tr class="teams-member-row${member.disabled ? ' teams-member-disabled' : ''}" data-username="${username}">`;
h += `<td class="teams-cell-identity">${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}</td>`;
h += `<td ${labelled('teams_member_state')}>${renderStatus(member)}</td>`;
h += `<td ${labelled('teams_member_since')}>${html_encode(dateText(member.createdAt))}</td>`;
h += `<td ${labelled('teams_member_plan')}>${planCell(member)}</td>`;
h += `<td class="teams-cell-actions">${renderMemberActions(member)}</td>`;
h += '</tr>';
return h;
};
const renderMembers = () => {
const annotated = annotateMembers(state.members, state.audit);
const summary = membersBillingSummary(annotated);
let h = '<div class="dashboard-card teams-panel">';
h += renderCardHead(
i18n('teams_accounts'),
i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }),
annotated.length,
);
if ( annotated.length === 0 ) {
h += `<p class="teams-empty">${i18n('teams_no_accounts')}</p>`;
} else {
h += '<div class="teams-table-wrapper"><table class="teams-table teams-members-table"><thead><tr>';
h += `<th>${i18n('teams_member_account')}</th>`;
h += `<th>${i18n('teams_member_state')}</th>`;
h += `<th>${i18n('teams_member_since')}</th>`;
h += `<th>${i18n('teams_member_plan')}</th>`;
h += `<th class="teams-cell-actions"><span class="sr-only">${i18n('teams_member_actions')}</span></th>`;
h += '</tr></thead><tbody>';
for ( const member of sortMembers(annotated) ) h += renderMemberRow(member);
h += '</tbody></table></div>';
}
h += '</div>';
return h;
};
const renderAddAccount = () => {
let h = '<div class="dashboard-card teams-panel">';
h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint'));
h += '<form class="teams-form" novalidate>';
h += '<div class="teams-field">';
h += `<label for="teams-new-username">${i18n('username')}</label>`;
h += '<input id="teams-new-username" class="teams-new-username" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" required>';
h += '</div>';
h += '<div class="teams-field">';
h += `<label for="teams-new-email">${i18n('teams_email_optional')}</label>`;
h += '<input id="teams-new-email" class="teams-new-email" type="email" autocomplete="off" spellcheck="false">';
h += '</div>';
h += `<button type="submit" class="button button-primary teams-add-btn">${i18n('teams_add_account')}</button>`;
h += '</form>';
h += `<p class="teams-field-help">${i18n('teams_add_account_email_hint')}</p>`;
h += '<div class="teams-credential" role="status" aria-live="polite" style="display:none;"></div>';
h += '</div>';
return h;
};
const renderAudit = ({ title, hint }) => {
let h = '<div class="dashboard-card teams-panel">';
h += renderCardHead(title, hint, state.audit.length || null);
if ( state.audit.length === 0 ) {
h += `<p class="teams-empty">${i18n('teams_no_audit')}</p>`;
} else {
const { items, page, pages, from, to, total } = auditSlice(
state.audit,
state.auditPage,
AUDIT_ROWS_PER_PAGE,
);
h += '<div class="teams-table-wrapper"><table class="teams-table teams-audit-table"><thead><tr>';
h += `<th>${i18n('teams_audit_action')}</th>`;
h += `<th>${i18n('teams_audit_account')}</th>`;
h += `<th>${i18n('teams_audit_actor')}</th>`;
h += `<th>${i18n('teams_audit_when')}</th>`;
h += '</tr></thead><tbody>';
for ( const entry of items ) {
const actionKey = auditActionKey(entry.action);
const reasonKey = auditReasonKey(entry.reason);
h += '<tr>';
h += `<td class="teams-cell-action">${actionKey ? i18n(actionKey) : html_encode(entry.action)}`;
if ( reasonKey ) h += ` <span class="teams-audit-reason">${i18n(reasonKey)}</span>`;
h += '</td>';
h += `<td ${labelled('teams_audit_account')}>${html_encode(entry.username ?? '')}</td>`;
h += `<td ${labelled('teams_audit_actor')}>${html_encode(entry.actorUsername ?? '')}</td>`;
h += `<td ${labelled('teams_audit_when')} class="teams-cell-when">${html_encode(dateTimeText(entry.createdAt))}</td>`;
h += '</tr>';
}
h += '</tbody></table></div>';
if ( pages > 1 ) {
h += '<div class="teams-pager">';
h += `<span class="teams-pager-count">${i18n('teams_audit_range', { from, to, total })}</span>`;
h += '<span class="teams-pager-buttons">';
h += `<button class="button button-small teams-audit-prev"${page === 0 ? ' disabled' : ''}>${i18n('previous')}</button>`;
h += `<button class="button button-small teams-audit-next"${page >= pages - 1 ? ' disabled' : ''}>${i18n('next')}</button>`;
h += '</span></div>';
}
}
h += '</div>';
return h;
};
/** Colleagues, by name. No state, no dates, no actions — none are theirs. */
const renderRoster = () => {
let h = '<div class="dashboard-card teams-panel">';
h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null);
if ( state.members.length === 0 ) {
h += `<p class="teams-empty">${i18n('teams_roster_empty')}</p>`;
} else {
h += '<ul class="teams-roster">';
for ( const member of sortMembers(state.members) ) {
h += `<li class="teams-roster-name">${renderIdentity(member.username)}</li>`;
}
h += '</ul>';
}
h += '</div>';
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 = '<div class="dashboard-card dashboard-settings-card teams-directory">';
h += '<div class="dashboard-settings-card-content">';
h += `<div class="dashboard-settings-card-icon">${ICONS.directory}</div>`;
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('teams_directory_label')}</strong>`;
h += `<span class="teams-directory-note">${i18n(on ? 'teams_directory_on_note' : 'teams_directory_off_note')}</span>`;
h += '</div></div>';
h += '<label class="dashboard-switch teams-directory-toggle">';
h += `<input type="checkbox" class="teams-directory-check"${on ? ' checked' : ''} aria-label="${html_encode(i18n('teams_directory_label', [], false))}">`;
h += '<span class="dashboard-switch-slider"></span>';
h += '</label>';
h += '</div>';
return h;
};
const renderRequire2fa = () => {
const on = state.selected?.require2fa === true;
let h = '<div class="dashboard-card dashboard-settings-card teams-require-2fa">';
h += '<div class="dashboard-settings-card-content">';
h += `<div class="dashboard-settings-card-icon">${ICONS.shield}</div>`;
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('teams_require_2fa_label')}</strong>`;
h += `<span class="teams-directory-note">${i18n(on ? 'teams_require_2fa_on_note' : 'teams_require_2fa_off_note')}</span>`;
h += '</div></div>';
h += '<label class="dashboard-switch teams-require-2fa-toggle">';
h += `<input type="checkbox" class="teams-require-2fa-check"${on ? ' checked' : ''} aria-label="${html_encode(i18n('teams_require_2fa_label', [], false))}">`;
h += '<span class="dashboard-switch-slider"></span>';
h += '</label>';
h += '</div>';
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 += '<div class="dashboard-danger-zone">';
h += `<h3>${i18n('teams_danger_zone')}</h3>`;
h += '<div class="dashboard-card dashboard-danger-card">';
h += '<div class="dashboard-danger-card-content"><div class="dashboard-danger-card-info">';
h += `<strong>${i18n('teams_delete_team')}</strong>`;
h += `<span>${i18n('teams_delete_team_hint')}</span>`;
h += '</div></div>';
h += `<button class="button button-danger teams-delete">${i18n('teams_delete_team')}</button>`;
h += '</div></div>';
return h;
};
/** Placeholder cards the size of the ones about to arrive, so nothing jumps. */
const renderSkeleton = () => {
let h = `<div class="teams-skeleton" role="status" aria-busy="true"><span class="sr-only">${i18n('teams_loading')}</span>`;
h += '<div class="dashboard-card teams-panel teams-skeleton-card teams-skeleton-hero"><i class="teams-skeleton-tile"></i><span><i style="width: 40%"></i><i style="width: 25%"></i></span></div>';
for ( let i = 0; i < 2; i++ ) {
h += '<div class="dashboard-card teams-panel teams-skeleton-card"><span><i style="width: 30%"></i><i style="width: 70%"></i><i style="width: 55%"></i></span></div>';
}
h += '</div>';
return h;
};
const renderState = ({ icon, title, hint, action, tone = '' }) => {
let h = `<div class="dashboard-card teams-panel teams-state${tone ? ` teams-state-${tone}` : ''}">`;
h += `<div class="teams-state-art">${icon}</div>`;
h += `<h3>${title}</h3>`;
if ( hint ) h += `<p class="teams-panel-hint">${hint}</p>`;
if ( action ) h += action;
h += '</div>';
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: `<button class="button teams-retry">${i18n('retry')}</button>`,
});
}
if ( state.teams.length === 0 ) {
return renderState({
icon: ICONS.team,
title: i18n('teams_create_team'),
hint: i18n('teams_create_team_hint'),
action: `<button class="button button-primary teams-create">${i18n('teams_create_team')}</button>`,
});
}
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 = `<p>${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}</p>`;
message += `<p>${i18n('teams_seat_limit_upgrade')}</p>`;
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 = '<div class="teams-credential-head">';
h += `<strong>${i18n('teams_credential_heading', { username })}</strong>`;
h += `<button type="button" class="teams-credential-dismiss" aria-label="${html_encode(i18n('close', [], false))}">${ICONS.close}</button>`;
h += '</div>';
h += '<div class="teams-credential-row">';
h += `<code class="teams-credential-value" tabindex="-1">${html_encode(temporaryPassword)}</code>`;
h += `<button type="button" class="button button-small teams-credential-copy" data-value="${html_encode(temporaryPassword)}">${ICONS.copy}<span>${i18n('copy')}</span></button>`;
h += '</div>';
h += `<span class="teams-credential-note">${i18n('teams_credential_once')}</span>`;
$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}<span>${i18n('teams_credential_copied')}</span>`);
setTimeout(() => {
$button.removeClass('teams-copied').html(`${ICONS.copy}<span>${i18n('copy')}</span>`);
}, 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,
`<p>${i18n('teams_reset_2fa_confirm', { username })}</p>`,
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,
`<p>${i18n('teams_confirm_reissue', { username })}</p>`,
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,
`<p>${i18n('teams_confirm_disable', { username })}</p>`,
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,
`<p>${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}</p>`
+ `<p>${i18n('teams_confirm_delete_billing')}</p>`,
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
html () {
let h = '<div class="dashboard-tab-content">';
h += '<div class="dashboard-section-header">';
h += `<h2>${i18n('teams')}</h2>`;
h += `<p>${i18n('teams_subtitle')}</p>`;
h += '</div>';
h += '<div class="dashboard-settings-grid teams-body"></div>';
h += '</div>';
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;
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 = `<div class="dashboard" data-element_uuid="w1">
<a class="dashboard-sidebar-item" data-section="teams"></a>
<div class="dashboard-section dashboard-section-teams">${TabTeams.html()}</div>
</div>`;
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);
});
});
-54
View File
@@ -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-<id>` 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('<svg')).toBe(true);
});
it('has the render and wiring hooks UIDashboard calls', () => {
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('<div class="dashboard-settings-grid teams-body"></div>');
});
});
-2
View File
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
// 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) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
export const TEAM_ACTION_ICONS = {
/** Change plan — a card. */
plan: SVG('<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>'),
/** Reissue credential — a key. */
credential: SVG('<circle cx="8" cy="12" r="4"/><path d="M12 12h9"/><path d="M17 12v4"/><path d="M20 12v3"/>'),
/** Suspend — pause. */
suspend: SVG('<rect x="7" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>'),
/** Re-enable — play. */
enable: SVG('<path d="M7 5l12 7-12 7z"/>'),
/** Delete the account — a bin. */
remove: SVG('<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/>'),
/** Edit the account — a pencil. */
edit: SVG('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
};
/** 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 (
`<button class="button button-small button-icon ${className}` +
`${danger ? ' button-danger' : ''}" title="${safeLabel}"` +
` aria-label="${safeLabel}"${extra}>` +
`${TEAM_ACTION_ICONS[icon] ?? ''}` +
`<span class="sr-only">${safeLabel}</span></button>`
);
};
export default teamActionButton;
@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest';
globalThis.window = { html_encode: (v) => String(v).replace(/[<"]/g, (c) => (c === '<' ? '&lt;' : '&quot;')) };
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('<span class="sr-only">Reissue password</span>');
});
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: '<script>',
attrs: { 'data-username': '"quoted' },
});
expect(h).not.toContain('<script>');
expect(h).not.toContain('data-username=""quoted"');
});
it('renders an empty glyph rather than breaking on an unknown icon', () => {
const h = teamActionButton({ className: 'a', icon: 'nope', label: 'x' });
expect(h).toContain('<button');
expect(h).not.toContain('undefined');
});
it('hides every glyph from the accessibility tree', () => {
// The label is on the button; the svg would be noise.
for (const svg of Object.values(TEAM_ACTION_ICONS)) {
expect(svg).toContain('aria-hidden="true"');
}
});
});
-273
View File
@@ -1,273 +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 <https://www.gnu.org/licenses/>.
*/
// Pure logic behind the team admin console. No DOM, no network.
/**
* Whether the team may permanently delete this account. The API refuses a live
* one, so offering the button before it is disabled only ever produces an error.
*
* @param {{ orgOwned?: boolean, disabled?: boolean }} member
* @returns {boolean}
*/
export function canDeleteAccount (member) {
return member?.orgOwned === true && member?.disabled === true;
}
/** i18n keys for the actions a team's audit log records. */
const AUDIT_ACTION_KEYS = {
provision: 'teams_audit_provision',
disable: 'teams_audit_disable',
enable: 'teams_audit_enable',
delete_team: 'teams_audit_delete_team',
reset_member_password: 'teams_audit_reset_member_password',
activate: 'teams_audit_activate',
delete_account: 'teams_audit_delete_account',
directory_enabled: 'teams_audit_directory_enabled',
directory_disabled: 'teams_audit_directory_disabled',
require_2fa_enabled: 'teams_audit_require_2fa_enabled',
require_2fa_disabled: 'teams_audit_require_2fa_disabled',
reset_member_2fa: 'teams_audit_reset_member_2fa',
};
/** i18n keys for the reasons the team attaches to an action. */
const AUDIT_REASON_KEYS = {
team_deleted: 'teams_audit_reason_team_deleted',
};
/**
* Whether each account is currently suspended, read off the audit log.
*
* The member listing carries no suspension flag, but every suspension and
* restoration is recorded, so the newest `disable`/`enable` per account is the
* current state. Entries arrive newest first, so the first match wins.
*
* @param {Array<{ action: string, username: string | null, reason: string | null, createdAt: string }>} entries
* @returns {Record<string, { disabled: boolean, reason: string | null, at: string }>}
*/
export function memberStatesFromAudit (entries) {
/** @type {Record<string, { disabled: boolean, reason: string | null, at: string }>} */
const states = {};
for ( const entry of entries ?? [] ) {
const username = entry?.username;
if ( ! username || username in states ) continue;
if ( entry.action !== 'disable' && entry.action !== 'enable' ) continue;
states[username] = {
disabled: entry.action === 'disable',
reason: entry.reason ?? null,
at: entry.createdAt,
};
}
return states;
}
/**
* The member listing with each account's suspension state folded in.
*
* Only accounts the team provisioned can be suspended by it, so an
* account that merely joined is always reported active.
*
* @param {Array<{ username: string, orgOwned: boolean, createdAt: string }>} members
* @param {Array<{ action: string, username: string | null, reason: string | null, createdAt: string }>} auditEntries
* @returns {Array<{ username: string, orgOwned: boolean, createdAt: string, disabled: boolean, disabledReason: string | null }>}
*/
export function annotateMembers (members, auditEntries) {
const states = memberStatesFromAudit(auditEntries);
return (members ?? []).map(member => {
const state = member.orgOwned ? states[member.username] : undefined;
return {
...member,
disabled: state?.disabled === true,
disabledReason: state?.disabled === true ? (state.reason ?? null) : null,
};
});
}
/**
* What the team is currently being charged for. Suspended accounts are
* counted separately because they stop costing a per-account charge and keep
* costing for the bytes they hold — an administrator deciding what to clean up
* needs the two apart.
*
* @param {Array<{ orgOwned: boolean, disabled: boolean }>} annotated
* @returns {{ total: number, billed: number, disabled: number, joined: number }}
*/
export function membersBillingSummary (annotated) {
const summary = { total: 0, billed: 0, disabled: 0, joined: 0 };
for ( const member of annotated ?? [] ) {
summary.total++;
if ( ! member.orgOwned ) summary.joined++;
else if ( member.disabled ) summary.disabled++;
else summary.billed++;
}
return summary;
}
/**
* What plan a row in the accounts table is on.
*
* `payer` is the owner, who keeps their personal plan. A suspended seat is
* `not_billed` -- it stops costing a per-account charge. Everyone else follows
* the team: its tier if it bought one, otherwise the reduced free allowance.
*
* @param {{ orgOwned: boolean, disabled: boolean }} member
* @param {{ current: { tier: string, name_en?: string } | null } | null} plan
* @returns {{ kind: 'payer'|'not_billed'|'free'|'tier', name?: string }}
*/
export function memberPlanLabel (member, plan) {
if ( ! member?.orgOwned ) return { kind: 'payer' };
if ( member.disabled ) return { kind: 'not_billed' };
// Per seat: a team can buy for some accounts and not others.
const tier = plan?.seatTiers?.[member.uuid];
if ( ! tier ) return { kind: 'free' };
const offering = (plan.offerings ?? []).find(o => o.tier === tier);
return { kind: 'tier', name: offering?.name_en || tier };
}
/**
* The i18n key for an audit action, or `null` for one this build does not know
* about — a new backend action must show as itself rather than as nothing.
*
* @param {string} action
* @returns {string | null}
*/
export function auditActionKey (action) {
return AUDIT_ACTION_KEYS[action] ?? null;
}
/**
* The i18n key for a recorded reason, or `null` when there is none to show.
*
* @param {string | null} reason
* @returns {string | null}
*/
export function auditReasonKey (reason) {
return reason ? (AUDIT_REASON_KEYS[reason] ?? null) : null;
}
/**
* Members ordered for display: suspended accounts last, then provisioned
* before joined, then by username. Suspended accounts stay on the list rather
* than being hidden, because they are still on the bill.
*
* @template {{ username: string, orgOwned: boolean, disabled: boolean }} T
* @param {T[]} annotated
* @returns {T[]}
*/
export function sortMembers (annotated) {
return [...(annotated ?? [])].sort((a, b) => {
if ( a.disabled !== b.disabled ) return a.disabled ? 1 : -1;
if ( a.orgOwned !== b.orgOwned ) return a.orgOwned ? -1 : 1;
return a.username.localeCompare(b.username);
});
}
/**
* One page of the record, with the numbers the pager prints. Clamps the page:
* deleting an account shortens the record, and a stale number would otherwise
* show an empty table with no way back.
*
* @template T
* @param {T[]} entries
* @param {number} page - Zero-based.
* @param {number} size
* @returns {{ items: T[], page: number, pages: number, from: number, to: number, total: number }}
*/
export function auditSlice (entries, page, size) {
const all = entries ?? [];
const total = all.length;
const perPage = size > 0 ? size : 1;
const pages = Math.max(1, Math.ceil(total / perPage));
const current = Math.min(Math.max(0, Math.trunc(page) || 0), pages - 1);
const start = current * perPage;
const items = all.slice(start, start + perPage);
return {
items,
page: current,
pages,
from: total === 0 ? 0 : start + 1,
to: start + items.length,
total,
};
}
/**
* A timestamp off the wire as a Date, or `null` when it is not one. Team
* audit rows carry unix seconds; members and teams carry an ISO string, so
* one reader covers both rather than each column guessing.
*
* @param {unknown} value
* @returns {Date | null}
*/
export function parseTimestamp (value) {
if ( value === null || value === undefined || value === '' ) return null;
let date;
if ( typeof value === 'number' ) {
// Anything below 1e12 cannot be milliseconds for a date after 2001.
date = new Date(value < 1e12 ? value * 1000 : value);
} else if ( typeof value === 'string' && /^\d+$/.test(value) ) {
return parseTimestamp(Number(value));
} else {
date = new Date(/** @type {string} */ (value));
}
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* The letter an avatar tile shows for a name. Uppercased so a lowercase
* username and a display name read the same in a row of tiles.
*
* @param {unknown} name
* @returns {string}
*/
export function initialOf (name) {
const text = typeof name === 'string' ? name.trim() : '';
if ( ! text ) return '?';
// A leading handle sigil or bracket says nothing about the name.
const letter = text.replace(/^[^\p{L}\p{N}]+/u, '') || text;
return [...letter][0].toUpperCase();
}
/**
* Which billing sentence fits the summary. The count of suspended accounts
* only earns a mention when there are any -- "0 suspended cost nothing" is
* noise on a healthy team.
*
* @param {{ billed: number, disabled: number }} summary
* @returns {'teams_billing_summary_one'|'teams_billing_summary'|'teams_billing_summary_one_none'|'teams_billing_summary_none'}
*/
export function billingSummaryKey (summary) {
const one = summary?.billed === 1;
if ( (summary?.disabled ?? 0) > 0 ) return one ? 'teams_billing_summary_one' : 'teams_billing_summary';
return one ? 'teams_billing_summary_one_none' : 'teams_billing_summary_none';
}
/**
* A stable hue for a name's avatar tile, so the same account gets the same
* colour in every row and the tiles are told apart at a glance.
*
* @param {unknown} name
* @returns {number} degrees, 0-359
*/
export function avatarHue (name) {
const text = typeof name === 'string' ? name : '';
let hash = 0;
for ( const ch of text ) hash = (hash * 31 + ch.codePointAt(0)) % 360;
return hash;
}
@@ -1,321 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
annotateMembers,
auditActionKey,
auditSlice,
avatarHue,
billingSummaryKey,
canDeleteAccount,
auditReasonKey,
initialOf,
memberStatesFromAudit,
parseTimestamp,
membersBillingSummary,
memberPlanLabel,
sortMembers,
} from './teamsConsole.js';
/** The audit log arrives newest first, which is what these fixtures encode. */
const entry = (action, username, createdAt, reason = null) =>
({ action, username, reason, createdAt, actorUsername: 'boss' });
const member = (username, orgOwned = true) =>
({ username, orgOwned, createdAt: '2026-01-01T00:00:00Z' });
describe('memberStatesFromAudit', () => {
it('takes the newest disable/enable per account', () => {
const states = memberStatesFromAudit([
entry('enable', 'ann', '2026-03-01T00:00:00Z'),
entry('disable', 'ann', '2026-02-01T00:00:00Z'),
entry('disable', 'bob', '2026-02-01T00:00:00Z'),
entry('provision', 'bob', '2026-01-01T00:00:00Z'),
]);
expect(states.ann.disabled).toBe(false);
expect(states.bob.disabled).toBe(true);
});
it('carries the recorded reason and time of the suspension', () => {
const states = memberStatesFromAudit([
entry('disable', 'ann', '2026-02-01T00:00:00Z', 'team_deleted'),
]);
expect(states.ann).toEqual({
disabled: true,
reason: 'team_deleted',
at: '2026-02-01T00:00:00Z',
});
});
it('ignores actions that do not move an account, and entries with no account', () => {
const states = memberStatesFromAudit([
entry('delete_team', null, '2026-03-01T00:00:00Z'),
entry('provision', 'ann', '2026-01-01T00:00:00Z'),
]);
expect(states).toEqual({});
});
it('survives an empty or missing log', () => {
expect(memberStatesFromAudit([])).toEqual({});
expect(memberStatesFromAudit(undefined)).toEqual({});
});
});
describe('annotateMembers', () => {
it('marks a suspended account and keeps it on the list', () => {
const annotated = annotateMembers(
[member('ann'), member('bob')],
[entry('disable', 'bob', '2026-02-01T00:00:00Z', 'team_deleted')],
);
expect(annotated).toHaveLength(2);
expect(annotated.find(m => m.username === 'ann').disabled).toBe(false);
expect(annotated.find(m => m.username === 'bob')).toMatchObject({
disabled: true,
disabledReason: 'team_deleted',
});
});
it('never reports an account the team does not own as suspended', () => {
// A joined account can be suspended by Puter, which is not the
// team's to show or to lift.
const annotated = annotateMembers(
[member('carol', false)],
[entry('disable', 'carol', '2026-02-01T00:00:00Z')],
);
expect(annotated[0].disabled).toBe(false);
});
it('clears the reason once an account is restored', () => {
const annotated = annotateMembers(
[member('ann')],
[
entry('enable', 'ann', '2026-03-01T00:00:00Z'),
entry('disable', 'ann', '2026-02-01T00:00:00Z', 'team_deleted'),
],
);
expect(annotated[0]).toMatchObject({ disabled: false, disabledReason: null });
});
});
describe('membersBillingSummary', () => {
it('separates what is charged per account from what only costs storage', () => {
const annotated = annotateMembers(
[member('ann'), member('bob'), member('cat'), member('dan', false)],
[entry('disable', 'bob', '2026-02-01T00:00:00Z')],
);
expect(membersBillingSummary(annotated)).toEqual({
total: 4,
billed: 2,
disabled: 1,
joined: 1,
});
});
it('counts nothing for an empty team', () => {
expect(membersBillingSummary([])).toEqual({ total: 0, billed: 0, disabled: 0, joined: 0 });
});
});
describe('the delete gate', () => {
// The API refuses a live account, so the button must not exist for one.
it('offers deletion only for a disabled account the team owns', () => {
expect(canDeleteAccount({ orgOwned: true, disabled: true })).toBe(true);
expect(canDeleteAccount({ orgOwned: true, disabled: false })).toBe(false);
});
it('never offers it for an account the team merely admitted', () => {
expect(canDeleteAccount({ orgOwned: false, disabled: true })).toBe(false);
expect(canDeleteAccount(undefined)).toBe(false);
});
});
describe('audit labels', () => {
it('names the actions the team records', () => {
expect(auditActionKey('provision')).toBe('teams_audit_provision');
expect(auditActionKey('disable')).toBe('teams_audit_disable');
expect(auditActionKey('enable')).toBe('teams_audit_enable');
expect(auditActionKey('delete_team')).toBe('teams_audit_delete_team');
});
// The whole vocabulary `TeamService` can append, so an action added there
// is not left rendering as its raw enum in the one view meant to read well.
it.each([
'provision', 'disable', 'enable', 'delete_team',
'reset_member_password', 'activate', 'delete_account',
'directory_enabled', 'directory_disabled',
])('has a label for %s', (action) => {
expect(auditActionKey(action)).not.toBeNull();
});
it('returns null for an action this build does not know, so it shows as itself', () => {
expect(auditActionKey('something_new')).toBeNull();
});
it('names a recorded reason, and nothing when there is none', () => {
expect(auditReasonKey('team_deleted')).toBe('teams_audit_reason_team_deleted');
expect(auditReasonKey('unrecognized')).toBeNull();
expect(auditReasonKey(null)).toBeNull();
});
});
describe('sortMembers', () => {
it('puts suspended accounts last, provisioned before joined, then alphabetical', () => {
const annotated = annotateMembers(
[member('zoe'), member('ann'), member('carol', false), member('bob')],
[entry('disable', 'ann', '2026-02-01T00:00:00Z')],
);
expect(sortMembers(annotated).map(m => m.username)).toEqual(['bob', 'zoe', 'carol', 'ann']);
});
it('does not mutate the input', () => {
const annotated = annotateMembers([member('zoe'), member('ann')], []);
sortMembers(annotated);
expect(annotated.map(m => m.username)).toEqual(['zoe', 'ann']);
});
});
describe('what plan a row in the accounts table shows', () => {
const plan = {
seatTiers: { 'u-1': 'team-basic', 'u-2': 'team-pro' },
offerings: [
{ tier: 'team-basic', name_en: 'Team Basic' },
{ tier: 'team-pro', name_en: 'Team Pro' },
],
};
it('names the tier that seat is on', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' }, plan))
.toEqual({ kind: 'tier', name: 'Team Basic' });
});
it('lets two seats be on different tiers', () => {
// The whole point of per-seat: one team, two plans.
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-2' }, plan))
.toEqual({ kind: 'tier', name: 'Team Pro' });
});
it('falls back to the tier id when the catalogue has no name', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' },
{ seatTiers: { 'u-1': 'team-basic' }, offerings: [] }))
.toEqual({ kind: 'tier', name: 'team-basic' });
});
it('says free for a seat nobody bought a tier for', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-9' }, plan))
.toEqual({ kind: 'free' });
});
it('says the owner is the payer, not a seat', () => {
expect(memberPlanLabel({ orgOwned: false, uuid: 'u-1' }, plan))
.toEqual({ kind: 'payer' });
});
it('says a suspended seat is not billed, whatever it was on', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1', disabled: true }, plan))
.toEqual({ kind: 'not_billed' });
});
it('says free when nothing is bought at all', () => {
for (const p of [null, undefined, { seatTiers: {} }]) {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' }, p))
.toEqual({ kind: 'free' });
}
});
});
describe('paging the record', () => {
const rows = (n) => Array.from({ length: n }, (_, i) => ({ id: i }));
it('cuts the list into pages and numbers them for the reader', () => {
const page = auditSlice(rows(25), 1, 10);
expect(page.items).toHaveLength(10);
expect(page.items[0].id).toBe(10);
expect(page).toMatchObject({ page: 1, pages: 3, from: 11, to: 20, total: 25 });
});
it('reports a short last page honestly', () => {
expect(auditSlice(rows(25), 2, 10)).toMatchObject({ from: 21, to: 25, pages: 3 });
});
it('clamps a page the record has shrunk past', () => {
// Deleting an account shortens the record; a stale page number would
// otherwise show an empty table with no way back.
const page = auditSlice(rows(5), 9, 10);
expect(page.page).toBe(0);
expect(page.items).toHaveLength(5);
});
it('clamps a negative or nonsensical page rather than throwing', () => {
expect(auditSlice(rows(5), -3, 10).page).toBe(0);
expect(auditSlice(rows(5), NaN, 10).page).toBe(0);
});
it('says 0 of 0 for an empty record, not 1 of 0', () => {
expect(auditSlice([], 0, 10)).toMatchObject({ from: 0, to: 0, total: 0, pages: 1 });
expect(auditSlice(undefined, 0, 10).items).toEqual([]);
});
it('never divides by a zero page size', () => {
expect(auditSlice(rows(3), 0, 0).items).toHaveLength(1);
});
});
describe('parseTimestamp', () => {
it('reads the unix seconds the audit routes send', () => {
expect(parseTimestamp(1789672096)?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
});
it('reads milliseconds and ISO strings, which members and teams use', () => {
expect(parseTimestamp(1789672096000)?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
expect(parseTimestamp('2026-09-17T19:08:16Z')?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
expect(parseTimestamp('1789672096')?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
});
it('is null for nothing or garbage, so a cell prints empty rather than 1970 or Invalid Date', () => {
expect(parseTimestamp(null)).toBeNull();
expect(parseTimestamp(undefined)).toBeNull();
expect(parseTimestamp('')).toBeNull();
expect(parseTimestamp('soon')).toBeNull();
});
});
describe('initialOf', () => {
it('uppercases the first letter', () => {
expect(initialOf('dana')).toBe('D');
expect(initialOf('Acme Design')).toBe('A');
});
it('skips a leading sigil and copes with non-Latin names', () => {
expect(initialOf('@acme')).toBe('A');
expect(initialOf(' élan')).toBe('É');
expect(initialOf('日本')).toBe('日');
});
it('falls back to a question mark for nothing', () => {
expect(initialOf('')).toBe('?');
expect(initialOf(undefined)).toBe('?');
});
});
describe('avatarHue', () => {
it('is stable for a name and within the colour wheel', () => {
expect(avatarHue('dana')).toBe(avatarHue('dana'));
expect(avatarHue('dana')).toBeGreaterThanOrEqual(0);
expect(avatarHue('dana')).toBeLessThan(360);
expect(avatarHue(undefined)).toBe(0);
});
it('usually differs between neighbouring names', () => {
expect(avatarHue('dana')).not.toBe(avatarHue('elliot'));
});
});
describe('billingSummaryKey', () => {
it('drops the suspended clause when nobody is suspended', () => {
expect(billingSummaryKey({ billed: 3, disabled: 0 })).toBe('teams_billing_summary_none');
expect(billingSummaryKey({ billed: 1, disabled: 0 })).toBe('teams_billing_summary_one_none');
});
it('keeps it when there is something to say', () => {
expect(billingSummaryKey({ billed: 3, disabled: 2 })).toBe('teams_billing_summary');
expect(billingSummaryKey({ billed: 1, disabled: 1 })).toBe('teams_billing_summary_one');
});
});
+1 -125
View File
@@ -569,141 +569,17 @@ const en = {
storage_usage: 'Storage Usage',
storage_puter_used: 'used by Puter',
'your_plan': 'Your Plan',
teams_account_of: 'This account belongs to %%',
plan_team_seat: 'Team account',
plan_team_managed: 'Managed by %%',
taking_longer_than_usual: 'Taking a little longer than usual. Please wait...',
teams: 'Teams',
teams_subtitle: 'The accounts your team pays for',
teams_team: 'Team',
teams_team_name: 'Team name',
teams_untitled: 'Untitled team',
teams_loading: 'Loading your team…',
teams_load_failed: 'Could not load your team.',
teams_rename: 'Rename',
teams_rename_prompt: 'What should this team be called?',
teams_create_team: 'Create a team',
teams_create_team_hint:
'A team pays for the accounts you create in it. You stay its only administrator.',
teams_create_team_prompt: 'What should the team be called?',
teams_role_owner: 'Owner',
teams_role_member: 'Member',
teams_member_count_one: '1 account',
teams_member_count: '{{count}} accounts',
teams_member_account: 'Account',
teams_credential_copied: 'Copied',
teams_billing_summary_one_none: 'You’re billed for 1 active account.',
teams_billing_summary_none: 'You’re billed for {{billed}} active accounts.',
email_needed_to_create_token: 'This account has no email address, which tokens require.',
payments_not_configured: 'Payments are not configured on this deployment.',
could_not_start_checkout: 'Could not start checkout. Please try again.',
checkout_failed: 'Checkout failed.',
payment_still_processing: 'Payment received. Your plan can take a moment to appear — it is safe to close this.',
team_pays_for_account: '%% pays for this account. Ask a team admin to change its plan.',
team_pays_for_account_generic: 'Your team pays for this account. Ask a team admin to change its plan.',
teams_plan_current: 'Current plan',
teams_plan_unavailable: 'Not available',
teams_choose_plan_for: 'Choose a plan for %%',
teams_billed_per_account: "Billed per account, per month, on the team's invoice",
teams_upgrade_to: 'Upgrade to %%',
teams_switch_to: 'Switch to %%',
teams_switch_to_free: 'Switch to Free',
teams_keep_plan: 'Keep %%',
teams_plan_ending: 'Ends with the current period',
teams_cancel_keeps_until: 'Done. The plan stays active until the end of the period it was paid for, then the account moves to Free.',
teams_audit_range: '{{from}}–{{to}} of {{total}}',
previous: 'Previous',
next: 'Next',
teams_seat_limit: 'Your plan covers {{limit}} accounts on this team, and they are all taken.',
teams_seat_limit_upgrade: 'Upgrading raises the limit. The accounts you already have are untouched.',
teams_see_plans: 'See plans',
teams_roster: 'Who else is here',
teams_roster_hint: 'Everyone on this team. You can share files with any of them.',
teams_roster_empty: 'Nobody else yet.',
teams_accounts: 'Accounts',
teams_no_accounts: 'This team has no accounts yet.',
teams_account_of: 'This account belongs to %%',
teams_add_account: 'Add an account',
teams_email_optional: 'Email (optional)',
teams_add_account_email_hint: 'If you add an address, we email them their username — never the password. The password below is the only copy, so pass it on yourself.',
teams_add_account_hint:
'Puter creates the account and gives you a one-time password to pass on. The username has to be free across all of Puter.',
teams_plan_change_for: 'Which plan for {{username}}?',
teams_plan_change: 'Change plan',
teams_member_plan: 'Plan',
teams_member_plan_payer: '— payer',
teams_member_plan_not_billed: 'Not billed',
teams_member_plan_free: 'Free',
teams_member_kind: 'Kind',
teams_member_state: 'State',
teams_member_since: 'Added',
teams_member_actions: 'Actions',
teams_member_provisioned: 'Provisioned',
teams_member_joined: 'Joined',
teams_member_state_active: 'Active',
teams_member_state_disabled: 'Suspended',
teams_billing_summary_one:
'You’re billed for 1 active account. {{disabled}} suspended cost nothing per account, but you’re still billed for the files they hold.',
teams_billing_summary:
'You’re billed for {{billed}} active accounts. {{disabled}} suspended cost nothing per account, but you’re still billed for the files they hold.',
teams_reissue_credential: 'Reissue password',
teams_disable_account: 'Suspend',
teams_enable_account: 'Restore',
teams_credential_heading: 'One-time password for {{username}}',
teams_credential_once:
'This is the only time it’s shown. Pass it on yourself — they’ll have to change it when they sign in.',
teams_confirm_reissue:
'Give {{username}} a new one-time password? Their old one stops working, they’ll be emailed about it, and it goes in this team’s record. It only works before they’ve ever signed in — after that they reset it themselves.',
teams_confirm_disable:
'Suspend {{username}}? They’re signed out and can’t sign back in. Their files are untouched, and you can restore them at any time.',
teams_audit: 'Record',
teams_audit_hint: 'What this team has done to its accounts.',
teams_no_audit: 'Nothing has happened yet.',
teams_audit_when: 'When',
teams_audit_action: 'What',
teams_audit_account: 'Account',
teams_audit_actor: 'By',
teams_audit_provision: 'Account created',
teams_audit_disable: 'Suspended',
teams_audit_enable: 'Restored',
teams_audit_delete_team: 'Team deleted',
teams_audit_reset_member_password: 'Password reset',
teams_audit_activate: 'Chose their own password',
teams_audit_delete_account: 'Account deleted',
teams_audit_directory_enabled: 'Directory opened to apps',
teams_audit_directory_disabled: 'Directory closed to apps',
teams_directory: 'Directory',
teams_directory_hint: 'Whether apps your members use may access team details and look up who else is here.',
teams_directory_label: 'Let apps access team information',
teams_directory_on_note: 'Apps your members use can access team details and read usernames of everyone active here. They cannot see emails, records, or suspended accounts.',
teams_directory_off_note: 'Members can still see each other. Only apps are shut out.',
teams_directory_confirm: 'Apps your members install will be able to access team details and read the usernames of everyone active in this team. They will not see emails, records, or suspended accounts. You can turn this off again at any time.',
teams_directory_confirm_action: 'Open the directory',
teams_require_2fa_label: 'Require two-factor authentication',
teams_require_2fa_on_note: 'Accounts this team created must set it up before they can use Puter.',
teams_require_2fa_off_note: 'Members choose for themselves whether to use two-factor authentication.',
teams_require_2fa_confirm: 'Every account this team created will be asked to set up two-factor authentication, and cannot use Puter until it has. Accounts that people joined with are not affected. You can turn this off again at any time.',
teams_require_2fa_confirm_action: 'Require two-factor authentication',
teams_audit_require_2fa_enabled: 'Two-factor authentication required',
teams_audit_require_2fa_disabled: 'Two-factor authentication no longer required',
teams_audit_reset_member_2fa: 'Two-factor authentication reset',
teams_reset_2fa: 'Reset 2FA',
teams_reset_2fa_confirm: 'This clears two-factor authentication on {{username}}, so they can set it up again on a new device. They will be signed out everywhere and told it happened. Their password is unchanged.',
teams_reset_2fa_confirm_action: 'Reset 2FA',
teams_audit_reason_team_deleted: '(team deleted)',
teams_your_record: 'Your record',
teams_your_record_hint:
'What {{team}} has done to your account. Only you and its administrator can see this.',
teams_delete_account: 'Delete account',
teams_confirm_delete_account:
'Permanently delete {{username}}? Their files are removed, their username is released, and this cannot be undone. It is also the only thing that stops the storage charge for what they held.',
teams_danger_zone: 'Danger zone',
teams_delete_team: 'Delete team',
teams_delete_team_hint:
'Suspends every account this team created. Recovering them takes a support request.',
teams_confirm_delete_team:
'Delete {{team}}? Every account it created is suspended, and getting them back takes a support request.',
teams_confirm_delete_billing:
'This stops the per-account charges. It does not stop the storage charges — the files those accounts hold are still billed to you until they’re deleted.',
task_manager: 'Task Manager',
taskmgr_header_name: 'Name',
taskmgr_header_status: 'Status',
@@ -14,6 +14,12 @@
* Suites can read the resulting tags from `t.env.capabilities`.
*/
import { fileURLToPath } from 'node:url';
const IN_TREE_EXTENSIONS = fileURLToPath(
new URL('../../../../../extensions', import.meta.url),
);
type ConfigOverrides = Record<string, unknown>;
type CapabilityMapping = {
@@ -41,6 +47,19 @@ const MAPPINGS: CapabilityMapping[] = [
envVars: ['PUTER_TEST_WISP_SERVER'],
configOverrides: ([server]) => ({ wisp: { server } }),
},
{
// Teams registers its own routes, so the suite needs a build that
// provides them: the var points at a directory supplying the
// feature. Arrays replace on merge, so the in-tree directory the
// client env always loads has to be named again alongside it.
capability: 'teams',
envVars: ['PUTER_TEST_TEAMS_EXTENSION'],
configOverrides: ([dir]) => ({
teams_enabled: true,
import_ts_extensions: true,
extensions: [IN_TREE_EXTENSIONS, dir],
}),
},
aiProvider('ai.openai', 'PUTER_TEST_AI_OPENAI_API_KEY', 'openai-completion'),
aiProvider('ai.claude', 'PUTER_TEST_AI_CLAUDE_API_KEY', 'claude'),
aiProvider('ai.gemini', 'PUTER_TEST_AI_GEMINI_API_KEY', 'gemini'),
+26 -3
View File
@@ -1,5 +1,28 @@
import { suite } from '../harness/types.ts';
import type { TestContext } from '../harness/types.ts';
import type {
SuiteTest,
SuiteTestSpec,
TestContext,
} from '../harness/types.ts';
/**
* Teams registers its own routes, so every test here needs a build that has
* it. Applied to the whole suite rather than each test: without the
* capability the runners report them skipped, not failed.
*/
const CLIENT_SIDE = new Set([
'create with a blank name rejects before reaching the server',
]);
const needsTeams = (
tests: Record<string, SuiteTest>,
): Record<string, SuiteTestSpec> =>
Object.fromEntries(
Object.entries(tests).map(([name, fn]) => [
name,
CLIENT_SIDE.has(name) ? fn : { requires: ['teams'], fn },
]),
);
/**
* Handles and usernames are unique across the whole deployment, and the suites
@@ -12,7 +35,7 @@ type Team = { uid: string; name: string | null; handle: string | null; isOwner:
const makeTeam = async (t: TestContext, label: string): Promise<Team> =>
(await t.puter.teams.create({ name: `Teams Suite ${label}`, handle: `ts-${label}-${tag()}` })) as Team;
export default suite('teams', {
export default suite('teams', needsTeams({
'create returns the team with the caller as owner': async (t) => {
const team = await makeTeam(t, 'create');
t.assert.ok(typeof team.uid === 'string' && team.uid.length > 0, 'uid should be set');
@@ -244,4 +267,4 @@ export default suite('teams', {
});
t.assert.ok(resp.status >= 400, `a non-member read should be refused, got ${resp.status}`);
},
});
}));
+1 -38
View File
@@ -1,54 +1,17 @@
{
"extensions/whoami.ts | TS2353": 1,
"src/backend/clients/database/MySQLDatabaseClient.ts | TS2769": 1,
"src/backend/clients/database/PostgresDatabaseClient.ts | TS2322": 1,
"src/backend/clients/s3/S3Client.ts | TS2307": 2,
"src/backend/controllers/auth/AuthController.ts | TS2345": 1,
"src/backend/controllers/auth/AuthController.ts | TS7018": 2,
"src/backend/controllers/fs/FSController.ts | TS7011": 3,
"src/backend/controllers/fs/FSController.ts | TS7011": 1,
"src/backend/controllers/homepage/HomepageController.ts | TS2352": 1,
"src/backend/controllers/wisp/WispController.ts | TS2345": 1,
"src/backend/core/http/__typecheck__.ts | TS2578": 1,
"src/backend/core/http/middleware/errorHandler.ts | TS2345": 1,
"src/backend/drivers/ai-chat/ChatCompletionDriver.ts | TS2322": 10,
"src/backend/drivers/ai-chat/ChatCompletionDriver.ts | TS2345": 1,
"src/backend/drivers/ai-chat/providers/ChatProvider.ts | TS2420": 1,
"src/backend/drivers/ai-chat/providers/FakeChatProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/FakeChatProvider.ts | TS7018": 1,
"src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts | TS2352": 1,
"src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2322": 1,
"src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2352": 1,
"src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2353": 1,
"src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS7006": 1,
"src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts | TS7011": 1,
"src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts | TS7006": 1,
"src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts | TS7006": 3,
"src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts | TS7053": 1,
"src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts | TS2352": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2322": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2352": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2353": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS7006": 2,
"src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts | TS7031": 6,
"src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts | TS7053": 1,
"src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts | TS2416": 1,
"src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts | TS7053": 1,
"src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts | TS2352": 2,
"src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts | TS2322": 1,
"src/backend/drivers/integrationTestUtil.ts | TS7011": 1,
"src/backend/services/apps/AppPermissionService.ts | TS2345": 2,
"src/backend/services/auth/OIDCService.ts | TS2322": 2,
"src/backend/services/broadcast/BroadcastService.ts | TS2345": 2,
"src/backend/services/fs/FSService.ts | TS2345": 1,
"src/backend/services/homepage/PuterHomepageService.ts | TS2345": 1,
"src/backend/services/localworker/LocalWorkerService.ts | TS7018": 3,
"src/backend/services/notification/NotificationService.ts | TS2322": 1,
"src/backend/stores/fs/S3ObjectStore.ts | TS7011": 1,
"src/backend/testUtil.ts | TS1470": 1,
"src/backend/testUtil.ts | TS2345": 1,