diff --git a/src/main/index.ts b/src/main/index.ts index 8b9f58646..557b3d008 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,16 +14,10 @@ import { registerUpdaterHandlers, } from './handlers'; import { getIdleTrayIcon } from './icons'; -import { - configureWindowEvents, - handleProtocolURL, - initializeAppLifecycle, - onFirstRunMaybe, -} from './lifecycle'; +import { configureWindowEvents, initializeAppLifecycle, onFirstRunMaybe } from './lifecycle'; import MenuBuilder from './menu'; import { applyOzonePlatform } from './ozone'; import AppUpdater from './updater'; -import { isDevMode } from './utils'; // Runs at module load: the Ozone platform is read during app startup, so this // has to happen before `app.whenReady()` below. @@ -48,10 +42,6 @@ const mb = menubar({ const menuBuilder = new MenuBuilder(mb); const contextMenu = menuBuilder.buildMenu(); -// Register your app as the handler for a custom protocol -const protocol = isDevMode() ? 'gitify-dev' : 'gitify'; -app.setAsDefaultProtocolClient(protocol); - const appUpdater = new AppUpdater(mb, menuBuilder); app @@ -59,7 +49,7 @@ app .then(async () => { await onFirstRunMaybe(); - initializeAppLifecycle(mb, contextMenu, protocol); + initializeAppLifecycle(mb, contextMenu); // Configure window event handlers (Escape key, DevTools resize) configureWindowEvents(mb, menuBuilder); @@ -75,9 +65,3 @@ app .catch((err) => { logError('main', 'Failed to initialize application', toError(err)); }); - -// Handle gitify:// custom protocol URL events for OAuth 2.0 callback -app.on('open-url', (event, url) => { - event.preventDefault(); - handleProtocolURL(mb, url, protocol); -}); diff --git a/src/main/lifecycle/startup.test.ts b/src/main/lifecycle/startup.test.ts index f81f9a66f..17ced7ae8 100644 --- a/src/main/lifecycle/startup.test.ts +++ b/src/main/lifecycle/startup.test.ts @@ -1,30 +1,19 @@ import type { Menubar } from 'electron-menubar'; -import { EVENTS } from '../../shared/events'; - -import { handleProtocolURL, initializeAppLifecycle } from './startup'; +import { initializeAppLifecycle } from './startup'; const requestSingleInstanceLockMock = vi.fn(() => true); -const appOnMock = vi.fn(); const appQuitMock = vi.fn(); vi.mock('electron', () => ({ app: { requestSingleInstanceLock: () => requestSingleInstanceLockMock(), - on: (...a: unknown[]) => appOnMock(...a), quit: () => appQuitMock(), - } satisfies Pick, -})); - -const sendRendererEventMock = vi.fn(); -vi.mock('../events', () => ({ - sendRendererEvent: (...a: unknown[]) => sendRendererEventMock(...a), + } satisfies Pick, })); -const logInfoMock = vi.fn(); const logWarnMock = vi.fn(); vi.mock('../../shared/logger', () => ({ - logInfo: (...a: unknown[]) => logInfoMock(...a), logWarn: (...a: unknown[]) => logWarnMock(...a), })); @@ -46,7 +35,7 @@ describe('main/lifecycle/startup.ts', () => { const mb = createMb(); const contextMenu = {} as Electron.Menu; - initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify'); + initializeAppLifecycle(mb as unknown as Menubar, contextMenu); expect(mb.on).toHaveBeenCalledWith('ready', expect.any(Function)); }); @@ -56,7 +45,7 @@ describe('main/lifecycle/startup.ts', () => { const mb = createMb(); const contextMenu = {} as Electron.Menu; - initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify'); + initializeAppLifecycle(mb as unknown as Menubar, contextMenu); expect(appQuitMock).toHaveBeenCalled(); expect(logWarnMock).toHaveBeenCalled(); @@ -66,7 +55,7 @@ describe('main/lifecycle/startup.ts', () => { const mb = createMb(); const contextMenu = {} as Electron.Menu; - initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify'); + initializeAppLifecycle(mb as unknown as Menubar, contextMenu); const readyHandler = (mb.on as unknown as ReturnType).mock.calls[0]?.[1]; expect(readyHandler).toBeDefined(); @@ -75,19 +64,4 @@ describe('main/lifecycle/startup.ts', () => { expect(mb.setContextMenu).toHaveBeenCalledWith(contextMenu); }); }); - - describe('handleProtocolURL', () => { - it('forwards matching protocol URL to renderer as AUTH_CALLBACK', () => { - const mb = createMb(); - const url = 'gitify://callback?code=abc123'; - handleProtocolURL(mb as unknown as Menubar, url, 'gitify'); - expect(sendRendererEventMock).toHaveBeenCalledWith(mb, EVENTS.AUTH_CALLBACK, url); - }); - - it('ignores URLs that do not match the protocol', () => { - const mb = createMb(); - handleProtocolURL(mb as unknown as Menubar, 'https://github.com', 'gitify'); - expect(sendRendererEventMock).not.toHaveBeenCalled(); - }); - }); }); diff --git a/src/main/lifecycle/startup.ts b/src/main/lifecycle/startup.ts index e80a38f71..a7b4faa98 100644 --- a/src/main/lifecycle/startup.ts +++ b/src/main/lifecycle/startup.ts @@ -2,14 +2,11 @@ import { app } from 'electron'; import type { Menubar } from 'electron-menubar'; import { APPLICATION } from '../../shared/constants'; -import { EVENTS } from '../../shared/events'; -import { logInfo, logWarn } from '../../shared/logger'; - -import { sendRendererEvent } from '../events'; +import { logWarn } from '../../shared/logger'; /** - * Set up core application lifecycle events including tray ready setup, - * protocol URL handling, and single-instance enforcement. + * Set up core application lifecycle events: tray ready setup and + * single-instance enforcement. * * The tray's context-menu wiring (Linux `setContextMenu` vs macOS/Windows * right-click popup) and the macOS `setIgnoreDoubleClickEvents` default @@ -18,60 +15,26 @@ import { sendRendererEvent } from '../events'; * * @param mb - The menubar instance to attach lifecycle events to. * @param contextMenu - The tray context menu to attach via `mb.setContextMenu`. - * @param protocol - The custom protocol string (e.g. 'gitify' or 'gitify-dev'). */ -export function initializeAppLifecycle( - mb: Menubar, - contextMenu: Electron.Menu, - protocol: string, -): void { +export function initializeAppLifecycle(mb: Menubar, contextMenu: Electron.Menu): void { mb.on('ready', () => { mb.app.setAppUserModelId(APPLICATION.ID); mb.tray.setToolTip(APPLICATION.NAME); mb.setContextMenu(contextMenu); }); - preventSecondInstance(mb, protocol); -} - -/** - * Handle a gitify:// protocol URL by forwarding it to the renderer process - * as an AUTH_CALLBACK event. - * - * @param mb - The menubar instance to forward the event through. - * @param url - The protocol URL to handle. - * @param protocol - The custom protocol string to match. - */ -export function handleProtocolURL(mb: Menubar, url: string, protocol: string): void { - if (url.startsWith(`${protocol}://`)) { - logInfo('main:handleUrl', `forwarding URL ${url} to renderer process`); - sendRendererEvent(mb, EVENTS.AUTH_CALLBACK, url); - } + preventSecondInstance(); } /** - * Enforce a single application instance. If a second instance is launched, - * any protocol URL in the command line is forwarded to the existing instance. - * - * @param mb - The menubar instance to show when a second instance is detected. - * @param protocol - The custom protocol string to extract from command line args. + * Enforce a single application instance. A second launch quits immediately so + * the already-running tray instance remains the only one. */ -function preventSecondInstance(mb: Menubar, protocol: string): void { +function preventSecondInstance(): void { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { logWarn('main:gotTheLock', 'Second instance detected, quitting'); app.quit(); - return; } - - app.on('second-instance', (_event, commandLine) => { - logInfo('main:second-instance', 'Second instance was launched. Extracting command to forward'); - - const url = commandLine.find((arg) => arg.startsWith(`${protocol}://`)); - - if (url) { - handleProtocolURL(mb, url, protocol); - } - }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index f760cd767..b88db7a2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -245,16 +245,6 @@ export const api = { return onRendererEvent(EVENTS.SYSTEM_WAKE, () => callback()); }, - /** - * Register a callback invoked when the main process delivers an OAuth callback URL. - * - * @param callback - Called with the full callback URL (e.g. `gitify://oauth?code=...`). - * @returns A function that removes the listener. - */ - onAuthCallback: (callback: (url: string) => void) => { - return onRendererEvent(EVENTS.AUTH_CALLBACK, (_, url) => callback(url)); - }, - /** * Display a native OS notification. * diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 19f4e11ef..29ae186fb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -11,9 +11,9 @@ import { AccountScopesRoute } from './routes/AccountScopes'; import { BitbucketLoginWithPersonalAccessTokenRoute } from './routes/bitbucket/LoginWithPersonalAccessToken'; import { FiltersRoute } from './routes/Filters'; import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken'; +import { GitHubLoginWithCustomOAuthAppRoute } from './routes/github/LoginWithCustomOAuthApp'; import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow'; import { GitHubLoginWithCLIRoute } from './routes/github/LoginWithGitHubCLI'; -import { GitHubLoginWithOAuthAppRoute } from './routes/github/LoginWithOAuthApp'; import { GitHubLoginWithPersonalAccessTokenRoute } from './routes/github/LoginWithPersonalAccessToken'; import { GitLabLoginWithPersonalAccessTokenRoute } from './routes/gitlab/LoginWithPersonalAccessToken'; import { LoginRoute } from './routes/Login'; @@ -108,7 +108,10 @@ export function App() { element={} path="/login/github/personal-access-token" /> - } path="/login/github/oauth-app" /> + } + path="/login/github/custom-oauth-app" + /> } path="/login/gitea/personal-access-token" diff --git a/src/renderer/__helpers__/hook-mocks.ts b/src/renderer/__helpers__/hook-mocks.ts index fcff10597..a99570529 100644 --- a/src/renderer/__helpers__/hook-mocks.ts +++ b/src/renderer/__helpers__/hook-mocks.ts @@ -52,7 +52,6 @@ function buildLoginsDefaults(): LoginsState { loginWithDeviceFlowPoll: vi.fn(), loginWithDeviceFlowComplete: vi.fn(), loginWithCli: vi.fn(), - loginWithOAuthApp: vi.fn(), loginWithPersonalAccessToken: vi.fn(), logoutFromAccount: vi.fn(), }; diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 53bf781fd..333e35ab0 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -47,7 +47,6 @@ const LOGIN_KEYS = [ 'loginWithDeviceFlowPoll', 'loginWithDeviceFlowComplete', 'loginWithCli', - 'loginWithOAuthApp', 'loginWithPersonalAccessToken', 'logoutFromAccount', ] as const; diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index c6bda06ec..3ea8fd8e1 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -105,7 +105,6 @@ function createGitifyBridgeApi(): Window['gitify'] { useUnreadActiveIcon: vi.fn(), }, notificationSoundPath: vi.fn(), - onAuthCallback: vi.fn(() => vi.fn()), onResetApp: vi.fn(() => vi.fn()), onSystemWake: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 87d475fa6..7319d2bf4 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -114,7 +114,6 @@ function createGitifyBridgeApi(): Window['gitify'] { useUnreadActiveIcon: vi.fn(), }, notificationSoundPath: vi.fn(), - onAuthCallback: vi.fn(() => vi.fn()), onResetApp: vi.fn(() => vi.fn()), onSystemWake: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 5c87fd56c..6229c78ad 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -1,14 +1,21 @@ import { Constants } from '../constants'; -import type { Account, AccountNotifications, GitifyError, Hostname, Token } from '../types'; +import type { + Account, + AccountNotifications, + ClientID, + GitifyError, + Hostname, + Token, +} from '../types'; import { getRecommendedScopeNames } from '../utils/auth/scopes'; import { mockGitifyUser } from './user-mocks'; -export const mockGitHubAppAccount: Account = { +export const mockGitifyOAuthAppAccount: Account = { forge: 'github', platform: 'GitHub Cloud', - method: 'GitHub App', + method: 'Gitify OAuth App', token: 'token-987654321' as Token, hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, @@ -37,12 +44,13 @@ export const mockGitHubCliAccount: Account = { scopes: ['gist', 'read:org', 'repo', 'workflow'], }; -export const mockOAuthAccount: Account = { +export const mockCustomOAuthAppAccount: Account = { forge: 'github', platform: 'GitHub Enterprise Server', - method: 'OAuth App', + method: 'Custom OAuth App', token: 'token-1234568790' as Token, hostname: 'github.gitify.io' as Hostname, + clientId: 'CUSTOM_CLIENT_ID_123' as ClientID, user: mockGitifyUser, scopes: getRecommendedScopeNames(), }; diff --git a/src/renderer/__mocks__/notifications-mocks.ts b/src/renderer/__mocks__/notifications-mocks.ts index 6f8d351a1..95560abc0 100644 --- a/src/renderer/__mocks__/notifications-mocks.ts +++ b/src/renderer/__mocks__/notifications-mocks.ts @@ -21,7 +21,7 @@ import { import { mockGiteaAccount, - mockGitHubAppAccount, + mockGitifyOAuthAppAccount, mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, } from './account-mocks'; @@ -332,7 +332,7 @@ export function mockPartialGitifyNotification( repository?: Partial, ): GitifyNotification { const mockNotification: Partial = { - account: mockGitHubAppAccount, + account: mockGitifyOAuthAppAccount, reason: { code: 'subscribed', title: 'Updated', diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index d776e334b..d7d5377dc 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../__helpers__/test-utils'; import { - mockGitHubAppAccount, + mockGitifyOAuthAppAccount, mockGitHubEnterpriseServerAccount, } from '../../__mocks__/account-mocks'; @@ -91,7 +91,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { ] as const)('should toggle %s checkbox', async (testId, setting) => { await act(async () => { renderWithProviders(, { - accounts: [mockGitHubAppAccount], + accounts: [mockGitifyOAuthAppAccount], }); }); @@ -135,7 +135,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { it('reactively hides the account header checkbox when a second account is added', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockGitHubAppAccount], + accounts: [mockGitifyOAuthAppAccount], }); }); @@ -143,7 +143,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { await act(async () => { useAccountsStore.setState({ - accounts: [mockGitHubAppAccount, mockGitHubEnterpriseServerAccount], + accounts: [mockGitifyOAuthAppAccount, mockGitHubEnterpriseServerAccount], }); }); diff --git a/src/renderer/hooks/useLogins.test.tsx b/src/renderer/hooks/useLogins.test.tsx index 28f23fbfd..635034455 100644 --- a/src/renderer/hooks/useLogins.test.tsx +++ b/src/renderer/hooks/useLogins.test.tsx @@ -14,7 +14,7 @@ import { Constants } from '../constants'; import { useAccountsStore } from '../stores'; -import type { ClientID, ClientSecret, Token } from '../types'; +import type { ClientID, Token } from '../types'; import type { DeviceFlowSession } from '../utils/auth/types'; import { getAdapter } from '../utils/forges/registry'; @@ -113,7 +113,7 @@ describe('renderer/hooks/useLogins.ts', () => { expect(pollSpy).toHaveBeenCalledWith('session'); }); - it('loginWithDeviceFlowComplete creates the account', async () => { + it('loginWithDeviceFlowComplete records the built-in Gitify OAuth App account', async () => { const { result } = renderLoginsHook(); await act(async () => { @@ -124,24 +124,36 @@ describe('renderer/hooks/useLogins.ts', () => { ); }); - expect(createAccountSpy).toHaveBeenCalledWith('GitHub App', 'token', 'github.com', 'github'); + expect(createAccountSpy).toHaveBeenCalledWith( + 'Gitify OAuth App', + 'token', + 'github.com', + 'github', + undefined, + undefined, + ); }); - it('loginWithOAuthApp delegates to the forge adapter', async () => { - const adapter = getAdapter('github'); - const oauthSpy = vi.spyOn(adapter.oauthWebApp!, 'performWebOAuth'); - + it('loginWithDeviceFlowComplete records a custom app client ID', async () => { const { result } = renderLoginsHook(); await act(async () => { - void result.current.loginWithOAuthApp('github', { - clientId: 'id' as ClientID, - clientSecret: 'secret' as ClientSecret, - hostname: Constants.GITHUB_HOSTNAME, - }); + await result.current.loginWithDeviceFlowComplete( + 'github', + 'token' as Token, + Constants.GITHUB_HOSTNAME, + 'CUSTOM_CLIENT_ID_123' as ClientID, + ); }); - expect(oauthSpy).toHaveBeenCalled(); + expect(createAccountSpy).toHaveBeenCalledWith( + 'Custom OAuth App', + 'token', + 'github.com', + 'github', + undefined, + 'CUSTOM_CLIENT_ID_123', + ); }); it('loginWithDeviceFlowStart throws when the forge does not support device flow', async () => { @@ -172,18 +184,6 @@ describe('renderer/hooks/useLogins.ts', () => { ).rejects.toThrow(/does not support device flow/); }); - it('loginWithOAuthApp throws when the forge does not support OAuth app login', async () => { - const { result } = renderLoginsHook(); - - await expect( - result.current.loginWithOAuthApp('gitea', { - clientId: 'id' as ClientID, - clientSecret: 'secret' as ClientSecret, - hostname: Constants.GITHUB_HOSTNAME, - }), - ).rejects.toThrow(/OAuth app login is not supported for forge "gitea"/); - }); - it('logoutFromAccount calls removeAccountNotifications, removeAccount', async () => { useAccountsStore.setState({ accounts: [mockGitHubCloudAccount] }); diff --git a/src/renderer/hooks/useLogins.ts b/src/renderer/hooks/useLogins.ts index 24ac225b4..e5fae7e03 100644 --- a/src/renderer/hooks/useLogins.ts +++ b/src/renderer/hooks/useLogins.ts @@ -4,10 +4,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useAccountsStore } from '../stores'; -import type { Account, Forge, Hostname, Token } from '../types'; +import type { Account, ClientID, Forge, Hostname, Token } from '../types'; import type { + AuthMethod, DeviceFlowSession, - LoginOAuthWebOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -21,11 +21,16 @@ interface LoginsState { forge: Forge, hostname?: Hostname, scopes?: string[], + clientId?: ClientID, ) => Promise; loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise; - loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise; + loginWithDeviceFlowComplete: ( + forge: Forge, + token: Token, + hostname: Hostname, + clientId?: ClientID, + ) => Promise; loginWithCli: (forge: Forge, hostname: Hostname) => Promise; - loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise; logoutFromAccount: (account: Account) => void; } @@ -63,12 +68,12 @@ export const useLogins = (): LoginsState => { * Initiate an OAuth device-flow session for the given forge. */ const loginWithDeviceFlowStart = useCallback( - async (forge: Forge, hostname?: Hostname, scopes?: string[]) => { + async (forge: Forge, hostname?: Hostname, scopes?: string[], clientId?: ClientID) => { const { deviceFlow } = getAdapter(forge); if (!deviceFlow) { throw new Error(`Device flow is not supported for forge "${forge}".`); } - return await deviceFlow.start(hostname, scopes); + return await deviceFlow.start(hostname, scopes, clientId); }, [], ); @@ -88,19 +93,22 @@ export const useLogins = (): LoginsState => { * Finalise an OAuth device-flow session by recording the account. */ const loginWithDeviceFlowComplete = useCallback( - async (forge: Forge, token: Token, hostname: Hostname) => { - const { deviceFlow } = getAdapter(forge); + async (forge: Forge, token: Token, hostname: Hostname, clientId?: ClientID) => { + const { deviceFlow, customOAuthApp } = getAdapter(forge); if (!deviceFlow) { throw new Error(`Forge "${forge}" does not support device flow.`); } - const method = deviceFlow.authMethod; + // A supplied client ID means the user authorized their own app; without + // one the flow used Gitify's preconfigured app. + const method: AuthMethod = + clientId && customOAuthApp ? customOAuthApp.authMethod : deviceFlow.authMethod; const existingAccount = accounts.find((a) => a.hostname === hostname && a.method === method); if (existingAccount) { removeAccountNotifications(existingAccount); } - await createAccount(method, token, hostname, forge); + await createAccount(method, token, hostname, forge, undefined, clientId); }, [accounts, createAccount, removeAccountNotifications], ); @@ -135,31 +143,6 @@ export const useLogins = (): LoginsState => { [createAccountInStore, queryClient, removeAccountNotifications], ); - /** - * Login with a custom OAuth app on the given forge. - */ - const loginWithOAuthApp = useCallback( - async (forge: Forge, data: LoginOAuthWebOptions) => { - const { oauthWebApp } = getAdapter(forge); - if (!oauthWebApp) { - throw new Error(`OAuth app login is not supported for forge "${forge}".`); - } - - const { authOptions, authCode } = await oauthWebApp.performWebOAuth(data); - const token = await oauthWebApp.exchangeAuthCodeForToken(authCode, authOptions); - - const existingAccount = accounts.find( - (a) => a.hostname === authOptions.hostname && a.method === 'OAuth App', - ); - if (existingAccount) { - removeAccountNotifications(existingAccount); - } - - await createAccount('OAuth App', token, authOptions.hostname, forge); - }, - [accounts, createAccount, removeAccountNotifications], - ); - /** * Login with Personal Access Token (PAT). */ @@ -203,7 +186,6 @@ export const useLogins = (): LoginsState => { loginWithDeviceFlowPoll, loginWithDeviceFlowComplete, loginWithCli, - loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, }; diff --git a/src/renderer/routes/AccountScopes.test.tsx b/src/renderer/routes/AccountScopes.test.tsx index 9a0677bed..9754f6dee 100644 --- a/src/renderer/routes/AccountScopes.test.tsx +++ b/src/renderer/routes/AccountScopes.test.tsx @@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../__helpers__/test-utils'; import { - mockGitHubAppAccount, + mockGitifyOAuthAppAccount, mockGitHubCliAccount, - mockOAuthAccount, + mockCustomOAuthAppAccount, mockPersonalAccessTokenAccount, } from '../__mocks__/account-mocks'; @@ -19,7 +19,7 @@ const navigateMock = vi.fn(); // Mutable variable — individual tests reassign this before rendering to simulate // different account states without needing vi.doMock. let mockLocationAccount: Account = { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, scopes: ['read:user', 'notifications', 'repo'], }; @@ -35,7 +35,7 @@ vi.mock('react-router-dom', async (importActual) => { describe('renderer/routes/AccountScopes.tsx', () => { beforeEach(() => { mockLocationAccount = { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, scopes: ['read:user', 'notifications', 'repo'], }; }); @@ -97,7 +97,7 @@ describe('renderer/routes/AccountScopes.tsx', () => { it('should show all-granted status when account has public_repo instead of repo', async () => { mockLocationAccount = { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, scopes: ['read:user', 'notifications', 'public_repo'], }; @@ -112,7 +112,7 @@ describe('renderer/routes/AccountScopes.tsx', () => { it('should show missing indicator when neither repo nor public_repo is granted', async () => { mockLocationAccount = { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, scopes: ['read:user', 'notifications'], }; @@ -128,7 +128,7 @@ describe('renderer/routes/AccountScopes.tsx', () => { it('should show missing indicator when notifications scope is absent', async () => { mockLocationAccount = { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, scopes: ['read:user', 'repo'], }; @@ -142,7 +142,7 @@ describe('renderer/routes/AccountScopes.tsx', () => { it('should show extra scopes when token has additional permissions', async () => { mockLocationAccount = { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, scopes: ['read:user', 'notifications', 'repo', 'write:packages', 'read:org'], }; @@ -156,7 +156,7 @@ describe('renderer/routes/AccountScopes.tsx', () => { }); it('should show no-scopes message when scopes are not loaded', async () => { - mockLocationAccount = { ...mockGitHubAppAccount, scopes: undefined }; + mockLocationAccount = { ...mockGitifyOAuthAppAccount, scopes: undefined }; await act(async () => { renderWithProviders(); @@ -191,8 +191,8 @@ describe('renderer/routes/AccountScopes.tsx', () => { describe('all auth methods show permissions button', () => { it.each([ - ['GitHub App', mockGitHubAppAccount], - ['OAuth App', mockOAuthAccount], + ['Gitify OAuth App', mockGitifyOAuthAppAccount], + ['Custom OAuth App', mockCustomOAuthAppAccount], ['Personal Access Token', mockPersonalAccessTokenAccount], ])('%s account has a permissions button', async (_method, account) => { mockLocationAccount = { diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 5ff682381..fdf784e2c 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -4,8 +4,8 @@ import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../__helpers__/test-utils'; import { mockGiteaAccount, - mockGitHubAppAccount, - mockOAuthAccount, + mockGitifyOAuthAppAccount, + mockCustomOAuthAppAccount, mockPersonalAccessTokenAccount, } from '../__mocks__/account-mocks'; @@ -30,7 +30,11 @@ describe('renderer/routes/Accounts.tsx', () => { it('should render itself & its children', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockPersonalAccessTokenAccount, mockOAuthAccount, mockGitHubAppAccount], + accounts: [ + mockPersonalAccessTokenAccount, + mockCustomOAuthAppAccount, + mockGitifyOAuthAppAccount, + ], }); }); @@ -241,8 +245,8 @@ describe('renderer/routes/Accounts.tsx', () => { ...mockPersonalAccessTokenAccount, scopes: ['read:user', 'notifications'], }, - mockOAuthAccount, - mockGitHubAppAccount, + mockCustomOAuthAppAccount, + mockGitifyOAuthAppAccount, ], }); }); @@ -266,7 +270,11 @@ describe('renderer/routes/Accounts.tsx', () => { it('should set account as primary account', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockPersonalAccessTokenAccount, mockOAuthAccount, mockGitHubAppAccount], + accounts: [ + mockPersonalAccessTokenAccount, + mockCustomOAuthAppAccount, + mockGitifyOAuthAppAccount, + ], }); }); @@ -276,7 +284,7 @@ describe('renderer/routes/Accounts.tsx', () => { await userEvent.click(screen.getAllByTestId('account-set-primary')[1]); - expect(useAccountsStore.getState().accounts[0]).toBe(mockOAuthAccount); + expect(useAccountsStore.getState().accounts[0]).toBe(mockCustomOAuthAppAccount); expect(navigateMock).toHaveBeenCalledWith('/accounts', { replace: true }); }); @@ -318,7 +326,11 @@ describe('renderer/routes/Accounts.tsx', () => { it('should show view-scopes button for all auth methods', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockPersonalAccessTokenAccount, mockOAuthAccount, mockGitHubAppAccount], + accounts: [ + mockPersonalAccessTokenAccount, + mockCustomOAuthAppAccount, + mockGitifyOAuthAppAccount, + ], }); }); @@ -344,7 +356,7 @@ describe('renderer/routes/Accounts.tsx', () => { it('should show github login methods after selecting GitHub forge', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockOAuthAccount], + accounts: [mockCustomOAuthAppAccount], }); }); @@ -367,7 +379,7 @@ describe('renderer/routes/Accounts.tsx', () => { it('should show login with personal access token after selecting GitHub forge', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockOAuthAccount], + accounts: [mockCustomOAuthAppAccount], }); }); @@ -384,7 +396,7 @@ describe('renderer/routes/Accounts.tsx', () => { }); }); - it('should show login with oauth app after selecting GitHub forge', async () => { + it('should show login with a custom OAuth app after selecting GitHub forge', async () => { await act(async () => { renderWithProviders(, { accounts: [mockPersonalAccessTokenAccount], @@ -394,12 +406,14 @@ describe('renderer/routes/Accounts.tsx', () => { await userEvent.click(screen.getByTestId('account-add-new')); await userEvent.click(screen.getByTestId('account-add-forge-github')); - expect(screen.getByTestId('account-add-oauth-app')).toHaveTextContent('OAuth App'); + expect(screen.getByTestId('account-add-custom-oauth-app')).toHaveTextContent( + 'Custom OAuth App', + ); - await userEvent.click(screen.getByTestId('account-add-oauth-app')); + await userEvent.click(screen.getByTestId('account-add-custom-oauth-app')); expect(navigateMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith('/login/github/oauth-app', { + expect(navigateMock).toHaveBeenCalledWith('/login/github/custom-oauth-app', { replace: true, }); }); @@ -407,7 +421,7 @@ describe('renderer/routes/Accounts.tsx', () => { it('should navigate directly to gitea login (single method forge)', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockOAuthAccount], + accounts: [mockCustomOAuthAppAccount], }); }); @@ -426,7 +440,7 @@ describe('renderer/routes/Accounts.tsx', () => { it('should navigate directly to bitbucket login (single method forge)', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockOAuthAccount], + accounts: [mockCustomOAuthAppAccount], }); }); @@ -472,10 +486,10 @@ describe('renderer/routes/Accounts.tsx', () => { it('should re-authenticate a github app account', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockGitHubAppAccount], + accounts: [mockGitifyOAuthAppAccount], notifications: [ { - account: mockGitHubAppAccount, + account: mockGitifyOAuthAppAccount, notifications: [], error: Errors.BAD_CREDENTIALS, }, @@ -488,17 +502,17 @@ describe('renderer/routes/Accounts.tsx', () => { expect(navigateMock).toHaveBeenCalledTimes(1); expect(navigateMock).toHaveBeenCalledWith('/login/github/device-flow', { replace: true, - state: { account: mockGitHubAppAccount }, + state: { account: mockGitifyOAuthAppAccount }, }); }); - it('should re-authenticate an oauth app account', async () => { + it('should re-authenticate a custom OAuth app account', async () => { await act(async () => { renderWithProviders(, { - accounts: [mockOAuthAccount], + accounts: [mockCustomOAuthAppAccount], notifications: [ { - account: mockOAuthAccount, + account: mockCustomOAuthAppAccount, notifications: [], error: Errors.BAD_CREDENTIALS, }, @@ -509,9 +523,9 @@ describe('renderer/routes/Accounts.tsx', () => { await userEvent.click(screen.getByTestId('account-reauthenticate')); expect(navigateMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith('/login/github/oauth-app', { + expect(navigateMock).toHaveBeenCalledWith('/login/github/custom-oauth-app', { replace: true, - state: { account: mockOAuthAccount }, + state: { account: mockCustomOAuthAppAccount }, }); }); @@ -540,10 +554,10 @@ describe('renderer/routes/Accounts.tsx', () => { it('should log and skip re-authenticate when no login method matches', async () => { const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); - // Gitea only registers PAT — a GitHub App method has no matching loginMethod. + // Gitea only registers PAT — a Custom OAuth App method has no matching loginMethod. const unsupportedAccount = { ...mockGiteaAccount, - method: 'GitHub App' as const, + method: 'Custom OAuth App' as const, }; await act(async () => { diff --git a/src/renderer/routes/Login.test.tsx b/src/renderer/routes/Login.test.tsx index f907c2747..55db58e0a 100644 --- a/src/renderer/routes/Login.test.tsx +++ b/src/renderer/routes/Login.test.tsx @@ -31,7 +31,7 @@ describe('renderer/routes/Login.tsx', () => { it.each([ ['login-github', '/login/github/device-flow'], ['login-pat', '/login/github/personal-access-token'], - ['login-oauth-app', '/login/github/oauth-app'], + ['login-custom-oauth-app', '/login/github/custom-oauth-app'], ])('navigates %s', async (testId, route) => { renderWithProviders(); diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png index caea7edd6..d710632b4 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-custom-oauth-app-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-custom-oauth-app-dark-classic-chromium-linux.png new file mode 100644 index 000000000..b65724cd4 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-custom-oauth-app-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-custom-oauth-app-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-custom-oauth-app-light-classic-chromium-linux.png new file mode 100644 index 000000000..e30e7e1f2 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-custom-oauth-app-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-dark-classic-chromium-linux.png deleted file mode 100644 index c4179a24e..000000000 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-dark-classic-chromium-linux.png and /dev/null differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-light-classic-chromium-linux.png deleted file mode 100644 index cc534051b..000000000 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-light-classic-chromium-linux.png and /dev/null differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png index cb7b1528c..fbc85fc59 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__snapshots__/Login.test.tsx.snap b/src/renderer/routes/__snapshots__/Login.test.tsx.snap index e4af27471..da70146dd 100644 --- a/src/renderer/routes/__snapshots__/Login.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Login.test.tsx.snap @@ -364,7 +364,7 @@ exports[`renderer/routes/Login.tsx > should render itself & its children 1`] = ` data-component="Button" data-loading="false" data-size="medium" - data-testid="login-oauth-app" + data-testid="login-custom-oauth-app" data-variant="invisible" type="button" > @@ -399,7 +399,7 @@ exports[`renderer/routes/Login.tsx > should render itself & its children 1`] = ` class="prc-Button-Label-FWkx3" data-component="text" > - OAuth App + Custom OAuth App diff --git a/src/renderer/routes/github/LoginWithDeviceFlow.test.tsx b/src/renderer/routes/github/DeviceFlowAuthorize.test.tsx similarity index 82% rename from src/renderer/routes/github/LoginWithDeviceFlow.test.tsx rename to src/renderer/routes/github/DeviceFlowAuthorize.test.tsx index 8b42a6158..91f989d20 100644 --- a/src/renderer/routes/github/LoginWithDeviceFlow.test.tsx +++ b/src/renderer/routes/github/DeviceFlowAuthorize.test.tsx @@ -2,15 +2,15 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; -import { mockGitHubAppAccount } from '../../__mocks__/account-mocks'; +import { mockGitifyOAuthAppAccount } from '../../__mocks__/account-mocks'; -import type { Hostname } from '../../types'; +import type { ClientID, Hostname } from '../../types'; import * as logger from '../../utils/core/logger'; import * as comms from '../../utils/system/comms'; -import { GitHubLoginWithDeviceFlowRoute } from './LoginWithDeviceFlow'; +import { GitHubDeviceFlowAuthorize } from './DeviceFlowAuthorize'; -describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { +describe('renderer/routes/github/DeviceFlowAuthorize.tsx', () => { const copyToClipboardSpy = vi.spyOn(comms, 'copyToClipboard').mockResolvedValue(); const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); @@ -25,7 +25,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); @@ -48,17 +48,18 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); await userEvent.click(screen.getByTestId('device-scope-public')); - expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith('github', undefined, [ - 'notifications', - 'read:user', - 'public_repo', - ]); + expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith( + 'github', + 'github.com', + ['notifications', 'read:user', 'public_repo'], + undefined, + ); await screen.findByTestId('device-user-code'); expect(screen.getByTestId('open-browser-button')).toBeInTheDocument(); @@ -79,17 +80,18 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); await userEvent.click(screen.getByTestId('device-scope-full')); - expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith('github', undefined, [ - 'notifications', - 'read:user', - 'repo', - ]); + expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith( + 'github', + 'github.com', + ['notifications', 'read:user', 'repo'], + undefined, + ); await screen.findByTestId('device-user-code'); }); @@ -105,7 +107,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); @@ -128,7 +130,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); @@ -157,7 +159,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); @@ -176,7 +178,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); const loginWithDeviceFlowStartMock = vi.fn().mockRejectedValueOnce(new Error('Network error')); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); @@ -197,7 +199,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); @@ -219,7 +221,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { const loginWithDeviceFlowPollMock = vi.fn().mockResolvedValueOnce('access-token'); const loginWithDeviceFlowCompleteMock = vi.fn().mockResolvedValueOnce(undefined); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, loginWithDeviceFlowPoll: loginWithDeviceFlowPollMock, loginWithDeviceFlowComplete: loginWithDeviceFlowCompleteMock, @@ -234,6 +236,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { 'github', 'access-token', 'github.com', + undefined, ); expect(navigateMock).toHaveBeenCalledWith('/'); }); @@ -254,7 +257,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { .fn() .mockRejectedValueOnce(new Error('authorization denied')); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, loginWithDeviceFlowPoll: loginWithDeviceFlowPollMock, }); @@ -281,7 +284,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { }); const loginWithDeviceFlowPollMock = vi.fn(); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, loginWithDeviceFlowPoll: loginWithDeviceFlowPollMock, }); @@ -306,7 +309,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { expiresAt: Date.now() + 900000, }); - renderWithProviders(, { + renderWithProviders(, { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, loginWithDeviceFlowPoll: vi.fn().mockResolvedValueOnce(null), initialEntries: [ @@ -314,7 +317,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { pathname: '/login/github/device-flow', state: { account: { - ...mockGitHubAppAccount, + ...mockGitifyOAuthAppAccount, hostname: 'github.enterprise.example' as Hostname, }, }, @@ -328,11 +331,41 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => { 'github', 'github.enterprise.example', ['notifications', 'read:user', 'repo'], + undefined, + ); + }); + + it('threads a custom client ID and hostname through the device flow', async () => { + const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({ + hostname: 'github.example.com', + clientId: 'CUSTOM_CLIENT_ID_123', + deviceCode: 'device-code', + userCode: 'USER-1234', + verificationUri: 'https://github.example.com/login/device', + intervalSeconds: 5, + expiresAt: Date.now() + 900000, + }); + + renderWithProviders( + , + { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock }, + ); + + await userEvent.click(screen.getByTestId('device-scope-full')); + + expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith( + 'github', + 'github.example.com', + ['notifications', 'read:user', 'repo'], + 'CUSTOM_CLIENT_ID_123', ); }); it('should open the revoke-access developer settings link', async () => { - renderWithProviders(); + renderWithProviders(); await userEvent.click(screen.getByTitle('GitHub → Developer Settings')); diff --git a/src/renderer/routes/github/DeviceFlowAuthorize.tsx b/src/renderer/routes/github/DeviceFlowAuthorize.tsx new file mode 100644 index 000000000..eac8ffe0a --- /dev/null +++ b/src/renderer/routes/github/DeviceFlowAuthorize.tsx @@ -0,0 +1,384 @@ +import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { + CheckCircleFillIcon, + CopyIcon, + GlobeIcon, + LinkIcon, + SignInIcon, + SyncIcon, +} from '@primer/octicons-react'; +import { Banner, Button, Heading, IconButton, Stack, Text } from '@primer/react'; + +import { Constants } from '../../constants'; + +import { useLogins } from '../../hooks/useLogins'; + +import { Contents } from '../../components/layout/Contents'; +import { Page } from '../../components/layout/Page'; +import { Footer } from '../../components/primitives/Footer'; +import { Header } from '../../components/primitives/Header'; + +import type { Account, ClientID, Forge, Hostname, Link } from '../../types'; +import type { DeviceFlowSession } from '../../utils/auth/types'; + +import { getAlternateScopeNames, getRecommendedScopeNames } from '../../utils/auth/scopes'; +import { rendererLogError, toError } from '../../utils/core/logger'; +import { describeDeviceFlowStartError } from '../../utils/forges/github/flows'; +import { getAdapter } from '../../utils/forges/registry'; +import { copyToClipboard, openExternalLink } from '../../utils/system/comms'; + +interface LocationState { + account?: Account; +} + +interface GitHubDeviceFlowAuthorizeProps { + /** OAuth app client ID to authorize; omit to use Gitify's preconfigured app. */ + clientId?: ClientID; + /** Hostname to authenticate against; defaults to the re-auth account or GitHub.com. */ + hostname?: Hostname; + /** Page heading; defaults to authorizing Gitify's preconfigured app. */ + title?: string; +} + +type ScopeChoice = 'public' | 'full'; + +export function GitHubDeviceFlowAuthorize({ + clientId, + hostname, + title, +}: GitHubDeviceFlowAuthorizeProps) { + const navigate = useNavigate(); + const location = useLocation(); + const { account: reAuthAccount } = (location.state ?? {}) as LocationState; + + const forge: Forge = 'github'; + + const resolvedHostname = (hostname ?? + reAuthAccount?.hostname ?? + Constants.GITHUB_HOSTNAME) as Hostname; + + const { loginWithDeviceFlowStart, loginWithDeviceFlowPoll, loginWithDeviceFlowComplete } = + useLogins(); + + const [session, setSession] = useState(null); + const [isPolling, setIsPolling] = useState(false); + const [error, setError] = useState(null); + const [scopeChoice, setScopeChoice] = useState(null); + + // Initialize device flow session, copy code, and open browser + useEffect(() => { + const initializeDeviceFlow = async () => { + try { + const scopes = + scopeChoice === 'public' ? getAlternateScopeNames() : getRecommendedScopeNames(); + + const newSession = await loginWithDeviceFlowStart( + forge, + resolvedHostname, + scopes, + clientId, + ); + setSession(newSession); + + // Auto-copy the user code to clipboard + await copyToClipboard(newSession.userCode); + + // Auto-open the verification URL in the browser + openExternalLink(newSession.verificationUri as Link); + } catch (err) { + rendererLogError('LoginWithDeviceFlow', 'Failed to start device flow', toError(err)); + setError( + describeDeviceFlowStartError(err, resolvedHostname, clientId) ?? + 'Failed to start authentication. Please try again.', + ); + } + }; + + if (scopeChoice) { + void initializeDeviceFlow(); + } + }, [loginWithDeviceFlowStart, resolvedHostname, clientId, scopeChoice, forge]); + + // Poll for device flow completion + useEffect(() => { + if (!session) { + return; + } + + let isActive = true; + let timeoutId: NodeJS.Timeout | null = null; + + const startPolling = async () => { + setIsPolling(true); + + const poll = async (): Promise => { + if (!isActive || Date.now() >= session.expiresAt) { + if (isActive) { + setError('Device code expired. Please start again.'); + } + return; + } + + const token = await loginWithDeviceFlowPoll(forge, session); + + if (token && isActive) { + await loginWithDeviceFlowComplete(forge, token, session.hostname, clientId); + void navigate('/'); + return; + } + + const intervalMs = Math.max(5000, session.intervalSeconds * 1000); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, intervalMs); + }); + await poll(); + }; + + try { + await poll(); + } catch (err) { + if (isActive) { + rendererLogError('LoginWithDeviceFlow', 'Failed to poll device flow', toError(err)); + setError('Authentication failed. Please try again.'); + } + } finally { + if (isActive) { + setIsPolling(false); + } + } + }; + + void startPolling(); + + return () => { + isActive = false; + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + // oxlint-disable-next-line react/exhaustive-deps -- navigate is stable + }, [session, loginWithDeviceFlowPoll, loginWithDeviceFlowComplete, clientId, forge]); + + const handleCopyUserCode = useCallback(async () => { + if (session?.userCode) { + await copyToClipboard(session.userCode); + } + }, [session?.userCode]); + + const handleOpenBrowser = useCallback(() => { + if (session?.verificationUri) { + openExternalLink(session.verificationUri as Link); + } + }, [session?.verificationUri]); + + const handleCopyVerificationLink = useCallback(async () => { + if (session?.verificationUri) { + await copyToClipboard(session.verificationUri); + } + }, [session?.verificationUri]); + + // Render UI states as separate functions for clarity + const renderSessionUI = () => { + if (!session) { + return null; + } + + return ( + + + Authorize the app using this code. + + + + + {session.userCode} + + + + + + + + Code copied to clipboard + + + + + + Opened{' '} + {session.hostname === Constants.GITHUB_HOSTNAME ? 'GitHub.com' : session.hostname} + + + + + + Waiting for authorization... + + + + ); + }; + + const renderScopeChoiceUI = () => ( + + Receive notifications for: + + + + + + + {(() => { + const adapter = getAdapter(forge); + const revokeUrl = adapter.deviceFlow?.getRevokeAccessUrl(resolvedHostname, clientId); + if (!revokeUrl) { + return null; + } + const label = `${adapter.displayName} → Developer Settings`; + return ( + + + {clientId + ? "Note: to change previously granted permissions, revoke your app's access at" + : "Note: to change previously granted permissions, revoke Gitify's access at"}{' '} + + {', then re-authorize above.'} + + + ); + })()} + + + ); + + const renderInitializingUI = () => ( + + + Initializing authentication... + + ); + + let mainContent: ReactNode; + if (session) { + mainContent = renderSessionUI(); + } else if (!scopeChoice) { + mainContent = renderScopeChoiceUI(); + } else { + mainContent = renderInitializingUI(); + } + + return ( + +
{title ?? 'Authorize with GitHub'}
+ + + {error && ( + + + {error} + + + } + hideTitle + title="Login errors" + variant="critical" + /> + )} + {mainContent} + + + {session ? ( +
+ + + + + +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/renderer/routes/github/LoginWithCustomOAuthApp.test.tsx b/src/renderer/routes/github/LoginWithCustomOAuthApp.test.tsx new file mode 100644 index 000000000..0987888e6 --- /dev/null +++ b/src/renderer/routes/github/LoginWithCustomOAuthApp.test.tsx @@ -0,0 +1,104 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; +import { mockCustomOAuthAppAccount } from '../../__mocks__/account-mocks'; + +import * as comms from '../../utils/system/comms'; +import { GitHubLoginWithCustomOAuthAppRoute } from './LoginWithCustomOAuthApp'; + +describe('renderer/routes/github/LoginWithCustomOAuthApp.tsx', () => { + const copyToClipboardSpy = vi.spyOn(comms, 'copyToClipboard').mockResolvedValue(); + const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); + + const session = { + hostname: 'github.example.com', + clientId: 'CUSTOM_CLIENT_ID_123', + deviceCode: 'device-code', + userCode: 'USER-1234', + verificationUri: 'https://github.example.com/login/device', + intervalSeconds: 5, + expiresAt: Date.now() + 900000, + }; + + it('collects a hostname and client ID, and no client secret', () => { + renderWithProviders(); + + expect(screen.getByTestId('login-hostname')).toBeInTheDocument(); + expect(screen.getByTestId('login-clientId')).toBeInTheDocument(); + expect(screen.queryByTestId('login-clientSecret')).not.toBeInTheDocument(); + }); + + it('shows validation errors for an invalid hostname and client ID', async () => { + renderWithProviders(); + + const hostname = screen.getByTestId('login-hostname'); + await userEvent.clear(hostname); + await userEvent.type(hostname, 'test'); + await userEvent.type(screen.getByTestId('login-clientId'), 'short'); + + await userEvent.click(screen.getByTestId('login-submit')); + + expect(screen.getByText('Hostname format is invalid')).toBeInTheDocument(); + expect(screen.getByText('Client ID format is invalid')).toBeInTheDocument(); + // Still on the credentials step — the device flow was not started. + expect(screen.getByTestId('login-submit')).toBeInTheDocument(); + }); + + it('reuses the scope step and starts the device flow with the supplied credentials', async () => { + const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce(session); + + renderWithProviders(, { + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, + loginWithDeviceFlowPoll: vi.fn().mockResolvedValue(null), + }); + + const hostname = screen.getByTestId('login-hostname'); + await userEvent.clear(hostname); + await userEvent.type(hostname, 'github.example.com'); + await userEvent.type(screen.getByTestId('login-clientId'), 'CUSTOM_CLIENT_ID_123'); + + await userEvent.click(screen.getByTestId('login-submit')); + + // The shared public/recommended scope step is shown before the session starts. + expect(screen.getByTestId('device-scope-full')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('device-scope-full')); + + await waitFor(() => { + expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith( + 'github', + 'github.example.com', + ['notifications', 'read:user', 'repo'], + 'CUSTOM_CLIENT_ID_123', + ); + }); + + await screen.findByTestId('device-user-code'); + expect(copyToClipboardSpy).toHaveBeenCalledWith('USER-1234'); + }); + + it('opens the OAuth app registration page without a callback URL', async () => { + renderWithProviders(); + + await userEvent.click(screen.getByTestId('login-create-oauth-app')); + + expect(openExternalLinkSpy).toHaveBeenCalledWith( + expect.stringContaining('https://github.com/settings/applications/new'), + ); + expect(openExternalLinkSpy).not.toHaveBeenCalledWith(expect.stringContaining('callback_url')); + }); + + it('prefills the hostname and client ID from the re-auth account', () => { + renderWithProviders(, { + initialEntries: [ + { + pathname: '/login/github/custom-oauth-app', + state: { account: mockCustomOAuthAppAccount }, + }, + ], + }); + + expect(screen.getByTestId('login-hostname')).toHaveValue('github.gitify.io'); + expect(screen.getByTestId('login-clientId')).toHaveValue('CUSTOM_CLIENT_ID_123'); + }); +}); diff --git a/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx b/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx new file mode 100644 index 000000000..ab41e6e4d --- /dev/null +++ b/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { BookIcon, PersonIcon } from '@primer/octicons-react'; +import { Button, FormControl, Stack, Text, TextInput, Tooltip } from '@primer/react'; + +import { Constants } from '../../constants'; + +import { Contents } from '../../components/layout/Contents'; +import { Page } from '../../components/layout/Page'; +import { Footer } from '../../components/primitives/Footer'; +import { Header } from '../../components/primitives/Header'; + +import type { Account, ClientID, Forge, Hostname } from '../../types'; + +import { + type CustomOAuthAppFormData, + type CustomOAuthAppFormErrors, + validateCustomOAuthAppForm, +} from '../../utils/auth/loginValidation'; +import { getAdapter } from '../../utils/forges/registry'; +import { openExternalLink } from '../../utils/system/comms'; +import { GitHubDeviceFlowAuthorize } from './DeviceFlowAuthorize'; + +interface LocationState { + account?: Account; +} + +/** + * Sign in with a user-supplied GitHub OAuth app. + * + * Collects the app's hostname and client ID (a public identifier — never a + * secret), then hands off to the shared device-flow authorization UI. + */ +export function GitHubLoginWithCustomOAuthAppRoute() { + const location = useLocation(); + const { account: reAuthAccount } = (location.state ?? {}) as LocationState; + + const forge: Forge = 'github'; + + const [formData, setFormData] = useState({ + hostname: (reAuthAccount?.hostname ?? Constants.GITHUB_HOSTNAME) as Hostname, + clientId: (reAuthAccount?.clientId ?? '') as ClientID, + }); + const [errors, setErrors] = useState({}); + const [credentials, setCredentials] = useState(null); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = () => { + const newErrors = validateCustomOAuthAppForm(formData, forge); + + setErrors(newErrors); + + if (!newErrors.hostname && !newErrors.clientId) { + setCredentials(formData); + } + }; + + if (credentials) { + return ( + + ); + } + + return ( + +
Login with Custom OAuth App
+ + + + + Authorize Gitify with your own GitHub OAuth app. Create an app, enable its device flow, + then paste its client ID below. + + + + Hostname + + Change only if you are using GitHub Enterprise Server + + + {errors.hostname && ( + {errors.hostname} + )} + + + + + + and enable its device flow, then use its client ID below. + + + + + Client ID + + {errors.clientId && ( + {errors.clientId} + )} + + + + +
+ + + + + +
+
+ ); +} diff --git a/src/renderer/routes/github/LoginWithDeviceFlow.tsx b/src/renderer/routes/github/LoginWithDeviceFlow.tsx index eca790539..2d7bd7ae5 100644 --- a/src/renderer/routes/github/LoginWithDeviceFlow.tsx +++ b/src/renderer/routes/github/LoginWithDeviceFlow.tsx @@ -1,358 +1,12 @@ -import { type ReactNode, useCallback, useEffect, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -import { - CheckCircleFillIcon, - CopyIcon, - GlobeIcon, - LinkIcon, - SignInIcon, - SyncIcon, -} from '@primer/octicons-react'; -import { Banner, Button, Heading, IconButton, Stack, Text } from '@primer/react'; - -import { Constants } from '../../constants'; - -import { useLogins } from '../../hooks/useLogins'; - -import { Contents } from '../../components/layout/Contents'; -import { Page } from '../../components/layout/Page'; -import { Footer } from '../../components/primitives/Footer'; -import { Header } from '../../components/primitives/Header'; - -import type { Account, Forge, Hostname, Link } from '../../types'; -import type { DeviceFlowSession } from '../../utils/auth/types'; - -import { getAlternateScopeNames, getRecommendedScopeNames } from '../../utils/auth/scopes'; -import { rendererLogError, toError } from '../../utils/core/logger'; -import { getAdapter } from '../../utils/forges/registry'; -import { copyToClipboard, openExternalLink } from '../../utils/system/comms'; - -interface LocationState { - account?: Account; -} - -type ScopeChoice = 'public' | 'full'; - +import { GitHubDeviceFlowAuthorize } from './DeviceFlowAuthorize'; + +/** + * Route element for `/login/github/device-flow`. + * + * Authorizes Gitify's preconfigured OAuth app on GitHub.com. A user-supplied + * custom app uses the `/login/github/custom-oauth-app` route instead, which + * composes the same `GitHubDeviceFlowAuthorize` UI with its own client ID. + */ export function GitHubLoginWithDeviceFlowRoute() { - const navigate = useNavigate(); - const location = useLocation(); - const { account: reAuthAccount } = (location.state ?? {}) as LocationState; - - const forge: Forge = 'github'; - - const { loginWithDeviceFlowStart, loginWithDeviceFlowPoll, loginWithDeviceFlowComplete } = - useLogins(); - - const [session, setSession] = useState(null); - const [isPolling, setIsPolling] = useState(false); - const [error, setError] = useState(null); - const [scopeChoice, setScopeChoice] = useState(null); - - // Initialize device flow session, copy code, and open browser - useEffect(() => { - const initializeDeviceFlow = async () => { - try { - const scopes = - scopeChoice === 'public' ? getAlternateScopeNames() : getRecommendedScopeNames(); - - const newSession = await loginWithDeviceFlowStart(forge, reAuthAccount?.hostname, scopes); - setSession(newSession); - - // Auto-copy the user code to clipboard - await copyToClipboard(newSession.userCode); - - // Auto-open the verification URL in the browser - openExternalLink(newSession.verificationUri as Link); - } catch (err) { - rendererLogError('LoginWithDeviceFlow', 'Failed to start device flow', toError(err)); - setError('Failed to start authentication. Please try again.'); - } - }; - - if (scopeChoice) { - void initializeDeviceFlow(); - } - }, [loginWithDeviceFlowStart, reAuthAccount, scopeChoice, forge]); - - // Poll for device flow completion - useEffect(() => { - if (!session) { - return; - } - - let isActive = true; - let timeoutId: NodeJS.Timeout | null = null; - - const startPolling = async () => { - setIsPolling(true); - - const poll = async (): Promise => { - if (!isActive || Date.now() >= session.expiresAt) { - if (isActive) { - setError('Device code expired. Please start again.'); - } - return; - } - - const token = await loginWithDeviceFlowPoll(forge, session); - - if (token && isActive) { - await loginWithDeviceFlowComplete(forge, token, session.hostname); - void navigate('/'); - return; - } - - const intervalMs = Math.max(5000, session.intervalSeconds * 1000); - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, intervalMs); - }); - await poll(); - }; - - try { - await poll(); - } catch (err) { - if (isActive) { - rendererLogError('LoginWithDeviceFlow', 'Failed to poll device flow', toError(err)); - setError('Authentication failed. Please try again.'); - } - } finally { - if (isActive) { - setIsPolling(false); - } - } - }; - - void startPolling(); - - return () => { - isActive = false; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - // oxlint-disable-next-line react/exhaustive-deps -- navigate is stable - }, [session, loginWithDeviceFlowPoll, loginWithDeviceFlowComplete, forge]); - - const handleCopyUserCode = useCallback(async () => { - if (session?.userCode) { - await copyToClipboard(session.userCode); - } - }, [session?.userCode]); - - const handleOpenBrowser = useCallback(() => { - if (session?.verificationUri) { - openExternalLink(session.verificationUri as Link); - } - }, [session?.verificationUri]); - - const handleCopyVerificationLink = useCallback(async () => { - if (session?.verificationUri) { - await copyToClipboard(session.verificationUri); - } - }, [session?.verificationUri]); - - // Render UI states as separate functions for clarity - const renderSessionUI = () => { - if (!session) { - return null; - } - - return ( - - - Authorize the app using this code. - - - - - {session.userCode} - - - - - - - - Code copied to clipboard - - - - - - Opened{' '} - {session.hostname === Constants.GITHUB_HOSTNAME ? 'GitHub.com' : session.hostname} - - - - - - Waiting for authorization... - - - - ); - }; - - const renderScopeChoiceUI = () => ( - - Receive notifications for: - - - - - - - {(() => { - const adapter = getAdapter(forge); - const revokeUrl = adapter.deviceFlow?.getRevokeAccessUrl( - (reAuthAccount?.hostname ?? Constants.GITHUB_HOSTNAME) as Hostname, - ); - if (!revokeUrl) { - return null; - } - const label = `${adapter.displayName} → Developer Settings`; - return ( - - - Note: to change previously granted permissions, revoke Gitify's access at{' '} - - {', then re-authorize above.'} - - - ); - })()} - - - ); - - const renderInitializingUI = () => ( - - - Initializing authentication... - - ); - - let mainContent: ReactNode; - if (session) { - mainContent = renderSessionUI(); - } else if (!scopeChoice) { - mainContent = renderScopeChoiceUI(); - } else { - mainContent = renderInitializingUI(); - } - - return ( - -
Authorize with GitHub
- - - {error && ( - - - {error} - - - } - hideTitle - title="Login errors" - variant="critical" - /> - )} - {mainContent} - - - {session ? ( -
- - - - - -
- ) : ( -
- -
- )} -
- ); + return ; } diff --git a/src/renderer/routes/github/LoginWithOAuthApp.test.tsx b/src/renderer/routes/github/LoginWithOAuthApp.test.tsx deleted file mode 100644 index c6aa28793..000000000 --- a/src/renderer/routes/github/LoginWithOAuthApp.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; - -import * as logger from '../../utils/core/logger'; -import * as comms from '../../utils/system/comms'; -import { GitHubLoginWithOAuthAppRoute } from './LoginWithOAuthApp'; - -describe('renderer/routes/github/LoginWithOAuthApp.tsx', () => { - const loginWithOAuthAppMock = vi.fn(); - - const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); - - it('renders correctly', () => { - const tree = renderWithProviders(); - - expect(tree.container).toMatchSnapshot(); - }); - - it('let us go back', async () => { - renderWithProviders(); - - await userEvent.click(screen.getByTestId('header-nav-back')); - - expect(navigateMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith(-1); - }); - - describe("'Create new OAuth App' button", () => { - it('should be disabled if no hostname configured', async () => { - renderWithProviders(); - - await userEvent.clear(screen.getByTestId('login-hostname')); - - await userEvent.click(screen.getByTestId('login-create-oauth-app')); - - expect(openExternalLinkSpy).toHaveBeenCalledTimes(0); - }); - - it('should open in browser if hostname configured', async () => { - renderWithProviders(); - - const hostname = screen.getByTestId('login-hostname'); - await userEvent.clear(hostname); - await userEvent.type(hostname, 'company.github.com'); - - await userEvent.click(screen.getByTestId('login-create-oauth-app')); - - expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); - }); - }); - - it('should login using a token - success', async () => { - loginWithOAuthAppMock.mockResolvedValueOnce(null); - - renderWithProviders(, { - loginWithOAuthApp: loginWithOAuthAppMock, - }); - - const hostname = screen.getByTestId('login-hostname'); - await userEvent.clear(hostname); - await userEvent.type(hostname, 'github.com'); - - await userEvent.type(screen.getByTestId('login-clientId'), '1234567890_ASDFGHJKL'); - - await userEvent.type( - screen.getByTestId('login-clientSecret'), - '1234567890_asdfghjklPOIUYTREWQ0987654321', - ); - - await userEvent.click(screen.getByTestId('login-submit')); - - expect(loginWithOAuthAppMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith('/'); - }); - - it('should login using a token - failure', async () => { - const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); - loginWithOAuthAppMock.mockRejectedValueOnce(null); - - renderWithProviders(, { - loginWithOAuthApp: loginWithOAuthAppMock, - }); - - const hostname = screen.getByTestId('login-hostname'); - await userEvent.clear(hostname); - await userEvent.type(hostname, 'github.com'); - - await userEvent.type(screen.getByTestId('login-clientId'), '1234567890_ASDFGHJKL'); - - await userEvent.type( - screen.getByTestId('login-clientSecret'), - '1234567890_asdfghjklPOIUYTREWQ0987654321', - ); - - await userEvent.click(screen.getByTestId('login-submit')); - - await waitFor(() => { - expect(loginWithOAuthAppMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledTimes(0); - expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); - }); - }); - - it('should render the form with errors', async () => { - renderWithProviders(); - - const hostname = screen.getByTestId('login-hostname'); - await userEvent.clear(hostname); - await userEvent.type(hostname, 'test'); - - await userEvent.type(screen.getByTestId('login-clientId'), '123'); - - await userEvent.type(screen.getByTestId('login-clientSecret'), 'abc'); - - await userEvent.click(screen.getByTestId('login-submit')); - - expect(screen.getByText('Hostname format is invalid')).toBeInTheDocument(); - expect(screen.getByText('Client ID format is invalid')).toBeInTheDocument(); - expect(screen.getByText('Client Secret format is invalid')).toBeInTheDocument(); - }); - - it('should open help docs in the browser', async () => { - renderWithProviders(); - - await userEvent.click(screen.getByTestId('login-docs')); - - expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); - }); - - it('should toggle client secret visibility', async () => { - renderWithProviders(); - - const secretInput = screen.getByTestId('login-clientSecret'); - expect(secretInput).toHaveAttribute('type', 'password'); - - await userEvent.click(screen.getByLabelText('Show token')); - expect(secretInput).toHaveAttribute('type', 'text'); - - await userEvent.click(screen.getByLabelText('Hide token')); - expect(secretInput).toHaveAttribute('type', 'password'); - }); -}); diff --git a/src/renderer/routes/github/LoginWithOAuthApp.tsx b/src/renderer/routes/github/LoginWithOAuthApp.tsx deleted file mode 100644 index 6f12e5e88..000000000 --- a/src/renderer/routes/github/LoginWithOAuthApp.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -import { BookIcon, EyeClosedIcon, EyeIcon, PersonIcon, SignInIcon } from '@primer/octicons-react'; -import { Banner, Button, FormControl, Stack, Text, TextInput, Tooltip } from '@primer/react'; - -import { Constants } from '../../constants'; - -import { useLogins } from '../../hooks/useLogins'; - -import { Contents } from '../../components/layout/Contents'; -import { Page } from '../../components/layout/Page'; -import { Footer } from '../../components/primitives/Footer'; -import { Header } from '../../components/primitives/Header'; - -import type { Account, ClientID, ClientSecret, Forge } from '../../types'; - -import { - type OAuthAppFormData, - type OAuthAppFormErrors, - validateOAuthAppForm, -} from '../../utils/auth/loginValidation'; -import { rendererLogError, toError } from '../../utils/core/logger'; -import { getAdapter } from '../../utils/forges/registry'; -import { openExternalLink } from '../../utils/system/comms'; - -interface LocationState { - account?: Account; -} - -export function GitHubLoginWithOAuthAppRoute() { - const navigate = useNavigate(); - const location = useLocation(); - const { account: reAuthAccount } = (location.state ?? {}) as LocationState; - - const forge: Forge = 'github'; - - const { loginWithOAuthApp } = useLogins(); - - const [shouldMaskClientSecret, setShouldMaskClientSecret] = useState(true); - const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); - - const [formData, setFormData] = useState({ - hostname: reAuthAccount?.hostname ?? Constants.GITHUB_HOSTNAME, - clientId: '' as ClientID, - clientSecret: '' as ClientSecret, - } as OAuthAppFormData); - - const [errors, setErrors] = useState({} as OAuthAppFormErrors); - - const handleSubmit = async () => { - setIsVerifyingCredentials(true); - - const newErrors = validateOAuthAppForm(formData, forge); - - setErrors(newErrors); - - if (!newErrors.hostname && !newErrors.clientId && !newErrors.clientSecret) { - await verifyLoginCredentials(formData); - } - setIsVerifyingCredentials(false); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData({ - ...formData, - [name]: value, - }); - }; - - const verifyLoginCredentials = useCallback( - async (data: OAuthAppFormData) => { - try { - await loginWithOAuthApp(forge, data); - void navigate('/'); - } catch (err) { - rendererLogError('loginWithOAuthApp', 'Failed to login with OAuth App', toError(err)); - setErrors({ - invalidCredentialsForHost: `Failed to validate provided Client ID and Secret against ${data.hostname}`, - }); - } - }, - // oxlint-disable-next-line react/exhaustive-deps -- navigate is stable - [loginWithOAuthApp, forge], - ); - - return ( - -
Login with OAuth App
- - - {errors.invalidCredentialsForHost && ( - - - {errors.invalidCredentialsForHost} - - - } - hideTitle - title="Validation errors" - variant="critical" - /> - )} - - - Hostname - - Change only if you are using GitHub Enterprise Server - - - {errors.hostname && ( - {errors.hostname} - )} - - - - - and use your client id & secret below. - - - - Client ID - - {errors.clientId && ( - {errors.clientId} - )} - - - Client Secret - setShouldMaskClientSecret(!shouldMaskClientSecret)} - /> - } - type={shouldMaskClientSecret ? 'password' : 'text'} - value={formData.clientSecret} - /> - {errors.clientSecret && ( - {errors.clientSecret} - )} - - - - -
- - - - - -
-
- ); -} diff --git a/src/renderer/routes/github/__snapshots__/LoginWithOAuthApp.test.tsx.snap b/src/renderer/routes/github/__snapshots__/LoginWithOAuthApp.test.tsx.snap deleted file mode 100644 index 1e0136890..000000000 --- a/src/renderer/routes/github/__snapshots__/LoginWithOAuthApp.test.tsx.snap +++ /dev/null @@ -1,487 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`renderer/routes/github/LoginWithOAuthApp.tsx > renders correctly 1`] = ` -
-
-
-
- - -
-
- -

- Login with OAuth App -

-
-
-
-
-
-
-
-
- - - - - - - Change only if you are using GitHub Enterprise Server - - -
-
- - - and use your - - client id & secret - - below. - -
-
- - - - -
-
- - - - - - - - -
-
-
- -
-
-`; diff --git a/src/renderer/routes/routes.visual.test.tsx b/src/renderer/routes/routes.visual.test.tsx index bb95e71b5..2c955687a 100644 --- a/src/renderer/routes/routes.visual.test.tsx +++ b/src/renderer/routes/routes.visual.test.tsx @@ -17,9 +17,9 @@ import { AccountScopesRoute } from './AccountScopes'; import { BitbucketLoginWithPersonalAccessTokenRoute } from './bitbucket/LoginWithPersonalAccessToken'; import { FiltersRoute } from './Filters'; import { GiteaLoginWithPersonalAccessTokenRoute } from './gitea/LoginWithPersonalAccessToken'; +import { GitHubLoginWithCustomOAuthAppRoute } from './github/LoginWithCustomOAuthApp'; import { GitHubLoginWithDeviceFlowRoute } from './github/LoginWithDeviceFlow'; import { GitHubLoginWithCLIRoute } from './github/LoginWithGitHubCLI'; -import { GitHubLoginWithOAuthAppRoute } from './github/LoginWithOAuthApp'; import { GitHubLoginWithPersonalAccessTokenRoute } from './github/LoginWithPersonalAccessToken'; import { GitLabLoginWithPersonalAccessTokenRoute } from './gitlab/LoginWithPersonalAccessToken'; import { LoginRoute } from './Login'; @@ -89,9 +89,9 @@ const ROUTES: RouteCase[] = [ state: { initialEntries: ['/login/github/personal-access-token'] }, }, { - name: 'login-github-oauth-app', - element: , - state: { initialEntries: ['/login/github/oauth-app'] }, + name: 'login-github-custom-oauth-app', + element: , + state: { initialEntries: ['/login/github/custom-oauth-app'] }, }, { name: 'login-gitea-personal-access-token', diff --git a/src/renderer/stores/types.ts b/src/renderer/stores/types.ts index c7ca4575f..287391c18 100644 --- a/src/renderer/stores/types.ts +++ b/src/renderer/stores/types.ts @@ -1,6 +1,7 @@ import type { Account, AccountUUID, + ClientID, FilterStateType, Forge, GitifyError, @@ -40,6 +41,7 @@ export interface AccountsActions { hostname: Hostname, forge?: Forge, username?: string, + clientId?: ClientID, ) => Promise; /** diff --git a/src/renderer/stores/useAccountsStore.test.ts b/src/renderer/stores/useAccountsStore.test.ts index 8a1b49df6..c900c51c9 100644 --- a/src/renderer/stores/useAccountsStore.test.ts +++ b/src/renderer/stores/useAccountsStore.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { + mockCustomOAuthAppAccount, mockGiteaAccount, mockGitHubCliAccount, mockGitHubCloudAccount, @@ -8,7 +9,8 @@ import { } from '../__mocks__/account-mocks'; import { mockRawUser } from '../utils/forges/github/__mocks__/response-mocks'; -import type { Account, Forge, Hostname, Link, Token } from '../types'; +import type { Account, ClientID, Forge, Hostname, Link, Token } from '../types'; +import type { AuthMethod } from '../utils/auth/types'; import type { GetAuthenticatedUserResponse } from '../utils/forges/github/types'; import { getRecommendedScopeNames } from '../utils/auth/scopes'; @@ -90,6 +92,25 @@ describe('renderer/stores/useAccountsStore.ts', () => { ); }, ); + + it('relabels a legacy GitHub App account as Gitify OAuth App without losing its token', () => { + const [account] = sanitizeAccounts([ + { ...mockGitHubCloudAccount, method: 'GitHub App' as AuthMethod }, + ]); + + expect(account.method).toBe('Gitify OAuth App'); + expect(account.token).toBe(mockGitHubCloudAccount.token); + }); + + it('relabels a legacy OAuth App account as Custom OAuth App and keeps its client ID', () => { + const [account] = sanitizeAccounts([ + { ...mockCustomOAuthAppAccount, method: 'OAuth App' as AuthMethod }, + ]); + + expect(account.method).toBe('Custom OAuth App'); + expect(account.token).toBe(mockCustomOAuthAppAccount.token); + expect(account.clientId).toBe('CUSTOM_CLIENT_ID_123'); + }); }); it.each([ @@ -201,16 +222,24 @@ describe('renderer/stores/useAccountsStore.ts', () => { ]); }); - test('should add oauth app account', async () => { + test('should add a custom OAuth app account with its client ID', async () => { await useAccountsStore .getState() - .createAccount('OAuth App', '123-456' as Token, 'github.com' as Hostname); + .createAccount( + 'Custom OAuth App', + '123-456' as Token, + 'github.com' as Hostname, + 'github', + undefined, + 'CUSTOM_CLIENT_ID_123' as ClientID, + ); expect(useAccountsStore.getState().accounts).toEqual([ { forge: 'github', hostname: 'github.com' as Hostname, - method: 'OAuth App', + method: 'Custom OAuth App', + clientId: 'CUSTOM_CLIENT_ID_123' as ClientID, platform: 'GitHub Cloud', scopes: getRecommendedScopeNames(), token: 'encrypted' as Token, @@ -306,16 +335,24 @@ describe('renderer/stores/useAccountsStore.ts', () => { ]); }); - test('should add oauth app account', async () => { + test('should add a custom OAuth app account', async () => { await useAccountsStore .getState() - .createAccount('OAuth App', '123-456' as Token, 'github.gitify.io' as Hostname); + .createAccount( + 'Custom OAuth App', + '123-456' as Token, + 'github.gitify.io' as Hostname, + 'github', + undefined, + 'CUSTOM_CLIENT_ID_123' as ClientID, + ); expect(useAccountsStore.getState().accounts).toEqual([ { forge: 'github', hostname: 'github.gitify.io' as Hostname, - method: 'OAuth App', + method: 'Custom OAuth App', + clientId: 'CUSTOM_CLIENT_ID_123' as ClientID, platform: 'GitHub Enterprise Server', scopes: getRecommendedScopeNames(), token: 'encrypted' as Token, diff --git a/src/renderer/stores/useAccountsStore.ts b/src/renderer/stores/useAccountsStore.ts index a689d30a8..e98426ee8 100644 --- a/src/renderer/stores/useAccountsStore.ts +++ b/src/renderer/stores/useAccountsStore.ts @@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware'; import { Constants } from '../constants'; -import type { Account, Forge, Hostname, Token } from '../types'; +import type { Account, ClientID, Forge, Hostname, Token } from '../types'; import type { AuthMethod } from '../utils/auth/types'; import type { AccountsState, AccountsStore } from './types'; @@ -13,11 +13,30 @@ import { getAccountAdapter, getAdapter, isKnownForge } from '../utils/forges/reg import { decryptValue, encryptValue } from '../utils/system/comms'; import { DEFAULT_ACCOUNTS_STATE } from './defaults'; +/** + * Map a persisted auth-method label onto the current vocabulary. + * + * Accounts stored before the custom OAuth app flow moved to the device flow + * carry the retired `GitHub App` / `OAuth App` labels; relabel them so their + * existing tokens keep working without forcing a re-login. + */ +function migrateLegacyAuthMethod(method: string): AuthMethod { + switch (method) { + case 'GitHub App': + return 'Gitify OAuth App'; + case 'OAuth App': + return 'Custom OAuth App'; + default: + return method as AuthMethod; + } +} + /** * Sanitise persisted accounts state. Persisted JSON is untrusted (XSS, malicious * extension, file-system tampering), so we project only the known fields, - * coerce `forge` to a registered value, and drop accounts with a - * structurally invalid hostname before the renderer ever touches them. + * coerce `forge` to a registered value, migrate retired auth-method labels, and + * drop accounts with a structurally invalid hostname before the renderer ever + * touches them. */ export function sanitizeAccounts(accounts: Account[]): Account[] { return accounts.flatMap((a) => { @@ -27,11 +46,12 @@ export function sanitizeAccounts(accounts: Account[]): Account[] { } const sanitised: Account = { forge, - method: a.method, + method: migrateLegacyAuthMethod(a.method as string), platform: a.platform, version: a.version, hostname: a.hostname, token: a.token, + clientId: a.clientId, username: a.username, user: a.user, scopes: a.scopes, @@ -57,6 +77,7 @@ const useAccountsStore = create()( hostname: Hostname, forge: Forge = 'github', username?: string, + clientId?: ClientID, ) => { const encryptedToken = (await encryptValue(token)) as Token; @@ -66,6 +87,7 @@ const useAccountsStore = create()( method: method, platform: getAdapter(forge).getPlatform(hostname), token: encryptedToken, + clientId, username, user: null, // Will be updated during the refresh call below }; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 74298639c..6b5ba4be0 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -27,14 +27,10 @@ type Brand = { [__brand]: B }; export type Branded = T & Brand; -export type AuthCode = Branded; - export type Token = Branded; export type ClientID = Branded; -export type ClientSecret = Branded; - export type Hostname = Branded; export type Link = Branded; @@ -73,6 +69,11 @@ export interface Account { version?: string; hostname: Hostname; token: Token; + /** + * OAuth app client ID for an account authorized through a user-supplied + * custom OAuth app; absent for Gitify's own preconfigured app. + */ + clientId?: ClientID; /** Atlassian account email — required for Bitbucket Basic Auth (email:token). */ username?: string; user: GitifyUser | null; diff --git a/src/renderer/utils/auth/loginValidation.test.ts b/src/renderer/utils/auth/loginValidation.test.ts index 6e4e136cc..0e34e1454 100644 --- a/src/renderer/utils/auth/loginValidation.test.ts +++ b/src/renderer/utils/auth/loginValidation.test.ts @@ -1,10 +1,10 @@ -import type { ClientID, ClientSecret, Forge, Hostname, Token } from '../../types'; +import type { ClientID, Forge, Hostname, Token } from '../../types'; import { - type OAuthAppFormData, + type CustomOAuthAppFormData, type PersonalAccessTokenFormData, summarizeLoginError, - validateOAuthAppForm, + validateCustomOAuthAppForm, validatePersonalAccessTokenForm, } from './loginValidation'; @@ -153,29 +153,34 @@ describe('renderer/utils/auth/loginValidation.ts', () => { }); }); - describe('validateOAuthAppForm', () => { - it('reports missing hostname, client ID, and client secret', () => { - const values: OAuthAppFormData = { + describe('validateCustomOAuthAppForm', () => { + it('reports missing hostname and client ID', () => { + const values: CustomOAuthAppFormData = { hostname: null as unknown as Hostname, clientId: null as unknown as ClientID, - clientSecret: null as unknown as ClientSecret, }; - expect(validateOAuthAppForm(values).hostname).toBe('Hostname is required'); - expect(validateOAuthAppForm(values).clientId).toBe('Client ID is required'); - expect(validateOAuthAppForm(values).clientSecret).toBe('Client Secret is required'); + expect(validateCustomOAuthAppForm(values).hostname).toBe('Hostname is required'); + expect(validateCustomOAuthAppForm(values).clientId).toBe('Client ID is required'); }); - it('reports invalid hostname, client ID, and client secret formats', () => { - const values: OAuthAppFormData = { + it('reports invalid hostname and client ID formats', () => { + const values: CustomOAuthAppFormData = { hostname: 'hello' as Hostname, clientId: '!@£INVALID-.1' as ClientID, - clientSecret: '!@£INVALID-.1' as ClientSecret, }; - expect(validateOAuthAppForm(values).hostname).toBe('Hostname format is invalid'); - expect(validateOAuthAppForm(values).clientId).toBe('Client ID format is invalid'); - expect(validateOAuthAppForm(values).clientSecret).toBe('Client Secret format is invalid'); + expect(validateCustomOAuthAppForm(values).hostname).toBe('Hostname format is invalid'); + expect(validateCustomOAuthAppForm(values).clientId).toBe('Client ID format is invalid'); + }); + + it('passes with a valid hostname and client ID and no client secret', () => { + const values: CustomOAuthAppFormData = { + hostname: 'github.com' as Hostname, + clientId: '1234567890_ASDFGHJKL' as ClientID, + }; + + expect(validateCustomOAuthAppForm(values)).toEqual({}); }); }); diff --git a/src/renderer/utils/auth/loginValidation.ts b/src/renderer/utils/auth/loginValidation.ts index e076f9fde..44289c800 100644 --- a/src/renderer/utils/auth/loginValidation.ts +++ b/src/renderer/utils/auth/loginValidation.ts @@ -1,5 +1,4 @@ -import type { Forge, Hostname, Token } from '../../types'; -import type { LoginOAuthWebOptions } from './types'; +import type { ClientID, Forge, Hostname, Token } from '../../types'; import { getAdapter } from '../forges/registry'; import { isValidHostname } from './utils'; @@ -20,14 +19,16 @@ export interface PersonalAccessTokenFormErrors { invalidCredentialsDetail?: string; } -/** OAuth app login form values — mirrors LoginOAuthWebOptions so it passes straight through. */ -export type OAuthAppFormData = LoginOAuthWebOptions; +/** Custom OAuth app login form values. */ +export interface CustomOAuthAppFormData { + hostname: Hostname; + clientId: ClientID; +} -/** OAuth app login form errors. */ -export interface OAuthAppFormErrors { +/** Custom OAuth app login form errors. */ +export interface CustomOAuthAppFormErrors { hostname?: string; clientId?: string; - clientSecret?: string; invalidCredentialsForHost?: string; } @@ -74,11 +75,11 @@ export const validatePersonalAccessTokenForm = ( return errors; }; -export const validateOAuthAppForm = ( - values: OAuthAppFormData, +export const validateCustomOAuthAppForm = ( + values: CustomOAuthAppFormData, forge: Forge = 'github', -): OAuthAppFormErrors => { - const errors: OAuthAppFormErrors = {}; +): CustomOAuthAppFormErrors => { + const errors: CustomOAuthAppFormErrors = {}; const adapter = getAdapter(forge); if (!values.hostname) { @@ -89,15 +90,9 @@ export const validateOAuthAppForm = ( if (!values.clientId) { errors.clientId = 'Client ID is required'; - } else if (!adapter.oauthWebApp?.validateClientId(values.clientId)) { + } else if (!adapter.customOAuthApp?.validateClientId(values.clientId)) { errors.clientId = 'Client ID format is invalid'; } - if (!values.clientSecret) { - errors.clientSecret = 'Client Secret is required'; - } else if (!adapter.validateToken(values.clientSecret as unknown as Token)) { - errors.clientSecret = 'Client Secret format is invalid'; - } - return errors; }; diff --git a/src/renderer/utils/auth/state.test.ts b/src/renderer/utils/auth/state.test.ts deleted file mode 100644 index 191a792b1..000000000 --- a/src/renderer/utils/auth/state.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { generateOAuthState } from './state'; - -const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -describe('renderer/utils/auth/state.ts', () => { - describe('generateOAuthState', () => { - it('should return a v4 UUID', () => { - expect(generateOAuthState()).toMatch(UUID_V4); - }); - - it('should return different values across calls', () => { - expect(generateOAuthState()).not.toBe(generateOAuthState()); - }); - - it('should use crypto.randomUUID', () => { - const spy = vi.spyOn(globalThis.crypto, 'randomUUID'); - generateOAuthState(); - expect(spy).toHaveBeenCalledOnce(); - spy.mockRestore(); - }); - }); -}); diff --git a/src/renderer/utils/auth/state.ts b/src/renderer/utils/auth/state.ts deleted file mode 100644 index 3b88de7c3..000000000 --- a/src/renderer/utils/auth/state.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Generate a cryptographically random OAuth 2.0 `state` value. - * - * `crypto.randomUUID()` is backed by the platform CSPRNG and yields 122 random - * bits — ample for a per-attempt, single-use value that an attacker cannot - * guess in order to bind the client to a different account. Using the platform - * primitive avoids hand-rolled random-value encoding entirely. - * - * @returns A random UUID to send as the OAuth `state` parameter. - */ -export function generateOAuthState(): string { - return crypto.randomUUID(); -} diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 60654acc2..d64e6fd1d 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -1,6 +1,10 @@ -import type { AuthCode, ClientID, ClientSecret, Forge, Hostname, Token } from '../../types'; +import type { ClientID, Forge, Hostname, Token } from '../../types'; -export type AuthMethod = 'GitHub App' | 'GitHub CLI' | 'Personal Access Token' | 'OAuth App'; +export type AuthMethod = + | 'Gitify OAuth App' + | 'Custom OAuth App' + | 'GitHub CLI' + | 'Personal Access Token'; export type PlatformType = | 'Bitbucket Cloud' @@ -11,12 +15,6 @@ export type PlatformType = | 'GitLab Self-Managed' | 'Gitea'; -export interface LoginOAuthWebOptions { - hostname: Hostname; - clientId: ClientID; - clientSecret: ClientSecret; -} - export interface DeviceFlowSession { hostname: Hostname; clientId: ClientID; @@ -35,9 +33,3 @@ export interface LoginPersonalAccessTokenOptions { /** Defaults to GitHub when omitted. */ forge?: Forge; } - -export interface AuthResponse { - authMethod: AuthMethod; - authCode: AuthCode; - authOptions: LoginOAuthWebOptions; -} diff --git a/src/renderer/utils/forges/bitbucket/adapter.test.ts b/src/renderer/utils/forges/bitbucket/adapter.test.ts index 01a78619e..f5936d2d6 100644 --- a/src/renderer/utils/forges/bitbucket/adapter.test.ts +++ b/src/renderer/utils/forges/bitbucket/adapter.test.ts @@ -118,7 +118,7 @@ describe('renderer/utils/forges/bitbucket/adapter.ts', () => { it('returns KeyIcon for every auth method', () => { expect(bitbucketAdapter.getAuthMethodIcon('Personal Access Token')).toBe(KeyIcon); - expect(bitbucketAdapter.getAuthMethodIcon('OAuth App')).toBe(KeyIcon); + expect(bitbucketAdapter.getAuthMethodIcon('Custom OAuth App')).toBe(KeyIcon); }); }); diff --git a/src/renderer/utils/forges/gitea/adapter.test.ts b/src/renderer/utils/forges/gitea/adapter.test.ts index aa5c502f2..bc22f9c11 100644 --- a/src/renderer/utils/forges/gitea/adapter.test.ts +++ b/src/renderer/utils/forges/gitea/adapter.test.ts @@ -74,8 +74,8 @@ describe('renderer/utils/forges/gitea/adapter.ts', () => { it('returns the key icon for every auth method (PAT-only forge today)', () => { expect(giteaAdapter.getAuthMethodIcon('Personal Access Token')).toBe(KeyIcon); - expect(giteaAdapter.getAuthMethodIcon('GitHub App')).toBe(KeyIcon); - expect(giteaAdapter.getAuthMethodIcon('OAuth App')).toBe(KeyIcon); + expect(giteaAdapter.getAuthMethodIcon('Gitify OAuth App')).toBe(KeyIcon); + expect(giteaAdapter.getAuthMethodIcon('Custom OAuth App')).toBe(KeyIcon); }); }); diff --git a/src/renderer/utils/forges/github/adapter.test.ts b/src/renderer/utils/forges/github/adapter.test.ts index b1d588add..a9800ed9b 100644 --- a/src/renderer/utils/forges/github/adapter.test.ts +++ b/src/renderer/utils/forges/github/adapter.test.ts @@ -1,15 +1,33 @@ -import { AgentIcon, AppsIcon, CopilotIcon, KeyIcon, PersonIcon } from '@primer/octicons-react'; +vi.mock('@octokit/oauth-methods', async () => { + const actual = + await vi.importActual('@octokit/oauth-methods'); + return { + ...actual, + createDeviceCode: vi.fn(), + exchangeDeviceCode: vi.fn(), + }; +}); + +import { AgentIcon, CopilotIcon, KeyIcon, PersonIcon } from '@primer/octicons-react'; + +import { createDeviceCode } from '@octokit/oauth-methods'; + +import type { MockedFunction } from 'vite-plus/test'; import { mockGitHubCliAccount, mockGitHubCloudAccount } from '../../../__mocks__/account-mocks'; import { mockPartialGitifyNotification } from '../../../__mocks__/notifications-mocks'; -import type { GitifyNotificationUser, Hostname, Link, Token } from '../../../types'; +import type { ClientID, GitifyNotificationUser, Hostname, Link, Token } from '../../../types'; import { githubAdapter } from './adapter'; import * as client from './client'; import * as octokit from './octokit'; describe('renderer/utils/forges/github/adapter.ts', () => { + const createDeviceCodeMock = createDeviceCode as unknown as MockedFunction< + typeof createDeviceCode + >; + describe('static fields', () => { it('identifies as github', () => { expect(githubAdapter.id).toBe('github'); @@ -18,7 +36,12 @@ describe('renderer/utils/forges/github/adapter.ts', () => { it('exposes the device-flow / PAT / CLI / OAuth login methods', () => { const ids = githubAdapter.loginMethods.map((m) => m.testId); - expect(ids).toEqual(['login-github', 'login-pat', 'login-github-cli', 'login-oauth-app']); + expect(ids).toEqual([ + 'login-github', + 'login-pat', + 'login-github-cli', + 'login-custom-oauth-app', + ]); }); it('defaults the PAT hostname to github.com', () => { @@ -54,20 +77,45 @@ 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('OAuth App')).toBe(PersonIcon); + expect(githubAdapter.getAuthMethodIcon('Gitify OAuth App')).toBe(PersonIcon); + expect(githubAdapter.getAuthMethodIcon('Custom 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'); + it('forwards a custom client ID through the device-flow start', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + data: { + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://github.example.com/login/device', + expires_in: 600, + interval: 5, + }, + } as unknown as Awaited>); + + const session = await githubAdapter.deviceFlow!.start( + 'github.example.com' as Hostname, + undefined, + 'CUSTOM_CLIENT_ID_123' as ClientID, + ); + + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'CUSTOM_CLIENT_ID_123', + scopes: expect.anything(), + request: expect.any(Function), + }); + expect(session.clientId).toBe('CUSTOM_CLIENT_ID_123'); + }); + + it('wires the device-flow and custom-OAuth-app methods so the context can dispatch via the adapter', () => { + expect(githubAdapter.deviceFlow?.authMethod).toBe('Gitify OAuth App'); expect(githubAdapter.deviceFlow?.start).toBeDefined(); expect(githubAdapter.deviceFlow?.poll).toBeDefined(); expect(githubAdapter.deviceFlow?.getRevokeAccessUrl).toBeDefined(); - expect(githubAdapter.oauthWebApp?.performWebOAuth).toBeDefined(); - expect(githubAdapter.oauthWebApp?.exchangeAuthCodeForToken).toBeDefined(); - expect(githubAdapter.oauthWebApp?.validateClientId).toBeDefined(); - expect(githubAdapter.oauthWebApp?.getNewOAuthAppUrl).toBeDefined(); + expect(githubAdapter.customOAuthApp?.authMethod).toBe('Custom OAuth App'); + expect(githubAdapter.customOAuthApp?.validateClientId).toBeDefined(); + expect(githubAdapter.customOAuthApp?.getNewOAuthAppUrl).toBeDefined(); }); }); diff --git a/src/renderer/utils/forges/github/adapter.ts b/src/renderer/utils/forges/github/adapter.ts index 973a2acaf..05b01973e 100644 --- a/src/renderer/utils/forges/github/adapter.ts +++ b/src/renderer/utils/forges/github/adapter.ts @@ -1,10 +1,4 @@ -import { - AppsIcon, - KeyIcon, - MarkGithubIcon, - PersonIcon, - TerminalIcon, -} from '@primer/octicons-react'; +import { KeyIcon, MarkGithubIcon, PersonIcon, TerminalIcon } from '@primer/octicons-react'; import { Constants, OAUTH_SCOPE } from '../../../constants'; @@ -15,6 +9,7 @@ import type { ForgeAdapter, NotificationDisplayHelpers, RefreshAccountData } fro import { isValidHostname } from '../../auth/utils'; import { extractHostVersion, + getAuthorizedOAuthAppURL, getDeveloperSettingsURL, getNewOAuthAppURL, getNewTokenURL, @@ -31,12 +26,7 @@ import { markNotificationThreadAsRead, } from './client'; import { enrichGitHubNotifications } from './enrich'; -import { - exchangeAuthCodeForAccessToken, - performGitHubWebOAuth, - pollGitHubDeviceFlow, - startGitHubDeviceFlow, -} from './flows'; +import { pollGitHubDeviceFlow, startGitHubDeviceFlow } from './flows'; import { createNotificationHandler } from './handlers'; import { clearOctokitClientCacheForAccount, createOctokitClient } from './octokit'; import { getGitHubPlatform } from './platform'; @@ -116,7 +106,7 @@ export const githubAdapter: ForgeAdapter = { label: 'GitHub', variant: 'primary', route: '/login/github/device-flow', - authMethod: 'GitHub App', + authMethod: 'Gitify OAuth App', }, { testId: 'login-pat', @@ -133,25 +123,23 @@ export const githubAdapter: ForgeAdapter = { authMethod: 'GitHub CLI', }, { - testId: 'login-oauth-app', + testId: 'login-custom-oauth-app', icon: PersonIcon, - label: 'OAuth App', - route: '/login/github/oauth-app', - authMethod: 'OAuth App', + label: 'Custom OAuth App', + route: '/login/github/custom-oauth-app', + authMethod: 'Custom OAuth App', }, ], deviceFlow: { - authMethod: 'GitHub App', + authMethod: 'Gitify OAuth App', start: startGitHubDeviceFlow, poll: pollGitHubDeviceFlow, - getRevokeAccessUrl: (hostname) => - getDeveloperSettingsURL({ hostname, method: 'GitHub App' } as Account), + getRevokeAccessUrl: getAuthorizedOAuthAppURL, }, - oauthWebApp: { - performWebOAuth: performGitHubWebOAuth, - exchangeAuthCodeForToken: exchangeAuthCodeForAccessToken, + customOAuthApp: { + authMethod: 'Custom OAuth App', validateClientId: isValidClientId, getNewOAuthAppUrl: getNewOAuthAppURL, }, @@ -231,12 +219,11 @@ function accountHasScopes( function githubAuthMethodIcon(method: AuthMethod) { switch (method) { - case 'GitHub App': - return AppsIcon; + case 'Gitify OAuth App': + case 'Custom OAuth App': + return PersonIcon; case 'GitHub CLI': return TerminalIcon; - case 'OAuth App': - return PersonIcon; default: return KeyIcon; } diff --git a/src/renderer/utils/forges/github/auth.test.ts b/src/renderer/utils/forges/github/auth.test.ts index 618bc917f..fb922a6e9 100644 --- a/src/renderer/utils/forges/github/auth.test.ts +++ b/src/renderer/utils/forges/github/auth.test.ts @@ -5,6 +5,7 @@ import type { AuthMethod } from '../../auth/types'; import { extractHostVersion, + getAuthorizedOAuthAppURL, getDeveloperSettingsURL, getGitHubAuthBaseUrl, getNewOAuthAppURL, @@ -60,23 +61,50 @@ describe('renderer/utils/forges/github/auth.ts', () => { }); }); + describe('getAuthorizedOAuthAppURL', () => { + it("builds the connections URL for a custom app's own client ID", () => { + expect( + getAuthorizedOAuthAppURL( + 'github.example.com' as Hostname, + 'CUSTOM_CLIENT_ID_123' as ClientID, + ), + ).toBe('https://github.example.com/settings/connections/applications/CUSTOM_CLIENT_ID_123'); + }); + + it("falls back to Gitify's preconfigured client ID when none is supplied", () => { + expect(getAuthorizedOAuthAppURL('github.com' as Hostname)).toBe( + 'https://github.com/settings/connections/applications/FAKE_CLIENT_ID_123', + ); + }); + }); + describe('getDeveloperSettingsURL', () => { - it('returns the GitHub App connections URL', () => { + it('returns the connections URL for Gitify OAuth App accounts', () => { expect( getDeveloperSettingsURL({ hostname: 'github.com' as Hostname, - method: 'GitHub App', + method: 'Gitify OAuth App', } as Account), ).toBe('https://github.com/settings/connections/applications/FAKE_CLIENT_ID_123'); }); - it('returns the OAuth App developer URL', () => { + it("returns the connections URL for a custom app's own client ID", () => { expect( getDeveloperSettingsURL({ hostname: 'github.com' as Hostname, - method: 'OAuth App', + method: 'Custom OAuth App', + clientId: 'CUSTOM_CLIENT_ID_123' as ClientID, } as Account), - ).toBe('https://github.com/settings/developers'); + ).toBe('https://github.com/settings/connections/applications/CUSTOM_CLIENT_ID_123'); + }); + + it('falls back to the preconfigured app when a custom account stores no client ID', () => { + expect( + getDeveloperSettingsURL({ + hostname: 'github.com' as Hostname, + method: 'Custom OAuth App', + } as Account), + ).toBe('https://github.com/settings/connections/applications/FAKE_CLIENT_ID_123'); }); it('returns the PAT settings URL', () => { @@ -132,6 +160,10 @@ describe('renderer/utils/forges/github/auth.ts', () => { ), ).toBeTruthy(); }); + + it('does not pre-fill a callback URL for a device-flow custom app', () => { + expect(getNewOAuthAppURL('github.com' as Hostname)).not.toContain('callback_url'); + }); }); describe('isValidClientId', () => { diff --git a/src/renderer/utils/forges/github/auth.ts b/src/renderer/utils/forges/github/auth.ts index 99915c750..f10568654 100644 --- a/src/renderer/utils/forges/github/auth.ts +++ b/src/renderer/utils/forges/github/auth.ts @@ -59,38 +59,45 @@ export function getGitHubAuthBaseUrl(hostname: Hostname): URL { return url; } +/** + * Build the URL where a user reviews and revokes an authorized OAuth app's access. + * + * Omit `clientId` for Gitify's preconfigured app; pass one for a user-supplied app. + * + * @param hostname - The GitHub hostname the app is authorized on. + * @param clientId - The OAuth app's client ID, when the account supplied one. + * @returns The authorized-applications URL for that client ID. + */ +export function getAuthorizedOAuthAppURL(hostname: Hostname, clientId?: ClientID): Link { + const url = new URL(`https://${hostname}`); + url.pathname = `/settings/connections/applications/${ + clientId ?? Constants.OAUTH_DEVICE_FLOW_CLIENT_ID + }`; + return url.toString() as Link; +} + /** * Return the GitHub developer settings URL appropriate for the account's auth method. * - * - GitHub App → application connections page + * - Gitify OAuth App / Custom OAuth App → the OAuth app's authorized-applications page * - GitHub CLI → authorized applications page - * - OAuth App → developer settings page * - Personal Access Token → tokens settings page * * @param account - The account whose settings URL to build. * @returns The URL to the relevant GitHub developer settings page. */ export function getDeveloperSettingsURL(account: Account): Link { - const settingsURL = new URL(`https://${account.hostname}`); - switch (account.method) { - case 'GitHub App': - settingsURL.pathname = `/settings/connections/applications/${Constants.OAUTH_DEVICE_FLOW_CLIENT_ID}`; - break; + case 'Gitify OAuth App': + case 'Custom OAuth App': + return getAuthorizedOAuthAppURL(account.hostname, account.clientId); case 'GitHub CLI': - settingsURL.pathname = '/settings/applications'; - break; - case 'OAuth App': - settingsURL.pathname = '/settings/developers'; - break; + return `https://${account.hostname}/settings/applications` as Link; case 'Personal Access Token': - settingsURL.pathname = '/settings/tokens'; - break; + return `https://${account.hostname}/settings/tokens` as Link; default: - settingsURL.pathname = '/settings'; - break; + return `https://${account.hostname}/settings` as Link; } - return settingsURL.toString() as Link; } /** @@ -115,8 +122,10 @@ export function getNewTokenURL(hostname: Hostname): Link { /** * Build a pre-filled URL for registering a new OAuth App. * - * Pre-populates the app name (with creation date), homepage URL, and - * the `gitify://oauth` callback URL. + * Pre-populates the app name (with creation date) and homepage URL. No callback + * URL is set: the custom OAuth app authorizes through the device flow, which + * needs no browser redirect. The user must still enable the device flow in the + * app's settings after registering it. * * @param hostname - The GitHub hostname to register the app on. * @returns The URL with pre-filled query parameters. @@ -129,7 +138,6 @@ export function getNewOAuthAppURL(hostname: Hostname): Link { `${APPLICATION.NAME} (Created on ${date})`, ); newOAuthAppURL.searchParams.append('oauth_application[url]', 'https://gitify.io'); - newOAuthAppURL.searchParams.append('oauth_application[callback_url]', 'gitify://oauth'); return newOAuthAppURL.toString() as Link; } diff --git a/src/renderer/utils/forges/github/flows.test.ts b/src/renderer/utils/forges/github/flows.test.ts index e9adc14fb..b3852cf22 100644 --- a/src/renderer/utils/forges/github/flows.test.ts +++ b/src/renderer/utils/forges/github/flows.test.ts @@ -6,23 +6,21 @@ vi.mock('@octokit/oauth-methods', async () => { ...actual, createDeviceCode: vi.fn(), exchangeDeviceCode: vi.fn(), - exchangeWebFlowCode: vi.fn(), }; }); -import { createDeviceCode, exchangeDeviceCode, exchangeWebFlowCode } from '@octokit/oauth-methods'; +import { createDeviceCode, exchangeDeviceCode } from '@octokit/oauth-methods'; import { RequestError } from '@octokit/request-error'; import type { MockedFunction } from 'vite-plus/test'; import { Constants } from '../../../constants'; -import type { AuthCode, ClientID, ClientSecret, Hostname } from '../../../types'; -import type { DeviceFlowSession, LoginOAuthWebOptions } from '../../auth/types'; +import type { ClientID, Hostname } from '../../../types'; +import type { DeviceFlowSession } from '../../auth/types'; import { getRecommendedScopeNames } from '../../auth/scopes'; import * as logger from '../../core/logger'; -import * as comms from '../../system/comms'; import * as flows from './flows'; const createDeviceCodeMock = createDeviceCode as unknown as MockedFunction; @@ -31,327 +29,189 @@ const exchangeDeviceCodeMock = exchangeDeviceCode as unknown as MockedFunction< typeof exchangeDeviceCode >; -const exchangeWebFlowCodeMock = exchangeWebFlowCode as unknown as MockedFunction< - typeof exchangeWebFlowCode ->; - describe('renderer/utils/forges/github/flows.ts', () => { - vi.spyOn(logger, 'rendererLogInfo').mockImplementation(vi.fn()); const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); - const rendererLogWarnSpy = vi.spyOn(logger, 'rendererLogWarn').mockImplementation(vi.fn()); - const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); beforeEach(() => { // Mock OAUTH_DEVICE_FLOW_CLIENT_ID value Constants.OAUTH_DEVICE_FLOW_CLIENT_ID = 'FAKE_CLIENT_ID_123' as ClientID; }); - describe('Gitify GitHub OAuth - Device Code Flow', () => { - describe('startGitHubDeviceFlow', () => { - it('should request a device code and return a session', async () => { - createDeviceCodeMock.mockResolvedValueOnce({ - data: { - device_code: 'device-code-xyz', - user_code: 'user-code-xyz', - verification_uri: 'https://github.com/login/device', - expires_in: 600, - interval: 5, - }, - } as unknown as Awaited>); - - const session = await flows.startGitHubDeviceFlow(); - - expect(createDeviceCodeMock).toHaveBeenCalledWith({ - clientType: 'oauth-app', - clientId: 'FAKE_CLIENT_ID_123', - scopes: getRecommendedScopeNames(), - request: expect.any(Function), - }); - - expect(session.deviceCode).toBe('device-code-xyz'); - expect(session.userCode).toBe('user-code-xyz'); - expect(session.verificationUri).toBe('https://github.com/login/device'); - expect(session.intervalSeconds).toBe(5); - expect(session.expiresAt).toBeGreaterThan(Date.now()); - }); - }); - - describe('pollGitHubDeviceFlow', () => { - const baseSession = { - hostname: 'github.com', - clientId: 'FAKE_CLIENT_ID_123', - deviceCode: 'device-code', - userCode: 'user-code', - verificationUri: 'https://github.com/login/device', - intervalSeconds: 5, - expiresAt: Date.now() + 10000, - } as const; - - it('returns token on successful exchange', async () => { - exchangeDeviceCodeMock.mockResolvedValueOnce({ - authentication: { token: 'device-token-xyz' }, - } as unknown as Awaited>); - - const token = await flows.pollGitHubDeviceFlow(baseSession as DeviceFlowSession); - - expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ - clientType: 'oauth-app', - clientId: 'FAKE_CLIENT_ID_123', - code: 'device-code', - request: expect.any(Function), - }); - - expect(token).toBe('device-token-xyz'); - }); - - it('returns null and does not change interval when authorization is pending', async () => { - const pendingErr = Object.create(RequestError.prototype); - pendingErr.response = { data: { error: 'authorization_pending' } }; - - exchangeDeviceCodeMock.mockRejectedValueOnce(pendingErr); + describe('startGitHubDeviceFlow', () => { + it('should request a device code and return a session', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + data: { + device_code: 'device-code-xyz', + user_code: 'user-code-xyz', + verification_uri: 'https://github.com/login/device', + expires_in: 600, + interval: 5, + }, + } as unknown as Awaited>); - const session = { ...baseSession } as DeviceFlowSession; - const token = await flows.pollGitHubDeviceFlow(session); + const session = await flows.startGitHubDeviceFlow(); - expect(token).toBeNull(); - expect(session.intervalSeconds).toBe(5); + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + scopes: getRecommendedScopeNames(), + request: expect.any(Function), }); - it('returns null and increases interval by 5 when slow_down', async () => { - const slowDownErr = Object.create(RequestError.prototype); - slowDownErr.response = { data: { error: 'slow_down' } }; - - exchangeDeviceCodeMock.mockRejectedValueOnce(slowDownErr); - - const session = { ...baseSession } as DeviceFlowSession; - const token = await flows.pollGitHubDeviceFlow(session); - - expect(token).toBeNull(); - expect(session.intervalSeconds).toBe(10); - }); + expect(session.deviceCode).toBe('device-code-xyz'); + expect(session.userCode).toBe('user-code-xyz'); + expect(session.verificationUri).toBe('https://github.com/login/device'); + expect(session.intervalSeconds).toBe(5); + expect(session.expiresAt).toBeGreaterThan(Date.now()); + }); - it('throws on other errors', async () => { - const otherErr = new Error('boom'); + it('authorizes a supplied custom OAuth app client ID', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + data: { + device_code: 'device-code-xyz', + user_code: 'user-code-xyz', + verification_uri: 'https://github.example.com/login/device', + expires_in: 600, + interval: 5, + }, + } as unknown as Awaited>); - exchangeDeviceCodeMock.mockRejectedValueOnce(otherErr); + const session = await flows.startGitHubDeviceFlow( + 'github.example.com' as Hostname, + getRecommendedScopeNames(), + 'CUSTOM_CLIENT_ID_123' as ClientID, + ); - await expect( - async () => await flows.pollGitHubDeviceFlow(baseSession as DeviceFlowSession), - ).rejects.toThrow('boom'); - expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'CUSTOM_CLIENT_ID_123', + scopes: getRecommendedScopeNames(), + request: expect.any(Function), }); + expect(session.clientId).toBe('CUSTOM_CLIENT_ID_123'); + expect(session.hostname).toBe('github.example.com'); }); }); - describe('performGitHubWebOAuth', () => { - const webAuthOptions: LoginOAuthWebOptions = { - hostname: 'github.com' as Hostname, - clientId: 'FAKE_CLIENT_ID_123' as ClientID, - clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, - }; - - it('should call performGitHubWebOAuth using custom oauth app - success oauth flow', async () => { - let deliverCallback: (callbackUrl: string) => void = () => {}; - window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => { - deliverCallback = callback; - }); - - const resultPromise = flows.performGitHubWebOAuth({ - clientId: 'BYO_CLIENT_ID' as ClientID, - clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret, - hostname: 'my.git.com' as Hostname, + describe('pollGitHubDeviceFlow', () => { + const baseSession = { + hostname: 'github.com', + clientId: 'FAKE_CLIENT_ID_123', + deviceCode: 'device-code', + userCode: 'user-code', + verificationUri: 'https://github.com/login/device', + intervalSeconds: 5, + expiresAt: Date.now() + 10000, + } as const; + + it('returns token on successful exchange', async () => { + exchangeDeviceCodeMock.mockResolvedValueOnce({ + authentication: { token: 'device-token-xyz' }, + } as unknown as Awaited>); + + const token = await flows.pollGitHubDeviceFlow(baseSession as DeviceFlowSession); + + expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + code: 'device-code', + request: expect.any(Function), }); - expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); - expect(openExternalLinkSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'https://my.git.com/login/oauth/authorize?allow_signup=false&client_id=BYO_CLIENT_ID&scope=notifications%2Cread%3Auser%2Crepo', - ), - ); - - // The authorization URL must carry a per-attempt state, and the login - // only completes when the callback echoes that same value back. - const authorizeUrl = new URL(openExternalLinkSpy.mock.calls[0][0] as string); - const state = authorizeUrl.searchParams.get('state'); - expect(state).toBeTruthy(); - - deliverCallback(`gitify://oauth?code=123-456&state=${state}`); - - const res = await resultPromise; - - expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); - expect(window.gitify.onAuthCallback).toHaveBeenCalledWith(expect.any(Function)); - - expect(res.authMethod).toBe('OAuth App'); - expect(res.authCode).toBe('123-456'); + expect(token).toBe('device-token-xyz'); }); - it('does not resolve when the callback state does not match the generated state', async () => { - let deliverCallback: (callbackUrl: string) => void = () => {}; - window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => { - deliverCallback = callback; - }); - - let resolved = false; - const resultPromise = flows.performGitHubWebOAuth(webAuthOptions).then((res) => { - resolved = true; - return res; + it("uses the session's own client ID when polling a custom app", async () => { + exchangeDeviceCodeMock.mockResolvedValueOnce({ + authentication: { token: 'custom-token' }, + } as unknown as Awaited>); + + const token = await flows.pollGitHubDeviceFlow({ + ...baseSession, + clientId: 'CUSTOM_CLIENT_ID_123', + } as DeviceFlowSession); + + expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'CUSTOM_CLIENT_ID_123', + code: 'device-code', + request: expect.any(Function), }); + expect(token).toBe('custom-token'); + }); - const authorizeUrl = new URL(openExternalLinkSpy.mock.calls[0][0] as string); - const state = authorizeUrl.searchParams.get('state'); + it('returns null and does not change interval when authorization is pending', async () => { + const pendingErr = Object.create(RequestError.prototype); + pendingErr.response = { data: { error: 'authorization_pending' } }; - deliverCallback('gitify://oauth?code=attacker-code&state=not-the-real-state'); - deliverCallback('gitify://oauth?code=attacker-code-no-state'); + exchangeDeviceCodeMock.mockRejectedValueOnce(pendingErr); - // Give any pending microtasks a chance to run, then confirm neither - // injected callback settled the login. - await Promise.resolve(); - await Promise.resolve(); - expect(resolved).toBe(false); - expect(rendererLogWarnSpy).toHaveBeenCalledTimes(2); + const session = { ...baseSession } as DeviceFlowSession; + const token = await flows.pollGitHubDeviceFlow(session); - // The still-pending attempt completes once the matching state arrives. - deliverCallback(`gitify://oauth?code=the-real-code&state=${state}`); - const res = await resultPromise; - expect(res.authCode).toBe('the-real-code'); + expect(token).toBeNull(); + expect(session.intervalSeconds).toBe(5); }); - it('ignores an unparseable callback URL instead of tearing down the pending login', async () => { - let deliverCallback: (callbackUrl: string) => void = () => {}; - window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => { - deliverCallback = callback; - }); - - let settled = false; - const resultPromise = flows.performGitHubWebOAuth(webAuthOptions).then( - (res) => { - settled = true; - return res; - }, - (err: unknown) => { - settled = true; - throw err; - }, - ); - - // `gitify://[` passes the protocol handler's `gitify://` prefix check but - // is not a parseable URL, so it must be ignored rather than rejecting the - // login before the state is ever compared. - deliverCallback('gitify://['); + it('returns null and increases interval by 5 when slow_down', async () => { + const slowDownErr = Object.create(RequestError.prototype); + slowDownErr.response = { data: { error: 'slow_down' } }; - await Promise.resolve(); - await Promise.resolve(); - expect(settled).toBe(false); - expect(rendererLogWarnSpy).toHaveBeenCalledTimes(1); + exchangeDeviceCodeMock.mockRejectedValueOnce(slowDownErr); - // Settle the still-pending attempt so no listener is left dangling. - const authorizeUrl = new URL(openExternalLinkSpy.mock.calls[0][0] as string); - const state = authorizeUrl.searchParams.get('state'); - deliverCallback(`gitify://oauth?code=real-code&state=${state}`); + const session = { ...baseSession } as DeviceFlowSession; + const token = await flows.pollGitHubDeviceFlow(session); - await expect(resultPromise).resolves.toMatchObject({ authCode: 'real-code' }); + expect(token).toBeNull(); + expect(session.intervalSeconds).toBe(10); }); - it('should call performGitHubWebOAuth - failure', async () => { - let deliverCallback: (callbackUrl: string) => void = () => {}; - window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => { - deliverCallback = callback; - }); + it('throws on other errors', async () => { + const otherErr = new Error('boom'); - const resultPromise = flows.performGitHubWebOAuth(webAuthOptions); + exchangeDeviceCodeMock.mockRejectedValueOnce(otherErr); - const authorizeUrl = new URL(openExternalLinkSpy.mock.calls[0][0] as string); - const state = authorizeUrl.searchParams.get('state'); + await expect( + async () => await flows.pollGitHubDeviceFlow(baseSession as DeviceFlowSession), + ).rejects.toThrow('boom'); + expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); + }); + }); - deliverCallback( - `gitify://oauth?state=${state}&error=invalid_request&error_description=The+redirect_uri+is+missing+or+invalid.&error_uri=https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors`, - ); + describe('describeDeviceFlowStartError', () => { + it('explains how to enable device flow for a custom app', () => { + const err = Object.create(RequestError.prototype); + err.response = { data: { error: 'device_flow_disabled' } }; - await expect(resultPromise).rejects.toEqual( - new Error( - "Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: The redirect_uri is missing or invalid. Docs: https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors", - ), + const message = flows.describeDeviceFlowStartError( + err, + 'github.example.com' as Hostname, + 'CUSTOM_CLIENT_ID_123' as ClientID, ); - expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); - expect(openExternalLinkSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=notifications%2Cread%3Auser%2Crepo', - ), + expect(message).toContain('Device flow is not enabled'); + expect(message).toContain( + 'https://github.example.com/settings/applications/CUSTOM_CLIENT_ID_123', ); - - expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); - expect(window.gitify.onAuthCallback).toHaveBeenCalledWith(expect.any(Function)); }); - it('ignores a callback error whose state does not match the attempt', async () => { - let deliverCallback: (callbackUrl: string) => void = () => {}; - window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => { - deliverCallback = callback; - }); - - let settled = false; - const resultPromise = flows.performGitHubWebOAuth(webAuthOptions).then( - (res) => { - settled = true; - return res; - }, - (err: unknown) => { - settled = true; - throw err; - }, - ); - - deliverCallback('gitify://oauth?error=access_denied'); - deliverCallback('gitify://oauth?error=access_denied&state=not-the-real-state'); - - await Promise.resolve(); - await Promise.resolve(); - expect(settled).toBe(false); - expect(rendererLogWarnSpy).toHaveBeenCalledTimes(2); + it('names an unrecognized client ID as invalid for the host', () => { + const err = Object.create(RequestError.prototype); + err.response = { data: { error: 'incorrect_client_credentials' } }; - // Settle the still-pending attempt so no listener is left dangling. - const authorizeUrl = new URL(openExternalLinkSpy.mock.calls[0][0] as string); - const state = authorizeUrl.searchParams.get('state'); - deliverCallback(`gitify://oauth?code=real-code&state=${state}`); - - await expect(resultPromise).resolves.toMatchObject({ authCode: 'real-code' }); + expect( + flows.describeDeviceFlowStartError( + err, + 'github.example.com' as Hostname, + 'CUSTOM_CLIENT_ID_123' as ClientID, + ), + ).toBe('The client ID is not valid for github.example.com. Check it and try again.'); }); - describe('exchangeAuthCodeForAccessToken', () => { - const authCode = '123-456' as AuthCode; - - it('should exchange auth code for access token', async () => { - exchangeWebFlowCodeMock.mockResolvedValueOnce({ - authentication: { - token: 'this-is-a-token', - }, - } as unknown as Awaited>); - - const res = await flows.exchangeAuthCodeForAccessToken(authCode, { - ...webAuthOptions, - }); - - expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ - clientType: 'oauth-app', - clientId: 'FAKE_CLIENT_ID_123', - clientSecret: 'FAKE_CLIENT_SECRET_123', - code: '123-456', - request: expect.any(Function), - }); - expect(res).toBe('this-is-a-token'); - }); - - it('should throw when client secret is missing', async () => { - await expect( - async () => - await flows.exchangeAuthCodeForAccessToken(authCode, { - ...webAuthOptions, - clientSecret: undefined as unknown as ClientSecret, - }), - ).rejects.toThrow('clientSecret is required to exchange an auth code'); - }); + it('returns undefined for a non-RequestError', () => { + expect( + flows.describeDeviceFlowStartError(new Error('boom'), 'github.com' as Hostname), + ).toBeUndefined(); }); }); }); diff --git a/src/renderer/utils/forges/github/flows.ts b/src/renderer/utils/forges/github/flows.ts index 6b7fe1d22..1b49ebf17 100644 --- a/src/renderer/utils/forges/github/flows.ts +++ b/src/renderer/utils/forges/github/flows.ts @@ -1,21 +1,14 @@ -import { - createDeviceCode, - exchangeDeviceCode, - exchangeWebFlowCode, - getWebFlowAuthorizationUrl, -} from '@octokit/oauth-methods'; +import { createDeviceCode, exchangeDeviceCode } from '@octokit/oauth-methods'; import { request } from '@octokit/request'; import { RequestError } from '@octokit/request-error'; import { Constants } from '../../../constants'; -import type { AuthCode, Hostname, Link, Token } from '../../../types'; -import type { AuthResponse, DeviceFlowSession, LoginOAuthWebOptions } from '../../auth/types'; +import type { ClientID, Hostname, Token } from '../../../types'; +import type { DeviceFlowSession } from '../../auth/types'; import { getRecommendedScopeNames } from '../../auth/scopes'; -import { generateOAuthState } from '../../auth/state'; -import { rendererLogError, rendererLogInfo, rendererLogWarn, toError } from '../../core/logger'; -import { openExternalLink } from '../../system/comms'; +import { rendererLogError, toError } from '../../core/logger'; import { getGitHubAuthBaseUrl } from './auth'; type DeviceFlowErrorResponse = { @@ -24,115 +17,6 @@ type DeviceFlowErrorResponse = { error_uri: string; }; -/** - * Initiate a GitHub OAuth Web Flow (OAuth App) authentication. - * - * Opens the GitHub authorization URL in the user's browser, then waits for the - * app's custom `gitify://oauth` callback to receive the authorization code. - * - * The login is bound to a per-attempt random `state` that is sent with the - * authorization request and must be echoed by the callback. A callback - * carrying a missing or mismatched `state` is logged and ignored, so a - * `gitify://oauth` link delivered from outside this attempt cannot bind the - * client to a different GitHub account or tear down the pending login. - * - * @param authOptions - The OAuth App client configuration and target hostname. - * @returns Resolves with the authorization code and options on success. - */ -export function performGitHubWebOAuth(authOptions: LoginOAuthWebOptions): Promise { - return new Promise((resolve, reject) => { - // A per-attempt, unguessable value this app itself tracks and checks on - // callback, so a `gitify://oauth` callback carrying an authorization code - // this specific login attempt never requested (e.g. one an attacker - // obtained for their own GitHub account) is rejected instead of silently - // accepted and exchanged for a token. - // - // Read the state back from the library's result rather than trusting the - // value echoed to us, so the string compared on callback is exactly the one - // the library embedded in the authorization URL. - const { url, state: expectedState } = getWebFlowAuthorizationUrl({ - clientType: 'oauth-app', - clientId: authOptions.clientId, - scopes: getRecommendedScopeNames(), - allowSignup: false, - state: generateOAuthState(), - request: request.defaults({ - baseUrl: `https://${authOptions.hostname}`, - }), - }); - - openExternalLink(url as Link); - - // Assigned once the listener is registered below; optional-chained in - // `handleCallback` so a synchronously-firing callback cannot hit the - // temporal dead zone. - let unsubscribeAuthCallback: (() => void) | undefined; - - const handleCallback = (callbackUrl: string) => { - let url: URL; - try { - url = new URL(callbackUrl); - } catch { - // An unparseable callback cannot carry the state this attempt - // generated, so treat it like any other unmatched callback: log and - // ignore rather than tearing down the pending login. Otherwise a - // forged `gitify://` URL (e.g. `gitify://[`) would abort a legitimate - // login even though the state check never runs. - rendererLogWarn( - 'renderer:auth-callback', - 'Ignored an unparseable authentication callback URL', - ); - return; - } - - const type = url.hostname; - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const error = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); - const errorUri = url.searchParams.get('error_uri'); - - // A callback belongs to this login attempt only when it echoes the state - // this attempt generated. Anything else (missing or mismatched state) is - // logged and ignored, leaving the pending login intact so an injected - // callback can neither bind another account nor tear down the attempt. - if (state !== expectedState) { - rendererLogWarn( - 'renderer:auth-callback', - 'Ignored an authentication callback with a missing or mismatched state parameter', - ); - return; - } - - if (code && type === 'oauth') { - unsubscribeAuthCallback?.(); - resolve({ - authMethod: 'OAuth App', - authCode: code as AuthCode, - authOptions: authOptions, - }); - } else if (error) { - unsubscribeAuthCallback?.(); - reject( - new Error( - `Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: ${errorDescription} Docs: ${errorUri}`, - ), - ); - } - }; - - // Unsubscribed inside `handleCallback` once the flow settles so repeated - // login attempts do not accumulate stale ipcRenderer listeners. - unsubscribeAuthCallback = window.gitify.onAuthCallback((callbackUrl: string) => { - rendererLogInfo( - 'renderer:auth-callback', - `received authentication callback URL ${callbackUrl}`, - ); - handleCallback(callbackUrl); - }); - }); -} - /** * Start a GitHub Device Flow authorization session. * @@ -141,15 +25,18 @@ export function performGitHubWebOAuth(authOptions: LoginOAuthWebOptions): Promis * * @param hostname - The GitHub hostname to authenticate against. Defaults to github.com. * @param scopes - Array of scope names to request. Defaults to recommended (full) scopes. + * @param clientId - The OAuth app client ID to authorize with. Defaults to Gitify's + * own preconfigured app; a user-supplied custom app passes its own. * @returns The device flow session data. */ export async function startGitHubDeviceFlow( hostname: Hostname = Constants.GITHUB_HOSTNAME, scopes: string[] = getRecommendedScopeNames(), + clientId: ClientID = Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, ): Promise { const deviceCode = await createDeviceCode({ clientType: 'oauth-app' as const, - clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, + clientId: clientId, scopes: scopes, request: request.defaults({ baseUrl: getGitHubAuthBaseUrl(hostname).toString(), @@ -158,7 +45,7 @@ export async function startGitHubDeviceFlow( return { hostname: hostname, - clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, + clientId: clientId, deviceCode: deviceCode.data.device_code, userCode: deviceCode.data.user_code, verificationUri: deviceCode.data.verification_uri, @@ -167,6 +54,42 @@ export async function startGitHubDeviceFlow( } as DeviceFlowSession; } +/** + * Describe a device-flow start failure in actionable terms. + * + * A device flow can only start when the app has device flow enabled and the + * client ID is known to the host. Both are common setup mistakes for a + * user-supplied custom app, so they are surfaced with a named cause and fix + * instead of a raw transport error. + * + * @param err - The error thrown while starting the device flow. + * @param hostname - The host the session was started against. + * @param clientId - The client ID supplied for a custom app, when any. + * @returns A user-facing message, or `undefined` for an unrecognised failure. + */ +export function describeDeviceFlowStartError( + err: unknown, + hostname: Hostname, + clientId?: ClientID, +): string | undefined { + if (!(err instanceof RequestError)) { + return undefined; + } + + const errorCode = (err.response?.data as Partial | undefined)?.error; + + switch (errorCode) { + case 'device_flow_disabled': + return clientId + ? `Device flow is not enabled for this OAuth app. Enable it in that app's settings at https://${hostname}/settings/applications/${clientId}, then try again.` + : `Device flow is not enabled for this OAuth app. Enable it in the app's settings on ${hostname}, then try again.`; + case 'incorrect_client_credentials': + return `The client ID is not valid for ${hostname}. Check it and try again.`; + default: + return undefined; + } +} + /** * Poll GitHub to exchange a device code for an access token. * @@ -209,35 +132,3 @@ export async function pollGitHubDeviceFlow(session: DeviceFlowSession): Promise< throw err; } } - -/** - * Exchange an OAuth authorization code for an access token. - * - * `authOptions.clientSecret` is required; this step must be performed - * server-side or in a trusted context to keep the secret confidential. - * - * @param authCode - The authorization code received from the OAuth callback. - * @param authOptions - The OAuth App options, including the client secret. - * @returns The access token. - * @throws If `clientSecret` is absent. - */ -export async function exchangeAuthCodeForAccessToken( - authCode: AuthCode, - authOptions: LoginOAuthWebOptions, -): Promise { - if (!authOptions.clientSecret) { - throw new Error('clientSecret is required to exchange an auth code'); - } - - const { authentication } = await exchangeWebFlowCode({ - clientType: 'oauth-app', - clientId: authOptions.clientId, - clientSecret: authOptions.clientSecret, - code: authCode, - request: request.defaults({ - baseUrl: getGitHubAuthBaseUrl(authOptions.hostname).toString().replace(/\/$/, ''), - }), - }); - - return authentication.token as Token; -} diff --git a/src/renderer/utils/forges/github/octokit.test.ts b/src/renderer/utils/forges/github/octokit.test.ts index 588c468f4..da1a96693 100644 --- a/src/renderer/utils/forges/github/octokit.test.ts +++ b/src/renderer/utils/forges/github/octokit.test.ts @@ -1,5 +1,5 @@ import { - mockGitHubAppAccount, + mockGitifyOAuthAppAccount, mockGitHubCliAccount, mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, @@ -113,7 +113,7 @@ describe('renderer/utils/forges/github/octokit.ts', () => { const getGitHubAPIBaseUrlSpy = vi.spyOn(utils, 'getGitHubAPIBaseUrl'); getGitHubAPIBaseUrlSpy.mockReturnValue(new URL('https://api.github.com/')); - const octokit1 = await createOctokitClient(mockGitHubAppAccount, 'rest'); + const octokit1 = await createOctokitClient(mockGitifyOAuthAppAccount, 'rest'); const octokit2 = await createOctokitClient(mockGitHubCloudAccount, 'rest'); diff --git a/src/renderer/utils/forges/gitlab/adapter.test.ts b/src/renderer/utils/forges/gitlab/adapter.test.ts index 1f2ddeddd..275faca28 100644 --- a/src/renderer/utils/forges/gitlab/adapter.test.ts +++ b/src/renderer/utils/forges/gitlab/adapter.test.ts @@ -83,7 +83,7 @@ describe('renderer/utils/forges/gitlab/adapter.ts', () => { it('returns the key icon for every auth method (PAT-only forge today)', () => { expect(gitlabAdapter.getAuthMethodIcon('Personal Access Token')).toBe(KeyIcon); - expect(gitlabAdapter.getAuthMethodIcon('OAuth App')).toBe(KeyIcon); + expect(gitlabAdapter.getAuthMethodIcon('Custom OAuth App')).toBe(KeyIcon); }); it('omits the oauthScopes bundle', () => { diff --git a/src/renderer/utils/forges/types.ts b/src/renderer/utils/forges/types.ts index 745d472d6..b5da9fe08 100644 --- a/src/renderer/utils/forges/types.ts +++ b/src/renderer/utils/forges/types.ts @@ -4,7 +4,7 @@ import type { OcticonProps } from '@primer/octicons-react'; import type { Account, - AuthCode, + ClientID, Forge, Hostname, IconColor, @@ -14,13 +14,7 @@ import type { Token, UserType, } from '../../types'; -import type { - AuthMethod, - AuthResponse, - DeviceFlowSession, - LoginOAuthWebOptions, - PlatformType, -} from '../auth/types'; +import type { AuthMethod, DeviceFlowSession, PlatformType } from '../auth/types'; /** * Capability flags exposed by a forge adapter. @@ -168,9 +162,9 @@ export interface ForgeAdapter { // --- Auth flows --- // Optional because not every forge supports every flow. Gitea today is - // PAT-only and omits both bundles. The orchestrator gates UI on the + // PAT-only and omits every bundle. The orchestrator gates UI on the // presence of these bundles (and on `loginMethods` entries pointing at - // the forge's device-flow or OAuth-app routes). + // the forge's device-flow or custom-OAuth-app routes). /** * OAuth device-flow capability. Forges without device-flow support (e.g. @@ -180,10 +174,11 @@ export interface ForgeAdapter { deviceFlow?: DeviceFlowSupport; /** - * Custom-OAuth-app web flow. Forges without OAuth-app support (e.g. Gitea) - * omit this bundle entirely — callers gate the OAuth-app UI on its presence. + * Custom-OAuth-app capability. Forges where a user cannot authorize their + * own OAuth app (e.g. Gitea) omit this bundle entirely — callers gate the + * custom-app UI on its presence. */ - oauthWebApp?: OAuthWebAppSupport; + customOAuthApp?: CustomOAuthAppSupport; /** * Reuse of a locally installed forge CLI's credential. Forges whose CLI @@ -320,22 +315,6 @@ export interface ExternallyManagedScopes { command: string; } -/** - * Custom-OAuth-app web flow capability bundle. Present only on forges that - * support browser-redirect OAuth with user-supplied client credentials - * (GitHub today). - */ -export interface OAuthWebAppSupport { - /** Start the OAuth web flow and return the auth code once the user consents. */ - performWebOAuth(options: LoginOAuthWebOptions): Promise; - /** Exchange an OAuth web-flow authorization code for an access token. */ - exchangeAuthCodeForToken(authCode: AuthCode, options: LoginOAuthWebOptions): Promise; - /** Whether the supplied OAuth client ID matches the forge's expected format. */ - validateClientId(clientId: string): boolean; - /** URL the user visits to create a new OAuth app on the forge. */ - getNewOAuthAppUrl(hostname: Hostname): Link; -} - /** * OAuth device-flow capability bundle. Present only on forges that support * browser-side device authorisation (GitHub today). @@ -343,12 +322,30 @@ export interface OAuthWebAppSupport { export interface DeviceFlowSupport { /** `AuthMethod` recorded on the account when a successful login completes. */ authMethod: AuthMethod; - /** Start a device-flow authorization session. */ - start(hostname?: Hostname, scopes?: string[]): Promise; + /** + * Start a device-flow authorization session. + * + * `clientId` authorizes the user's own OAuth app instead of Gitify's + * preconfigured one; when omitted the forge's built-in app is used. + */ + start(hostname?: Hostname, scopes?: string[], clientId?: ClientID): Promise; /** Poll for completion; resolves to a token when granted. */ poll(session: DeviceFlowSession): Promise; - /** URL the user visits to revoke Gitify's access on this forge. */ - getRevokeAccessUrl(hostname: Hostname): Link; + /** URL the user visits to revoke the authorized OAuth app's access on this forge. */ + getRevokeAccessUrl(hostname: Hostname, clientId?: ClientID): Link; +} + +/** + * Custom-OAuth-app capability bundle. Present only on forges where a user can + * authorize their own OAuth app (GitHub today). + */ +export interface CustomOAuthAppSupport { + /** `AuthMethod` recorded on accounts created from a user-supplied OAuth app. */ + authMethod: AuthMethod; + /** Whether the supplied OAuth client ID matches the forge's expected format. */ + validateClientId(clientId: string): boolean; + /** URL the user visits to register a new OAuth app on the forge. */ + getNewOAuthAppUrl(hostname: Hostname): Link; } /** diff --git a/src/shared/events.ts b/src/shared/events.ts index 90e88c632..ddac48bd3 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -5,7 +5,6 @@ const P = 'gitify:' as const; * Each value is prefixed with `gitify:` to prevent collisions. */ export const EVENTS = { - AUTH_CALLBACK: `${P}auth-callback`, QUIT: `${P}quit`, WINDOW_SHOW: `${P}window-show`, WINDOW_HIDE: `${P}window-hide`, @@ -117,7 +116,6 @@ type AssertEventCoverage> = T; * - For events with no payload, `request` is `undefined`. */ export type EventContracts = AssertEventCoverage<{ - [EVENTS.AUTH_CALLBACK]: { request: string; response: undefined }; [EVENTS.QUIT]: { request: undefined; response: undefined }; [EVENTS.WINDOW_SHOW]: { request: undefined; response: undefined }; [EVENTS.WINDOW_HIDE]: { request: undefined; response: undefined };