Skip to content

Commit

Permalink
Merge pull request #880 from grafana/jorlando/mobile-app-qr-code
Browse files Browse the repository at this point in the history
Fetch/Display Mobile App QR Code
  • Loading branch information
joeyorlando authored Nov 28, 2022
2 parents 3582f9b + 5a4fc90 commit eb97797
Show file tree
Hide file tree
Showing 28 changed files with 8,067 additions and 139 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions engine/apps/mobile_app/backend.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,15 +13,19 @@ 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

# remove existing token before creating a new one
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
Expand Down
1 change: 1 addition & 0 deletions grafana-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions grafana-plugin/src/assets/img/brand/apple-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions grafana-plugin/src/assets/img/brand/play-store-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
}
20 changes: 17 additions & 3 deletions grafana-plugin/src/components/GBlock/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading

0 comments on commit eb97797

Please sign in to comment.