Skip to content
Draft
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
29 changes: 27 additions & 2 deletions src/renderer/hooks/useLogins.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import { setNotificationsOverrides } from '../__helpers__/hook-mocks';
import { mockBitbucketAccount, mockGitHubCloudAccount } from '../__mocks__/account-mocks';
import {
mockBitbucketAccount,
mockGitHubAppAccount,
mockGitHubCloudAccount,
} from '../__mocks__/account-mocks';

import { Constants } from '../constants';

Expand Down Expand Up @@ -87,7 +91,28 @@ describe('renderer/hooks/useLogins.ts', () => {
);
});

expect(createAccountSpy).toHaveBeenCalledWith('GitHub App', 'token', 'github.com', 'github');
expect(createAccountSpy).toHaveBeenCalledWith(
'Gitify OAuth App',
'token',
'github.com',
'github',
);
});

it('migrates a legacy GitHub App device-flow account after re-authentication', async () => {
useAccountsStore.setState({ accounts: [mockGitHubAppAccount] });
const { result } = renderLoginsHook();

await act(async () => {
await result.current.loginWithDeviceFlowComplete(
'github',
'token' as Token,
Constants.GITHUB_HOSTNAME,
);
});

expect(removeAccountNotificationsMock).toHaveBeenCalledWith(mockGitHubAppAccount);
expect(removeAccountSpy).toHaveBeenCalledWith(mockGitHubAppAccount);
});

it('loginWithOAuthApp delegates to the forge adapter', async () => {
Expand Down
13 changes: 11 additions & 2 deletions src/renderer/hooks/useLogins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,23 @@ export const useLogins = (): LoginsState => {
}
const method = deviceFlow.authMethod;

const existingAccount = accounts.find((a) => a.hostname === hostname && a.method === method);
const existingAccount = accounts.find(
(a) =>
a.hostname === hostname &&
(a.method === method ||
(forge === 'github' && method === 'Gitify OAuth App' && a.method === 'GitHub App')),
);
if (existingAccount) {
await removeAccountNotifications(existingAccount);
}

await createAccount(method, token, hostname, forge);

if (existingAccount?.method === 'GitHub App' && method === 'Gitify OAuth App') {
removeAccount(existingAccount);
}
},
[accounts, createAccount, removeAccountNotifications],
[accounts, createAccount, removeAccount, removeAccountNotifications],
);

/**
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/routes/Accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,12 @@ export const AccountsRoute: FC = () => {
};

const handleReAuthenticate = (account: Account) => {
const authMethod =
account.forge === 'github' && account.method === 'GitHub App'
? 'Gitify OAuth App'
: account.method;
const loginMethod = getAdapter(account).loginMethods.find(
(method) => method.authMethod === account.method,
(method) => method.authMethod === authMethod,
);

if (!loginMethod) {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/routes/github/LoginWithDeviceFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => {
});

expect(screen.getByText('Receive notifications for:')).toBeInTheDocument();
expect(screen.getByText("Authorize Gitify's OAuth App")).toBeInTheDocument();
expect(screen.getByTestId('device-scope-public')).toBeInTheDocument();
expect(screen.getByTestId('device-scope-full')).toBeInTheDocument();

Expand Down
2 changes: 1 addition & 1 deletion src/renderer/routes/github/LoginWithDeviceFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export const GitHubLoginWithDeviceFlowRoute: FC = () => {

return (
<Page testId="Login With Device Flow">
<Header icon={SignInIcon}>Authorize with GitHub</Header>
<Header icon={SignInIcon}>Authorize Gitify's OAuth App</Header>

<Contents scrollFade>
{error && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ describe('renderer/routes/github/LoginWithPersonalAccessToken.tsx', () => {
expect(tree.container).toMatchSnapshot();
});

it('explains that fine-grained tokens are unsupported', () => {
renderWithProviders(<GitHubLoginWithPersonalAccessTokenRoute />);

expect(
screen.getByText(/Fine-grained personal access tokens are not supported/),
).toBeInTheDocument();
});

it('let us go back', async () => {
renderWithProviders(<GitHubLoginWithPersonalAccessTokenRoute />);

Expand Down
6 changes: 3 additions & 3 deletions src/renderer/routes/github/LoginWithPersonalAccessToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ export const GitHubLoginWithPersonalAccessTokenRoute: FC = () => (
forge="github"
hostnameCaption="Change only if you are using GitHub Enterprise Server"
hostnamePlaceholder="github.com"
title="Login with Personal Access Token"
tokenPlaceholder="Your generated token (40 characters)"
title="Login with Classic Personal Access Token"
tokenPlaceholder="Your classic token (40 characters)"
tokenSettingsCaption="on GitHub to paste the token below."
tokenSettingsLabel="Generate a PAT"
>
<Text as="i" className="text-xs">
The{' '}
Fine-grained personal access tokens are not supported. The{' '}
<Tooltip direction="se" text={formatRecommendedOAuthScopes()}>
<button type="button">
<Text as="u">recommended scopes</Text>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/renderer/utils/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AuthCode, ClientID, ClientSecret, Forge, Hostname, Token } from '../../types';

export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App';
export type AuthMethod = 'GitHub App' | 'Gitify OAuth App' | 'Personal Access Token' | 'OAuth App';

export type PlatformType =
| 'Bitbucket Cloud'
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/utils/forges/github/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ describe('renderer/utils/forges/github/adapter.ts', () => {

it('maps each auth method to its icon', () => {
expect(githubAdapter.getAuthMethodIcon('GitHub App')).toBe(AppsIcon);
expect(githubAdapter.getAuthMethodIcon('Gitify OAuth App')).toBe(PersonIcon);
expect(githubAdapter.getAuthMethodIcon('OAuth App')).toBe(PersonIcon);
expect(githubAdapter.getAuthMethodIcon('Personal Access Token')).toBe(KeyIcon);
});

it('wires the device-flow and OAuth-app methods so the context can dispatch via the adapter', () => {
expect(githubAdapter.deviceFlow?.authMethod).toBe('GitHub App');
expect(githubAdapter.deviceFlow?.authMethod).toBe('Gitify OAuth App');
expect(githubAdapter.deviceFlow?.start).toBeDefined();
expect(githubAdapter.deviceFlow?.poll).toBeDefined();
expect(githubAdapter.deviceFlow?.getRevokeAccessUrl).toBeDefined();
Expand Down
9 changes: 5 additions & 4 deletions src/renderer/utils/forges/github/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ForgeAdapter, NotificationDisplayHelpers, RefreshAccountData } fro

import {
extractHostVersion,
getDeviceFlowRevokeAccessURL,
getDeveloperSettingsURL,
getNewOAuthAppURL,
getNewTokenURL,
Expand Down Expand Up @@ -100,7 +101,7 @@ export const githubAdapter: ForgeAdapter = {
label: 'GitHub',
variant: 'primary',
route: '/login/github/device-flow',
authMethod: 'GitHub App',
authMethod: 'Gitify OAuth App',
},
{
testId: 'login-pat',
Expand All @@ -119,11 +120,10 @@ export const githubAdapter: ForgeAdapter = {
],

deviceFlow: {
authMethod: 'GitHub App',
authMethod: 'Gitify OAuth App',
start: startGitHubDeviceFlow,
poll: pollGitHubDeviceFlow,
getRevokeAccessUrl: (hostname) =>
getDeveloperSettingsURL({ hostname, method: 'GitHub App' } as Account),
getRevokeAccessUrl: getDeviceFlowRevokeAccessURL,
},

oauthWebApp: {
Expand Down Expand Up @@ -172,6 +172,7 @@ function githubAuthMethodIcon(method: AuthMethod) {
switch (method) {
case 'GitHub App':
return AppsIcon;
case 'Gitify OAuth App':
case 'OAuth App':
return PersonIcon;
default:
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/utils/forges/github/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AuthMethod } from '../../auth/types';
import {
extractHostVersion,
getDeveloperSettingsURL,
getDeviceFlowRevokeAccessURL,
getGitHubAuthBaseUrl,
getNewOAuthAppURL,
getNewTokenURL,
Expand Down Expand Up @@ -70,6 +71,23 @@ describe('renderer/utils/forges/github/auth.ts', () => {
).toBe('https://github.com/settings/connections/applications/FAKE_CLIENT_ID_123');
});

it('returns the preconfigured Gitify OAuth App connections URL', () => {
expect(
getDeveloperSettingsURL({
hostname: 'github.com' as Hostname,
method: 'Gitify OAuth App',
} as Account),
).toBe('https://github.com/settings/connections/applications/FAKE_CLIENT_ID_123');
});

describe('getDeviceFlowRevokeAccessURL', () => {
it('returns the built-in OAuth App connection URL', () => {
expect(getDeviceFlowRevokeAccessURL('github.com' as Hostname)).toBe(
'https://github.com/settings/connections/applications/FAKE_CLIENT_ID_123',
);
});
});

it('returns the OAuth App developer URL', () => {
expect(
getDeveloperSettingsURL({
Expand Down
16 changes: 15 additions & 1 deletion src/renderer/utils/forges/github/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function getGitHubAuthBaseUrl(hostname: Hostname): URL {
/**
* Return the GitHub developer settings URL appropriate for the account's auth method.
*
* - GitHub App → application connections page
* - GitHub App or preconfigured Gitify OAuth App → application connections page
* - OAuth App → developer settings page
* - Personal Access Token → tokens settings page
*
Expand All @@ -74,6 +74,7 @@ export function getDeveloperSettingsURL(account: Account): Link {

switch (account.method) {
case 'GitHub App':
case 'Gitify OAuth App':
settingsURL.pathname = `/settings/connections/applications/${Constants.OAUTH_DEVICE_FLOW_CLIENT_ID}`;
break;
case 'OAuth App':
Expand All @@ -86,6 +87,19 @@ export function getDeveloperSettingsURL(account: Account): Link {
settingsURL.pathname = '/settings';
break;
}

return settingsURL.toString() as Link;
}

/**
* Return the connected-application settings URL for Gitify's preconfigured OAuth App.
*
* This intentionally differs from the generic OAuth App settings page used for
* user-configured apps: it lets a user revoke Gitify's existing authorization.
*/
export function getDeviceFlowRevokeAccessURL(hostname: Hostname): Link {
const settingsURL = new URL(`https://${hostname}`);
settingsURL.pathname = `/settings/connections/applications/${Constants.OAUTH_DEVICE_FLOW_CLIENT_ID}`;
return settingsURL.toString() as Link;
}

Expand Down
Loading