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  ## 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:
parent
a35a8949eb
commit
59f727d4f5
55 changed files with 1254 additions and 93 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 += [
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
engine/apps/auth_token/migrations/0006_googleoauth2token.py
Normal file
32
engine/apps/auth_token/migrations/0006_googleoauth2token.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
56
engine/apps/auth_token/models/google_oauth2_token.py
Normal file
56
engine/apps/auth_token/models/google_oauth2_token.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
0
engine/apps/google/__init__.py
Normal file
0
engine/apps/google/__init__.py
Normal file
76
engine/apps/google/client.py
Normal file
76
engine/apps/google/client.py
Normal 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", [])
|
||||
29
engine/apps/google/migrations/0001_initial.py
Normal file
29
engine/apps/google/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
engine/apps/google/migrations/__init__.py
Normal file
0
engine/apps/google/migrations/__init__.py
Normal file
1
engine/apps/google/models/__init__.py
Normal file
1
engine/apps/google/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .google_oauth2_user import GoogleOAuth2User # noqa: F401
|
||||
14
engine/apps/google/models/google_oauth2_user.py
Normal file
14
engine/apps/google/models/google_oauth2_user.py
Normal 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)
|
||||
26
engine/apps/google/tasks.py
Normal file
26
engine/apps/google/tasks.py
Normal 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,))
|
||||
47
engine/apps/google/types.py
Normal file
47
engine/apps/google/types.py
Normal 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
|
||||
"""
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
0
engine/apps/social_auth/pipeline/__init__.py
Normal file
0
engine/apps/social_auth/pipeline/__init__.py
Normal file
27
engine/apps/social_auth/pipeline/common.py
Normal file
27
engine/apps/social_auth/pipeline/common.py
Normal 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,
|
||||
}
|
||||
27
engine/apps/social_auth/pipeline/google.py
Normal file
27
engine/apps/social_auth/pipeline/google.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
14
engine/apps/social_auth/types.py
Normal file
14
engine/apps/social_auth/types.py
Normal 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
|
||||
"""
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
20
engine/apps/user_management/types.py
Normal file
20
engine/apps/user_management/types.py
Normal 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.
|
||||
"""
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ module = [
|
|||
"factory.*",
|
||||
"fcm_django.*",
|
||||
"firebase_admin.*",
|
||||
"googleapiclient.discovery.*",
|
||||
"google.oauth2.credentials.*",
|
||||
"httpretty.*",
|
||||
"humanize.*",
|
||||
"ipware.*",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export enum UserSettingsTab {
|
||||
UserInfo,
|
||||
NotificationSettings,
|
||||
GoogleCalendar,
|
||||
PhoneVerification,
|
||||
SlackInfo,
|
||||
TelegramInfo,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
.user-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
margin-bottom: 15px;
|
||||
.external-link-style {
|
||||
margin-right: 4px;
|
||||
align-self: baseline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
|
|
|
|||
53
grafana-plugin/src/icons/GoogleCalendarLogo.tsx
Normal file
53
grafana-plugin/src/icons/GoogleCalendarLogo.tsx
Normal 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;
|
||||
26
grafana-plugin/src/icons/GoogleLogo.tsx
Normal file
26
grafana-plugin/src/icons/GoogleLogo.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}/', {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export enum AppFeature {
|
|||
CloudConnection = 'grafana_cloud_connection',
|
||||
Labels = 'labels',
|
||||
MsTeams = 'msteams',
|
||||
GoogleOauth2 = 'google_oauth2',
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue