diff --git a/package.json b/package.json index 46d3cc39e0..78b76592ac 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "react-draggable": "^4.4.5", "react-emoji-render": "^1.2.4", "react-modal": "^3.15.1", + "react-qr-code": "^2.0.8", "react-responsive": "^8.1.0", "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", diff --git a/src/assets/img/brand/apple-logo.svg b/src/assets/img/brand/apple-logo.svg new file mode 100644 index 0000000000..acfaa37e17 --- /dev/null +++ b/src/assets/img/brand/apple-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/brand/play-store-logo.svg b/src/assets/img/brand/play-store-logo.svg new file mode 100644 index 0000000000..e4e5150d58 --- /dev/null +++ b/src/assets/img/brand/play-store-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/components/GBlock/Block.module.css b/src/components/GBlock/Block.module.scss similarity index 61% rename from src/components/GBlock/Block.module.css rename to src/components/GBlock/Block.module.scss index 49f4ed2d09..b83e6c794a 100644 --- a/src/components/GBlock/Block.module.css +++ b/src/components/GBlock/Block.module.scss @@ -1,6 +1,18 @@ .root { padding: 16px; border-radius: 2px; + + &--withBackGround { + background: var(--secondary-background); + } + + &--fullWidth { + width: 100%; + } + + &--hover:hover { + background: var(--hover-selected); + } } :global(.theme-dark) .root_bordered { @@ -14,7 +26,3 @@ :global(.theme-dark) .root_shadowed { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); } - -.root_with-background { - background: var(--secondary-background); -} diff --git a/src/components/GBlock/Block.tsx b/src/components/GBlock/Block.tsx index 16fd72607c..f3a4dc0ec7 100644 --- a/src/components/GBlock/Block.tsx +++ b/src/components/GBlock/Block.tsx @@ -2,25 +2,39 @@ import React, { FC, HTMLAttributes } from 'react'; import cn from 'classnames/bind'; -import styles from './Block.module.css'; +import styles from './Block.module.scss'; interface BlockProps extends HTMLAttributes { bordered?: boolean; shadowed?: boolean; withBackground?: boolean; + hover?: boolean; + fullWidth?: boolean; } const cx = cn.bind(styles); const Block: FC = (props) => { - const { children, style, className, bordered = false, shadowed = false, withBackground = false, ...rest } = props; + const { + children, + style, + className, + bordered = false, + fullWidth = false, + hover = false, + shadowed = false, + withBackground = false, + ...rest + } = props; return (
>; + +const mockUseStore = (rest?: any, connected = false) => { + const store = { + userStore: { + currentUser: { + messaging_backends: { + MOBILE_APP: { connected }, + }, + } as unknown as User, + ...(rest ? rest : {}), + } as unknown as UserStore, + } as unknown as RootStore; + + useStore.mockReturnValue(store); + + return store; +}; + +const USER_PK = '8585'; +const BACKEND = 'MOBILE_APP'; + +describe('MobileAppVerification', () => { + test('it shows a loading message if it is currently fetching the QR code', async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + 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 () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }, + true + ); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0); + }); + + test('it shows an error message if there was an error fetching the QR code', async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockRejectedValueOnce('dfd'), + }); + + const component = render(); + await screen.findByText(/.*error fetching your QR code.*/); + + expect(component.container).toMatchSnapshot(); + + 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 () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }); + + const component = render(); + 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); + }); + + test('if we disconnect the app, it disconnects and fetches a new QR code', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockResolvedValueOnce('asdfadsfafds'), + }, + true + ); + + const component = render(); + + const user = userEvent.setup(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await user.click(button); + // 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); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + + test('it shows a loading message if it is currently disconnecting', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockResolvedValueOnce('aaa'), + }, + true + ); + + const component = render(); + + const user = userEvent.setup(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await user.click(button); + // click the confirm button within the modal, which actually triggers the callback + // this is maybe a bit "hacky" but by not awaiting the below promise it allows us to check the loading state.. + user.click(screen.getByText('Remove')); + + // wait for loading state + await screen.findByText(/.*Loading.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).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 () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'), + }, + true + ); + + const component = render(); + + const user = userEvent.setup(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await user.click(button); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + await screen.findByText(/.*error disconnecting your mobile app.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); +}); diff --git a/src/containers/MobileAppVerification/MobileAppVerification.tsx b/src/containers/MobileAppVerification/MobileAppVerification.tsx index 5413c2705a..cdaf5af2ef 100644 --- a/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -1,104 +1,107 @@ -import React, { HTMLAttributes, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; -import { Button, LoadingPlaceholder } from '@grafana/ui'; -import cn from 'classnames/bind'; +import { HorizontalGroup, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { observer } from 'mobx-react'; +import Block from 'components/GBlock/Block'; import Text from 'components/Text/Text'; -import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; -import styles from './MobileAppVerification.module.css'; +import DisconnectButton from './parts/DisconnectButton'; +import DownloadIcons from './parts/DownloadIcons'; +import QRCode from './parts/QRCode'; -const cx = cn.bind(styles); +type Props = { + userPk: User['pk']; +}; -interface MobileAppVerificationProps extends HTMLAttributes { - userPk?: User['pk']; - phone?: string; -} +const BACKEND = 'MOBILE_APP'; -const MobileAppVerification = observer((props: MobileAppVerificationProps) => { - const { userPk: propsUserPk } = props; +const MobileAppVerification = observer(({ userPk }: Props) => { + const { userStore } = useStore(); - const store = useStore(); - const { userStore } = store; + const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState( + userStore.currentUser.messaging_backends[BACKEND]?.connected === true + ); - const userPk = (propsUserPk || userStore.currentUserPk) as User['pk']; - const user = userStore.items[userPk as User['pk']]; - const isCurrent = userStore.currentUserPk === user.pk; - const action = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const [fetchingQRCode, setFetchingQRCode] = useState(!mobileAppIsCurrentlyConnected); + const [QRCodeValue, setQRCodeValue] = useState(null); + const [errorFetchingQRCode, setErrorFetchingQRCode] = useState(null); - const [showMobileAppVerificationToken, setShowMobileAppVerificationToken] = useState(undefined); - const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState(false); - const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState(true); + const [disconnectingMobileApp, setDisconnectingMobileApp] = useState(false); + const [errorDisconnectingMobileApp, setErrorDisconnectingMobileApp] = useState(null); - const handleCreateMobileAppVerificationToken = async () => { - setIsMobileAppVerificationTokenExisting(true); - await userStore - .sendBackendConfirmationCode(userPk, 'MOBILE_APP') - .then((res) => setShowMobileAppVerificationToken(res)); - }; + const fetchQRCode = useCallback(async () => { + setFetchingQRCode(true); + try { + // backend verification code that we receive is a JSON object that has been "stringified" + const qrCodeContent = await userStore.sendBackendConfirmationCode(userPk, BACKEND); + setQRCodeValue(qrCodeContent); + } catch (e) { + setErrorFetchingQRCode('There was an error fetching your QR code. Please try again.'); + } + setFetchingQRCode(false); + }, [userPk]); - useEffect(() => { - handleCreateMobileAppVerificationToken().then(() => { - setMobileAppVerificationTokenLoading(false); - }); + const resetState = useCallback(() => { + setErrorDisconnectingMobileApp(null); + setMobileAppIsCurrentlyConnected(false); + setQRCodeValue(null); }, []); + const disconnectMobileApp = useCallback(async () => { + setDisconnectingMobileApp(true); + + try { + await userStore.unlinkBackend(userPk, BACKEND); + resetState(); + } catch (e) { + setErrorDisconnectingMobileApp('There was an error disconnecting your mobile app. Please try again.'); + } + setDisconnectingMobileApp(false); + }, [userPk, resetState]); + + useEffect(() => { + if (!mobileAppIsCurrentlyConnected) { + fetchQRCode(); + } + }, [mobileAppIsCurrentlyConnected]); + + let content: React.ReactNode = null; + + if (fetchingQRCode || disconnectingMobileApp) { + content = ; + } else if (errorFetchingQRCode || errorDisconnectingMobileApp) { + content = {errorFetchingQRCode || errorDisconnectingMobileApp}; + } else if (mobileAppIsCurrentlyConnected) { + content = ( + + Your mobile app is currently connected. Click below to disconnect. + + + ); + } else if (QRCodeValue) { + content = ( + + + + 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. + + + ); + } + return ( -
- {MobileAppVerificationTokenLoading ? ( - - ) : ( - <> -

- Open Grafana OnCall mobile application and enter the following code to add the new device: -

- {isMobileAppVerificationTokenExisting ? ( - <> - {showMobileAppVerificationToken !== undefined ? ( - <> -

{showMobileAppVerificationToken}

-

- * This code is active only for a minute -

-

- - - -

- - ) : ( - <> - )} - - ) : ( -

- - - -

- )} -

- * Only iOS is currently supported -

- - )} -
+ + + {content} + + + + + ); }); diff --git a/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap b/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap new file mode 100644 index 0000000000..bb4670cfe4 --- /dev/null +++ b/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap @@ -0,0 +1,5191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 1`] = ` +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + 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. + +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a QR code if the app isn't already connected 1`] = ` +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + 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. + +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = ` +
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently fetching the QR code 1`] = ` +
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a message when the mobile app is already connected 1`] = ` +
+
+
+
+
+
+ + Your mobile app is currently connected. Click below to disconnect. + +
+
+ +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 1`] = ` +
+
+
+
+ + There was an error disconnecting your mobile app. Please try again. + +
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows an error message if there was an error fetching the QR code 1`] = ` +
+
+
+
+ + There was an error fetching your QR code. Please try again. + +
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; diff --git a/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx b/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx new file mode 100644 index 0000000000..b660d5af50 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DisconnectButton from '.'; + +describe('DisconnectButton', () => { + test('it renders properly', () => { + const component = render( {}} />); + expect(component.container).toMatchSnapshot(); + }); + + test('It calls the onClick handler when clicked', async () => { + const mockedOnClick = jest.fn(); + + const user = userEvent.setup(); + render(); + + // click the button, which opens the modal + await user.click(screen.getByRole('button')); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + expect(mockedOnClick).toHaveBeenCalledWith(); + expect(mockedOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap b/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap new file mode 100644 index 0000000000..88ffb190a7 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DisconnectButton it renders properly 1`] = ` +
+ +
+`; diff --git a/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx b/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx new file mode 100644 index 0000000000..ec108f47b7 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +import { Button } from '@grafana/ui'; + +import WithConfirm from 'components/WithConfirm/WithConfirm'; + +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 = ({ onClick }) => ( + + + +); + +export default DisconnectButton; diff --git a/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss b/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss new file mode 100644 index 0000000000..6f41181e81 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss @@ -0,0 +1,16 @@ +.icon { + width: 25px; + height: auto; + margin-right: 12px; +} + +.icon-text, +.icon { + cursor: default; +} + +.icon-block { + display: flex; + align-items: center; + min-height: 80px; +} diff --git a/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx b/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx new file mode 100644 index 0000000000..bc2011e336 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import DownloadIcons from './'; + +describe('DownloadIcons', () => { + test('it renders properly', () => { + const component = render(); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap b/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap new file mode 100644 index 0000000000..63cd089997 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DownloadIcons it renders properly 1`] = ` +
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+`; diff --git a/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx b/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx new file mode 100644 index 0000000000..79b54f2e17 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; + +import { VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import AppleLogoSVG from 'assets/img/brand/apple-logo.svg'; +import PlayStoreLogoSVG from 'assets/img/brand/play-store-logo.svg'; +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; + +import styles from './DownloadIcons.module.scss'; + +const cx = cn.bind(styles); + +const DownloadIcons: FC = () => ( + + Download + The Grafana IRM app is available on both the App Store and Google Play Store. + + + Apple + + iOS + + + + Play Store + + Android + + + + +); + +export default DownloadIcons; diff --git a/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx b/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx new file mode 100644 index 0000000000..8c3e1554a4 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import QRCode from './'; + +describe('QRCode', () => { + test('it renders properly', () => { + const component = render(); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap b/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap new file mode 100644 index 0000000000..511496e956 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap @@ -0,0 +1,2221 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QRCode it renders properly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+`; diff --git a/src/containers/MobileAppVerification/parts/QRCode/index.tsx b/src/containers/MobileAppVerification/parts/QRCode/index.tsx new file mode 100644 index 0000000000..98ec356150 --- /dev/null +++ b/src/containers/MobileAppVerification/parts/QRCode/index.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react'; + +import QRCodeBase from 'react-qr-code'; + +import Block from 'components/GBlock/Block'; + +type Props = { + value: string; +}; + +const QRCode: FC = ({ value }) => ( + + + +); + +export default QRCode; diff --git a/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap b/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap index 08fcbc9eb4..8d36ea1716 100644 --- a/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap +++ b/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap @@ -209,7 +209,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
@@ -75,7 +79,7 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U showNotificationSettingsTab={showNotificationSettingsTab} showSlackConnectionTab={showSlackConnectionTab} showTelegramConnectionTab={showTelegramConnectionTab} - showMobileAppVerificationTab={showMobileAppVerificationTab} + showMobileAppVerificationTab={true} />
diff --git a/src/containers/UserSettings/parts/index.tsx b/src/containers/UserSettings/parts/index.tsx index 24221f4b15..9c486a581f 100644 --- a/src/containers/UserSettings/parts/index.tsx +++ b/src/containers/UserSettings/parts/index.tsx @@ -30,16 +30,14 @@ interface TabsProps { showTelegramConnectionTab: boolean; } -export const Tabs = (props: TabsProps) => { - const { - activeTab, - onTabChange, - showNotificationSettingsTab, - showMobileAppVerificationTab, - showSlackConnectionTab, - showTelegramConnectionTab, - } = props; - +export const Tabs = ({ + activeTab, + onTabChange, + showNotificationSettingsTab, + showMobileAppVerificationTab, + showSlackConnectionTab, + showTelegramConnectionTab, +}: TabsProps) => { const getTabClickHandler = useCallback( (tab: UserSettingsTab) => { return () => { @@ -106,17 +104,13 @@ interface TabsContentProps { isDesktopOrLaptop: boolean; } -export const TabsContent = observer((props: TabsContentProps) => { - const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; +export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLaptop }: TabsContentProps) => { + const store = useStore(); + useEffect(() => { store.updateFeatures(); }, []); - const store = useStore(); - const { userStore } = store; - - const storeUser = userStore.items[id]; - return ( {activeTab === UserSettingsTab.UserInfo && @@ -139,9 +133,8 @@ export const TabsContent = observer((props: TabsContentProps) => { ) : ( ))} - {activeTab === UserSettingsTab.MobileAppVerification && ( - - )} + {/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */} + {activeTab === UserSettingsTab.MobileAppVerification && } {activeTab === UserSettingsTab.SlackInfo && } {activeTab === UserSettingsTab.TelegramInfo && } diff --git a/src/models/user/user.test.ts b/src/models/user/user.test.ts new file mode 100644 index 0000000000..a8f0b68115 --- /dev/null +++ b/src/models/user/user.test.ts @@ -0,0 +1,56 @@ +import { makeRequest as makeRequestOriginal } from 'network'; +import { RootStore } from 'state'; + +import { UserStore } from './user'; + +const makeRequest = makeRequestOriginal as jest.Mock>; + +jest.mock('network'); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('UserStore.sendBackendConfirmationCode', () => { + const rootStore = new RootStore(); + const userStore = new UserStore(rootStore); + + const userPk = '5'; + const backend = 'dfkjfdjkfdkjfdaaa'; + const mockedQrCode = 'dfkjfdkjfdkjfdjk'; + + test('it makes the proper API call and returns the response', async () => { + makeRequest.mockResolvedValueOnce(mockedQrCode); + + expect(await userStore.sendBackendConfirmationCode(userPk, backend)).toEqual(mockedQrCode); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, { + method: 'GET', + }); + }); +}); + +describe('UserStore.unlinkBackend', () => { + const rootStore = new RootStore(); + const userStore = new UserStore(rootStore); + + const userPk = '5'; + const backend = 'dfkjfdjkfdkjfdaaa'; + + test('it makes the proper API call and returns the response', async () => { + makeRequest.mockResolvedValueOnce('hello'); + + userStore.loadCurrentUser = jest.fn(); + + await userStore.unlinkBackend(userPk, backend); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`/users/${userPk}/unlink_backend/?backend=${backend}`, { + method: 'POST', + }); + + expect(userStore.loadCurrentUser).toHaveBeenCalledTimes(1); + expect(userStore.loadCurrentUser).toHaveBeenCalledWith(); + }); +}); diff --git a/src/models/user/user.ts b/src/models/user/user.ts index 8d7ea00252..edcea94dcd 100644 --- a/src/models/user/user.ts +++ b/src/models/user/user.ts @@ -82,11 +82,6 @@ export class UserStore extends BaseStore { }; } - @action - getCurrentUser() { - return this.items[this.currentUserPk as User['pk']]; - } - @action async updateItem(userPk: User['pk']) { if (this.itemsCurrentlyUpdating[userPk]) { @@ -144,10 +139,6 @@ export class UserStore extends BaseStore { return await makeRequest(`/users/${userPk}/get_telegram_verification_code/`, {}); }; - sendBackendConfirmationCode = async (userPk: User['pk'], backend: string) => { - return await makeRequest(`/users/${userPk}/get_backend_verification_code/?backend=${backend}`, {}); - }; - @action unlinkSlack = async (userPk: User['pk']) => { await makeRequest(`/users/${userPk}/unlink_slack/`, { @@ -176,6 +167,11 @@ export class UserStore extends BaseStore { }; }; + sendBackendConfirmationCode = (userPk: User['pk'], backend: string) => + makeRequest(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, { + method: 'GET', + }); + @action unlinkBackend = async (userPk: User['pk'], backend: string) => { await makeRequest(`/users/${userPk}/unlink_backend/?backend=${backend}`, { diff --git a/src/style/vars.css b/src/style/vars.css index 8204231aac..bb0f28530d 100644 --- a/src/style/vars.css +++ b/src/style/vars.css @@ -34,6 +34,7 @@ --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); --oncall-icon-stroke-color: #fff; + --hover-selected: #f4f5f5; --background-canvas: #f4f5f5; --background-primary: #fff; --background-secondary: #f4f5f5; diff --git a/yarn.lock b/yarn.lock index a11f7b610c..1d3a045f9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10597,6 +10597,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ== + query-string@*: version "7.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1" @@ -11062,6 +11067,14 @@ react-popper@2.3.0, react-popper@^2.3.0: react-fast-compare "^3.0.1" warning "^4.0.2" +react-qr-code@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.8.tgz#d34a766fb5b664a40dbdc7020f7ac801bacb2851" + integrity sha512-zYO9EAPQU8IIeD6c6uAle7NlKOiVKs8ji9hpbWPTGxO+FLqBN2on+XCXQvnhm91nrRd306RvNXUkUNcXXSfhWA== + dependencies: + prop-types "^15.8.1" + qr.js "0.0.0" + react-redux@^7.2.0: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"