diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index b428aefb..2133b80f 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -222,12 +222,14 @@ class UserSerializer(ListUserSerializer): class CurrentUserSerializer(UserSerializer): rbac_permissions = UserPermissionSerializer(read_only=True, many=True, source="permissions") - class Meta: - model = User + class Meta(UserSerializer.Meta): fields = UserSerializer.Meta.fields + [ "rbac_permissions", + "google_oauth2_token_is_missing_scopes", + ] + read_only_fields = UserSerializer.Meta.read_only_fields + [ + "google_oauth2_token_is_missing_scopes", ] - read_only_fields = UserSerializer.Meta.read_only_fields class UserHiddenFieldsSerializer(ListUserSerializer): diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index 7ba330f9..85a15554 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -195,3 +195,30 @@ def test_google_complete_auth_redirect_ok( assert response.status_code == status.HTTP_302_FOUND assert response.url == "/a/grafana-oncall-app/users/me" + + +@pytest.mark.django_db +def test_complete_google_auth_redirect_error( + make_organization, + make_user_for_organization, + make_google_oauth2_token_for_user, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, google_oauth2_token = make_google_oauth2_token_for_user(admin) + + client = APIClient() + url = ( + reverse("api-internal:complete-social-auth", kwargs={"backend": "google-oauth2"}) + + f"?state={google_oauth2_token}" + ) + + def _custom_do_complete(backend, *args, **kwargs): + backend.strategy.session[REDIRECT_FIELD_NAME] = "some-url" + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + with patch("apps.api.views.auth.do_complete", side_effect=_custom_do_complete): + response = client.get(url) + + assert response.status_code == status.HTTP_302_FOUND + assert response.url == "some-url" diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index fbe8ebb9..a4468f89 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -66,6 +66,7 @@ def test_current_user( "avatar_full": user.avatar_full_url, "has_google_oauth2_connected": False, "google_calendar_settings": None, + "google_oauth2_token_is_missing_scopes": False, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 3bbc50ac..d1cd14c7 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -127,6 +127,7 @@ class CachedSchedulesContextMixin: return context +@extend_schema(responses={status.HTTP_200_OK: CurrentUserSerializer}) class CurrentUserView(APIView, CachedSchedulesContextMixin): authentication_classes = (MobileAppAuthTokenAuthentication, PluginAuthentication) permission_classes = (IsAuthenticated,) diff --git a/engine/apps/google/client.py b/engine/apps/google/client.py index bf7f9818..f4383bcc 100644 --- a/engine/apps/google/client.py +++ b/engine/apps/google/client.py @@ -104,25 +104,44 @@ class GoogleCalendarAPIClient: ) except HttpError as e: if e.status_code == 403: - # this scenario can be encountered when, for some reason, the OAuth2 token that we have + # this scenario can be encountered when, the OAuth2 token that we have # does not contain the https://www.googleapis.com/auth/calendar.events.readonly scope # example error: # # noqa: E501 + # + # this should really only occur for tokens granted prior to this commit (which wrote this comment). + # Before then we didn't handle the scenario where the Google oauth consent screen could potentially + # have checkboxes and users would have to actively check the checkbox to grant this scope. We now + # handle this scenario. + # + # References + # https://jpassing.com/2022/08/01/dealing-with-partial-consent-in-google-oauth-clients/ + # https://raintank-corp.slack.com/archives/C05AMEGMLCT/p1723556508149689 + # https://raintank-corp.slack.com/archives/C04JCU51NF8/p1723493330369349 logger.error(f"GoogleCalendarAPIClient - HttpError 403 when fetching out of office events: {e}") raise GoogleCalendarUnauthorizedHTTPError(e) logger.error(f"GoogleCalendarAPIClient - HttpError when fetching out of office events: {e}") raise GoogleCalendarGenericHTTPError(e) except RefreshError as e: - # TODO: come back and solve this properly once we get better logging output - # it seems like right now we are seeing RefreshError in two different scenarios: + # we see RefreshError in two different scenarios: # 1. RefreshError('invalid_grant: Account has been deleted', {'error': 'invalid_grant', 'error_description': 'Account has been deleted'}) # 2. RefreshError('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'}) + # # https://stackoverflow.com/a/49024030/3902555 + # + # in both of these cases the granted token is no longer good and we should delete it + + try: + error_details = e.args[1] # should be a dict like in the comment above + except IndexError: + error_details = None # catch this just in case + + error_description = error_details.get("error_description") if error_details else None + logger.error( f"GoogleCalendarAPIClient - RefreshError when fetching out of office events: {e} " - # NOTE: remove e.args after debugging how to dig into the error details - f"args={e.args}" + f"error_description={error_description}" ) raise GoogleCalendarRefreshError(e) diff --git a/engine/apps/google/constants.py b/engine/apps/google/constants.py index f0f58bae..b2b2bb40 100644 --- a/engine/apps/google/constants.py +++ b/engine/apps/google/constants.py @@ -1,3 +1,5 @@ +from django.conf import settings + EVENT_SUMMARY_IGNORE_KEYWORD = "#grafana-oncall-ignore" GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" @@ -6,3 +8,4 @@ https://stackoverflow.com/a/17159470/3902555 """ DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS = 30 +REQUIRED_OAUTH_SCOPES = settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE diff --git a/engine/apps/google/tasks.py b/engine/apps/google/tasks.py index b8efc5bc..7c0f4b4d 100644 --- a/engine/apps/google/tasks.py +++ b/engine/apps/google/tasks.py @@ -1,6 +1,7 @@ import logging from celery.utils.log import get_task_logger +from django.db.models import Q from apps.google import constants from apps.google.client import ( @@ -39,15 +40,24 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N try: out_of_office_events = google_api_client.fetch_out_of_office_events() except GoogleCalendarUnauthorizedHTTPError: - # this happens because the user's access token is (somehow) missing the - # https://www.googleapis.com/auth/calendar.events.readonly scope - # they will need to reconnect their Google account and grant us the necessary scopes, retrying will not help - logger.exception(f"Failed to fetch out of office events for user {user_id} due to an unauthorized HTTP error") - # TODO: come back and solve this properly once we get better logging output - # user.reset_google_oauth2_settings() + # this means the user's current token is missing the required scopes, don't delete their token for now + # we'll notify them via the plugin UI and ask them to reauth and grant us the missing scope + logger.warning( + f"Failed to fetch out of office events for user {user_id} due to missing required scopes. " + "Safe to skip for now" + ) return - except (GoogleCalendarRefreshError, GoogleCalendarGenericHTTPError): - logger.exception(f"Failed to fetch out of office events for user {user_id}") + except GoogleCalendarRefreshError: + # in this scenarios there's really not much we can do with the refresh/access token that we + # have available. The user will need to re-connect with Google so lets delete their persisted token + + logger.exception( + f"Failed to fetch out of office events for user {user_id} due to an invalid access and/or refresh token" + ) + user.reset_google_oauth2_settings() + return + except GoogleCalendarGenericHTTPError: + logger.exception(f"Failed to fetch out of office events for user {user_id} due to a generic HTTP error") return for out_of_office_event in out_of_office_events: @@ -118,5 +128,16 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N @shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True) def sync_out_of_office_calendar_events_for_all_users() -> None: - for google_oauth2_user in GoogleOAuth2User.objects.filter(user__organization__deleted_at__isnull=True): + # some existing tokens may not have all the required scopes, lets skip these + tokens_containing_required_scopes = GoogleOAuth2User.objects.filter( + *[Q(oauth_scope__contains=scope) for scope in constants.REQUIRED_OAUTH_SCOPES], + user__organization__deleted_at__isnull=True, + ) + + logger.info( + f"Google OAuth2 tokens with the required scopes - " + f"{tokens_containing_required_scopes.count()}/{GoogleOAuth2User.objects.count()}" + ) + + for google_oauth2_user in tokens_containing_required_scopes: sync_out_of_office_calendar_events_for_user.apply_async(args=(google_oauth2_user.pk,)) diff --git a/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py b/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py index e4692fbe..77ad912f 100644 --- a/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py +++ b/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py @@ -1,11 +1,13 @@ import datetime -from unittest.mock import patch +from unittest.mock import call, patch import pytest from django.utils import timezone +from google.auth.exceptions import RefreshError from googleapiclient.errors import HttpError from apps.google import constants, tasks +from apps.google.models import GoogleOAuth2User from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest @@ -148,19 +150,61 @@ class MockResponse: @patch("apps.google.client.build") +@pytest.mark.parametrize( + "ErrorClass,http_status,should_reset_user_google_oauth2_settings,task_should_raise_exception", + [ + (RefreshError, None, True, False), + (HttpError, 401, False, False), + (HttpError, 500, False, False), + (HttpError, 403, False, False), + (Exception, None, False, True), + ], +) @pytest.mark.django_db -def test_sync_out_of_office_calendar_events_for_user_httperror(mock_google_api_client_build, test_setup): - mock_response = MockResponse(reason="forbidden", status=403) - mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.side_effect = HttpError( - resp=mock_response, content=b"error" - ) +def test_sync_out_of_office_calendar_events_for_user_error_scenarios( + mock_google_api_client_build, + ErrorClass, + http_status, + should_reset_user_google_oauth2_settings, + task_should_raise_exception, + test_setup, +): + if ErrorClass == HttpError: + mock_response = MockResponse(reason="forbidden", status=http_status) + exception = ErrorClass(resp=mock_response, content=b"error") + elif ErrorClass == RefreshError: + exception = ErrorClass( + "invalid_grant: Token has been expired or revoked.", + {"error": "invalid_grant", "error_description": "Token has been expired or revoked."}, + ) + else: + exception = ErrorClass() + + mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.side_effect = exception google_oauth2_user, schedule = test_setup([]) user = google_oauth2_user.user - tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk) + assert user.google_calendar_settings is not None - assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 0 + if task_should_raise_exception: + with pytest.raises(ErrorClass): + tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk) + else: + tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk) + + assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 0 + + user.refresh_from_db() + + google_oauth2_user_count = GoogleOAuth2User.objects.filter(user=user).count() + + if should_reset_user_google_oauth2_settings: + assert user.google_calendar_settings is None + assert google_oauth2_user_count == 0 + else: + assert user.google_calendar_settings is not None + assert google_oauth2_user_count == 1 @patch("apps.google.client.build") @@ -374,14 +418,50 @@ def test_sync_out_of_office_calendar_events_for_user_preexisting_shift_swap_requ assert _fetch_shift_swap_requests().count() == 1 +REQUIRED_SCOPE_1 = "https://www.googleapis.com/test/foo" +REQUIRED_SCOPE_2 = "https://www.googleapis.com/test/bar" + + +@patch("apps.google.tasks.constants.REQUIRED_OAUTH_SCOPES", [REQUIRED_SCOPE_1, REQUIRED_SCOPE_2]) @patch("apps.google.tasks.sync_out_of_office_calendar_events_for_user.apply_async") @pytest.mark.django_db -def test_sync_out_of_office_calendar_events_for_all_users( +def test_sync_out_of_office_calendar_events_for_all_users_only_called_for_tokens_having_all_required_scopes( + mock_sync_out_of_office_calendar_events_for_user, + make_organization_and_user, + make_user_for_organization, + make_google_oauth2_user_for_user, +): + organization, user1 = make_organization_and_user() + user2 = make_user_for_organization(organization) + user3 = make_user_for_organization(organization) + + missing_a_scope = f"{REQUIRED_SCOPE_1} foo_bar" + has_all_scopes = f"{REQUIRED_SCOPE_1} {REQUIRED_SCOPE_2} foo_bar" + + _ = make_google_oauth2_user_for_user(user1, oauth_scope=missing_a_scope) + user2_google_oauth2_user = make_google_oauth2_user_for_user(user2, oauth_scope=has_all_scopes) + user3_google_oauth2_user = make_google_oauth2_user_for_user(user3, oauth_scope=has_all_scopes) + + tasks.sync_out_of_office_calendar_events_for_all_users() + + assert len(mock_sync_out_of_office_calendar_events_for_user.mock_calls) == 2 + mock_sync_out_of_office_calendar_events_for_user.assert_has_calls( + [ + call(args=(user2_google_oauth2_user.pk,)), + call(args=(user3_google_oauth2_user.pk,)), + ], + any_order=True, + ) + + +@patch("apps.google.tasks.sync_out_of_office_calendar_events_for_user.apply_async") +@pytest.mark.django_db +def test_sync_out_of_office_calendar_events_for_all_users_filters_out_users_from_deleted_organizations( mock_sync_out_of_office_calendar_events_for_user, make_organization_and_user, make_google_oauth2_user_for_user, ): - organization, user = make_organization_and_user() + _, user = make_organization_and_user() google_oauth2_user = make_google_oauth2_user_for_user(user) deleted_organization, deleted_user = make_organization_and_user() diff --git a/engine/apps/google/tests/test_utils.py b/engine/apps/google/tests/test_utils.py new file mode 100644 index 00000000..038c3b8a --- /dev/null +++ b/engine/apps/google/tests/test_utils.py @@ -0,0 +1,18 @@ +import pytest + +from apps.google import utils + +SCOPES_ALWAYS_GRANTED = ( + "openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email" +) + + +@pytest.mark.parametrize( + "granted_scopes,expected", + ( + (SCOPES_ALWAYS_GRANTED, False), + (f"{SCOPES_ALWAYS_GRANTED} https://www.googleapis.com/auth/calendar.events.readonly", True), + ), +) +def test_user_granted_all_required_scopes(granted_scopes, expected): + assert utils.user_granted_all_required_scopes(granted_scopes) == expected diff --git a/engine/apps/google/utils.py b/engine/apps/google/utils.py index ce30cd99..7498d81a 100644 --- a/engine/apps/google/utils.py +++ b/engine/apps/google/utils.py @@ -3,6 +3,14 @@ import datetime from apps.google import constants +def user_granted_all_required_scopes(user_granted_scopes: str) -> bool: + """ + `user_granted_scopes` should be a space-separated string of scopes + """ + granted_scopes = user_granted_scopes.split(" ") + return all(scope in granted_scopes for scope in constants.REQUIRED_OAUTH_SCOPES) + + def datetime_strftime(dt: datetime.datetime) -> str: return dt.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT) diff --git a/engine/apps/social_auth/exceptions.py b/engine/apps/social_auth/exceptions.py index 23835ccb..8912c9a5 100644 --- a/engine/apps/social_auth/exceptions.py +++ b/engine/apps/social_auth/exceptions.py @@ -1,2 +1,5 @@ class InstallMultiRegionSlackException(Exception): pass + + +GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR = "missing_granted_scope" diff --git a/engine/apps/social_auth/pipeline/google.py b/engine/apps/social_auth/pipeline/google.py index bed5996b..d79a4bae 100644 --- a/engine/apps/social_auth/pipeline/google.py +++ b/engine/apps/social_auth/pipeline/google.py @@ -1,24 +1,51 @@ import logging import typing +from urllib.parse import urljoin import requests -from rest_framework.response import Response +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.http import HttpResponse +from rest_framework import status from social_core.backends.base import BaseAuth -from apps.user_management.models import User +from apps.google.utils import user_granted_all_required_scopes +from apps.social_auth.exceptions import GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR +from apps.social_auth.types import GoogleOauth2Response +from apps.user_management.models import Organization, User logger = logging.getLogger(__name__) -def persist_access_and_refresh_tokens(backend: typing.Type[BaseAuth], response: Response, user: User, *args, **kwargs): - """ - TODO: what happens if `refresh_token` is not present? what are the scenarios that lead to this? +def connect_user_to_google( + strategy, + response: GoogleOauth2Response, + user: User, + organization: Organization, + *args, + **kwargs, +): + granted_scopes = response.get("scope", "") - NOTE: I think it's only included when the user initially grants access to our Google OAuth2 app - on subsequent logins, the refresh_token is not included in the response, only access_token.. what to do here? + if not user_granted_all_required_scopes(granted_scopes): + logger.warning( + f"User {user.pk} did not grant all required scopes, redirecting w/ error message " + f"granted_scopes={granted_scopes}" + ) + + base_url_to_redirect = urljoin(organization.grafana_url, "/a/grafana-oncall-app/users/me") + strategy.session[ + REDIRECT_FIELD_NAME + ] = f"{base_url_to_redirect}?google_error={GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR}" + + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + # at this point everything is correct and we can persist the Google OAuth2 token + generate any other relevant + # config for the user + # + # be sure to clear any pre-existing sessions, in case the user previously enecountered errors we want + # to be sure to clear these so they do not see them again + strategy.session.flush() - https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 - """ user.save_google_oauth2_settings(response) diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 97163883..376dd82a 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -20,6 +20,7 @@ from apps.api.permissions import ( RBACPermission, user_is_authorized, ) +from apps.google import utils as google_utils from apps.google.models import GoogleOAuth2User from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization from apps.user_management.types import AlertGroupTableColumn, GoogleCalendarSettings @@ -188,6 +189,12 @@ class User(models.Model): except ObjectDoesNotExist: return False + @property + def google_oauth2_token_is_missing_scopes(self) -> bool: + if not self.has_google_oauth2_connected: + return False + return not google_utils.user_granted_all_required_scopes(self.google_oauth2_user.oauth_scope) + @property def avatar_full_url(self): return urljoin(self.organization.grafana_url, self.avatar_url) @@ -386,7 +393,7 @@ class User(models.Model): logger.info( f"Saving Google OAuth2 settings for user {self.pk} " f"sub={google_oauth2_response.get('sub')} " - f"oauth_scope={google_oauth2_response.get('oauth_scope')}" + f"oauth_scope={google_oauth2_response.get('scope')}" ) _, created = GoogleOAuth2User.objects.update_or_create( diff --git a/engine/apps/user_management/tests/test_user.py b/engine/apps/user_management/tests/test_user.py index 54e95788..7d29770b 100644 --- a/engine/apps/user_management/tests/test_user.py +++ b/engine/apps/user_management/tests/test_user.py @@ -4,6 +4,7 @@ import pytest from django.utils import timezone from apps.api.permissions import LegacyAccessControlRole +from apps.google import constants as google_constants from apps.google.models import GoogleOAuth2User from apps.user_management.models import User @@ -120,6 +121,39 @@ def test_has_google_oauth2_connected(make_organization_and_user, make_google_oau assert user.has_google_oauth2_connected is True +@pytest.mark.django_db +def test_google_oauth2_token_is_missing_scopes(make_organization_and_user, make_google_oauth2_user_for_user): + initial_granted_scope = "foo bar baz" + initial_oauth_response = { + "access_token": "access", + "refresh_token": "refresh", + "sub": "google_user_id", + "scope": initial_granted_scope, + } + + _, user = make_organization_and_user() + + # false because the user hasn't yet connected their google account + assert user.google_oauth2_token_is_missing_scopes is False + + user.save_google_oauth2_settings(initial_oauth_response) + user.refresh_from_db() + + # true because we're missing a granted scope + assert user.google_oauth2_token_is_missing_scopes is True + + user.save_google_oauth2_settings( + { + **initial_oauth_response, + "scope": f"{initial_granted_scope} {' '.join(google_constants.REQUIRED_OAUTH_SCOPES)}", + } + ) + user.refresh_from_db() + + # False because we now have all the required scopes + assert user.google_oauth2_token_is_missing_scopes is False + + @pytest.mark.django_db def test_save_google_oauth2_settings(make_organization_and_user): oauth_response = { diff --git a/engine/conftest.py b/engine/conftest.py index c4de1730..df5fcb01 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -63,6 +63,7 @@ from apps.base.tests.factories import ( UserNotificationPolicyLogRecordFactory, ) from apps.email.tests.factories import EmailMessageFactory +from apps.google import constants as google_constants from apps.google.tests.factories import GoogleOAuth2UserFactory from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory from apps.labels.tests.factories import ( @@ -1075,8 +1076,9 @@ def make_webhook_label_association(make_label_key_and_value): @pytest.fixture def make_google_oauth2_user_for_user(): - def _make_google_oauth2_user_for_user(user): - return GoogleOAuth2UserFactory(user=user) + def _make_google_oauth2_user_for_user(user, **kwargs): + oauth_scope = kwargs.pop("oauth_scope", " ".join(google_constants.REQUIRED_OAUTH_SCOPES)) + return GoogleOAuth2UserFactory(user=user, oauth_scope=oauth_scope, **kwargs) return _make_google_oauth2_user_for_user diff --git a/engine/settings/base.py b/engine/settings/base.py index e19be784..0112f002 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -346,6 +346,8 @@ SPECTACULAR_INCLUDED_PATHS = [ "/features", "/alertgroups", "/alert_receive_channels", + # current user endpoint 👇, without trailing slash we pick-up /user_group endpoints, which we don't want for now + "/user/", "/users", "/labels", # social auth routes @@ -740,7 +742,7 @@ SOCIAL_AUTH_PIPELINE = ( SOCIAL_AUTH_GOOGLE_OAUTH2_PIPELINE = ( "apps.social_auth.pipeline.common.set_user_and_organization_from_request", - "apps.social_auth.pipeline.google.persist_access_and_refresh_tokens", + "apps.social_auth.pipeline.google.connect_user_to_google", ) SOCIAL_AUTH_GOOGLE_OAUTH2_DISCONNECT_PIPELINE = ( diff --git a/grafana-plugin/src/containers/Alerts/Alerts.tsx b/grafana-plugin/src/containers/Alerts/Alerts.tsx index 3a5c44e0..5b6ccde8 100644 --- a/grafana-plugin/src/containers/Alerts/Alerts.tsx +++ b/grafana-plugin/src/containers/Alerts/Alerts.tsx @@ -3,11 +3,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Alert } from '@grafana/ui'; import cn from 'classnames/bind'; import { sanitize } from 'dompurify'; +import { observer } from 'mobx-react'; import { PluginLink } from 'components/PluginLink/PluginLink'; import { getSlackMessage } from 'containers/DefaultPageLayout/DefaultPageLayout.helpers'; import { SlackError } from 'containers/DefaultPageLayout/DefaultPageLayout.types'; import { getIfChatOpsConnected } from 'containers/DefaultPageLayout/helper'; +import { UserHelper } from 'models/user/user.helpers'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; @@ -24,9 +26,10 @@ const cx = cn.bind(styles); enum AlertID { CONNECTIVITY_WARNING = 'Connectivity Warning', + USER_GOOGLE_OAUTH2_TOKEN_MISSING_SCOPES = 'User Google OAuth2 token is missing scopes', } -export const Alerts = function () { +export const Alerts = observer(() => { const queryParams = useQueryParams(); const [showSlackInstallAlert, setShowSlackInstallAlert] = useState(); @@ -40,7 +43,7 @@ export const Alerts = function () { if (queryParams.get('slack_error')) { setShowSlackInstallAlert(queryParams.get('slack_error') as SlackError); - LocationHelper.update({ slack_error: undefined }, 'replace'); + LocationHelper.update({ slack_error: undefined }, 'partial'); } }, []); @@ -54,17 +57,24 @@ export const Alerts = function () { const store = useStore(); const { - userStore: { currentUser }, + userStore: { currentUser, currentUserPk }, organizationStore: { currentOrganization }, } = store; + const versionMismatchLocalStorageId = `version_mismatch_${store.backendVersion}_${plugin?.version}`; const isChatOpsConnected = getIfChatOpsConnected(currentUser); const isPhoneVerified = currentUser?.cloud_connection_status === 3 || currentUser?.verified_phone_number; const isDefaultNotificationsSet = currentUser?.notification_chain_verbal.default; const isImportantNotificationsSet = currentUser?.notification_chain_verbal.important; - if (!showSlackInstallAlert && !showBannerTeam() && !showMismatchWarning() && !showChannelWarnings()) { + if ( + !showSlackInstallAlert && + !showCurrentUserGoogleOAuth2TokenIsMissingScopes() && + !showBannerTeam() && + !showMismatchWarning() && + !showChannelWarnings() + ) { return null; } return ( @@ -79,6 +89,22 @@ export const Alerts = function () { {getSlackMessage(showSlackInstallAlert, currentOrganization, store.hasFeature(AppFeature.LiveSettings))} )} + {showCurrentUserGoogleOAuth2TokenIsMissingScopes() && ( + + Your Google OAuth2 token is missing some required permissions (you may have forgotten to check the necessary + checkboxes when connecting your Google account). To rectify this, please grant Grafana OnCall these + permissions by clicking{' '} + + here + {' '} + and re-connecting your Google account. + + )} {showBannerTeam() && ( Please make sure you have the same versions of the Grafana OnCall plugin and the Grafana OnCall engine, otherwise there could be issues with your Grafana OnCall installation! @@ -151,7 +177,7 @@ export const Alerts = function () { store.backendVersion && plugin?.version && store.backendVersion !== plugin?.version && - !getItem(`version_mismatch_${store.backendVersion}_${plugin?.version}`) + !getItem(versionMismatchLocalStorageId) ); } @@ -164,4 +190,24 @@ export const Alerts = function () { !getItem(AlertID.CONNECTIVITY_WARNING) ); } -}; + + /** + * tbh we don't really need the `currentUserPk` reference here... + * the only reason why it's here is to appease mobx. Without this reference, the `@computed` property + * on `UserStore.currentUser` doesn't recalculate and will just be stuck on returning `undefined`.. + * + * If we dereference `currentUserPk` here, even if we don't use it.. things just seem to work + * (what is this `mobx` wizardry?) + * + * Seems to be related to this https://stackoverflow.com/questions/77724466/mobx-computed-not-updating + */ + function showCurrentUserGoogleOAuth2TokenIsMissingScopes(): boolean { + return Boolean( + currentUserPk && + currentUser && + currentUser.has_google_oauth2_connected && + currentUser.google_oauth2_token_is_missing_scopes && + !getItem(AlertID.USER_GOOGLE_OAUTH2_TOKEN_MISSING_SCOPES) + ); + } +}); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.module.css b/grafana-plugin/src/containers/UserSettings/UserSettings.module.css index 389005b0..f30a20d1 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.module.css +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.module.css @@ -8,3 +8,18 @@ width: calc(100% - var(--spacing) * 2); /* allow lateral spacing */ max-width: 1100px; } + +.alerts-container { + display: flex; + flex-direction: column; + margin-bottom: 10px; + gap: 10px; + + &:empty { + display: none; + } +} + +.alert { + margin: 0; +} diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index 3e704c8f..2cc5bd78 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { HorizontalGroup, Modal } from '@grafana/ui'; +import { Alert, HorizontalGroup, Modal } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useMediaQuery } from 'react-responsive'; @@ -10,7 +10,9 @@ import { Tabs, TabsContent } from 'containers/UserSettings/parts/UserSettingsPar import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; +import { LocationHelper } from 'utils/LocationHelper'; import { BREAKPOINT_TABS } from 'utils/consts'; +import { useQueryParams } from 'utils/hooks'; import { UserSettingsTab } from './UserSettings.types'; @@ -18,6 +20,10 @@ import styles from './UserSettings.module.css'; const cx = cn.bind(styles); +enum GoogleError { + MISSING_GRANTED_SCOPE = 'missing_granted_scope', +} + interface UserFormProps { onHide: () => void; id: ApiSchemas['User']['pk'] | 'new'; @@ -26,6 +32,53 @@ interface UserFormProps { tab?: UserSettingsTab; } +function getGoogleMessage(googleError: GoogleError) { + if (googleError === GoogleError.MISSING_GRANTED_SCOPE) { + return ( + <> + Couldn't connect your Google account. You did not grant Grafana OnCall the necessary permissions. Please retry + and be sure to check any checkboxes which grant Grafana OnCall read access to your calendar events. + + ); + } + + return <>Couldn't connect your Google account.; +} + +const UserAlerts: React.FC = () => { + const queryParams = useQueryParams(); + const [showGoogleConnectAlert, setShowGoogleConnectAlert] = useState(); + + const handleCloseGoogleAlert = useCallback(() => { + setShowGoogleConnectAlert(undefined); + }, []); + + useEffect(() => { + if (queryParams.get('google_error')) { + setShowGoogleConnectAlert(queryParams.get('google_error') as GoogleError); + + LocationHelper.update({ google_error: undefined }, 'partial'); + } + }, []); + + if (!showGoogleConnectAlert) { + return null; + } + + return ( +
+ + {getGoogleMessage(showGoogleConnectAlert)} + +
+ ); +}; + export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: UserFormProps) => { const store = useStore(); const { userStore, organizationStore } = store; @@ -74,6 +127,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn return ( <> +
; readonly render_for_web: | { title: string; @@ -1370,22 +1405,22 @@ export interface components { source_link: string | null; } | Record; - dependent_alert_groups: components['schemas']['ShortAlertGroup'][]; + dependent_alert_groups: Array; root_alert_group: components['schemas']['ShortAlertGroup']; readonly status: number; /** @description Generate a link for AlertGroup to declare Grafana Incident by click */ readonly declare_incident_link: string; team: string | null; grafana_incident_id?: string | null; - readonly labels: components['schemas']['AlertGroupLabel'][]; + readonly labels: Array; readonly permalinks: { slack: string | null; slack_app: string | null; telegram: string | null; web: string; }; - readonly alerts: components['schemas']['Alert'][]; - readonly render_after_resolve_report_json: { + readonly alerts: Array; + readonly render_after_resolve_report_json: Array<{ time: string; action: string; /** @enum {string} */ @@ -1398,11 +1433,11 @@ export interface components { avatar: string; avatar_full: string; }; - }[]; + }>; readonly slack_permalink: string | null; /** Format: date-time */ readonly last_alert_at: string; - readonly paged_users: { + readonly paged_users: Array<{ id: number; username: string; name: string; @@ -1410,13 +1445,13 @@ export interface components { avatar: string; avatar_full: string; important: boolean; - }[]; - readonly external_urls: { + }>; + readonly external_urls: Array<{ integration: string; integration_type: string; external_id: string; url: string; - }[]; + }>; }; AlertGroupAttach: { root_alert_group_pk: string; @@ -1473,7 +1508,7 @@ export interface components { silenced_until?: string | null; /** Format: date-time */ readonly started_at: string; - readonly related_users: components['schemas']['UserShort'][]; + readonly related_users: Array; readonly render_for_web: | { title: string; @@ -1482,14 +1517,14 @@ export interface components { source_link: string | null; } | Record; - dependent_alert_groups: components['schemas']['ShortAlertGroup'][]; + dependent_alert_groups: Array; root_alert_group: components['schemas']['ShortAlertGroup']; readonly status: number; /** @description Generate a link for AlertGroup to declare Grafana Incident by click */ readonly declare_incident_link: string; team: string | null; grafana_incident_id?: string | null; - readonly labels: components['schemas']['AlertGroupLabel'][]; + readonly labels: Array; readonly permalinks: { slack: string | null; slack_app: string | null; @@ -1589,7 +1624,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1606,15 +1641,15 @@ export interface components { AlertReceiveChannelConnectedContactPoints: { uid: string; name: string; - contact_points: components['schemas']['AlertReceiveChannelConnectedContactPointsInner'][]; + contact_points: Array; }; AlertReceiveChannelConnectedContactPointsInner: { name: string; notification_connected: boolean; }; AlertReceiveChannelConnection: { - readonly source_alert_receive_channels: components['schemas']['AlertReceiveChannelSourceChannel'][]; - readonly connected_alert_receive_channels: components['schemas']['AlertReceiveChannelConnectedChannel'][]; + readonly source_alert_receive_channels: Array; + readonly connected_alert_receive_channels: Array; }; AlertReceiveChannelContactPoints: { uid: string; @@ -1656,7 +1691,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1741,7 +1776,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1749,6 +1784,45 @@ export interface components { }; /** @enum {integer} */ CloudConnectionStatusEnum: 0 | 1 | 2 | 3; + CurrentUser: { + readonly pk: string; + readonly organization: components['schemas']['FastOrganization']; + current_team?: string | null; + /** Format: email */ + readonly email: string; + readonly username: string; + readonly name: string; + readonly role: components['schemas']['RoleEnum']; + /** Format: uri */ + readonly avatar: string; + /** Format: uri */ + readonly avatar_full: string; + timezone?: string | null; + working_hours?: components['schemas']['WorkingHours']; + unverified_phone_number?: string | null; + /** @description Use property to highlight that _verified_phone_number should not be modified directly */ + readonly verified_phone_number: string | null; + readonly slack_user_identity: components['schemas']['SlackUserIdentity']; + readonly telegram_configuration: components['schemas']['TelegramToUserConnector']; + readonly messaging_backends: { + [key: string]: + | { + [key: string]: unknown; + } + | undefined; + }; + readonly notification_chain_verbal: { + default: string; + important: string; + }; + readonly cloud_connection_status: components['schemas']['CloudConnectionStatusEnum'] | null; + hide_phone_number?: boolean; + readonly has_google_oauth2_connected: boolean; + readonly is_currently_oncall: boolean; + google_calendar_settings?: components['schemas']['GoogleCalendarSettings']; + readonly rbac_permissions: Array; + readonly google_oauth2_token_is_missing_scopes: boolean; + }; /** @description This serializer is consistent with apps.api.serializers.labels.LabelPairSerializer, but allows null for value ID. */ CustomLabel: { key: components['schemas']['CustomLabelKey']; @@ -1813,17 +1887,14 @@ export interface components { inheritable: { [key: string]: boolean | undefined; }; - custom: components['schemas']['CustomLabel'][]; + custom: Array; template: string | null; }; /** - * @description * `alertmanager` - Alertmanager - * * `legacy_alertmanager` - (Legacy) AlertManager - * * `grafana` - Grafana - * * `grafana_alerting` - Grafana Alerting - * * `legacy_grafana_alerting` - (Legacy) Grafana Alerting - * * `formatted_webhook` - Formatted webhook + * @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook + * * `alertmanager` - Alertmanager + * * `formatted_webhook` - Formatted webhook * * `kapacitor` - Kapacitor * * `elastalert` - Elastalert * * `heartbeat` - Heartbeat @@ -1833,6 +1904,9 @@ export interface components { * * `slack_channel` - Slack Channel * * `zabbix` - Zabbix * * `direct_paging` - Direct paging + * * `grafana` - Grafana Legacy Alerting + * * `legacy_alertmanager` - (Legacy) AlertManager + * * `legacy_grafana_alerting` - (Deprecated) Grafana Alerting * * `servicenow` - ServiceNow * * `amazon_sns` - Amazon SNS * * `stackdriver` - Stackdriver @@ -1852,13 +1926,10 @@ export interface components { * @enum {string} */ IntegrationEnum: - | 'alertmanager' - | 'legacy_alertmanager' - | 'grafana' | 'grafana_alerting' - | 'legacy_grafana_alerting' - | 'formatted_webhook' | 'webhook' + | 'alertmanager' + | 'formatted_webhook' | 'kapacitor' | 'elastalert' | 'heartbeat' @@ -1868,6 +1939,9 @@ export interface components { | 'slack_channel' | 'zabbix' | 'direct_paging' + | 'grafana' + | 'legacy_alertmanager' + | 'legacy_grafana_alerting' | 'servicenow' | 'amazon_sns' | 'stackdriver' @@ -1906,7 +1980,7 @@ export interface components { }; LabelCreate: { key: components['schemas']['LabelRepr']; - values: components['schemas']['LabelRepr'][]; + values: Array; }; LabelKey: { id: string; @@ -1916,7 +1990,7 @@ export interface components { }; LabelOption: { key: components['schemas']['LabelKey']; - values: components['schemas']['LabelValue'][]; + values: Array; }; LabelPair: { key: components['schemas']['LabelKey']; @@ -1981,14 +2055,22 @@ export interface components { /** @enum {unknown} */ NullEnum: null; PaginatedAlertGroupListList: { + /** + * Format: uri + * @example http://api.example.org/accounts/?cursor=cD00ODY%3D" + */ next?: string | null; + /** + * Format: uri + * @example http://api.example.org/accounts/?cursor=cj0xJnA9NDg3 + */ previous?: string | null; - results?: components['schemas']['AlertGroupList'][]; + results: Array; page_size?: number; }; PaginatedAlertReceiveChannelPolymorphicList: { /** @example 123 */ - count?: number; + count: number; /** * Format: uri * @example http://api.example.org/accounts/?page=4 @@ -1999,14 +2081,14 @@ export interface components { * @example http://api.example.org/accounts/?page=2 */ previous?: string | null; - results?: components['schemas']['AlertReceiveChannelPolymorphic'][]; + results: Array; page_size?: number; current_page_number?: number; total_pages?: number; }; PaginatedUserPolymorphicList: { /** @example 123 */ - count?: number; + count: number; /** * Format: uri * @example http://api.example.org/accounts/?page=4 @@ -2017,7 +2099,7 @@ export interface components { * @example http://api.example.org/accounts/?page=2 */ previous?: string | null; - results?: components['schemas']['UserPolymorphic'][]; + results: Array; page_size?: number; current_page_number?: number; total_pages?: number; @@ -2057,7 +2139,7 @@ export interface components { readonly is_based_on_alertmanager?: boolean; readonly inbound_email?: string; readonly is_legacy?: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at?: string | null; @@ -2240,6 +2322,21 @@ export interface components { created_at: string; export_url: string; }; + UserFilters: { + name: string; + type: string; + href?: string; + global?: boolean; + default?: { + [key: string]: unknown; + }; + description?: string; + options: components['schemas']['UserFiltersOptions']; + }; + UserFiltersOptions: { + value: string; + display_name: number; + }; UserGetTelegramVerificationCode: { telegram_code: string; bot_link: string; @@ -2251,9 +2348,12 @@ export interface components { avatar_full: string; name: string; readonly timezone: string | null; - readonly teams: components['schemas']['FastTeam'][]; + readonly teams: Array; readonly is_currently_oncall: boolean; }; + UserPermission: { + readonly action: string; + }; UserPolymorphic: | components['schemas']['FilterUser'] | components['schemas']['UserIsCurrentlyOnCall'] @@ -2288,16 +2388,16 @@ export interface components { readonly last_response_log: string; integration_filter?: string[]; preset?: string | null; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; }; WorkingHours: { - monday: components['schemas']['WorkingHoursPeriod'][]; - tuesday: components['schemas']['WorkingHoursPeriod'][]; - wednesday: components['schemas']['WorkingHoursPeriod'][]; - thursday: components['schemas']['WorkingHoursPeriod'][]; - friday: components['schemas']['WorkingHoursPeriod'][]; - saturday: components['schemas']['WorkingHoursPeriod'][]; - sunday: components['schemas']['WorkingHoursPeriod'][]; + monday: Array; + tuesday: Array; + wednesday: Array; + thursday: Array; + friday: Array; + saturday: Array; + sunday: Array; }; WorkingHoursPeriod: { start: string; @@ -2316,13 +2416,10 @@ export interface operations { parameters: { query?: { id_ne?: string[]; - /** @description * `alertmanager` - Alertmanager - * * `legacy_alertmanager` - (Legacy) AlertManager - * * `grafana` - Grafana - * * `grafana_alerting` - Grafana Alerting - * * `legacy_grafana_alerting` - (Legacy) Grafana Alerting - * * `formatted_webhook` - Formatted webhook + /** @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook + * * `alertmanager` - Alertmanager + * * `formatted_webhook` - Formatted webhook * * `kapacitor` - Kapacitor * * `elastalert` - Elastalert * * `heartbeat` - Heartbeat @@ -2332,6 +2429,9 @@ export interface operations { * * `slack_channel` - Slack Channel * * `zabbix` - Zabbix * * `direct_paging` - Direct paging + * * `grafana` - Grafana Legacy Alerting + * * `legacy_alertmanager` - (Legacy) AlertManager + * * `legacy_grafana_alerting` - (Deprecated) Grafana Alerting * * `servicenow` - ServiceNow * * `amazon_sns` - Amazon SNS * * `stackdriver` - Stackdriver @@ -2348,7 +2448,7 @@ export interface operations { * * `jira` - Jira * * `zendesk` - Zendesk * * `appdynamics` - AppDynamics */ - integration?: ( + integration?: Array< | 'alertmanager' | 'amazon_sns' | 'appdynamics' @@ -2381,14 +2481,11 @@ export interface operations { | 'webhook' | 'zabbix' | 'zendesk' - )[]; - /** @description * `alertmanager` - Alertmanager - * * `legacy_alertmanager` - (Legacy) AlertManager - * * `grafana` - Grafana - * * `grafana_alerting` - Grafana Alerting - * * `legacy_grafana_alerting` - (Legacy) Grafana Alerting - * * `formatted_webhook` - Formatted webhook + >; + /** @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook + * * `alertmanager` - Alertmanager + * * `formatted_webhook` - Formatted webhook * * `kapacitor` - Kapacitor * * `elastalert` - Elastalert * * `heartbeat` - Heartbeat @@ -2398,6 +2495,9 @@ export interface operations { * * `slack_channel` - Slack Channel * * `zabbix` - Zabbix * * `direct_paging` - Direct paging + * * `grafana` - Grafana Legacy Alerting + * * `legacy_alertmanager` - (Legacy) AlertManager + * * `legacy_grafana_alerting` - (Deprecated) Grafana Alerting * * `servicenow` - ServiceNow * * `amazon_sns` - Amazon SNS * * `stackdriver` - Stackdriver @@ -2414,7 +2514,7 @@ export interface operations { * * `jira` - Jira * * `zendesk` - Zendesk * * `appdynamics` - AppDynamics */ - integration_ne?: ( + integration_ne?: Array< | 'alertmanager' | 'amazon_sns' | 'appdynamics' @@ -2447,10 +2547,10 @@ export interface operations { | 'webhook' | 'zabbix' | 'zendesk' - )[]; + >; /** @description * `0` - Debug * * `1` - Maintenance */ - maintenance_mode?: (0 | 1)[]; + maintenance_mode?: Array<0 | 1>; /** @description A page number within the paginated result set. */ page?: number; /** @description Number of results to return per page. */ @@ -2726,9 +2826,9 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['AlertReceiveChannelNewConnection'][]; - 'application/x-www-form-urlencoded': components['schemas']['AlertReceiveChannelNewConnection'][]; - 'multipart/form-data': components['schemas']['AlertReceiveChannelNewConnection'][]; + 'application/json': Array; + 'application/x-www-form-urlencoded': Array; + 'multipart/form-data': Array; }; }; responses: { @@ -2810,7 +2910,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelConnectedContactPoints'][]; + 'application/json': Array; }; }; }; @@ -3088,7 +3188,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Webhook'][]; + 'application/json': Array; }; }; }; @@ -3186,7 +3286,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelContactPoints'][]; + 'application/json': Array; }; }; }; @@ -3231,7 +3331,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelFilters'][]; + 'application/json': Array; }; }; }; @@ -3250,7 +3350,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelIntegrationOptions'][]; + 'application/json': Array; }; }; }; @@ -3330,7 +3430,7 @@ export interface operations { * * `1` - Acknowledged * * `2` - Resolved * * `3` - Silenced */ - status?: (0 | 1 | 2 | 3)[]; + status?: Array<0 | 1 | 2 | 3>; with_resolution_note?: boolean; }; header?: never; @@ -3701,7 +3801,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertGroupBulkActionOptions'][]; + 'application/json': Array; }; }; }; @@ -3720,7 +3820,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertGroupFilters'][]; + 'application/json': Array; }; }; }; @@ -3760,7 +3860,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LabelKey'][]; + 'application/json': Array; }; }; }; @@ -3779,7 +3879,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertGroupSilenceOptions'][]; + 'application/json': Array; }; }; }; @@ -3804,7 +3904,7 @@ export interface operations { * * `1` - Acknowledged * * `2` - Resolved * * `3` - Silenced */ - status?: (0 | 1 | 2 | 3)[]; + status?: Array<0 | 1 | 2 | 3>; with_resolution_note?: boolean; }; header?: never; @@ -3877,9 +3977,10 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': ( + 'application/json': Array< | 'msteams' | 'slack' + | 'unified_slack' | 'telegram' | 'live_settings' | 'grafana_cloud_notifications' @@ -3887,7 +3988,7 @@ export interface operations { | 'grafana_alerting_v2' | 'labels' | 'google_oauth2' - )[]; + >; }; }; }; @@ -3901,9 +4002,9 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['LabelCreate'][]; - 'application/x-www-form-urlencoded': components['schemas']['LabelCreate'][]; - 'multipart/form-data': components['schemas']['LabelCreate'][]; + 'application/json': Array; + 'application/x-www-form-urlencoded': Array; + 'multipart/form-data': Array; }; }; responses: { @@ -4056,7 +4157,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LabelKey'][]; + 'application/json': Array; }; }; }; @@ -4101,6 +4202,44 @@ export interface operations { }; }; }; + user_retrieve: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CurrentUser']; + }; + }; + }; + }; + user_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CurrentUser']; + }; + }; + }; + }; users_list: { parameters: { query?: { @@ -4170,7 +4309,7 @@ export interface operations { * * `1` - EDITOR * * `2` - VIEWER * * `3` - NONE */ - roles?: (0 | 1 | 2 | 3)[]; + roles?: Array<0 | 1 | 2 | 3>; /** @description A search term. */ search?: string; team?: string[]; @@ -4591,7 +4730,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + 'application/json': Array<{ schedule_id: string; schedule_name: string; is_oncall: boolean; @@ -4601,7 +4740,7 @@ export interface operations { start: string; /** Format: date-time */ end: string; - users: { + users: Array<{ display_name: string; pk: string; email: string; @@ -4615,7 +4754,7 @@ export interface operations { avatar_full: string; } | null; } | null; - }[]; + }>; missing_users: string[]; priority_level: number | null; source: string | null; @@ -4633,7 +4772,7 @@ export interface operations { start: string; /** Format: date-time */ end: string; - users: { + users: Array<{ display_name: string; pk: string; email: string; @@ -4647,7 +4786,7 @@ export interface operations { avatar_full: string; } | null; } | null; - }[]; + }>; missing_users: string[]; priority_level: number | null; source: string | null; @@ -4659,7 +4798,7 @@ export interface operations { pk: string; }; } | null; - }[]; + }>; }; }; }; @@ -4687,6 +4826,25 @@ export interface operations { }; }; }; + users_filters_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': Array; + }; + }; + }; + }; users_timezone_options_retrieve: { parameters: { query?: never;