Modrinth/pages/auth/authorize.vue
Carter 1f58aebb2b
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>
2023-12-04 20:26:50 -07:00

313 lines
6.8 KiB
Vue

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