diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index c406453b7..fb4c0fb52 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -7,6 +7,7 @@ from .user import ( UserAdminLiteSerializer, UserMeSerializer, UserMeSettingsSerializer, + ConnectedAccountSerializer, ) from .workspace import ( WorkSpaceSerializer, diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 1b94758e8..214dcfdc6 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -3,7 +3,7 @@ from rest_framework import serializers # Module import from .base import BaseSerializer -from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.db.models import User, Workspace, WorkspaceMemberInvite, ConnectedAccount from plane.license.models import InstanceAdmin, Instance @@ -190,4 +190,15 @@ class ResetPasswordSerializer(serializers.Serializer): """ Serializer for password change endpoint. """ + new_password = serializers.CharField(required=True, min_length=8) + + +class ConnectedAccountSerializer(BaseSerializer): + class Meta: + model = ConnectedAccount + fields = "__all__" + read_only_field = [ + "user", + "access_token", + ] diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index 9dae7b5da..480daf48d 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -8,6 +8,7 @@ from plane.app.views import ( UserActivityEndpoint, ChangePasswordEndpoint, SetUserPasswordEndpoint, + ConnectedAccountEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, @@ -95,5 +96,15 @@ urlpatterns = [ SetUserPasswordEndpoint.as_view(), name="set-password", ), + path( + "users/me/connected-accounts/", + ConnectedAccountEndpoint.as_view(), + name="connected-account", + ), + path( + "users/me/connected-accounts//", + ConnectedAccountEndpoint.as_view(), + name="connected-account", + ), ## End User Graph ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index c122dce9f..df9576859 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -18,6 +18,7 @@ from .user import ( UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, + ConnectedAccountEndpoint, ) from .oauth import OauthEndpoint diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index e8f5926fe..30b6ff104 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -296,6 +296,10 @@ class OauthEndpoint(BaseAPIView): user=user, medium=medium ).first() + if access_token_expired_at: + access_token_expired_at = timezone.now() + timezone.timedelta(seconds=access_token_expired_at) + refresh_token_expired_at = timezone.now() + timezone.timedelta(seconds=refresh_token_expired_at) + # If the connected account exists if connected_account: connected_account.access_token = github_access_token @@ -307,6 +311,7 @@ class OauthEndpoint(BaseAPIView): else: # Create the connected account ConnectedAccount.objects.create( + medium=medium, user=user, access_token=github_access_token, refresh_token=github_refresh_token, diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index 196adda59..4927622dd 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -1,20 +1,31 @@ # Python import import os +import requests + +# Django imports +from django.utils import timezone + # Third party imports from rest_framework.response import Response from rest_framework import status - # Module imports from plane.app.serializers import ( UserSerializer, IssueActivitySerializer, UserMeSerializer, UserMeSettingsSerializer, + ConnectedAccountSerializer, ) from plane.app.views.base import BaseViewSet, BaseAPIView -from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember +from plane.db.models import ( + User, + IssueActivity, + WorkspaceMember, + ProjectMember, + ConnectedAccount, +) from plane.license.models import Instance, InstanceAdmin from plane.utils.paginator import BasePaginator from django.db.models import Q, F, Count, Case, When, IntegerField @@ -52,7 +63,12 @@ class UserEndpoint(BaseViewSet): # Instance admin check if InstanceAdmin.objects.filter(user=user).exists(): - return Response({"error": "You cannot deactivate your account since you are an instance admin"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + { + "error": "You cannot deactivate your account since you are an instance admin" + }, + status=status.HTTP_400_BAD_REQUEST, + ) projects_to_deactivate = [] workspaces_to_deactivate = [] @@ -162,13 +178,115 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): class ConnectedAccountEndpoint(BaseAPIView): + def get_access_token(self, request_token: str) -> str: + """Obtain the request token from github. + Given the client id, client secret and request issued out by GitHub, this method + should give back an access token + Parameters + ---------- + CLIENT_ID: str + A string representing the client id issued out by github + CLIENT_SECRET: str + A string representing the client secret issued out by github + request_token: str + A string representing the request token issued out by github + Throws + ------ + ValueError: + if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string + Returns + ------- + access_token: str + A string representing the access token issued out by github + """ - def post(self, request): - GITHUB_APP_CLIENT_ID, = get_configuration_value( + if not request_token: + raise ValueError("The request token has to be supplied!") + + (CLIENT_SECRET, GITHUB_CLIENT_ID) = get_configuration_value( [ { - "key": "GITHUB_APP_CLIENT_ID", - "default": os.environ.get("GITHUB_APP_CLIENT_ID"), + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get("GITHUB_CLIENT_SECRET", None), + }, + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), }, ] - ) \ No newline at end of file + ) + + url = f"https://github.com/login/oauth/access_token?client_id={str(GITHUB_CLIENT_ID)}&client_secret={str(CLIENT_SECRET)}&code={str(request_token)}" + + headers = {"accept": "application/json"} + + res = requests.post(url, headers=headers) + + data = res.json() + + return data + + def post(self, request): + # Get the medium and temporary code + medium = request.data.get("medium", False) + id_token = request.data.get("credential", False) + + if medium == "github": + account_data = self.get_access_token(id_token) + # Get the values from the tokens + ( + github_access_token, + github_refresh_token, + access_token_expired_at, + refresh_token_expired_at, + ) = ( + account_data.get("access_token"), + account_data.get("refresh_token", None), + account_data.get("expires_in", None), + account_data.get("refresh_token_expires_in", None), + ) + # Get the connected account + connected_account = ConnectedAccount.objects.filter( + user=request.user, medium=medium + ).first() + + if access_token_expired_at: + access_token_expired_at = timezone.now() + timezone.timedelta( + seconds=access_token_expired_at + ) + refresh_token_expired_at = timezone.now() + timezone.timedelta( + seconds=refresh_token_expired_at + ) + + # If the connected account exists + if connected_account: + connected_account.access_token = github_access_token + connected_account.refresh_token = github_refresh_token + connected_account.access_token_expired_at = access_token_expired_at + connected_account.refresh_token_expired_at = refresh_token_expired_at + connected_account.last_connected_at = timezone.now() + connected_account.save() + else: + # Create the connected account + connected_account = ConnectedAccount.objects.create( + medium=medium, + user=request.user, + access_token=github_access_token, + refresh_token=github_refresh_token, + access_token_expired_at=access_token_expired_at, + refresh_token_expired_at=refresh_token_expired_at, + last_connected_at=timezone.now(), + ) + + serializer = ConnectedAccountSerializer(connected_account) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request): + connected_accounts = ConnectedAccount.objects.filter(user=request.user) + serializer = ConnectedAccountSerializer(connected_accounts, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, medium): + connected_account = ConnectedAccount.objects.get(medium=medium, user=request.user) + connected_account.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/db/migrations/0051_connectedaccount.py b/apiserver/plane/db/migrations/0051_connectedaccount.py new file mode 100644 index 000000000..d5578dc1a --- /dev/null +++ b/apiserver/plane/db/migrations/0051_connectedaccount.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.7 on 2023-12-18 11:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0050_user_use_case_alter_workspace_organization_size'), + ] + + operations = [ + migrations.CreateModel( + name='ConnectedAccount', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('medium', models.CharField(choices=[('Google', 'google'), ('Github', 'github')], max_length=20)), + ('access_token', models.CharField(max_length=255)), + ('access_token_expired_at', models.DateTimeField(null=True)), + ('refresh_token', models.CharField(max_length=255, null=True)), + ('refresh_token_expired_at', models.DateTimeField(null=True)), + ('metadata', models.JSONField(default=dict)), + ('last_connected_at', models.DateTimeField(default=django.utils.timezone.now)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_connected_accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'ConnectedAccount', + 'verbose_name_plural': 'ConnectedAccounts', + 'db_table': 'connected_accounts', + 'ordering': ('-created_at',), + 'unique_together': {('user', 'medium')}, + }, + ), + ]