Skip to content

Commit 1a02658

Browse files
authored
feat: Login with a personal access token (#471)
* chore: Update path to /login-enterprise * feat: Login with a personal access token * feat: Add helpText to login with token form * chore: Fix/Write more tests * chore: Add more tests * feat: Improve UI for light/dark mode
1 parent d1da6c2 commit 1a02658

17 files changed

+656
-64
lines changed

src/app.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AppContext, AppProvider } from './context/App';
1010
import { Loading } from './components/Loading';
1111
import { LoginEnterpriseRoute } from './routes/LoginEnterprise';
1212
import { LoginRoute } from './routes/Login';
13+
import { LoginWithToken } from './routes/LoginWithToken';
1314
import { NotificationsRoute } from './routes/Notifications';
1415
import { SettingsRoute } from './routes/Settings';
1516
import { Sidebar } from './components/Sidebar';
@@ -45,7 +46,8 @@ export const App = () => {
4546
<PrivateRoute path="/" exact component={NotificationsRoute} />
4647
<PrivateRoute path="/settings" exact component={SettingsRoute} />
4748
<Route path="/login" component={LoginRoute} />
48-
<Route path="/enterpriselogin" component={LoginEnterpriseRoute} />
49+
<Route path="/login-enterprise" component={LoginEnterpriseRoute} />
50+
<Route path="/login-token" component={LoginWithToken} />
4951
</Switch>
5052
</div>
5153
</Router>

src/components/fields/FieldInput.tsx

+42-37
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,54 @@
1-
import * as React from 'react';
1+
import React from 'react';
22
import { Field } from 'react-final-form';
33

44
export interface IProps {
55
name: string;
6-
type: string;
6+
type?: string;
77
label: string;
88
placeholder?: string;
9+
helpText?: React.ReactNode | string;
910
required?: boolean;
1011
}
1112

12-
export class FieldInput extends React.PureComponent<IProps> {
13-
public static defaultProps = {
14-
type: 'text',
15-
placeholder: '',
16-
required: false,
17-
};
13+
export const FieldInput: React.FC<IProps> = ({
14+
label,
15+
name,
16+
placeholder = '',
17+
helpText,
18+
type = 'text',
19+
required = false,
20+
}) => {
21+
return (
22+
<Field name={name}>
23+
{({ input, meta: { touched, error } }) => (
24+
<div className="mt-2">
25+
<label
26+
className="block tracking-wide text-grey-dark text-sm font-semibold mb-2"
27+
htmlFor={input.name}
28+
>
29+
{label}
30+
</label>
1831

19-
render() {
20-
const { label, name, placeholder } = this.props;
32+
<input
33+
type={type}
34+
className="appearance-none block w-full dark:text-gray-800 bg-gray-100 border border-red rounded py-1.5 px-4 mb-2 focus:bg-gray-200 focus:outline-none"
35+
id={input.name}
36+
placeholder={placeholder}
37+
required={required}
38+
{...input}
39+
/>
2140

22-
return (
23-
<Field name={name}>
24-
{({ input, meta: { touched, error } }) => (
25-
<div className="mt-2">
26-
<label
27-
className="block tracking-wide text-grey-dark text-sm font-semibold mb-2"
28-
htmlFor={input.name}
29-
>
30-
{label}
31-
</label>
41+
{helpText && (
42+
<div className="mt-3 text-gray-700 dark:text-gray-200 text-xs">
43+
{helpText}
44+
</div>
45+
)}
3246

33-
<input
34-
type="text"
35-
className="appearance-none block w-full dark:text-gray-800 bg-gray-100 border border-red rounded py-2 px-4 mb-2 focus:bg-gray-200 focus:outline-none"
36-
id={input.name}
37-
placeholder={placeholder}
38-
{...input}
39-
/>
40-
41-
{touched && error && (
42-
<div className="text-red-500 text-xs italic">{error}</div>
43-
)}
44-
</div>
45-
)}
46-
</Field>
47-
);
48-
}
49-
}
47+
{touched && error && (
48+
<div className="mt-2 text-red-500 text-xs italic">{error}</div>
49+
)}
50+
</div>
51+
)}
52+
</Field>
53+
);
54+
};

src/context/App.test.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AppContext, AppProvider } from './App';
55
import { AuthState, SettingsState } from '../types';
66
import { mockAccounts, mockSettings } from '../__mocks__/mock-state';
77
import { useNotifications } from '../hooks/useNotifications';
8+
import * as apiRequests from '../utils/api-requests';
89
import * as comms from '../utils/comms';
910
import * as storage from '../utils/storage';
1011

@@ -29,6 +30,7 @@ describe('context/App.tsx', () => {
2930

3031
describe('api methods', () => {
3132
const updateSettingMock = jest.fn();
33+
const apiRequestAuthMock = jest.spyOn(apiRequests, 'apiRequestAuth');
3234

3335
const fetchNotificationsMock = jest.fn();
3436
const markNotificationMock = jest.fn();
@@ -164,6 +166,57 @@ describe('context/App.tsx', () => {
164166
'github.com'
165167
);
166168
});
169+
170+
it('should call validateToken', async () => {
171+
apiRequestAuthMock.mockResolvedValueOnce(null);
172+
173+
const TestComponent = () => {
174+
const { validateToken } = useContext(AppContext);
175+
176+
return (
177+
<button
178+
onClick={() =>
179+
validateToken({ hostname: 'github.com', token: '123-456' })
180+
}
181+
>
182+
Test Case
183+
</button>
184+
);
185+
};
186+
187+
const { getByText } = customRender(<TestComponent />);
188+
189+
fireEvent.click(getByText('Test Case'));
190+
191+
await waitFor(() =>
192+
expect(fetchNotificationsMock).toHaveBeenCalledTimes(2)
193+
);
194+
195+
expect(apiRequestAuthMock).toHaveBeenCalledTimes(1);
196+
expect(apiRequestAuthMock).toHaveBeenCalledWith(
197+
'https://api.github.com/notifications',
198+
'HEAD',
199+
'123-456'
200+
);
201+
});
202+
});
203+
204+
it('should call logout', async () => {
205+
const clearStateMock = jest.spyOn(storage, 'clearState');
206+
207+
const TestComponent = () => {
208+
const { logout } = useContext(AppContext);
209+
210+
return <button onClick={logout}>Test Case</button>;
211+
};
212+
213+
const { getByText } = customRender(<TestComponent />);
214+
215+
act(() => {
216+
fireEvent.click(getByText('Test Case'));
217+
});
218+
219+
expect(clearStateMock).toHaveBeenCalledTimes(1);
167220
});
168221

169222
it('should call updateSetting', async () => {

src/context/App.tsx

+25-6
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
Appearance,
1212
AuthOptions,
1313
AuthState,
14+
AuthTokenOptions,
1415
SettingsState,
1516
} from '../types';
16-
import { authGitHub, getToken } from '../utils/auth';
17+
import { apiRequestAuth } from '../utils/api-requests';
18+
import { addAccount, authGitHub, getToken } from '../utils/auth';
1719
import { clearState, loadState, saveState } from '../utils/storage';
1820
import { setAppearance } from '../utils/appearance';
1921
import { setAutoLaunch } from '../utils/comms';
@@ -39,6 +41,7 @@ interface AppContextState {
3941
isLoggedIn: boolean;
4042
login: () => void;
4143
loginEnterprise: (data: AuthOptions) => void;
44+
validateToken: (data: AuthTokenOptions) => void;
4245
logout: () => void;
4346

4447
notifications: AccountNotifications[];
@@ -110,16 +113,31 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
110113
const { token } = await getToken(authCode);
111114
setAccounts({ ...accounts, token });
112115
saveState({ ...accounts, token }, settings);
113-
}, [accounts]);
116+
}, [accounts, settings]);
114117

115118
const loginEnterprise = useCallback(
116119
async (data: AuthOptions) => {
117120
const { authOptions, authCode } = await authGitHub(data);
118-
const { token } = await getToken(authCode, authOptions);
119-
setAccounts({ ...accounts, token });
120-
saveState({ ...accounts, token }, settings);
121+
const { token, hostname } = await getToken(authCode, authOptions);
122+
const updatedAccounts = addAccount(accounts, token, hostname);
123+
setAccounts(updatedAccounts);
124+
saveState(updatedAccounts, settings);
125+
},
126+
[accounts, settings]
127+
);
128+
129+
const validateToken = useCallback(
130+
async ({ token, hostname }: AuthTokenOptions) => {
131+
await apiRequestAuth(
132+
`https://api.${hostname}/notifications`,
133+
'HEAD',
134+
token
135+
);
136+
const updatedAccounts = addAccount(accounts, token, hostname);
137+
setAccounts(updatedAccounts);
138+
saveState(updatedAccounts, settings);
121139
},
122-
[accounts]
140+
[accounts, settings]
123141
);
124142

125143
const logout = useCallback(() => {
@@ -169,6 +187,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
169187
isLoggedIn,
170188
login,
171189
loginEnterprise,
190+
validateToken,
172191
logout,
173192

174193
notifications,

src/routes/Login.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,6 @@ describe('routes/Login.tsx', () => {
6363
fireEvent.click(getByLabelText('Login with GitHub Enterprise'));
6464

6565
expect(pushMock).toHaveBeenCalledTimes(1);
66-
expect(pushMock).toHaveBeenCalledWith('/enterpriselogin');
66+
expect(pushMock).toHaveBeenCalledWith('/login-enterprise');
6767
});
6868
});

src/routes/Login.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,23 @@ export const LoginRoute: React.FC = () => {
4141
onClick={loginUser}
4242
aria-label="Login with GitHub"
4343
>
44-
<span>Login to GitHub</span>
44+
Login to GitHub
4545
</button>
4646

4747
<button
4848
className={loginButtonClass}
49-
onClick={() => history.push('/enterpriselogin')}
49+
onClick={() => history.push('/login-enterprise')}
5050
aria-label="Login with GitHub Enterprise"
5151
>
52-
<span>Login to GitHub Enterprise</span>
52+
Login to GitHub Enterprise
53+
</button>
54+
55+
<button
56+
className="bg-none hover:text-gray-800 dark:text-gray-100 dark:hover:text-gray-300 mt-4 focus:outline-none"
57+
onClick={() => history.push('/login-token')}
58+
aria-label="Login with Personal Token"
59+
>
60+
<small>or login with a personal token</small>
5361
</button>
5462
</div>
5563
);

src/routes/LoginEnterprise.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const LoginEnterpriseRoute: React.FC = () => {
8989
type="submit"
9090
title="Login Button"
9191
>
92-
<span>Login</span>
92+
Login
9393
</button>
9494
</form>
9595
);

0 commit comments

Comments
 (0)