diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js index a835b238ccb56b0cdfc465f3b7ea2341e45322f8..6a980fc8dab2fea0b74fec8ef042de413db35bee 100644 --- a/aleksis/core/frontend/app/dateTimeFormats.js +++ b/aleksis/core/frontend/app/dateTimeFormats.js @@ -19,6 +19,13 @@ const dateTimeFormats = { minute: "numeric", second: "numeric", }, + longNumeric: { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }, }, de: { short: { @@ -39,6 +46,13 @@ const dateTimeFormats = { minute: "numeric", second: "numeric", }, + longNumeric: { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }, }, }; diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplication.vue b/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplication.vue new file mode 100644 index 0000000000000000000000000000000000000000..73d581937e36ff8f42133c25b73a967bff2ef503 --- /dev/null +++ b/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplication.vue @@ -0,0 +1,82 @@ +<template> + <v-expansion-panel> + <v-expansion-panel-header v-slot="{ open }"> + <div class="d-flex justify-start align-center"> + <v-avatar + x-large + v-if="accessToken.application.icon.absoluteUrl" + class="mr-4" + > + <img + :src="accessToken.application.icon.absoluteUrl" + :alt="accessToken.application.name" + /> + </v-avatar> + <v-avatar x-large v-else class="mr-4" color="secondary"> + <v-icon color="white">mdi-apps</v-icon> + </v-avatar> + <div class="subtitle-1 font-weight-medium"> + {{ accessToken.application.name }} + </div> + </div> + </v-expansion-panel-header> + <v-expansion-panel-content> + <v-list dense class="pa-0"> + <v-list-item> + <v-list-item-content class="body-2"> + {{ + $t("oauth.authorized_application.access_since", { + date: $d(new Date(accessToken.created), "longNumeric"), + }) + }} + · + {{ + $t("oauth.authorized_application.valid_until", { + date: $d(new Date(accessToken.expires), "longNumeric"), + }) + }} + </v-list-item-content> + <v-list-item-action> + <v-btn color="primary" @click="deleteItem(accessToken)"> + {{ $t("oauth.authorized_application.revoke") }} + </v-btn> + </v-list-item-action> + </v-list-item> + <v-list-item v-if="accessToken.scopes && accessToken.scopes.length > 0"> + <div class="pr-4"> + <v-list-item-content class="body-2"> + {{ $t("oauth.authorized_application.has_access_to") }} + </v-list-item-content> + </div> + <v-list dense class="pa-0 flex-grow-1"> + <div v-for="(scope, idx) in accessToken.scopes" :key="scope.name"> + <v-list-item> + <v-list-item-content class="body-2"> + {{ scope.description }} + </v-list-item-content> + </v-list-item> + <v-divider v-if="idx < accessToken.scopes.length - 1" /> + </div> + </v-list> + </v-list-item> + </v-list> + </v-expansion-panel-content> + </v-expansion-panel> +</template> + +<script> +export default { + name: "AuthorizedApplication", + props: { + accessToken: { + type: Object, + required: true, + }, + }, + methods: { + deleteItem(item) { + this.$emit("delete-item", item); + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue b/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue new file mode 100644 index 0000000000000000000000000000000000000000..bbf35c8e7f3c2f9e71479c03feb86799b3328d7f --- /dev/null +++ b/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue @@ -0,0 +1,70 @@ +<template> + <div> + <h1 class="mb-4">{{ $t("oauth.authorized_application.title") }}</h1> + <div v-if="$apollo.queries.accessTokens.loading"> + <v-skeleton-loader type="card"></v-skeleton-loader> + </div> + <div v-else-if="accessTokens"> + <v-card class="mb-4"> + <v-card-title> + {{ $t("oauth.authorized_application.subtitle") }} + </v-card-title> + <v-card-text> + {{ $t("oauth.authorized_application.description") }} + </v-card-text> + <v-expansion-panels flat> + <authorized-application + v-for="(accessToken, index) in accessTokens" + :key="accessToken.id" + :access-token="accessToken" + @delete-item="openDeleteDialog" + /> + </v-expansion-panels> + </v-card> + </div> + <delete-dialog + :item="deleteItem" + :gql-mutation="require('./revokeOauthToken.graphql')" + :gql-query="require('./accessTokens.graphql')" + v-model="deleteDialog" + > + <template #title> + {{ $t("oauth.authorized_application.revoke_question") }} + </template> + <template #body> + <span v-if="deleteItem">{{ deleteItem.application.name }}</span> + </template> + <template #deleteContent> + {{ $t("oauth.authorized_application.revoke") }} + </template> + </delete-dialog> + </div> +</template> + +<script> +import gqlAccessTokens from "./accessTokens.graphql"; +import AuthorizedApplication from "./AuthorizedApplication.vue"; +import DeleteDialog from "../generic/dialogs/DeleteDialog.vue"; + +export default { + name: "AuthorizedApplications", + components: { DeleteDialog, AuthorizedApplication }, + data() { + return { + deleteDialog: false, + deleteItem: null, + }; + }, + methods: { + openDeleteDialog(item) { + this.deleteItem = item; + this.deleteDialog = true; + }, + }, + apollo: { + accessTokens: { + query: gqlAccessTokens, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql new file mode 100644 index 0000000000000000000000000000000000000000..68ab08b05d479b8149b3772406a81dd0673c3169 --- /dev/null +++ b/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql @@ -0,0 +1,19 @@ +{ + accessTokens: oauthAccessTokens { + id + created + updated + expires + scopes { + name + description + } + application { + id + name + icon { + absoluteUrl + } + } + } +} diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql new file mode 100644 index 0000000000000000000000000000000000000000..6b2f8adbde00d9e0e4f3419673324f8962266683 --- /dev/null +++ b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql @@ -0,0 +1,5 @@ +mutation ($id: ID!) { + revokeOauthToken(id: $id) { + ok + } +} diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 5235e23265346a5842cccf793e67987459a5b40f..3a046e694bda219530c46e30d32e3d9ec1b44658 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -167,8 +167,16 @@ "title": "OAuth Application", "title_plural": "OAuth Applications" }, - "authorized_token": { - "menu_title": "Authorized Applications" + "authorized_application": { + "menu_title": "Third-party Applications", + "title": "Third-party Applications", + "subtitle": "Third-party Applications With Access to Your Account", + "description": "The following third-party applications have access to your account. You can revoke access at any time for those you don't need or trust anymore.", + "valid_until": "Valid until {date}", + "access_since": "Access since {date}", + "has_access_to": "Has access to:", + "revoke": "Revoke Access", + "revoke_question": "Are you sure you want to revoke access for this application?" } }, "people": "People", diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 215f4d1485644883eef42f14216735547abb041a..e7fc1e4fe28da824dd4d7f308154c120551b43ed 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -920,14 +920,14 @@ const routes = [ }, { path: "/oauth/authorized_tokens/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => + import( + "./components/authorized_oauth_applications/AuthorizedApplications.vue" + ), name: "core.oauth.authorizedTokens", meta: { inAccountMenu: true, - titleKey: "oauth.authorized_token.menu_title", + titleKey: "oauth.authorized_application.menu_title", icon: "mdi-gesture-tap-hold", permission: "core.manage_authorized_tokens_rule", }, diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index deab0e28b5d93e854a020aad687a618817196536..24a68978aa2a40dce7aa6fbf3546e1e5929d62cb 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -9,7 +9,15 @@ from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex -from ..models import CustomMenu, DynamicRoute, Notification, PDFFile, Person, TaskUserAssignment +from ..models import ( + CustomMenu, + DynamicRoute, + Notification, + OAuthAccessToken, + PDFFile, + Person, + TaskUserAssignment, +) from ..util.apps import AppConfig from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType @@ -19,6 +27,7 @@ from .group import GroupType # noqa from .installed_apps import AppType from .message import MessageType from .notification import MarkNotificationReadMutation, NotificationType +from .oauth import OAuthAccessTokenType, OAuthRevokeTokenMutation from .pdf import PDFFileType from .person import PersonMutation, PersonType from .school_term import SchoolTermType # noqa @@ -59,6 +68,8 @@ class Query(graphene.ObjectType): two_factor = graphene.Field(TwoFactorType) + oauth_access_tokens = graphene.List(OAuthAccessTokenType) + def resolve_ping(root, info, payload) -> str: return payload @@ -157,6 +168,10 @@ class Query(graphene.ObjectType): return None return info.context.user + @staticmethod + def resolve_oauth_access_tokens(root, info, **kwargs): + return OAuthAccessToken.objects.filter(user=info.context.user) + class Mutation(graphene.ObjectType): update_person = PersonMutation.Field() @@ -165,6 +180,8 @@ class Mutation(graphene.ObjectType): celery_progress_fetched = CeleryProgressFetchedMutation.Field() + revoke_oauth_token = OAuthRevokeTokenMutation.Field() + def build_global_schema(): """Build global GraphQL schema from all apps.""" diff --git a/aleksis/core/schema/oauth.py b/aleksis/core/schema/oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..c51527d0bb316f2768a844e71697a0eeb8c29100 --- /dev/null +++ b/aleksis/core/schema/oauth.py @@ -0,0 +1,44 @@ +import graphene +from graphene_django import DjangoObjectType + +from aleksis.core.models import OAuthAccessToken, OAuthApplication + +from .base import FieldFileType + + +class OAuthScope(graphene.ObjectType): + name = graphene.String() + description = graphene.String() + + +class OAuthApplicationType(DjangoObjectType): + icon = graphene.Field(FieldFileType) + + class Meta: + model = OAuthApplication + fields = ["id", "name", "icon"] + + +class OAuthAccessTokenType(DjangoObjectType): + scopes = graphene.List(OAuthScope) + + @staticmethod + def resolve_scopes(root: OAuthAccessToken, info, **kwargs): + return [OAuthScope(name=key, description=value) for key, value in root.scopes.items()] + + class Meta: + model = OAuthAccessToken + fields = ["id", "application", "expires", "created", "updated"] + + +class OAuthRevokeTokenMutation(graphene.Mutation): + class Arguments: + id = graphene.ID() # noqa + + ok = graphene.Boolean() + + @staticmethod + def mutate(root, info, id): # noqa + token = OAuthAccessToken.objects.get(id=id, user=info.context.user) + token.delete() + return OAuthRevokeTokenMutation(ok=True) diff --git a/aleksis/core/templates/oauth2_provider/authorized-token-delete.html b/aleksis/core/templates/oauth2_provider/authorized-token-delete.html deleted file mode 100644 index ece2011b6e6170bd8ed845f8464302bb1e6fb8fb..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/oauth2_provider/authorized-token-delete.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n %} - -{% block browser_title %}{% trans "Revoke access" %}{% endblock %} -{% block page_title %}{% trans "Revoke access" %}{% endblock %} - -{% block content %} - <div class="alert info"> - <p> - <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i> - {% trans "Are you sure to revoke the access for this application?" %} - </p> - </div> - - <form method="post"> - {% csrf_token %} - <a class="btn waves-effect waves-light red" href="{% url "oauth2_applications" %}"> - <i class="material-icons iconify left" data-icon="mdi:delete-outline"></i> - {% trans "Revoke" %} - </a> - <a class="btn waves-effect waves-light" href="{% url "oauth2_applications" %}"> - <i class="material-icons iconify left" data-icon="mdi:close"></i> - {% trans "Cancel" %} - </a> - </form> -{% endblock %} diff --git a/aleksis/core/templates/oauth2_provider/authorized-tokens.html b/aleksis/core/templates/oauth2_provider/authorized-tokens.html deleted file mode 100644 index dfe058056fb4ffaabd2a44eaac04adaa8aa3ad3b..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/oauth2_provider/authorized-tokens.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n %} - -{% block browser_title %}{% blocktrans %}Authorized applications{% endblocktrans %}{% endblock %} -{% block page_title %}{% trans "Authorized applications" %}{% endblock %} - -{% block content %} - {% if authorized_tokens %} - <div class="row"> - {% for authorized_token in authorized_tokens %} - <div class="col s12 m6 l4 xl3"> - <div class="card"> - <div class="card-content"> - <div class="card-title">{{ authorized_token.application }}</div> - {% for scope_name, scope_description in authorized_token.scopes.items %} - <p> - {{ scope_name }}: {{ scope_description }} - </p> - {% endfor %} - </div> - <div class="card-action"> - <a href="{% url 'oauth2_provider:authorized-token-delete' authorized_token.pk %}">{% trans "Revoke access" %}</a> - </div> - </div> - </div> - {% endfor %} - </div> - {% else %} - <div class="alert info"> - <p> - <i class="material-icons iconify left" data-icon="mdi:information-outline"></i> - {% trans "No authorized applications." %} - </p> - </div> - {% endif %} -{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index f5e5899063678abc338ac971d1df3f55db3d0430..29fe9dcb2f5e51d0c1461898efae087ac9c6ee27 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -33,6 +33,10 @@ urlpatterns = [ ConnectDiscoveryInfoView.as_view(), name="oidc_configuration", ), + path("oauth/applications/", views.TemplateView.as_view(template_name="core/vue_index.html")), + path( + "oauth/authorized_tokens/", views.TemplateView.as_view(template_name="core/vue_index.html") + ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), path( "django/",