Skip to content

Commit 18b030f

Browse files
committed
feat(auth): account scopes and expired pat workflow
Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent e80c449 commit 18b030f

34 files changed

Lines changed: 1247 additions & 101 deletions

src/renderer/App.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
/** Tailwind CSS Configuration */
88
@config "../../tailwind.config.mts";
99

10+
@layer components {
11+
.gitify-scope-row {
12+
@apply rounded-md py-1.5 px-4 bg-gitify-accounts;
13+
}
14+
}
15+
1016
html,
1117
body,
1218
#root {

src/renderer/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { BaseStyles, ThemeProvider } from '@primer/react';
1010

1111
import { AppProvider } from './context/App';
12+
import { AccountScopesRoute } from './routes/AccountScopes';
1213
import { AccountsRoute } from './routes/Accounts';
1314
import { FiltersRoute } from './routes/Filters';
1415
import { LoginRoute } from './routes/Login';
@@ -78,6 +79,14 @@ export const App = () => {
7879
}
7980
path="/accounts"
8081
/>
82+
<Route
83+
element={
84+
<RequireAuth>
85+
<AccountScopesRoute />
86+
</RequireAuth>
87+
}
88+
path="/account-scopes"
89+
/>
8190
<Route element={<LoginRoute />} path="/login" />
8291
<Route
8392
element={<LoginWithDeviceFlowRoute />}

src/renderer/__mocks__/account-mocks.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
Token,
99
} from '../types';
1010

11+
import { getRecommendedScopeNames } from '../utils/auth/utils';
1112
import { mockGitifyUser } from './user-mocks';
1213

1314
export const mockGitHubAppAccount: Account = {
@@ -16,7 +17,7 @@ export const mockGitHubAppAccount: Account = {
1617
token: 'token-987654321' as Token,
1718
hostname: Constants.GITHUB_HOSTNAME,
1819
user: mockGitifyUser,
19-
hasRequiredScopes: true,
20+
scopes: getRecommendedScopeNames(),
2021
};
2122

2223
export const mockPersonalAccessTokenAccount: Account = {
@@ -25,7 +26,7 @@ export const mockPersonalAccessTokenAccount: Account = {
2526
token: 'token-123-456' as Token,
2627
hostname: Constants.GITHUB_HOSTNAME,
2728
user: mockGitifyUser,
28-
hasRequiredScopes: true,
29+
scopes: getRecommendedScopeNames(),
2930
};
3031

3132
export const mockOAuthAccount: Account = {
@@ -34,7 +35,7 @@ export const mockOAuthAccount: Account = {
3435
token: 'token-1234568790' as Token,
3536
hostname: 'github.gitify.io' as Hostname,
3637
user: mockGitifyUser,
37-
hasRequiredScopes: true,
38+
scopes: getRecommendedScopeNames(),
3839
};
3940

4041
export const mockGitHubCloudAccount: Account = {
@@ -44,7 +45,6 @@ export const mockGitHubCloudAccount: Account = {
4445
hostname: Constants.GITHUB_HOSTNAME,
4546
user: mockGitifyUser,
4647
version: 'latest',
47-
hasRequiredScopes: true,
4848
};
4949

5050
export const mockGitHubEnterpriseServerAccount: Account = {
@@ -53,7 +53,6 @@ export const mockGitHubEnterpriseServerAccount: Account = {
5353
token: 'token-1234568790' as Token,
5454
hostname: 'github.gitify.io' as Hostname,
5555
user: mockGitifyUser,
56-
hasRequiredScopes: true,
5756
};
5857

5958
export function mockAccountWithError(error: GitifyError): AccountNotifications {

src/renderer/components/Oops.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import {
77

88
import { Oops } from './Oops';
99

10+
const navigateMock = vi.fn();
11+
vi.mock('react-router-dom', async () => ({
12+
...(await vi.importActual('react-router-dom')),
13+
useNavigate: () => navigateMock,
14+
}));
15+
1016
describe('renderer/components/Oops.tsx', () => {
1117
beforeEach(() => {
1218
ensureStableEmojis();

src/renderer/components/Oops.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { type FC, useMemo } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
4+
import { Button } from '@primer/react';
25

36
import { EmojiSplash } from './layout/EmojiSplash';
47

@@ -16,14 +19,31 @@ export const Oops: FC<OopsProps> = ({
1619
fullHeight = true,
1720
}: OopsProps) => {
1821
const err = error ?? Errors.UNKNOWN;
22+
const navigate = useNavigate();
1923

2024
const emoji = useMemo(
2125
() => err.emojis[Math.floor(Math.random() * err.emojis.length)],
2226
[err],
2327
);
2428

29+
const actions = err.actions?.length ? (
30+
<>
31+
{err.actions.map((action) => (
32+
<Button
33+
key={action.route}
34+
leadingVisual={action.icon}
35+
onClick={() => navigate(action.route)}
36+
variant={action.variant}
37+
>
38+
{action.label}
39+
</Button>
40+
))}
41+
</>
42+
) : null;
43+
2544
return (
2645
<EmojiSplash
46+
actions={actions}
2747
emoji={emoji}
2848
fullHeight={fullHeight}
2949
heading={err.title}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { screen } from '@testing-library/react';
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { renderWithAppContext } from '../../__helpers__/test-utils';
6+
7+
import { ScopeStatusIcon } from './ScopeStatusIcon';
8+
9+
describe('renderer/components/icons/ScopeStatusIcon.tsx', () => {
10+
describe('granted state', () => {
11+
it('renders a success icon when granted', () => {
12+
const tree = renderWithAppContext(<ScopeStatusIcon granted={true} />);
13+
expect(tree.container).toMatchSnapshot();
14+
});
15+
16+
it('renders a success icon with test id when withTestId is true', () => {
17+
renderWithAppContext(
18+
<ScopeStatusIcon granted={true} withTestId={true} />,
19+
);
20+
expect(
21+
screen.getByTestId('account-scopes-scope-granted'),
22+
).toBeInTheDocument();
23+
});
24+
25+
it('does not render a test id by default', () => {
26+
renderWithAppContext(<ScopeStatusIcon granted={true} />);
27+
expect(
28+
screen.queryByTestId('account-scopes-scope-granted'),
29+
).not.toBeInTheDocument();
30+
});
31+
});
32+
33+
describe('missing state', () => {
34+
it('renders a danger icon when not granted', () => {
35+
const tree = renderWithAppContext(<ScopeStatusIcon granted={false} />);
36+
expect(tree.container).toMatchSnapshot();
37+
});
38+
39+
it('renders a danger icon with test id when withTestId is true', () => {
40+
renderWithAppContext(
41+
<ScopeStatusIcon granted={false} withTestId={true} />,
42+
);
43+
expect(
44+
screen.getByTestId('account-scopes-scope-missing'),
45+
).toBeInTheDocument();
46+
});
47+
48+
it('does not render a test id by default', () => {
49+
renderWithAppContext(<ScopeStatusIcon granted={false} />);
50+
expect(
51+
screen.queryByTestId('account-scopes-scope-missing'),
52+
).not.toBeInTheDocument();
53+
});
54+
});
55+
56+
describe('notApplicable state', () => {
57+
it('renders a dash icon when notApplicable', () => {
58+
const tree = renderWithAppContext(
59+
<ScopeStatusIcon granted={false} notApplicable={true} />,
60+
);
61+
expect(tree.container).toMatchSnapshot();
62+
});
63+
64+
it('renders a dash icon with test id when withTestId is true', () => {
65+
renderWithAppContext(
66+
<ScopeStatusIcon
67+
granted={false}
68+
notApplicable={true}
69+
withTestId={true}
70+
/>,
71+
);
72+
expect(screen.getByTestId('account-scopes-scope-na')).toBeInTheDocument();
73+
});
74+
75+
it('does not render a test id by default', () => {
76+
renderWithAppContext(
77+
<ScopeStatusIcon granted={false} notApplicable={true} />,
78+
);
79+
expect(
80+
screen.queryByTestId('account-scopes-scope-na'),
81+
).not.toBeInTheDocument();
82+
});
83+
84+
it('ignores granted=true when notApplicable is true', () => {
85+
renderWithAppContext(
86+
<ScopeStatusIcon
87+
granted={true}
88+
notApplicable={true}
89+
withTestId={true}
90+
/>,
91+
);
92+
expect(screen.getByTestId('account-scopes-scope-na')).toBeInTheDocument();
93+
expect(
94+
screen.queryByTestId('account-scopes-scope-granted'),
95+
).not.toBeInTheDocument();
96+
});
97+
});
98+
99+
describe('size prop', () => {
100+
it('renders correctly with a custom size', () => {
101+
const tree = renderWithAppContext(
102+
<ScopeStatusIcon granted={true} size={20} />,
103+
);
104+
expect(tree.container).toMatchSnapshot();
105+
});
106+
});
107+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { FC } from 'react';
2+
3+
import {
4+
CheckCircleFillIcon,
5+
DashIcon,
6+
XCircleFillIcon,
7+
} from '@primer/octicons-react';
8+
9+
export interface ScopeStatusIconProps {
10+
granted: boolean;
11+
notApplicable?: boolean;
12+
size?: number;
13+
withTestId?: boolean;
14+
}
15+
16+
export const ScopeStatusIcon: FC<ScopeStatusIconProps> = ({
17+
granted,
18+
notApplicable = false,
19+
size = 14,
20+
withTestId = false,
21+
}) => {
22+
if (notApplicable) {
23+
return (
24+
<DashIcon
25+
className="opacity-30"
26+
data-testid={withTestId ? 'account-scopes-scope-na' : undefined}
27+
size={size}
28+
/>
29+
);
30+
}
31+
32+
return granted ? (
33+
<CheckCircleFillIcon
34+
className="text-gitify-success"
35+
data-testid={withTestId ? 'account-scopes-scope-granted' : undefined}
36+
size={size}
37+
/>
38+
) : (
39+
<XCircleFillIcon
40+
className="text-gitify-danger"
41+
data-testid={withTestId ? 'account-scopes-scope-missing' : undefined}
42+
size={size}
43+
/>
44+
);
45+
};

0 commit comments

Comments
 (0)