Google OAuth2 flow + fetch Google Calendar OOO events (#4067)

# What this PR does

The following is deployed under a feature flag.

**How it works**
1. The user clicks on the "Connect using your Google account" button in
the user profile settings modal
2. The UI makes a call to `GET /api/internal/v1/login/google-oauth2`.
The backend has now been configured to add
`apps.social_auth.backends.GoogleOAuth2` as a "`social_auth` backend".
3. The backend will respond w/ a URL which points to the Google OAuth2
consent screen. The frontend then proceeds by sending the user to this
page. This URL includes the following query parameters (amongst others):
- `redirect_uri` - this will send the user back to
`/api/internal/v1/complete/google-oauth2` (ie. make another API call to
the OnCall backend to finalize the Google OAuth2 flow)
- `state` - this represents an
`apps.auth_token.models.GoogleOAuth2Token` token. This allows us to
identify the OnCall user once they've linked their Google account.
4. Once redirected back to `/api/internal/v1/complete/google-oauth2`,
this will complete the OAuth2 flow. At this point, the backend has
access to several pieces of information about the Google user, including
their `access_token` and `refresh_token`. We persist these (encrypted)
for future use to fetch the user's out-of-office calendar events
5. The response from the API call in 4 above ☝️ is HTTP 302 (redirect)
to `/a/grafana-oncall-app/users/me` (ie. open the user profile settings
modal). At this point the user will see that their account has been
connected and they can further configure the settings

![image](https://github.com/grafana/oncall/assets/9406895/c7673055-8485-4f9a-98df-b4f7347229ce)


## Which issue(s) this PR closes

Closes https://github.com/grafana/oncall-private/issues/2584

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required) - will be done in
https://github.com/grafana/oncall-private/issues/2591
- [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. - will be done in
https://github.com/grafana/oncall-private/issues/2591

---------

Co-authored-by: Dominik <dominik.broj@grafana.com>
Co-authored-by: Maxim Mordasov <maxim.mordasov@grafana.com>
This commit is contained in:
Joey Orlando 2024-04-02 14:59:03 -04:00 committed by GitHub
parent a35a8949eb
commit 59f727d4f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1254 additions and 93 deletions

View file

@ -1,6 +1,7 @@
import typing
from apps.user_management.constants import AlertGroupTableColumns, default_columns
from apps.user_management.constants import default_columns
from apps.user_management.types import AlertGroupTableColumns
if typing.TYPE_CHECKING:
from apps.user_management.models import User

View file

@ -31,6 +31,25 @@ class UserPermissionSerializer(serializers.Serializer):
action = serializers.CharField(read_only=True)
class GoogleCalendarSettingsSerializer(serializers.Serializer):
# # TODO: figure out how to get OrganizationFilteredPrimaryKeyRelatedField to work with many=True
# oncall_schedules_to_consider_for_shift_swaps =
# oncall_schedules_to_consider_for_shift_swaps = serializers.ListField(
# child=OrganizationFilteredPrimaryKeyRelatedField(
# queryset=OnCallSchedule.objects,
# required=False,
# allow_null=True,
# ),
# required=False,
# allow_null=True,
# )
oncall_schedules_to_consider_for_shift_swaps = serializers.ListField(
child=serializers.CharField(),
required=False,
allow_null=True,
)
class NotificationChainVerbal(typing.TypedDict):
default: str
important: str
@ -93,6 +112,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"notification_chain_verbal",
"cloud_connection_status",
"hide_phone_number",
"has_google_oauth2_connected",
]
read_only_fields = [
"email",
@ -100,6 +120,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"name",
"role",
"verified_phone_number",
"has_google_oauth2_connected",
]
def validate_working_hours(self, working_hours):
@ -169,10 +190,12 @@ class UserSerializer(ListUserSerializer):
context: UserSerializerContext
is_currently_oncall = serializers.SerializerMethodField()
google_calendar_settings = GoogleCalendarSettingsSerializer(required=False)
class Meta(ListUserSerializer.Meta):
fields = ListUserSerializer.Meta.fields + [
"is_currently_oncall",
"google_calendar_settings",
]
read_only_fields = ListUserSerializer.Meta.read_only_fields + [
"is_currently_oncall",

View file

@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponse
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@ -31,7 +32,7 @@ def test_complete_slack_auth_redirect_ok(
client = APIClient()
url = (
reverse("api-internal:complete-slack-auth", kwargs={"backend": backend_name})
reverse("api-internal:complete-social-auth", kwargs={"backend": backend_name})
+ f"?{SLACK_AUTH_TOKEN_NAME}={slack_token}"
)
@ -55,7 +56,7 @@ def test_complete_slack_auth_redirect_error(
client = APIClient()
url = (
reverse("api-internal:complete-slack-auth", kwargs={"backend": "slack-login"})
reverse("api-internal:complete-social-auth", kwargs={"backend": "slack-login"})
+ f"?{SLACK_AUTH_TOKEN_NAME}={slack_token}"
)
@ -68,3 +69,56 @@ def test_complete_slack_auth_redirect_error(
assert response.status_code == status.HTTP_302_FOUND
assert response.url == "some-url"
@pytest.mark.django_db
@patch("apps.social_auth.backends.GoogleOAuth2.get_redirect_uri")
@patch("apps.social_auth.backends.GoogleOAuth2Token.create_auth_token", return_value=("something", "token_string"))
@override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE=["https://www.googleapis.com/auth/calendar.events.readonly"])
@override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_KEY="ouath2_key")
def test_google_start_auth_redirect_ok(
_mock_create_google_oauth2_auth_token,
mock_google_oauth2_backend_get_redirect_uri,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
redirect_uri = "http://testserver"
mock_google_oauth2_backend_get_redirect_uri.return_value = redirect_uri
_, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": "google-oauth2"})
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == (
"https://accounts.google.com/o/oauth2/auth?client_id=ouath2_key"
f"&redirect_uri={redirect_uri}&response_type=code"
"&state=token_string&scope=https://www.googleapis.com/auth/calendar.events.readonly+openid+email+profile"
"&access_type=offline&approval_prompt=auto"
)
@pytest.mark.django_db
@patch("apps.api.views.auth.do_complete", return_value=None)
def test_google_complete_auth_redirect_ok(
_mock_do_complete,
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}"
)
response = client.get(url)
assert response.status_code == status.HTTP_302_FOUND
assert response.url == "/a/grafana-oncall-app/users/me"

View file

@ -63,6 +63,8 @@ def test_current_user(
"slack_user_identity": None,
"avatar": user.avatar_url,
"avatar_full": user.avatar_full_url,
"has_google_oauth2_connected": False,
"google_calendar_settings": None,
}
response = client.get(url, format="json", **make_user_auth_headers(user, token))
@ -126,6 +128,44 @@ def test_update_user(
assert response.json()["current_team"] == data["current_team"]
@pytest.mark.parametrize("oncall_schedules_to_consider_for_shift_swaps", [True, False])
@pytest.mark.django_db
def test_update_user_google_calendar_settings(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_user_auth_headers,
make_schedule,
oncall_schedules_to_consider_for_shift_swaps,
):
organization = make_organization()
admin = make_user_for_organization(organization)
_, token = make_token_for_organization(organization)
schedule1 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
schedule2 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
client = APIClient()
url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key})
schedule_public_primary_keys = (
[schedule1.public_primary_key, schedule2.public_primary_key]
if oncall_schedules_to_consider_for_shift_swaps
else []
)
data = {
"google_calendar_settings": {
"oncall_schedules_to_consider_for_shift_swaps": schedule_public_primary_keys,
},
}
response = client.put(url, data, format="json", **make_user_auth_headers(admin, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["google_calendar_settings"] == {
"oncall_schedules_to_consider_for_shift_swaps": schedule_public_primary_keys,
}
@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False)
@pytest.mark.django_db
def test_update_user_cant_change_email_and_username(
@ -171,6 +211,8 @@ def test_update_user_cant_change_email_and_username(
"slack_user_identity": None,
"avatar": admin.avatar_url,
"avatar_full": admin.avatar_full_url,
"has_google_oauth2_connected": False,
"google_calendar_settings": None,
}
response = client.put(url, data, format="json", **make_user_auth_headers(admin, token))
assert response.status_code == status.HTTP_200_OK
@ -222,6 +264,7 @@ def test_list_users(
"avatar": admin.avatar_url,
"avatar_full": admin.avatar_full_url,
"cloud_connection_status": None,
"has_google_oauth2_connected": False,
},
{
"pk": editor.public_primary_key,
@ -247,6 +290,7 @@ def test_list_users(
"avatar": editor.avatar_url,
"avatar_full": editor.avatar_full_url,
"cloud_connection_status": None,
"has_google_oauth2_connected": False,
},
],
"current_page_number": 1,

View file

@ -106,9 +106,12 @@ urlpatterns = [
urlpatterns += [
# For some reason frontend is using url without / at the end. Hacking here to avoid 301's :(
path(r"login/<backend>", auth.overridden_login_slack_auth, name="slack-auth-with-no-slash"),
path(r"login/<backend>/", auth.overridden_login_slack_auth, name="slack-auth"),
path(r"complete/<backend>/", auth.overridden_complete_slack_auth, name="complete-slack-auth"),
# TODO: I'm fairly certain this is not needed anymore, it looks like the frontend instead
# makes calls w/ a trailing slash.. we can probably get rid of social-auth-with-no-slash
path(r"login/<backend>", auth.overridden_login_social_auth, name="social-auth-with-no-slash"),
path(r"login/<backend>/", auth.overridden_login_social_auth, name="social-auth"),
path(r"complete/<backend>/", auth.overridden_complete_social_auth, name="complete-social-auth"),
path(r"disconnect/<backend>", auth.overridden_disconnect_social_auth, name="disconnect-social-auth"),
]
urlpatterns += [

View file

@ -12,7 +12,8 @@ from apps.api.serializers.alert_group_table_settings import (
)
from apps.api.views.labels import LabelsFeatureFlagViewSet
from apps.auth_token.auth import PluginAuthentication
from apps.user_management.constants import AlertGroupTableColumn, default_columns
from apps.user_management.constants import default_columns
from apps.user_management.types import AlertGroupTableColumn
class AlertGroupTableColumnsViewSet(LabelsFeatureFlagViewSet):

View file

@ -7,12 +7,14 @@ from django.http import HttpResponse, HttpResponseRedirect
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, authentication_classes
from rest_framework.request import Request
from rest_framework.response import Response
from social_core.actions import do_auth, do_complete
from social_core.actions import do_auth, do_complete, do_disconnect
from social_core.backends.google import GoogleOAuth2
from social_django.utils import psa
from social_django.views import _do_login
from apps.auth_token.auth import PluginAuthentication, SlackTokenAuthentication
from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication
from apps.social_auth.backends import LoginSlackOAuth2V2
logger = logging.getLogger(__name__)
@ -22,32 +24,33 @@ logger = logging.getLogger(__name__)
@authentication_classes([PluginAuthentication])
@never_cache
@psa("social:complete")
def overridden_login_slack_auth(request, backend):
def overridden_login_social_auth(request: Request, backend: str) -> Response:
# We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS.
# So frontend can't follow our redirect.
# So wrapping and returning URL to redirect as a string.
if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
if "slack" in backend and settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
return Response(
"Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace",
status=400,
)
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
return Response(url_to_redirect_to, 200)
@api_view(["GET"])
@authentication_classes([SlackTokenAuthentication])
@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication])
@never_cache
@csrf_exempt
@psa("social:complete")
def overridden_complete_slack_auth(request, backend, *args, **kwargs):
def overridden_complete_social_auth(request: Request, backend: str, *args, **kwargs) -> Response:
"""Authentication complete view"""
# InstallSlackOAuth2V2 backend
redirect_to = "/a/grafana-oncall-app/chat-ops"
if isinstance(request.backend, LoginSlackOAuth2V2):
if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2)):
# if this was a user login/linking account, redirect to profile
redirect_to = "/a/grafana-oncall-app/users/me"
else:
# InstallSlackOAuth2V2 backend
redirect_to = "/a/grafana-oncall-app/chat-ops"
kwargs.update(
user=request.user,
@ -71,3 +74,13 @@ def overridden_complete_slack_auth(request, backend, *args, **kwargs):
# We build the frontend url using org url since multiple stacks could be connected to one backend.
return_to = urljoin(request.user.organization.grafana_url, redirect_to)
return HttpResponseRedirect(return_to)
@api_view(["GET"])
@authentication_classes([PluginAuthentication])
@never_cache
@psa("social:disconnect")
def overridden_disconnect_social_auth(request: Request, backend: str) -> Response:
if backend == "google-oauth2":
do_disconnect(request.backend, request.user)
return Response("ok", 200)

View file

@ -24,6 +24,7 @@ class Feature(enum.StrEnum):
# On OnCall side it do nothing, just indicating if OnCall API is ready to that integration.
GRAFANA_ALERTING_V2 = "grafana_alerting_v2"
LABELS = "labels"
GOOGLE_OAUTH2 = "google_oauth2"
class FeaturesAPIView(APIView):
@ -64,4 +65,7 @@ class FeaturesAPIView(APIView):
if is_labels_feature_enabled(self.request.auth.organization):
enabled_features.append(Feature.LABELS)
if settings.FEATURE_GOOGLE_OAUTH2_ENABLED:
enabled_features.append(Feature.GOOGLE_OAUTH2)
return enabled_features

View file

@ -1,6 +1,6 @@
import json
import logging
from typing import Tuple
import typing
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
@ -16,15 +16,24 @@ from apps.user_management.models import User
from apps.user_management.models.organization import Organization
from settings.base import SELF_HOSTED_SETTINGS
from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .exceptions import InvalidToken
from .grafana.grafana_auth_token import get_service_account_token_permissions
from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken
from .models.integration_backsync_auth_token import IntegrationBacksyncAuthToken
from .models import (
ApiAuthToken,
GoogleOAuth2Token,
IntegrationBacksyncAuthToken,
PluginAuthToken,
ScheduleExportAuthToken,
SlackAuthToken,
UserScheduleExportAuthToken,
)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
T = typing.TypeVar("T")
class ServerUser(AnonymousUser):
@property
@ -77,7 +86,7 @@ class BasePluginAuthentication(BaseAuthentication):
# Check parent's method comments
return "Bearer"
def authenticate(self, request: Request) -> Tuple[User, PluginAuthToken]:
def authenticate(self, request: Request) -> typing.Tuple[User, PluginAuthToken]:
token_string = get_authorization_header(request).decode()
if not token_string:
@ -85,7 +94,7 @@ class BasePluginAuthentication(BaseAuthentication):
return self.authenticate_credentials(token_string, request)
def authenticate_credentials(self, token_string: str, request: Request) -> Tuple[User, PluginAuthToken]:
def authenticate_credentials(self, token_string: str, request: Request) -> typing.Tuple[User, PluginAuthToken]:
context_string = request.headers.get("X-Instance-Context")
if not context_string:
raise exceptions.AuthenticationFailed("No instance context provided.")
@ -184,7 +193,7 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication):
# Check parent's method comments
return "Bearer"
def authenticate(self, request: Request) -> Tuple[GrafanaIncidentUser, None]:
def authenticate(self, request: Request) -> typing.Tuple[GrafanaIncidentUser, None]:
token_string = get_authorization_header(request).decode()
if (
@ -198,7 +207,7 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication):
return self.authenticate_credentials(token_string, request)
def authenticate_credentials(self, token_string: str, request: Request) -> Tuple[GrafanaIncidentUser, None]:
def authenticate_credentials(self, token_string: str, request: Request) -> typing.Tuple[GrafanaIncidentUser, None]:
try:
user = GrafanaIncidentUser()
except InvalidToken:
@ -207,29 +216,41 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication):
return user, None
class SlackTokenAuthentication(BaseAuthentication):
class _SocialAuthTokenAuthentication(BaseAuthentication, typing.Generic[T]):
def authenticate(self, request) -> typing.Optional[typing.Tuple[User, T]]:
"""
If you don't return `None`, the authenticate will raise an `APIException`, so the next authentication class
will not be called.
https://stackoverflow.com/a/61623607/3902555
This is useful for the social_auth views where we want to use multiple authentication classes
for the same view.
"""
auth = request.query_params.get(self.token_query_param_name)
if not auth:
return None
try:
auth_token = self.model.validate_token_string(auth)
return auth_token.user, auth_token
except InvalidToken:
return None
class SlackTokenAuthentication(_SocialAuthTokenAuthentication[SlackAuthToken]):
token_query_param_name = SLACK_AUTH_TOKEN_NAME
model = SlackAuthToken
def authenticate(self, request) -> Tuple[User, SlackAuthToken]:
auth = request.query_params.get(SLACK_AUTH_TOKEN_NAME)
if not auth:
raise exceptions.AuthenticationFailed("Invalid token.")
user, auth_token = self.authenticate_credentials(auth)
return user, auth_token
def authenticate_credentials(self, token_string: str) -> Tuple[User, SlackAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
raise exceptions.AuthenticationFailed("Invalid token.")
return auth_token.user, auth_token
class GoogleTokenAuthentication(_SocialAuthTokenAuthentication[GoogleOAuth2Token]):
token_query_param_name = GOOGLE_OAUTH2_AUTH_TOKEN_NAME
model = GoogleOAuth2Token
class ScheduleExportAuthentication(BaseAuthentication):
model = ScheduleExportAuthToken
def authenticate(self, request) -> Tuple[User, ScheduleExportAuthToken]:
def authenticate(self, request) -> typing.Tuple[User, ScheduleExportAuthToken]:
auth = request.query_params.get(SCHEDULE_EXPORT_TOKEN_NAME)
public_primary_key = request.parser_context.get("kwargs", {}).get("pk")
if not auth:
@ -240,7 +261,7 @@ class ScheduleExportAuthentication(BaseAuthentication):
def authenticate_credentials(
self, token_string: str, public_primary_key: str
) -> Tuple[User, ScheduleExportAuthToken]:
) -> typing.Tuple[User, ScheduleExportAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
@ -263,7 +284,7 @@ class ScheduleExportAuthentication(BaseAuthentication):
class UserScheduleExportAuthentication(BaseAuthentication):
model = UserScheduleExportAuthToken
def authenticate(self, request) -> Tuple[User, UserScheduleExportAuthToken]:
def authenticate(self, request) -> typing.Tuple[User, UserScheduleExportAuthToken]:
auth = request.query_params.get(SCHEDULE_EXPORT_TOKEN_NAME)
public_primary_key = request.parser_context.get("kwargs", {}).get("pk")
@ -275,7 +296,7 @@ class UserScheduleExportAuthentication(BaseAuthentication):
def authenticate_credentials(
self, token_string: str, public_primary_key: str
) -> Tuple[User, UserScheduleExportAuthToken]:
) -> typing.Tuple[User, UserScheduleExportAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
@ -363,7 +384,7 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication):
class IntegrationBacksyncAuthentication(BaseAuthentication):
model = IntegrationBacksyncAuthToken
def authenticate(self, request) -> Tuple[ServerUser, IntegrationBacksyncAuthToken]:
def authenticate(self, request) -> typing.Tuple[ServerUser, IntegrationBacksyncAuthToken]:
token = get_authorization_header(request).decode("utf-8")
if not token:
@ -371,7 +392,7 @@ class IntegrationBacksyncAuthentication(BaseAuthentication):
return self.authenticate_credentials(token)
def authenticate_credentials(self, token_string: str) -> Tuple[ServerUser, IntegrationBacksyncAuthToken]:
def authenticate_credentials(self, token_string: str) -> typing.Tuple[ServerUser, IntegrationBacksyncAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:

View file

@ -5,6 +5,12 @@ DIGEST_LENGTH = 128
MAX_PUBLIC_API_TOKENS_PER_USER = 5
SLACK_AUTH_TOKEN_NAME = "slack_login_token"
GOOGLE_OAUTH2_AUTH_TOKEN_NAME = "state"
"""
We must use the `state` query param, otherwise Google returns a 400 error.
https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response
"""
SCHEDULE_EXPORT_TOKEN_NAME = "token"
SCHEDULE_EXPORT_TOKEN_CHARACTER_LENGTH = 32

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.10 on 2024-03-19 10:27
import apps.auth_token.models.google_oauth2_token
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
('auth_token', '0005_integrationauthtoken'),
]
operations = [
migrations.CreateModel(
name='GoogleOAuth2Token',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_key', models.CharField(db_index=True, max_length=8)),
('digest', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(null=True)),
('expire_date', models.DateTimeField(default=apps.auth_token.models.google_oauth2_token.get_expire_date)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_auth_token_set', to='user_management.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_auth_token_set', to='user_management.user')),
],
options={
'abstract': False,
},
),
]

View file

@ -1,5 +1,6 @@
from .api_auth_token import ApiAuthToken # noqa: F401
from .base_auth_token import BaseAuthToken # noqa: F401
from .google_oauth2_token import GoogleOAuth2Token # noqa: F401
from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401
from .plugin_auth_token import PluginAuthToken # noqa: F401
from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401

View file

@ -0,0 +1,56 @@
from typing import Tuple
from django.db import models
from django.utils import timezone
from apps.auth_token import constants, crypto
from apps.auth_token.models import BaseAuthToken
from apps.user_management.models import Organization, User
from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS
def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS)
class GoogleOAuth2TokenQueryset(models.QuerySet):
def filter(self, *args, **kwargs):
now = timezone.now()
return super().filter(*args, **kwargs, revoked_at=None, expire_date__gte=now)
def delete(self):
self.update(revoked_at=timezone.now())
class GoogleOAuth2Token(BaseAuthToken):
"""
Not to be confused with `apps.google.models.GoogleOAuth2User` which is a model for storing user/token data that is
received from Google OAuth2 when the user completes the OAuth2 flow.
This model is primarly used for storing a token during the OAuth2 redirect flow to allow us to identify the user
after they've been redirected back to us.
"""
objects = GoogleOAuth2TokenQueryset.as_manager()
user = models.ForeignKey(
"user_management.User",
related_name="google_oauth2_auth_token_set",
on_delete=models.CASCADE,
)
organization = models.ForeignKey(
"user_management.Organization", related_name="google_oauth2_auth_token_set", on_delete=models.CASCADE
)
expire_date = models.DateTimeField(default=get_expire_date)
@classmethod
def create_auth_token(cls, user: User, organization: Organization) -> Tuple["GoogleOAuth2Token", str]:
token_string = crypto.generate_token_string()
digest = crypto.hash_token_string(token_string)
instance = cls.objects.create(
token_key=token_string[: constants.TOKEN_KEY_LENGTH],
digest=digest,
user=user,
organization=organization,
)
return instance, token_string

View file

@ -6,11 +6,11 @@ from django.utils import timezone
from apps.auth_token import constants, crypto
from apps.auth_token.models import BaseAuthToken
from apps.user_management.models import Organization, User
from settings.base import SLACK_AUTH_TOKEN_TIMEOUT_SECONDS
from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS
def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=SLACK_AUTH_TOKEN_TIMEOUT_SECONDS)
return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS)
class SlackAuthTokenQueryset(models.QuerySet):

View file

View file

@ -0,0 +1,76 @@
import datetime
import logging
import typing
from django.conf import settings
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from apps.google.types import GoogleCalendarEvent
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class GoogleCalendarAPIClient:
MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250
"""
By default the value is 250 events. The page size can never be larger than 2500 events
"""
CALENDAR_ID = "primary"
"""
for right now we only consider the user's primary calendar. If in the future we
want to allow the user to specify a different calendar, we'd need to [retrieve all their calendars](https://developers.google.com/calendar/v3/reference/calendarList/list)
, display this list to them + perist their choice
See `calendarId` under the "Parameters" section [here](https://developers.google.com/calendar/api/v3/reference/events/list)
"""
def __init__(self, access_token: str, refresh_token: str):
"""
https://developers.google.com/calendar/api/quickstart/python
https://google-auth.readthedocs.io/en/stable/reference/google.oauth2.credentials.html
"""
credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri="https://www.googleapis.com/oauth2/v3/token",
client_id=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY,
client_secret=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET,
)
self.service = build("calendar", "v3", credentials=credentials)
def fetch_out_of_office_events(self) -> typing.List[GoogleCalendarEvent]:
"""
https://developers.google.com/calendar/api/v3/reference/events/list
"""
def _format_datetime_arg(dt: datetime.datetime) -> str:
"""
https://stackoverflow.com/a/17159470/3902555
"""
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
now = _format_datetime_arg(datetime.datetime.now(datetime.UTC))
logger.info(
f"GoogleCalendarAPIClient - Getting the upcoming {self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH} "
"out of office events"
)
events_result = (
self.service.events()
.list(
calendarId=self.CALENDAR_ID,
timeMin=now,
# timeMax= TODO: should we only fetch out of office events for next X amount of time?
maxResults=self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH,
singleEvents=True,
orderBy="startTime",
eventTypes="outOfOffice",
)
.execute()
)
return events_result.get("items", [])

View file

@ -0,0 +1,29 @@
# Generated by Django 4.2.10 on 2024-03-17 19:54
from django.db import migrations, models
import django.db.models.deletion
import mirage.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
]
operations = [
migrations.CreateModel(
name='GoogleOAuth2User',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('google_user_id', models.CharField(max_length=100)),
('access_token', mirage.fields.EncryptedCharField(max_length=300)),
('refresh_token', mirage.fields.EncryptedCharField(max_length=300)),
('oauth_scope', models.TextField(max_length=30000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_user', to='user_management.user')),
],
),
]

View file

@ -0,0 +1 @@
from .google_oauth2_user import GoogleOAuth2User # noqa: F401

View file

@ -0,0 +1,14 @@
from django.db import models
from mirage import fields as mirage_fields
class GoogleOAuth2User(models.Model):
id = models.AutoField(primary_key=True)
user = models.OneToOneField(
to="user_management.User", null=False, blank=False, related_name="google_oauth2_user", on_delete=models.CASCADE
)
google_user_id = models.CharField(max_length=100)
access_token = mirage_fields.EncryptedCharField(max_length=300)
refresh_token = mirage_fields.EncryptedCharField(max_length=300)
oauth_scope = models.TextField(max_length=30000)
created_at = models.DateTimeField(auto_now_add=True)

View file

@ -0,0 +1,26 @@
import logging
from celery.utils.log import get_task_logger
from apps.google.client import GoogleCalendarAPIClient
from apps.google.models import GoogleOAuth2User
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True)
def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> None:
google_oauth2_user = GoogleOAuth2User.objects.get(pk=google_oauth2_user_pk)
google_api_client = GoogleCalendarAPIClient(google_oauth2_user.access_token, google_oauth2_user.refresh_token)
# NOTE: shift swap request generation will be done in https://github.com/grafana/oncall-private/issues/2590
# QUESTION: will we need to persist any information about these calendar events in our database?
google_api_client.fetch_out_of_office_events()
@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.all():
sync_out_of_office_calendar_events_for_user.apply_async(args=(google_oauth2_user.pk,))

View file

@ -0,0 +1,47 @@
import typing
class GoogleCalendarEventDate(typing.TypedDict):
date: typing.NotRequired[str]
"""
The date, in the format "yyyy-mm-dd", if this is an all-day event.
"""
dateTime: typing.NotRequired[str]
"""
The time, as a combined date-time value (formatted according to RFC3339).
A time zone offset is required unless a time zone is explicitly specified in timeZone.
"""
timeZone: typing.NotRequired[str]
"""
The time zone in which the time is specified. (Formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich".)
For recurring events this field is required and specifies the time zone in which the recurrence is expanded.
For single events this field is optional and indicates a custom time zone for the event start/end.
"""
class GoogleCalendarEvent(typing.TypedDict):
"""
https://developers.google.com/calendar/api/v3/reference/events#resource
"""
id: str
"""
Opaque identifier of the event
"""
start: GoogleCalendarEventDate
"""
The (inclusive) start time of the event. For a recurring event, this is the start time of the first instance.
"""
end: GoogleCalendarEventDate
"""
The (exclusive) end time of the event. For a recurring event, this is the end time of the first instance.
"""
summary: str
"""
Title of the event
"""

View file

@ -1,10 +1,11 @@
from urllib.parse import urljoin
from social_core.backends.google import GoogleOAuth2 as BaseGoogleOAuth2
from social_core.backends.slack import SlackOAuth2
from social_core.utils import handle_http_errors
from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
from apps.auth_token.models import SlackAuthToken
from apps.auth_token.models import GoogleOAuth2Token, SlackAuthToken
# Scopes for slack user token.
# It is main purpose - retrieve user data in SlackOAuth2V2 but we are using it in legacy code or weird Slack api cases.
@ -39,11 +40,37 @@ BOT_SCOPE = [
"users:write",
]
# Reference to Slack tokens: https://api.slack.com/authentication/token-types
class GoogleOAuth2(BaseGoogleOAuth2):
REDIRECT_STATE = False
"""
Remove redirect state because we lose session during redirects
"""
STATE_PARAMETER = False
"""
keep `False` to avoid having `BaseGoogleOAuth2` check the `state` query param against a session value
"""
def auth_params(self, state=None):
"""
Override to generate `GoogleOAuth2Token` token to include as `state` query parameter.
https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response
"""
params = super().auth_params(state)
_, token_string = GoogleOAuth2Token.create_auth_token(
self.strategy.request.user, self.strategy.request.auth.organization
)
params["state"] = token_string
return params
class SlackOAuth2V2(SlackOAuth2):
"""
Reference to Slack tokens: https://api.slack.com/authentication/token-types
Slack app with granular permissions require using SlackOauth2.0 V2.
SlackOAuth2V2 and its inheritors tune SlackOAuth2 implementation from social core to fit new endpoints
and response shapes.
@ -54,8 +81,10 @@ class SlackOAuth2V2(SlackOAuth2):
ACCESS_TOKEN_URL = "https://slack.com/api/oauth.v2.access"
AUTH_TOKEN_NAME = SLACK_AUTH_TOKEN_NAME
# Remove redirect state because we lose session during redirects
REDIRECT_STATE = False
"""
Remove redirect state because we lose session during redirects
"""
STATE_PARAMETER = False
EXTRA_DATA = [("id", "id"), ("name", "name"), ("real_name", "real_name"), ("team", "team")]

View file

@ -0,0 +1,27 @@
import typing
from django.http import HttpResponse
from rest_framework import status
from social_core.backends.base import BaseAuth
from social_core.exceptions import AuthForbidden
from social_core.strategy import BaseStrategy
from apps.user_management.models import Organization, User
class UserOrganizationKwargsResponse(typing.TypedDict):
user: User
organization: Organization
def set_user_and_organization_from_request(
backend: typing.Type[BaseAuth], strategy: typing.Type[BaseStrategy], *args, **kwargs
) -> UserOrganizationKwargsResponse:
user = strategy.request.user
organization = strategy.request.auth.organization
if user is None or organization is None:
return HttpResponse(str(AuthForbidden(backend)), status=status.HTTP_401_UNAUTHORIZED)
return {
"user": user,
"organization": organization,
}

View file

@ -0,0 +1,27 @@
import logging
import typing
from rest_framework.response import Response
from social_core.backends.base import BaseAuth
from apps.user_management.models import 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?
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?
https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9
"""
user.finish_google_oauth2_connection_flow(response)
def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: User, *args, **kwargs):
# 2nd argument, uid, is not needed for GoogleOauth2 backend
backend.revoke_token(user.google_oauth2_user.access_token, "")
user.finish_google_oauth2_disconnection_flow()

View file

@ -5,7 +5,6 @@ from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponse
from rest_framework import status
from social_core.exceptions import AuthForbidden
from apps.slack.tasks import populate_slack_channels_for_team, populate_slack_usergroups_for_team
from apps.social_auth.exceptions import InstallMultiRegionSlackException
@ -16,17 +15,6 @@ from common.oncall_gateway import can_link_slack_team_wrapper, link_slack_team_w
logger = logging.getLogger(__name__)
def set_user_and_organization_from_request(backend, strategy, *args, **kwargs):
user = strategy.request.user
organization = strategy.request.auth.organization
if user is None or organization is None:
return HttpResponse(str(AuthForbidden(backend)), status=status.HTTP_401_UNAUTHORIZED)
return {
"user": user,
"organization": organization,
}
def connect_user_to_slack(response, backend, strategy, user, organization, *args, **kwargs):
from apps.slack.models import SlackUserIdentity

View file

@ -0,0 +1,14 @@
import typing
class GoogleOauth2Response(typing.TypedDict):
sub: str
scope: str
access_token: str
refresh_token: typing.Optional[str]
"""
NOTE: I think `refresh_token` is 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`
https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9
"""

View file

@ -2,6 +2,8 @@ import typing
from django.db.models import TextChoices
from apps.user_management.types import AlertGroupTableColumn
class AlertGroupTableDefaultColumnChoices(TextChoices):
STATUS = "status", "Status"
@ -20,18 +22,6 @@ class AlertGroupTableColumnTypeChoices(TextChoices):
LABEL = "label"
class AlertGroupTableColumn(typing.TypedDict):
id: str
name: str
type: str
class AlertGroupTableColumns(typing.TypedDict):
visible: typing.List[AlertGroupTableColumn]
hidden: typing.List[AlertGroupTableColumn]
default: bool
def default_columns() -> typing.List[AlertGroupTableColumn]:
return [
{"name": column.label, "id": column.value, "type": AlertGroupTableColumnTypeChoices.DEFAULT.value}

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-03-18 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
]
operations = [
migrations.AddField(
model_name='user',
name='google_calendar_settings',
field=models.JSONField(default=None, null=True),
),
]

View file

@ -11,8 +11,8 @@ from django.utils import timezone
from mirage import fields as mirage_fields
from apps.alerts.models import MaintainableObject
from apps.user_management.constants import AlertGroupTableColumn
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from apps.user_management.types import AlertGroupTableColumn
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import (
register_oncall_tenant_wrapper,

View file

@ -7,6 +7,7 @@ from urllib.parse import urljoin
import pytz
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MinLengthValidator
from django.db import models, transaction
from django.db.models import Q
@ -20,8 +21,9 @@ from apps.api.permissions import (
RBACPermission,
user_is_authorized,
)
from apps.google.models import GoogleOAuth2User
from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization
from apps.user_management.constants import AlertGroupTableColumn
from apps.user_management.types import AlertGroupTableColumn, GoogleCalendarSettings
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
@ -31,6 +33,7 @@ if typing.TYPE_CHECKING:
from apps.auth_token.models import ApiAuthToken, ScheduleExportAuthToken, UserScheduleExportAuthToken
from apps.base.models import UserNotificationPolicy
from apps.slack.models import SlackUserIdentity
from apps.social_auth.types import GoogleOauth2Response
from apps.user_management.models import Organization, Team
logger = logging.getLogger(__name__)
@ -174,6 +177,7 @@ class User(models.Model):
auth_tokens: "RelatedManager['ApiAuthToken']"
current_team: typing.Optional["Team"]
escalation_policy_notify_queues: "RelatedManager['EscalationPolicy']"
google_oauth2_user: typing.Optional[GoogleOAuth2User]
last_notified_in_escalation_policies: "RelatedManager['EscalationPolicy']"
notification_policies: "RelatedManager['UserNotificationPolicy']"
organization: "Organization"
@ -239,6 +243,8 @@ class User(models.Model):
alert_group_table_selected_columns: list[AlertGroupTableColumn] | None = models.JSONField(default=None, null=True)
google_calendar_settings: GoogleCalendarSettings | None = models.JSONField(default=None, null=True)
def __str__(self):
return f"{self.pk}: {self.username}"
@ -252,6 +258,14 @@ class User(models.Model):
def is_authenticated(self):
return True
@property
def has_google_oauth2_connected(self) -> bool:
try:
# https://stackoverflow.com/a/35005034/3902555
return self.google_oauth2_user is not None
except ObjectDoesNotExist:
return False
@property
def avatar_full_url(self):
return urljoin(self.organization.grafana_url, self.avatar_url)
@ -458,6 +472,28 @@ class User(models.Model):
self.alert_group_table_selected_columns = columns
self.save(update_fields=["alert_group_table_selected_columns"])
def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None:
_obj, created = GoogleOAuth2User.objects.update_or_create(
user=self,
defaults={
"google_user_id": google_oauth2_response.get("sub"),
"access_token": google_oauth2_response.get("access_token"),
"refresh_token": google_oauth2_response.get("refresh_token"),
"oauth_scope": google_oauth2_response.get("scope"),
},
)
if created:
self.google_calendar_settings = {
"oncall_schedules_to_consider_for_shift_swaps": [],
}
self.save(update_fields=["google_calendar_settings"])
def finish_google_oauth2_disconnection_flow(self) -> None:
GoogleOAuth2User.objects.filter(user=self).delete()
self.google_calendar_settings = None
self.save(update_fields=["google_calendar_settings"])
# TODO: check whether this signal can be moved to save method of the model
@receiver(post_save, sender=User)

View file

@ -518,6 +518,8 @@ def test_sync_organization_lock(make_organization):
@dataclass
class TestSyncGrafanaLabelsPluginParams:
__test__ = False
response: tuple
expected_result: bool
@ -548,6 +550,8 @@ def test_sync_grafana_labels_plugin(make_organization, test_params: TestSyncGraf
@dataclass
class TestSyncGrafanaIncidentParams:
__test__ = False
response: tuple
expected_flag: bool
expected_url: Optional[str]

View file

@ -0,0 +1,20 @@
import typing
class AlertGroupTableColumn(typing.TypedDict):
id: str
name: str
type: str
class AlertGroupTableColumns(typing.TypedDict):
visible: typing.List[AlertGroupTableColumn]
hidden: typing.List[AlertGroupTableColumn]
default: bool
class GoogleCalendarSettings(typing.TypedDict):
oncall_schedules_to_consider_for_shift_swaps: typing.Optional[typing.List[str]]
"""
`public_primary_key` of specific OnCall schedules that should be considered for autogenerated shift swap requests.
"""

View file

@ -45,7 +45,13 @@ from apps.api.permissions import (
LegacyAccessControlRole,
RBACPermission,
)
from apps.auth_token.models import ApiAuthToken, IntegrationBacksyncAuthToken, PluginAuthToken, SlackAuthToken
from apps.auth_token.models import (
ApiAuthToken,
GoogleOAuth2Token,
IntegrationBacksyncAuthToken,
PluginAuthToken,
SlackAuthToken,
)
from apps.base.models.user_notification_policy_log_record import (
UserNotificationPolicyLogRecord,
listen_for_usernotificationpolicylogrecord_model_save,
@ -283,6 +289,14 @@ def make_slack_token_for_user():
return _make_slack_token_for_user
@pytest.fixture
def make_google_oauth2_token_for_user():
def _make_google_oauth2_token_for_user(user):
return GoogleOAuth2Token.create_auth_token(organization=user.organization, user=user)
return _make_google_oauth2_token_for_user
@pytest.fixture
def make_public_api_token():
def _make_public_api_token(user, organization, name="test_api_token"):

View file

@ -57,6 +57,8 @@ module = [
"factory.*",
"fcm_django.*",
"firebase_admin.*",
"googleapiclient.discovery.*",
"google.oauth2.credentials.*",
"httpretty.*",
"humanize.*",
"ipware.*",

View file

@ -61,3 +61,6 @@ twilio~=6.37.0
urllib3==1.26.18
uwsgi==2.0.21
whitenoise==5.3.0
google-api-python-client==2.122.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.0

View file

@ -168,17 +168,24 @@ google-api-core==2.17.0
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
google-api-python-client==2.118.0
# via firebase-admin
google-api-python-client==2.122.0
# via
# -r ./engine/requirements.in
# firebase-admin
google-auth==2.27.0
# via
# google-api-core
# google-api-python-client
# google-auth-httplib2
# google-auth-oauthlib
# google-cloud-core
# google-cloud-storage
google-auth-httplib2==0.2.0
# via google-api-python-client
# via
# -r ./engine/requirements.in
# google-api-python-client
google-auth-oauthlib==1.2.0
# via -r ./engine/requirements.in
google-cloud-core==2.4.1
# via
# google-cloud-firestore
@ -397,7 +404,9 @@ requests==2.31.0
# social-auth-core
# twilio
requests-oauthlib==1.3.1
# via social-auth-core
# via
# google-auth-oauthlib
# social-auth-core
rpds-py==0.18.0
# via
# jsonschema

View file

@ -71,6 +71,7 @@ GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATION
FEATURE_LABELS_ENABLED_FOR_ALL = getenv_boolean("FEATURE_LABELS_ENABLED_FOR_ALL", default=False)
# Enable labels feature for organizations from the list. Use OnCall organization ID, for this flag
FEATURE_LABELS_ENABLED_PER_ORG = getenv_list("FEATURE_LABELS_ENABLED_PER_ORG", default=list())
FEATURE_GOOGLE_OAUTH2_ENABLED = getenv_boolean("FEATURE_GOOGLE_OAUTH2_ENABLED", default=False)
TWILIO_API_KEY_SID = os.environ.get("TWILIO_API_KEY_SID")
TWILIO_API_KEY_SECRET = os.environ.get("TWILIO_API_KEY_SECRET")
@ -280,6 +281,7 @@ INSTALLED_APPS = [
"django_dbconn_retry",
"apps.phone_notifications",
"drf_spectacular",
"apps.google",
]
REST_FRAMEWORK = {
@ -320,6 +322,10 @@ SPECTACULAR_INCLUDED_PATHS = [
"/alert_receive_channels",
"/users",
"/labels",
# social auth routes
"/login",
"/complete",
"/disconnect",
]
MIDDLEWARE = [
@ -642,8 +648,27 @@ AUTHENTICATION_BACKENDS = [
"apps.social_auth.backends.InstallSlackOAuth2V2",
"apps.social_auth.backends.LoginSlackOAuth2V2",
"django.contrib.auth.backends.ModelBackend",
"apps.social_auth.backends.GoogleOAuth2",
]
if FEATURE_GOOGLE_OAUTH2_ENABLED:
CELERY_BEAT_SCHEDULE["sync_google_calendar_out_of_office_events_for_all_users"] = {
"task": "apps.google.tasks.sync_out_of_office_calendar_events_for_all_users",
"schedule": crontab(minute="*/30"), # every 30 minutes
"args": (),
}
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY")
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET")
# NOTE: for right now we probably only need the calendar.events.readonly scope
# however, if we want to write events back to the user's calendar
# we'll probably need to change this to the calendar.events scope
# (not sure how hard this is to migrate to in the future?)
# https://developers.google.com/identity/protocols/oauth2/scopes#calendar
SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = getenv_list(
"SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE", default=["https://www.googleapis.com/auth/calendar.events.readonly"]
)
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET")
SLACK_SIGNING_SECRET_LIVE = os.environ.get("SLACK_SIGNING_SECRET_LIVE", "")
@ -673,13 +698,32 @@ SOCIAL_AUTH_SLACK_INSTALL_FREE_CUSTOM_SCOPE = [
]
SOCIAL_AUTH_PIPELINE = (
"apps.social_auth.pipeline.set_user_and_organization_from_request",
"apps.social_auth.pipeline.common.set_user_and_organization_from_request",
"social_core.pipeline.social_auth.social_details",
"apps.social_auth.pipeline.connect_user_to_slack",
"apps.social_auth.pipeline.populate_slack_identities",
"apps.social_auth.pipeline.delete_slack_auth_token",
"apps.social_auth.pipeline.slack.connect_user_to_slack",
"apps.social_auth.pipeline.slack.populate_slack_identities",
"apps.social_auth.pipeline.slack.delete_slack_auth_token",
)
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",
)
SOCIAL_AUTH_GOOGLE_OAUTH2_DISCONNECT_PIPELINE = (
"apps.social_auth.pipeline.common.set_user_and_organization_from_request",
"apps.social_auth.pipeline.google.disconnect_user_google_oauth2_settings",
)
# https://python-social-auth.readthedocs.io/en/latest/use_cases.html#re-prompt-google-oauth2-users-to-refresh-the-refresh-token
# https://developers.google.com/identity/protocols/oauth2/web-server
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = {
# Indicates whether your application can refresh access tokens when the user is not present at the browser.
# Valid parameter values are online, which is the default value, and offline.
"access_type": "offline",
"approval_prompt": "auto",
}
SOCIAL_AUTH_FIELDS_STORED_IN_SESSION: typing.List[str] = []
SOCIAL_AUTH_REDIRECT_IS_HTTPS = getenv_boolean("SOCIAL_AUTH_REDIRECT_IS_HTTPS", default=True)
SOCIAL_AUTH_SLUGIFY_USERNAMES = True
@ -689,7 +733,7 @@ PUBLIC_PRIMARY_KEY_MIN_LENGTH = 12
PUBLIC_PRIMARY_KEY_ALLOWED_CHARS = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
AUTH_LINK_TIMEOUT_SECONDS = 300
SLACK_AUTH_TOKEN_TIMEOUT_SECONDS = 300
AUTH_TOKEN_TIMEOUT_SECONDS = 300
SLACK_INSTALL_RETURN_REDIRECT_HOST = os.environ.get("SLACK_INSTALL_RETURN_REDIRECT_HOST", None)

View file

@ -113,6 +113,8 @@ CELERY_TASK_ROUTES = {
"apps.base.tasks.process_failed_to_invoke_celery_tasks": {"queue": "critical"},
"apps.base.tasks.process_failed_to_invoke_celery_tasks_batch": {"queue": "critical"},
"apps.email.tasks.notify_user_async": {"queue": "critical"},
"apps.google.tasks.sync_out_of_office_calendar_events_for_all_users": {"queue": "critical"},
"apps.google.tasks.sync_out_of_office_calendar_events_for_user": {"queue": "critical"},
"apps.integrations.tasks.create_alert": {"queue": "critical"},
"apps.integrations.tasks.create_alertmanager_alerts": {"queue": "critical"},
"apps.integrations.tasks.start_notify_about_integration_ratelimit": {"queue": "critical"},

View file

@ -20,7 +20,12 @@ test.skip(
'Insights is only available in Grafana 10.0.0 and above'
);
test.describe('Insights', () => {
/**
* skipping as these tests are currently flaky
* see this Slack conversation for more details:
* https://raintank-corp.slack.com/archives/C04JCU51NF8/p1712069772861909
*/
test.describe.skip('Insights', () => {
test.beforeAll(async ({ adminRolePage: { page } }) => {
const DATASOURCE_NAME = 'OnCall Prometheus';
const DATASOURCE_URL = 'http://oncall-dev-prometheus-server.default.svc.cluster.local';

View file

@ -0,0 +1,21 @@
import { AppFeature } from 'state/features';
import { test, expect } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
test('Google Calendar connector and Google Calendar tab are visible if google_oauth2 feature enabled', async ({
adminRolePage: { page },
}) => {
goToOnCallPage(page, 'users/me');
const featuresResponse = await page.waitForResponse((resp) => {
return resp.url().includes('/features/') && resp.status() === 200;
});
const features = await featuresResponse.json();
if (features.includes(AppFeature.GoogleOauth2)) {
await expect(page.getByTestId('google-calendar-connector-title')).toBeVisible();
await expect(page.getByTestId('google-calendar-tab')).toBeVisible();
}
});

View file

@ -55,12 +55,14 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
showTelegramConnectionTab,
showMobileAppConnectionTab,
showMsTeamsConnectionTab,
showGoogleCalendarTab,
] = [
!isDesktopOrLaptop,
isCurrent && organizationStore.currentOrganization?.slack_team_identity && !storeUser.slack_user_identity,
isCurrent && store.hasFeature(AppFeature.Telegram) && !storeUser.telegram_configuration,
isCurrent,
store.hasFeature(AppFeature.MsTeams) && !storeUser.messaging_backends.MSTEAMS,
isCurrent && store.hasFeature(AppFeature.GoogleOauth2),
];
const title = (
@ -81,6 +83,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
showTelegramConnectionTab={showTelegramConnectionTab}
showMobileAppConnectionTab={showMobileAppConnectionTab}
showMsTeamsConnectionTab={showMsTeamsConnectionTab}
showGoogleCalendarTab={showGoogleCalendarTab}
/>
<TabsContent id={id} activeTab={activeTab} onTabChange={onTabChange} isDesktopOrLaptop={isDesktopOrLaptop} />
</div>

View file

@ -1,6 +1,7 @@
export enum UserSettingsTab {
UserInfo,
NotificationSettings,
GoogleCalendar,
PhoneVerification,
SlackInfo,
TelegramInfo,

View file

@ -10,6 +10,7 @@ import { MobileAppConnectionTab } from 'containers/MobileAppConnection/MobileApp
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
import { CloudPhoneSettings } from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
import { GoogleCalendar } from 'containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar';
import { MSTeamsInfo } from 'containers/UserSettings/parts/tabs/MSTeamsInfo/MSTeamsInfo';
import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab';
import { PhoneVerification } from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification';
@ -29,6 +30,7 @@ interface TabsProps {
onTabChange: (tab: UserSettingsTab) => void;
showNotificationSettingsTab: boolean;
showMobileAppConnectionTab: boolean;
showGoogleCalendarTab: boolean;
showSlackConnectionTab: boolean;
showTelegramConnectionTab: boolean;
showMsTeamsConnectionTab: boolean;
@ -38,6 +40,7 @@ export const Tabs = ({
activeTab,
onTabChange,
showNotificationSettingsTab,
showGoogleCalendarTab,
showMobileAppConnectionTab,
showSlackConnectionTab,
showTelegramConnectionTab,
@ -70,6 +73,15 @@ export const Tabs = ({
data-testid="tab-notification-settings"
/>
)}
{showGoogleCalendarTab && (
<Tab
active={activeTab === UserSettingsTab.GoogleCalendar}
label="Google Calendar"
key={UserSettingsTab.GoogleCalendar}
onChangeTab={getTabClickHandler(UserSettingsTab.GoogleCalendar)}
data-testid="google-calendar-tab"
/>
)}
<Tab
active={activeTab === UserSettingsTab.PhoneVerification}
label="Phone Verification"
@ -147,6 +159,7 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
<UserInfoTab id={id} onTabChange={onTabChange} />
))}
{activeTab === UserSettingsTab.NotificationSettings && <NotificationSettingsTab id={id} />}
{activeTab === UserSettingsTab.GoogleCalendar && <GoogleCalendar id={id} />}
{activeTab === UserSettingsTab.PhoneVerification &&
(store.hasFeature(AppFeature.CloudNotifications) ? (
<CloudPhoneSettings userPk={id} />

View file

@ -0,0 +1,50 @@
import React from 'react';
import { Button, HorizontalGroup, InlineField } from '@grafana/ui';
import { observer } from 'mobx-react';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { UserHelper } from 'models/user/user.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization/authorization';
interface GoogleConnectorProps {
id: ApiSchemas['User']['pk'];
}
export const GoogleConnector = observer((props: GoogleConnectorProps) => {
const { id } = props;
const store = useStore();
const { userStore } = store;
const storeUser = userStore.items[id];
const isCurrentUser = id === store.userStore.currentUserPk;
return (
<div>
<InlineField label="Google Account" labelWidth={15}>
{storeUser.has_google_oauth2_connected ? (
<HorizontalGroup spacing="xs">
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<WithConfirm title="Are you sure to disconnect your Google account?" confirmText="Disconnect">
<Button disabled={!isCurrentUser} variant="destructive" onClick={userStore.disconnectGoogle}>
Disconnect
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
) : (
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<Button disabled={!isCurrentUser} onClick={UserHelper.handleConnectGoogle}>
Connect account
</Button>
</WithPermissionControlTooltip>
)}
</InlineField>
</div>
);
});

View file

@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import { css } from '@emotion/css';
import { Button, HorizontalGroup, InlineSwitch, VerticalGroup, useStyles2 } from '@grafana/ui';
import { observer } from 'mobx-react';
import { Block } from 'components/GBlock/Block';
import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { GSelect } from 'containers/GSelect/GSelect';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import GoogleCalendarLogo from 'icons/GoogleCalendarLogo';
import { Schedule } from 'models/schedule/schedule.types';
import { UserHelper } from 'models/user/user.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization/authorization';
const GoogleCalendar: React.FC<{ id: ApiSchemas['User']['pk'] }> = observer(({ id }) => {
const { userStore, scheduleStore } = useStore();
const styles = useStyles2(getStyles);
const user = userStore.items[id];
const [googleCalendarSettings, setGoogleCalendarSettings] = useState(user?.google_calendar_settings);
const [showSchedulesDropdown, setShowSchedulesDropdown] = useState(
user.google_calendar_settings?.oncall_schedules_to_consider_for_shift_swaps?.length > 0
);
const handleShowSchedulesDropdownChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.checked;
setShowSchedulesDropdown(value);
if (!value) {
handleSchedulesChange([]);
}
};
useEffect(() => {
if (user) {
setGoogleCalendarSettings(user.google_calendar_settings);
}
}, [user]);
const handleSchedulesChange = (value) => {
setGoogleCalendarSettings((v) => ({ ...v, oncall_schedules_to_consider_for_shift_swaps: value }));
userStore.updateCurrentUser({
google_calendar_settings: { ...googleCalendarSettings, oncall_schedules_to_consider_for_shift_swaps: value },
});
};
return (
<VerticalGroup>
<Block bordered className={styles.root}>
<VerticalGroup>
{user.has_google_oauth2_connected ? (
<VerticalGroup>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<GoogleCalendarLogo width={32} height={32} />
<Text>Google calendar is connected</Text>
</HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<WithConfirm title="Are you sure to disconnect your Google account?" confirmText="Disconnect">
<Button variant="destructive" onClick={userStore.disconnectGoogle}>
Disconnect
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
</VerticalGroup>
) : (
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<GoogleCalendarLogo width={32} height={32} />
<div>
<Text.Title level={5}>Connect your Google Calendar</Text.Title>
<Text type="secondary">
This connection allows Grafana OnCall to read your Out of Office events and autogenerate Shift Swap
Requests
</Text>
</div>
</HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<Button variant="primary" onClick={UserHelper.handleConnectGoogle}>
Connect
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
)}
{user.has_google_oauth2_connected && (
<VerticalGroup>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<InlineSwitch
showLabel
label="Specify the schedules to sync with Google calendar"
value={showSchedulesDropdown}
transparent
onChange={handleShowSchedulesDropdownChange}
/>
</WithPermissionControlTooltip>
{showSchedulesDropdown && (
<div style={{ width: '100%' }}>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<GSelect<Schedule>
isMulti
showSearch
allowClear
disabled={false}
items={scheduleStore.items}
fetchItemsFn={scheduleStore.updateItems}
fetchItemFn={scheduleStore.updateItem}
getSearchResult={scheduleStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select Schedules"
value={googleCalendarSettings?.oncall_schedules_to_consider_for_shift_swaps}
onChange={handleSchedulesChange}
/>
</WithPermissionControlTooltip>
</div>
)}
</VerticalGroup>
)}
</VerticalGroup>
</Block>
</VerticalGroup>
);
});
export const getStyles = () => ({
root: css({
width: '100%',
}),
});
export { GoogleCalendar };

View file

@ -1,7 +1,4 @@
.user-value {
font-size: 16px;
}
.user-item {
margin-bottom: 15px;
.external-link-style {
margin-right: 4px;
align-self: baseline;
}

View file

@ -5,7 +5,9 @@ import { InlineField, Input, Legend } from '@grafana/ui';
import { GrafanaTeamSelect } from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { Connectors } from 'containers/UserSettings/parts/connectors/Connectors';
import { GoogleConnector } from 'containers/UserSettings/parts/connectors/GoogleConnector';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
interface UserInfoTabProps {
@ -55,6 +57,12 @@ export const UserInfoTab = (props: UserInfoTabProps) => {
}}
/>
</InlineField>
{store.hasFeature(AppFeature.GoogleOauth2) && (
<>
<Legend data-testid="google-calendar-connector-title">Google Calendar</Legend>
<GoogleConnector {...props} />
</>
)}
<Legend>Notification channels</Legend>
<Connectors {...props} />
</>

View file

@ -0,0 +1,53 @@
import React from 'react';
const GoogleCalendarLogo = ({ width, height }) => (
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 200 200"
enableBackground="new 0 0 200 200"
width={`${width}px`}
height={`${height}px`}
>
<g>
<g transform="translate(3.75 3.75)">
<path
fill="#FFFFFF"
d="M148.882,43.618l-47.368-5.263l-57.895,5.263L38.355,96.25l5.263,52.632l52.632,6.579l52.632-6.579
l5.263-53.947L148.882,43.618z"
/>
<path
fill="#1A73E8"
d="M65.211,125.276c-3.934-2.658-6.658-6.539-8.145-11.671l9.132-3.763c0.829,3.158,2.276,5.605,4.342,7.342
c2.053,1.737,4.553,2.592,7.474,2.592c2.987,0,5.553-0.908,7.697-2.724s3.224-4.132,3.224-6.934c0-2.868-1.132-5.211-3.395-7.026
s-5.105-2.724-8.5-2.724h-5.276v-9.039H76.5c2.921,0,5.382-0.789,7.382-2.368c2-1.579,3-3.737,3-6.487
c0-2.447-0.895-4.395-2.684-5.855s-4.053-2.197-6.803-2.197c-2.684,0-4.816,0.711-6.395,2.145s-2.724,3.197-3.447,5.276
l-9.039-3.763c1.197-3.395,3.395-6.395,6.618-8.987c3.224-2.592,7.342-3.895,12.342-3.895c3.697,0,7.026,0.711,9.974,2.145
c2.947,1.434,5.263,3.421,6.934,5.947c1.671,2.539,2.5,5.382,2.5,8.539c0,3.224-0.776,5.947-2.329,8.184
c-1.553,2.237-3.461,3.947-5.724,5.145v0.539c2.987,1.25,5.421,3.158,7.342,5.724c1.908,2.566,2.868,5.632,2.868,9.211
s-0.908,6.776-2.724,9.579c-1.816,2.803-4.329,5.013-7.513,6.618c-3.197,1.605-6.789,2.421-10.776,2.421
C73.408,129.263,69.145,127.934,65.211,125.276z"
/>
<path
fill="#1A73E8"
d="M121.25,79.961l-9.974,7.25l-5.013-7.605l17.987-12.974h6.895v61.197h-9.895L121.25,79.961z"
/>
<path
fill="#EA4335"
d="M148.882,196.25l47.368-47.368l-23.684-10.526l-23.684,10.526l-10.526,23.684L148.882,196.25z"
/>
<path fill="#34A853" d="M33.092,172.566l10.526,23.684h105.263v-47.368H43.618L33.092,172.566z" />
<path
fill="#4285F4"
d="M12.039-3.75C3.316-3.75-3.75,3.316-3.75,12.039v136.842l23.684,10.526l23.684-10.526V43.618h105.263
l10.526-23.684L148.882-3.75H12.039z"
/>
<path fill="#188038" d="M-3.75,148.882v31.579c0,8.724,7.066,15.789,15.789,15.789h31.579v-47.368H-3.75z" />
<path fill="#FBBC04" d="M148.882,43.618v105.263h47.368V43.618l-23.684-10.526L148.882,43.618z" />
<path fill="#1967D2" d="M196.25,43.618V12.039c0-8.724-7.066-15.789-15.789-15.789h-31.579v47.368H196.25z" />
</g>
</g>
</svg>
);
export default GoogleCalendarLogo;

View file

@ -0,0 +1,26 @@
import React from 'react';
const GoogleLogo = ({ width, height }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={`${width}px`} height={`${height}px`}>
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</svg>
);
export default GoogleLogo;

View file

@ -107,4 +107,9 @@ export class UserHelper {
})
).data;
}
static async handleConnectGoogle() {
const { data } = await onCallApi().GET('/login/{backend}', { params: { path: { backend: 'google-oauth2' } } });
window.location = data;
}
}

View file

@ -143,6 +143,12 @@ export class UserStore {
this.loadCurrentUser();
}
async disconnectGoogle() {
await onCallApi().GET('/disconnect/{backend}', { params: { path: { backend: 'google-oauth2' } } });
this.loadCurrentUser();
}
async updateUser(data: Partial<ApiSchemas['User']>) {
const user = (
await onCallApi().PUT('/users/{id}/', {

View file

@ -849,6 +849,39 @@ export interface paths {
patch?: never;
trace?: never;
};
'/complete/{backend}/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Authentication complete view */
get: operations['complete_retrieve'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/disconnect/{backend}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['disconnect_retrieve'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/features/': {
parameters: {
query?: never;
@ -954,6 +987,38 @@ export interface paths {
patch?: never;
trace?: never;
};
'/login/{backend}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['login_retrieve'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/login/{backend}/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['login_retrieve_2'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/users/': {
parameters: {
query?: never;
@ -1732,6 +1797,9 @@ export interface components {
value: string;
display_name: string;
};
GoogleCalendarSettings: {
oncall_schedules_to_consider_for_shift_swaps?: string[] | null;
};
/** @description Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details. */
IntegrationAlertGroupLabels: {
inheritable: {
@ -1887,6 +1955,7 @@ export interface components {
};
readonly cloud_connection_status: components['schemas']['CloudConnectionStatusEnum'] | null;
hide_phone_number?: boolean;
readonly has_google_oauth2_connected: boolean;
};
/**
* @description * `0` - Debug
@ -2018,7 +2087,9 @@ export interface components {
};
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'];
};
PreviewTemplateRequest: {
template_body?: string | null;
@ -2143,7 +2214,9 @@ export interface components {
};
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'];
};
UserExportTokenGetResponse: {
/** Format: date-time */
@ -3741,6 +3814,46 @@ export interface operations {
};
};
};
complete_retrieve: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
disconnect_retrieve: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
features_retrieve: {
parameters: {
query?: never;
@ -3764,6 +3877,7 @@ export interface operations {
| 'grafana_cloud_connection'
| 'grafana_alerting_v2'
| 'labels'
| 'google_oauth2'
)[];
};
};
@ -3938,6 +4052,46 @@ export interface operations {
};
};
};
login_retrieve: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login_retrieve_2: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
users_list: {
parameters: {
query?: {

View file

@ -6,4 +6,5 @@ export enum AppFeature {
CloudConnection = 'grafana_cloud_connection',
Labels = 'labels',
MsTeams = 'msteams',
GoogleOauth2 = 'google_oauth2',
}