address Google OAuth2 issues where user didn't grant us the https://www.googleapis.com/auth/calendar.events.readonly scope (#4802)

# What this PR does

Follow up PR to https://github.com/grafana/oncall/pull/4792

Basically if when communicating with Google Calendar's API we encounter
an HTTP 403, or the Google client throws a
`google.auth.exceptions.RefreshError` this means one of three things:
1. the refresh token we have persisted for the user is missing the
`https://www.googleapis.com/auth/calendar.events.readonly` scope (HTTP
403)
2. the Google user has been deleted
(`google.auth.exceptions.RefreshError`)
3. the refresh token has expired (`google.auth.exceptions.RefreshError`)

To prevent scenario 1 above from happening in the future we now will
check that the token has been granted the required scopes. If the user
doesn't grant us all the necessary scopes, we will show them an error
message in the UI:
https://www.loom.com/share/0055ef03192b4154b894c2221cecbd5f

For tokens that were granted prior to this PR and which are missing the
required scope, we will show the user a dismissible warning banner in
the UI letting them know that they will need to reconnect their account
and grant us the missing permissions (see [this second demo
video](https://www.loom.com/share/bf2ee8b840864a64893165370a892bcd)
showing this).

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.

---------

Co-authored-by: Dominik <dominik.broj@grafana.com>
This commit is contained in:
Joey Orlando 2024-08-14 18:02:34 -04:00 committed by GitHub
parent 29bd42c0b1
commit eb777f5415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 670 additions and 142 deletions

View file

@ -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):

View file

@ -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"

View file

@ -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))

View file

@ -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,)

View file

@ -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:
# <HttpError 403 when requesting https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=2024-08-08T14%3A00%3A00%2B0000&timeMax=2024-09-07T14%3A00%3A00%2B0000&maxResults=250&singleEvents=true&orderBy=startTime&eventTypes=outOfOffice&alt=json returned "Request had insufficient authentication scopes.". Details: "[{'message': 'Insufficient Permission', 'domain': 'global', 'reason': 'insufficientPermissions'}]"> # 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)

View file

@ -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

View file

@ -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,))

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -1,2 +1,5 @@
class InstallMultiRegionSlackException(Exception):
pass
GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR = "missing_granted_scope"

View file

@ -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)

View file

@ -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(

View file

@ -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 = {

View file

@ -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

View file

@ -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 = (

View file

@ -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<SlackError | undefined>();
@ -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))}
</Alert>
)}
{showCurrentUserGoogleOAuth2TokenIsMissingScopes() && (
<Alert
className={cx('alert')}
severity="warning"
title="User Google OAuth2 token is missing scopes"
onRemove={getRemoveAlertHandler(AlertID.USER_GOOGLE_OAUTH2_TOKEN_MISSING_SCOPES)}
>
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{' '}
<a onClick={UserHelper.handleConnectGoogle} className={cx('instructions-link')}>
here
</a>{' '}
and re-connecting your Google account.
</Alert>
)}
{showBannerTeam() && (
<Alert
className={cx('alert')}
@ -98,7 +124,7 @@ export const Alerts = function () {
className={cx('alert')}
severity="warning"
title={'Version mismatch!'}
onRemove={getRemoveAlertHandler(`version_mismatch_${store.backendVersion}_${plugin?.version}`)}
onRemove={getRemoveAlertHandler(versionMismatchLocalStorageId)}
>
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)
);
}
});

View file

@ -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;
}

View file

@ -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<GoogleError | undefined>();
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 (
<div className={cx('alerts-container')}>
<Alert
className={cx('alert')}
onRemove={handleCloseGoogleAlert}
severity="error"
title="Google integration error"
>
{getGoogleMessage(showGoogleConnectAlert)}
</Alert>
</div>
);
};
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 (
<>
<Modal title={title} className={cx('modal', 'modal-wide')} isOpen closeOnEscape={false} onDismiss={onHide}>
<UserAlerts />
<div className={cx('root')}>
<Tabs
onTabChange={onTabChange}

View file

@ -298,10 +298,10 @@ export class UserStore {
}
@computed
get currentUser() {
get currentUser(): undefined | ApiSchemas['CurrentUser'] {
if (!this.currentUserPk) {
return undefined;
}
return this.items[this.currentUserPk];
return this.items[this.currentUserPk] as ApiSchemas['CurrentUser'];
}
}

View file

@ -994,6 +994,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** @description overridden_login_social_auth starts the installation of integration which uses OAuth flow. */
get: operations['login_retrieve'];
put?: never;
post?: never;
@ -1010,6 +1011,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** @description overridden_login_social_auth starts the installation of integration which uses OAuth flow. */
get: operations['login_retrieve_2'];
put?: never;
post?: never;
@ -1019,6 +1021,22 @@ export interface paths {
patch?: never;
trace?: never;
};
'/user/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['user_retrieve'];
put: operations['user_update'];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/users/': {
parameters: {
query?: never;
@ -1295,6 +1313,23 @@ export interface paths {
patch?: never;
trace?: never;
};
'/users/filters/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Internal API endpoints for users. */
get: operations['users_filters_list'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/users/timezone_options/': {
parameters: {
query?: never;
@ -1361,7 +1396,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<components['schemas']['UserShort']>;
readonly render_for_web:
| {
title: string;
@ -1370,22 +1405,22 @@ export interface components {
source_link: string | null;
}
| Record<string, never>;
dependent_alert_groups: components['schemas']['ShortAlertGroup'][];
dependent_alert_groups: Array<components['schemas']['ShortAlertGroup']>;
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<components['schemas']['AlertGroupLabel']>;
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<components['schemas']['Alert']>;
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<components['schemas']['UserShort']>;
readonly render_for_web:
| {
title: string;
@ -1482,14 +1517,14 @@ export interface components {
source_link: string | null;
}
| Record<string, never>;
dependent_alert_groups: components['schemas']['ShortAlertGroup'][];
dependent_alert_groups: Array<components['schemas']['ShortAlertGroup']>;
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<components['schemas']['AlertGroupLabel']>;
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<components['schemas']['LabelPair']>;
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<components['schemas']['AlertReceiveChannelConnectedContactPointsInner']>;
};
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<components['schemas']['AlertReceiveChannelSourceChannel']>;
readonly connected_alert_receive_channels: Array<components['schemas']['AlertReceiveChannelConnectedChannel']>;
};
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<components['schemas']['LabelPair']>;
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<components['schemas']['LabelPair']>;
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<components['schemas']['UserPermission']>;
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<components['schemas']['CustomLabel']>;
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<components['schemas']['LabelRepr']>;
};
LabelKey: {
id: string;
@ -1916,7 +1990,7 @@ export interface components {
};
LabelOption: {
key: components['schemas']['LabelKey'];
values: components['schemas']['LabelValue'][];
values: Array<components['schemas']['LabelValue']>;
};
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<components['schemas']['AlertGroupList']>;
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<components['schemas']['AlertReceiveChannelPolymorphic']>;
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<components['schemas']['UserPolymorphic']>;
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<components['schemas']['LabelPair']>;
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<components['schemas']['FastTeam']>;
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<components['schemas']['LabelPair']>;
};
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<components['schemas']['WorkingHoursPeriod']>;
tuesday: Array<components['schemas']['WorkingHoursPeriod']>;
wednesday: Array<components['schemas']['WorkingHoursPeriod']>;
thursday: Array<components['schemas']['WorkingHoursPeriod']>;
friday: Array<components['schemas']['WorkingHoursPeriod']>;
saturday: Array<components['schemas']['WorkingHoursPeriod']>;
sunday: Array<components['schemas']['WorkingHoursPeriod']>;
};
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<components['schemas']['AlertReceiveChannelNewConnection']>;
'application/x-www-form-urlencoded': Array<components['schemas']['AlertReceiveChannelNewConnection']>;
'multipart/form-data': Array<components['schemas']['AlertReceiveChannelNewConnection']>;
};
};
responses: {
@ -2810,7 +2910,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['AlertReceiveChannelConnectedContactPoints'][];
'application/json': Array<components['schemas']['AlertReceiveChannelConnectedContactPoints']>;
};
};
};
@ -3088,7 +3188,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Webhook'][];
'application/json': Array<components['schemas']['Webhook']>;
};
};
};
@ -3186,7 +3286,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['AlertReceiveChannelContactPoints'][];
'application/json': Array<components['schemas']['AlertReceiveChannelContactPoints']>;
};
};
};
@ -3231,7 +3331,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['AlertReceiveChannelFilters'][];
'application/json': Array<components['schemas']['AlertReceiveChannelFilters']>;
};
};
};
@ -3250,7 +3350,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['AlertReceiveChannelIntegrationOptions'][];
'application/json': Array<components['schemas']['AlertReceiveChannelIntegrationOptions']>;
};
};
};
@ -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<components['schemas']['AlertGroupBulkActionOptions']>;
};
};
};
@ -3720,7 +3820,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['AlertGroupFilters'][];
'application/json': Array<components['schemas']['AlertGroupFilters']>;
};
};
};
@ -3760,7 +3860,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['LabelKey'][];
'application/json': Array<components['schemas']['LabelKey']>;
};
};
};
@ -3779,7 +3879,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['AlertGroupSilenceOptions'][];
'application/json': Array<components['schemas']['AlertGroupSilenceOptions']>;
};
};
};
@ -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<components['schemas']['LabelCreate']>;
'application/x-www-form-urlencoded': Array<components['schemas']['LabelCreate']>;
'multipart/form-data': Array<components['schemas']['LabelCreate']>;
};
};
responses: {
@ -4056,7 +4157,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['LabelKey'][];
'application/json': Array<components['schemas']['LabelKey']>;
};
};
};
@ -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<components['schemas']['UserFilters']>;
};
};
};
};
users_timezone_options_retrieve: {
parameters: {
query?: never;