Merge pull request #880 from grafana/jorlando/mobile-app-qr-code
Fetch/Display Mobile App QR Code
This commit is contained in:
commit
eb97797d43
28 changed files with 8067 additions and 139 deletions
|
|
@ -15,12 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Errors and warnings that occur when rendering templates during notification or webhooks will now render and display the error/warning as the result.
|
||||
## v1.1.5 (2022-11-24)
|
||||
|
||||
### Added
|
||||
|
||||
- Added a QR code in the "Mobile App Verification" tab on the user settings modal to connect the mobile application to your OnCall instance
|
||||
|
||||
## v1.1.5 (2022-11-24)
|
||||
|
||||
### Fixed
|
||||
|
||||
- UI bug fixes for Grafana 9.3 ([#860](https://github.com/grafana/oncall/pull/860))
|
||||
- Bug fix for saving source link template ([#898](https://github.com/grafana/oncall/pull/898))
|
||||
|
||||
|
||||
## v1.1.4 (2022-11-23)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import json
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.base.messaging import BaseMessagingBackend
|
||||
from apps.mobile_app.tasks import notify_user_async
|
||||
|
||||
|
|
@ -9,7 +13,6 @@ class MobileAppBackend(BaseMessagingBackend):
|
|||
available_for_use = True
|
||||
template_fields = ["title"]
|
||||
|
||||
# TODO: add QR code generation (base64 encode?)
|
||||
def generate_user_verification_code(self, user):
|
||||
from apps.mobile_app.models import MobileAppVerificationToken
|
||||
|
||||
|
|
@ -17,7 +20,12 @@ class MobileAppBackend(BaseMessagingBackend):
|
|||
MobileAppVerificationToken.objects.filter(user=user).delete()
|
||||
|
||||
_, token = MobileAppVerificationToken.create_auth_token(user, user.organization)
|
||||
return token
|
||||
return json.dumps(
|
||||
{
|
||||
"token": token,
|
||||
"oncall_api_url": settings.BASE_URL,
|
||||
}
|
||||
)
|
||||
|
||||
def unlink_user(self, user):
|
||||
from apps.mobile_app.models import MobileAppAuthToken
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
3
grafana-plugin/src/assets/img/brand/apple-logo.svg
Normal file
3
grafana-plugin/src/assets/img/brand/apple-logo.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="39" viewBox="0 0 32 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.6151 3.80685C20.1136 0.516043 23.5871 0.5 23.5871 0.5C23.5871 0.5 24.1037 3.59392 21.6216 6.57435C18.9714 9.75679 15.959 9.23604 15.959 9.23604C15.959 9.23604 15.3933 6.73316 17.6151 3.80685V3.80685ZM16.2768 11.4033C17.5621 11.4033 19.9476 9.63768 23.0527 9.63768C28.3976 9.63768 30.5003 13.4383 30.5003 13.4383C30.5003 13.4383 26.3878 15.5395 26.3878 20.6379C26.3878 26.3893 31.5108 28.3714 31.5108 28.3714C31.5108 28.3714 27.9296 38.4442 23.0925 38.4442C20.8708 38.4442 19.1436 36.948 16.8027 36.948C14.4172 36.948 12.0499 38.5 10.5081 38.5C6.09106 38.5001 0.510803 28.9451 0.510803 21.2644C0.510803 13.7077 5.23413 9.74356 9.66441 9.74356C12.5445 9.74356 14.7795 11.4033 16.2768 11.4033V11.4033Z" fill="#CCCCDC" fill-opacity="0.65"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
6
grafana-plugin/src/assets/img/brand/play-store-logo.svg
Normal file
6
grafana-plugin/src/assets/img/brand/play-store-logo.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="33" height="35" viewBox="0 0 33 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.144928 1.65332C0.058842 1.94693 0 2.26361 0 2.62534V32.3797C0 32.7359 0.058842 33.0492 0.141569 33.3396L17.3422 17.5186C17.3422 17.5184 0.144928 1.65332 0.144928 1.65332Z" fill="#CCCCDC" fill-opacity="0.65"/>
|
||||
<path d="M24.0545 11.3435L3.97164 0.497705C3.17099 0.0249806 2.38278 -0.0948309 1.71686 0.0700307L18.99 16.0044L24.0545 11.3435Z" fill="#CCCCDC" fill-opacity="0.65"/>
|
||||
<path d="M31.4591 15.3426L26.0989 12.4475L20.6084 17.4965L26.2008 22.6577L31.416 19.877C33.9619 18.4685 33.0333 16.2013 31.4591 15.3426Z" fill="#CCCCDC" fill-opacity="0.65"/>
|
||||
<path d="M1.66586 34.9163C2.34199 35.0999 3.14948 34.99 3.97053 34.5051L24.1146 23.7671L18.9583 19.0105C18.9583 19.0106 1.66586 34.9163 1.66586 34.9163Z" fill="#CCCCDC" fill-opacity="0.65"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<HTMLElement> {
|
||||
bordered?: boolean;
|
||||
shadowed?: boolean;
|
||||
withBackground?: boolean;
|
||||
hover?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const Block: FC<BlockProps> = (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 (
|
||||
<div
|
||||
className={cx('root', className, {
|
||||
root_bordered: bordered,
|
||||
root_shadowed: shadowed,
|
||||
'root_with-background': withBackground,
|
||||
'root--fullWidth': fullWidth,
|
||||
'root--withBackground': withBackground,
|
||||
'root--hover': hover,
|
||||
})}
|
||||
style={style}
|
||||
{...rest}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
.input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.telegram-code {
|
||||
font-weight: bolder;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserStore } from 'models/user/user';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { RootStore } from 'state';
|
||||
import { useStore as useStoreOriginal } from 'state/useStore';
|
||||
|
||||
import MobileAppVerification from './MobileAppVerification';
|
||||
|
||||
jest.mock('state/useStore');
|
||||
|
||||
const useStore = useStoreOriginal as jest.Mock<ReturnType<typeof useStoreOriginal>>;
|
||||
|
||||
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(<MobileAppVerification userPk={USER_PK} />);
|
||||
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(<MobileAppVerification userPk={USER_PK} />);
|
||||
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(<MobileAppVerification userPk={USER_PK} />);
|
||||
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(<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);
|
||||
});
|
||||
|
||||
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(<MobileAppVerification userPk={USER_PK} />);
|
||||
|
||||
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(<MobileAppVerification userPk={USER_PK} />);
|
||||
|
||||
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(<MobileAppVerification userPk={USER_PK} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<HTMLElement> {
|
||||
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<boolean>(
|
||||
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<boolean>(!mobileAppIsCurrentlyConnected);
|
||||
const [QRCodeValue, setQRCodeValue] = useState<string>(null);
|
||||
const [errorFetchingQRCode, setErrorFetchingQRCode] = useState<string>(null);
|
||||
|
||||
const [showMobileAppVerificationToken, setShowMobileAppVerificationToken] = useState<string>(undefined);
|
||||
const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState<boolean>(false);
|
||||
const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState<boolean>(true);
|
||||
const [disconnectingMobileApp, setDisconnectingMobileApp] = useState<boolean>(false);
|
||||
const [errorDisconnectingMobileApp, setErrorDisconnectingMobileApp] = useState<string>(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 = <LoadingPlaceholder text="Loading..." />;
|
||||
} else if (errorFetchingQRCode || errorDisconnectingMobileApp) {
|
||||
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>
|
||||
);
|
||||
} 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.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('mobile-app-settings')}>
|
||||
{MobileAppVerificationTokenLoading ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<Text>Open Grafana OnCall mobile application and enter the following code to add the new device:</Text>
|
||||
</p>
|
||||
{isMobileAppVerificationTokenExisting ? (
|
||||
<>
|
||||
{showMobileAppVerificationToken !== undefined ? (
|
||||
<>
|
||||
<h1>{showMobileAppVerificationToken}</h1>
|
||||
<p>
|
||||
<Text>* This code is active only for a minute</Text>
|
||||
</p>
|
||||
<p>
|
||||
<WithPermissionControl userAction={action}>
|
||||
<Button
|
||||
onClick={handleCreateMobileAppVerificationToken}
|
||||
className={cx('iCal-button')}
|
||||
variant="secondary"
|
||||
>
|
||||
Refresh the code
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<WithPermissionControl userAction={action}>
|
||||
<Button
|
||||
onClick={handleCreateMobileAppVerificationToken}
|
||||
className={cx('iCal-button')}
|
||||
variant="secondary"
|
||||
>
|
||||
Get the code
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<Text>* Only iOS is currently supported</Text>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalGroup>
|
||||
<Block bordered withBackground>
|
||||
{content}
|
||||
</Block>
|
||||
<Block bordered withBackground>
|
||||
<DownloadIcons />
|
||||
</Block>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(<DisconnectButton onClick={() => {}} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('It calls the onClick handler when clicked', async () => {
|
||||
const mockedOnClick = jest.fn();
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<DisconnectButton onClick={mockedOnClick} />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`DisconnectButton it renders properly 1`] = `
|
||||
<div>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Disconnect
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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<Props> = ({ onClick }) => (
|
||||
<WithConfirm title="Are you sure to disconnect your mobile application?" confirmText="Remove">
|
||||
<Button variant="destructive" onClick={onClick} size="md">
|
||||
Disconnect
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
);
|
||||
|
||||
export default DisconnectButton;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(<DownloadIcons />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`DownloadIcons it renders properly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-1j7sh2x-vertical-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
The Grafana IRM app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1j7sh2x-vertical-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
|
||||
>
|
||||
<img
|
||||
alt="Apple"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 = () => (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="primary">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')}>
|
||||
<img src={AppleLogoSVG} alt="Apple" className={cx('icon')} />
|
||||
<Text type="primary" className={cx('icon-text')}>
|
||||
iOS
|
||||
</Text>
|
||||
</Block>
|
||||
<Block hover fullWidth bordered className={cx('icon-block')}>
|
||||
<img src={PlayStoreLogoSVG} alt="Play Store" className={cx('icon')} />
|
||||
<Text type="primary" className={cx('icon-text')}>
|
||||
Android
|
||||
</Text>
|
||||
</Block>
|
||||
</VerticalGroup>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
||||
export default DownloadIcons;
|
||||
|
|
@ -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(<QRCode value="helloooo" />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<Props> = ({ value }) => (
|
||||
<Block bordered>
|
||||
<QRCodeBase value={value} />
|
||||
</Block>
|
||||
);
|
||||
|
||||
export default QRCode;
|
||||
|
|
@ -209,7 +209,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="root info-block root_with-background"
|
||||
class="root info-block root--withBackground"
|
||||
>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
|
|
|
|||
|
|
@ -51,13 +51,17 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U
|
|||
const isModalWide =
|
||||
!isDesktopOrLaptop || activeTab === UserSettingsTab.UserInfo || activeTab === UserSettingsTab.PhoneVerification;
|
||||
|
||||
const [showNotificationSettingsTab, showSlackConnectionTab, showTelegramConnectionTab, showMobileAppVerificationTab] =
|
||||
[
|
||||
!isDesktopOrLaptop,
|
||||
isCurrent && teamStore.currentTeam?.slack_team_identity && !storeUser.slack_user_identity,
|
||||
isCurrent && !storeUser.telegram_configuration,
|
||||
store.hasFeature(AppFeature.MobileApp),
|
||||
];
|
||||
const [
|
||||
showNotificationSettingsTab,
|
||||
showSlackConnectionTab,
|
||||
showTelegramConnectionTab,
|
||||
_showMobileAppVerificationTab,
|
||||
] = [
|
||||
!isDesktopOrLaptop,
|
||||
isCurrent && teamStore.currentTeam?.slack_team_identity && !storeUser.slack_user_identity,
|
||||
isCurrent && !storeUser.telegram_configuration,
|
||||
store.hasFeature(AppFeature.MobileApp),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -75,7 +79,7 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U
|
|||
showNotificationSettingsTab={showNotificationSettingsTab}
|
||||
showSlackConnectionTab={showSlackConnectionTab}
|
||||
showTelegramConnectionTab={showTelegramConnectionTab}
|
||||
showMobileAppVerificationTab={showMobileAppVerificationTab}
|
||||
showMobileAppVerificationTab={true}
|
||||
/>
|
||||
<TabsContent id={id} activeTab={activeTab} onTabChange={onTabChange} isDesktopOrLaptop={isDesktopOrLaptop} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<TabContent className={cx('content')}>
|
||||
{activeTab === UserSettingsTab.UserInfo &&
|
||||
|
|
@ -139,9 +133,8 @@ export const TabsContent = observer((props: TabsContentProps) => {
|
|||
) : (
|
||||
<PhoneVerification userPk={id} />
|
||||
))}
|
||||
{activeTab === UserSettingsTab.MobileAppVerification && (
|
||||
<MobileAppVerification userPk={id} phone={storeUser.unverified_phone_number || '+'} />
|
||||
)}
|
||||
{/* 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 && <MobileAppVerification userPk={id} />}
|
||||
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
|
||||
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
|
||||
</TabContent>
|
||||
|
|
|
|||
56
grafana-plugin/src/models/user/user.test.ts
Normal file
56
grafana-plugin/src/models/user/user.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { makeRequest as makeRequestOriginal } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
import { UserStore } from './user';
|
||||
|
||||
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>(`/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}`, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue