Introduce application tokens
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
@@ -17,6 +17,10 @@
|
||||
:title="t('Notifications')"
|
||||
:to="{ name: RouteName.NOTIFICATIONS }"
|
||||
/>
|
||||
<SettingMenuItem
|
||||
:title="t('Apps')"
|
||||
:to="{ name: RouteName.AUTHORIZED_APPS }"
|
||||
/>
|
||||
</SettingMenuSection>
|
||||
<SettingMenuSection
|
||||
:title="t('Profiles')"
|
||||
|
||||
55
js/src/graphql/application.ts
Normal file
55
js/src/graphql/application.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
export const AUTH_APPLICATION = gql`
|
||||
query AuthApplication($clientId: String!) {
|
||||
authApplication(clientId: $clientId) {
|
||||
clientId
|
||||
name
|
||||
website
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTORIZE_APPLICATION = gql`
|
||||
mutation AuthorizeApplication(
|
||||
$applicationClientId: String!
|
||||
$redirectURI: String!
|
||||
$state: String
|
||||
$scope: String
|
||||
) {
|
||||
authorizeApplication(
|
||||
clientId: $applicationClientId
|
||||
redirectURI: $redirectURI
|
||||
state: $state
|
||||
scope: $scope
|
||||
) {
|
||||
code
|
||||
state
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTH_AUTHORIZED_APPLICATIONS = gql`
|
||||
query AuthAuthorizedApplications {
|
||||
loggedUser {
|
||||
id
|
||||
authAuthorizedApplications {
|
||||
id
|
||||
application {
|
||||
name
|
||||
website
|
||||
}
|
||||
lastUsedAt
|
||||
insertedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REVOKED_AUTHORIZED_APPLICATION = gql`
|
||||
mutation RevokeApplicationToken($appTokenId: String!) {
|
||||
revokeApplicationToken(appTokenId: $appTokenId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1453,5 +1453,9 @@
|
||||
"Report as ham": "Report as ham",
|
||||
"Report as undetected spam": "Report as undetected spam",
|
||||
"The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.": "The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.",
|
||||
"Submit to Akismet": "Submit to Akismet"
|
||||
"Submit to Akismet": "Submit to Akismet",
|
||||
"Autorize this application to access your account?": "Autorize this application to access your account?",
|
||||
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.": "This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.",
|
||||
"Authorize application": "Authorize application",
|
||||
"Authorize": "Authorize"
|
||||
}
|
||||
@@ -1451,5 +1451,9 @@
|
||||
"{username} was invited to {group}": "{username} a été invité à {group}",
|
||||
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
|
||||
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
|
||||
"Autorize this application to access your account?": "Autoriser cette application à accéder à votre compte ?",
|
||||
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.": "Cette application sera capable d'accéder à toutes vos informations et poster du contenu en votre nom. Assurez-vous d'approuver uniquement des applications en lesquelles vous avez confiance.",
|
||||
"Authorize application": "Autoriser l'application",
|
||||
"Authorize": "Autoriser"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum SettingsRouteName {
|
||||
CREATE_IDENTITY = "CreateIdentity",
|
||||
UPDATE_IDENTITY = "UpdateIdentity",
|
||||
IDENTITIES = "IDENTITIES",
|
||||
AUTHORIZED_APPS = "AUTHORIZED_APPS",
|
||||
}
|
||||
|
||||
export const settingsRoutes: RouteRecordRaw[] = [
|
||||
@@ -84,6 +85,18 @@ export const settingsRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "authorized-apps",
|
||||
name: SettingsRouteName.AUTHORIZED_APPS,
|
||||
component: (): Promise<any> => import("@/views/Settings/AppsView.vue"),
|
||||
props: true,
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => t("Apps") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
name: SettingsRouteName.ADMIN,
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum UserRouteName {
|
||||
EMAIL_VALIDATE = "EMAIL_VALIDATE",
|
||||
VALIDATE = "Validate",
|
||||
LOGIN = "Login",
|
||||
OAUTH_AUTORIZE = "OAUTH_AUTORIZE",
|
||||
}
|
||||
|
||||
export const userRoutes: RouteRecordRaw[] = [
|
||||
@@ -108,4 +109,15 @@ export const userRoutes: RouteRecordRaw[] = [
|
||||
announcer: { message: (): string => t("Login") as string },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/oauth/autorize_approve",
|
||||
name: UserRouteName.OAUTH_AUTORIZE,
|
||||
component: (): Promise<any> => import("@/views/OAuth/AuthorizeView.vue"),
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => t("Authorize application") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
15
js/src/types/application.model.ts
Normal file
15
js/src/types/application.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface IApplication {
|
||||
name: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUris?: string;
|
||||
scopes: string | null;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export interface IApplicationToken {
|
||||
id: string;
|
||||
application: IApplication;
|
||||
lastUsedAt: string;
|
||||
insertedAt: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { IFollowedGroupEvent } from "./followedGroupEvent.model";
|
||||
import { PictureInformation } from "./picture";
|
||||
import { IMember } from "./actor/member.model";
|
||||
import { IFeedToken } from "./feedtoken.model";
|
||||
import { IApplicationToken } from "./application.model";
|
||||
|
||||
export interface ICurrentUser {
|
||||
id: string;
|
||||
@@ -66,4 +67,5 @@ export interface IUser extends ICurrentUser {
|
||||
currentSignInAt: string;
|
||||
memberships: Paginate<IMember>;
|
||||
feedTokens: IFeedToken[];
|
||||
authAuthorizedApplications: IApplicationToken[];
|
||||
}
|
||||
|
||||
191
js/src/views/OAuth/AuthorizeView.vue
Normal file
191
js/src/views/OAuth/AuthorizeView.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="container mx-auto w-96">
|
||||
<div v-show="authApplicationLoading && !resultCode">
|
||||
<o-skeleton active size="large" class="mt-6" />
|
||||
<o-skeleton active width="80%" />
|
||||
<div
|
||||
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<div>
|
||||
<o-skeleton circle active width="42px" height="42px" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white shadow-xl my-6">
|
||||
<div class="p-4 pb-0">
|
||||
<p class="text-3xl"><o-skeleton active size="large" /></p>
|
||||
<o-skeleton active width="40%" />
|
||||
</div>
|
||||
<div class="flex gap-3 p-4">
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="!authApplicationLoading && !authApplicationError && !resultCode"
|
||||
>
|
||||
<h1 class="text-3xl">
|
||||
{{ t("Autorize this application to access your account?") }}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle :size="42" />
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white shadow-xl my-6">
|
||||
<div class="p-4 pb-0">
|
||||
<p class="text-3xl font-bold">{{ authApplication?.name }}</p>
|
||||
<p>{{ authApplication?.website }}</p>
|
||||
</div>
|
||||
<div class="flex gap-3 p-4">
|
||||
<o-button @click="() => authorize()">{{ t("Authorize") }}</o-button>
|
||||
<o-button outlined tag="router-link" :to="{ name: RouteName.HOME }">{{
|
||||
t("Decline")
|
||||
}}</o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="authApplicationError">
|
||||
<div
|
||||
class="rounded-lg bg-mbz-danger shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
v-if="authApplicationGraphError?.status_code === 404"
|
||||
>
|
||||
<AlertCircle :size="42" />
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ t("Application not found") }}
|
||||
</p>
|
||||
<p>{{ t("The provided application was not found.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.HOME }"
|
||||
>{{ t("Back to homepage") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="resultCode"
|
||||
class="rounded-lg bg-white shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ t("Your application code") }}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You need to provide the following code to your application. It will only be valid for a few minutes."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-4xl">{{ resultCode }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<o-button variant="text" tag="router-link" :to="{ name: RouteName.HOME }">{{
|
||||
t("Back to homepage")
|
||||
}}</o-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouteQuery } from "vue-use-route-query";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { AUTH_APPLICATION, AUTORIZE_APPLICATION } from "@/graphql/application";
|
||||
import { IApplication } from "@/types/application.model";
|
||||
import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
|
||||
import type { AbsintheGraphQLError } from "@/types/errors.model";
|
||||
import RouteName from "@/router/name";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const clientId = useRouteQuery("client_id", null);
|
||||
const redirectURI = useRouteQuery("redirect_uri", null);
|
||||
const state = useRouteQuery("state", null);
|
||||
const scope = useRouteQuery("scope", null);
|
||||
|
||||
const OUT_OF_BAND_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
|
||||
const resultCode = ref<string | null>(null);
|
||||
|
||||
const {
|
||||
result: authApplicationResult,
|
||||
loading: authApplicationLoading,
|
||||
error: authApplicationError,
|
||||
} = useQuery<{ authApplication: IApplication }, { clientId: string }>(
|
||||
AUTH_APPLICATION,
|
||||
() => ({
|
||||
clientId: clientId.value as string,
|
||||
}),
|
||||
() => ({
|
||||
enabled: clientId.value !== null,
|
||||
})
|
||||
);
|
||||
|
||||
const authApplication = computed(
|
||||
() => authApplicationResult.value?.authApplication
|
||||
);
|
||||
|
||||
const authApplicationGraphError = computed(
|
||||
() => authApplicationError.value?.graphQLErrors[0] as AbsintheGraphQLError
|
||||
);
|
||||
|
||||
const { mutate: authorizeMutation, onDone: onAuthorizeMutationDone } =
|
||||
useMutation<
|
||||
{ authorizeApplication: { code: string; state: string } },
|
||||
{
|
||||
applicationClientId: string;
|
||||
redirectURI: string;
|
||||
state?: string | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
>(AUTORIZE_APPLICATION);
|
||||
|
||||
const authorize = () => {
|
||||
authorizeMutation({
|
||||
applicationClientId: clientId.value as string,
|
||||
redirectURI: redirectURI.value as string,
|
||||
state: state.value,
|
||||
scope: scope.value,
|
||||
});
|
||||
};
|
||||
|
||||
onAuthorizeMutationDone(({ data }) => {
|
||||
const code = data?.authorizeApplication?.code;
|
||||
const returnedState = data?.authorizeApplication?.state ?? "";
|
||||
|
||||
if (!code) return;
|
||||
|
||||
if (redirectURI.value !== OUT_OF_BAND_REDIRECT_URI) {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({ code, state: returnedState })
|
||||
);
|
||||
window.location.assign(
|
||||
new URL(`${redirectURI.value}?${params.toString()}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
resultCode.value = code;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Authorize application")),
|
||||
});
|
||||
</script>
|
||||
138
js/src/views/Settings/AppsView.vue
Normal file
138
js/src/views/Settings/AppsView.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div v-if="loggedUser">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.AUTHORIZED_APPS,
|
||||
text: t('Apps'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
|
||||
text: t('General'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h2>{{ t("Apps") }}</h2>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"These apps can access your account through the API. If you see here apps that you don't recognize, that don't work as expected or that you don't use anymore, you can revoke their access."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="flex justify-between items-center rounded-lg bg-white shadow-xl my-6"
|
||||
v-for="authAuthorizedApplication in authAuthorizedApplications"
|
||||
:key="authAuthorizedApplication.id"
|
||||
>
|
||||
<div class="p-4">
|
||||
<p class="text-3xl font-bold">
|
||||
{{ authAuthorizedApplication.application.name }}
|
||||
</p>
|
||||
<a
|
||||
v-if="authAuthorizedApplication.application.website"
|
||||
target="_blank"
|
||||
:href="authAuthorizedApplication.application.website"
|
||||
>{{
|
||||
urlToHostname(authAuthorizedApplication.application.website)
|
||||
}}</a
|
||||
>
|
||||
<p>
|
||||
<span v-if="authAuthorizedApplication.lastUsedAt">{{
|
||||
t("Last used on {last_used_date}", {
|
||||
last_used_date: formatDateString(
|
||||
authAuthorizedApplication.lastUsedAt
|
||||
),
|
||||
})
|
||||
}}</span>
|
||||
<span v-else>{{ t("Never used") }}</span> ⋅
|
||||
{{
|
||||
t("Authorized on {authorization_date}", {
|
||||
authorization_date: formatDateString(
|
||||
authAuthorizedApplication.insertedAt
|
||||
),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<o-button
|
||||
@click="() => revoke({ appTokenId: authAuthorizedApplication.id })"
|
||||
variant="danger"
|
||||
>{{ t("Revoke") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useLoggedUser } from "@/composition/apollo/user";
|
||||
import {
|
||||
AUTH_AUTHORIZED_APPLICATIONS,
|
||||
REVOKED_AUTHORIZED_APPLICATION,
|
||||
} from "@/graphql/application";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import RouteName from "../../router/name";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { formatDateString } from "@/filters/datetime";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { loggedUser } = useLoggedUser();
|
||||
|
||||
const { result: authAuthorizedApplicationsResult } = useQuery<{
|
||||
loggedUser: Pick<IUser, "authAuthorizedApplications">;
|
||||
}>(AUTH_AUTHORIZED_APPLICATIONS);
|
||||
|
||||
const authAuthorizedApplications = computed(
|
||||
() =>
|
||||
authAuthorizedApplicationsResult.value?.loggedUser
|
||||
?.authAuthorizedApplications
|
||||
);
|
||||
|
||||
const urlToHostname = (url: string | undefined): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: revoke, onDone: onRevokedApplication } = useMutation<
|
||||
{ revokeApplicationToken: { id: string } },
|
||||
{ appTokenId: string }
|
||||
>(REVOKED_AUTHORIZED_APPLICATION, {
|
||||
update: (cache, { data: returnedData }) => {
|
||||
const data = cache.readQuery<{
|
||||
loggedUser: Pick<IUser, "authAuthorizedApplications">;
|
||||
}>({ query: AUTH_AUTHORIZED_APPLICATIONS });
|
||||
if (!data) return;
|
||||
if (!returnedData) return;
|
||||
const authorizedApplications =
|
||||
data.loggedUser.authAuthorizedApplications.filter(
|
||||
(app) => app.id !== returnedData.revokeApplicationToken.id
|
||||
);
|
||||
cache.writeQuery({
|
||||
query: AUTH_AUTHORIZED_APPLICATIONS,
|
||||
data: {
|
||||
...data,
|
||||
loggedUser: {
|
||||
...data.loggedUser,
|
||||
authAuthorizedApplications: authorizedApplications,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Apps")),
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user