Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 187 additions & 120 deletions src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ it('does not offer an update check in a runtime that cannot install updates', as
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
expect(container.textContent).toContain('accountLogin.relayFailureVersionRetired');
expect(container.textContent).not.toContain('update.checkForUpdates');
// Without a banner action the section heading still offers a way forward.
const retryButtons = Array.from(container.querySelectorAll('button'))
.filter(node => node.textContent === 'accountLogin.retryConnect');
expect(retryButtons).toHaveLength(1);
});

it('offers a retry when the Relay is temporarily unavailable', async () => {
Expand All @@ -161,7 +165,10 @@ it('offers a retry when the Relay is temporarily unavailable', async () => {
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
expect(container.textContent).toContain('accountLogin.relayFailureUnavailable');
expect(container.textContent).not.toContain('502');
expect(container.textContent).toContain('accountLogin.retryConnect');
const retryButtons = Array.from(container.querySelectorAll('button'))
.filter(node => node.textContent === 'accountLogin.retryConnect');
expect(retryButtons).toHaveLength(1);
expect(retryButtons[0]!.closest('[data-openbitfun-part="error"]')).not.toBeNull();
});

async function retryConnection() {
Expand Down Expand Up @@ -441,3 +448,34 @@ it('still allows switching to a device whose compatibility flag is absent', asyn
await act(async () => { peer!.click(); });
expect(mocks.switchToDevice).toHaveBeenCalledWith('peer', 'Peer build');
});

it('groups the account and its devices into titled sections with fixed row columns', async () => {
mocks.accountConnectDevices.mockResolvedValue([]);
mocks.getDeviceInfo.mockReset().mockResolvedValue({ device_id: 'local' });
mocks.accountListDevices.mockResolvedValue([
{ device_id: 'local', device_name: 'My computer', online: true },
{ device_id: 'peer', device_name: 'Peer build', online: false },
]);
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
const titles = Array.from(container.querySelectorAll('.account-panel__section-title')).map(node => node.textContent);
expect(titles).toEqual(['accountLogin.accountSectionTitle', 'accountLogin.linkedDevices']);
const statuses = Array.from(container.querySelectorAll('.account-panel__device-status'))
.map(node => node.getAttribute('data-openbitfun-state'));
expect(statuses).toEqual(['online', 'offline']);
for (const row of container.querySelectorAll('[data-openbitfun-part="deviceCard"]')) {
const children = Array.from(row.children).map(node => node.className);
expect(children).toEqual([
'account-panel__device-select',
'account-panel__device-actions',
'account-panel__device-chevron',
]);
}
});

it('shows the sign-in action inside the account section when signed out', async () => {
mocks.identity = { resolved: true, status: 'signed-out', me: null };
mocks.accountStatus.mockReset().mockResolvedValue({ logged_in: false });
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
expect(container.querySelector('.account-panel__section-title')?.textContent).toBe('accountLogin.accountSectionTitle');
expect(container.querySelector('[data-openbitfun-part="form"]')?.textContent).toContain('accountLogin.login');
});
135 changes: 82 additions & 53 deletions src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useI18n } from '@/infrastructure/i18n';
import {
confirmDanger,
} from '@/infrastructure/confirm-dialog';
import { LogIn, Pencil, Check, X } from 'lucide-react';
import { LogIn, LogOut, Pencil, Check, X } from 'lucide-react';
import { DeviceSystemGlyph } from '../NavPanel/components/DeviceSystemGlyph';
import { reportedHostKind } from '../NavPanel/deviceInterconnectionOverview';
import { remoteConnectAPI, deviceDisplayName, deviceMetadataLabel } from '@/infrastructure/api/service-api/RemoteConnectAPI';
Expand Down Expand Up @@ -138,16 +138,23 @@ interface FailureBannerProps {
onAction: (action: RelayFailureAction) => void;
}

/**
* The button a failure banner offers, if any. An update check only exists where
* updates can be installed; elsewhere the banner stays a statement instead of
* offering an action that cannot run.
*/
const failureBannerAction = (failure: PanelFailure): RelayFailureAction | null => (
failure.action === 'retry' || (failure.action === 'check-updates' && canCheckForAppUpdates())
? failure.action
: null
);

/**
* Red banner plus the single next step for a classified failure. A failure whose
* action is the existing sign-in flow renders no extra button.
*/
const FailureBanner: React.FC<FailureBannerProps> = ({ failure, t, busy, onClose, onAction }) => {
// An update check only exists where updates can be installed; elsewhere the
// banner stays a statement instead of offering an action that cannot run.
const actionable = failure.action === 'retry' || (failure.action === 'check-updates' && canCheckForAppUpdates())
? failure.action
: null;
const actionable = failureBannerAction(failure);
return (
<div className="account-panel__error-banner" data-openbitfun-component="remote-account-panel" data-openbitfun-part="error">
<Alert
Expand Down Expand Up @@ -803,6 +810,13 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
}
}, [handleCheckForUpdates, handleLogin, handleRetryConnect, view]);

const renderSectionHeading = (id: string, title: string, action?: React.ReactNode) => (
<div className="account-panel__section-heading">
<h3 id={id} className="account-panel__section-title">{title}</h3>
{action}
</div>
);

return (
<>
<div data-openbitfun-component="remote-account-panel" data-openbitfun-part="root" data-openbitfun-view={view} className="account-panel">
Expand All @@ -824,63 +838,73 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({

{loading && view === 'devices' && (
<div className="account-panel__loading-overlay" data-openbitfun-component="remote-account-panel" data-openbitfun-part="loading">
<Icon name="refresh" size="lg" className="spinning" style={{ width: 20, height: 20 }} />
<Icon name="refresh" size="lg" className="spinning" />
<span>{t('accountLogin.processing')}</span>
</div>
)}

{view === 'login' && (
<ScrollArea className="account-panel__scroll" data-openbitfun-component="remote-account-panel" data-openbitfun-part="scroll">
<div className="account-panel__login-card" data-openbitfun-component="remote-account-panel" data-openbitfun-part="form">
<span className="account-panel__login-icon" aria-hidden="true"><Icon name="user" size="lg" /></span>
<p className="account-panel__value-prop">{t('accountLogin.loginValueProp')}</p>
<p className="account-panel__security-note">{t('accountLogin.securityNote')}</p>
<div className="account-panel__actions" data-openbitfun-component="remote-account-panel" data-openbitfun-part="actions">
<Button variant="primary" size="sm" leadingIcon={<LogIn />} onClick={handleLogin} loading={loading && identity.status !== 'authorizing'}>
{identity.status === 'authorizing' ? t('accountLogin.reopen') : loading ? t('accountLogin.processing') : t('accountLogin.login')}
</Button>
<section className="account-panel__section" aria-labelledby="account-panel-account-title">
{renderSectionHeading('account-panel-account-title', t('accountLogin.accountSectionTitle'))}
<div className="account-panel__card account-panel__login-card" data-openbitfun-component="remote-account-panel" data-openbitfun-part="form">
<span className="account-panel__login-icon" aria-hidden="true"><Icon name="user" size="md" /></span>
<span className="account-panel__login-copy">
<span className="account-panel__value-prop">{t('accountLogin.loginValueProp')}</span>
<span className="account-panel__security-note">{t('accountLogin.securityNote')}</span>
</span>
<div className="account-panel__actions" data-openbitfun-component="remote-account-panel" data-openbitfun-part="actions">
<Button variant="primary" size="sm" leadingIcon={<LogIn size={14} />} onClick={handleLogin} loading={loading && identity.status !== 'authorizing'}>
{identity.status === 'authorizing' ? t('accountLogin.reopen') : loading ? t('accountLogin.processing') : t('accountLogin.login')}
</Button>
</div>
</div>
</div>
</section>
</ScrollArea>
)}

{view === 'devices' && (
<ScrollArea className="account-panel__scroll" data-openbitfun-component="remote-account-panel" data-openbitfun-part="scroll">
<div className="account-panel__identity-line">
<Avatar key={username} size="md" src={identity.me?.user.avatarUrl} alt={username} aria-label={username}>
{username.trim().charAt(0).toUpperCase() || <Icon name="user" />}
</Avatar>
<span className="account-panel__identity-copy">
<span className="account-panel__identity-label">{t('accountLogin.signedInAccount')}</span>
<OverflowText className="account-panel__identity-name" title={username}>{username.trim()}</OverflowText>
</span>
<Button variant="text" size="sm" onClick={handleLogout} disabled={loading}>
{t('accountLogin.logout')}
</Button>
</div>
<div className="account-panel__section-heading">
<h3>{t('accountLogin.linkedDevices')}</h3>
<Button variant="text" size="sm" leadingIcon={<Icon name="refresh" size="sm" />}
onClick={relayFailure ? handleRetryConnect : refreshDevices} disabled={loading}>
{t(relayFailure ? 'accountLogin.retryConnect' : 'accountLogin.refreshDevices')}
</Button>
</div>
<div className="account-panel__devices-card">
<section className="account-panel__section" aria-labelledby="account-panel-account-title">
{renderSectionHeading('account-panel-account-title', t('accountLogin.accountSectionTitle'))}
<div className="account-panel__card account-panel__identity-line">
<Avatar key={username} size="md" src={identity.me?.user.avatarUrl} alt={username} aria-label={username}>
{username.trim().charAt(0).toUpperCase() || <Icon name="user" />}
</Avatar>
<span className="account-panel__identity-copy">
<OverflowText className="account-panel__identity-name" title={username}>{username.trim()}</OverflowText>
<span className="account-panel__identity-label">{t('accountLogin.signedInAccount')}</span>
</span>
<Button variant="outline" size="sm" leadingIcon={<LogOut size={14} />} onClick={handleLogout} disabled={loading}>
{t('accountLogin.logout')}
</Button>
</div>
</section>
<section className="account-panel__section" aria-labelledby="account-panel-devices-title">
{renderSectionHeading('account-panel-devices-title', t('accountLogin.linkedDevices'), (
relayFailure && failureBannerAction(relayFailure) ? null : (
<Button variant="text" size="xs" leadingIcon={<Icon name="refresh" size="sm" />}
onClick={relayFailure ? handleRetryConnect : refreshDevices} disabled={loading}>
{t(relayFailure ? 'accountLogin.retryConnect' : 'accountLogin.refreshDevices')}
</Button>
)
))}
{aliasCapability === 'unsupported' && <Alert tone="info" message={t('accountLogin.deviceAliasUnsupported')} />}
{relayFailure && (
<FailureBanner failure={relayFailure} t={t} busy={loading} onAction={runFailureAction} />
)}
<div className="account-panel__device-list" data-openbitfun-component="remote-account-panel" data-openbitfun-part="deviceList">
{!relayFailure && devicesReady && devices.length === 0 && (
<div className="account-panel__empty">{t('accountLogin.noDevices')}</div>
)}
{!relayFailure && !devicesReady && (
<div className="account-panel__empty account-panel__empty--loading" role="status">
<Icon name="refresh" size="sm" className="spinning" />
{t('accountLogin.loadingDevices')}
</div>
)}
{!relayFailure && sortedDevices.map((d) => {
{!relayFailure && <div className="account-panel__card account-panel__devices-card">
<div className="account-panel__device-list" data-openbitfun-component="remote-account-panel" data-openbitfun-part="deviceList">
{devicesReady && devices.length === 0 && (
<div className="account-panel__empty">{t('accountLogin.noDevices')}</div>
)}
{!devicesReady && (
<div className="account-panel__empty account-panel__empty--loading" role="status">
<Icon name="refresh" size="sm" className="spinning" />
{t('accountLogin.loadingDevices')}
</div>
)}
{sortedDevices.map((d) => {
const isLocal = localDeviceId === d.device_id;
// This machine is selectable while the window renders a peer,
// so the dialog can bring the UI back without disconnecting.
Expand Down Expand Up @@ -927,7 +951,9 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
'aria-label': t('accountLogin.openDevice', { name: displayName }),
} : {})}
>
<DeviceSystemGlyph device={systemFacts} />
<span className="account-panel__device-glyph" aria-hidden="true">
<DeviceSystemGlyph device={systemFacts} size={18} />
</span>
<span className="account-panel__device-info">
{/* Renaming replaces the name in place: showing the old
name next to an editor reads as two device names. */}
Expand Down Expand Up @@ -986,12 +1012,11 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
</span>
)}
</span>
{isSelectable && <Icon name="chevron-right" size="sm" />}
</DeviceEntry>
{/* The open editor owns the row's actions; keeping rename and
delete beside it rendered two competing icon clusters. */}
{editingDeviceId !== d.device_id && (
<>
<span className="account-panel__device-actions">
<IconButton
aria-label={t('accountLogin.editDeviceAlias')}
disabled={loading || editingDeviceId !== null || !aliasSupported || savingAliasId !== null}
Expand All @@ -1010,13 +1035,17 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
title={removeLabel}
variant="quiet"
/>
</>
</span>
)}
<span className="account-panel__device-chevron" aria-hidden="true">
{isSelectable && editingDeviceId !== d.device_id && <Icon name="chevron-right" size="sm" />}
</span>
</div>
);
})}
</div>
</div>
</div>
</div>}
</section>
</ScrollArea>
)}
</div>
Expand Down
Loading
Loading