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:
Carter 2023-12-04 19:26:50 -08:00 committed by GitHub
parent 6d70ced93a
commit 1f58aebb2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1380 additions and 21 deletions

View File

@ -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}`
}

View File

@ -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)
}

View File

@ -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
View 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>

View File

@ -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')
}

View File

@ -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}${

View File

@ -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'

View File

@ -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>

View 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>

View 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
View 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',
}