You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
568
apps/frontend/src/pages/settings/applications.vue
Normal file
568
apps/frontend/src/pages/settings/applications.vue
Normal file
@@ -0,0 +1,568 @@
|
||||
<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="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
: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>{{ formatMessage(commonSettingsMessages.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 { UploadIcon, PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
|
||||
import { CopyCode, ConfirmModal, Button, Checkbox, Avatar, FileInput } from '@modrinth/ui'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
import {
|
||||
scopeList,
|
||||
hasScope,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
getScopeValue,
|
||||
} from '~/composables/auth/scopes.ts'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Applications - Modrinth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
|
||||
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',
|
||||
internal: true,
|
||||
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',
|
||||
internal: true,
|
||||
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',
|
||||
internal: true,
|
||||
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}`, {
|
||||
internal: true,
|
||||
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()
|
||||
}
|
||||
</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>
|
||||
Reference in New Issue
Block a user