diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx
index 0cb2970e4..5ff682381 100644
--- a/src/renderer/routes/Accounts.test.tsx
+++ b/src/renderer/routes/Accounts.test.tsx
@@ -11,6 +11,14 @@ import {
import { useAccountsStore } from '../stores';
+import { IconColor } from '../types';
+
+import {
+ getAlternateScopeNames,
+ getRecommendedScopeNames,
+ getRequiredScopeNames,
+} from '../utils/auth/scopes';
+import { getAccountUUID } from '../utils/auth/utils';
import { Errors } from '../utils/core/errors';
import * as logger from '../utils/core/logger';
import * as comms from '../utils/system/comms';
@@ -61,6 +69,124 @@ describe('renderer/routes/Accounts.tsx', () => {
});
});
+ describe('Scopes icon', () => {
+ const scopeCases = [
+ {
+ name: 'recommended scopes',
+ scopes: getRecommendedScopeNames(),
+ color: IconColor.GREEN,
+ variant: 'default',
+ },
+ {
+ name: 'alternate scopes only',
+ scopes: getAlternateScopeNames(),
+ color: 'text-gitify-warning',
+ variant: 'default',
+ },
+ {
+ name: 'neither scope set',
+ scopes: getRequiredScopeNames(),
+ color: null,
+ variant: 'danger',
+ },
+ {
+ name: 'both scope sets',
+ scopes: [...new Set([...getRecommendedScopeNames(), ...getAlternateScopeNames()])],
+ color: IconColor.GREEN,
+ variant: 'default',
+ },
+ ];
+
+ it.each(scopeCases)(
+ 'preserves the icon color and button variant for $name',
+ async ({ scopes, color, variant }) => {
+ await act(async () => {
+ renderWithProviders(, {
+ accounts: [{ ...mockPersonalAccessTokenAccount, scopes }],
+ });
+ });
+
+ const button = screen.getByTestId('account-view-scopes');
+ const icon = button.querySelector('svg');
+
+ expect(icon).toHaveClass('octicon-shield-check');
+ expect(button).toHaveAttribute('data-variant', variant);
+ expect(button).toHaveAttribute('data-size', 'small');
+ expect(button).toHaveAccessibleName(
+ `View scopes for ${mockPersonalAccessTokenAccount.user?.login}`,
+ );
+ if (color) {
+ expect(icon).toHaveClass(color);
+ }
+ for (const scopeColor of [IconColor.GREEN, 'text-gitify-warning']) {
+ if (scopeColor !== color) {
+ expect(icon).not.toHaveClass(scopeColor);
+ }
+ }
+ },
+ );
+
+ it.each(scopeCases)(
+ 'retains the icon across an unrelated account update for $name',
+ async ({ scopes }) => {
+ const account = { ...mockPersonalAccessTokenAccount, scopes };
+
+ await act(async () => {
+ renderWithProviders(, { accounts: [account] });
+ });
+
+ const icon = screen.getByTestId('account-view-scopes').querySelector('svg');
+ expect(icon).toBeInTheDocument();
+
+ const updatedAccount = {
+ ...account,
+ user: { ...account.user!, name: 'Updated account name' },
+ };
+ expect(getAccountUUID(updatedAccount)).toBe(getAccountUUID(account));
+
+ await act(async () => {
+ useAccountsStore.setState({ accounts: [updatedAccount] });
+ });
+
+ expect(screen.getByText('Updated account name')).toBeInTheDocument();
+ expect(screen.getByTestId('account-view-scopes').querySelector('svg')).toBe(icon);
+ },
+ );
+
+ it('updates the icon, variant, and navigation account when scopes change', async () => {
+ const account = {
+ ...mockPersonalAccessTokenAccount,
+ scopes: getRequiredScopeNames(),
+ };
+
+ await act(async () => {
+ renderWithProviders(, { accounts: [account] });
+ });
+
+ const button = screen.getByTestId('account-view-scopes');
+ expect(button).toHaveAttribute('data-variant', 'danger');
+ expect(button.querySelector('svg')).not.toHaveClass(IconColor.GREEN, 'text-gitify-warning');
+
+ const updatedAccount = { ...account, scopes: getRecommendedScopeNames() };
+ expect(getAccountUUID(updatedAccount)).toBe(getAccountUUID(account));
+
+ await act(async () => {
+ useAccountsStore.setState({ accounts: [updatedAccount] });
+ });
+
+ const updatedButton = screen.getByTestId('account-view-scopes');
+ expect(updatedButton.querySelector('svg')).toHaveClass(IconColor.GREEN);
+ expect(updatedButton.querySelector('svg')).not.toHaveClass('text-gitify-warning');
+ expect(updatedButton).toHaveAttribute('data-variant', 'default');
+
+ await userEvent.click(updatedButton);
+
+ expect(navigateMock).toHaveBeenCalledWith('/account-scopes', {
+ state: { account: updatedAccount },
+ });
+ });
+ });
+
describe('Account interactions', () => {
const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn());
@@ -332,6 +458,8 @@ describe('renderer/routes/Accounts.tsx', () => {
});
});
+ expect(screen.queryByTestId('account-view-scopes')).not.toBeInTheDocument();
+
await userEvent.click(screen.getByTestId('account-reauthenticate'));
expect(navigateMock).toHaveBeenCalledTimes(1);
diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx
index b633edea2..066af7164 100644
--- a/src/renderer/routes/Accounts.tsx
+++ b/src/renderer/routes/Accounts.tsx
@@ -6,6 +6,7 @@ import {
ArrowLeftIcon,
ChevronRightIcon,
KeyIcon,
+ type OcticonProps,
PersonAddIcon,
PersonIcon,
ShieldCheckIcon,
@@ -16,6 +17,8 @@ import {
} from '@primer/octicons-react';
import { ActionList, ActionMenu, Button, IconButton, Stack, Text } from '@primer/react';
+import { cn } from 'cn';
+
import { useLogins } from '../hooks/useLogins';
import { useNotifications } from '../hooks/useNotifications';
import { useAccountsStore } from '../stores';
@@ -38,14 +41,22 @@ import { getAdapter, listAdapters } from '../utils/forges/registry';
import { openAccountProfile, openAccountSettings, openHost } from '../utils/system/links';
import { getPlatformIcon } from '../utils/ui/icons';
-function getScopeIconClassName(account: Account): string {
+const RecommendedScopesIcon: FC = ({ className, ...props }) => (
+
+);
+
+const AlternateScopesIcon: FC = ({ className, ...props }) => (
+
+);
+
+function getScopeIcon(account: Account): FC {
if (hasRecommendedScopes(account)) {
- return IconColor.GREEN;
+ return RecommendedScopesIcon;
}
if (hasAlternateScopes(account)) {
- return 'text-gitify-warning';
+ return AlternateScopesIcon;
}
- return '';
+ return ShieldCheckIcon;
}
export const AccountsRoute: FC = () => {
@@ -242,7 +253,7 @@ export const AccountsRoute: FC = () => {
}
+ icon={getScopeIcon(account)}
onClick={() =>
navigate('/account-scopes', {
state: { account },