From bafe8af71be39d2ed48821a18eddee8dd5ba3a07 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 11 Sep 2026 10:09:39 -0400 Subject: [PATCH] feat: icon buttons for the per-account actions A row now carries up to five actions -- change plan, reissue, suspend or enable, delete -- and five labelled buttons do not fit the cell. Each becomes a 30px icon button. The label is not dropped, only hidden: it stays as `title`, as `aria-label`, and as visually-hidden text, so a screen reader and a hover both still get it. The glyphs are `aria-hidden`, since the button already carries the name. Inline SVG rather than files under `icons/`: these are one-place 24px line glyphs, and the dashboard already inlines its sidebar chevron the same way. An `edit` glyph is defined but unused, ready for the account-edit action. Falsified: removing the visually-hidden label fails "keeps the label reachable without showing it". --- src/gui/src/UI/Dashboard/TabTeams.js | 39 +++++++++-- src/gui/src/UI/Dashboard/teamActionIcons.js | 56 ++++++++++++++++ .../src/UI/Dashboard/teamActionIcons.test.js | 66 +++++++++++++++++++ src/gui/src/css/dashboard.css | 25 +++++++ 4 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/teamActionIcons.js create mode 100644 src/gui/src/UI/Dashboard/teamActionIcons.test.js diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 730ef8862..c31736215 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -20,6 +20,7 @@ import UIAlert from '../UIAlert.js'; import UIPrompt from '../UIPrompt.js'; import teamPlanHtml from './teamPlan.js'; +import teamActionButton from './teamActionIcons.js'; import { annotateMembers, auditActionKey, @@ -89,15 +90,43 @@ const renderMemberRow = (member) => { h += `${planCell(member)}`; h += ''; if ( member.orgOwned ) { + const forMember = { 'data-username': member.username }; if ( window.team_billing_ui && state.plan?.status === 'ready' ) { - h += ``; + h += teamActionButton({ + className: 'teams-plan-change', + icon: 'plan', + label: i18n('teams_plan_change'), + attrs: { ...forMember, 'data-uuid': member.uuid ?? '' }, + }); } - h += ``; + h += teamActionButton({ + className: 'teams-reset', + icon: 'credential', + label: i18n('teams_reissue_credential'), + attrs: forMember, + }); h += member.disabled - ? `` - : ``; + ? teamActionButton({ + className: 'teams-enable', + icon: 'enable', + label: i18n('teams_enable_account'), + attrs: forMember, + }) + : teamActionButton({ + className: 'teams-disable', + icon: 'suspend', + label: i18n('teams_disable_account'), + danger: true, + attrs: forMember, + }); if ( canDeleteAccount(member) ) { - h += ``; + h += teamActionButton({ + className: 'teams-delete-account', + icon: 'remove', + label: i18n('teams_delete_account'), + danger: true, + attrs: forMember, + }); } } h += ''; diff --git a/src/gui/src/UI/Dashboard/teamActionIcons.js b/src/gui/src/UI/Dashboard/teamActionIcons.js new file mode 100644 index 000000000..79ba78daf --- /dev/null +++ b/src/gui/src/UI/Dashboard/teamActionIcons.js @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// A row carries up to five actions, and five labelled buttons do not fit. +// Inline like the sidebar chevron: one-place glyphs, not shared assets. + +const SVG = (body) => + ``; + +export const TEAM_ACTION_ICONS = { + /** Change plan — a card. */ + plan: SVG(''), + /** Reissue credential — a key. */ + credential: SVG(''), + /** Suspend — pause. */ + suspend: SVG(''), + /** Re-enable — play. */ + enable: SVG(''), + /** Delete the account — a bin. */ + remove: SVG(''), + /** Edit the account — a pencil. */ + edit: SVG(''), +}; + +/** The label stays as tooltip and screen-reader text, so nothing is lost. */ +export const teamActionButton = ({ className, icon, label, danger, attrs }) => { + const safeLabel = window.html_encode(label); + const extra = Object.entries(attrs ?? {}) + .map(([k, v]) => ` ${k}="${window.html_encode(v ?? '')}"`) + .join(''); + return ( + `` + ); +}; + +export default teamActionButton; diff --git a/src/gui/src/UI/Dashboard/teamActionIcons.test.js b/src/gui/src/UI/Dashboard/teamActionIcons.test.js new file mode 100644 index 000000000..6da17221c --- /dev/null +++ b/src/gui/src/UI/Dashboard/teamActionIcons.test.js @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +globalThis.window = { html_encode: (v) => String(v).replace(/[<"]/g, (c) => (c === '<' ? '<' : '"')) }; + +const { teamActionButton, TEAM_ACTION_ICONS } = await import('./teamActionIcons.js'); + +describe('an icon action button', () => { + it('keeps the label reachable without showing it', () => { + // Dropping the visible text must not drop it for a screen reader. + const h = teamActionButton({ + className: 'teams-reset', + icon: 'credential', + label: 'Reissue password', + }); + expect(h).toContain('title="Reissue password"'); + expect(h).toContain('aria-label="Reissue password"'); + expect(h).toContain('Reissue password'); + }); + + it('carries the class the click handler binds to', () => { + const h = teamActionButton({ className: 'teams-disable', icon: 'suspend', label: 'x' }); + expect(h).toContain('teams-disable'); + }); + + it('passes through the data attributes an action needs', () => { + const h = teamActionButton({ + className: 'teams-plan-change', + icon: 'plan', + label: 'Change plan', + attrs: { 'data-username': 'ana', 'data-uuid': 'u-1' }, + }); + expect(h).toContain('data-username="ana"'); + expect(h).toContain('data-uuid="u-1"'); + }); + + it('marks only the destructive ones', () => { + expect(teamActionButton({ className: 'a', icon: 'remove', label: 'x', danger: true })) + .toContain('button-danger'); + expect(teamActionButton({ className: 'a', icon: 'plan', label: 'x' })) + .not.toContain('button-danger'); + }); + + it('encodes a label and an attribute value', () => { + const h = teamActionButton({ + className: 'a', + icon: 'plan', + label: '