From 6b87ad74e9695fc77ae3c55fe0ea7ce9dfab2aec Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Thu, 19 Jan 2023 11:15:56 +0000 Subject: [PATCH] Enforce cloud connection to send push notifications on OSS (#1132) This PR modifies how OSS instances send mobile app push notifications. It also adds frontend warnings when user is trying to use the mobile app without connecting to cloud. - [x] Add public API authentication to `FCMRelayView` and throttle the view to 300 push notifications per instance per minute. This is similar to how SMS and phone call notifications work on OSS instances. - [x] Add frontend warnings based on cloud connectivity - [x] Fix/add frontend tests - [x] Add tests for FCMRelayView and mobile app backend ## Screenshots When a user tries to connect the mobile app in his settings and cloud is not connected (clicking "Connect Cloud OnCall" redirects to the "Cloud" tab): Screenshot 2023-01-12 at 18 48 58 When a user tries to use mobile push notifications as a personal notification step and cloud is not connected: Screenshot 2023-01-12 at 19 01 10 Now on the "Cloud" tab there's some info about the mobile app (the last section at the bottom of the page): Screenshot 2023-01-12 at 18 49 10 After connecting to the cloud instance, everything goes back to active and it's now possible to connect the mobile app: Screenshot 2023-01-12 at 19 08 27 After connecting the app the warning is gone: Screenshot 2023-01-12 at 19 07 00 --- engine/apps/mobile_app/fcm_relay.py | 26 ++- engine/apps/mobile_app/tasks.py | 45 ++++- engine/apps/mobile_app/tests/__init__.py | 0 .../apps/mobile_app/tests/test_fcm_relay.py | 81 +++++++++ .../apps/mobile_app/tests/test_notify_user.py | 171 ++++++++++++++++++ engine/apps/mobile_app/urls.py | 9 +- engine/conftest.py | 20 +- .../components/Policy/NotificationPolicy.tsx | 22 +++ .../MobileAppConnection.test.tsx | 42 ++++- .../MobileAppConnection.tsx | 29 ++- .../MobileAppConnection.test.tsx.snap | 53 ++++++ .../PersonalNotificationSettings.tsx | 7 + .../CloudPhoneSettings/CloudPhoneSettings.tsx | 4 +- grafana-plugin/src/models/cloud/cloud.ts | 7 + .../pages/settings/tabs/Cloud/CloudPage.tsx | 33 +++- .../src/state/rootBaseStore/index.ts | 11 +- 16 files changed, 527 insertions(+), 33 deletions(-) create mode 100644 engine/apps/mobile_app/tests/__init__.py create mode 100644 engine/apps/mobile_app/tests/test_fcm_relay.py create mode 100644 engine/apps/mobile_app/tests/test_notify_user.py diff --git a/engine/apps/mobile_app/fcm_relay.py b/engine/apps/mobile_app/fcm_relay.py index bcf63a55..717bfa34 100644 --- a/engine/apps/mobile_app/fcm_relay.py +++ b/engine/apps/mobile_app/fcm_relay.py @@ -5,25 +5,37 @@ from django.conf import settings from fcm_django.models import FCMDevice from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message from rest_framework import status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import UserRateThrottle from rest_framework.views import APIView +from apps.auth_token.auth import ApiTokenAuthentication from common.custom_celery_tasks import shared_dedicated_queue_retry_task task_logger = get_task_logger(__name__) task_logger.setLevel(logging.DEBUG) +class FCMRelayThrottler(UserRateThrottle): + scope = "fcm_relay" + rate = "300/m" + + class FCMRelayView(APIView): - # TODO: use public API authentication (then it would be required to connect to a cloud instance to use the app) - authentication_classes = [] - permission_classes = [] + """ + This view accepts push notifications from OSS instances and forwards these requests to FCM. + Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay. + The view uses public API authentication, so an OSS instance must be connected to cloud to use FCM relay. + """ + + authentication_classes = [ApiTokenAuthentication] + permission_classes = [IsAuthenticated] + throttle_classes = [FCMRelayThrottler] def post(self, request): - """ - This view accepts push notifications from OSS instances and forwards these requests to FCM. - Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay - """ + if not settings.FCM_RELAY_ENABLED: + return Response(status=status.HTTP_404_NOT_FOUND) try: token = request.data["token"] diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index d3198adb..4d9a098b 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -6,9 +6,13 @@ from celery.utils.log import get_task_logger from django.conf import settings from fcm_django.models import FCMDevice from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message +from requests import HTTPError +from rest_framework import status from apps.alerts.models import AlertGroup +from apps.base.utils import live_settings from apps.mobile_app.alert_rendering import get_push_notification_message +from apps.oss_installation.models import CloudConnector from apps.user_management.models import User from common.api_helpers.utils import create_engine_url from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -41,10 +45,10 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) logger.warning(f"User notification policy {notification_policy_pk} does not exist") return - device_to_notify = FCMDevice.objects.filter(user=user).first() - - # create an error log in case user has no devices set up - if not device_to_notify: + def _create_error_log_record(): + """ + Utility method to create a UserNotificationPolicyLogRecord with error + """ UserNotificationPolicyLogRecord.objects.create( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -54,7 +58,13 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) notification_step=notification_policy.step, notification_channel=notification_policy.notify_by, ) - logger.info(f"Error while sending a mobile push notification: user {user_pk} has no device set up") + + device_to_notify = FCMDevice.objects.filter(user=user).first() + + # create an error log in case user has no devices set up + if not device_to_notify: + _create_error_log_record() + logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up") return thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" @@ -116,8 +126,25 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};") if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME: - response = send_push_notification_to_fcm_relay(message) - logger.debug(f"FCM relay response: {response}") + # FCM relay uses cloud connection to send push notifications + if not CloudConnector.objects.exists(): + _create_error_log_record() + logger.error(f"Error while sending a mobile push notification: not connected to cloud") + return + + try: + response = send_push_notification_to_fcm_relay(message) + logger.debug(f"FCM relay response: {response}") + except HTTPError as e: + if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR: + # do not retry on HTTP client errors (4xx errors) + _create_error_log_record() + logger.error( + f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}" + ) + return + else: + raise else: response = device_to_notify.send_message(message) # NOTE: we may want to further handle the response from FCM, but for now lets simply log it out @@ -131,7 +158,9 @@ def send_push_notification_to_fcm_relay(message): """ url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - response = requests.post(url, json=json.loads(str(message))) + response = requests.post( + url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message)) + ) response.raise_for_status() return response diff --git a/engine/apps/mobile_app/tests/__init__.py b/engine/apps/mobile_app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mobile_app/tests/test_fcm_relay.py b/engine/apps/mobile_app/tests/test_fcm_relay.py new file mode 100644 index 00000000..23612eac --- /dev/null +++ b/engine/apps/mobile_app/tests/test_fcm_relay.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.mobile_app.fcm_relay import FCMRelayThrottler + + +@pytest.mark.django_db +def test_fcm_relay_disabled( + settings, + load_mobile_app_urls, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, +): + settings.FCM_RELAY_ENABLED = False + + organization, user, token = make_organization_and_user_with_plugin_token() + _, token = make_public_api_token(user, organization) + + client = APIClient() + url = reverse("mobile_app:fcm_relay") + + response = client.post(url, HTTP_AUTHORIZATION=token) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_fcm_relay_post( + settings, + load_mobile_app_urls, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, +): + settings.FCM_RELAY_ENABLED = True + + organization, user, token = make_organization_and_user_with_plugin_token() + _, token = make_public_api_token(user, organization) + + client = APIClient() + url = reverse("mobile_app:fcm_relay") + + data = { + "token": "test_registration_id", + "data": {}, + "apns": {}, + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_fcm_relay_ratelimit( + settings, + load_mobile_app_urls, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, +): + settings.FCM_RELAY_ENABLED = True + + organization, user, token = make_organization_and_user_with_plugin_token() + _, token = make_public_api_token(user, organization) + + client = APIClient() + url = reverse("mobile_app:fcm_relay") + + data = { + "token": "test_registration_id", + "data": {}, + "apns": {}, + } + + with patch.object(FCMRelayThrottler, "rate", "0/m"): + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS diff --git a/engine/apps/mobile_app/tests/test_notify_user.py b/engine/apps/mobile_app/tests/test_notify_user.py new file mode 100644 index 00000000..df18105c --- /dev/null +++ b/engine/apps/mobile_app/tests/test_notify_user.py @@ -0,0 +1,171 @@ +from unittest.mock import patch + +import pytest +from fcm_django.models import FCMDevice + +from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +from apps.mobile_app.tasks import notify_user_async +from apps.oss_installation.models import CloudConnector + +MOBILE_APP_BACKEND_ID = 5 +CLOUD_LICENSE_NAME = "Cloud" +OPEN_SOURCE_LICENSE_NAME = "OpenSource" + + +@pytest.mark.django_db +def test_notify_user_async_cloud( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user and connect a mobile device + organization, user = make_organization_and_user() + FCMDevice.objects.create(user=user, registration_id="test_device_id") + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # check FCM is contacted directly when using the cloud license + settings.LICENSE = CLOUD_LICENSE_NAME + with patch.object(FCMDevice, "send_message", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_called() + + +@pytest.mark.django_db +def test_notify_user_async_oss( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user and connect a mobile device + organization, user = make_organization_and_user() + FCMDevice.objects.create(user=user, registration_id="test_device_id") + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # create cloud connection + CloudConnector.objects.create(cloud_url="test") + + # check FCM relay is contacted when using the OSS license + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_called() + + +@pytest.mark.django_db +def test_notify_user_async_oss_no_device_connected( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user without mobile device + organization, user = make_organization_and_user() + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # create cloud connection + CloudConnector.objects.create(cloud_url="test") + + # check FCM relay is contacted when using the OSS license + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_not_called() + + log_record = alert_group.personal_log_records.last() + assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + + +@pytest.mark.django_db +def test_notify_user_async_oss_no_cloud_connection( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user and connect a mobile device + organization, user = make_organization_and_user() + FCMDevice.objects.create(user=user, registration_id="test_device_id") + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # check FCM relay is contacted when using the OSS license + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_not_called() + + log_record = alert_group.personal_log_records.last() + assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 8b14dc7c..2f0433d9 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -1,5 +1,3 @@ -from django.conf import settings - from apps.mobile_app.fcm_relay import FCMRelayView from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path @@ -14,7 +12,6 @@ urlpatterns = [ optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"), ] -if settings.FCM_RELAY_ENABLED: - urlpatterns += [ - optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"), - ] +urlpatterns += [ + optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"), +] diff --git a/engine/conftest.py b/engine/conftest.py index 353b194c..2a34be16 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -736,10 +736,12 @@ def make_integration_heartbeat(): return _make_integration_heartbeat -@pytest.fixture() -def load_slack_urls(settings): +def reload_urls(settings): + """ + Reloads Django URLs, especially useful when testing conditionally registered URLs + """ + clear_url_caches() - settings.FEATURE_SLACK_INTEGRATION_ENABLED = True urlconf = settings.ROOT_URLCONF if urlconf in sys.modules: reload(sys.modules[urlconf]) @@ -747,6 +749,18 @@ def load_slack_urls(settings): import_module(urlconf) +@pytest.fixture() +def load_slack_urls(settings): + settings.FEATURE_SLACK_INTEGRATION_ENABLED = True + reload_urls(settings) + + +@pytest.fixture() +def load_mobile_app_urls(settings): + settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED = True + reload_urls(settings) + + @pytest.fixture def make_region(): def _make_region(**kwargs): diff --git a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx index b897dfc7..b51c0d90 100644 --- a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx @@ -36,6 +36,8 @@ export interface NotificationPolicyProps { notifyByOptions?: NotifyBy[]; telegramVerified: boolean; phoneStatus: number; + isMobileAppConnected: boolean; + showCloudConnectionWarning: boolean; color: string; number: number; userAction: UserAction; @@ -132,6 +134,20 @@ export class NotificationPolicy extends React.ComponentCloud is not connected; + } + + if (!isMobileAppConnected) { + return Mobile app is not connected; + } + + return Mobile app is connected; + } + _renderTelegramNote() { const { telegramVerified } = this.props; @@ -203,6 +219,12 @@ export class NotificationPolicy extends React.Component{this._renderTelegramNote()}; + case 5: + return <>{this._renderMobileAppNote()}; + + case 6: + return <>{this._renderMobileAppNote()}; + default: return null; } diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx index 4967b9bb..5e8e3749 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { CloudStore } from 'models/cloud/cloud'; import { UserStore } from 'models/user/user'; import { User } from 'models/user/user.types'; import { RootStore } from 'state'; @@ -10,12 +12,33 @@ import { useStore as useStoreOriginal } from 'state/useStore'; import MobileAppConnection from './MobileAppConnection'; +jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({ + isTopNavbar: () => false, +})); + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { + topNav: false, + }, + }, +})); + +jest.mock('utils/authorization', () => ({ + ...jest.requireActual('utils/authorization'), + isUserActionAllowed: jest.fn().mockReturnValue(true), +})); + +jest.mock('@grafana/runtime', () => ({ + getLocationSrv: jest.fn(), +})); + jest.mock('state/useStore'); const useStore = useStoreOriginal as jest.Mock>; const loadUserMock = jest.fn().mockReturnValue(undefined); -const mockUseStore = (rest?: any, connected = false) => { +const mockUseStore = (rest?: any, connected = false, cloud_connected = true) => { const store = { userStore: { loadUser: loadUserMock, @@ -26,6 +49,11 @@ const mockUseStore = (rest?: any, connected = false) => { } as unknown as User, ...(rest ? rest : {}), } as unknown as UserStore, + cloudStore: { + getCloudConnectionStatus: jest.fn().mockReturnValue({ cloud_connection_status: cloud_connected }), + cloudConnectionStatus: { cloud_connection_status: cloud_connected }, + } as unknown as CloudStore, + hasFeature: jest.fn().mockReturnValue(true), } as unknown as RootStore; useStore.mockReturnValue(store); @@ -232,4 +260,16 @@ describe('MobileAppConnection', () => { { timeout: 6000 } ); }); + + test('it shows a warning when cloud is not connected', async () => { + mockUseStore({}, true, false); + + // Using MemoryRouter to avoid "Invariant failed: You should not use outside a " + const component = render( + + + + ); + expect(component.container).toMatchSnapshot(); + }); }); diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index f09d73dc..39de4a98 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -1,14 +1,17 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; +import { Button, 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 PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; import styles from './MobileAppConnection.module.scss'; import DisconnectButton from './parts/DisconnectButton/DisconnectButton'; @@ -29,7 +32,29 @@ const INTERVAL_POLLING = 5000; const BACKEND = 'MOBILE_APP'; const MobileAppConnection = observer(({ userPk }: Props) => { - const { userStore } = useStore(); + const store = useStore(); + const { userStore, cloudStore } = store; + + // Show link to cloud page for OSS instances with no cloud connection + if (store.hasFeature(AppFeature.CloudConnection) && !cloudStore.cloudConnectionStatus.cloud_connection_status) { + return ( + + Please connect Cloud OnCall to use the mobile app + {isUserActionAllowed(UserActions.OtherSettingsWrite) ? ( + + + + ) : ( + + You do not have permission to perform this action. Ask an admin to connect Cloud OnCall or upgrade your + permissions. + + )} + + ); + } const isMounted = useRef(false); const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState(isUserConnected()); diff --git a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap index d45f37d1..6284efb3 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap @@ -2774,6 +2774,59 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c `; +exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = ` +
+
+
+ + Please connect Cloud OnCall to use the mobile app + +
+ +
+
+`; + exports[`MobileAppConnection it shows an error message if there was an error disconnecting the mobile app 1`] = `
{title} @@ -134,6 +139,8 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin number={index + 1} telegramVerified={Boolean(user.telegram_configuration)} phoneStatus={getPhoneStatus()} + isMobileAppConnected={isMobileAppConnected} + showCloudConnectionWarning={showCloudConnectionWarning} slackTeamIdentity={store.teamStore.currentTeam?.slack_team_identity} slackUserIdentity={user.slack_user_identity} data={notificationPolicy} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index ecf2b50c..e6502cc5 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -122,7 +122,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { {isUserActionAllowed(UserActions.OtherSettingsWrite) ? ( - OnCall use Grafana Cloud for SMS and phone call notifications + OnCall uses Grafana Cloud for SMS and phone call notifications {syncing ? (