Tweaks for mobile app verification (#916)

* slightly style mobile app screen

* minor tweaks

* more changes

* wrap calls in waitFor to stop jest complains, PR review fix

* fixed polling

* use timeout instead of interval

* suggestion from Joe
This commit is contained in:
Rares Mardare 2022-11-29 18:59:34 +02:00 committed by GitHub
parent 96868e1038
commit 329beb62ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 880 additions and 5126 deletions

View file

@ -14,9 +14,9 @@ module.exports = {
'jest/outgoingWebhooksStub': '<rootDir>/src/jest/outgoingWebhooksStub.ts',
'^jest$': '<rootDir>/src/jest',
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
// '^.+\\.(ts|tsx)$': 'ts-jest',
'^lodash-es$': 'lodash',
'^.+\\.svg$': '<rootDir>/src/jest/svgTransform.ts',
'^.+\\.png$': '<rootDir>/src/jest/grafanaMock.ts',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,35 @@
.container {
display: flex;
flex-direction: row;
&__box {
flex-basis: 50%;
}
&__box:first-child {
margin-right: 8px;
}
&__box:last-child {
margin-left: 8px;
}
}
.icon {
margin-top: -6px;
margin-left: 4px;
fill: var(--green-6);
}
.disconnect__container {
position: relative;
display: flex;
justify-content: center;
width: 100%;
}
.disconnect__qrCode {
width: 240px;
height: auto;
filter: blur(6px);
opacity: 0.6;
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserStore } from 'models/user/user';
@ -13,10 +13,12 @@ import MobileAppVerification from './MobileAppVerification';
jest.mock('state/useStore');
const useStore = useStoreOriginal as jest.Mock<ReturnType<typeof useStoreOriginal>>;
const loadUserMock = jest.fn().mockReturnValue(undefined);
const mockUseStore = (rest?: any, connected = false) => {
const store = {
userStore: {
loadUser: loadUserMock,
currentUser: {
messaging_backends: {
MOBILE_APP: { connected },
@ -35,6 +37,10 @@ const USER_PK = '8585';
const BACKEND = 'MOBILE_APP';
describe('MobileAppVerification', () => {
beforeEach(() => {
loadUserMock.mockClear();
});
test('it shows a loading message if it is currently fetching the QR code', async () => {
const { userStore } = mockUseStore({
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
@ -43,8 +49,10 @@ describe('MobileAppVerification', () => {
const component = render(<MobileAppVerification userPk={USER_PK} />);
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('it shows a message when the mobile app is already connected', async () => {
@ -58,7 +66,9 @@ describe('MobileAppVerification', () => {
const component = render(<MobileAppVerification userPk={USER_PK} />);
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
});
});
test('it shows an error message if there was an error fetching the QR code', async () => {
@ -69,10 +79,12 @@ describe('MobileAppVerification', () => {
const component = render(<MobileAppVerification userPk={USER_PK} />);
await screen.findByText(/.*error fetching your QR code.*/);
expect(component.container).toMatchSnapshot();
waitFor(() => {
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test("it shows a QR code if the app isn't already connected", async () => {
@ -81,12 +93,12 @@ describe('MobileAppVerification', () => {
});
const component = render(<MobileAppVerification userPk={USER_PK} />);
await screen.findByText(/.*the QR code is only valid for one minute.*/);
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('if we disconnect the app, it disconnects and fetches a new QR code', async () => {
@ -108,15 +120,15 @@ describe('MobileAppVerification', () => {
// click the confirm button within the modal, which actually triggers the callback
await user.click(screen.getByText('Remove'));
await screen.findByText(/.*the QR code is only valid for one minute.*/);
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('it shows a loading message if it is currently disconnecting', async () => {
@ -144,11 +156,13 @@ describe('MobileAppVerification', () => {
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('it shows an error message if there was an error disconnecting the mobile app', async () => {
@ -163,7 +177,7 @@ describe('MobileAppVerification', () => {
const component = render(<MobileAppVerification userPk={USER_PK} />);
const user = userEvent.setup();
const button = await screen.findByRole('button');
const button = await screen.findByTestId('test__disconnect');
// click the disconnect button, which opens the modal
await user.click(button);
@ -174,9 +188,51 @@ describe('MobileAppVerification', () => {
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('it polls loadUser on first render if not connected', async () => {
mockUseStore(
{
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
},
false
);
render(<MobileAppVerification userPk={USER_PK} />);
await waitFor(() => {
expect(loadUserMock).toHaveBeenCalledTimes(1);
});
});
test('it polls loadUser after disconnect', async () => {
mockUseStore(
{
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dff'),
unlinkBackend: jest.fn().mockRejectedValueOnce('asdff'),
},
true
);
render(<MobileAppVerification userPk={USER_PK} />);
const user = userEvent.setup();
const button = await screen.findByRole('button');
loadUserMock.mockClear();
await user.click(button); // click the disconnect button, which opens the modal
await user.click(screen.getByText('Remove')); // click the confirm button within the modal, which actually triggers the callback
await waitFor(() => {
expect(loadUserMock).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,29 +1,33 @@
import React, { useCallback, useEffect, useState } from 'react';
import { HorizontalGroup, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import qrCodeImage from 'assets/img/qr-code.png';
import Block from 'components/GBlock/Block';
import Text from 'components/Text/Text';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import DisconnectButton from './parts/DisconnectButton';
import styles from './MobileAppVerification.module.scss';
import DisconnectButton from './parts/DisconnectButton/DisconnectButton';
import DownloadIcons from './parts/DownloadIcons';
import QRCode from './parts/QRCode';
const cx = cn.bind(styles);
type Props = {
userPk: User['pk'];
};
const INTERVAL_MS = 5000;
const BACKEND = 'MOBILE_APP';
const MobileAppVerification = observer(({ userPk }: Props) => {
const { userStore } = useStore();
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(
userStore.currentUser.messaging_backends[BACKEND]?.connected === true
);
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
const [fetchingQRCode, setFetchingQRCode] = useState<boolean>(!mobileAppIsCurrentlyConnected);
const [QRCodeValue, setQRCodeValue] = useState<string>(null);
@ -31,6 +35,7 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
const [disconnectingMobileApp, setDisconnectingMobileApp] = useState<boolean>(false);
const [errorDisconnectingMobileApp, setErrorDisconnectingMobileApp] = useState<string>(null);
const [userTimeoutId, setUserTimeoutId] = useState<NodeJS.Timeout>(undefined);
const fetchQRCode = useCallback(async () => {
setFetchingQRCode(true);
@ -59,9 +64,24 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
} catch (e) {
setErrorDisconnectingMobileApp('There was an error disconnecting your mobile app. Please try again.');
}
setDisconnectingMobileApp(false);
pollUserProfile();
}, [userPk, resetState]);
useEffect(() => {
if (!isUserConnected()) {
pollUserProfile();
}
// clear on unmount
return () => {
if (userTimeoutId) {
clearTimeout(userTimeoutId);
}
};
}, []);
useEffect(() => {
if (!mobileAppIsCurrentlyConnected) {
fetchQRCode();
@ -76,33 +96,63 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
content = <Text type="primary">{errorFetchingQRCode || errorDisconnectingMobileApp}</Text>;
} else if (mobileAppIsCurrentlyConnected) {
content = (
<VerticalGroup>
<Text type="primary">Your mobile app is currently connected. Click below to disconnect.</Text>
<DisconnectButton onClick={disconnectMobileApp} />
<VerticalGroup spacing="lg">
<Text strong type="primary">
App connected <Icon name="check-circle" size="md" className={cx('icon')} />
</Text>
<Text type="primary">
You can sync one application to your account. To setup new device please disconnect app first.
</Text>
<div className={cx('disconnect__container')}>
<img src={qrCodeImage} className={cx('disconnect__qrCode')} />
<DisconnectButton onClick={disconnectMobileApp} />
</div>
</VerticalGroup>
);
} else if (QRCodeValue) {
content = (
<VerticalGroup>
<QRCode value={QRCodeValue} />
<Text type="primary">
Note: the QR code is only valid for one minute. If you have issues connecting your mobile app, try refreshing
this page to generate a new code.
<VerticalGroup spacing="lg">
<Text type="primary" strong>
Sign In
</Text>
<Text type="primary">Open Grafana IRM mobile application and scan this code to sync it with your account.</Text>
<div className="u-width-100 u-flex u-flex-center">
<QRCode value={QRCodeValue} />
</div>
<Text type="primary" className="u-break-word">
<strong>Note:</strong> the QR code is only valid for one minute. If you have issues connecting your mobile
app, try refreshing this page to generate a new code.
</Text>
</VerticalGroup>
);
}
return (
<HorizontalGroup>
<Block bordered withBackground>
{content}
</Block>
<Block bordered withBackground>
<div className={cx('container')}>
<Block shadowed bordered withBackground className={cx('container__box')}>
<DownloadIcons />
</Block>
</HorizontalGroup>
<Block shadowed bordered withBackground className={cx('container__box')}>
{content}
</Block>
</div>
);
function isUserConnected(user?: User): boolean {
return !!(user || userStore.currentUser).messaging_backends[BACKEND]?.connected;
}
async function pollUserProfile(): Promise<void> {
clearTimeout(userTimeoutId);
setUserTimeoutId(undefined);
const user = await userStore.loadUser(userPk);
if (!isUserConnected(user)) {
setUserTimeoutId(setTimeout(() => pollUserProfile(), INTERVAL_MS));
} else {
setMobileAppIsCurrentlyConnected(true);
}
}
});
export default MobileAppVerification;

View file

@ -0,0 +1,6 @@
.disconnect-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View file

@ -3,7 +3,7 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DisconnectButton from '.';
import DisconnectButton from './DisconnectButton';
describe('DisconnectButton', () => {
test('it renders properly', () => {

View file

@ -1,17 +1,27 @@
import React, { FC } from 'react';
import { Button } from '@grafana/ui';
import cn from 'classnames/bind';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import styles from './DisconnectButton.module.scss';
const cx = cn.bind(styles);
type Props = {
onClick: () => void;
};
// TODO: right now this shows a confirmation pop-up modal on top of the user settings modal, do we want to maybe change this?
const DisconnectButton: FC<Props> = ({ onClick }) => (
<WithConfirm title="Are you sure to disconnect your mobile application?" confirmText="Remove">
<Button variant="destructive" onClick={onClick} size="md">
<Button
variant="destructive"
onClick={onClick}
size="md"
className={cx('disconnect-button')}
data-testid="test__disconnect"
>
Disconnect
</Button>
</WithConfirm>

View file

@ -3,7 +3,8 @@
exports[`DisconnectButton it renders properly 1`] = `
<div>
<button
class="css-mk7eo3-button"
class="css-mk7eo3-button disconnect-button"
data-testid="test__disconnect"
type="button"
>
<span

View file

@ -10,7 +10,7 @@ exports[`DownloadIcons it renders properly 1`] = `
class="css-ztyofd-layoutChildrenWrapper"
>
<span
class="root text text--primary text--medium"
class="root text text--primary text--medium text--strong"
>
Download
</span>

View file

@ -14,7 +14,9 @@ const cx = cn.bind(styles);
const DownloadIcons: FC = () => (
<VerticalGroup spacing="lg">
<Text type="primary">Download</Text>
<Text type="primary" strong>
Download
</Text>
<Text type="primary">The Grafana IRM app is available on both the App Store and Google Play Store.</Text>
<VerticalGroup>
<Block hover fullWidth withBackground bordered className={cx('icon-block')}>

View file

@ -74,13 +74,15 @@ export class UserStore extends BaseStore {
}
@action
async loadUser(userPk: User['pk'], skipErrorHandling = false) {
async loadUser(userPk: User['pk'], skipErrorHandling = false): Promise<User> {
const user = await this.getById(userPk, skipErrorHandling);
this.items = {
...this.items,
[user.pk]: { ...user, timezone: getTimezone(user) },
};
return user;
}
@action

View file

@ -1,12 +1,3 @@
.u-flex {
display: flex;
flex-direction: row;
}
.u-align-items-center {
align-items: center;
}
.u-position-relative {
position: relative;
}
@ -18,3 +9,25 @@
.u-pull-left {
margin-right: auto;
}
.u-break-word {
word-break: break-word;
}
.u-width-100 {
width: 100%;
}
.u-flex {
display: flex;
flex-direction: row;
}
.u-flex-center {
justify-content: center;
align-items: center;
}
.u-align-items-center {
align-items: center;
}