Repository: gitify-app/gitify
Pull request #1120 (2024-05-17 12:55:17)

feat(auth): disable `generate a pat` if no hostname


Patch:
diff --git a/src/routes/LoginEnterprise.tsx b/src/routes/LoginEnterprise.tsx
index c025ba32..b38d77f2 100644
--- a/src/routes/LoginEnterprise.tsx
+++ b/src/routes/LoginEnterprise.tsx
@@ -9,6 +9,8 @@ import { useNavigate } from 'react-router-dom';
 import { FieldInput } from '../components/fields/FieldInput';
 import { AppContext } from '../context/App';
 import type { AuthOptions } from '../types';
+import { getNewOAuthAppURL } from '../utils/auth';
+import { openExternalLink } from '../utils/comms';
 
 interface IValues {
   hostname?: string;
@@ -65,8 +67,15 @@ export const LoginEnterpriseRoute: FC = () => {
     }
   }, [enterpriseAccounts]);
 
+  const openLink = useCallback((url: string) => {
+    openExternalLink(url);
+  }, []);
+
   const renderForm = (formProps: FormRenderProps) => {
-    const { handleSubmit, submitting, pristine } = formProps;
+    const { handleSubmit, submitting, pristine, values } = formProps;
+
+    const buttonClasses =
+      'rounded bg-gray-300 font-semibold rounded text-sm text-center hover:bg-gray-500 hover:text-white dark:text-black focus:outline-none cursor-pointer';
 
     return (
       <form onSubmit={handleSubmit}>
@@ -74,6 +83,22 @@ export const LoginEnterpriseRoute: FC = () => {
           name="hostname"
           label="Hostname"
           placeholder="github.company.com"
+          helpText={
+            <div>
+              <div className="mb-1">
+                <button
+                  type="button"
+                  className={`px-2 py-1 text-xs ${buttonClasses}`}
+                  disabled={!values.hostname}
+                  onClick={() => openLink(getNewOAuthAppURL(values.hostname))}
+                >
+                  Create new OAuth App
+                </button>{' '}
+                then{' '}
+                <span className="italic">generate a new client secret</span>.
+              </div>
+            </div>
+          }
         />
 
         <FieldInput name="clientId" label="Client ID" placeholder="123456789" />
diff --git a/src/routes/LoginWithToken.tsx b/src/routes/LoginWithToken.tsx
index d249190a..bd4c4969 100644
--- a/src/routes/LoginWithToken.tsx
+++ b/src/routes/LoginWithToken.tsx
@@ -70,6 +70,7 @@ export const LoginWithToken: FC = () => {
                 <button
                   type="button"
                   className={`px-2 py-1 text-xs ${buttonClasses}`}
+                  disabled={!values.hostname}
                   onClick={() => openLink(getNewTokenURL())}
                 >
                   Generate a PAT
diff --git a/src/utils/api/client.ts b/src/utils/api/client.ts
index e1bd63db..75fcd7dd 100644
--- a/src/utils/api/client.ts
+++ b/src/utils/api/client.ts
@@ -18,10 +18,9 @@ import type {
 import { apiRequestAuth } from './request';
 
 import { print } from 'graphql/language/printer';
-import Constants from '../constants';
 import { QUERY_SEARCH_DISCUSSIONS } from './graphql/discussions';
 import { formatAsGitHubSearchSyntax } from './graphql/utils';
-import { getGitHubAPIBaseUrl } from './utils';
+import { getGitHubAPIBaseUrl, getGitHubGraphQLUrl } from './utils';
 
 /**
  * Get Hypermedia links to resources accessible in GitHub's REST API
@@ -247,7 +246,8 @@ export async function searchDiscussions(
   notification: Notification,
   token: string,
 ): AxiosPromise<GraphQLSearch<Discussion>> {
-  return apiRequestAuth(Constants.GITHUB_API_GRAPHQL_URL, 'POST', token, {
+  const url = getGitHubGraphQLUrl(notification.hostname);
+  return apiRequestAuth(url.toString(), 'POST', token, {
     query: print(QUERY_SEARCH_DISCUSSIONS),
     variables: {
       queryStatement: formatAsGitHubSearchSyntax(
diff --git a/src/utils/api/utils.ts b/src/utils/api/utils.ts
index 3b35b300..f217ef6d 100644
--- a/src/utils/api/utils.ts
+++ b/src/utils/api/utils.ts
@@ -10,3 +10,14 @@ export function getGitHubAPIBaseUrl(hostname: string): URL {
   }
   return url;
 }
+
+export function getGitHubGraphQLUrl(hostname: string): URL {
+  const url = new URL(Constants.GITHUB_API_GRAPHQL_URL);
+
+  if (isEnterpriseHost(hostname)) {
+    url.hostname = hostname;
+    url.pathname = '/api/graphql';
+  }
+
+  return url;
+}
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
index 222e1900..4d27fea2 100644
--- a/src/utils/auth.ts
+++ b/src/utils/auth.ts
@@ -1,5 +1,6 @@
 import { BrowserWindow } from '@electron/remote';
 
+import { format } from 'date-fns';
 import type {
   AuthResponse,
   AuthState,
@@ -136,3 +137,24 @@ export const addAccount = (
     ],
   };
 };
+
+export function getNewOAuthAppURL(hostname: string): string {
+  const date = format(new Date(), 'PP p');
+  const newOAuthAppURL = new URL(
+    `https://${hostname}/settings/applications/new`,
+  );
+  newOAuthAppURL.searchParams.append(
+    'oauth_application[name]',
+    `Gitify (Created on ${date})`,
+  );
+  newOAuthAppURL.searchParams.append(
+    'oauth_application[url]',
+    'https://www.gitify.io',
+  );
+  newOAuthAppURL.searchParams.append(
+    'oauth_application[callback_url]',
+    'https://www.gitify.io/callback',
+  );
+
+  return newOAuthAppURL.toString();
+}


Test patch:
diff --git a/src/routes/LoginEnterprise.test.tsx b/src/routes/LoginEnterprise.test.tsx
index fbacb1dd..2936fe77 100644
--- a/src/routes/LoginEnterprise.test.tsx
+++ b/src/routes/LoginEnterprise.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 import * as TestRenderer from 'react-test-renderer';
 const { ipcRenderer } = require('electron');
+import { shell } from 'electron';
 import { mockedEnterpriseAccounts } from '../__mocks__/mockedData';
 import { AppContext } from '../context/App';
 import type { AuthState } from '../types';
@@ -14,6 +15,8 @@ jest.mock('react-router-dom', () => ({
 }));
 
 describe('routes/LoginEnterprise.tsx', () => {
+  const openExternalMock = jest.spyOn(shell, 'openExternal');
+
   const mockAccounts: AuthState = {
     enterpriseAccounts: [],
     user: null,
@@ -75,6 +78,40 @@ describe('routes/LoginEnterprise.tsx', () => {
     expect(validate(values).clientSecret).toBe('Invalid client secret.');
   });
 
+  describe("'Create new OAuth App' button", () => {
+    it('should be disabled if no hostname configured', async () => {
+      render(
+        <AppContext.Provider value={{ accounts: mockAccounts }}>
+          <MemoryRouter>
+            <LoginEnterpriseRoute />
+          </MemoryRouter>
+        </AppContext.Provider>,
+      );
+
+      fireEvent.click(screen.getByText('Create new OAuth App'));
+
+      expect(openExternalMock).toHaveBeenCalledTimes(0);
+    });
+
+    it('should open in browser if hostname configured', async () => {
+      render(
+        <AppContext.Provider value={{ accounts: mockAccounts }}>
+          <MemoryRouter>
+            <LoginEnterpriseRoute />
+          </MemoryRouter>
+        </AppContext.Provider>,
+      );
+
+      fireEvent.change(screen.getByLabelText('Hostname'), {
+        target: { value: 'company.github.com' },
+      });
+
+      fireEvent.click(screen.getByText('Create new OAuth App'));
+
+      expect(openExternalMock).toHaveBeenCalledTimes(1);
+    });
+  });
+
   it('should receive a logged-in enterprise account', () => {
     const { rerender } = render(
       <AppContext.Provider value={{ accounts: mockAccounts }}>
diff --git a/src/routes/LoginWithToken.test.tsx b/src/routes/LoginWithToken.test.tsx
index cb50d13d..3c806772 100644
--- a/src/routes/LoginWithToken.test.tsx
+++ b/src/routes/LoginWithToken.test.tsx
@@ -72,18 +72,38 @@ describe('routes/LoginWithToken.tsx', () => {
     expect(validate(values).token).toBe('Invalid token.');
   });
 
-  it("should click on the 'Generate a PAT' link and open the browser", async () => {
-    render(
-      <AppContext.Provider value={{ validateToken: mockValidateToken }}>
-        <MemoryRouter>
-          <LoginWithToken />
-        </MemoryRouter>
-      </AppContext.Provider>,
-    );
+  describe("'Generate a PAT' button", () => {
+    it('should be disabled if no hostname configured', async () => {
+      render(
+        <AppContext.Provider value={{ validateToken: mockValidateToken }}>
+          <MemoryRouter>
+            <LoginWithToken />
+          </MemoryRouter>
+        </AppContext.Provider>,
+      );
 
-    fireEvent.click(screen.getByText('Generate a PAT'));
+      fireEvent.change(screen.getByLabelText('Hostname'), {
+        target: { value: '' },
+      });
+
+      fireEvent.click(screen.getByText('Generate a PAT'));
+
+      expect(openExternalMock).toHaveBeenCalledTimes(0);
+    });
 
-    expect(openExternalMock).toHaveBeenCalledTimes(1);
+    it('should open in browser if hostname configured', async () => {
+      render(
+        <AppContext.Provider value={{ validateToken: mockValidateToken }}>
+          <MemoryRouter>
+            <LoginWithToken />
+          </MemoryRouter>
+        </AppContext.Provider>,
+      );
+
+      fireEvent.click(screen.getByText('Generate a PAT'));
+
+      expect(openExternalMock).toHaveBeenCalledTimes(1);
+    });
   });
 
   it('should login using a token - success', async () => {
diff --git a/src/routes/__snapshots__/LoginEnterprise.test.tsx.snap b/src/routes/__snapshots__/LoginEnterprise.test.tsx.snap
index 0efd39df..50381c37 100644
--- a/src/routes/__snapshots__/LoginEnterprise.test.tsx.snap
+++ b/src/routes/__snapshots__/LoginEnterprise.test.tsx.snap
@@ -69,6 +69,33 @@ exports[`routes/LoginEnterprise.tsx renders correctly 1`] = `
           type="text"
           value=""
         />
+        <div
+          className="mt-3 text-gray-700 dark:text-gray-200 text-xs"
+        >
+          <div>
+            <div
+              className="mb-1"
+            >
+              <button
+                className="px-2 py-1 text-xs rounded bg-gray-300 font-semibold rounded text-sm text-center hover:bg-gray-500 hover:text-white dark:text-black focus:outline-none cursor-pointer"
+                disabled={true}
+                onClick={[Function]}
+                type="button"
+              >
+                Create new OAuth App
+              </button>
+               
+              then
+               
+              <span
+                className="italic"
+              >
+                generate a new client secret
+              </span>
+              .
+            </div>
+          </div>
+        </div>
       </div>
       <div
         className="mb-4"
diff --git a/src/routes/__snapshots__/LoginWithToken.test.tsx.snap b/src/routes/__snapshots__/LoginWithToken.test.tsx.snap
index 58b6d25c..cac0708b 100644
--- a/src/routes/__snapshots__/LoginWithToken.test.tsx.snap
+++ b/src/routes/__snapshots__/LoginWithToken.test.tsx.snap
@@ -76,6 +76,7 @@ exports[`routes/LoginWithToken.tsx renders correctly 1`] = `
             <div>
               <button
                 className="px-2 py-1 text-xs rounded bg-gray-300 font-semibold rounded text-sm text-center hover:bg-gray-500 hover:text-white dark:text-black focus:outline-none cursor-pointer"
+                disabled={false}
                 onClick={[Function]}
                 type="button"
               >
diff --git a/src/utils/api/utils.test.ts b/src/utils/api/utils.test.ts
index 551b3caf..8c05e4ac 100644
--- a/src/utils/api/utils.test.ts
+++ b/src/utils/api/utils.test.ts
@@ -1,15 +1,27 @@
-import { getGitHubAPIBaseUrl } from './utils';
+import { getGitHubAPIBaseUrl, getGitHubGraphQLUrl } from './utils';
 
 describe('utils/api/utils.ts', () => {
-  describe('generateGitHubAPIUrl', () => {
+  describe('getGitHubAPIBaseUrl', () => {
     it('should generate a GitHub API url - non enterprise', () => {
       const result = getGitHubAPIBaseUrl('github.com');
       expect(result.toString()).toBe('https://api.github.com/');
     });
 
     it('should generate a GitHub API url - enterprise', () => {
-      const result = getGitHubAPIBaseUrl('github.manos.im');
-      expect(result.toString()).toBe('https://github.manos.im/api/v3/');
+      const result = getGitHubAPIBaseUrl('github.gitify.io');
+      expect(result.toString()).toBe('https://github.gitify.io/api/v3/');
+    });
+  });
+
+  describe('getGitHubGraphQLUrl', () => {
+    it('should generate a GitHub GraphQL url - non enterprise', () => {
+      const result = getGitHubGraphQLUrl('github.com');
+      expect(result.toString()).toBe('https://api.github.com/graphql');
+    });
+
+    it('should generate a GitHub GraphQL url - enterprise', () => {
+      const result = getGitHubGraphQLUrl('github.gitify.io');
+      expect(result.toString()).toBe('https://github.gitify.io/api/graphql');
     });
   });
 });
diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts
index dc6d9d22..abaf57d0 100644
--- a/src/utils/auth.test.ts
+++ b/src/utils/auth.test.ts
@@ -6,6 +6,7 @@ const browserWindow = new remote.BrowserWindow();
 import type { AuthState } from '../types';
 import * as apiRequests from './api/request';
 import * as auth from './auth';
+import { getNewOAuthAppURL } from './auth';
 
 describe('utils/auth.tsx', () => {
   describe('authGitHub', () => {
@@ -130,4 +131,22 @@ describe('utils/auth.tsx', () => {
       });
     });
   });
+
+  describe('getNewOAuthAppURL', () => {
+    it('should generate new oauth app url - github cloud', () => {
+      expect(
+        getNewOAuthAppURL('github.com').startsWith(
+          'https://github.com/settings/applications/new',
+        ),
+      ).toBeTruthy();
+    });
+
+    it('should generate new oauth app url - github server', () => {
+      expect(
+        getNewOAuthAppURL('github.gitify.io').startsWith(
+          'https://github.gitify.io/settings/applications/new',
+        ),
+      ).toBeTruthy();
+    });
+  });
 });