';
h += `
${i18n('teams_danger_zone')} `;
@@ -298,21 +370,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) => {
@@ -418,38 +518,39 @@ const showSeatLimit = async ($el_window, e) => {
const canUpgrade = typeof window.UIUpgradeAccount === 'function';
let message = `
${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}
`;
message += `
${i18n('teams_seat_limit_upgrade')}
`;
- const answer = await UIAlert({
- type: 'warning',
+ const answer = await dialog($el_window, {
+ title: i18n('teams_add_account', [], false),
+ tone: 'warning',
message,
buttons: canUpgrade
? [
- { label: i18n('teams_see_plans'), value: 'upgrade', type: 'primary' },
- { label: i18n('cancel'), value: 'no' },
+ { label: i18n('cancel', [], false), value: 'no' },
+ { label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' },
]
- : [{ label: i18n('ok'), value: 'no' }],
- ...modalOptions($el_window),
+ : [{ 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);
- return UIAlert({
- type: 'error',
- message: e?.message ?? i18n('error_unknown_cause'),
- ...modalOptions($el_window),
+ 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 UIAlert({
- type: 'confirm',
+ 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 },
- { label: i18n('cancel'), value: 'no' },
],
- ...modalOptions($el_window),
});
return answer === 'yes';
};
@@ -457,10 +558,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 +594,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 +605,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');
}
};
@@ -489,7 +613,7 @@ const reissueCredential = async ($el_window, username) => {
const ok = await confirm(
$el_window,
`
${i18n('teams_confirm_reissue', { username })}
`,
- i18n('teams_reissue_credential'),
+ i18n('teams_reissue_credential', [], false),
);
if ( ! ok ) return;
try {
@@ -506,7 +630,7 @@ const setMemberEnabled = async ($el_window, username, enabled) => {
const ok = await confirm(
$el_window,
`
${i18n('teams_confirm_disable', { username })}
`,
- i18n('teams_disable_account'),
+ i18n('teams_disable_account', [], false),
);
if ( ! ok ) return;
}
@@ -521,11 +645,14 @@ const setMemberEnabled = async ($el_window, username, enabled) => {
};
const renameTeam = async ($el_window) => {
- const name = await UIPrompt({
+ const name = await dialog($el_window, {
+ title: i18n('teams_rename', [], false),
message: i18n('teams_rename_prompt'),
- placeholder: i18n('teams_team_name'),
- defaultValue: state.selected.name ?? '',
- ...modalOptions($el_window),
+ 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 {
@@ -540,7 +667,7 @@ const deleteMemberAccount = async ($el_window, username) => {
const ok = await confirm(
$el_window,
i18n('teams_confirm_delete_account', { username }),
- i18n('teams_delete_account'),
+ i18n('teams_delete_account', [], false),
);
if ( ! ok ) return;
try {
@@ -558,7 +685,7 @@ const setDirectoryEnabled = async ($el_window, enabled) => {
const ok = await confirm(
$el_window,
i18n('teams_directory_confirm'),
- i18n('teams_directory_confirm_action'),
+ i18n('teams_directory_confirm_action', [], false),
// Reversible, and the wording says so — red would overstate it.
'primary',
);
@@ -575,10 +702,14 @@ const setDirectoryEnabled = async ($el_window, enabled) => {
};
const createTeam = async ($el_window) => {
- const name = await UIPrompt({
+ const name = await dialog($el_window, {
+ title: i18n('teams_create_team', [], false),
message: i18n('teams_create_team_prompt'),
- placeholder: i18n('teams_team_name'),
- ...modalOptions($el_window),
+ 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 {
@@ -595,7 +726,7 @@ const deleteTeam = async ($el_window) => {
$el_window,
`
${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}
`
+ `
${i18n('teams_confirm_delete_billing')}
`,
- i18n('teams_delete_team'),
+ i18n('teams_delete_team', [], false),
);
if ( ! ok ) return;
try {
@@ -639,7 +770,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..6e30e5b6b
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/TabTeams.render.test.js
@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
+
+vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
+
+globalThis.$ = jQuery;
+globalThis.jQuery = jQuery;
+globalThis.i18n = (key, replacements) => {
+ if ( replacements && typeof replacements === 'object' && ! Array.isArray(replacements) ) {
+ return `${key}(${Object.entries(replacements).map(([k, v]) => `${k}=${v}`).join(',')})`;
+ }
+ return key;
+};
+globalThis.html_encode = (value) => String(value ?? '')
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+window.html_encode = globalThis.html_encode;
+
+const { default: TabTeams } = await import('./TabTeams.js');
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 0));
+
+const team = { uid: 't1', name: 'Acme Design', handle: 'acme', isOwner: true, directoryEnabled: false };
+const members = [
+ { username: 'dana', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-dana' },
+ { username: 'boss', orgOwned: false, createdAt: '2026-09-01T00:00:00Z' },
+ { username: 'elliot', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-elliot' },
+];
+// Newest first, as the API sends it; unix seconds, as the audit routes send it.
+const audit = [
+ { action: 'disable', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789672096 },
+ { action: 'provision', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
+ { action: 'provision', username: 'dana', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
+];
+
+const mount = async ({ teams = [team], plan = null } = {}) => {
+ window.teams_ui = true;
+ window.team_billing_ui = false;
+ window.api_origin = 'http://api.test';
+ window.user = { username: 'boss' };
+ globalThis.puter = {
+ authToken: 'tok',
+ teams: {
+ list: vi.fn(async () => teams),
+ listAudit: vi.fn(async () => audit),
+ listOwnAudit: vi.fn(async () => audit.filter(e => e.username === 'boss')),
+ listMembers: vi.fn(async () => members),
+ },
+ };
+ globalThis.fetch = vi.fn(async () => ({ ok: false }));
+ if ( plan ) globalThis.fetch = plan;
+
+ document.body.innerHTML = `
`;
+ const $el_window = $('.dashboard');
+ TabTeams.init($el_window);
+ await flush();
+ await flush();
+ return $el_window;
+};
+
+describe('the owner view', () => {
+ let $w;
+ beforeEach(async () => { $w = await mount(); });
+
+ it('names the team, its role and headcount in the hero', () => {
+ const hero = $w.find('.teams-hero');
+ expect(hero.find('strong').text()).toBe('Acme Design');
+ expect(hero.find('.teams-role').text()).toBe('teams_role_owner');
+ expect(hero.find('.teams-info-meta').text()).toContain('@acme');
+ expect(hero.find('.teams-info-meta').text()).toContain('teams_member_count(count=3)');
+ expect(hero.find('.teams-rename')).toHaveLength(1);
+ // One team: no picker to choose it with.
+ expect(hero.find('.teams-picker-select')).toHaveLength(0);
+ });
+
+ it('renders the directory as a switch that still carries the wired class', () => {
+ const input = $w.find('.dashboard-switch input.teams-directory-check');
+ expect(input).toHaveLength(1);
+ expect(input.is(':checked')).toBe(false);
+ expect($w.find('.teams-directory-note').text()).toBe('teams_directory_off_note');
+ });
+
+ it('lists accounts with a status pill and folds the audit state in', () => {
+ const rows = $w.find('.teams-members-table tbody tr');
+ expect(rows).toHaveLength(3);
+ // Suspended last, provisioned before joined.
+ expect(rows.map((_, r) => $(r).attr('data-username')).get()).toEqual(['dana', 'boss', 'elliot']);
+ expect(rows.eq(2).hasClass('teams-member-disabled')).toBe(true);
+ expect(rows.eq(2).find('.teams-status').hasClass('teams-status-disabled')).toBe(true);
+ expect(rows.eq(0).find('.teams-status').hasClass('teams-status-active')).toBe(true);
+ });
+
+ it('marks the signed-in account and gives it no actions', () => {
+ const you = $w.find('.teams-member-row[data-username="boss"]');
+ expect(you.find('.teams-you')).toHaveLength(1);
+ expect(you.find('.teams-member-actions')).toHaveLength(0);
+ expect($w.find('.teams-member-row[data-username="dana"] .teams-you')).toHaveLength(0);
+ });
+
+ it('offers restore and delete only for a suspended account', () => {
+ const dana = $w.find('.teams-member-row[data-username="dana"]');
+ const elliot = $w.find('.teams-member-row[data-username="elliot"]');
+ expect(dana.find('.teams-disable')).toHaveLength(1);
+ expect(dana.find('.teams-delete-account')).toHaveLength(0);
+ expect(elliot.find('.teams-enable')).toHaveLength(1);
+ expect(elliot.find('.teams-delete-account')).toHaveLength(1);
+ });
+
+ it('labels every data cell for the phone layout, which drops the header row', () => {
+ const cells = $w.find('.teams-members-table tbody tr').first().find('td[data-label]');
+ expect(cells.map((_, c) => $(c).attr('data-label')).get()).toEqual([
+ 'teams_member_state', 'teams_member_since', 'teams_member_plan',
+ ]);
+ });
+
+ it('keeps the actions in a block inside the cell, not on the cell itself', () => {
+ const cell = $w.find('.teams-member-row[data-username="dana"] td.teams-cell-actions');
+ expect(cell.children('.teams-member-actions')).toHaveLength(1);
+ });
+
+ it('prints audit seconds as a real date, not 1970', () => {
+ const when = $w.find('.teams-audit-table tbody tr').first().find('.teams-cell-when').text();
+ expect(when).toContain('2026');
+ expect(when).not.toContain('1970');
+ });
+
+ it('skips the suspended clause of the billing note when there is nothing suspended', async () => {
+ expect($w.find('.teams-panel-hint').filter((_, el) => $(el).text().startsWith('teams_billing_summary')).text())
+ .toBe('teams_billing_summary_one(billed=1,disabled=1)');
+ });
+
+ it('adds an account on form submit, so Enter works, and shows the credential with a copy button', async () => {
+ globalThis.puter.teams.createMember = vi.fn(async () => ({ username: 'gil', temporaryPassword: 'p4ss' }));
+ $w.find('.teams-new-username').val('gil');
+ $w.find('.teams-form').trigger('submit');
+ await flush();
+ await flush();
+ await flush();
+ expect(globalThis.puter.teams.createMember).toHaveBeenCalledWith('t1', { username: 'gil' });
+ const box = $w.find('.teams-credential');
+ expect(box.is(':visible') || box.css('display') !== 'none').toBe(true);
+ expect(box.find('.teams-credential-value').text()).toBe('p4ss');
+ expect(box.find('.teams-credential-copy').attr('data-value')).toBe('p4ss');
+ box.find('.teams-credential-dismiss').trigger('click');
+ expect(box.children()).toHaveLength(0);
+ });
+});
+
+describe('the member view', () => {
+ it('shows the roster and own record, and nothing an administrator would use', async () => {
+ const $w = await mount({ teams: [{ ...team, isOwner: false }] });
+ expect($w.find('.teams-role').text()).toBe('teams_role_member');
+ expect($w.find('.teams-rename')).toHaveLength(0);
+ expect($w.find('.teams-roster .teams-identity')).toHaveLength(3);
+ expect($w.find('.teams-directory-check')).toHaveLength(0);
+ expect($w.find('.teams-form')).toHaveLength(0);
+ expect($w.find('.dashboard-danger-zone')).toHaveLength(0);
+ expect($w.find('.teams-panel h3').first().text()).toContain('teams_roster');
+ });
+});
+
+describe('states', () => {
+ it('shows a picker when there is more than one team', async () => {
+ const $w = await mount({ teams: [team, { ...team, uid: 't2', name: 'Other' }] });
+ expect($w.find('.teams-hero .teams-picker-select option')).toHaveLength(2);
+ });
+
+ it('offers to create a team when there is none', async () => {
+ const $w = await mount({ teams: [] });
+ expect($w.find('.teams-state .teams-create')).toHaveLength(1);
+ });
+
+ it('offers a retry when the load fails', async () => {
+ window.teams_ui = true;
+ const $w = await mount();
+ globalThis.puter.teams.list = vi.fn(async () => { throw new Error('boom'); });
+ await TabTeams.onActivate($w);
+ await flush();
+ expect($w.find('.teams-state .teams-retry')).toHaveLength(1);
+ globalThis.puter.teams.list = vi.fn(async () => [team]);
+ $w.find('.teams-retry').trigger('click');
+ expect($w.find('.teams-skeleton')).toHaveLength(1);
+ await flush();
+ await flush();
+ expect($w.find('.teams-hero')).toHaveLength(1);
+ });
+});
diff --git a/src/gui/src/UI/Dashboard/TabTeams.test.js b/src/gui/src/UI/Dashboard/TabTeams.test.js
index 58a670783..97ca802fa 100644
--- a/src/gui/src/UI/Dashboard/TabTeams.test.js
+++ b/src/gui/src/UI/Dashboard/TabTeams.test.js
@@ -1,9 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
-// The tab pulls in the dialog primitives, which pull in the whole window stack.
-// Only the tab-object contract is under test here.
-vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
-vi.mock('../UIPrompt.js', () => ({ default: vi.fn() }));
+// 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);
diff --git a/src/gui/src/UI/Dashboard/UIDashboardDialog.js b/src/gui/src/UI/Dashboard/UIDashboardDialog.js
new file mode 100644
index 000000000..60968ca59
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/UIDashboardDialog.js
@@ -0,0 +1,172 @@
+/*
+ * 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
.
+ */
+
+// The dashboard's own confirm / alert / prompt: an overlay card like the
+// share and properties modals, not a UIWindow. Resolves with the pressed
+// button's value (or the input's text), and `false` when dismissed.
+
+const SVG = (body) =>
+ `
${body} `;
+
+const ICONS = {
+ close: SVG('
'),
+ danger: SVG('
'),
+ warning: SVG('
'),
+ error: SVG('
'),
+};
+
+const TONES = new Set(['danger', 'warning', 'error']);
+const BUTTON_TYPES = new Set(['primary', 'danger', 'quiet']);
+
+/**
+ * @param {object} opts
+ * @param {string} [opts.title] Plain text.
+ * @param {string} opts.message HTML the caller has already encoded.
+ * @param {'danger'|'warning'|'error'} [opts.tone] Adds an icon; the primary
+ * button of a `danger` dialog is red.
+ * @param {Array<{ label: string, value: unknown, type?: 'primary'|'danger'|'quiet' }>} [opts.buttons]
+ * Rendered in order, right-aligned. Defaults to a single OK.
+ * @param {{ label?: string, placeholder?: string, value?: string }} [opts.input]
+ * Turns the dialog into a prompt: Enter presses the primary button and the
+ * result is the trimmed text rather than the button's value.
+ * @param {jQuery} [opts.$container] Where the overlay mounts; defaults to body.
+ * @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
+ * @returns {Promise
}
+ */
+export default function UIDashboardDialog ({ title, message, tone, buttons, input, $container, returnFocusTo }) {
+ const { html_encode } = window;
+ const $root = $container && $container.length ? $container : $('body');
+ const id = window.uuidv4?.() ?? String(Date.now());
+ const toneClass = TONES.has(tone) ? ` dashboard-dialog-${tone}` : '';
+ const actions = buttons?.length
+ ? buttons
+ : [{ label: i18n('ok'), value: true, type: 'primary' }];
+ const primary = actions.find(b => b.type === 'primary' || b.type === 'danger') ?? actions[actions.length - 1];
+
+ let h = '';
+ h += `
`;
+ if ( title ) {
+ h += '';
+ }
+ h += '
';
+ if ( toneClass ) h += `
${ICONS[tone]}
`;
+ h += '
';
+ h += `
${message ?? ''}
`;
+ if ( input ) {
+ h += '
';
+ if ( input.label ) h += `${html_encode(input.label)} `;
+ h += ` `;
+ h += ' ';
+ }
+ h += '
';
+ h += '
';
+
+ const $overlay = $(h);
+ $root.append($overlay);
+ requestAnimationFrame(() => $overlay.addClass('dashboard-dialog-show'));
+
+ const el_previous_focus = document.activeElement;
+ const $input = $overlay.find('.dashboard-dialog-input');
+ // A prompt wants the text; anything else, the safest button.
+ const $first = $input.length
+ ? $input
+ : $overlay.find('.dashboard-dialog-btn-quiet').first().add($overlay.find('.dashboard-dialog-btn').first()).first();
+ ($first.get(0) ?? $overlay.find('.dashboard-dialog').get(0))?.focus({ preventScroll: true });
+ if ( $input.length ) $input.get(0).select();
+
+ return new Promise((resolve) => {
+ let closed = false;
+ const close = (value) => {
+ if ( closed ) return;
+ closed = true;
+ $overlay.removeClass('dashboard-dialog-show');
+ $(document).off(`keydown.dashboard-dialog-${id}`);
+ setTimeout(() => $overlay.remove(), 200);
+ const el_focus_target = [returnFocusTo, el_previous_focus].find((el) => el && document.contains(el));
+ try {
+ el_focus_target?.focus({ preventScroll: true });
+ } catch { /* focus restoration is best-effort */ }
+ resolve(value);
+ };
+
+ const submit = (button) => {
+ if ( ! button ) return close(false);
+ if ( $input.length && button === primary ) return close(String($input.val() ?? '').trim());
+ close(button.value);
+ };
+
+ $overlay.on('click', '.dashboard-dialog-btn', function () {
+ submit(actions[Number($(this).attr('data-index'))]);
+ });
+ $overlay.on('click', '.dashboard-dialog-close', () => close(false));
+
+ // Backdrop close goes by where the press started, so a drag that
+ // starts in the card and ends outside does not dismiss it.
+ let backdrop_pressed = false;
+ $overlay.on('mousedown', function (e) {
+ backdrop_pressed = e.target === $overlay[0];
+ // A press inside the overlay must not let initgui's global mousedown
+ // hand focus (and the z-index) back to the window underneath it.
+ window.mouseover_window = undefined;
+ });
+ $overlay.on('click', function (e) {
+ if ( e.target === $overlay[0] && backdrop_pressed ) close(false);
+ });
+ $(document).on(`keydown.dashboard-dialog-${id}`, function (e) {
+ if ( e.key === 'Escape' ) close(false);
+ });
+ $input.on('keydown', function (e) {
+ if ( e.key === 'Enter' ) {
+ e.preventDefault();
+ submit(primary);
+ }
+ });
+
+ // Keep Tab cycling inside the dialog while it's up.
+ $overlay.on('keydown', function (e) {
+ if ( e.key !== 'Tab' ) return;
+ const focusables = $overlay
+ .find('button, input, [tabindex]:not([tabindex="-1"])')
+ .filter(':visible:not(:disabled)');
+ if ( ! focusables.length ) return;
+ const first = focusables.get(0);
+ const last = focusables.get(focusables.length - 1);
+ if ( focusables.index(document.activeElement) === -1 ) {
+ e.preventDefault();
+ (e.shiftKey ? last : first).focus();
+ } else if ( e.shiftKey && document.activeElement === first ) {
+ e.preventDefault();
+ last.focus();
+ } else if ( ! e.shiftKey && document.activeElement === last ) {
+ e.preventDefault();
+ first.focus();
+ }
+ });
+ });
+}
diff --git a/src/gui/src/UI/Dashboard/UIDashboardDialog.test.js b/src/gui/src/UI/Dashboard/UIDashboardDialog.test.js
new file mode 100644
index 000000000..ba5b83a6b
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/UIDashboardDialog.test.js
@@ -0,0 +1,116 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
+
+globalThis.$ = jQuery;
+globalThis.jQuery = jQuery;
+globalThis.i18n = (key) => key;
+globalThis.html_encode = (value) => String(value ?? '')
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+window.html_encode = globalThis.html_encode;
+window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
+
+const { default: UIDashboardDialog } = await import('./UIDashboardDialog.js');
+
+const tick = () => new Promise(resolve => setTimeout(resolve, 0));
+const overlay = () => $('.dashboard-dialog-overlay');
+
+afterEach(() => {
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+});
+
+describe('UIDashboardDialog', () => {
+ it('mounts an overlay card, not a window, and reveals it after a frame', async () => {
+ UIDashboardDialog({ title: 'T', message: 'hello' });
+ expect(overlay()).toHaveLength(1);
+ expect($('.window')).toHaveLength(0);
+ expect(overlay().hasClass('dashboard-dialog-show')).toBe(false);
+ await tick();
+ expect(overlay().hasClass('dashboard-dialog-show')).toBe(true);
+ expect($('.dashboard-dialog-title').text()).toBe('T');
+ expect($('.dashboard-dialog-message').html()).toBe('hello');
+ });
+
+ it('resolves with the pressed button value', async () => {
+ const p = UIDashboardDialog({
+ message: 'm',
+ buttons: [{ label: 'No', value: 'no' }, { label: 'Yes', value: 'yes', type: 'danger' }],
+ });
+ expect($('.dashboard-dialog-btn-danger').text()).toBe('Yes');
+ $('.dashboard-dialog-btn-danger').trigger('click');
+ await expect(p).resolves.toBe('yes');
+ });
+
+ it('resolves false on Escape, on the close button, and on a backdrop press-and-release', async () => {
+ const a = UIDashboardDialog({ title: 'T', message: 'm' });
+ $(document).trigger($.Event('keydown', { key: 'Escape' }));
+ await expect(a).resolves.toBe(false);
+
+ const b = UIDashboardDialog({ title: 'T', message: 'm' });
+ $('.dashboard-dialog-close').last().trigger('click');
+ await expect(b).resolves.toBe(false);
+
+ const c = UIDashboardDialog({ title: 'T', message: 'm' });
+ const el = overlay().last().get(0);
+ $(el).trigger($.Event('mousedown', { target: el }));
+ $(el).trigger($.Event('click', { target: el }));
+ await expect(c).resolves.toBe(false);
+ });
+
+ it('does not dismiss when a press starts inside the card and ends on the backdrop', async () => {
+ const resolved = vi.fn();
+ UIDashboardDialog({ title: 'T', message: 'm' }).then(resolved);
+ const el = overlay().get(0);
+ const card = $('.dashboard-dialog').get(0);
+ $(card).trigger($.Event('mousedown', { target: card }));
+ $(el).trigger($.Event('click', { target: el }));
+ await tick();
+ expect(resolved).not.toHaveBeenCalled();
+ });
+
+ it('as a prompt, returns the trimmed text from the primary button or Enter', async () => {
+ const a = UIDashboardDialog({
+ message: 'm',
+ input: { value: 'old' },
+ buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }],
+ });
+ expect($('.dashboard-dialog-input').val()).toBe('old');
+ $('.dashboard-dialog-input').val(' new name ');
+ $('.dashboard-dialog-btn-primary').trigger('click');
+ await expect(a).resolves.toBe('new name');
+
+ const b = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Save', value: 'ok', type: 'primary' }] });
+ $('.dashboard-dialog-input').last().val('typed').trigger($.Event('keydown', { key: 'Enter' }));
+ await expect(b).resolves.toBe('typed');
+
+ const c = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }] });
+ $('.dashboard-dialog-btn-quiet').last().trigger('click');
+ await expect(c).resolves.toBe(false);
+ });
+
+ it('escapes the title and button labels but trusts the message as HTML', () => {
+ UIDashboardDialog({ title: 'x ', message: 'ok
', buttons: [{ label: 'y ', value: 1 }] });
+ expect($('.dashboard-dialog-title').html()).toBe('<b>x</b>');
+ expect($('.dashboard-dialog-btn').html()).toBe('<i>y</i>');
+ expect($('.dashboard-dialog-message p')).toHaveLength(1);
+ });
+
+ it('mounts into the given container and marks the tone', () => {
+ document.body.innerHTML = '
';
+ UIDashboardDialog({ message: 'm', tone: 'danger', $container: $('.host') });
+ expect($('.host .dashboard-dialog-overlay')).toHaveLength(1);
+ expect($('.dashboard-dialog').hasClass('dashboard-dialog-danger')).toBe(true);
+ expect($('.dashboard-dialog-icon')).toHaveLength(1);
+ });
+
+ it('removes itself from the DOM after the exit transition', async () => {
+ vi.useFakeTimers();
+ const p = UIDashboardDialog({ message: 'm' });
+ $('.dashboard-dialog-btn').trigger('click');
+ await p;
+ expect(overlay()).toHaveLength(1);
+ vi.advanceTimersByTime(250);
+ expect(overlay()).toHaveLength(0);
+ });
+});
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..060eb9b2d 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,436 @@ 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 {
+/* ======================================
+ Dashboard dialog (confirm / alert / prompt)
+ ====================================== */
+
+.dashboard-dialog-overlay {
+ position: fixed;
+ inset: 0;
display: flex;
align-items: center;
- gap: 8px;
- margin: 4px 0 10px;
- cursor: pointer;
+ justify-content: center;
+ padding: 24px;
+ box-sizing: border-box;
+ background: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5));
+ opacity: 0;
+ transition: opacity 200ms ease;
+ z-index: 1000;
}
-.teams-directory-note {
- margin: 0;
+.dashboard-dialog-overlay.dashboard-dialog-show {
+ opacity: 1;
+}
+
+.dashboard-dialog {
+ display: flex;
+ flex-direction: column;
+ width: min(440px, 100%);
+ max-height: 100%;
+ background: var(--dashboard-card-background);
+ border: 1px solid var(--dashboard-border);
+ border-radius: 14px;
+ box-shadow: 0 12px 40px var(--dashboard-shadow-medium);
+ overflow: hidden;
+ transform: scale(0.96);
+ opacity: 0;
+ transition: transform 200ms ease, opacity 200ms ease;
+}
+
+.dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog {
+ transform: scale(1);
+ opacity: 1;
+}
+
+.dashboard-dialog:focus {
+ outline: none;
+}
+
+.dashboard-dialog-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--dashboard-border);
+ flex-shrink: 0;
+}
+
+.dashboard-dialog-title {
+ min-width: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--dashboard-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dashboard-dialog-close {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--dashboard-text-secondary);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+
+.dashboard-dialog-close svg {
+ width: 18px;
+ height: 18px;
+}
+
+@media (hover: hover) {
+ .dashboard-dialog-close:hover {
+ background: var(--dashboard-hover);
+ color: var(--dashboard-text-primary);
+ }
+}
+
+.dashboard-dialog-close:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.dashboard-dialog-body {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 20px 18px;
+ overflow-y: auto;
+}
+
+.dashboard-dialog-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--select-tint);
+ color: var(--select-color);
+}
+
+.dashboard-dialog-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.dashboard-dialog-danger .dashboard-dialog-icon,
+.dashboard-dialog-error .dashboard-dialog-icon {
+ background: var(--dashboard-danger-background);
+ color: var(--dashboard-danger-text);
+}
+
+.dashboard-dialog-warning .dashboard-dialog-icon {
+ background: var(--dashboard-warning-background);
+ color: var(--dashboard-warning-text);
+}
+
+.dashboard-dialog-content {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.dashboard-dialog-message {
+ font-size: 14px;
+ line-height: 1.55;
+ color: var(--dashboard-text-primary);
+ overflow-wrap: anywhere;
+}
+
+.dashboard-dialog-message p {
+ margin: 0 0 10px;
+}
+
+.dashboard-dialog-message p:last-child {
+ margin-bottom: 0;
+}
+
+.dashboard-dialog-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.dashboard-dialog-field > span {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--dashboard-text-secondary);
+}
+
+.dashboard-dialog-input {
+ width: 100%;
+ height: 38px;
+ 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;
+ box-sizing: border-box;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
+}
+
+.dashboard-dialog-input:focus {
+ outline: none;
+ border-color: var(--select-color);
+ background: var(--dashboard-card-background);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+.dashboard-dialog-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 14px 18px;
+ border-top: 1px solid var(--dashboard-border);
+ flex-shrink: 0;
+}
+
+.dashboard-dialog-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 88px;
+ height: 36px;
+ padding: 0 16px;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 10px;
+ background: var(--dashboard-card-background);
+ color: var(--dashboard-text-primary);
font-size: 13px;
- opacity: 0.7;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.dashboard-dialog-btn-primary {
+ border-color: var(--dashboard-link);
+ background: var(--dashboard-link);
+ color: #fff;
+}
+
+.dashboard-dialog-btn-danger {
+ border-color: #f00808;
+ background: #f00808;
+ color: #fff;
+}
+
+@media (hover: hover) {
+ .dashboard-dialog-btn-quiet:hover {
+ background: var(--dashboard-sidebar-background);
+ }
+
+ .dashboard-dialog-btn-primary:hover {
+ background: var(--dashboard-link-hover);
+ border-color: var(--dashboard-link-hover);
+ }
+
+ .dashboard-dialog-btn-danger:hover {
+ background: #d90707;
+ border-color: #d90707;
+ }
+}
+
+.dashboard-dialog-btn:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+@media (pointer: coarse) {
+ .dashboard-dialog-btn {
+ min-height: 44px;
+ height: auto;
+ padding: 10px 18px;
+ }
+}
+
+/* Mobile: dock to the bottom as a slide-up sheet, like the other modals. */
+@media (max-width: 600px) {
+ .dashboard-dialog-overlay {
+ align-items: flex-end;
+ padding: 0;
+ }
+
+ .dashboard-dialog {
+ width: 100%;
+ max-width: none;
+ max-height: 92vh;
+ border-radius: 16px 16px 0 0;
+ border-bottom: none;
+ transform: translateY(24px);
+ }
+
+ .dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog {
+ transform: translateY(0);
+ }
+
+ .dashboard-dialog-footer {
+ flex-direction: column-reverse;
+ padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
+ }
+
+ .dashboard-dialog-btn {
+ width: 100%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .dashboard-dialog-overlay,
+ .dashboard-dialog {
+ transition: none;
+ }
}
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 6a5b62c3d..b47923604 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -562,6 +562,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.',