Skip to content

Commit c4b9128

Browse files
authored
feat(connections): show device auth code in the confirmation modal for multiple connectinos connect flow COMPASS-8140 (#6086)
* feat(connections): show device auth code in the confirmation modal for multiple connectinos connect flow * fix(e2e): update selectors * chore(connections): clarify the usage of a feature flag * chore(connections): fix bad automerge
1 parent 0bc694b commit c4b9128

File tree

8 files changed

+672
-282
lines changed

8 files changed

+672
-282
lines changed

package-lock.json

Lines changed: 463 additions & 235 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"@leafygreen-ui/card": "^10.0.6",
4141
"@leafygreen-ui/checkbox": "^12.1.1",
4242
"@leafygreen-ui/code": "^14.3.1",
43-
"@leafygreen-ui/confirmation-modal": "^5.0.11",
43+
"@leafygreen-ui/confirmation-modal": "^5.2.0",
4444
"@leafygreen-ui/emotion": "^4.0.7",
4545
"@leafygreen-ui/guide-cue": "^5.0.6",
4646
"@leafygreen-ui/hooks": "^8.1.2",

packages/compass-components/src/hooks/use-confirmation.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import React, { useContext, useEffect, useRef, useState } from 'react';
22
import { Variant as ConfirmationModalVariant } from '@leafygreen-ui/confirmation-modal';
33
import ConfirmationModal from '../components/modals/confirmation-modal';
4+
import { css } from '@leafygreen-ui/emotion';
45

56
export { ConfirmationModalVariant };
67

78
type ConfirmationModalProps = React.ComponentProps<typeof ConfirmationModal>;
89

910
type ConfirmationProperties = Partial<
10-
Pick<
11-
ConfirmationModalProps,
12-
'title' | 'buttonText' | 'variant' | 'requiredInputText'
13-
>
11+
Pick<ConfirmationModalProps, 'title' | 'variant' | 'requiredInputText'>
1412
> & {
13+
buttonText?: React.ReactNode;
14+
cancelButtonText?: React.ReactNode;
15+
hideConfirmButton?: boolean;
16+
hideCancelButton?: boolean;
1517
description?: React.ReactNode;
1618
signal?: AbortSignal;
1719
'data-testid'?: string;
@@ -85,6 +87,10 @@ type ConfirmationModalAreaProps = Partial<
8587
ShowConfirmationEventDetail['props']
8688
> & { open: boolean };
8789

90+
const hideButtonStyles = css({
91+
display: 'none !important',
92+
});
93+
8894
export const ConfirmationModalArea: React.FC = ({ children }) => {
8995
const hasParentContext = useContext(ConfirmationModalContext).isMounted;
9096

@@ -159,10 +165,21 @@ export const ConfirmationModalArea: React.FC = ({ children }) => {
159165
open={confirmationProps.open}
160166
title={confirmationProps.title ?? 'Are you sure?'}
161167
variant={confirmationProps.variant ?? ConfirmationModalVariant.Default}
162-
buttonText={confirmationProps.buttonText ?? 'Confirm'}
168+
confirmButtonProps={{
169+
className: confirmationProps.hideConfirmButton
170+
? hideButtonStyles
171+
: undefined,
172+
children: confirmationProps.buttonText ?? 'Confirm',
173+
onClick: handleConfirm,
174+
}}
175+
cancelButtonProps={{
176+
className: confirmationProps.hideCancelButton
177+
? hideButtonStyles
178+
: undefined,
179+
children: confirmationProps.cancelButtonText ?? 'Cancel',
180+
onClick: handleCancel,
181+
}}
163182
requiredInputText={confirmationProps.requiredInputText ?? undefined}
164-
onConfirm={handleConfirm}
165-
onCancel={handleCancel}
166183
>
167184
{confirmationProps.description}
168185
</ConfirmationModal>

packages/compass-connections/src/components/connection-status-toasts.tsx renamed to packages/compass-connections/src/components/connection-status-notifications.tsx

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import React, { useCallback } from 'react';
2-
import { css, Link, spacing, useToast } from '@mongodb-js/compass-components';
2+
import {
3+
Body,
4+
Code,
5+
css,
6+
Link,
7+
showConfirmation,
8+
spacing,
9+
useToast,
10+
} from '@mongodb-js/compass-components';
311
import type { ConnectionInfo } from '@mongodb-js/connection-info';
412
import { getConnectionTitle } from '@mongodb-js/connection-info';
513
import { usePreference } from 'compass-preferences-model/provider';
@@ -70,7 +78,23 @@ function ConnectionErrorToastBody({
7078

7179
const noop = () => undefined;
7280

73-
export function useConnectionStatusToasts() {
81+
const deviceAuthModalContentStyles = css({
82+
textAlign: 'center',
83+
'& > *:not(:last-child)': {
84+
paddingBottom: spacing[150],
85+
},
86+
});
87+
88+
/**
89+
* Returns triggers for various notifications (toasts and modals) that are
90+
* supposed to be displayed every time connection flow is happening in the
91+
* application.
92+
*
93+
* All toasts and modals are only applicable in multiple connections mode. Right
94+
* now it's gated by the feature flag, the flag check can be removed when this
95+
* is the default behavior
96+
*/
97+
export function useConnectionStatusNotifications() {
7498
const enableNewMultipleConnectionSystem = usePreference(
7599
'enableNewMultipleConnectionSystem'
76100
);
@@ -156,15 +180,66 @@ export function useConnectionStatusToasts() {
156180
[openToast]
157181
);
158182

183+
const openNotifyDeviceAuthModal = useCallback(
184+
(
185+
connectionInfo: ConnectionInfo,
186+
verificationUrl: string,
187+
userCode: string,
188+
onCancel: () => void,
189+
signal: AbortSignal
190+
) => {
191+
void showConfirmation({
192+
title: `Complete authentication in the browser`,
193+
description: (
194+
<div className={deviceAuthModalContentStyles}>
195+
<Body>
196+
Visit the following URL to complete authentication for{' '}
197+
<b>{getConnectionTitle(connectionInfo)}</b>:
198+
</Body>
199+
<Body>
200+
<Link href={verificationUrl} target="_blank">
201+
{verificationUrl}
202+
</Link>
203+
</Body>
204+
<br></br>
205+
<Body>Enter the following code on that page:</Body>
206+
<Body as="div">
207+
<Code language="none" copyable>
208+
{userCode}
209+
</Code>
210+
</Body>
211+
</div>
212+
),
213+
hideConfirmButton: true,
214+
signal,
215+
}).then(
216+
(result) => {
217+
if (result === false) {
218+
onCancel?.();
219+
}
220+
},
221+
() => {
222+
// Abort signal was triggered
223+
}
224+
);
225+
},
226+
[]
227+
);
228+
229+
// Gated by the feature flag: if flag is on, we return trigger functions, if
230+
// flag is off, we return noop functions so that we can call them
231+
// unconditionally in the actual flow
159232
return enableNewMultipleConnectionSystem
160233
? {
234+
openNotifyDeviceAuthModal,
161235
openConnectionStartedToast,
162236
openConnectionSucceededToast,
163237
openConnectionFailedToast,
164238
openMaximumConnectionsReachedToast,
165239
closeConnectionStatusToast: closeToast,
166240
}
167241
: {
242+
openNotifyDeviceAuthModal: noop,
168243
openConnectionStartedToast: noop,
169244
openConnectionSucceededToast: noop,
170245
openConnectionFailedToast: noop,

packages/compass-connections/src/components/legacy-connections.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { createNewConnectionInfo } from '../stores/connections-store';
2828
import {
2929
getConnectingStatusText,
3030
getConnectionErrorMessage,
31-
} from './connection-status-toasts';
31+
} from './connection-status-notifications';
3232
import { useConnectionFormPreferences } from '../hooks/use-connection-form-preferences';
3333

3434
type ConnectFn = typeof connect;

packages/compass-connections/src/stores/connections-store.spec.tsx

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ function wait(ms: number) {
4343
});
4444
}
4545

46+
function createMockDataService() {
47+
return {
48+
mockDataService: 'yes',
49+
addReauthenticationHandler() {},
50+
getUpdatedSecrets() {
51+
return Promise.resolve({});
52+
},
53+
disconnect() {},
54+
} as unknown as DataService;
55+
}
56+
4657
const mockConnections: ConnectionInfo[] = [
4758
{
4859
id: 'turtle',
@@ -122,14 +133,7 @@ describe('useConnections', function () {
122133
mockConnectionStorage = new InMemoryConnectionStorage([...mockConnections]);
123134
connectionsManager = getConnectionsManager(async () => {
124135
await wait(200);
125-
return {
126-
mockDataService: 'yes',
127-
addReauthenticationHandler() {},
128-
getUpdatedSecrets() {
129-
return Promise.resolve({});
130-
},
131-
disconnect() {},
132-
} as unknown as DataService;
136+
return createMockDataService();
133137
});
134138
});
135139

@@ -245,8 +249,6 @@ describe('useConnections', function () {
245249
const connections = renderHookWithContext();
246250
const connectionInfo = createNewConnectionInfo();
247251

248-
sinon.spy(connectionsManager, 'connect');
249-
250252
const connectPromise = connections.current.connect(connectionInfo);
251253

252254
await waitFor(() => {
@@ -258,6 +260,50 @@ describe('useConnections', function () {
258260
await connectPromise;
259261
});
260262

263+
it('should show device auth code modal when OIDC flow triggers the notification', async function () {
264+
const connections = renderHookWithContext();
265+
let resolveConnect;
266+
const spy = sinon.stub(connectionsManager, 'connect').returns(
267+
new Promise((resolve) => {
268+
resolveConnect = () => resolve(createMockDataService());
269+
})
270+
);
271+
const connectPromise = connections.current.connect(
272+
createNewConnectionInfo()
273+
);
274+
275+
await waitFor(() => {
276+
expect(spy).to.have.been.calledOnce;
277+
});
278+
279+
spy.getCall(0).lastArg.onNotifyOIDCDeviceFlow({
280+
verificationUrl: 'http://example.com/device-auth',
281+
userCode: 'ABCabc123',
282+
});
283+
284+
await waitFor(() => {
285+
expect(
286+
screen.getByText(/Visit the following URL to complete authentication/)
287+
).to.exist;
288+
289+
expect(
290+
screen.getByRole('link', { name: 'http://example.com/device-auth' })
291+
).to.exist;
292+
293+
expect(screen.getByText('ABCabc123')).to.exist;
294+
});
295+
296+
resolveConnect();
297+
298+
await connectPromise;
299+
300+
await waitFor(() => {
301+
expect(() =>
302+
screen.getByText('Complete authentication in the browser')
303+
).to.throw();
304+
});
305+
});
306+
261307
describe('saving connections during connect in single connection mode', function () {
262308
it('should NOT update existing connection with new props when existing connection is successfull', async function () {
263309
await preferences.savePreferences({

0 commit comments

Comments
 (0)