';
h += `
${i18n('teams_danger_zone')} `;
@@ -298,21 +376,49 @@ const renderOwnerView = () => {
return h;
};
+/** Placeholder cards the size of the ones about to arrive, so nothing jumps. */
+const renderSkeleton = () => {
+ let h = `
${i18n('teams_loading')} `;
+ h += '
';
+ for ( let i = 0; i < 2; i++ ) {
+ h += '
';
+ }
+ h += '
';
+ return h;
+};
+
+const renderState = ({ icon, title, hint, action, tone = '' }) => {
+ let h = `
`;
+ h += `
${icon}
`;
+ h += `
${title} `;
+ if ( hint ) h += `
${hint}
`;
+ if ( action ) h += action;
+ h += '
';
+ return h;
+};
+
const renderBody = () => {
// Nothing at all where teams are off: an empty panel is the honest
// rendering of a feature this deployment does not have.
if ( state.status === 'unavailable' ) return '';
- if ( state.status === 'loading' ) return `
${i18n('teams_loading')}
`;
- if ( state.status === 'error' ) return `
${i18n('teams_load_failed')}
`;
- if ( state.teams.length === 0 ) {
- let h = '
';
- h += `
${i18n('teams_create_team')} `;
- h += `
${i18n('teams_create_team_hint')}
`;
- h += `
${i18n('teams_create_team')} `;
- h += '
';
- return h;
+ if ( state.status === 'loading' ) return renderSkeleton();
+ if ( state.status === 'error' ) {
+ return renderState({
+ icon: ICONS.warning,
+ tone: 'warning',
+ title: i18n('teams_load_failed'),
+ action: `
${i18n('retry')} `,
+ });
}
- return renderTeamPicker() + (state.selected?.isOwner ? renderOwnerView() : renderMemberView());
+ if ( state.teams.length === 0 ) {
+ return renderState({
+ icon: ICONS.team,
+ title: i18n('teams_create_team'),
+ hint: i18n('teams_create_team_hint'),
+ action: `
${i18n('teams_create_team')} `,
+ });
+ }
+ return state.selected?.isOwner ? renderOwnerView() : renderMemberView();
};
const paint = ($el_window) => {
@@ -457,10 +563,33 @@ const confirm = async ($el_window, message, label, kind = 'danger') => {
/** 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 = `
${i18n('teams_credential_heading', { username })} `;
- h += `
${html_encode(temporaryPassword)}`;
- h += `
${i18n('teams_credential_once')} `;
+ let h = '
';
+ h += `${i18n('teams_credential_heading', { username })} `;
+ h += `${ICONS.close} `;
+ h += '
';
+ h += '
';
+ h += `${html_encode(temporaryPassword)}`;
+ h += `${ICONS.copy}${i18n('copy')} `;
+ h += '
';
+ h += `
${i18n('teams_credential_once')} `;
$box.html(h).show();
+ $box.find('.teams-credential-value').trigger('focus');
+};
+
+/** Confirms on the button itself; a toast would appear away from the value. */
+const copyCredential = async ($button) => {
+ try {
+ await navigator.clipboard.writeText($button.attr('data-value') ?? '');
+ } catch {
+ // Selecting the value is the fallback: it is one keystroke from copied.
+ const node = $button.closest('.teams-credential').find('.teams-credential-value')[0];
+ if ( node ) window.getSelection()?.selectAllChildren(node);
+ return;
+ }
+ $button.addClass('teams-copied').html(`${ICONS.check}
${i18n('teams_credential_copied')} `);
+ setTimeout(() => {
+ $button.removeClass('teams-copied').html(`${ICONS.copy}
${i18n('copy')} `);
+ }, 1800);
};
const addAccount = async ($el_window) => {
@@ -470,7 +599,7 @@ const addAccount = async ($el_window) => {
const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim();
const $button = $el_window.find(`${SECTION} .teams-add-btn`);
- $button.prop('disabled', true);
+ $button.prop('disabled', true).addClass('teams-busy');
try {
const created = await puter.teams.createMember(state.selected.uid, {
username,
@@ -481,7 +610,7 @@ const addAccount = async ($el_window) => {
} catch (e) {
await showError($el_window, e);
} finally {
- $el_window.find(`${SECTION} .teams-add-btn`).prop('disabled', false);
+ $el_window.find(`${SECTION} .teams-add-btn`).prop('disabled', false).removeClass('teams-busy');
}
};
@@ -639,7 +768,22 @@ const TabTeams = {
state.auditPage += 1;
paint($el_window);
});
- $el_window.on('click', `${SECTION} .teams-add-btn`, () => addAccount($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));
diff --git a/src/gui/src/UI/Dashboard/TabTeams.render.test.js b/src/gui/src/UI/Dashboard/TabTeams.render.test.js
new file mode 100644
index 000000000..9cb96449f
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/TabTeams.render.test.js
@@ -0,0 +1,191 @@
+// @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('../UIAlert.js', () => ({ default: vi.fn() }));
+vi.mock('../UIPrompt.js', () => ({ default: vi.fn() }));
+
+globalThis.$ = jQuery;
+globalThis.jQuery = jQuery;
+globalThis.i18n = (key, replacements) => {
+ if ( replacements && typeof replacements === 'object' && ! Array.isArray(replacements) ) {
+ return `${key}(${Object.entries(replacements).map(([k, v]) => `${k}=${v}`).join(',')})`;
+ }
+ return key;
+};
+globalThis.html_encode = (value) => String(value ?? '')
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+window.html_encode = globalThis.html_encode;
+
+const { default: TabTeams } = await import('./TabTeams.js');
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 0));
+
+const team = { uid: 't1', name: 'Acme Design', handle: 'acme', isOwner: true, directoryEnabled: false };
+const members = [
+ { username: 'dana', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-dana' },
+ { username: 'boss', orgOwned: false, createdAt: '2026-09-01T00:00:00Z' },
+ { username: 'elliot', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-elliot' },
+];
+// Newest first, as the API sends it; unix seconds, as the audit routes send it.
+const audit = [
+ { action: 'disable', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789672096 },
+ { action: 'provision', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
+ { action: 'provision', username: 'dana', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
+];
+
+const mount = async ({ teams = [team], plan = null } = {}) => {
+ window.teams_ui = true;
+ window.team_billing_ui = false;
+ window.api_origin = 'http://api.test';
+ window.user = { username: 'boss' };
+ globalThis.puter = {
+ authToken: 'tok',
+ teams: {
+ list: vi.fn(async () => teams),
+ listAudit: vi.fn(async () => audit),
+ listOwnAudit: vi.fn(async () => audit.filter(e => e.username === 'boss')),
+ listMembers: vi.fn(async () => members),
+ },
+ };
+ globalThis.fetch = vi.fn(async () => ({ ok: false }));
+ if ( plan ) globalThis.fetch = plan;
+
+ document.body.innerHTML = `
`;
+ const $el_window = $('.dashboard');
+ TabTeams.init($el_window);
+ await flush();
+ await flush();
+ return $el_window;
+};
+
+describe('the owner view', () => {
+ let $w;
+ beforeEach(async () => { $w = await mount(); });
+
+ it('names the team, its role and headcount in the hero', () => {
+ const hero = $w.find('.teams-hero');
+ expect(hero.find('strong').text()).toBe('Acme Design');
+ expect(hero.find('.teams-role').text()).toBe('teams_role_owner');
+ expect(hero.find('.teams-info-meta').text()).toContain('@acme');
+ expect(hero.find('.teams-info-meta').text()).toContain('teams_member_count(count=3)');
+ expect(hero.find('.teams-rename')).toHaveLength(1);
+ // One team: no picker to choose it with.
+ expect(hero.find('.teams-picker-select')).toHaveLength(0);
+ });
+
+ it('renders the directory as a switch that still carries the wired class', () => {
+ const input = $w.find('.dashboard-switch input.teams-directory-check');
+ expect(input).toHaveLength(1);
+ expect(input.is(':checked')).toBe(false);
+ expect($w.find('.teams-directory-note').text()).toBe('teams_directory_off_note');
+ });
+
+ it('lists accounts with a status pill and folds the audit state in', () => {
+ const rows = $w.find('.teams-members-table tbody tr');
+ expect(rows).toHaveLength(3);
+ // Suspended last, provisioned before joined.
+ expect(rows.map((_, r) => $(r).attr('data-username')).get()).toEqual(['dana', 'boss', 'elliot']);
+ expect(rows.eq(2).hasClass('teams-member-disabled')).toBe(true);
+ expect(rows.eq(2).find('.teams-status').hasClass('teams-status-disabled')).toBe(true);
+ expect(rows.eq(0).find('.teams-status').hasClass('teams-status-active')).toBe(true);
+ });
+
+ it('marks the signed-in account and gives it no actions', () => {
+ const you = $w.find('.teams-member-row[data-username="boss"]');
+ expect(you.find('.teams-you')).toHaveLength(1);
+ expect(you.find('.teams-member-actions')).toHaveLength(0);
+ expect($w.find('.teams-member-row[data-username="dana"] .teams-you')).toHaveLength(0);
+ });
+
+ it('offers restore and delete only for a suspended account', () => {
+ const dana = $w.find('.teams-member-row[data-username="dana"]');
+ const elliot = $w.find('.teams-member-row[data-username="elliot"]');
+ expect(dana.find('.teams-disable')).toHaveLength(1);
+ expect(dana.find('.teams-delete-account')).toHaveLength(0);
+ expect(elliot.find('.teams-enable')).toHaveLength(1);
+ expect(elliot.find('.teams-delete-account')).toHaveLength(1);
+ });
+
+ it('labels every data cell for the phone layout, which drops the header row', () => {
+ const cells = $w.find('.teams-members-table tbody tr').first().find('td[data-label]');
+ expect(cells.map((_, c) => $(c).attr('data-label')).get()).toEqual([
+ 'teams_member_state', 'teams_member_since', 'teams_member_plan',
+ ]);
+ });
+
+ it('keeps the actions in a block inside the cell, not on the cell itself', () => {
+ const cell = $w.find('.teams-member-row[data-username="dana"] td.teams-cell-actions');
+ expect(cell.children('.teams-member-actions')).toHaveLength(1);
+ });
+
+ it('prints audit seconds as a real date, not 1970', () => {
+ const when = $w.find('.teams-audit-table tbody tr').first().find('.teams-cell-when').text();
+ expect(when).toContain('2026');
+ expect(when).not.toContain('1970');
+ });
+
+ it('skips the suspended clause of the billing note when there is nothing suspended', async () => {
+ expect($w.find('.teams-panel-hint').filter((_, el) => $(el).text().startsWith('teams_billing_summary')).text())
+ .toBe('teams_billing_summary_one(billed=1,disabled=1)');
+ });
+
+ it('adds an account on form submit, so Enter works, and shows the credential with a copy button', async () => {
+ globalThis.puter.teams.createMember = vi.fn(async () => ({ username: 'gil', temporaryPassword: 'p4ss' }));
+ $w.find('.teams-new-username').val('gil');
+ $w.find('.teams-form').trigger('submit');
+ await flush();
+ await flush();
+ await flush();
+ expect(globalThis.puter.teams.createMember).toHaveBeenCalledWith('t1', { username: 'gil' });
+ const box = $w.find('.teams-credential');
+ expect(box.is(':visible') || box.css('display') !== 'none').toBe(true);
+ expect(box.find('.teams-credential-value').text()).toBe('p4ss');
+ expect(box.find('.teams-credential-copy').attr('data-value')).toBe('p4ss');
+ box.find('.teams-credential-dismiss').trigger('click');
+ expect(box.children()).toHaveLength(0);
+ });
+});
+
+describe('the member view', () => {
+ it('shows the roster and own record, and nothing an administrator would use', async () => {
+ const $w = await mount({ teams: [{ ...team, isOwner: false }] });
+ expect($w.find('.teams-role').text()).toBe('teams_role_member');
+ expect($w.find('.teams-rename')).toHaveLength(0);
+ expect($w.find('.teams-roster .teams-identity')).toHaveLength(3);
+ expect($w.find('.teams-directory-check')).toHaveLength(0);
+ expect($w.find('.teams-form')).toHaveLength(0);
+ expect($w.find('.dashboard-danger-zone')).toHaveLength(0);
+ expect($w.find('.teams-panel h3').first().text()).toContain('teams_roster');
+ });
+});
+
+describe('states', () => {
+ it('shows a picker when there is more than one team', async () => {
+ const $w = await mount({ teams: [team, { ...team, uid: 't2', name: 'Other' }] });
+ expect($w.find('.teams-hero .teams-picker-select option')).toHaveLength(2);
+ });
+
+ it('offers to create a team when there is none', async () => {
+ const $w = await mount({ teams: [] });
+ expect($w.find('.teams-state .teams-create')).toHaveLength(1);
+ });
+
+ it('offers a retry when the load fails', async () => {
+ window.teams_ui = true;
+ const $w = await mount();
+ globalThis.puter.teams.list = vi.fn(async () => { throw new Error('boom'); });
+ await TabTeams.onActivate($w);
+ await flush();
+ expect($w.find('.teams-state .teams-retry')).toHaveLength(1);
+ globalThis.puter.teams.list = vi.fn(async () => [team]);
+ $w.find('.teams-retry').trigger('click');
+ expect($w.find('.teams-skeleton')).toHaveLength(1);
+ await flush();
+ await flush();
+ expect($w.find('.teams-hero')).toHaveLength(1);
+ });
+});
diff --git a/src/gui/src/UI/Dashboard/teamsConsole.js b/src/gui/src/UI/Dashboard/teamsConsole.js
index bf2b3f9a2..844fcdad6 100644
--- a/src/gui/src/UI/Dashboard/teamsConsole.js
+++ b/src/gui/src/UI/Dashboard/teamsConsole.js
@@ -203,3 +203,68 @@ export function auditSlice (entries, page, size) {
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;
+}
diff --git a/src/gui/src/UI/Dashboard/teamsConsole.test.js b/src/gui/src/UI/Dashboard/teamsConsole.test.js
index 4fcf241ef..c27426fe9 100644
--- a/src/gui/src/UI/Dashboard/teamsConsole.test.js
+++ b/src/gui/src/UI/Dashboard/teamsConsole.test.js
@@ -3,9 +3,13 @@ import {
annotateMembers,
auditActionKey,
auditSlice,
+ avatarHue,
+ billingSummaryKey,
canDeleteAccount,
auditReasonKey,
+ initialOf,
memberStatesFromAudit,
+ parseTimestamp,
membersBillingSummary,
memberPlanLabel,
sortMembers,
@@ -253,3 +257,65 @@ describe('paging the record', () => {
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');
+ });
+});
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index b723a62cb..f793b63ba 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -8022,33 +8022,652 @@ body.dashboard-mode .notifications-close-all {
}
}
+
/* ======================================
Teams tab
====================================== */
+/* Kept in the DOM for screen readers; a tooltip or layout carries it visually. */
+.dashboard-section-teams .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
.teams-panel {
- padding: 20px 24px;
+ padding: 22px 24px;
+}
+
+/* -- Buttons: the settings cards' outline style, one accent for the primary -- */
+
+.dashboard-section-teams .button {
+ height: 36px;
+ line-height: 34px;
+ padding: 0 16px;
+ color: var(--dashboard-text-primary);
+ background: var(--dashboard-card-background);
+ border-color: var(--dashboard-border);
+ border-radius: 10px;
+ font-size: 13.5px;
+ font-weight: 600;
+ font-family: inherit;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
+}
+
+.dashboard-section-teams .button-small {
+ height: 30px;
+ line-height: 28px;
+ padding: 0 12px;
+ font-size: 13px;
+}
+
+@media (hover: hover) {
+ .dashboard-section-teams .button:hover {
+ background: var(--dashboard-sidebar-background);
+ }
+}
+
+.dashboard-section-teams .button:focus-visible {
+ border-color: var(--select-color);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.dashboard-section-teams .button:active {
+ background: var(--dashboard-hover);
+ border-color: var(--dashboard-border);
+ color: var(--dashboard-text-primary);
+ box-shadow: none;
+}
+
+/* Base .button:disabled hard-codes light greys with !important. */
+.dashboard-section-teams .button:disabled {
+ background: var(--dashboard-card-background) !important;
+ border-color: var(--dashboard-border) !important;
+ color: var(--dashboard-text-muted) !important;
+ text-shadow: none !important;
+}
+
+.dashboard-section-teams .button-primary {
+ background: var(--dashboard-link);
+ border-color: var(--dashboard-link);
+ color: #fff;
+}
+
+@media (hover: hover) {
+ .dashboard-section-teams .button-primary:hover {
+ background: var(--dashboard-link-hover);
+ border-color: var(--dashboard-link-hover);
+ }
+}
+
+.dashboard-section-teams .button-primary:active {
+ background: var(--dashboard-link-hover);
+ border-color: var(--dashboard-link-hover);
+ color: #fff;
+}
+
+.dashboard-section-teams .button-primary:disabled {
+ background: var(--dashboard-link) !important;
+ border-color: var(--dashboard-link) !important;
+ color: #fff !important;
+ opacity: 0.6;
+}
+
+.dashboard-section-teams .button-danger {
+ background: #f00808;
+ border-color: #f00808;
+ color: #fff;
+}
+
+@media (hover: hover) {
+ .dashboard-section-teams .button-danger:hover {
+ background: #d90707;
+ border-color: #d90707;
+ }
+}
+
+.dashboard-section-teams .button-danger:active {
+ background: #c20606;
+ border-color: #c20606;
+ color: #fff;
+}
+
+/* -- Hero: the selected team -- */
+
+.teams-hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.teams-hero-main {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-width: 0;
+}
+
+/* Lettered tile in place of a picture; hue comes from the name, per row. */
+.teams-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 10px;
+ flex-shrink: 0;
+ font-size: 13px;
+ font-weight: 600;
+ background: hsl(var(--teams-avatar-hue, 213) 70% 94%);
+ color: hsl(var(--teams-avatar-hue, 213) 55% 38%);
+ user-select: none;
+}
+
+.teams-avatar-lg {
+ width: 52px;
+ height: 52px;
+ border-radius: 14px;
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.teams-info {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+
+.teams-info-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+
+.teams-info strong {
+ font-size: 17px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--dashboard-text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.teams-role {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 2px 7px;
+ border-radius: 999px;
+ background: var(--select-tint);
+ color: var(--select-color);
+ white-space: nowrap;
+}
+
+.teams-info-meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 13px;
+ color: var(--dashboard-text-hint);
+}
+
+.teams-dot {
+ margin: 0 6px;
+ color: var(--dashboard-text-muted);
+}
+
+.teams-hero-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.teams-picker-select {
+ height: 36px;
+ max-width: 220px;
+ padding: 0 32px 0 12px;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 10px;
+ background-color: var(--dashboard-card-background);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 16px;
+ color: var(--dashboard-text-primary);
+ font-family: inherit;
+ font-size: 13.5px;
+ font-weight: 600;
+ appearance: none;
+ -webkit-appearance: none;
+ cursor: pointer;
+ text-overflow: ellipsis;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.teams-picker-select:focus-visible {
+ outline: none;
+ border-color: var(--select-color);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+/* -- Card headings -- */
+
+.teams-card-head {
margin-bottom: 16px;
}
.teams-panel h3 {
- margin: 0 0 4px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--dashboard-text-card-title);
}
-.teams-panel-hint,
-.teams-billing-note,
-.teams-empty {
- margin: 0 0 16px 0;
+.teams-count {
+ display: inline-block;
+ min-width: 20px;
+ padding: 0 7px;
+ border-radius: 999px;
+ line-height: 20px;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--dashboard-text-hint);
+ background: var(--dashboard-input-background);
+}
+
+.teams-panel-hint {
+ margin: 4px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--dashboard-text-hint);
}
.teams-empty {
- margin-bottom: 0;
+ margin: 0;
+ padding: 6px 0 2px;
+ font-size: 13px;
+ color: var(--dashboard-text-hint);
+}
+
+/* -- Directory: a settings row with the shared switch -- */
+
+.teams-directory .dashboard-settings-card-info span {
+ white-space: normal;
+ line-height: 1.45;
+}
+
+.teams-directory-toggle {
+ margin: 0;
+}
+
+/* -- Add an account -- */
+
+.teams-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: end;
+}
+
+.teams-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+
+.teams-field label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--dashboard-text-secondary);
+}
+
+.teams-form input {
+ width: 100%;
+ height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 10px;
+ background: var(--dashboard-input-background);
+ color: var(--dashboard-text-primary);
+ font-family: inherit;
+ font-size: 14px;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
+}
+
+.teams-form input:focus {
+ outline: none;
+ border-color: var(--select-color);
+ background: var(--dashboard-card-background);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.teams-field-help {
+ margin: 10px 0 0;
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--dashboard-text-muted);
+}
+
+.dashboard-section-teams .teams-busy {
+ opacity: 0.75;
+}
+
+/* Shown once and never retrievable, so it is deliberately hard to miss. */
+.teams-credential {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 16px;
+ padding: 14px 16px;
+ border: 1px solid var(--dashboard-warning-border);
+ border-radius: 12px;
+ background: var(--dashboard-warning-background);
+ color: var(--dashboard-warning-text);
+ font-size: 13px;
+}
+
+.teams-credential-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.teams-credential-head strong {
+ font-size: 13.5px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.teams-credential-dismiss {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ margin: -4px -6px 0 0;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ opacity: 0.7;
+ flex-shrink: 0;
+}
+
+@media (hover: hover) {
+ .teams-credential-dismiss:hover {
+ opacity: 1;
+ background: rgba(0, 0, 0, 0.06);
+ }
+}
+
+.teams-credential-dismiss:focus-visible {
+ outline: none;
+ opacity: 1;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.teams-credential-dismiss svg {
+ width: 14px;
+ height: 14px;
+}
+
+.teams-credential-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.teams-credential-value {
+ flex: 1;
+ min-width: 0;
+ padding: 8px 12px;
+ border: 1px solid var(--dashboard-warning-border);
+ border-radius: 8px;
+ background: var(--dashboard-card-background);
+ color: var(--dashboard-text-primary);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 15px;
+ letter-spacing: 0.02em;
+ user-select: all;
+ word-break: break-all;
+ outline: none;
+}
+
+.teams-credential-value:focus-visible {
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.dashboard-section-teams .teams-credential-copy {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ padding: 0 12px;
+}
+
+.teams-credential-copy svg {
+ width: 14px;
+ height: 14px;
+}
+
+.dashboard-section-teams .teams-copied {
+ color: var(--dashboard-success-text);
+ border-color: var(--dashboard-success-border);
+}
+
+.teams-credential-note {
+ line-height: 1.5;
+}
+
+/* -- Tables: hairlines only, no grid -- */
+
+.teams-table-wrapper {
+ overflow-x: auto;
+}
+
+.teams-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13.5px;
+ color: var(--dashboard-text-primary);
+}
+
+.teams-table th {
+ padding: 0 10px 10px;
+ border-bottom: 1px solid var(--dashboard-border);
+ text-align: left;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dashboard-text-muted);
+ white-space: nowrap;
+}
+
+.teams-table td {
+ padding: 12px 10px;
+ border-bottom: 1px solid var(--dashboard-border);
+ vertical-align: middle;
+}
+
+.teams-table th:first-child,
+.teams-table td:first-child {
+ padding-left: 0;
+}
+
+.teams-table th:last-child,
+.teams-table td:last-child {
+ padding-right: 0;
+}
+
+.teams-table tbody tr:last-child td {
+ border-bottom: none;
+ padding-bottom: 4px;
+}
+
+.teams-identity {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.teams-identity-text {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.teams-identity-name {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.teams-identity-sub {
+ font-size: 12px;
+ color: var(--dashboard-text-hint);
+}
+
+.teams-you {
+ padding: 1px 6px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: var(--dashboard-input-background);
+ color: var(--dashboard-text-hint);
+}
+
+.teams-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12.5px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.teams-status::before {
+ content: "";
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.teams-status-active {
+ color: var(--dashboard-success-text);
+}
+
+.teams-status-disabled {
+ color: var(--dashboard-text-muted);
+}
+
+/* Suspended accounts stay legible: they are still on the bill. */
+.teams-member-disabled td,
+.teams-member-disabled .teams-identity-sub {
+ color: var(--dashboard-text-muted);
+}
+
+.teams-member-disabled .teams-avatar {
+ filter: grayscale(1);
+ opacity: 0.7;
+}
+
+.teams-cell-actions {
+ width: 1%;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.teams-member-actions {
+ display: inline-flex;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+.dashboard-section-teams .teams-member-actions .button-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ line-height: 1;
+ border-radius: 8px;
+ border-color: transparent;
+ background: transparent;
+ color: var(--dashboard-text-secondary);
+ box-shadow: none;
+}
+
+@media (hover: hover) {
+ .dashboard-section-teams .teams-member-actions .button-icon:hover {
+ background: var(--dashboard-input-background);
+ color: var(--dashboard-text-primary);
+ }
+
+ .dashboard-section-teams .teams-member-actions .button-icon.button-danger:hover {
+ background: var(--dashboard-danger-background);
+ color: var(--dashboard-danger-text);
+ }
+}
+
+.dashboard-section-teams .teams-member-actions .button-icon:active {
+ background: var(--dashboard-hover);
+ color: var(--dashboard-text-primary);
+}
+
+.dashboard-section-teams .teams-member-actions .button-icon:focus-visible {
+ border-color: var(--select-color);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.teams-member-actions .button-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.teams-cell-action {
+ font-weight: 500;
+}
+
+.teams-audit-reason {
+ font-weight: 400;
+ color: var(--dashboard-text-muted);
+}
+
+.teams-cell-when {
+ color: var(--dashboard-text-hint);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
}
.teams-pager {
@@ -8057,12 +8676,15 @@ body.dashboard-mode .notifications-close-all {
justify-content: space-between;
gap: 12px;
margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--dashboard-border);
flex-wrap: wrap;
}
.teams-pager-count {
font-size: 13px;
color: var(--dashboard-text-hint);
+ font-variant-numeric: tabular-nums;
}
.teams-pager-buttons {
@@ -8070,61 +8692,123 @@ body.dashboard-mode .notifications-close-all {
gap: 8px;
}
-.teams-pager-buttons .button:disabled {
- opacity: 0.5;
- cursor: default;
-}
+/* -- Roster (member view) -- */
.teams-roster {
list-style: none;
margin: 0;
padding: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
}
.teams-roster-name {
- padding: 7px 0;
- font-size: 14px;
- color: var(--dashboard-text-primary);
+ padding: 10px 0;
border-bottom: 1px solid var(--dashboard-border);
}
.teams-roster-name:last-child {
border-bottom: none;
+ padding-bottom: 2px;
}
-.teams-roster-you {
- font-size: 13px;
- color: var(--dashboard-text-hint);
+/* -- Empty / error states -- */
+
+.teams-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 40px 24px 44px;
}
-.teams-member-actions .button-icon {
- display: inline-flex;
+.teams-state-art {
+ display: flex;
align-items: center;
justify-content: center;
- width: 30px;
- height: 30px;
- padding: 0;
- margin-right: 6px;
+ width: 56px;
+ height: 56px;
+ margin-bottom: 16px;
+ border-radius: 50%;
+ background: var(--select-tint);
+ color: var(--select-color);
}
-.teams-member-actions .button-icon svg {
- width: 15px;
- height: 15px;
+.teams-state-art svg {
+ width: 26px;
+ height: 26px;
}
-/* Kept in the DOM for screen readers; the tooltip carries it visually. */
-.teams-member-actions .sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
+.teams-state-warning .teams-state-art {
+ background: var(--dashboard-warning-background);
+ color: var(--dashboard-warning-text);
}
+.teams-state h3 {
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--dashboard-text-primary);
+}
+
+.teams-state .teams-panel-hint {
+ max-width: 400px;
+ margin-top: 6px;
+}
+
+.teams-state .button {
+ margin-top: 20px;
+}
+
+/* -- Loading skeleton -- */
+
+.teams-skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.teams-skeleton-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.teams-skeleton-card > span {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.teams-skeleton-card i,
+.teams-skeleton-tile {
+ display: block;
+ height: 12px;
+ border-radius: 6px;
+ background: linear-gradient(90deg, var(--dashboard-hover) 25%, var(--dashboard-border) 50%, var(--dashboard-hover) 75%);
+ background-size: 200% 100%;
+ animation: teams-shimmer 1.4s linear infinite;
+}
+
+.teams-skeleton-tile {
+ width: 52px;
+ height: 52px;
+ border-radius: 14px;
+ flex-shrink: 0;
+}
+
+@keyframes teams-shimmer {
+ from { background-position: 200% 0; }
+ to { background-position: -200% 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .teams-skeleton-card i,
+ .teams-skeleton-tile {
+ animation: none;
+ }
+}
+
+/* -- Plan picker (rendered by the billing extension) -- */
+
.teams-plan-list {
list-style: none;
margin: 0;
@@ -8160,150 +8844,139 @@ body.dashboard-mode .notifications-close-all {
color: var(--dashboard-text-muted);
}
-.teams-card {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 24px;
+/* -- Dark mode -- */
+
+@media (prefers-color-scheme: dark) {
+ .teams-avatar {
+ background: hsl(var(--teams-avatar-hue, 213) 35% 24%);
+ color: hsl(var(--teams-avatar-hue, 213) 70% 80%);
+ }
+
+ .teams-credential-dismiss:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
}
-.teams-info {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.teams-info strong {
- font-size: 15px;
- font-weight: 600;
- color: var(--dashboard-text-card-title);
-}
-
-.teams-info span {
- font-size: 13px;
- color: var(--dashboard-text-hint);
-}
-
-.teams-picker {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 16px;
- font-size: 13px;
- color: var(--dashboard-text-hint);
-}
-
-.teams-picker-select,
-.teams-form input {
- padding: 7px 10px;
- border: 1px solid var(--dashboard-border);
- border-radius: 8px;
- background: var(--dashboard-input-background);
- color: inherit;
- font-size: 13px;
-}
-
-.teams-form {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.teams-form input {
- flex: 1 1 160px;
- min-width: 0;
-}
-
-/* Shown once and never retrievable, so it is deliberately hard to miss. */
-.teams-credential {
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin-top: 16px;
- padding: 12px 16px;
- border: 1px solid var(--dashboard-warning-border);
- border-radius: 8px;
- background: var(--dashboard-warning-background);
- color: var(--dashboard-warning-text);
- font-size: 13px;
-}
-
-.teams-credential-value {
- font-family: monospace;
- font-size: 15px;
- user-select: all;
- word-break: break-all;
-}
-
-.teams-table-wrapper {
- overflow-x: auto;
-}
-
-.teams-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
-}
-
-.teams-table th {
- padding: 7px 8px;
- border: 1px solid var(--dashboard-border);
- background: var(--dashboard-input-background);
- text-align: left;
- font-weight: 500;
- white-space: nowrap;
-}
-
-.teams-table td {
- padding: 7px 8px;
- border: 1px solid var(--dashboard-border);
- vertical-align: middle;
-}
-
-/* Suspended accounts stay legible: they are still on the bill. */
-.teams-member-disabled td {
- color: var(--dashboard-text-muted);
-}
-
-.teams-member-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- border-left: none;
-}
-
-.teams-audit-reason {
- color: var(--dashboard-text-muted);
-}
+/* -- Phone: cards stack, tables become labelled rows -- */
@media (max-width: 768px) {
- .teams-card {
+ .teams-panel {
+ padding: 18px 16px;
+ }
+
+ .teams-hero {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
- .teams-card .button,
+ .teams-hero-actions {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .teams-hero-actions .button,
+ .teams-picker-select {
+ width: 100%;
+ max-width: none;
+ }
+
+ .teams-form {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
.teams-form .button {
width: 100%;
}
- .teams-form input {
- flex-basis: 100%;
+ .teams-table thead {
+ display: none;
+ }
+
+ .teams-table,
+ .teams-table tbody,
+ .teams-table tr,
+ .teams-table td {
+ display: block;
+ }
+
+ .teams-table tr {
+ padding: 12px 0;
+ border-bottom: 1px solid var(--dashboard-border);
+ }
+
+ .teams-table tbody tr:last-child {
+ border-bottom: none;
+ padding-bottom: 2px;
+ }
+
+ .teams-table td,
+ .teams-table th:first-child,
+ .teams-table td:first-child,
+ .teams-table tbody tr:last-child td {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 4px 0;
+ border: none;
+ }
+
+ .teams-table td[data-label]::before {
+ content: attr(data-label);
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--dashboard-text-muted);
+ }
+
+ .teams-cell-identity {
+ padding-bottom: 8px !important;
+ }
+
+ .teams-cell-actions {
+ width: auto;
+ justify-content: flex-end;
+ padding-top: 8px !important;
+ }
+
+ .teams-cell-actions:empty {
+ display: none;
+ }
+
+ /* Touch targets, with an edge: nothing hovers here to reveal them. */
+ .dashboard-section-teams .teams-member-actions .button-icon {
+ width: 40px;
+ height: 40px;
+ border-color: var(--dashboard-border);
+ }
+
+ /* The settings cards stack on phones; a switch reads better beside its label. */
+ .dashboard-section-teams .teams-directory {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .teams-cell-when {
+ white-space: normal;
+ text-align: right;
+ }
+
+ .teams-credential-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .dashboard-section-teams .teams-credential-copy {
+ justify-content: center;
+ }
+
+ .teams-pager {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .teams-pager-buttons .button {
+ flex: 1;
}
}
-
-.teams-directory-toggle {
- display: flex;
- align-items: center;
- gap: 8px;
- margin: 4px 0 10px;
- cursor: pointer;
-}
-
-.teams-directory-note {
- margin: 0;
- font-size: 13px;
- opacity: 0.7;
-}
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index d2937a2ea..4b7169b3c 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -559,6 +559,14 @@ const en = {
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.',