428 lines
11 KiB
Vue

<template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)"
:description="formatMessage(deleteModalMessages.description)"
:proceed-label="formatMessage(deleteModalMessages.action)"
@proceed="removePat(deletePatIndex)"
/>
<Modal
ref="patModal"
:header="
editPatIndex !== null
? formatMessage(createModalMessages.editTitle)
: formatMessage(createModalMessages.createTitle)
"
>
<div class="universal-modal">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<input
id="pat-name"
v-model="name"
maxlength="2048"
type="email"
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
/>
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div id="pat-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="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<input id="pat-name" v-model="expires" type="date" />
<p></p>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.patModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
v-if="editPatIndex !== null"
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="editPat"
>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
<button
v-else
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="createPat"
>
<PlusIcon />
{{ formatMessage(createModalMessages.action) }}
</button>
</div>
</div>
</Modal>
<div class="header__row">
<div class="header__title">
<h2>{{ formatMessage(commonSettingsMessages.pats) }}</h2>
</div>
<button
class="btn btn-primary"
@click="
() => {
name = null
scopesVal = 0
expires = null
editPatIndex = null
$refs.patModal.show()
}
"
>
<PlusIcon /> {{ formatMessage(messages.create) }}
</button>
</div>
<p>
<IntlFormatted :message-id="messages.description">
<template #doc-link="{ children }">
<a class="text-link" href="https://docs.modrinth.com">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<div v-for="(pat, index) in pats" :key="pat.id" class="universal-card recessed token">
<div>
<div>
<strong>{{ pat.name }}</strong>
</div>
<div>
<template v-if="pat.access_token">
<CopyCode :text="pat.access_token" />
</template>
<template v-else>
<span
v-tooltip="
pat.last_used
? formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.last_used),
time: new Date(pat.last_used),
})
: null
"
>
<template v-if="pat.last_used">
{{
formatMessage(tokenMessages.lastUsed, {
ago: formatRelativeTime(pat.last_used),
})
}}
</template>
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.expires),
time: new Date(pat.expires),
})
"
>
<template v-if="new Date(pat.expires) > new Date()">
{{
formatMessage(tokenMessages.expiresIn, {
inTime: formatRelativeTime(pat.expires),
})
}}
</template>
<template v-else>
{{
formatMessage(tokenMessages.expiredAgo, {
ago: formatRelativeTime(pat.expires),
})
}}
</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.created),
time: new Date(pat.created),
})
"
>
{{
formatMessage(commonMessages.createdAgoLabel, {
ago: formatRelativeTime(pat.created),
})
}}
</span>
</template>
</div>
</div>
<div class="input-group">
<button
class="iconified-button raised-button"
@click="
() => {
editPatIndex = index
name = pat.name
scopesVal = pat.scopes
expires = $dayjs(pat.expires).format('YYYY-MM-DD')
$refs.patModal.show()
}
"
>
<EditIcon /> {{ formatMessage(tokenMessages.edit) }}
</button>
<button
class="iconified-button raised-button"
@click="
() => {
deletePatIndex = pat.id
$refs.modal_confirm.show()
}
"
>
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { PlusIcon, XIcon, Checkbox, TrashIcon, EditIcon, SaveIcon, ConfirmModal } from 'omorphia'
import { commonSettingsMessages } from '~/utils/common-messages.ts'
import {
hasScope,
scopeList,
toggleScope,
useScopes,
getScopeValue,
} from '~/composables/auth/scopes.ts'
import CopyCode from '~/components/ui/CopyCode.vue'
import Modal from '~/components/ui/Modal.vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const createModalMessages = defineMessages({
createTitle: {
id: 'settings.pats.modal.create.title',
defaultMessage: 'Create personal access token',
},
editTitle: {
id: 'settings.pats.modal.edit.title',
defaultMessage: 'Edit personal access token',
},
nameLabel: {
id: 'settings.pats.modal.create.name.label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'settings.pats.modal.create.name.placeholder',
defaultMessage: "Enter the PAT's name...",
},
expiresLabel: {
id: 'settings.pats.modal.create.expires.label',
defaultMessage: 'Expires',
},
action: {
id: 'settings.pats.modal.create.action',
defaultMessage: 'Create PAT',
},
})
const deleteModalMessages = defineMessages({
title: {
id: 'settings.pats.modal.delete.title',
defaultMessage: 'Are you sure you want to delete this token?',
},
description: {
id: 'settings.pats.modal.delete.description',
defaultMessage: 'This will remove this token forever (like really forever).',
},
action: {
id: 'settings.pats.modal.delete.action',
defaultMessage: 'Delete this token',
},
})
const messages = defineMessages({
description: {
id: 'settings.pats.description',
defaultMessage:
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
},
create: {
id: 'settings.pats.action.create',
defaultMessage: 'Create a PAT',
},
})
const tokenMessages = defineMessages({
edit: {
id: 'settings.pats.token.action.edit',
defaultMessage: 'Edit token',
},
revoke: {
id: 'settings.pats.token.action.revoke',
defaultMessage: 'Revoke token',
},
lastUsed: {
id: 'settings.pats.token.last-used',
defaultMessage: 'Last used {ago}',
},
neverUsed: {
id: 'settings.pats.token.never-used',
defaultMessage: 'Never used',
},
expiresIn: {
id: 'settings.pats.token.expires-in',
defaultMessage: 'Expires {inTime}',
},
expiredAgo: {
id: 'settings.pats.token.expired-ago',
defaultMessage: 'Expired {ago}',
},
})
definePageMeta({
middleware: 'auth',
})
useHead({
title: `${formatMessage(messages.title)} - Modrinth`,
})
const data = useNuxtApp()
const { scopesToLabels } = useScopes()
const patModal = ref()
const editPatIndex = ref(null)
const name = ref(null)
const scopesVal = ref(BigInt(0))
const expires = ref(null)
const deletePatIndex = ref(null)
const loading = ref(false)
const { data: pats, refresh } = await useAsyncData('pat', () => useBaseFetch('pat'))
async function createPat() {
startLoading()
loading.value = true
try {
const res = await useBaseFetch('pat', {
method: 'POST',
body: {
name: name.value,
scopes: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
})
pats.value.push(res)
patModal.value.hide()
} catch (err) {
data.$notify({
group: 'main',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
loading.value = false
stopLoading()
}
async function editPat() {
startLoading()
loading.value = true
try {
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
method: 'PATCH',
body: {
name: name.value,
scopes: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
})
await refresh()
patModal.value.hide()
} catch (err) {
data.$notify({
group: 'main',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
loading.value = false
stopLoading()
}
async function removePat(id) {
startLoading()
try {
pats.value = pats.value.filter((x) => x.id !== id)
await useBaseFetch(`pat/${id}`, {
method: 'DELETE',
})
await refresh()
} catch (err) {
data.$notify({
group: 'main',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.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);
}
}
.token {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>