Skip to content

Commit 4cb2136

Browse files
committed
feat: add bitbucket cloud support
Signed-off-by: Adam Setch <[email protected]>
1 parent 6a43886 commit 4cb2136

File tree

14 files changed

+250
-14
lines changed

14 files changed

+250
-14
lines changed

src/app.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AppContext, AppProvider } from './context/App';
1212
import { AccountsRoute } from './routes/Accounts';
1313
import { FiltersRoute } from './routes/Filters';
1414
import { LoginRoute } from './routes/Login';
15+
import { LoginWithBitbucketCloud } from './routes/LoginWithBitbucketCloud';
1516
import { LoginWithOAuthApp } from './routes/LoginWithOAuthApp';
1617
import { LoginWithPersonalAccessToken } from './routes/LoginWithPersonalAccessToken';
1718
import { NotificationsRoute } from './routes/Notifications';
@@ -61,7 +62,7 @@ export const App = () => {
6162
}
6263
/>
6364
<Route
64-
path="/accounts"
65+
path="/settings/accounts"
6566
element={
6667
<RequireAuth>
6768
<AccountsRoute />
@@ -74,6 +75,10 @@ export const App = () => {
7475
element={<LoginWithPersonalAccessToken />}
7576
/>
7677
<Route path="/login-oauth-app" element={<LoginWithOAuthApp />} />
78+
<Route
79+
path="/login-bitbucket-cloud"
80+
element={<LoginWithBitbucketCloud />}
81+
/>
7782
</Routes>
7883
</div>
7984
</Router>

src/components/settings/SettingsFooter.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe('routes/components/settings/SettingsFooter.tsx', () => {
125125
});
126126

127127
fireEvent.click(screen.getByTitle('Accounts'));
128-
expect(mockNavigate).toHaveBeenCalledWith('/accounts');
128+
expect(mockNavigate).toHaveBeenCalledWith('/settings/accounts');
129129
});
130130

131131
it('should quit the app', async () => {

src/components/settings/SettingsFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const SettingsFooter: FC = () => {
3939
className={BUTTON_CLASS_NAME}
4040
title="Accounts"
4141
onClick={() => {
42-
navigate('/accounts');
42+
navigate('/settings/accounts');
4343
}}
4444
>
4545
<PersonIcon size={Size.LARGE} aria-label="Accounts" />

src/context/App.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type AuthState,
1616
type GitifyError,
1717
GroupBy,
18+
type Hostname,
1819
OpenPreference,
1920
type SettingsState,
2021
type SettingsValue,
@@ -25,6 +26,7 @@ import type { Notification } from '../typesGitHub';
2526
import { headNotifications } from '../utils/api/client';
2627
import { migrateAuthenticatedAccounts } from '../utils/auth/migration';
2728
import type {
29+
LoginBitbucketCloudOptions,
2830
LoginOAuthAppOptions,
2931
LoginPersonalAccessTokenOptions,
3032
} from '../utils/auth/types';
@@ -97,6 +99,7 @@ interface AppContextState {
9799
loginWithGitHubApp: () => void;
98100
loginWithOAuthApp: (data: LoginOAuthAppOptions) => void;
99101
loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => void;
102+
loginWithBitbucketCloud: (data: LoginBitbucketCloudOptions) => void;
100103
logoutFromAccount: (account: Account) => void;
101104

102105
notifications: AccountNotifications[];
@@ -242,6 +245,21 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
242245
[auth, settings],
243246
);
244247

248+
const loginWithBitbucketCloud = useCallback(
249+
async ({ token, workspace, username }: LoginBitbucketCloudOptions) => {
250+
const updatedAuth = await addAccount(
251+
auth,
252+
'App Password',
253+
token,
254+
`https://api.bitbucket.org/internal/workspaces/${workspace}` as Hostname,
255+
username,
256+
);
257+
setAuth(updatedAuth);
258+
saveState({ auth: updatedAuth, settings });
259+
},
260+
[auth, settings],
261+
);
262+
245263
const logoutFromAccount = useCallback(
246264
async (account: Account) => {
247265
// Remove notifications for account
@@ -320,6 +338,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
320338
loginWithGitHubApp,
321339
loginWithOAuthApp,
322340
loginWithPersonalAccessToken,
341+
loginWithBitbucketCloud,
323342
logoutFromAccount,
324343

325344
notifications,

src/routes/Accounts.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe('routes/Accounts.tsx', () => {
196196
'token-123-456',
197197
);
198198
await waitFor(() =>
199-
expect(mockNavigate).toHaveBeenNthCalledWith(1, '/accounts', {
199+
expect(mockNavigate).toHaveBeenNthCalledWith(1, '/settings/accounts', {
200200
replace: true,
201201
}),
202202
);

src/routes/Accounts.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
BeakerIcon,
23
FeedPersonIcon,
34
KeyIcon,
45
MarkGithubIcon,
@@ -48,7 +49,7 @@ export const AccountsRoute: FC = () => {
4849
const setAsPrimaryAccount = useCallback((account: Account) => {
4950
auth.accounts = [account, ...auth.accounts.filter((a) => a !== account)];
5051
saveState({ auth, settings });
51-
navigate('/accounts', { replace: true });
52+
navigate('/settings/accounts', { replace: true });
5253
}, []);
5354

5455
const loginWithGitHub = useCallback(async () => {
@@ -67,6 +68,10 @@ export const AccountsRoute: FC = () => {
6768
return navigate('/login-oauth-app', { replace: true });
6869
}, []);
6970

71+
const loginWithBitbucketCloud = useCallback(() => {
72+
return navigate('/login-bitbucket-cloud', { replace: true });
73+
}, []);
74+
7075
return (
7176
<div className="flex h-screen flex-col" data-testid="accounts">
7277
<Header icon={PersonIcon}>Accounts</Header>
@@ -155,7 +160,7 @@ export const AccountsRoute: FC = () => {
155160
title={`Refresh ${account.user.login}`}
156161
onClick={async () => {
157162
await refreshAccount(account);
158-
navigate('/accounts', { replace: true });
163+
navigate('/settings/accounts', { replace: true });
159164
}}
160165
>
161166
<SyncIcon
@@ -216,6 +221,18 @@ export const AccountsRoute: FC = () => {
216221
<PersonIcon size={Size.XLARGE} aria-label="Login with OAuth App" />
217222
<PlusIcon size={Size.SMALL} className="mb-2" />
218223
</button>
224+
<button
225+
type="button"
226+
className={BUTTON_CLASS_NAME}
227+
title="Login with Bitbucket Cloud"
228+
onClick={loginWithBitbucketCloud}
229+
>
230+
<BeakerIcon
231+
size={Size.XLARGE}
232+
aria-label="Login with Bitbucket Cloud"
233+
/>
234+
<PlusIcon size={Size.SMALL} className="mb-2" />
235+
</button>
219236
</div>
220237
</div>
221238
</div>

src/routes/Login.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react';
1+
import {
2+
BeakerIcon,
3+
KeyIcon,
4+
MarkGithubIcon,
5+
PersonIcon,
6+
} from '@primer/octicons-react';
27
import log from 'electron-log';
38
import { type FC, useCallback, useContext, useEffect } from 'react';
49
import { useNavigate } from 'react-router-dom';
@@ -63,6 +68,14 @@ export const LoginRoute: FC = () => {
6368
>
6469
OAuth App
6570
</Button>
71+
<Button
72+
icon={{ icon: BeakerIcon }}
73+
label="Login with Bitbucket Cloud"
74+
className="mt-2 py-2"
75+
onClick={() => navigate('/login-bitbucket-cloud')}
76+
>
77+
Bitbucket Cloud
78+
</Button>
6679
</div>
6780
);
6881
};
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { BookIcon, KeyIcon, SignInIcon } from '@primer/octicons-react';
2+
import log from 'electron-log';
3+
import { type FC, useCallback, useContext, useState } from 'react';
4+
import { Form, type FormRenderProps } from 'react-final-form';
5+
import { useNavigate } from 'react-router-dom';
6+
import { Header } from '../components/Header';
7+
import { Button } from '../components/buttons/Button';
8+
import { FieldInput } from '../components/fields/FieldInput';
9+
import { AppContext } from '../context/App';
10+
import { Size, type Token, type Username, type Workspace } from '../types';
11+
import type { LoginBitbucketCloudOptions } from '../utils/auth/types';
12+
import { isValidAppPassword } from '../utils/auth/utils';
13+
import { Constants } from '../utils/constants';
14+
15+
interface IValues {
16+
username: Username;
17+
token?: Token;
18+
workspace?: Workspace;
19+
}
20+
21+
interface IFormErrors {
22+
username?: string;
23+
token?: string;
24+
workspace?: string;
25+
}
26+
27+
export const validate = (values: IValues): IFormErrors => {
28+
const errors: IFormErrors = {};
29+
30+
if (!values.username) {
31+
errors.token = 'Required';
32+
}
33+
34+
if (!values.token) {
35+
errors.token = 'Required';
36+
} else if (!isValidAppPassword(values.token)) {
37+
errors.token = 'Invalid app password.';
38+
}
39+
40+
if (!values.workspace) {
41+
errors.workspace = 'Required';
42+
}
43+
44+
return errors;
45+
};
46+
47+
export const LoginWithBitbucketCloud: FC = () => {
48+
const { loginWithBitbucketCloud } = useContext(AppContext);
49+
const navigate = useNavigate();
50+
const [isValidToken, setIsValidToken] = useState<boolean>(true);
51+
52+
const renderForm = (formProps: FormRenderProps) => {
53+
const { handleSubmit, submitting, pristine, values } = formProps;
54+
55+
return (
56+
<form onSubmit={handleSubmit}>
57+
<FieldInput
58+
name="username"
59+
label="Username"
60+
placeholder="Your Bitbucket Cloud username"
61+
/>
62+
63+
<FieldInput
64+
name="token"
65+
label="App Password"
66+
placeholder="The 36 characters app password generated on Bitbucket Cloud"
67+
/>
68+
69+
<FieldInput
70+
name="workspace"
71+
label="Workspace"
72+
placeholder="Your Bitbucket Cloud workspace name"
73+
/>
74+
75+
{!isValidToken && (
76+
<div className="mt-4 text-sm font-medium text-red-500">
77+
This token could not be validated with {values.workspace}.
78+
</div>
79+
)}
80+
81+
<div className="flex items-end justify-between mt-2">
82+
<Button
83+
label="Bitbucket Cloud Docs"
84+
icon={{ icon: BookIcon, size: Size.XSMALL }}
85+
url={Constants.ATLASSIAN_DOCS.BITBUCKET_APP_PASSWORD_URL}
86+
size="xs"
87+
>
88+
Docs
89+
</Button>
90+
<Button
91+
label="Login"
92+
className="px-4 py-2 !text-sm"
93+
icon={{ icon: SignInIcon, size: Size.MEDIUM }}
94+
disabled={submitting || pristine}
95+
type="submit"
96+
>
97+
Login
98+
</Button>
99+
</div>
100+
</form>
101+
);
102+
};
103+
104+
const login = useCallback(
105+
async (data: IValues) => {
106+
setIsValidToken(true);
107+
try {
108+
await loginWithBitbucketCloud(data as LoginBitbucketCloudOptions);
109+
navigate(-1);
110+
} catch (err) {
111+
log.error('Auth: failed to login with personal access token', err);
112+
setIsValidToken(false);
113+
}
114+
},
115+
[loginWithBitbucketCloud],
116+
);
117+
118+
return (
119+
<>
120+
<Header icon={KeyIcon}>Login with Bitbucket Cloud</Header>
121+
122+
<div className="px-8">
123+
<Form
124+
initialValues={{
125+
username: '' as Username,
126+
token: '' as Token,
127+
workspace: '' as Workspace,
128+
}}
129+
onSubmit={login}
130+
validate={validate}
131+
>
132+
{renderForm}
133+
</Form>
134+
</div>
135+
</>
136+
);
137+
};

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export type ClientSecret = Branded<string, 'ClientSecret'>;
3939

4040
export type Hostname = Branded<string, 'Hostname'>;
4141

42+
export type Username = Branded<string, 'Username'>;
43+
44+
export type Workspace = Branded<string, 'Workspace'>;
45+
4246
export type Link = Branded<string, 'WebUrl'>;
4347

4448
export type Status = 'loading' | 'success' | 'error';

src/utils/auth/types.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@ import type {
44
ClientSecret,
55
Hostname,
66
Token,
7+
Username,
8+
Workspace,
79
} from '../../types';
810

9-
export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App';
11+
export type AuthMethod =
12+
| 'GitHub App'
13+
| 'Personal Access Token'
14+
| 'OAuth App'
15+
| 'App Password';
1016

11-
export type PlatformType = 'GitHub Cloud' | 'GitHub Enterprise Server';
17+
export type PlatformType =
18+
| 'GitHub Cloud'
19+
| 'GitHub Enterprise Server'
20+
| 'Bitbucket Cloud';
1221

1322
export interface LoginOAuthAppOptions {
1423
hostname: Hostname;
@@ -21,6 +30,12 @@ export interface LoginPersonalAccessTokenOptions {
2130
token: Token;
2231
}
2332

33+
export interface LoginBitbucketCloudOptions {
34+
username: Username;
35+
token: Token;
36+
workspace: Workspace;
37+
}
38+
2439
export interface AuthResponse {
2540
authCode: AuthCode;
2641
authOptions: LoginOAuthAppOptions;

0 commit comments

Comments
 (0)