From 71f15625afda0edc5a62dfaa7e6ab7cdd186f05d Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 20 Jan 2026 11:29:54 -0500 Subject: [PATCH 01/14] refactor(auth): device code flow Signed-off-by: Adam Setch --- src/renderer/__mocks__/account-mocks.ts | 6 +- src/renderer/constants.ts | 9 ++- src/renderer/context/App.tsx | 21 +++---- src/renderer/routes/LoginWithOAuthApp.tsx | 4 +- src/renderer/utils/auth/types.ts | 9 ++- src/renderer/utils/auth/utils.test.ts | 71 +++++++++++++++++++++-- src/renderer/utils/auth/utils.ts | 45 ++++++++++++-- src/renderer/utils/helpers.ts | 4 +- src/renderer/utils/storage.test.ts | 6 +- 9 files changed, 135 insertions(+), 40 deletions(-) diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 16787ffa4..3ded02c4e 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -14,7 +14,7 @@ export const mockGitHubAppAccount: Account = { platform: 'GitHub Cloud', method: 'GitHub App', token: 'token-987654321' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -23,7 +23,7 @@ export const mockPersonalAccessTokenAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -41,7 +41,7 @@ export const mockGitHubCloudAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, user: mockGitifyUser, version: 'latest', hasRequiredScopes: true, diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index c81c24317..b4f4e89ad 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,5 +1,5 @@ -import type { ClientID, ClientSecret, Hostname, Link } from './types'; -import type { LoginOAuthAppOptions } from './utils/auth/types'; +import type { ClientID, Hostname, Link } from './types'; +import type { LoginOAuthDeviceOptions } from './utils/auth/types'; export const Constants = { STORAGE_KEY: 'gitify-storage', @@ -10,11 +10,10 @@ export const Constants = { ALTERNATE: ['read:user', 'notifications', 'public_repo'], }, - DEFAULT_AUTH_OPTIONS: { + OAUTH_DEVICE_FLOW: { hostname: 'github.com' as Hostname, clientId: process.env.OAUTH_CLIENT_ID as ClientID, - clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, - } satisfies LoginOAuthAppOptions, + } satisfies LoginOAuthDeviceOptions, GITHUB_API_BASE_URL: 'https://api.github.com', GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql', diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 4d37c4491..2092cd35e 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -32,7 +32,7 @@ import type { } from '../types'; import { FetchType } from '../types'; import type { - LoginOAuthAppOptions, + LoginOAuthWebOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -42,7 +42,8 @@ import { exchangeAuthCodeForAccessToken, getAccountUUID, hasAccounts, - performGitHubOAuth, + performGitHubDeviceOAuth, + performGitHubWebOAuth, refreshAccount, removeAccount, } from '../utils/auth/utils'; @@ -75,7 +76,7 @@ export interface AppContextState { auth: AuthState; isLoggedIn: boolean; loginWithGitHubApp: () => Promise; - loginWithOAuthApp: (data: LoginOAuthAppOptions) => Promise; + loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: ( data: LoginPersonalAccessTokenOptions, ) => Promise; @@ -396,15 +397,11 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [auth]); /** - * Login with GitHub App. - * - * Note: although we call this "Login with GitHub App", this function actually - * authenticates via a predefined "Gitify" GitHub OAuth App. + * Login with GitHub App using device flow so the client secret is never bundled or persisted. */ const loginWithGitHubApp = useCallback(async () => { - const { authCode } = await performGitHubOAuth(); - const token = await exchangeAuthCodeForAccessToken(authCode); - const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname; + const token = await performGitHubDeviceOAuth(); + const hostname = Constants.OAUTH_DEVICE_FLOW.hostname; const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); @@ -415,8 +412,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { * Login with custom GitHub OAuth App. */ const loginWithOAuthApp = useCallback( - async (data: LoginOAuthAppOptions) => { - const { authOptions, authCode } = await performGitHubOAuth(data); + async (data: LoginOAuthWebOptions) => { + const { authOptions, authCode } = await performGitHubWebOAuth(data); const token = await exchangeAuthCodeForAccessToken(authCode, authOptions); const updatedAuth = await addAccount( diff --git a/src/renderer/routes/LoginWithOAuthApp.tsx b/src/renderer/routes/LoginWithOAuthApp.tsx index ac5674194..599e53c87 100644 --- a/src/renderer/routes/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/LoginWithOAuthApp.tsx @@ -28,7 +28,7 @@ import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; import type { ClientID, ClientSecret, Hostname, Token } from '../types'; -import type { LoginOAuthAppOptions } from '../utils/auth/types'; +import type { LoginOAuthWebOptions } from '../utils/auth/types'; import { getNewOAuthAppURL, @@ -116,7 +116,7 @@ export const LoginWithOAuthAppRoute: FC = () => { const verifyLoginCredentials = useCallback( async (data: IFormData) => { try { - await loginWithOAuthApp(data as LoginOAuthAppOptions); + await loginWithOAuthApp(data as LoginOAuthWebOptions); navigate(-1); } catch (err) { rendererLogError( diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 684f73904..689f6aeda 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -10,7 +10,12 @@ export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App'; export type PlatformType = 'GitHub Cloud' | 'GitHub Enterprise Server'; -export interface LoginOAuthAppOptions { +export interface LoginOAuthDeviceOptions { + hostname: Hostname; + clientId: ClientID; +} + +export interface LoginOAuthWebOptions { hostname: Hostname; clientId: ClientID; clientSecret: ClientSecret; @@ -24,5 +29,5 @@ export interface LoginPersonalAccessTokenOptions { export interface AuthResponse { authMethod: AuthMethod; authCode: AuthCode; - authOptions: LoginOAuthAppOptions; + authOptions: LoginOAuthWebOptions; } diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 98fc67a8c..a4f481ef8 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -24,11 +24,23 @@ import { getNewOAuthAppURL, getNewTokenURL } from './utils'; jest.mock('@octokit/oauth-methods', () => ({ ...jest.requireActual('@octokit/oauth-methods'), + createDeviceCode: jest.fn(), + exchangeDeviceCode: jest.fn(), exchangeWebFlowCode: jest.fn(), })); -import { exchangeWebFlowCode } from '@octokit/oauth-methods'; +import { + createDeviceCode, + exchangeDeviceCode, + exchangeWebFlowCode, +} from '@octokit/oauth-methods'; +const createDeviceCodeMock = createDeviceCode as jest.MockedFunction< + typeof createDeviceCode +>; +const exchangeDeviceCodeMock = exchangeDeviceCode as jest.MockedFunction< + typeof exchangeDeviceCode +>; const exchangeWebFlowCodeMock = exchangeWebFlowCode as jest.MockedFunction< typeof exchangeWebFlowCode >; @@ -48,6 +60,47 @@ describe('renderer/utils/auth/utils.ts', () => { jest.clearAllMocks(); }); + it('should authenticate using device flow for GitHub app', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://github.com/login/device', + verification_uri_complete: + 'https://github.com/login/device?user_code=user-code', + expires_in: 900, + interval: 5, + } as any); + + exchangeDeviceCodeMock.mockResolvedValueOnce({ + authentication: { + token: 'device-token', + }, + } as any); + + const token = await authUtils.performGitHubDeviceOAuth(); + + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + request: expect.any(Function), + }); + + expect(openExternalLinkSpy).toHaveBeenCalledWith( + 'https://github.com/login/device?user_code=user-code', + ); + + expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + code: 'device-code', + interval: 5, + request: expect.any(Function), + }); + + expect(token).toBe('device-token'); + }); + it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { window.gitify.onAuthCallback = jest .fn() @@ -143,10 +196,10 @@ describe('renderer/utils/auth/utils.ts', () => { }, } as any); - const res = await authUtils.exchangeAuthCodeForAccessToken( - authCode, - Constants.DEFAULT_AUTH_OPTIONS, - ); + const res = await authUtils.exchangeAuthCodeForAccessToken(authCode, { + ...Constants.OAUTH_DEVICE_FLOW, + clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + }); expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ clientType: 'oauth-app', @@ -157,6 +210,12 @@ describe('renderer/utils/auth/utils.ts', () => { }); expect(res).toBe('this-is-a-token'); }); + + it('should throw when client secret is missing', async () => { + await expect( + async () => await authUtils.exchangeAuthCodeForAccessToken(authCode), + ).rejects.toThrow('clientSecret is required to exchange an auth code'); + }); }); describe('addAccount', () => { @@ -475,7 +534,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should use default hostname if no accounts', () => { expect(authUtils.getPrimaryAccountHostname({ accounts: [] })).toBe( - Constants.DEFAULT_AUTH_OPTIONS.hostname, + Constants.OAUTH_DEVICE_FLOW.hostname, ); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 6246a707e..ce4bf591c 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -1,4 +1,6 @@ import { + createDeviceCode, + exchangeDeviceCode, exchangeWebFlowCode, getWebFlowAuthorizationUrl, } from '@octokit/oauth-methods'; @@ -19,7 +21,7 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod, AuthResponse, LoginOAuthAppOptions } from './types'; +import type { AuthMethod, AuthResponse, LoginOAuthWebOptions } from './types'; import { fetchAuthenticatedUserDetails } from '../api/client'; import { getGitHubAuthBaseUrl } from '../api/utils'; @@ -27,8 +29,8 @@ import { encryptValue, openExternalLink } from '../comms'; import { getPlatformFromHostname } from '../helpers'; import { rendererLogError, rendererLogInfo, rendererLogWarn } from '../logger'; -export function performGitHubOAuth( - authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, +export function performGitHubWebOAuth( + authOptions: LoginOAuthWebOptions, ): Promise { return new Promise((resolve, reject) => { const { url } = getWebFlowAuthorizationUrl({ @@ -80,10 +82,43 @@ export function performGitHubOAuth( }); } +export async function performGitHubDeviceOAuth(): Promise { + const deviceCode = await createDeviceCode({ + clientType: 'oauth-app', + clientId: Constants.OAUTH_DEVICE_FLOW.clientId, + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl( + Constants.OAUTH_DEVICE_FLOW.hostname, + ).toString(), + }), + }); + + openExternalLink(deviceCode.data.verification_uri as Link); + + const { authentication } = await exchangeDeviceCode({ + clientType: 'oauth-app', + clientId: Constants.OAUTH_DEVICE_FLOW.clientId, + code: deviceCode.data.device_code, + interval: deviceCode.data.interval, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl( + Constants.OAUTH_DEVICE_FLOW.hostname, + ).toString(), + }), + }); + + return authentication.token as Token; +} + export async function exchangeAuthCodeForAccessToken( authCode: AuthCode, - authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, + 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, @@ -278,7 +313,7 @@ export function getAccountUUID(account: Account): string { * Return the primary (first) account hostname */ export function getPrimaryAccountHostname(auth: AuthState) { - return auth.accounts[0]?.hostname ?? Constants.DEFAULT_AUTH_OPTIONS.hostname; + return auth.accounts[0]?.hostname ?? Constants.OAUTH_DEVICE_FLOW.hostname; } export function hasAccounts(auth: AuthState) { diff --git a/src/renderer/utils/helpers.ts b/src/renderer/utils/helpers.ts index 04788b63a..334c6def6 100644 --- a/src/renderer/utils/helpers.ts +++ b/src/renderer/utils/helpers.ts @@ -14,13 +14,13 @@ import { rendererLogError } from './logger'; import { createNotificationHandler } from './notifications/handlers'; export function getPlatformFromHostname(hostname: string): PlatformType { - return hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname) + return hostname.endsWith(Constants.OAUTH_DEVICE_FLOW.hostname) ? 'GitHub Cloud' : 'GitHub Enterprise Server'; } export function isEnterpriseServerHost(hostname: Hostname): boolean { - return !hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname); + return !hostname.endsWith(Constants.OAUTH_DEVICE_FLOW.hostname); } export function generateNotificationReferrerId( diff --git a/src/renderer/utils/storage.test.ts b/src/renderer/utils/storage.test.ts index f4e69377b..6d38df7d8 100644 --- a/src/renderer/utils/storage.test.ts +++ b/src/renderer/utils/storage.test.ts @@ -13,7 +13,7 @@ describe('renderer/utils/storage.ts', () => { auth: { accounts: [ { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, platform: 'GitHub Cloud', method: 'Personal Access Token', token: '123-456' as Token, @@ -28,7 +28,7 @@ describe('renderer/utils/storage.ts', () => { expect(result.auth.accounts).toEqual([ { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, platform: 'GitHub Cloud', method: 'Personal Access Token', token: '123-456' as Token, @@ -55,7 +55,7 @@ describe('renderer/utils/storage.ts', () => { auth: { accounts: [ { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, platform: 'GitHub Cloud', method: 'Personal Access Token', token: '123-456' as Token, From fb2cf1e5c10c5fc25306eecb7f4629dbea29ad4b Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 20 Jan 2026 11:50:38 -0500 Subject: [PATCH 02/14] refactor(auth): device code flow Signed-off-by: Adam Setch --- src/renderer/App.tsx | 5 + src/renderer/__helpers__/test-utils.tsx | 3 + src/renderer/context/App.tsx | 71 +++++- src/renderer/routes/Login.tsx | 21 +- .../routes/LoginWithDeviceFlow.test.tsx | 96 ++++++++ src/renderer/routes/LoginWithDeviceFlow.tsx | 231 ++++++++++++++++++ src/renderer/utils/auth/types.ts | 10 + src/renderer/utils/auth/utils.test.ts | 49 ++-- src/renderer/utils/auth/utils.ts | 83 +++++-- src/renderer/utils/comms.ts | 4 +- 10 files changed, 509 insertions(+), 64 deletions(-) create mode 100644 src/renderer/routes/LoginWithDeviceFlow.test.tsx create mode 100644 src/renderer/routes/LoginWithDeviceFlow.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d652819ae..541344f66 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { AppProvider } from './context/App'; import { AccountsRoute } from './routes/Accounts'; import { FiltersRoute } from './routes/Filters'; import { LoginRoute } from './routes/Login'; +import { LoginWithDeviceFlowRoute } from './routes/LoginWithDeviceFlow'; import { LoginWithOAuthAppRoute } from './routes/LoginWithOAuthApp'; import { LoginWithPersonalAccessTokenRoute } from './routes/LoginWithPersonalAccessToken'; import { NotificationsRoute } from './routes/Notifications'; @@ -78,6 +79,10 @@ export const App = () => { path="/accounts" /> } path="/login" /> + } + path="/login-device-flow" + /> } path="/login-personal-access-token" diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 6a4a8cbdf..1a35488b2 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -42,6 +42,9 @@ export function AppContextProvider({ // Default mock implementations for all required methods loginWithGitHubApp: jest.fn(), + startGitHubDeviceFlow: jest.fn(), + pollGitHubDeviceFlow: jest.fn(), + completeGitHubDeviceLogin: jest.fn(), loginWithOAuthApp: jest.fn(), loginWithPersonalAccessToken: jest.fn(), logoutFromAccount: jest.fn(), diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 2092cd35e..43037dea5 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -25,6 +25,7 @@ import type { FilterSettingsValue, GitifyError, GitifyNotification, + Hostname, SettingsState, SettingsValue, Status, @@ -32,6 +33,7 @@ import type { } from '../types'; import { FetchType } from '../types'; import type { + DeviceFlowSession, LoginOAuthWebOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -42,10 +44,11 @@ import { exchangeAuthCodeForAccessToken, getAccountUUID, hasAccounts, - performGitHubDeviceOAuth, performGitHubWebOAuth, + pollGitHubDeviceFlow, refreshAccount, removeAccount, + startGitHubDeviceFlow, } from '../utils/auth/utils'; import { decryptValue, @@ -76,6 +79,12 @@ export interface AppContextState { auth: AuthState; isLoggedIn: boolean; loginWithGitHubApp: () => Promise; + startGitHubDeviceFlow: () => Promise; + pollGitHubDeviceFlow: (session: DeviceFlowSession) => Promise; + completeGitHubDeviceLogin: ( + token: Token, + hostname?: Hostname, + ) => Promise; loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: ( data: LoginPersonalAccessTokenOptions, @@ -396,17 +405,61 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { return hasAccounts(auth); }, [auth]); + /** + * Start a GitHub device flow session. + */ + const startGitHubDeviceFlowWithDefaults = useCallback( + async () => await startGitHubDeviceFlow(), + [], + ); + + /** + * Poll GitHub device flow session for completion. + */ + const pollGitHubDeviceFlowWithSession = useCallback( + async (session: DeviceFlowSession) => await pollGitHubDeviceFlow(session), + [], + ); + + /** + * Persist GitHub app login after device flow completes. + */ + const completeGitHubDeviceLogin = useCallback( + async ( + token: Token, + hostname: Hostname = Constants.OAUTH_DEVICE_FLOW.hostname, + ) => { + const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + + persistAuth(updatedAuth); + }, + [auth, persistAuth], + ); + /** * Login with GitHub App using device flow so the client secret is never bundled or persisted. */ const loginWithGitHubApp = useCallback(async () => { - const token = await performGitHubDeviceOAuth(); - const hostname = Constants.OAUTH_DEVICE_FLOW.hostname; + const session = await startGitHubDeviceFlowWithDefaults(); + const intervalMs = Math.max(5000, session.intervalSeconds * 1000); - const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + while (Date.now() < session.expiresAt) { + const token = await pollGitHubDeviceFlowWithSession(session); - persistAuth(updatedAuth); - }, [auth, persistAuth]); + if (token) { + await completeGitHubDeviceLogin(token, session.hostname); + return; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Device code expired before authorization completed'); + }, [ + startGitHubDeviceFlowWithDefaults, + pollGitHubDeviceFlowWithSession, + completeGitHubDeviceLogin, + ]); /** * Login with custom GitHub OAuth App. @@ -487,6 +540,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { auth, isLoggedIn, loginWithGitHubApp, + startGitHubDeviceFlow: startGitHubDeviceFlowWithDefaults, + pollGitHubDeviceFlow: pollGitHubDeviceFlowWithSession, + completeGitHubDeviceLogin, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, @@ -517,6 +573,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { auth, isLoggedIn, loginWithGitHubApp, + startGitHubDeviceFlowWithDefaults, + pollGitHubDeviceFlowWithSession, + completeGitHubDeviceLogin, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index c8dbffda6..711ce3007 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useEffect } from 'react'; +import { type FC, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; @@ -12,31 +12,18 @@ import { Centered } from '../components/layout/Centered'; import { Size } from '../types'; import { showWindow } from '../utils/comms'; -import { rendererLogError } from '../utils/logger'; export const LoginRoute: FC = () => { const navigate = useNavigate(); - const { loginWithGitHubApp, isLoggedIn } = useAppContext(); + const { isLoggedIn } = useAppContext(); useEffect(() => { if (isLoggedIn) { showWindow(); navigate('/', { replace: true }); } - }, [isLoggedIn]); - - const loginUser = useCallback(async () => { - try { - await loginWithGitHubApp(); - } catch (err) { - rendererLogError( - 'loginWithGitHubApp', - 'failed to login with GitHub', - err, - ); - } - }, [loginWithGitHubApp]); + }, [isLoggedIn, navigate]); return ( @@ -54,7 +41,7 @@ export const LoginRoute: FC = () => { + + {session && ( + + )} + + + ); +}; diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 689f6aeda..71a7fa324 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -21,6 +21,16 @@ export interface LoginOAuthWebOptions { clientSecret: ClientSecret; } +export interface DeviceFlowSession { + hostname: Hostname; + clientId: ClientID; + deviceCode: string; + userCode: string; + verificationUri: string; + intervalSeconds: number; + expiresAt: number; +} + export interface LoginPersonalAccessTokenOptions { hostname: Hostname; token: Token; diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index a4f481ef8..3fc7b92ec 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -14,7 +14,7 @@ import type { Hostname, Token, } from '../../types'; -import type { AuthMethod } from './types'; +import type { AuthMethod, LoginOAuthWebOptions } from './types'; import * as comms from '../../utils/comms'; import * as apiClient from '../api/client'; @@ -50,6 +50,12 @@ describe('renderer/utils/auth/utils.ts', () => { configureAxiosHttpAdapterForNock(); }); + const webAuthOptions: LoginOAuthWebOptions = { + hostname: 'github.com' as Hostname, + clientId: 'FAKE_CLIENT_ID_123' as ClientID, + clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + }; + describe('authGitHub', () => { jest.spyOn(logger, 'rendererLogInfo').mockImplementation(); const openExternalLinkSpy = jest @@ -62,20 +68,20 @@ describe('renderer/utils/auth/utils.ts', () => { it('should authenticate using device flow for GitHub app', async () => { createDeviceCodeMock.mockResolvedValueOnce({ - device_code: 'device-code', - user_code: 'user-code', - verification_uri: 'https://github.com/login/device', - verification_uri_complete: - 'https://github.com/login/device?user_code=user-code', - expires_in: 900, - interval: 5, - } as any); + data: { + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + } as unknown as Awaited>); exchangeDeviceCodeMock.mockResolvedValueOnce({ authentication: { token: 'device-token', }, - } as any); + } as unknown as Awaited>); const token = await authUtils.performGitHubDeviceOAuth(); @@ -101,14 +107,14 @@ describe('renderer/utils/auth/utils.ts', () => { expect(token).toBe('device-token'); }); - it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { + it('should call performGitHubWebOAuth using gitify oauth app - success auth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://auth?code=123-456'); }); - const res = await authUtils.performGitHubOAuth(); + const res = await authUtils.performGitHubWebOAuth(webAuthOptions); expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( @@ -126,14 +132,14 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call performGitHubOAuth using custom oauth app - success oauth flow', async () => { + it('should call performGitHubWebOAuth using custom oauth app - success oauth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://oauth?code=123-456'); }); - const res = await authUtils.performGitHubOAuth({ + const res = await authUtils.performGitHubWebOAuth({ clientId: 'BYO_CLIENT_ID' as ClientID, clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret, hostname: 'my.git.com' as Hostname, @@ -155,7 +161,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call performGitHubOAuth - failure', async () => { + it('should call performGitHubWebOAuth - failure', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { @@ -165,7 +171,7 @@ describe('renderer/utils/auth/utils.ts', () => { }); await expect( - async () => await authUtils.performGitHubOAuth(), + async () => await authUtils.performGitHubWebOAuth(webAuthOptions), ).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", @@ -194,11 +200,10 @@ describe('renderer/utils/auth/utils.ts', () => { authentication: { token: 'this-is-a-token', }, - } as any); + } as unknown as Awaited>); const res = await authUtils.exchangeAuthCodeForAccessToken(authCode, { - ...Constants.OAUTH_DEVICE_FLOW, - clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + ...webAuthOptions, }); expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ @@ -213,7 +218,11 @@ describe('renderer/utils/auth/utils.ts', () => { it('should throw when client secret is missing', async () => { await expect( - async () => await authUtils.exchangeAuthCodeForAccessToken(authCode), + async () => + await authUtils.exchangeAuthCodeForAccessToken(authCode, { + ...webAuthOptions, + clientSecret: undefined as unknown as ClientSecret, + }), ).rejects.toThrow('clientSecret is required to exchange an auth code'); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index ce4bf591c..b233aafb0 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -21,7 +21,13 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod, AuthResponse, LoginOAuthWebOptions } from './types'; +import type { + AuthMethod, + AuthResponse, + DeviceFlowSession, + LoginOAuthDeviceOptions, + LoginOAuthWebOptions, +} from './types'; import { fetchAuthenticatedUserDetails } from '../api/client'; import { getGitHubAuthBaseUrl } from '../api/utils'; @@ -82,33 +88,70 @@ export function performGitHubWebOAuth( }); } -export async function performGitHubDeviceOAuth(): Promise { +export async function startGitHubDeviceFlow( + authOptions: LoginOAuthDeviceOptions = Constants.OAUTH_DEVICE_FLOW, +): Promise { const deviceCode = await createDeviceCode({ - clientType: 'oauth-app', - clientId: Constants.OAUTH_DEVICE_FLOW.clientId, + clientType: 'oauth-app' as const, + clientId: authOptions.clientId, scopes: Constants.OAUTH_SCOPES.RECOMMENDED, request: request.defaults({ - baseUrl: getGitHubAuthBaseUrl( - Constants.OAUTH_DEVICE_FLOW.hostname, - ).toString(), + baseUrl: getGitHubAuthBaseUrl(authOptions.hostname).toString(), }), }); - openExternalLink(deviceCode.data.verification_uri as Link); + return { + hostname: authOptions.hostname, + clientId: authOptions.clientId, + deviceCode: deviceCode.data.device_code, + userCode: deviceCode.data.user_code, + verificationUri: deviceCode.data.verification_uri, + intervalSeconds: deviceCode.data.interval, + expiresAt: Date.now() + deviceCode.data.expires_in * 1000, + } as DeviceFlowSession; +} - const { authentication } = await exchangeDeviceCode({ - clientType: 'oauth-app', - clientId: Constants.OAUTH_DEVICE_FLOW.clientId, - code: deviceCode.data.device_code, - interval: deviceCode.data.interval, - request: request.defaults({ - baseUrl: getGitHubAuthBaseUrl( - Constants.OAUTH_DEVICE_FLOW.hostname, - ).toString(), - }), - }); +export async function pollGitHubDeviceFlow( + session: DeviceFlowSession, +): Promise { + try { + const { authentication } = await exchangeDeviceCode({ + clientType: 'oauth-app' as const, + clientId: session.clientId, + code: session.deviceCode, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl(session.hostname).toString(), + }), + }); - return authentication.token as Token; + return (authentication as { token: string }).token as Token; + } catch (err) { + const errorCode = (err as Record)?.response?.data?.error; + + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + return null; + } + + throw err; + } +} + +export async function performGitHubDeviceOAuth(): Promise { + const session = await startGitHubDeviceFlow(); + + const intervalMs = Math.max(5000, session.intervalSeconds * 1000); + + while (Date.now() < session.expiresAt) { + const token = await pollGitHubDeviceFlow(session); + + if (token) { + return token; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Device code expired before authorization completed'); } export async function exchangeAuthCodeForAccessToken( diff --git a/src/renderer/utils/comms.ts b/src/renderer/utils/comms.ts index 5ffa3b64c..e5b91d420 100644 --- a/src/renderer/utils/comms.ts +++ b/src/renderer/utils/comms.ts @@ -50,7 +50,9 @@ export function setAutoLaunch(value: boolean): void { export function setUseAlternateIdleIcon(value: boolean): void { window.gitify.tray.useAlternateIdleIcon(value); } - +export async function copyToClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} export function setUseUnreadActiveIcon(value: boolean): void { window.gitify.tray.useUnreadActiveIcon(value); } From a1ce6a49f8f8640a188b8ad9e9583b11782f36b1 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 20 Jan 2026 12:10:30 -0500 Subject: [PATCH 03/14] refactor(auth): device code flow Signed-off-by: Adam Setch --- src/renderer/utils/auth/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index b233aafb0..96b5d9909 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -124,7 +124,7 @@ export async function pollGitHubDeviceFlow( }), }); - return (authentication as { token: string }).token as Token; + return authentication.token as Token; } catch (err) { const errorCode = (err as Record)?.response?.data?.error; From 4ffe084e1fcdb4e5880d0daf5285c635132098cd Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 24 Jan 2026 11:37:18 -0500 Subject: [PATCH 04/14] feat: device code flow Signed-off-by: Adam Setch --- .github/workflows/publish.yml | 1 - CONTRIBUTING.md | 2 +- config/webpack.config.renderer.base.ts | 1 - src/renderer/__helpers__/jest.setup.env.ts | 1 - src/renderer/__helpers__/test-utils.tsx | 1 - src/renderer/context/App.test.tsx | 64 ++++++++++--------- src/renderer/context/App.tsx | 28 -------- src/renderer/routes/Accounts.test.tsx | 8 +-- src/renderer/routes/Accounts.tsx | 30 ++++----- src/renderer/routes/Login.test.tsx | 6 +- src/renderer/routes/Login.tsx | 4 +- .../routes/LoginWithDeviceFlow.test.tsx | 22 ++++++- src/renderer/routes/LoginWithDeviceFlow.tsx | 41 ++++++++---- src/renderer/utils/auth/utils.test.ts | 5 -- src/renderer/utils/auth/utils.ts | 3 +- 15 files changed, 105 insertions(+), 112 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 877104a21..8af83e658 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,7 +41,6 @@ jobs: run: pnpm build env: OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} - OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Package and publish for ${{ matrix.platform }} run: ${{ matrix.package-cmd }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f5c7b97a..70202a52c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ pnpm install > [!TIP] > _Optional: If you prefer to use your own OAuth credentials, you can do so by passing them as environment variables when bundling the app. This is optional as the app has some default "development" keys (use at your own discretion)._ > ```shell -> OAUTH_CLIENT_ID="123" OAUTH_CLIENT_SECRET="456789" pnpm build +> OAUTH_CLIENT_ID="123" pnpm build > ``` Copy the `.env.template` to `.env` and add update `GITHUB_TOKEN` with a GitHub Personal Access Token. This is used for fetching the latest GitHub GraphQL API schema for `graphql-codegen`. diff --git a/config/webpack.config.renderer.base.ts b/config/webpack.config.renderer.base.ts index 3e016d01c..a7e8d9c20 100644 --- a/config/webpack.config.renderer.base.ts +++ b/config/webpack.config.renderer.base.ts @@ -58,7 +58,6 @@ const configuration: webpack.Configuration = { // Development Keys - See CONTRIBUTING.md new webpack.EnvironmentPlugin({ OAUTH_CLIENT_ID: 'Ov23liQIkFs5ehQLNzHF', - OAUTH_CLIENT_SECRET: '404b80632292e18419dbd2a6ed25976856e95255', }), // Extract CSS into a separate file diff --git a/src/renderer/__helpers__/jest.setup.env.ts b/src/renderer/__helpers__/jest.setup.env.ts index f70f10b5c..fa815fa2a 100644 --- a/src/renderer/__helpers__/jest.setup.env.ts +++ b/src/renderer/__helpers__/jest.setup.env.ts @@ -8,5 +8,4 @@ export default () => { // Mock OAuth client ID and secret process.env.OAUTH_CLIENT_ID = 'FAKE_CLIENT_ID_123'; - process.env.OAUTH_CLIENT_SECRET = 'FAKE_CLIENT_SECRET_123'; }; diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 44d34f5db..463ed1d73 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -38,7 +38,6 @@ function AppContextProvider({ children, value = {} }: AppContextProviderProps) { globalError: null, // Default mock implementations for all required methods - loginWithGitHubApp: jest.fn(), startGitHubDeviceFlow: jest.fn(), pollGitHubDeviceFlow: jest.fn(), completeGitHubDeviceLogin: jest.fn(), diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index 106beded3..a63d6f9b2 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -3,15 +3,24 @@ import { act, fireEvent, waitFor } from '@testing-library/react'; import { renderWithAppContext } from '../__helpers__/test-utils'; import { mockGitifyNotification } from '../__mocks__/notifications-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; +import { mockRawUser } from '../utils/api/__mocks__/response-mocks'; import { Constants } from '../constants'; import { useAppContext } from '../hooks/useAppContext'; import { useNotifications } from '../hooks/useNotifications'; -import type { AuthState, SettingsState } from '../types'; - -// import * as apiRequests from '../utils/api/request'; +import type { + AuthState, + ClientID, + ClientSecret, + Hostname, + SettingsState, + Token, +} from '../types'; +import type { GetAuthenticatedUserResponse } from '../utils/api/types'; + +import * as apiClient from '../utils/api/client'; import * as notifications from '../utils/notifications/notifications'; import * as storage from '../utils/storage'; import * as tray from '../utils/tray'; @@ -64,6 +73,8 @@ describe('renderer/context/App.tsx', () => { .spyOn(storage, 'saveState') .mockImplementation(jest.fn()); + const mockAuthenticatedResponse = mockRawUser('authenticated-user'); + beforeEach(() => { jest.useFakeTimers(); (useNotifications as jest.Mock).mockReturnValue({ @@ -184,9 +195,21 @@ describe('renderer/context/App.tsx', () => { }); describe('authentication methods', () => { - it('should call loginWithGitHubApp', async () => { - const { button } = renderContextButton('loginWithGitHubApp'); + jest.spyOn(apiClient, 'fetchAuthenticatedUserDetails').mockResolvedValue({ + status: 200, + url: 'https://api.github.com/user', + data: mockAuthenticatedResponse as GetAuthenticatedUserResponse, + headers: { + 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED.join(', '), + }, + }); + it('should call loginWithOAuthApp', async () => { + const { button } = renderContextButton('loginWithOAuthApp', { + hostname: 'github.com' as Hostname, + clientId: 'FAKE_CLIENT_ID_123' as ClientID, + clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + }); fireEvent.click(button); await waitFor(() => @@ -194,8 +217,11 @@ describe('renderer/context/App.tsx', () => { ); }); - it('should call loginWithOAuthApp', async () => { - const { button } = renderContextButton('loginWithOAuthApp'); + it('should call loginWithPersonalAccessToken', async () => { + const { button } = renderContextButton('loginWithPersonalAccessToken', { + hostname: 'github.com' as Hostname, + token: '123-456' as Token, + }); fireEvent.click(button); @@ -203,30 +229,6 @@ describe('renderer/context/App.tsx', () => { expect(mockFetchNotifications).toHaveBeenCalledTimes(1), ); }); - - // it('should call loginWithPersonalAccessToken', async () => { - // const performAuthenticatedRESTRequestSpy = jest - // .spyOn(apiRequests, 'performAuthenticatedRESTRequest') - // .mockResolvedValueOnce(null); - - // const { button } = renderContextButton('loginWithPersonalAccessToken', { - // hostname: 'github.com' as Hostname, - // token: '123-456' as Token, - // }); - - // fireEvent.click(button); - - // await waitFor(() => - // expect(mockFetchNotifications).toHaveBeenCalledTimes(1), - // ); - - // expect(performAuthenticatedRESTRequestSpy).toHaveBeenCalledTimes(1); - // expect(performAuthenticatedRESTRequestSpy).toHaveBeenCalledWith( - // 'HEAD', - // 'https://api.github.com/notifications', - // 'encrypted', - // ); - // }); }); describe('settings methods', () => { diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index c8528de3d..caf110d50 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -79,7 +79,6 @@ import { export interface AppContextState { auth: AuthState; isLoggedIn: boolean; - loginWithGitHubApp: () => Promise; startGitHubDeviceFlow: () => Promise; pollGitHubDeviceFlow: (session: DeviceFlowSession) => Promise; completeGitHubDeviceLogin: ( @@ -437,31 +436,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [auth, persistAuth], ); - /** - * Login with GitHub App using device flow so the client secret is never bundled or persisted. - */ - const loginWithGitHubApp = useCallback(async () => { - const session = await startGitHubDeviceFlowWithDefaults(); - const intervalMs = Math.max(5000, session.intervalSeconds * 1000); - - while (Date.now() < session.expiresAt) { - const token = await pollGitHubDeviceFlowWithSession(session); - - if (token) { - await completeGitHubDeviceLogin(token, session.hostname); - return; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error('Device code expired before authorization completed'); - }, [ - startGitHubDeviceFlowWithDefaults, - pollGitHubDeviceFlowWithSession, - completeGitHubDeviceLogin, - ]); - /** * Login with custom GitHub OAuth App. */ @@ -546,7 +520,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { () => ({ auth, isLoggedIn, - loginWithGitHubApp, startGitHubDeviceFlow: startGitHubDeviceFlowWithDefaults, pollGitHubDeviceFlow: pollGitHubDeviceFlowWithSession, completeGitHubDeviceLogin, @@ -579,7 +552,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [ auth, isLoggedIn, - loginWithGitHubApp, startGitHubDeviceFlowWithDefaults, pollGitHubDeviceFlowWithSession, completeGitHubDeviceLogin, diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 96c5f9702..b34dd3a91 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -198,19 +198,19 @@ describe('renderer/routes/Accounts.tsx', () => { describe('Add new accounts', () => { it('should show login with github app', async () => { - const loginWithGitHubAppMock = jest.fn(); - await act(async () => { renderWithAppContext(, { auth: { accounts: [mockOAuthAccount] }, - loginWithGitHubApp: loginWithGitHubAppMock, }); }); await userEvent.click(screen.getByTestId('account-add-new')); await userEvent.click(screen.getByTestId('account-add-github')); - expect(loginWithGitHubAppMock).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/login-device-flow', { + replace: true, + }); }); it('should show login with personal access token', async () => { diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 73277cc10..8d9514ce6 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -43,14 +43,12 @@ import { openDeveloperSettings, openHost, } from '../utils/links'; -import { rendererLogError } from '../utils/logger'; import { saveState } from '../utils/storage'; export const AccountsRoute: FC = () => { const navigate = useNavigate(); - const { auth, settings, loginWithGitHubApp, logoutFromAccount } = - useAppContext(); + const { auth, settings, logoutFromAccount } = useAppContext(); const [loadingStates, setLoadingStates] = useState>( {}, @@ -64,13 +62,13 @@ export const AccountsRoute: FC = () => { [logoutFromAccount], ); - const setAsPrimaryAccount = useCallback((account: Account) => { + const setAsPrimaryAccount = (account: Account) => { auth.accounts = [account, ...auth.accounts.filter((a) => a !== account)]; saveState({ auth, settings }); navigate('/accounts', { replace: true }); - }, []); + }; - const handleRefresh = useCallback(async (account: Account) => { + const handleRefresh = async (account: Account) => { const accountUUID = getAccountUUID(account); setLoadingStates((prev) => ({ @@ -91,23 +89,19 @@ export const AccountsRoute: FC = () => { [accountUUID]: false, })); }, 500); - }, []); + }; - const loginWithGitHub = useCallback(async () => { - try { - await loginWithGitHubApp(); - } catch (err) { - rendererLogError('loginWithGitHub', 'failed to login with GitHub', err); - } - }, []); + const loginWithGitHub = async () => { + return navigate('/login-device-flow', { replace: true }); + }; - const loginWithPersonalAccessToken = useCallback(() => { + const loginWithPersonalAccessToken = () => { return navigate('/login-personal-access-token', { replace: true }); - }, []); + }; - const loginWithOAuthApp = useCallback(() => { + const loginWithOAuthApp = () => { return navigate('/login-oauth-app', { replace: true }); - }, []); + }; return ( diff --git a/src/renderer/routes/Login.test.tsx b/src/renderer/routes/Login.test.tsx index 3f1ac0f74..23e82af01 100644 --- a/src/renderer/routes/Login.test.tsx +++ b/src/renderer/routes/Login.test.tsx @@ -38,16 +38,14 @@ describe('renderer/routes/Login.tsx', () => { }); it('should login with github', async () => { - const loginWithGitHubAppMock = jest.fn(); - renderWithAppContext(, { isLoggedIn: false, - loginWithGitHubApp: loginWithGitHubAppMock, }); await userEvent.click(screen.getByTestId('login-github')); - expect(loginWithGitHubAppMock).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/login-device-flow'); }); it('should navigate to login with personal access token', async () => { diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index 711ce3007..e690b18b9 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -23,7 +23,7 @@ export const LoginRoute: FC = () => { showWindow(); navigate('/', { replace: true }); } - }, [isLoggedIn, navigate]); + }, [isLoggedIn]); return ( @@ -41,7 +41,7 @@ export const LoginRoute: FC = () => { - {session && ( + {/* {session && ( - )} + )} */} ); diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 5d8dfc762..972a7dc94 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -88,15 +88,10 @@ describe('renderer/utils/auth/utils.ts', () => { request: expect.any(Function), }); - expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://github.com/login/device?user_code=user-code', - ); - expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ clientType: 'oauth-app', clientId: 'FAKE_CLIENT_ID_123', code: 'device-code', - interval: 5, request: expect.any(Function), }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index bfdd96ef3..4d7d8044a 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -127,7 +127,8 @@ export async function pollGitHubDeviceFlow( return authentication.token as Token; } catch (err) { - const errorCode = (err as Record)?.response?.data?.error; + const errorCode = (err as { response?: { data?: { error?: string } } }) + ?.response?.data?.error; if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { return null; From c55b1a9f97f0f642a89ffd894df4af171d16f3c9 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 24 Jan 2026 12:37:25 -0500 Subject: [PATCH 05/14] update avatar Signed-off-by: Adam Setch --- src/renderer/utils/api/__mocks__/response-mocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/api/__mocks__/response-mocks.ts b/src/renderer/utils/api/__mocks__/response-mocks.ts index a12521776..e81dc9c1c 100644 --- a/src/renderer/utils/api/__mocks__/response-mocks.ts +++ b/src/renderer/utils/api/__mocks__/response-mocks.ts @@ -20,7 +20,7 @@ export function mockRawUser(login: string): RawUser { login, id: 1, node_id: 'MDQ6VXNlcjE=', - avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' as Link, + avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, gravatar_id: '', url: `https://api.github.com/users/${login}` as Link, html_url: `https://github.com/${login}` as Link, From 99f40af47dc33f402eb5298608d15abda75df797 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 06:49:57 -0500 Subject: [PATCH 06/14] fix tests rename device flow login functions Signed-off-by: Adam Setch --- src/renderer/context/App.tsx | 38 +++++++++++-------- .../routes/LoginWithDeviceFlow.test.tsx | 18 ++++----- src/renderer/routes/LoginWithDeviceFlow.tsx | 16 ++++---- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index caf110d50..2a1d78ecf 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -79,9 +79,11 @@ import { export interface AppContextState { auth: AuthState; isLoggedIn: boolean; - startGitHubDeviceFlow: () => Promise; - pollGitHubDeviceFlow: (session: DeviceFlowSession) => Promise; - completeGitHubDeviceLogin: ( + loginWithDeviceFlowStart: () => Promise; + loginWithDeviceFlowPoll: ( + session: DeviceFlowSession, + ) => Promise; + loginWithDeviceFlowComplete: ( token: Token, hostname?: Hostname, ) => Promise; @@ -406,25 +408,31 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [auth]); /** - * Start a GitHub device flow session. + * Login to GitHub Gitify OAuth App. + * + * Initiate device flow session. */ - const startGitHubDeviceFlowWithDefaults = useCallback( + const loginWithDeviceFlowStart = useCallback( async () => await startGitHubDeviceFlow(), [], ); /** - * Poll GitHub device flow session for completion. + * Login to GitHub Gitify OAuth App. + * + * Poll for device flow session. */ - const pollGitHubDeviceFlowWithSession = useCallback( + const loginWithDeviceFlowPoll = useCallback( async (session: DeviceFlowSession) => await pollGitHubDeviceFlow(session), [], ); /** - * Persist GitHub app login after device flow completes. + * Login to GitHub Gitify OAuth App. + * + * Finalize device flow session. */ - const completeGitHubDeviceLogin = useCallback( + const loginWithDeviceFlowComplete = useCallback( async ( token: Token, hostname: Hostname = Constants.OAUTH_DEVICE_FLOW.hostname, @@ -520,9 +528,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { () => ({ auth, isLoggedIn, - startGitHubDeviceFlow: startGitHubDeviceFlowWithDefaults, - pollGitHubDeviceFlow: pollGitHubDeviceFlowWithSession, - completeGitHubDeviceLogin, + loginWithDeviceFlowStart, + loginWithDeviceFlowPoll, + loginWithDeviceFlowComplete, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, @@ -552,9 +560,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [ auth, isLoggedIn, - startGitHubDeviceFlowWithDefaults, - pollGitHubDeviceFlowWithSession, - completeGitHubDeviceLogin, + loginWithDeviceFlowStart, + loginWithDeviceFlowPoll, + loginWithDeviceFlowComplete, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, diff --git a/src/renderer/routes/LoginWithDeviceFlow.test.tsx b/src/renderer/routes/LoginWithDeviceFlow.test.tsx index e299d97cf..133e364fd 100644 --- a/src/renderer/routes/LoginWithDeviceFlow.test.tsx +++ b/src/renderer/routes/LoginWithDeviceFlow.test.tsx @@ -25,7 +25,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); it('should render and initialize device flow', async () => { - const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({ + const loginWithDeviceFlowStartMock = jest.fn().mockResolvedValueOnce({ hostname: 'github.com', clientId: 'test-id', deviceCode: 'device-code', @@ -36,10 +36,10 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); renderWithAppContext(, { - startGitHubDeviceFlow: startGitHubDeviceFlowMock, + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); - expect(startGitHubDeviceFlowMock).toHaveBeenCalled(); + expect(loginWithDeviceFlowStartMock).toHaveBeenCalled(); await screen.findByText(/USER-1234/); expect(screen.getByText(/github.com\/login\/device/)).toBeInTheDocument(); @@ -52,7 +52,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); it('should copy user code to clipboard when clicking copy button', async () => { - const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({ + const loginWithDeviceFlowStartMock = jest.fn().mockResolvedValueOnce({ hostname: 'github.com', clientId: 'test-id', deviceCode: 'device-code', @@ -63,7 +63,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); renderWithAppContext(, { - startGitHubDeviceFlow: startGitHubDeviceFlowMock, + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); await screen.findByText(/USER-1234/); @@ -77,19 +77,19 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); it('should handle device flow errors during initialization', async () => { - const startGitHubDeviceFlowMock = jest + const loginWithDeviceFlowStartMock = jest .fn() .mockRejectedValueOnce(new Error('Network error')); renderWithAppContext(, { - startGitHubDeviceFlow: startGitHubDeviceFlowMock, + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); await screen.findByText(/Failed to start authentication/); }); it('should navigate back on cancel', async () => { - const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({ + const loginWithDeviceFlowStartMock = jest.fn().mockResolvedValueOnce({ hostname: 'github.com', clientId: 'test-id', deviceCode: 'device-code', @@ -100,7 +100,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); renderWithAppContext(, { - startGitHubDeviceFlow: startGitHubDeviceFlowMock, + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); await screen.findByText(/USER-1234/); diff --git a/src/renderer/routes/LoginWithDeviceFlow.tsx b/src/renderer/routes/LoginWithDeviceFlow.tsx index 0098f9388..9e69f10ae 100644 --- a/src/renderer/routes/LoginWithDeviceFlow.tsx +++ b/src/renderer/routes/LoginWithDeviceFlow.tsx @@ -28,9 +28,9 @@ export const LoginWithDeviceFlowRoute: FC = () => { const navigate = useNavigate(); const { - startGitHubDeviceFlow, - pollGitHubDeviceFlow, - completeGitHubDeviceLogin, + loginWithDeviceFlowStart, + loginWithDeviceFlowPoll, + loginWithDeviceFlowComplete, } = useAppContext(); const [session, setSession] = useState(null); @@ -41,7 +41,7 @@ export const LoginWithDeviceFlowRoute: FC = () => { useEffect(() => { const initializeDeviceFlow = async () => { try { - const newSession = await startGitHubDeviceFlow(); + const newSession = await loginWithDeviceFlowStart(); setSession(newSession); // Auto-copy the user code to clipboard @@ -60,7 +60,7 @@ export const LoginWithDeviceFlowRoute: FC = () => { }; initializeDeviceFlow(); - }, [startGitHubDeviceFlow]); + }, [loginWithDeviceFlowStart]); // Poll for device flow completion useEffect(() => { @@ -77,10 +77,10 @@ export const LoginWithDeviceFlowRoute: FC = () => { try { while (isActive && Date.now() < session.expiresAt) { - const token = await pollGitHubDeviceFlow(session); + const token = await loginWithDeviceFlowPoll(session); if (token && isActive) { - await completeGitHubDeviceLogin(token, session.hostname); + await loginWithDeviceFlowComplete(token, session.hostname); navigate(-1); return; } @@ -117,7 +117,7 @@ export const LoginWithDeviceFlowRoute: FC = () => { clearTimeout(timeoutId); } }; - }, [session, pollGitHubDeviceFlow, completeGitHubDeviceLogin]); + }, [session, loginWithDeviceFlowPoll, loginWithDeviceFlowComplete]); const handleCopyUserCode = useCallback(async () => { if (session?.userCode) { From d2cb5a6f0f53900adce1d16659a0513ba7e68bed Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 07:06:56 -0500 Subject: [PATCH 07/14] remove authentication tests Signed-off-by: Adam Setch --- src/renderer/context/App.test.tsx | 52 ++----------------------------- 1 file changed, 2 insertions(+), 50 deletions(-) diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index a63d6f9b2..ac1a41590 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -10,17 +10,8 @@ import { Constants } from '../constants'; import { useAppContext } from '../hooks/useAppContext'; import { useNotifications } from '../hooks/useNotifications'; -import type { - AuthState, - ClientID, - ClientSecret, - Hostname, - SettingsState, - Token, -} from '../types'; -import type { GetAuthenticatedUserResponse } from '../utils/api/types'; - -import * as apiClient from '../utils/api/client'; +import type { AuthState, SettingsState } from '../types'; + import * as notifications from '../utils/notifications/notifications'; import * as storage from '../utils/storage'; import * as tray from '../utils/tray'; @@ -73,8 +64,6 @@ describe('renderer/context/App.tsx', () => { .spyOn(storage, 'saveState') .mockImplementation(jest.fn()); - const mockAuthenticatedResponse = mockRawUser('authenticated-user'); - beforeEach(() => { jest.useFakeTimers(); (useNotifications as jest.Mock).mockReturnValue({ @@ -194,43 +183,6 @@ describe('renderer/context/App.tsx', () => { }); }); - describe('authentication methods', () => { - jest.spyOn(apiClient, 'fetchAuthenticatedUserDetails').mockResolvedValue({ - status: 200, - url: 'https://api.github.com/user', - data: mockAuthenticatedResponse as GetAuthenticatedUserResponse, - headers: { - 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED.join(', '), - }, - }); - - it('should call loginWithOAuthApp', async () => { - const { button } = renderContextButton('loginWithOAuthApp', { - hostname: 'github.com' as Hostname, - clientId: 'FAKE_CLIENT_ID_123' as ClientID, - clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, - }); - fireEvent.click(button); - - await waitFor(() => - expect(mockFetchNotifications).toHaveBeenCalledTimes(1), - ); - }); - - it('should call loginWithPersonalAccessToken', async () => { - const { button } = renderContextButton('loginWithPersonalAccessToken', { - hostname: 'github.com' as Hostname, - token: '123-456' as Token, - }); - - fireEvent.click(button); - - await waitFor(() => - expect(mockFetchNotifications).toHaveBeenCalledTimes(1), - ); - }); - }); - describe('settings methods', () => { const saveStateSpy = jest .spyOn(storage, 'saveState') From e72f33f47424939c15fef904986986330f1f490c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 08:24:59 -0500 Subject: [PATCH 08/14] fix tests Signed-off-by: Adam Setch --- src/renderer/context/App.test.tsx | 239 ++++++++++++++++++++---------- 1 file changed, 161 insertions(+), 78 deletions(-) diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index ac1a41590..1793d2c8d 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -1,17 +1,26 @@ -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { renderWithAppContext } from '../__helpers__/test-utils'; +import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; import { mockGitifyNotification } from '../__mocks__/notifications-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; -import { mockRawUser } from '../utils/api/__mocks__/response-mocks'; import { Constants } from '../constants'; import { useAppContext } from '../hooks/useAppContext'; import { useNotifications } from '../hooks/useNotifications'; -import type { AuthState, SettingsState } from '../types'; - +import type { + AuthState, + ClientID, + ClientSecret, + Hostname, + SettingsState, + Token, +} from '../types'; +import type { DeviceFlowSession } from '../utils/auth/types'; + +import * as authUtils from '../utils/auth/utils'; import * as notifications from '../utils/notifications/notifications'; import * as storage from '../utils/storage'; import * as tray from '../utils/tray'; @@ -20,45 +29,30 @@ import { defaultSettings } from './defaults'; jest.mock('../hooks/useNotifications'); -// Helper to render a button that calls a context method when clicked -const renderContextButton = ( - contextMethodName: keyof AppContextState, - ...args: unknown[] -) => { - const TestComponent = () => { - const context = useAppContext(); - - const method = context[contextMethodName]; - return ( - - ); +// Helper to render the context +const renderWithContext = () => { + let context!: AppContextState; + + const CaptureContext = () => { + context = useAppContext(); + return null; }; - const result = renderWithAppContext( + renderWithAppContext( - + , ); - const button = result.getByTestId('context-method-button'); - return { ...result, button }; + return () => context; }; describe('renderer/context/App.tsx', () => { - const mockFetchNotifications = jest.fn(); + const fetchNotificationsMock = jest.fn(); const markNotificationsAsReadMock = jest.fn(); const markNotificationsAsDoneMock = jest.fn(); const unsubscribeNotificationMock = jest.fn(); + const removeAccountNotificationsMock = jest.fn(); const saveStateSpy = jest .spyOn(storage, 'saveState') @@ -67,10 +61,11 @@ describe('renderer/context/App.tsx', () => { beforeEach(() => { jest.useFakeTimers(); (useNotifications as jest.Mock).mockReturnValue({ - fetchNotifications: mockFetchNotifications, + fetchNotifications: fetchNotificationsMock, markNotificationsAsRead: markNotificationsAsReadMock, markNotificationsAsDone: markNotificationsAsDoneMock, unsubscribeNotification: unsubscribeNotificationMock, + removeAccountNotifications: removeAccountNotificationsMock, }); }); @@ -101,7 +96,7 @@ describe('renderer/context/App.tsx', () => { renderWithAppContext({null}); await waitFor(() => - expect(mockFetchNotifications).toHaveBeenCalledTimes(1), + expect(fetchNotificationsMock).toHaveBeenCalledTimes(1), ); act(() => { @@ -109,39 +104,40 @@ describe('renderer/context/App.tsx', () => { Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS, ); }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(2); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(2); act(() => { jest.advanceTimersByTime( Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS, ); }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(3); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(3); act(() => { jest.advanceTimersByTime( Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS, ); }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(4); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(4); }); it('should call fetchNotifications', async () => { - const { button } = renderContextButton('fetchNotifications'); - - mockFetchNotifications.mockReset(); + const getContext = renderWithContext(); + fetchNotificationsMock.mockReset(); - fireEvent.click(button); + act(() => { + getContext().fetchNotifications(); + }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(1); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(1); }); it('should call markNotificationsAsRead', async () => { - const { button } = renderContextButton('markNotificationsAsRead', [ - mockGitifyNotification, - ]); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().markNotificationsAsRead([mockGitifyNotification]); + }); expect(markNotificationsAsReadMock).toHaveBeenCalledTimes(1); expect(markNotificationsAsReadMock).toHaveBeenCalledWith( @@ -152,11 +148,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call markNotificationsAsDone', async () => { - const { button } = renderContextButton('markNotificationsAsDone', [ - mockGitifyNotification, - ]); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().markNotificationsAsDone([mockGitifyNotification]); + }); expect(markNotificationsAsDoneMock).toHaveBeenCalledTimes(1); expect(markNotificationsAsDoneMock).toHaveBeenCalledWith( @@ -167,12 +163,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call unsubscribeNotification', async () => { - const { button } = renderContextButton( - 'unsubscribeNotification', - mockGitifyNotification, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().unsubscribeNotification(mockGitifyNotification); + }); expect(unsubscribeNotificationMock).toHaveBeenCalledTimes(1); expect(unsubscribeNotificationMock).toHaveBeenCalledWith( @@ -189,13 +184,11 @@ describe('renderer/context/App.tsx', () => { .mockImplementation(jest.fn()); it('should call updateSetting', async () => { - const { button } = renderContextButton( - 'updateSetting', - 'participating', - true, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().updateSetting('participating', true); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -209,9 +202,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call resetSettings', async () => { - const { button } = renderContextButton('resetSettings'); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().resetSettings(); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -224,14 +219,11 @@ describe('renderer/context/App.tsx', () => { describe('filter methods', () => { it('should call updateFilter - checked', async () => { - const { button } = renderContextButton( - 'updateFilter', - 'filterReasons', - 'assign', - true, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().updateFilter('filterReasons', 'assign', true); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -245,14 +237,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call updateFilter - unchecked', async () => { - const { button } = renderContextButton( - 'updateFilter', - 'filterReasons', - 'assign', - false, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().updateFilter('filterReasons', 'assign', false); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -266,9 +255,11 @@ describe('renderer/context/App.tsx', () => { }); it('should clear filters back to default', async () => { - const { button } = renderContextButton('clearFilters'); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().clearFilters(); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -286,4 +277,96 @@ describe('renderer/context/App.tsx', () => { }); }); }); + + describe('authentication functions', () => { + const addAccountSpy = jest + .spyOn(authUtils, 'addAccount') + .mockImplementation(jest.fn()); + const removeAccountSpy = jest.spyOn(authUtils, 'removeAccount'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loginWithDeviceFlowStart calls startGitHubDeviceFlow', async () => { + const startGitHubDeviceFlowSpy = jest + .spyOn(authUtils, 'startGitHubDeviceFlow') + .mockImplementation(jest.fn()); + + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithDeviceFlowStart(); + }); + + expect(startGitHubDeviceFlowSpy).toHaveBeenCalled(); + }); + + it('loginWithDeviceFlowPoll calls pollGitHubDeviceFlow', async () => { + const pollGitHubDeviceFlowSpy = jest + .spyOn(authUtils, 'pollGitHubDeviceFlow') + .mockImplementation(jest.fn()); + + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithDeviceFlowPoll( + 'session' as unknown as DeviceFlowSession, + ); + }); + + expect(pollGitHubDeviceFlowSpy).toHaveBeenCalledWith('session'); + }); + + it('loginWithDeviceFlowComplete calls addAccount', async () => { + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithDeviceFlowComplete( + 'token' as Token, + 'github.com' as Hostname, + ); + }); + + expect(addAccountSpy).toHaveBeenCalledWith( + expect.anything(), + 'GitHub App', + 'token', + 'github.com', + ); + }); + + it('loginWithOAuthApp calls performGitHubWebOAuth', async () => { + const performGitHubWebOAuthSpy = jest.spyOn( + authUtils, + 'performGitHubWebOAuth', + ); + + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithOAuthApp({ + clientId: 'id' as ClientID, + clientSecret: 'secret' as ClientSecret, + hostname: 'github.com' as Hostname, + }); + }); + + expect(performGitHubWebOAuthSpy).toHaveBeenCalled(); + }); + + it('logoutFromAccount calls removeAccountNotifications, removeAccount', async () => { + const getContext = renderWithContext(); + + getContext().logoutFromAccount(mockGitHubCloudAccount); + + expect(removeAccountNotificationsMock).toHaveBeenCalledWith( + mockGitHubCloudAccount, + ); + expect(removeAccountSpy).toHaveBeenCalledWith( + expect.anything(), + mockGitHubCloudAccount, + ); + }); + }); }); From 2b40f8a132ba47f06f8c0fae17bc98584ec71755 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 08:45:33 -0500 Subject: [PATCH 09/14] remove unused callback for github app oauth web flow Signed-off-by: Adam Setch --- src/renderer/utils/auth/utils.test.ts | 25 ------------------------- src/renderer/utils/auth/utils.ts | 7 ++----- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 972a7dc94..cf2c49170 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -106,31 +106,6 @@ describe('renderer/utils/auth/utils.ts', () => { clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, }; - it('should call performGitHubWebOAuth using gitify oauth app - success auth flow', async () => { - window.gitify.onAuthCallback = jest - .fn() - .mockImplementation((callback) => { - callback('gitify://auth?code=123-456'); - }); - - const res = await authUtils.performGitHubWebOAuth(webAuthOptions); - - 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=read%3Auser%2Cnotifications%2Crepo', - ), - ); - - expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); - expect(window.gitify.onAuthCallback).toHaveBeenCalledWith( - expect.any(Function), - ); - - expect(res.authMethod).toBe('GitHub App'); - expect(res.authCode).toBe('123-456'); - }); - it('should call performGitHubWebOAuth using custom oauth app - success oauth flow', async () => { window.gitify.onAuthCallback = jest .fn() diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 4d7d8044a..96541774a 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -61,12 +61,9 @@ export function performGitHubWebOAuth( const errorDescription = url.searchParams.get('error_description'); const errorUri = url.searchParams.get('error_uri'); - if (code && (type === 'auth' || type === 'oauth')) { - const authMethod: AuthMethod = - type === 'auth' ? 'GitHub App' : 'OAuth App'; - + if (code && type === 'oauth') { resolve({ - authMethod: authMethod, + authMethod: 'OAuth App', authCode: code as AuthCode, authOptions: authOptions, }); From beb8fcd8d50504a4ea13c578f3d77cdb9d656ccd Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 09:11:07 -0500 Subject: [PATCH 10/14] remove unused callback for github app oauth web flow Signed-off-by: Adam Setch --- src/renderer/__helpers__/jest.setup.ts | 8 ++++++++ src/renderer/utils/auth/types.ts | 6 ++++++ src/renderer/utils/auth/utils.ts | 18 ++++++++++++++---- src/renderer/utils/comms.test.ts | 8 ++++++++ src/renderer/utils/comms.ts | 8 +++++--- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index c4ccc10e3..c98149063 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -70,3 +70,11 @@ globalThis.matchMedia = (query: string): MediaQueryList => ({ removeEventListener: () => {}, dispatchEvent: () => false, }); + +Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn().mockResolvedValue(undefined), + readText: jest.fn().mockResolvedValue(''), + }, + configurable: true, +}); diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index b25a7b330..44ee76ef1 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -34,6 +34,12 @@ export interface DeviceFlowSession { expiresAt: number; } +export type DeviceFlowErrorResponse = { + error: string; + error_description: string; + error_uri: string; +}; + export interface LoginPersonalAccessTokenOptions { hostname: Hostname; token: Token; diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 96541774a..f07d16dd9 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -5,6 +5,7 @@ import { getWebFlowAuthorizationUrl, } from '@octokit/oauth-methods'; import { request } from '@octokit/request'; +import { RequestError } from '@octokit/request-error'; import { format } from 'date-fns'; import semver from 'semver'; @@ -26,6 +27,7 @@ import type { import type { AuthMethod, AuthResponse, + DeviceFlowErrorResponse, DeviceFlowSession, LoginOAuthDeviceOptions, LoginOAuthWebOptions, @@ -124,13 +126,21 @@ export async function pollGitHubDeviceFlow( return authentication.token as Token; } catch (err) { - const errorCode = (err as { response?: { data?: { error?: string } } }) - ?.response?.data?.error; + if (err instanceof RequestError) { + const response = err.response.data as DeviceFlowErrorResponse; + const errorCode = response.error; - if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { - return null; + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + return null; + } } + rendererLogError( + 'pollGitHubDeviceFlow', + 'Error exchanging device code', + err, + ); + throw err; } } diff --git a/src/renderer/utils/comms.test.ts b/src/renderer/utils/comms.test.ts index 080020c98..b6f96d9b6 100644 --- a/src/renderer/utils/comms.test.ts +++ b/src/renderer/utils/comms.test.ts @@ -3,6 +3,7 @@ import { mockSettings } from '../__mocks__/state-mocks'; import { type Link, OpenPreference } from '../types'; import { + copyToClipboard, decryptValue, encryptValue, getAppVersion, @@ -156,4 +157,11 @@ describe('renderer/utils/comms.ts', () => { expect(window.gitify.tray.updateTitle).toHaveBeenCalledWith('gitify'); }); }); + + it('copy to clipboard', async () => { + copyToClipboard('some-value'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('some-value'); + }); }); diff --git a/src/renderer/utils/comms.ts b/src/renderer/utils/comms.ts index e5b91d420..836294a06 100644 --- a/src/renderer/utils/comms.ts +++ b/src/renderer/utils/comms.ts @@ -50,9 +50,7 @@ export function setAutoLaunch(value: boolean): void { export function setUseAlternateIdleIcon(value: boolean): void { window.gitify.tray.useAlternateIdleIcon(value); } -export async function copyToClipboard(text: string): Promise { - await navigator.clipboard.writeText(text); -} + export function setUseUnreadActiveIcon(value: boolean): void { window.gitify.tray.useUnreadActiveIcon(value); } @@ -80,3 +78,7 @@ export function updateTrayColor(notificationsLength: number): void { export function updateTrayTitle(title: string): void { window.gitify.tray.updateTitle(title); } + +export async function copyToClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} From 4abbf62102d470c3f0a4e0e9a68bd491ba111757 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 09:17:41 -0500 Subject: [PATCH 11/14] feat: device code flow Signed-off-by: Adam Setch --- src/renderer/__helpers__/test-utils.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 463ed1d73..7bcc2684b 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -38,9 +38,9 @@ function AppContextProvider({ children, value = {} }: AppContextProviderProps) { globalError: null, // Default mock implementations for all required methods - startGitHubDeviceFlow: jest.fn(), - pollGitHubDeviceFlow: jest.fn(), - completeGitHubDeviceLogin: jest.fn(), + loginWithDeviceFlowStart: jest.fn(), + loginWithDeviceFlowPoll: jest.fn(), + loginWithDeviceFlowComplete: jest.fn(), loginWithOAuthApp: jest.fn(), loginWithPersonalAccessToken: jest.fn(), logoutFromAccount: jest.fn(), From 1bb877e131070ccd81c5b85d61bb2ee8e3f69223 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 09:19:20 -0500 Subject: [PATCH 12/14] feat: device code flow Signed-off-by: Adam Setch --- src/renderer/__mocks__/account-mocks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 3ded02c4e..804853d81 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -23,7 +23,7 @@ export const mockPersonalAccessTokenAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.OAUTH_DEVICE_FLOW.hostname, + hostname: 'github.com' as Hostname, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -41,7 +41,7 @@ export const mockGitHubCloudAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.OAUTH_DEVICE_FLOW.hostname, + hostname: 'github.com' as Hostname, user: mockGitifyUser, version: 'latest', hasRequiredScopes: true, From 2c70e485c701301280f246f6a258e8e84e832608 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 09:22:48 -0500 Subject: [PATCH 13/14] feat: device code flow Signed-off-by: Adam Setch --- src/renderer/utils/storage.test.ts | 35 ++++-------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/src/renderer/utils/storage.test.ts b/src/renderer/utils/storage.test.ts index 6d38df7d8..4069caafb 100644 --- a/src/renderer/utils/storage.test.ts +++ b/src/renderer/utils/storage.test.ts @@ -1,9 +1,6 @@ +import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; -import { Constants } from '../constants'; - -import type { Token } from '../types'; - import { clearState, loadState, saveState } from './storage'; describe('renderer/utils/storage.ts', () => { @@ -11,30 +8,14 @@ describe('renderer/utils/storage.ts', () => { jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce( JSON.stringify({ auth: { - accounts: [ - { - hostname: Constants.OAUTH_DEVICE_FLOW.hostname, - platform: 'GitHub Cloud', - method: 'Personal Access Token', - token: '123-456' as Token, - user: null, - }, - ], + accounts: [mockGitHubCloudAccount], }, settings: { theme: 'DARK_DEFAULT' }, }), ); const result = loadState(); - expect(result.auth.accounts).toEqual([ - { - hostname: Constants.OAUTH_DEVICE_FLOW.hostname, - platform: 'GitHub Cloud', - method: 'Personal Access Token', - token: '123-456' as Token, - user: null, - }, - ]); + expect(result.auth.accounts).toEqual([mockGitHubCloudAccount]); expect(result.settings.theme).toBe('DARK_DEFAULT'); }); @@ -53,15 +34,7 @@ describe('renderer/utils/storage.ts', () => { saveState({ auth: { - accounts: [ - { - hostname: Constants.OAUTH_DEVICE_FLOW.hostname, - platform: 'GitHub Cloud', - method: 'Personal Access Token', - token: '123-456' as Token, - user: null, - }, - ], + accounts: [mockGitHubCloudAccount], }, settings: mockSettings, }); From 066012469ba6ebd3e8c81a99ee22e48ac1d5ee19 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Jan 2026 09:34:43 -0500 Subject: [PATCH 14/14] feat: device code flow Signed-off-by: Adam Setch --- src/renderer/__mocks__/account-mocks.ts | 6 +++--- src/renderer/constants.ts | 8 +++----- src/renderer/context/App.test.tsx | 5 ++--- src/renderer/context/App.tsx | 7 ++----- src/renderer/routes/LoginWithOAuthApp.tsx | 2 +- .../routes/LoginWithPersonalAccessToken.tsx | 2 +- src/renderer/utils/api/utils.test.ts | 6 ++++-- src/renderer/utils/auth/types.ts | 5 ----- src/renderer/utils/auth/utils.test.ts | 2 +- src/renderer/utils/auth/utils.ts | 15 ++++++--------- src/renderer/utils/helpers.ts | 2 +- src/renderer/utils/links.test.ts | 4 ++-- 12 files changed, 26 insertions(+), 38 deletions(-) diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 804853d81..348cb748b 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -14,7 +14,7 @@ export const mockGitHubAppAccount: Account = { platform: 'GitHub Cloud', method: 'GitHub App', token: 'token-987654321' as Token, - hostname: Constants.OAUTH_DEVICE_FLOW.hostname, + hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -23,7 +23,7 @@ export const mockPersonalAccessTokenAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -41,7 +41,7 @@ export const mockGitHubCloudAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, version: 'latest', hasRequiredScopes: true, diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 76f5594fc..6250ea0ba 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,5 +1,4 @@ import type { ClientID, Hostname, Link } from './types'; -import type { LoginOAuthDeviceOptions } from './utils/auth/types'; export const Constants = { STORAGE_KEY: 'gitify-storage', @@ -10,10 +9,9 @@ export const Constants = { ALTERNATE: ['read:user', 'notifications', 'public_repo'], }, - OAUTH_DEVICE_FLOW: { - hostname: 'github.com' as Hostname, - clientId: process.env.OAUTH_CLIENT_ID as ClientID, - } satisfies LoginOAuthDeviceOptions, + GITHUB_HOSTNAME: 'github.com' as Hostname, + + OAUTH_DEVICE_FLOW_CLIENT_ID: process.env.OAUTH_CLIENT_ID as ClientID, // GitHub Enterprise Cloud with Data Residency uses *.ghe.com domains GITHUB_ENTERPRISE_CLOUD_DATA_RESIDENCY_HOSTNAME: 'ghe.com', diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index 1793d2c8d..ed5989d6c 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -14,7 +14,6 @@ import type { AuthState, ClientID, ClientSecret, - Hostname, SettingsState, Token, } from '../types'; @@ -324,7 +323,7 @@ describe('renderer/context/App.tsx', () => { act(() => { getContext().loginWithDeviceFlowComplete( 'token' as Token, - 'github.com' as Hostname, + Constants.GITHUB_HOSTNAME, ); }); @@ -348,7 +347,7 @@ describe('renderer/context/App.tsx', () => { getContext().loginWithOAuthApp({ clientId: 'id' as ClientID, clientSecret: 'secret' as ClientSecret, - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, }); }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 2a1d78ecf..05daa5e42 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -85,7 +85,7 @@ export interface AppContextState { ) => Promise; loginWithDeviceFlowComplete: ( token: Token, - hostname?: Hostname, + hostname: Hostname, ) => Promise; loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: ( @@ -433,10 +433,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { * Finalize device flow session. */ const loginWithDeviceFlowComplete = useCallback( - async ( - token: Token, - hostname: Hostname = Constants.OAUTH_DEVICE_FLOW.hostname, - ) => { + async (token: Token, hostname: Hostname) => { const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); persistAuth(updatedAuth); diff --git a/src/renderer/routes/LoginWithOAuthApp.tsx b/src/renderer/routes/LoginWithOAuthApp.tsx index a2a6a6ace..2393ff2ae 100644 --- a/src/renderer/routes/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/LoginWithOAuthApp.tsx @@ -85,7 +85,7 @@ export const LoginWithOAuthAppRoute: FC = () => { const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); const [formData, setFormData] = useState({ - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, clientId: '' as ClientID, clientSecret: '' as ClientSecret, } as IFormData); diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.tsx index 3c1c43d9f..91a300545 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/LoginWithPersonalAccessToken.tsx @@ -78,7 +78,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); const [formData, setFormData] = useState({ - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, token: '' as Token, } as IFormData); diff --git a/src/renderer/utils/api/utils.test.ts b/src/renderer/utils/api/utils.test.ts index a35dab86a..3cf2960d9 100644 --- a/src/renderer/utils/api/utils.test.ts +++ b/src/renderer/utils/api/utils.test.ts @@ -1,3 +1,5 @@ +import { Constants } from '../../constants'; + import type { Hostname } from '../../types'; import { getGitHubAPIBaseUrl } from './utils'; @@ -5,7 +7,7 @@ import { getGitHubAPIBaseUrl } from './utils'; describe('renderer/utils/api/utils.ts', () => { describe('getGitHubAPIBaseUrl', () => { it('should generate a GitHub REST API url - non enterprise', () => { - const result = getGitHubAPIBaseUrl('github.com' as Hostname, 'rest'); + const result = getGitHubAPIBaseUrl(Constants.GITHUB_HOSTNAME, 'rest'); expect(result.toString()).toBe('https://api.github.com/'); }); @@ -27,7 +29,7 @@ describe('renderer/utils/api/utils.ts', () => { }); it('should generate a GitHub GraphQL url - non enterprise', () => { - const result = getGitHubAPIBaseUrl('github.com' as Hostname, 'graphql'); + const result = getGitHubAPIBaseUrl(Constants.GITHUB_HOSTNAME, 'graphql'); expect(result.toString()).toBe('https://api.github.com/'); }); diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 44ee76ef1..1b7be6db5 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -13,11 +13,6 @@ export type PlatformType = | 'GitHub Enterprise Server' | 'GitHub Enterprise Cloud with Data Residency'; -export interface LoginOAuthDeviceOptions { - hostname: Hostname; - clientId: ClientID; -} - export interface LoginOAuthWebOptions { hostname: Hostname; clientId: ClientID; diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index cf2c49170..6b3a19e4a 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -557,7 +557,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should use default hostname if no accounts', () => { expect(authUtils.getPrimaryAccountHostname({ accounts: [] })).toBe( - Constants.OAUTH_DEVICE_FLOW.hostname, + Constants.GITHUB_HOSTNAME, ); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index f07d16dd9..0ab0b6718 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -29,7 +29,6 @@ import type { AuthResponse, DeviceFlowErrorResponse, DeviceFlowSession, - LoginOAuthDeviceOptions, LoginOAuthWebOptions, } from './types'; @@ -88,21 +87,19 @@ export function performGitHubWebOAuth( }); } -export async function startGitHubDeviceFlow( - authOptions: LoginOAuthDeviceOptions = Constants.OAUTH_DEVICE_FLOW, -): Promise { +export async function startGitHubDeviceFlow(): Promise { const deviceCode = await createDeviceCode({ clientType: 'oauth-app' as const, - clientId: authOptions.clientId, + clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, scopes: Constants.OAUTH_SCOPES.RECOMMENDED, request: request.defaults({ - baseUrl: getGitHubAuthBaseUrl(authOptions.hostname).toString(), + baseUrl: getGitHubAuthBaseUrl(Constants.GITHUB_HOSTNAME).toString(), }), }); return { - hostname: authOptions.hostname, - clientId: authOptions.clientId, + hostname: Constants.GITHUB_HOSTNAME, + clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, deviceCode: deviceCode.data.device_code, userCode: deviceCode.data.user_code, verificationUri: deviceCode.data.verification_uri, @@ -391,7 +388,7 @@ export function getAccountUUID(account: Account): AccountUUID { * Return the primary (first) account hostname */ export function getPrimaryAccountHostname(auth: AuthState) { - return auth.accounts[0]?.hostname ?? Constants.OAUTH_DEVICE_FLOW.hostname; + return auth.accounts[0]?.hostname ?? Constants.GITHUB_HOSTNAME; } export function hasAccounts(auth: AuthState) { diff --git a/src/renderer/utils/helpers.ts b/src/renderer/utils/helpers.ts index 8d09d2dcd..d00f42d3c 100644 --- a/src/renderer/utils/helpers.ts +++ b/src/renderer/utils/helpers.ts @@ -20,7 +20,7 @@ export interface ParsedCodePart { } export function getPlatformFromHostname(hostname: string): PlatformType { - if (hostname.endsWith(Constants.OAUTH_DEVICE_FLOW.hostname)) { + if (hostname.endsWith(Constants.GITHUB_HOSTNAME)) { return 'GitHub Cloud'; } diff --git a/src/renderer/utils/links.test.ts b/src/renderer/utils/links.test.ts index 8c50feb20..e910265ec 100644 --- a/src/renderer/utils/links.test.ts +++ b/src/renderer/utils/links.test.ts @@ -4,7 +4,7 @@ import { mockGitifyNotificationUser } from '../__mocks__/user-mocks'; import { Constants } from '../constants'; -import type { GitifyRepository, Hostname, Link } from '../types'; +import type { GitifyRepository, Link } from '../types'; import * as authUtils from './auth/utils'; import * as comms from './comms'; @@ -83,7 +83,7 @@ describe('renderer/utils/links.ts', () => { }); it('openHost', () => { - openHost('github.com' as Hostname); + openHost(Constants.GITHUB_HOSTNAME); expect(openExternalLinkSpy).toHaveBeenCalledWith('https://github.com'); });