Oauth 2 Flow UI (#1440)
* adjust existing sign-in flow * test fetching of oauth client * allow for apiversion override * getAuthUrl refactor * Adjust auth to accept complex url redirections * introduce scopes * accept oauth flow * rename login/oauth to authorize * conform to labrinth spec and oauth2 spec * use cute icons for scope items * applications pages * Modal for copy client secret on creation * rip out old state * add authorizations * add flow error state and implement feedback * implement error notifications on error * Client secret modal flow aligned with PAT copy * Authorized scopes now aligned with Authorize screen * Fix spelling and capitalization * change redirect uris to include the input field * refactor 2fa flow to be more stable * visual adjustments for authorizations * Fix empty field submission bug * Add file upload for application icon * Change shape of editing/create application * replace icon with Avatar component * Refactor authorization card styling * UI feedback * clean up spacing, styling * Create a "Developer" section of user settings * Fix spacing and scope access * app description and url implementations * clean up imports * Update authorization endpoint * Update placeholder URL in applications.vue * Remove app information from authorization page * Remove max scopes from application settings * Fix import statement and update label styles * Replace useless headers * Update pages/auth/authorize.vue Co-authored-by: Calum H. <contact@mineblock11.dev> * Update pages/auth/authorize.vue Co-authored-by: Calum H. <contact@mineblock11.dev> * Finish PR --------- Co-authored-by: Calum H. <contact@mineblock11.dev> Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
parent
6d70ced93a
commit
1f58aebb2b
@ -92,9 +92,14 @@ export const initAuth = async (oldToken = null) => {
|
||||
return auth
|
||||
}
|
||||
|
||||
export const getAuthUrl = (provider) => {
|
||||
export const getAuthUrl = (provider, redirect = '') => {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
|
||||
return `${config.public.apiBaseUrl}auth/init?url=${config.public.siteUrl}${route.path}&provider=${provider}`
|
||||
if (redirect === '') {
|
||||
redirect = route.path
|
||||
}
|
||||
const fullURL = `${config.public.siteUrl}${redirect}`
|
||||
|
||||
return `${config.public.apiBaseUrl}auth/init?url=${fullURL}&provider=${provider}`
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
let base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
|
||||
if (!options.headers) {
|
||||
options.headers = {}
|
||||
@ -16,5 +16,21 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
options.headers.Authorization = auth.value.token
|
||||
}
|
||||
|
||||
if (options.apiVersion || options.internal) {
|
||||
// Base may end in /vD/ or /vD. We would need to replace the digit with the new version number
|
||||
// and keep the trailing slash if it exists
|
||||
const baseVersion = base.match(/\/v\d\//)
|
||||
|
||||
const replaceStr = options.internal ? `/_internal/` : `/v${options.apiVersion}/`
|
||||
|
||||
if (baseVersion) {
|
||||
base = base.replace(baseVersion[0], replaceStr)
|
||||
} else {
|
||||
base = base.replace(/\/v\d$/, replaceStr)
|
||||
}
|
||||
|
||||
delete options.apiVersion
|
||||
}
|
||||
|
||||
return await $fetch(`${base}${url}`, options)
|
||||
}
|
||||
|
||||
@ -1,7 +1,35 @@
|
||||
const whitelistedParams = ['flow', 'error']
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (_to, from) => {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = await useAuth()
|
||||
|
||||
if (!auth.value.user) {
|
||||
return navigateTo(`/auth/sign-in?redirect=${encodeURIComponent(from.fullPath)}`)
|
||||
const fullPath = from.fullPath
|
||||
|
||||
const url = new URL(fullPath, config.public.apiBaseUrl)
|
||||
|
||||
const extractedParams = whitelistedParams.reduce((acc, param) => {
|
||||
if (url.searchParams.has(param)) {
|
||||
acc[param] = url.searchParams.get(param)
|
||||
url.searchParams.delete(param)
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const redirectPath = encodeURIComponent(url.pathname + url.search)
|
||||
|
||||
return await navigateTo(
|
||||
{
|
||||
path: '/auth/sign-in',
|
||||
query: {
|
||||
redirect: redirectPath,
|
||||
...extractedParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
312
pages/auth/authorize.vue
Normal file
312
pages/auth/authorize.vue
Normal file
@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="error" class="oauth-items">
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
</div>
|
||||
<p>
|
||||
<span>{{ error.data.error }}: </span>
|
||||
{{ error.data.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="oauth-items">
|
||||
<div class="connected-items">
|
||||
<div class="profile-pics">
|
||||
<Avatar size="md" :src="app.icon_url" />
|
||||
<!-- <img class="profile-pic" :src="app.icon_url" alt="User profile picture" /> -->
|
||||
<div class="connection-indicator">→</div>
|
||||
<Avatar size="md" circle :src="auth.user.avatar_url" />
|
||||
<!-- <img class="profile-pic" :src="auth.user.avatar_url" alt="User profile picture" /> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="title">
|
||||
<h1>Authorize {{ app.name }}</h1>
|
||||
</div>
|
||||
<div class="auth-info">
|
||||
<div class="scope-heading">
|
||||
<strong>{{ app.name }}</strong> by
|
||||
<nuxt-link class="text-link" :to="'/user/' + createdBy.id">{{
|
||||
createdBy.username
|
||||
}}</nuxt-link>
|
||||
will be able to:
|
||||
</div>
|
||||
<div class="scope-items">
|
||||
<div v-for="scopeItem in scopeDefinitions" :key="scopeItem">
|
||||
<div class="scope-item">
|
||||
<div class="scope-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{{ scopeItem }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button class="wide-button" large :action="onReject" :disabled="pending">
|
||||
<XIcon />
|
||||
Decline
|
||||
</Button>
|
||||
<Button class="wide-button" color="primary" large :action="onAuthorize" :disabled="pending">
|
||||
<CheckIcon />
|
||||
Authorize
|
||||
</Button>
|
||||
</div>
|
||||
<div class="redirection-notice">
|
||||
<p class="redirect-instructions">
|
||||
You will be redirected to
|
||||
<span class="redirect-url">{{ redirectUri }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, XIcon, CheckIcon, Avatar } from 'omorphia'
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
import { getScopeDefinitions } from '@/utils/auth/scopes.ts'
|
||||
|
||||
const data = useNuxtApp()
|
||||
|
||||
const router = useRoute()
|
||||
const auth = await useAuth()
|
||||
|
||||
const clientId = router.query?.client_id || false
|
||||
const redirectUri = router.query?.redirect_uri || false
|
||||
const scope = router.query?.scope || false
|
||||
const state = router.query?.state || false
|
||||
|
||||
const getFlowIdAuthorization = async () => {
|
||||
const query = {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
}
|
||||
if (state) {
|
||||
query.state = state
|
||||
}
|
||||
|
||||
const authorization = await useBaseFetch('oauth/authorize', {
|
||||
method: 'GET',
|
||||
internal: true,
|
||||
query,
|
||||
}) // This will contain the flow_id and oauth_client_id for accepting the oauth on behalf of the user
|
||||
|
||||
if (typeof authorization === 'string') {
|
||||
await navigateTo(authorization, {
|
||||
external: true,
|
||||
})
|
||||
}
|
||||
|
||||
return authorization
|
||||
}
|
||||
|
||||
const {
|
||||
data: authorizationData,
|
||||
pending,
|
||||
error,
|
||||
} = await useAsyncData('authorization', getFlowIdAuthorization)
|
||||
|
||||
const { data: app } = await useAsyncData('oauth/app/' + clientId, () =>
|
||||
useBaseFetch('oauth/app/' + clientId, {
|
||||
method: 'GET',
|
||||
internal: true,
|
||||
})
|
||||
)
|
||||
|
||||
const scopeDefinitions = getScopeDefinitions(BigInt(authorizationData.value?.requested_scopes || 0))
|
||||
|
||||
const { data: createdBy } = await useAsyncData('user/' + app.value.created_by, () =>
|
||||
useBaseFetch('user/' + app.value.created_by, {
|
||||
method: 'GET',
|
||||
apiVersion: 3,
|
||||
})
|
||||
)
|
||||
|
||||
const onAuthorize = async () => {
|
||||
try {
|
||||
const res = await useBaseFetch('oauth/accept', {
|
||||
method: 'POST',
|
||||
internal: true,
|
||||
body: {
|
||||
flow: authorizationData.value.flow_id,
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof res === 'string') {
|
||||
navigateTo(res, {
|
||||
external: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('No redirect location found in response')
|
||||
} catch (error) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onReject = async () => {
|
||||
try {
|
||||
const res = await useBaseFetch('oauth/reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
flow: authorizationData.value.flow_id,
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof res === 'string') {
|
||||
navigateTo(res, {
|
||||
external: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('No redirect location found in response')
|
||||
} catch (error) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oauth-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xl);
|
||||
}
|
||||
|
||||
.scope-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-icon {
|
||||
display: flex;
|
||||
|
||||
color: var(--color-raised-bg);
|
||||
background-color: var(--color-green);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
padding: var(--gap-xs);
|
||||
}
|
||||
.title {
|
||||
margin-inline: auto;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
.redirection-notice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
text-align: center;
|
||||
|
||||
.redirect-instructions {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.redirect-url {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.wide-button {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
justify-content: center;
|
||||
}
|
||||
.auth-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-heading {
|
||||
margin-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.profile-pics {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
|
||||
.connection-indicator {
|
||||
// Make sure the text sits in the middle and is centered.
|
||||
// Make the text large, and make sure it's not selectable.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
user-select: none;
|
||||
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
border-radius: 50%;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.dotted-border-line {
|
||||
width: 75%;
|
||||
border: 0.1rem dashed var(--color-divider);
|
||||
}
|
||||
|
||||
.connected-items {
|
||||
// Display dotted-border-line under profile-pics and centered behind them
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 1rem;
|
||||
|
||||
// Display profile-pics on top of dotted-border-line
|
||||
.profile-pics {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Display dotted-border-line behind profile-pics
|
||||
.dotted-border-line {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -22,27 +22,27 @@
|
||||
<h1>Sign in with</h1>
|
||||
|
||||
<section class="third-party">
|
||||
<a class="btn" :href="getAuthUrl('discord')">
|
||||
<a class="btn" :href="getAuthUrl('discord', redirectTarget)">
|
||||
<DiscordIcon />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('github')">
|
||||
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
|
||||
<GitHubIcon />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('microsoft')">
|
||||
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
|
||||
<MicrosoftIcon />
|
||||
<span>Microsoft</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('google')">
|
||||
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
|
||||
<GoogleIcon />
|
||||
<span>Google</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('steam')">
|
||||
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
|
||||
<SteamIcon />
|
||||
<span>Steam</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('gitlab')">
|
||||
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
|
||||
<GitLabIcon />
|
||||
<span>GitLab</span>
|
||||
</a>
|
||||
@ -111,6 +111,8 @@ useHead({
|
||||
const auth = await useAuth()
|
||||
const route = useRoute()
|
||||
|
||||
const redirectTarget = route.query.redirect || ''
|
||||
|
||||
if (route.fullPath.includes('new_account=true')) {
|
||||
await navigateTo(
|
||||
`/auth/welcome?authToken=${route.query.code}${
|
||||
@ -122,7 +124,7 @@ if (route.fullPath.includes('new_account=true')) {
|
||||
}
|
||||
|
||||
if (auth.value.user) {
|
||||
await navigateTo('/dashboard')
|
||||
await finishSignIn()
|
||||
}
|
||||
|
||||
const turnstile = ref()
|
||||
@ -190,6 +192,7 @@ async function begin2FASignIn() {
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function finishSignIn(token) {
|
||||
if (token) {
|
||||
await useAuth(token)
|
||||
@ -197,7 +200,10 @@ async function finishSignIn(token) {
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect)
|
||||
const redirect = decodeURIComponent(route.query.redirect)
|
||||
await navigateTo(redirect, {
|
||||
replace: true,
|
||||
})
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
|
||||
@ -3,27 +3,27 @@
|
||||
<h1>Sign up with</h1>
|
||||
|
||||
<section class="third-party">
|
||||
<a class="btn discord-btn" :href="getAuthUrl('discord')">
|
||||
<a class="btn discord-btn" :href="getAuthUrl('discord', redirectTarget)">
|
||||
<DiscordIcon />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('github')">
|
||||
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
|
||||
<GitHubIcon />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('microsoft')">
|
||||
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
|
||||
<MicrosoftIcon />
|
||||
<span>Microsoft</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('google')">
|
||||
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
|
||||
<GoogleIcon />
|
||||
<span>Google</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('steam')">
|
||||
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
|
||||
<SteamIcon />
|
||||
<span>Steam</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('gitlab')">
|
||||
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
|
||||
<GitLabIcon />
|
||||
<span>GitLab</span>
|
||||
</a>
|
||||
@ -129,6 +129,8 @@ useHead({
|
||||
const auth = await useAuth()
|
||||
const route = useRoute()
|
||||
|
||||
const redirectTarget = route.query.redirect
|
||||
|
||||
if (route.fullPath.includes('new_account=true')) {
|
||||
await navigateTo(
|
||||
`/auth/welcome?authToken=${route.query.code}${
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
<NavStackItem link="/settings/account" label="Account">
|
||||
<UserIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/settings/pats" label="PATs">
|
||||
<KeyIcon />
|
||||
<NavStackItem link="/settings/authorizations" label="Authorizations">
|
||||
<UsersIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/settings/sessions" :label="formatMessage(messages.sessionsTitle)">
|
||||
<ShieldIcon />
|
||||
@ -25,6 +25,15 @@
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
</template>
|
||||
<template v-if="auth.user">
|
||||
<h3>Developer Settings</h3>
|
||||
<NavStackItem link="/settings/pats" label="PATs">
|
||||
<KeyIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/settings/applications" label="Applications">
|
||||
<ServerIcon />
|
||||
</NavStackItem>
|
||||
</template>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
@ -34,6 +43,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { UsersIcon, ServerIcon } from 'omorphia'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
|
||||
@ -264,7 +264,11 @@
|
||||
>
|
||||
<TrashIcon /> Remove
|
||||
</button>
|
||||
<a v-else class="btn" :href="`${getAuthUrl(provider.id)}&token=${auth.token}`">
|
||||
<a
|
||||
v-else
|
||||
class="btn"
|
||||
:href="`${getAuthUrl(provider.id, '/settings/account')}&token=${auth.token}`"
|
||||
>
|
||||
<ExternalIcon /> Add
|
||||
</a>
|
||||
</div>
|
||||
|
||||
574
pages/settings/applications.vue
Normal file
574
pages/settings/applications.vue
Normal file
@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this application?"
|
||||
description="This will permanently delete this application and revoke all access tokens. (forever!)"
|
||||
proceed-label="Delete this application"
|
||||
@proceed="removeApp(editingId)"
|
||||
/>
|
||||
<Modal ref="appModal" header="Application information">
|
||||
<div class="universal-modal">
|
||||
<label for="app-name"><span class="label__title">Name</span> </label>
|
||||
<input
|
||||
id="app-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's name..."
|
||||
/>
|
||||
<label v-if="editingId" for="app-icon"><span class="label__title">Icon</span> </label>
|
||||
<div v-if="editingId" class="icon-submission">
|
||||
<Avatar size="md" :src="icon" />
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
class="btn"
|
||||
prompt="Upload icon"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="onImageSelection"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
</div>
|
||||
<label v-if="editingId" for="app-url">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="editingId"
|
||||
id="app-url"
|
||||
v-model="url"
|
||||
maxlength="255"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<label v-if="editingId" for="app-description">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="editingId"
|
||||
id="app-description"
|
||||
v-model="description"
|
||||
class="description-textarea"
|
||||
maxlength="255"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's description..."
|
||||
/>
|
||||
<label for="app-scopes"><span class="label__title">Scopes</span> </label>
|
||||
<div id="app-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="constCaseToSentenceCase(scope)"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
|
||||
/>
|
||||
</div>
|
||||
<label for="app-redirect-uris"><span class="label__title">Redirect uris</span> </label>
|
||||
<div class="uri-input-list">
|
||||
<div v-for="(_, index) in redirectUris" :key="index">
|
||||
<div class="input-group url-input-group-fixes">
|
||||
<input
|
||||
v-model="redirectUris[index]"
|
||||
maxlength="2048"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com/auth/callback"
|
||||
/>
|
||||
<Button v-if="index !== 0" icon-only @click="() => redirectUris.splice(index, 1)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="index === 0"
|
||||
color="primary"
|
||||
icon-only
|
||||
@click="() => redirectUris.push('')"
|
||||
>
|
||||
<PlusIcon /> Add more
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="redirectUris.length <= 0">
|
||||
<Button color="primary" icon-only @click="() => redirectUris.push('')">
|
||||
<PlusIcon /> Add a redirect uri
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.appModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="editingId"
|
||||
:disabled="!canSubmit"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editApp"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="!canSubmit"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createApp"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2>Applications</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null
|
||||
icon = null
|
||||
scopesVal = 0
|
||||
redirectUris = ['']
|
||||
editingId = null
|
||||
expires = null
|
||||
$refs.appModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> New Application
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Applications can be used to authenticate Modrinth's users with your products. For more
|
||||
information, see
|
||||
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>.
|
||||
</p>
|
||||
<div v-for="app in usersApps" :key="app.id" class="universal-card recessed token">
|
||||
<div class="token-info">
|
||||
<div class="token-icon">
|
||||
<Avatar size="sm" :src="app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">{{ app.name }}</h2>
|
||||
<div>Created on {{ new Date(app.created).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="token-information">
|
||||
<span class="label__title">About</span>
|
||||
</label>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
Client ID
|
||||
<CopyCode :text="app.id" />
|
||||
</div>
|
||||
<div v-if="!!clientCreatedInState(app.id)">
|
||||
<div>
|
||||
Client Secret <CopyCode :text="clientCreatedInState(app.id)?.client_secret" />
|
||||
</div>
|
||||
<div class="secret_disclaimer">
|
||||
<i> Save your secret now, it will be hidden after you leave this page! </i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<Button
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
setForm({
|
||||
...app,
|
||||
redirect_uris: app.redirect_uris.map((u) => u.uri) || [],
|
||||
})
|
||||
$refs.appModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
editingId = app.id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
FileInput,
|
||||
UploadIcon,
|
||||
PlusIcon,
|
||||
Avatar,
|
||||
Modal,
|
||||
XIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
CopyCode,
|
||||
ConfirmModal,
|
||||
} from 'omorphia'
|
||||
import { scopeList, hasScope, toggleScope } from '~/utils/auth/scopes.ts'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Applications - Modrinth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
|
||||
const appModal = ref()
|
||||
|
||||
// Any apps created in the current state will be stored here
|
||||
// Users can copy Client Secrets and such before the page reloads
|
||||
const createdApps = ref([])
|
||||
|
||||
const editingId = ref(null)
|
||||
const name = ref(null)
|
||||
const icon = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const redirectUris = ref([''])
|
||||
const url = ref(null)
|
||||
const description = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: usersApps, refresh } = await useAsyncData(
|
||||
'usersApps',
|
||||
() =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/oauth_apps`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
{
|
||||
watch: [auth],
|
||||
}
|
||||
)
|
||||
|
||||
const setForm = (app) => {
|
||||
if (app?.id) {
|
||||
editingId.value = app.id
|
||||
} else {
|
||||
editingId.value = null
|
||||
}
|
||||
name.value = app?.name || ''
|
||||
icon.value = app?.icon_url || ''
|
||||
scopesVal.value = app?.max_scopes || BigInt(0)
|
||||
url.value = app?.url || ''
|
||||
description.value = app?.description || ''
|
||||
|
||||
if (app?.redirect_uris) {
|
||||
redirectUris.value = app.redirect_uris.map((uri) => uri?.uri || uri)
|
||||
} else {
|
||||
redirectUris.value = ['']
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
// Make sure name, scopes, and return uri are at least filled in
|
||||
const filledIn =
|
||||
name.value && name.value !== '' && name.value?.length > 2 && redirectUris.value.length > 0
|
||||
// Make sure the redirect uris are either one empty string or all filled in with valid urls
|
||||
const oneValid = redirectUris.value.length === 1 && redirectUris.value[0] === ''
|
||||
let allValid
|
||||
try {
|
||||
allValid = redirectUris.value.every((uri) => {
|
||||
const url = new URL(uri)
|
||||
return !!url
|
||||
})
|
||||
} catch (err) {
|
||||
allValid = false
|
||||
}
|
||||
return filledIn && (oneValid || allValid)
|
||||
})
|
||||
|
||||
const clientCreatedInState = (id) => {
|
||||
return createdApps.value.find((app) => app.id === id)
|
||||
}
|
||||
|
||||
async function onImageSelection(files) {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
const extFromType = file.type.split('/')[1]
|
||||
|
||||
await useBaseFetch('oauth/app/' + editingId.value + '/icon', {
|
||||
method: 'PATCH',
|
||||
apiVersion: 3,
|
||||
body: file,
|
||||
query: {
|
||||
ext: extFromType,
|
||||
},
|
||||
})
|
||||
|
||||
await refresh()
|
||||
|
||||
const app = usersApps.value.find((app) => app.id === editingId.value)
|
||||
if (app) {
|
||||
setForm(app)
|
||||
}
|
||||
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'Icon updated',
|
||||
text: 'Your application icon has been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const createdAppInfo = await useBaseFetch('oauth/app', {
|
||||
method: 'POST',
|
||||
apiVersion: 3,
|
||||
body: {
|
||||
name: name.value,
|
||||
icon_url: icon.value,
|
||||
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
|
||||
redirect_uris: redirectUris.value,
|
||||
},
|
||||
})
|
||||
|
||||
createdApps.value.push(createdAppInfo)
|
||||
|
||||
setForm(null)
|
||||
appModal.value.hide()
|
||||
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function editApp() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
|
||||
// check if there's any difference between the current app and the one in the state
|
||||
const app = usersApps.value.find((app) => app.id === editingId.value)
|
||||
if (!app) {
|
||||
throw new Error('No app found')
|
||||
}
|
||||
|
||||
if (
|
||||
app.name === name.value &&
|
||||
app.icon_url === icon.value &&
|
||||
app.max_scopes === scopesVal.value &&
|
||||
app.redirect_uris === redirectUris.value &&
|
||||
app.url === url.value &&
|
||||
app.description === description.value
|
||||
) {
|
||||
setForm(null)
|
||||
editingId.value = null
|
||||
appModal.value.hide()
|
||||
throw new Error('No changes detected')
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: name.value,
|
||||
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
|
||||
redirect_uris: redirectUris.value,
|
||||
}
|
||||
|
||||
if (url.value && url.value?.length > 0) {
|
||||
body.url = url.value
|
||||
}
|
||||
|
||||
if (description.value && description.value?.length > 0) {
|
||||
body.description = description.value
|
||||
}
|
||||
|
||||
if (icon.value && icon.value?.length > 0) {
|
||||
body.icon_url = icon.value
|
||||
}
|
||||
|
||||
await useBaseFetch('oauth/app/' + editingId.value, {
|
||||
method: 'PATCH',
|
||||
apiVersion: 3,
|
||||
body,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
setForm(null)
|
||||
editingId.value = null
|
||||
|
||||
appModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removeApp() {
|
||||
startLoading()
|
||||
try {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
await useBaseFetch(`oauth/app/${editingId.value}`, {
|
||||
apiVersion: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
editingId.value = null
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const constCaseToSentenceCase = (str) => {
|
||||
str = str.replace(/_/g, ' ')
|
||||
return str[0].toUpperCase() + str.slice(1).toLowerCase()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.description-textarea {
|
||||
height: 6rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.secret_disclaimer {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.submit-row {
|
||||
padding-top: var(--gap-lg);
|
||||
}
|
||||
.uri-input-list {
|
||||
display: grid;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
.url-input-group-fixes {
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100% !important;
|
||||
flex-basis: 24rem !important;
|
||||
}
|
||||
}
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-submission {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.token-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
display: grid;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.token-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-heading {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-gray-700);
|
||||
|
||||
margin-top: var(--spacing-card-md);
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
|
||||
// For the children override the padding so that y padding is --gap-sm and x padding is --gap-lg
|
||||
// Knossos global styling breaks everything
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
245
pages/settings/authorizations.vue
Normal file
245
pages/settings/authorizations.vue
Normal file
@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to revoke this application?"
|
||||
description="This will revoke the application's access to your account. You can always re-authorize it later."
|
||||
proceed-label="Revoke"
|
||||
@proceed="revokeApp(revokingId)"
|
||||
/>
|
||||
<h2>Authorizations</h2>
|
||||
<p>
|
||||
When you authorize an application with your Modrinth account, you grant it access to your
|
||||
account. You can manage and review access to your account here at any time.
|
||||
</p>
|
||||
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
|
||||
You have not authorized any applications.
|
||||
</div>
|
||||
<div
|
||||
v-for="authorization in appInfoLookup"
|
||||
:key="authorization.id"
|
||||
class="universal-card recessed token"
|
||||
>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
<div class="icon-name">
|
||||
<Avatar :src="authorization.app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">
|
||||
{{ authorization.app.name }}
|
||||
</h2>
|
||||
<div>
|
||||
by
|
||||
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
|
||||
authorization.owner.username
|
||||
}}</nuxt-link>
|
||||
<template v-if="authorization.app.url">
|
||||
<span> ⋅ </span>
|
||||
<nuxt-link class="text-link" :to="authorization.app.url">
|
||||
{{ authorization.app.url }}
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="authorization.app.description">
|
||||
<label for="app-description">
|
||||
<span class="label__title"> About this app </span>
|
||||
</label>
|
||||
<div id="app-description">{{ authorization.app.description }}</div>
|
||||
</template>
|
||||
|
||||
<label for="app-scope-list">
|
||||
<span class="label__title">Scopes</span>
|
||||
</label>
|
||||
<div class="scope-list">
|
||||
<div
|
||||
v-for="scope in getScopeDefinitions(authorization.scopes)"
|
||||
:key="scope"
|
||||
class="scope-list-item"
|
||||
>
|
||||
<div class="scope-list-item-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{{ constCaseToTitleCase(scope) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
revokingId = authorization.app_id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, TrashIcon, CheckIcon, ConfirmModal, Avatar } from 'omorphia'
|
||||
import { getScopeDefinitions } from '~/utils/auth/scopes.ts'
|
||||
|
||||
const revokingId = ref(null)
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Authorizations - Modrinth',
|
||||
})
|
||||
|
||||
const { data: usersApps, refresh } = await useAsyncData('userAuthorizations', () =>
|
||||
useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
})
|
||||
)
|
||||
|
||||
const { data: appInformation } = await useAsyncData(
|
||||
'appInfo',
|
||||
() =>
|
||||
useBaseFetch('oauth/apps', {
|
||||
internal: true,
|
||||
query: {
|
||||
ids: usersApps.value.map((c) => c.app_id).join(','),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: usersApps,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: appCreatorsInformation } = await useAsyncData(
|
||||
'appCreatorsInfo',
|
||||
() =>
|
||||
useBaseFetch('users', {
|
||||
query: {
|
||||
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: appInformation,
|
||||
}
|
||||
)
|
||||
|
||||
const appInfoLookup = computed(() => {
|
||||
return usersApps.value.map((app) => {
|
||||
const info = appInformation.value.find((c) => c.id === app.app_id)
|
||||
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by)
|
||||
return {
|
||||
...app,
|
||||
app: info || null,
|
||||
owner: owner || null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function revokeApp(id) {
|
||||
try {
|
||||
await useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
method: 'DELETE',
|
||||
query: {
|
||||
client_id: id,
|
||||
},
|
||||
})
|
||||
revokingId.value = null
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const constCaseToTitleCase = (str) =>
|
||||
str
|
||||
.split('_')
|
||||
.map((x) => x[0].toUpperCase() + x.slice(1).toLowerCase())
|
||||
.join(' ')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
// Overrides for omorphia compat
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.scope-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-gray-200);
|
||||
color: var(--color-gray-700);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
|
||||
// avoid breaking text or overflowing
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scope-list-item-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 auto;
|
||||
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-green);
|
||||
color: var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
width: 100%;
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
utils/auth/scopes.ts
Normal file
157
utils/auth/scopes.ts
Normal file
@ -0,0 +1,157 @@
|
||||
export const Scopes = {
|
||||
USER_READ_EMAIL: BigInt(1) << BigInt(0),
|
||||
USER_READ: BigInt(1) << BigInt(1),
|
||||
USER_WRITE: BigInt(1) << BigInt(2),
|
||||
USER_DELETE: BigInt(1) << BigInt(3),
|
||||
USER_AUTH_WRITE: BigInt(1) << BigInt(4),
|
||||
NOTIFICATION_READ: BigInt(1) << BigInt(5),
|
||||
NOTIFICATION_WRITE: BigInt(1) << BigInt(6),
|
||||
PAYOUTS_READ: BigInt(1) << BigInt(7),
|
||||
PAYOUTS_WRITE: BigInt(1) << BigInt(8),
|
||||
ANALYTICS: BigInt(1) << BigInt(9),
|
||||
PROJECT_CREATE: BigInt(1) << BigInt(10),
|
||||
PROJECT_READ: BigInt(1) << BigInt(11),
|
||||
PROJECT_WRITE: BigInt(1) << BigInt(12),
|
||||
PROJECT_DELETE: BigInt(1) << BigInt(13),
|
||||
VERSION_CREATE: BigInt(1) << BigInt(14),
|
||||
VERSION_READ: BigInt(1) << BigInt(15),
|
||||
VERSION_WRITE: BigInt(1) << BigInt(16),
|
||||
VERSION_DELETE: BigInt(1) << BigInt(17),
|
||||
REPORT_CREATE: BigInt(1) << BigInt(18),
|
||||
REPORT_READ: BigInt(1) << BigInt(19),
|
||||
REPORT_WRITE: BigInt(1) << BigInt(20),
|
||||
REPORT_DELETE: BigInt(1) << BigInt(21),
|
||||
THREAD_READ: BigInt(1) << BigInt(22),
|
||||
THREAD_WRITE: BigInt(1) << BigInt(23),
|
||||
PAT_CREATE: BigInt(1) << BigInt(24),
|
||||
PAT_READ: BigInt(1) << BigInt(25),
|
||||
PAT_WRITE: BigInt(1) << BigInt(26),
|
||||
PAT_DELETE: BigInt(1) << BigInt(27),
|
||||
SESSION_READ: BigInt(1) << BigInt(28),
|
||||
SESSION_DELETE: BigInt(1) << BigInt(29),
|
||||
PERFORM_ANALYTICS: BigInt(1) << BigInt(30),
|
||||
COLLECTION_CREATE: BigInt(1) << BigInt(31),
|
||||
COLLECTION_READ: BigInt(1) << BigInt(32),
|
||||
COLLECTION_WRITE: BigInt(1) << BigInt(33),
|
||||
COLLECTION_DELETE: BigInt(1) << BigInt(34),
|
||||
ORGANIZATION_CREATE: BigInt(1) << BigInt(35),
|
||||
ORGANIZATION_READ: BigInt(1) << BigInt(36),
|
||||
ORGANIZATION_WRITE: BigInt(1) << BigInt(37),
|
||||
ORGANIZATION_DELETE: BigInt(1) << BigInt(38),
|
||||
SESSION_ACCESS: BigInt(1) << BigInt(39),
|
||||
}
|
||||
|
||||
export const restrictedScopes = [
|
||||
Scopes.PAT_READ,
|
||||
Scopes.PAT_CREATE,
|
||||
Scopes.PAT_WRITE,
|
||||
Scopes.PAT_DELETE,
|
||||
Scopes.SESSION_READ,
|
||||
Scopes.SESSION_DELETE,
|
||||
Scopes.SESSION_ACCESS,
|
||||
Scopes.USER_AUTH_WRITE,
|
||||
Scopes.USER_DELETE,
|
||||
Scopes.PERFORM_ANALYTICS,
|
||||
]
|
||||
|
||||
export const scopeList = Object.entries(Scopes)
|
||||
.filter(([_, value]) => !restrictedScopes.includes(value))
|
||||
.map(([key, _]) => key)
|
||||
|
||||
export const encodeScopes = (scopes: string[]) => {
|
||||
let scopeFlag = BigInt(0)
|
||||
|
||||
// We iterate over the provided scopes
|
||||
for (const scope of scopes) {
|
||||
// We iterate over the entries of the Scopes object
|
||||
for (const [scopeName, scopeFlagValue] of Object.entries(Scopes)) {
|
||||
// If the scope name is the same as the provided scope, add the scope flag to the scopeFlag variable
|
||||
if (scopeName === scope) {
|
||||
scopeFlag = scopeFlag | scopeFlagValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopeFlag
|
||||
}
|
||||
|
||||
export const decodeScopes = (scopes: bigint | number) => {
|
||||
if (typeof scopes === 'number') {
|
||||
scopes = BigInt(scopes)
|
||||
}
|
||||
|
||||
const authorizedScopes = []
|
||||
|
||||
// We iterate over the entries of the Scopes object
|
||||
for (const [scopeName, scopeFlag] of Object.entries(Scopes)) {
|
||||
// If the scope flag is present in the provided number, add the scope name to the list
|
||||
if ((scopes & scopeFlag) === scopeFlag) {
|
||||
authorizedScopes.push(scopeName)
|
||||
}
|
||||
}
|
||||
|
||||
return authorizedScopes
|
||||
}
|
||||
|
||||
export const hasScope = (scopes: bigint, scope: string) => {
|
||||
const authorizedScopes = decodeScopes(scopes)
|
||||
return authorizedScopes.includes(scope)
|
||||
}
|
||||
|
||||
export const toggleScope = (scopes: bigint, scope: string) => {
|
||||
const authorizedScopes = decodeScopes(scopes)
|
||||
if (authorizedScopes.includes(scope)) {
|
||||
return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope))
|
||||
} else {
|
||||
return encodeScopes([...authorizedScopes, scope])
|
||||
}
|
||||
}
|
||||
|
||||
export const getScopeDefinitions = (scopes: bigint) => {
|
||||
return decodeScopes(scopes)
|
||||
.filter((scope) => Object.keys(ScopeDescriptions).includes(scope))
|
||||
.map((scope) => (ScopeDescriptions as Record<string, string>)[scope])
|
||||
}
|
||||
|
||||
export const ScopeDescriptions = {
|
||||
USER_READ_EMAIL: 'Read your email',
|
||||
USER_READ: 'Access your public profile information',
|
||||
USER_WRITE: 'Write to your profile',
|
||||
USER_DELETE: 'Delete your account',
|
||||
USER_AUTH_WRITE: 'Modify your authentication data',
|
||||
NOTIFICATION_READ: 'Read your notifications',
|
||||
NOTIFICATION_WRITE: 'Delete/View your notifications',
|
||||
PAYOUTS_READ: 'Read your payouts data',
|
||||
PAYOUTS_WRITE: 'Withdraw money',
|
||||
ANALYTICS: 'Access your analytics data',
|
||||
PROJECT_CREATE: 'Create new projects',
|
||||
PROJECT_READ: 'Read all your projects',
|
||||
PROJECT_WRITE: 'Write to project data',
|
||||
PROJECT_DELETE: 'Delete your projects',
|
||||
VERSION_CREATE: 'Create new versions',
|
||||
VERSION_READ: 'Read all versions',
|
||||
VERSION_WRITE: 'Write to version data',
|
||||
VERSION_DELETE: 'Delete a version',
|
||||
REPORT_CREATE: 'Create reports',
|
||||
REPORT_READ: 'Read reports',
|
||||
REPORT_WRITE: 'Edit reports',
|
||||
REPORT_DELETE: 'Delete reports',
|
||||
THREAD_READ: 'Read threads',
|
||||
THREAD_WRITE: 'Write to threads',
|
||||
PAT_CREATE: 'Create personal API tokens',
|
||||
PAT_READ: 'View created API tokens',
|
||||
PAT_WRITE: 'Edit personal API tokens',
|
||||
PAT_DELETE: 'Delete your personal API tokens',
|
||||
SESSION_READ: 'Read active sessions',
|
||||
SESSION_DELETE: 'Delete sessions',
|
||||
PERFORM_ANALYTICS: 'Perform analytics actions',
|
||||
COLLECTION_CREATE: 'Create collections',
|
||||
COLLECTION_READ: 'Read collections',
|
||||
COLLECTION_WRITE: 'Write to collections',
|
||||
COLLECTION_DELETE: 'Delete collections',
|
||||
ORGANIZATION_CREATE: 'Create organizations',
|
||||
ORGANIZATION_READ: 'Read organizations',
|
||||
ORGANIZATION_WRITE: 'Write to organizations',
|
||||
ORGANIZATION_DELETE: 'Delete organizations',
|
||||
SESSION_ACCESS: 'Access modrinth-issued sessions',
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user