diff --git a/src/__mocks__/state-mocks.ts b/src/__mocks__/state-mocks.ts index 13dc08529..1a1fd0553 100644 --- a/src/__mocks__/state-mocks.ts +++ b/src/__mocks__/state-mocks.ts @@ -53,6 +53,14 @@ export const mockGitHubEnterpriseServerAccount: Account = { user: mockGitifyUser, }; +export const mockGitHubAppAccount: Account = { + platform: 'GitHub Cloud', + method: 'GitHub App', + token: '987654321', + hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + user: mockGitifyUser, +}; + export const mockAuth: AuthState = { accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount], }; diff --git a/src/app.tsx b/src/app.tsx index 3beb15806..c15ab937b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -9,6 +9,7 @@ import { import { Loading } from './components/Loading'; import { Sidebar } from './components/Sidebar'; import { AppContext, AppProvider } from './context/App'; +import { AccountsRoute } from './routes/Accounts'; import { LoginRoute } from './routes/Login'; import { LoginWithOAuthApp } from './routes/LoginWithOAuthApp'; import { LoginWithPersonalAccessToken } from './routes/LoginWithPersonalAccessToken'; @@ -50,7 +51,14 @@ export const App = () => { } /> - + + + + } + /> } /> void; loginWithOAuthApp: (data: LoginOAuthAppOptions) => void; loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => void; + logoutFromAccount: (account: Account) => void; logout: () => void; notifications: AccountNotifications[]; @@ -193,6 +196,15 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [auth, settings], ); + const logoutFromAccount = useCallback( + async (account: Account) => { + const updatedAuth = removeAccount(auth, account); + setAuth(updatedAuth); + saveState({ auth: updatedAuth, settings }); + }, + [auth, settings], + ); + const logout = useCallback(() => { setAuth(defaultAuth); clearState(); @@ -256,6 +268,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { loginWithGitHubApp, loginWithOAuthApp, loginWithPersonalAccessToken, + logoutFromAccount, logout, notifications, diff --git a/src/routes/Accounts.test.tsx b/src/routes/Accounts.test.tsx new file mode 100644 index 000000000..1693605ad --- /dev/null +++ b/src/routes/Accounts.test.tsx @@ -0,0 +1,279 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { ipcRenderer, shell } from 'electron'; +import { MemoryRouter } from 'react-router-dom'; +import { + mockAuth, + mockGitHubAppAccount, + mockOAuthAccount, + mockPersonalAccessTokenAccount, + mockSettings, +} from '../__mocks__/state-mocks'; +import { AppContext } from '../context/App'; +import { AccountsRoute } from './Accounts'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +describe('routes/Accounts.tsx', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('General', () => { + it('should render itself & its children', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + expect(screen.getByTestId('accounts')).toMatchSnapshot(); + }); + + it('should go back by pressing the icon', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('Go Back')); + expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); + }); + }); + + describe('Account interactions', () => { + it('open profile in external browser', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByTitle('Open Profile')); + + expect(shell.openExternal).toHaveBeenCalledTimes(1); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/octocat', + ); + }); + + it('open host in external browser', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByTitle('Open Host')); + + expect(shell.openExternal).toHaveBeenCalledTimes(1); + expect(shell.openExternal).toHaveBeenCalledWith('https://github.com'); + }); + + it('open developer settings in external browser', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByTitle('Open Developer Settings')); + + expect(shell.openExternal).toHaveBeenCalledTimes(1); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/settings/tokens', + ); + }); + + it('should logout', async () => { + const logoutFromAccountMock = jest.fn(); + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByTitle('Logout octocat')); + + expect(logoutFromAccountMock).toHaveBeenCalledTimes(1); + + expect(ipcRenderer.send).toHaveBeenCalledTimes(2); + expect(ipcRenderer.send).toHaveBeenCalledWith('update-icon'); + expect(ipcRenderer.send).toHaveBeenCalledWith('update-title', ''); + expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); + }); + }); + + describe('Add new accounts', () => { + describe('Login with Personal Access Token', () => { + it('should show login with personal access token button if not logged in', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + expect( + screen.getByTitle('Login with Personal Access Token').hidden, + ).toBe(false); + + fireEvent.click(screen.getByTitle('Login with Personal Access Token')); + expect(mockNavigate).toHaveBeenNthCalledWith( + 1, + '/login-personal-access-token', + { + replace: true, + }, + ); + }); + + it('should hide login with personal access token button if already logged in', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + expect( + screen.getByTitle('Login with Personal Access Token').hidden, + ).toBe(true); + }); + }); + + describe('Login with OAuth App', () => { + it('should show login with oauth app if not logged in', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + expect(screen.getByTitle('Login with OAuth App').hidden).toBe(false); + + fireEvent.click(screen.getByTitle('Login with OAuth App')); + expect(mockNavigate).toHaveBeenNthCalledWith(1, '/login-oauth-app', { + replace: true, + }); + }); + + it('should hide login with oauth app route if already logged in', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + expect(screen.getByTitle('Login with OAuth App').hidden).toBe(true); + }); + }); + }); +}); diff --git a/src/routes/Accounts.tsx b/src/routes/Accounts.tsx new file mode 100644 index 000000000..09c32d56b --- /dev/null +++ b/src/routes/Accounts.tsx @@ -0,0 +1,208 @@ +import { + AppsIcon, + ArrowLeftIcon, + KeyIcon, + MarkGithubIcon, + PersonIcon, + PlusIcon, + ServerIcon, + SignOutIcon, +} from '@primer/octicons-react'; + +import { type FC, useCallback, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { AppContext } from '../context/App'; + +import type { Account } from '../types'; +import { getAccountUUID, getDeveloperSettingsURL } from '../utils/auth/utils'; +import { + openExternalLink, + updateTrayIcon, + updateTrayTitle, +} from '../utils/comms'; +import { + isOAuthAppLoggedIn, + isPersonalAccessTokenLoggedIn, +} from '../utils/helpers'; + +export const AccountsRoute: FC = () => { + const { auth, logoutFromAccount } = useContext(AppContext); + const navigate = useNavigate(); + + const logoutAccount = useCallback((account: Account) => { + logoutFromAccount(account); + navigate(-1); + updateTrayIcon(); + updateTrayTitle(); + }, []); + + const openProfile = (account: Account) => { + const url = new URL(`https://${account.hostname}`); + url.pathname = account.user.login; + openExternalLink(url.toString()); + }; + + const openHost = (hostname: string) => { + openExternalLink(`https://${hostname}`); + }; + + const openDeveloperSettings = (account: Account) => { + const url = getDeveloperSettingsURL(account); + openExternalLink(url); + }; + + const loginWithPersonalAccessToken = useCallback(() => { + return navigate('/login-personal-access-token', { replace: true }); + }, []); + + const loginWithOAuthApp = useCallback(() => { + return navigate('/login-oauth-app', { replace: true }); + }, []); + + const buttonClass = + 'hover:text-gray-500 py-1 px-2 my-1 mx-2 focus:outline-none'; + + return ( +
+
+ + +

Accounts

+
+ +
+
+ {auth.accounts.map((account) => ( +
+
+ + + + +
+
+ +
+
+ ))} +
+
+ +
+
Add new account
+
+ + +
+
+
+ ); +}; diff --git a/src/routes/Settings.test.tsx b/src/routes/Settings.test.tsx index be80c903d..2f42d5ff8 100644 --- a/src/routes/Settings.test.tsx +++ b/src/routes/Settings.test.tsx @@ -1,12 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { ipcRenderer, shell } from 'electron'; import { MemoryRouter } from 'react-router-dom'; -import { - mockAuth, - mockOAuthAccount, - mockPersonalAccessTokenAccount, - mockSettings, -} from '../__mocks__/state-mocks'; +import { mockAuth, mockSettings } from '../__mocks__/state-mocks'; import { mockPlatform } from '../__mocks__/utils'; import { AppContext } from '../context/App'; import { SettingsRoute } from './Settings'; @@ -508,130 +503,21 @@ describe('routes/Settings.tsx', () => { ); }); - describe('Login with Personal Access Token', () => { - it('should show login with personal access token button if not logged in', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - expect( - screen.getByTitle('Login with Personal Access Token').hidden, - ).toBe(false); - - fireEvent.click(screen.getByTitle('Login with Personal Access Token')); - expect(mockNavigate).toHaveBeenNthCalledWith( - 1, - '/login-personal-access-token', - { - replace: true, - }, - ); - }); - - it('should hide login with personal access token button if already logged in', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - expect( - screen.getByTitle('Login with Personal Access Token').hidden, - ).toBe(true); - }); - }); - - describe('Login with OAuth App', () => { - it('should show login with oauth app if not logged in', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - expect(screen.getByTitle('Login with OAuth App').hidden).toBe(false); - - fireEvent.click(screen.getByTitle('Login with OAuth App')); - expect(mockNavigate).toHaveBeenNthCalledWith(1, '/login-oauth-app', { - replace: true, - }); - }); - - it('should hide login with oauth app route if already logged in', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - expect(screen.getByTitle('Login with OAuth App').hidden).toBe(true); - }); - }); - - it('should press the logout', async () => { - const logoutMock = jest.fn(); - await act(async () => { - render( - - - - - , - ); - }); - - fireEvent.click(screen.getByTitle('Logout')); - - expect(logoutMock).toHaveBeenCalledTimes(1); - - expect(ipcRenderer.send).toHaveBeenCalledTimes(2); - expect(ipcRenderer.send).toHaveBeenCalledWith('update-icon'); - expect(ipcRenderer.send).toHaveBeenCalledWith('update-title', ''); - expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); + it('should open account management', () => { + render( + + + + + , + ); + fireEvent.click(screen.getByTitle('Accounts')); + expect(mockNavigate).toHaveBeenCalledWith('/accounts'); }); it('should quit the app', async () => { diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index 0d91f0dc6..dec907f2b 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -3,11 +3,8 @@ import { CheckIcon, CommentIcon, IssueClosedIcon, - KeyIcon, MilestoneIcon, PersonIcon, - PlusIcon, - SignOutIcon, TagIcon, XCircleIcon, } from '@primer/octicons-react'; @@ -25,21 +22,13 @@ import { Checkbox } from '../components/fields/Checkbox'; import { RadioGroup } from '../components/fields/RadioGroup'; import { AppContext } from '../context/App'; import { Theme } from '../types'; -import { - openExternalLink, - updateTrayIcon, - updateTrayTitle, -} from '../utils/comms'; +import { openExternalLink } from '../utils/comms'; import Constants from '../utils/constants'; -import { - isOAuthAppLoggedIn, - isPersonalAccessTokenLoggedIn, -} from '../utils/helpers'; import { isLinux, isMacOS } from '../utils/platform'; import { setTheme } from '../utils/theme'; export const SettingsRoute: FC = () => { - const { auth, settings, updateSetting, logout } = useContext(AppContext); + const { settings, updateSetting } = useContext(AppContext); const navigate = useNavigate(); const [appVersion, setAppVersion] = useState(null); @@ -72,25 +61,10 @@ export const SettingsRoute: FC = () => { }); }, []); - const logoutUser = useCallback(() => { - logout(); - navigate(-1); - updateTrayIcon(); - updateTrayTitle(); - }, []); - const quitApp = useCallback(() => { ipcRenderer.send('app-quit'); }, []); - const loginWithPersonalAccessToken = useCallback(() => { - return navigate('/login-personal-access-token', { replace: true }); - }, []); - - const loginWithOAuthApp = useCallback(() => { - return navigate('/login-oauth-app', { replace: true }); - }, []); - const footerButtonClass = 'hover:text-gray-500 py-1 px-2 my-1 mx-2 focus:outline-none'; @@ -330,33 +304,12 @@ export const SettingsRoute: FC = () => { - - - - +

+ Accounts +

+ +
+
+
+
+ + + +
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+
+
+
+ Add new account +
+
+ + +
+
+ +`; diff --git a/src/routes/__snapshots__/Settings.test.tsx.snap b/src/routes/__snapshots__/Settings.test.tsx.snap index 3003202c4..4bf0d4b0f 100644 --- a/src/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/routes/__snapshots__/Settings.test.tsx.snap @@ -561,86 +561,14 @@ exports[`routes/Settings.tsx General should render itself & its children 1`] = `
- - diff --git a/src/utils/auth/utils.test.ts b/src/utils/auth/utils.test.ts index 19ab1e621..f120fd49a 100644 --- a/src/utils/auth/utils.test.ts +++ b/src/utils/auth/utils.test.ts @@ -1,6 +1,7 @@ import remote from '@electron/remote'; import type { AxiosPromise, AxiosResponse } from 'axios'; -import type { AuthState } from '../../types'; +import { mockAuth, mockGitHubCloudAccount } from '../../__mocks__/state-mocks'; +import type { Account, AuthState } from '../../types'; import * as apiRequests from '../api/request'; import * as auth from './utils'; import { getNewOAuthAppURL, getNewTokenURL } from './utils'; @@ -192,6 +193,49 @@ describe('utils/auth/utils.ts', () => { }); }); + describe('removeAccount', () => { + it('should remove account with matching token', async () => { + expect(mockAuth.accounts.length).toBe(2); + + const result = auth.removeAccount(mockAuth, mockGitHubCloudAccount); + + expect(result.accounts.length).toBe(1); + }); + + it('should do nothing if no accounts match', async () => { + const mockAccount = { + token: 'unknown-token', + } as Account; + + expect(mockAuth.accounts.length).toBe(2); + + const result = auth.removeAccount(mockAuth, mockAccount); + + expect(result.accounts.length).toBe(2); + }); + }); + + it('getDeveloperSettingsURL', () => { + expect( + auth.getDeveloperSettingsURL({ + hostname: 'github.com', + method: 'GitHub App', + } as Account), + ).toBe('https://github.com/settings/apps'); + expect( + auth.getDeveloperSettingsURL({ + hostname: 'github.com', + method: 'OAuth App', + } as Account), + ).toBe('https://github.com/settings/developers'); + expect( + auth.getDeveloperSettingsURL({ + hostname: 'github.com', + method: 'Personal Access Token', + } as Account), + ).toBe('https://github.com/settings/tokens'); + }); + describe('getNewTokenURL', () => { it('should generate new PAT url - github cloud', () => { expect( diff --git a/src/utils/auth/utils.ts b/src/utils/auth/utils.ts index 05a80e957..f4ab7fe4a 100644 --- a/src/utils/auth/utils.ts +++ b/src/utils/auth/utils.ts @@ -1,6 +1,6 @@ import { BrowserWindow } from '@electron/remote'; import { format } from 'date-fns'; -import type { AuthState, GitifyUser } from '../../types'; +import type { Account, AuthState, GitifyUser } from '../../types'; import type { UserDetails } from '../../typesGitHub'; import { getAuthenticatedUser } from '../api/client'; import { apiRequest } from '../api/request'; @@ -128,6 +128,33 @@ export function addAccount( }; } +export function removeAccount(auth: AuthState, account: Account): AuthState { + const updatedAccounts = auth.accounts.filter( + (a) => a.token !== account.token, + ); + + return { + accounts: updatedAccounts, + }; +} + +export function getDeveloperSettingsURL(account: Account): string { + const settingsURL = new URL(`https://${account.hostname}`); + + switch (account.method) { + case 'GitHub App': + settingsURL.pathname = '/settings/apps'; + break; + case 'OAuth App': + settingsURL.pathname = '/settings/developers'; + break; + case 'Personal Access Token': + settingsURL.pathname = 'settings/tokens'; + break; + } + return settingsURL.toString(); +} + export function getNewTokenURL(hostname: string): string { const date = format(new Date(), 'PP p'); const newTokenURL = new URL(`https://${hostname}/settings/tokens/new`); @@ -171,3 +198,7 @@ export function isValidClientId(clientId: string) { export function isValidToken(token: string) { return /^[A-Z0-9_]{40}$/i.test(token); } + +export function getAccountUUID(account: Account): string { + return btoa(`${account.hostname}-${account.user.id}-${account.method}`); +}