From edaf70aa5c356d080cda1eb38b476b36727b5182 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 17 Sep 2026 18:20:31 -0700 Subject: [PATCH] Redesign the Teams tab in the dashboard Hero card with a lettered tile, role pill, handle and headcount; the directory setting becomes a settings row with the shared toggle switch; the add-account form gets labels, Enter to submit, and a copy button on the one-time password. The accounts and record tables drop the grid for hairlines, avatar tiles and status pills, and stack into labelled rows on phones so actions stay reachable. Loading, empty and error states get a skeleton, a create call to action, and a retry. Also fixes audit dates showing 1970 (the audit routes send unix seconds), the actions cell breaking the row border by being a flex table cell, and the "0 suspended" clause on a healthy team. --- src/gui/src/UI/Dashboard/TabTeams.js | 406 ++++--- .../src/UI/Dashboard/TabTeams.render.test.js | 191 ++++ src/gui/src/UI/Dashboard/teamsConsole.js | 65 ++ src/gui/src/UI/Dashboard/teamsConsole.test.js | 66 ++ src/gui/src/css/dashboard.css | 1011 ++++++++++++++--- src/gui/src/i18n/translations/en.js | 8 + 6 files changed, 1447 insertions(+), 300 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/TabTeams.render.test.js diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 9141c354c..fc64da024 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -23,11 +23,15 @@ import teamActionButton from './teamActionIcons.js'; import { annotateMembers, auditActionKey, - auditSlice, - canDeleteAccount, auditReasonKey, + auditSlice, + avatarHue, + billingSummaryKey, + canDeleteAccount, + initialOf, membersBillingSummary, memberPlanLabel, + parseTimestamp, sortMembers, } from './teamsConsole.js'; @@ -51,9 +55,12 @@ const modalOptions = ($el_window) => ({ stay_on_top: true, }); -const dateText = (value) => { - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? '' : parsed.toLocaleDateString(); +const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? ''; + +/** The record needs the hour: several entries share a day. */ +const dateTimeText = (value) => { + const date = parseTimestamp(value); + return date ? date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : ''; }; const teamName = (team) => team?.name || team?.handle || i18n('teams_untitled'); @@ -63,18 +70,69 @@ const setTabVisible = ($el_window, visible) => { $el_window.find('.dashboard-sidebar-item[data-section="teams"]').toggle(visible); }; +// Inline like the other tabs' card icons: one-place glyphs, not shared assets. +const SVG = (body) => + ``; +const ICONS = { + team: SVG(''), + directory: SVG(''), + copy: SVG(''), + check: SVG(''), + close: SVG(''), + warning: SVG(''), +}; + // -- Rendering ------------------------------------------------------------ +/** A lettered tile in place of a picture: team accounts have none. */ +const renderAvatar = (name, extraClass = '') => { + const hue = avatarHue(name); + return ``; +}; + const renderTeamPicker = () => { if ( state.teams.length < 2 ) return ''; - let h = '
'; - h += ``; - h += '`; for ( const team of state.teams ) { const selected = team.uid === state.selected?.uid ? ' selected' : ''; h += ``; } - h += '
'; + h += ''; + return h; +}; + +/** Name, handle and headcount for the selected team, plus who you are in it. */ +const renderHero = () => { + const team = state.selected; + const count = state.members.length; + let h = '
'; + h += '
'; + h += renderAvatar(teamName(team), ' teams-avatar-lg'); + h += '
'; + h += '
'; + h += `${html_encode(teamName(team))}`; + h += `${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}`; + h += '
'; + const meta = []; + // Nothing here sets a handle, so its absence is not worth a label. + if ( team.handle ) meta.push(html_encode(`@${team.handle}`)); + meta.push(i18n(count === 1 ? 'teams_member_count_one' : 'teams_member_count', { count })); + h += `${meta.join('')}`; + h += '
'; + const picker = renderTeamPicker(); + const rename = team.isOwner ? `` : ''; + if ( picker || rename ) h += `
${picker}${rename}
`; + h += '
'; + return h; +}; + +const renderCardHead = (title, hint, count = null) => { + let h = '
'; + h += `

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

'; + if ( hint ) h += `

${hint}

`; + h += '
'; return h; }; @@ -84,56 +142,82 @@ const planCell = (member) => { return i18n(`teams_member_plan_${label.kind}`); }; +const renderStatus = (member) => { + const kind = member.disabled ? 'disabled' : 'active'; + return `${i18n(`teams_member_state_${kind}`)}`; +}; + +const renderIdentity = (username, subline) => { + const you = username === window.user?.username; + let h = '
'; + h += renderAvatar(username); + h += '
'; + h += `${html_encode(username)}`; + if ( you ) h += `${i18n('share_you')}`; + h += ''; + if ( subline ) h += `${subline}`; + h += '
'; + return h; +}; + +const renderMemberActions = (member) => { + if ( ! member.orgOwned ) return ''; + const forMember = { 'data-username': member.username }; + let h = '
'; + if ( window.team_billing_ui && state.plan?.status === 'ready' ) { + h += teamActionButton({ + className: 'teams-plan-change', + icon: 'plan', + label: i18n('teams_plan_change'), + attrs: { ...forMember, 'data-uuid': member.uuid ?? '' }, + }); + } + h += teamActionButton({ + className: 'teams-reset', + icon: 'credential', + label: i18n('teams_reissue_credential'), + attrs: forMember, + }); + h += member.disabled + ? teamActionButton({ + className: 'teams-enable', + icon: 'enable', + label: i18n('teams_enable_account'), + attrs: forMember, + }) + : teamActionButton({ + className: 'teams-disable', + icon: 'suspend', + label: i18n('teams_disable_account'), + danger: true, + attrs: forMember, + }); + if ( canDeleteAccount(member) ) { + h += teamActionButton({ + className: 'teams-delete-account', + icon: 'remove', + label: i18n('teams_delete_account'), + danger: true, + attrs: forMember, + }); + } + h += '
'; + return h; +}; + +// `data-label` repeats the column heading so the phone layout, which drops +// the header row, can print it beside each value. +const labelled = (key) => `data-label="${html_encode(i18n(key, [], false))}"`; + const renderMemberRow = (member) => { const username = html_encode(member.username); let h = ``; - h += `${username}`; - h += `${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}`; - h += `${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}`; - h += `${html_encode(dateText(member.createdAt))}`; - h += `${planCell(member)}`; - h += ''; - if ( member.orgOwned ) { - const forMember = { 'data-username': member.username }; - if ( window.team_billing_ui && state.plan?.status === 'ready' ) { - h += teamActionButton({ - className: 'teams-plan-change', - icon: 'plan', - label: i18n('teams_plan_change'), - attrs: { ...forMember, 'data-uuid': member.uuid ?? '' }, - }); - } - h += teamActionButton({ - className: 'teams-reset', - icon: 'credential', - label: i18n('teams_reissue_credential'), - attrs: forMember, - }); - h += member.disabled - ? teamActionButton({ - className: 'teams-enable', - icon: 'enable', - label: i18n('teams_enable_account'), - attrs: forMember, - }) - : teamActionButton({ - className: 'teams-disable', - icon: 'suspend', - label: i18n('teams_disable_account'), - danger: true, - attrs: forMember, - }); - if ( canDeleteAccount(member) ) { - h += teamActionButton({ - className: 'teams-delete-account', - icon: 'remove', - label: i18n('teams_delete_account'), - danger: true, - attrs: forMember, - }); - } - } - h += ''; + h += `${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}`; + h += `${renderStatus(member)}`; + h += `${html_encode(dateText(member.createdAt))}`; + h += `${planCell(member)}`; + h += `${renderMemberActions(member)}`; + h += ''; return h; }; @@ -142,20 +226,21 @@ const renderMembers = () => { const summary = membersBillingSummary(annotated); let h = '
'; - h += `

${i18n('teams_accounts')}

`; - const billingKey = summary.billed === 1 ? 'teams_billing_summary_one' : 'teams_billing_summary'; - h += `

${i18n(billingKey, { billed: summary.billed, disabled: summary.disabled })}

`; + h += renderCardHead( + i18n('teams_accounts'), + i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }), + annotated.length, + ); if ( annotated.length === 0 ) { h += `

${i18n('teams_no_accounts')}

`; } else { - h += '
'; - h += ``; - h += ``; + h += '
${i18n('username')}${i18n('teams_member_kind')}
'; + h += ``; h += ``; h += ``; h += ``; - h += ``; + h += ``; h += ''; for ( const member of sortMembers(annotated) ) h += renderMemberRow(member); h += '
${i18n('teams_member_account')}${i18n('teams_member_state')}${i18n('teams_member_since')}${i18n('teams_member_plan')}${i18n('teams_member_actions')}${i18n('teams_member_actions')}
'; @@ -166,48 +251,52 @@ const renderMembers = () => { const renderAddAccount = () => { let h = '
'; - h += `

${i18n('teams_add_account')}

`; - h += `

${i18n('teams_add_account_hint')}

`; - h += `

${i18n('teams_add_account_email_hint')}

`; - h += '
'; - h += ``; - h += ``; - h += ``; + h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint')); + h += '
'; + h += '
'; + h += ``; + h += ''; h += '
'; - h += ''; + h += '
'; + h += ``; + h += ''; + h += '
'; + h += ``; + h += '
'; + h += `

${i18n('teams_add_account_email_hint')}

`; + h += ''; h += '
'; return h; }; -const renderAudit = () => { +const renderAudit = ({ title, hint }) => { let h = '
'; - h += `

${i18n('teams_audit')}

`; - h += `

${i18n('teams_audit_hint')}

`; + h += renderCardHead(title, hint, state.audit.length || null); if ( state.audit.length === 0 ) { h += `

${i18n('teams_no_audit')}

`; } else { - h += '
'; - h += ``; - h += ``; - h += ``; - h += ``; - h += ''; const { items, page, pages, from, to, total } = auditSlice( state.audit, state.auditPage, AUDIT_ROWS_PER_PAGE, ); + h += '
${i18n('teams_audit_when')}${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}
'; + h += ``; + h += ``; + h += ``; + h += ``; + h += ''; for ( const entry of items ) { const actionKey = auditActionKey(entry.action); const reasonKey = auditReasonKey(entry.reason); h += ''; - h += ``; - h += `'; - h += ``; - h += ``; + h += ``; + h += ``; + h += ``; h += ''; } h += '
${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}${i18n('teams_audit_when')}
${html_encode(dateText(entry.createdAt))}${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; + h += `${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; if ( reasonKey ) h += ` ${i18n(reasonKey)}`; h += '${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(dateTimeText(entry.createdAt))}
'; @@ -227,17 +316,13 @@ const renderAudit = () => { /** Colleagues, by name. No state, no dates, no actions — none are theirs. */ const renderRoster = () => { let h = '
'; - h += `

${i18n('teams_roster')}

`; - h += `

${i18n('teams_roster_hint')}

`; + h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null); if ( state.members.length === 0 ) { h += `

${i18n('teams_roster_empty')}

`; } else { h += '
    '; for ( const member of sortMembers(state.members) ) { - const you = member.username === window.user?.username; - h += `
  • ${html_encode(member.username)}`; - if ( you ) h += ` (${i18n('share_you')})`; - h += '
  • '; + h += `
  • ${renderIdentity(member.username)}
  • `; } h += '
'; } @@ -247,44 +332,37 @@ const renderRoster = () => { /** What a member sees: who else is here, their own entries, nothing admin. */ const renderMemberView = () => { - let h = '
'; - h += `

${i18n('teams_your_record')}

`; - h += `

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

`; - h += '
'; - return h + renderRoster() + renderAudit(); + return renderHero() + + renderRoster() + + renderAudit({ + title: i18n('teams_your_record'), + hint: i18n('teams_your_record_hint', { team: teamName(state.selected) }), + }); }; const renderDirectory = () => { const on = state.selected?.directoryEnabled === true; - let h = '
'; - h += `

${i18n('teams_directory')}

`; - h += `

${i18n('teams_directory_hint')}

`; - h += '