
Previously, only routes which were coming from crudable entities could be used with an api token because there was no way to assign permissions to them. This change implements a more flexible structure for api permissions under the hood, allowing to add permissions for these routes and making them usable with an api token. Resolves https://github.com/go-vikunja/vikunja/issues/266
363 lines
9.6 KiB
Vue
363 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import ApiTokenService from '@/services/apiToken'
|
|
import {computed, onMounted, ref} from 'vue'
|
|
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
|
|
import XButton from '@/components/input/button.vue'
|
|
import BaseButton from '@/components/base/BaseButton.vue'
|
|
import ApiTokenModel from '@/models/apiTokenModel'
|
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
|
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
|
import flatPickr from 'vue-flatpickr-component'
|
|
import 'flatpickr/dist/flatpickr.css'
|
|
import {useI18n} from 'vue-i18n'
|
|
import Message from '@/components/misc/message.vue'
|
|
import type {IApiToken} from '@/modelTypes/IApiToken'
|
|
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
|
|
|
const service = new ApiTokenService()
|
|
const tokens = ref<IApiToken[]>([])
|
|
const apiDocsUrl = window.API_URL + '/docs'
|
|
const showCreateForm = ref(false)
|
|
const availableRoutes = ref(null)
|
|
const newToken = ref<IApiToken>(new ApiTokenModel())
|
|
const newTokenExpiry = ref<string | number>(30)
|
|
const newTokenExpiryCustom = ref(new Date())
|
|
const newTokenPermissions = ref({})
|
|
const newTokenPermissionsGroup = ref({})
|
|
const newTokenTitleValid = ref(true)
|
|
const apiTokenTitle = ref()
|
|
const tokenCreatedSuccessMessage = ref('')
|
|
|
|
const showDeleteModal = ref<boolean>(false)
|
|
const tokenToDelete = ref<IApiToken>()
|
|
|
|
const {t} = useI18n()
|
|
|
|
const now = new Date()
|
|
|
|
const flatPickerConfig = computed(() => ({
|
|
altFormat: t('date.altFormatLong'),
|
|
altInput: true,
|
|
dateFormat: 'Y-m-d H:i',
|
|
enableTime: true,
|
|
time_24hr: true,
|
|
locale: getFlatpickrLanguage(),
|
|
minDate: now,
|
|
}))
|
|
|
|
onMounted(async () => {
|
|
tokens.value = await service.getAll()
|
|
const allRoutes = await service.getAvailableRoutes()
|
|
|
|
const routesAvailable = {}
|
|
const keys = Object.keys(allRoutes)
|
|
keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0))
|
|
keys.forEach(key => {
|
|
routesAvailable[key] = allRoutes[key]
|
|
})
|
|
|
|
availableRoutes.value = routesAvailable
|
|
|
|
resetPermissions()
|
|
})
|
|
|
|
function resetPermissions() {
|
|
newTokenPermissions.value = {}
|
|
Object.entries(availableRoutes.value).forEach(entry => {
|
|
const [group, routes] = entry
|
|
newTokenPermissions.value[group] = {}
|
|
Object.keys(routes).forEach(r => {
|
|
newTokenPermissions.value[group][r] = false
|
|
})
|
|
})
|
|
}
|
|
|
|
async function deleteToken() {
|
|
await service.delete(tokenToDelete.value)
|
|
showDeleteModal.value = false
|
|
const index = tokens.value.findIndex(el => el.id === tokenToDelete.value.id)
|
|
tokenToDelete.value = null
|
|
if (index === -1) {
|
|
return
|
|
}
|
|
tokens.value.splice(index, 1)
|
|
}
|
|
|
|
async function createToken() {
|
|
if (!newTokenTitleValid.value) {
|
|
apiTokenTitle.value.focus()
|
|
return
|
|
}
|
|
|
|
const expiry = Number(newTokenExpiry.value)
|
|
if (!isNaN(expiry)) {
|
|
// if it's a number, we assume it's the number of days in the future
|
|
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
|
|
} else {
|
|
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
|
|
}
|
|
|
|
newToken.value.permissions = {}
|
|
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
|
|
const all = Object.entries(ps)
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
.filter(([_, v]) => v)
|
|
.map(p => p[0])
|
|
if (all.length > 0) {
|
|
newToken.value.permissions[key] = all
|
|
}
|
|
})
|
|
|
|
const token = await service.create(newToken.value)
|
|
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
|
|
newToken.value = new ApiTokenModel()
|
|
newTokenExpiry.value = 30
|
|
newTokenExpiryCustom.value = new Date()
|
|
resetPermissions()
|
|
tokens.value.push(token)
|
|
showCreateForm.value = false
|
|
}
|
|
|
|
function formatPermissionTitle(title: string): string {
|
|
return title.replaceAll('_', ' ')
|
|
}
|
|
|
|
function selectPermissionGroup(group: string, checked: boolean) {
|
|
Object.entries(availableRoutes.value[group]).forEach(entry => {
|
|
const [key] = entry
|
|
newTokenPermissions.value[group][key] = checked
|
|
})
|
|
}
|
|
|
|
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
|
if (checked) {
|
|
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
|
|
let allChecked = true
|
|
Object.entries(availableRoutes.value[group]).forEach(entry => {
|
|
const [key] = entry
|
|
if (!newTokenPermissions.value[group][key]) {
|
|
allChecked = false
|
|
}
|
|
})
|
|
|
|
if (allChecked) {
|
|
newTokenPermissionsGroup.value[group] = true
|
|
}
|
|
} else {
|
|
newTokenPermissionsGroup.value[group] = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<card :title="$t('user.settings.apiTokens.title')">
|
|
<Message
|
|
v-if="tokenCreatedSuccessMessage !== ''"
|
|
class="has-text-centered mb-4"
|
|
>
|
|
{{ tokenCreatedSuccessMessage }}<br>
|
|
{{ $t('user.settings.apiTokens.tokenCreatedNotSeeAgain') }}
|
|
</Message>
|
|
|
|
<p>
|
|
{{ $t('user.settings.apiTokens.general') }}
|
|
<BaseButton :href="apiDocsUrl">
|
|
{{ $t('user.settings.apiTokens.apiDocs') }}
|
|
</BaseButton>
|
|
.
|
|
</p>
|
|
|
|
<table
|
|
v-if="tokens.length > 0"
|
|
class="table"
|
|
>
|
|
<tr>
|
|
<th>{{ $t('misc.id') }}</th>
|
|
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
|
|
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
|
|
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
|
|
<th>{{ $t('misc.created') }}</th>
|
|
<th class="has-text-right">
|
|
{{ $t('misc.actions') }}
|
|
</th>
|
|
</tr>
|
|
<tr
|
|
v-for="tk in tokens"
|
|
:key="tk.id"
|
|
>
|
|
<td>{{ tk.id }}</td>
|
|
<td>{{ tk.title }}</td>
|
|
<td class="is-capitalized">
|
|
<template
|
|
v-for="(v, p) in tk.permissions"
|
|
:key="'permission-' + p"
|
|
>
|
|
<strong>{{ formatPermissionTitle(p) }}:</strong>
|
|
{{ v.map(formatPermissionTitle).join(', ') }}
|
|
<br>
|
|
</template>
|
|
</td>
|
|
<td>
|
|
{{ formatDateShort(tk.expiresAt) }}
|
|
<p
|
|
v-if="tk.expiresAt < new Date()"
|
|
class="has-text-danger"
|
|
>
|
|
{{ $t('user.settings.apiTokens.expired', {ago: formatDateSince(tk.expiresAt)}) }}
|
|
</p>
|
|
</td>
|
|
<td>{{ formatDateShort(tk.created) }}</td>
|
|
<td class="has-text-right">
|
|
<XButton
|
|
variant="secondary"
|
|
@click="() => {tokenToDelete = tk; showDeleteModal = true}"
|
|
>
|
|
{{ $t('misc.delete') }}
|
|
</XButton>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<form
|
|
v-if="showCreateForm"
|
|
@submit.prevent="createToken"
|
|
>
|
|
<!-- Title -->
|
|
<div class="field">
|
|
<label
|
|
class="label"
|
|
for="apiTokenTitle"
|
|
>{{ $t('user.settings.apiTokens.attributes.title') }}</label>
|
|
<div class="control">
|
|
<input
|
|
id="apiTokenTitle"
|
|
ref="apiTokenTitle"
|
|
v-model="newToken.title"
|
|
v-focus
|
|
class="input"
|
|
type="text"
|
|
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
|
|
@keyup="() => newTokenTitleValid = newToken.title !== ''"
|
|
@focusout="() => newTokenTitleValid = newToken.title !== ''"
|
|
>
|
|
</div>
|
|
<p
|
|
v-if="!newTokenTitleValid"
|
|
class="help is-danger"
|
|
>
|
|
{{ $t('user.settings.apiTokens.titleRequired') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Expiry -->
|
|
<div class="field">
|
|
<label
|
|
class="label"
|
|
for="apiTokenExpiry"
|
|
>
|
|
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
|
|
</label>
|
|
<div class="is-flex">
|
|
<div class="control select">
|
|
<select
|
|
id="apiTokenExpiry"
|
|
v-model="newTokenExpiry"
|
|
class="select"
|
|
>
|
|
<option value="30">
|
|
{{ $t('user.settings.apiTokens.30d') }}
|
|
</option>
|
|
<option value="60">
|
|
{{ $t('user.settings.apiTokens.60d') }}
|
|
</option>
|
|
<option value="90">
|
|
{{ $t('user.settings.apiTokens.90d') }}
|
|
</option>
|
|
<option value="custom">
|
|
{{ $t('misc.custom') }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<flat-pickr
|
|
v-if="newTokenExpiry === 'custom'"
|
|
v-model="newTokenExpiryCustom"
|
|
class="ml-2"
|
|
:config="flatPickerConfig"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Permissions -->
|
|
<div class="field">
|
|
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
|
|
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
|
|
<div
|
|
v-for="(routes, group) in availableRoutes"
|
|
:key="group"
|
|
class="mb-2"
|
|
>
|
|
<template
|
|
v-if="Object.keys(routes).length >= 1"
|
|
>
|
|
<Fancycheckbox
|
|
v-model="newTokenPermissionsGroup[group]"
|
|
class="mr-2 is-capitalized has-text-weight-bold"
|
|
@update:modelValue="checked => selectPermissionGroup(group, checked)"
|
|
>
|
|
{{ formatPermissionTitle(group) }}
|
|
</Fancycheckbox>
|
|
<br>
|
|
</template>
|
|
<template
|
|
v-for="(paths, route) in routes"
|
|
:key="group+'-'+route"
|
|
>
|
|
<Fancycheckbox
|
|
v-model="newTokenPermissions[group][route]"
|
|
class="ml-4 mr-2 is-capitalized"
|
|
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
|
|
>
|
|
{{ formatPermissionTitle(route) }}
|
|
</Fancycheckbox>
|
|
<br>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<XButton
|
|
:loading="service.loading"
|
|
@click="createToken"
|
|
>
|
|
{{ $t('user.settings.apiTokens.createToken') }}
|
|
</XButton>
|
|
</form>
|
|
|
|
<XButton
|
|
v-else
|
|
icon="plus"
|
|
class="mb-4"
|
|
:loading="service.loading"
|
|
@click="() => showCreateForm = true"
|
|
>
|
|
{{ $t('user.settings.apiTokens.createAToken') }}
|
|
</XButton>
|
|
|
|
<modal
|
|
:enabled="showDeleteModal"
|
|
@close="showDeleteModal = false"
|
|
@submit="deleteToken()"
|
|
>
|
|
<template #header>
|
|
{{ $t('user.settings.apiTokens.delete.header') }}
|
|
</template>
|
|
|
|
<template #text>
|
|
<p>
|
|
{{ $t('user.settings.apiTokens.delete.text1', {token: tokenToDelete.title}) }}<br>
|
|
{{ $t('user.settings.apiTokens.delete.text2') }}
|
|
</p>
|
|
</template>
|
|
</modal>
|
|
</card>
|
|
</template>
|