mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 10:55:05 +02:00
Add get all, update and delete endpoints. Add api keys config page
Some checks are pending
Some checks are pending
This commit is contained in:
parent
d96ed01ce4
commit
af1ff12dbb
10 changed files with 710 additions and 9 deletions
|
@ -70,6 +70,11 @@ export default {
|
|||
title: this.$strings.HeaderUsers,
|
||||
path: '/config/users'
|
||||
},
|
||||
{
|
||||
id: 'config-api-keys',
|
||||
title: this.$strings.HeaderApiKeys,
|
||||
path: '/config/api-keys'
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: this.$strings.HeaderListeningSessions,
|
||||
|
|
|
@ -351,9 +351,6 @@ export default {
|
|||
this.$toast.error(errMsg || 'Failed to create account')
|
||||
})
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
userTypeUpdated(type) {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
|
|
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key-created" :width="800" :height="'unset'" persistent>
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 200px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<p class="text-lg text-white mb-4">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>
|
||||
|
||||
<p class="text-lg text-white mb-4">{{ $strings.LabelApiKeyCreatedDescription }}</p>
|
||||
|
||||
<ui-text-input label="API Key" :value="apiKeyKey" readonly show-copy />
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<ui-btn color="bg-primary" @click="show = false">{{ $strings.ButtonClose }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderNewApiKey
|
||||
},
|
||||
apiKeyName() {
|
||||
return this.apiKey?.name || ''
|
||||
},
|
||||
apiKeyKey() {
|
||||
return this.apiKey?.apiKey || ''
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
317
client/components/modals/ApiKeyModal.vue
Normal file
317
client/components/modals/ApiKeyModal.vue
Normal file
|
@ -0,0 +1,317 @@
|
|||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div v-if="isNew" class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="newApiKey.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newApiKey.permissions.download" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newApiKey.permissions.update" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newApiKey.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newApiKey.permissions.upload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="ereader-permissions-toggle">{{ $strings.LabelPermissionsCreateEreader }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="ereader-permissions-toggle" v-model="newApiKey.permissions.createEreader" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newApiKey.permissions.accessExplicitContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newApiKey.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!newApiKey.permissions.accessAllLibraries" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newApiKey.permissions.librariesAccessible" :items="libraryItems" :label="$strings.LabelLibrariesAccessibleToUser" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-cen~ter my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newApiKey.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newApiKey.permissions.accessAllTags" class="my-4">
|
||||
<div class="flex items-center">
|
||||
<ui-multi-select-dropdown v-model="newApiKey.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newApiKey.permissions.selectedTagsNotAccessible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
<div class="grow" />
|
||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newApiKey: {},
|
||||
isNew: true,
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
},
|
||||
libraryItems() {
|
||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||
},
|
||||
itemTags() {
|
||||
return this.tags.map((t) => {
|
||||
return {
|
||||
text: t,
|
||||
value: t
|
||||
}
|
||||
})
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newApiKey.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
accessAllTagsToggled(val) {
|
||||
if (val) {
|
||||
if (this.newApiKey.itemTagsSelected?.length) {
|
||||
this.newApiKey.itemTagsSelected = []
|
||||
}
|
||||
this.newApiKey.permissions.selectedTagsNotAccessible = false
|
||||
}
|
||||
},
|
||||
fetchAllTags() {
|
||||
this.loadingTags = true
|
||||
this.$axios
|
||||
.$get(`/api/tags`)
|
||||
.then((res) => {
|
||||
this.tags = res.tags
|
||||
this.loadingTags = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tags', error)
|
||||
this.loadingTags = false
|
||||
})
|
||||
},
|
||||
accessAllLibrariesToggled(val) {
|
||||
if (!val && !this.newApiKey.permissions.librariesAccessible.length) {
|
||||
this.newApiKey.permissions.librariesAccessible = this.libraries.map((l) => l.id)
|
||||
} else if (val && this.newApiKey.permissions.librariesAccessible.length) {
|
||||
this.newApiKey.permissions.librariesAccessible = []
|
||||
}
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.newApiKey.name) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyNameError)
|
||||
return
|
||||
}
|
||||
if (!this.newApiKey.permissions.accessAllLibraries && !this.newApiKey.permissions.librariesAccessible.length) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyLibraryError)
|
||||
return
|
||||
}
|
||||
if (!this.newApiKey.permissions.accessAllTags && !this.newApiKey.itemTagsSelected.length) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyTagError)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateApiKey()
|
||||
} else {
|
||||
this.submitUpdateApiKey()
|
||||
}
|
||||
},
|
||||
submitUpdateApiKey() {
|
||||
var apiKey = {
|
||||
isActive: this.newApiKey.isActive,
|
||||
permissions: this.newApiKey.permissions
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('updated', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to update apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
submitCreateApiKey() {
|
||||
const apiKey = { ...this.newApiKey }
|
||||
|
||||
if (this.newApiKey.expiresIn) {
|
||||
apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)
|
||||
} else {
|
||||
delete apiKey.expiresIn
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/api-keys', apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('created', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to create apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
this.isNew = !this.apiKey
|
||||
|
||||
if (this.apiKey) {
|
||||
this.newApiKey = {
|
||||
name: this.apiKey.name,
|
||||
isActive: this.apiKey.isActive,
|
||||
permissions: { ...this.apiKey.permissions }
|
||||
}
|
||||
} else {
|
||||
this.newApiKey = {
|
||||
name: null,
|
||||
expiresIn: null,
|
||||
isActive: true,
|
||||
permissions: {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: false,
|
||||
selectedTagsNotAccessible: false,
|
||||
createEreader: false,
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
167
client/components/tables/ApiKeysTable.vue
Normal file
167
client/components/tables/ApiKeysTable.vue
Normal file
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||
<tr>
|
||||
<th>{{ $strings.LabelName }}</th>
|
||||
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||
<th class="w-32">{{ $strings.LabelLastUsed }}</th>
|
||||
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
<tr v-for="apiKey in apiKeys" :key="apiKey.id" :class="apiKey.isActive ? '' : 'bg-error/10!'">
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">{{ apiKey.expiresAt ? $formatJsDatetime(new Date(apiKey.expiresAt), dateFormat, timeFormat) : $strings.LabelExpiresNever }}</td>
|
||||
<td class="text-xs">
|
||||
<ui-tooltip v-if="apiKey.lastUsedAt" direction="top" :text="$formatJsDatetime(new Date(apiKey.lastUsedAt), dateFormat, timeFormat)">
|
||||
{{ $dateDistanceFromNow(new Date(apiKey.lastUsedAt).getTime()) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||
{{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="w-full flex justify-left">
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer" @click.stop="editApiKey(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base">edit</button>
|
||||
</div>
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer" @click.stop="deleteApiKeyClick(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonDelete" class="material-symbols text-base">delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p v-else class="text-base text-gray-300 py-4">{{ $strings.LabelNoApiKeys }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
apiKeys: [],
|
||||
isDeletingApiKey: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteApiKeyClick(apiKey) {
|
||||
if (this.isDeletingApiKey) return
|
||||
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteApiKey', [apiKey.name]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteApiKey(apiKey) {
|
||||
this.isDeletingApiKey = true
|
||||
this.$axios
|
||||
.$delete(`/api/api-keys/${apiKey.id}`)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.removeApiKey(apiKey.id)
|
||||
this.$emit('deleted', apiKey.id)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete apiKey', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isDeletingApiKey = false
|
||||
})
|
||||
},
|
||||
editApiKey(apiKey) {
|
||||
this.$emit('edit', apiKey)
|
||||
},
|
||||
addApiKey(apiKey) {
|
||||
this.apiKeys.push(apiKey)
|
||||
},
|
||||
removeApiKey(apiKeyId) {
|
||||
this.apiKeys = this.apiKeys.filter((a) => a.id !== apiKeyId)
|
||||
},
|
||||
updateApiKey(apiKey) {
|
||||
this.apiKeys = this.apiKeys.map((a) => (a.id === apiKey.id ? apiKey : a))
|
||||
},
|
||||
loadApiKeys() {
|
||||
this.$axios
|
||||
.$get('/api/api-keys')
|
||||
.then((res) => {
|
||||
this.apiKeys = res.apiKeys.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load apiKeys', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadApiKeys()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#api-keys {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #474747;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#api-keys td,
|
||||
#api-keys th {
|
||||
/* border: 1px solid #2e2e2e; */
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#api-keys td.py-0 {
|
||||
padding: 0px 8px;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(even) {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#api-keys tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#api-keys th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
|
@ -53,6 +53,7 @@ export default {
|
|||
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
|
||||
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
|
|
68
client/pages/config/api-keys/index.vue
Normal file
68
client/pages/config/api-keys/index.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderApiKeys">
|
||||
<template #header-items>
|
||||
<div v-if="numApiKeys" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numApiKeys }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn color="bg-primary" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||
</app-settings-content>
|
||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" @created="apiKeyCreated" @deleted="apiKeyDeleted" @updated="apiKeyUpdated" />
|
||||
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedApiKey: null,
|
||||
showApiKeyModal: false,
|
||||
showApiKeyCreatedModal: false,
|
||||
numApiKeys: 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
apiKeyCreated(apiKey) {
|
||||
this.numApiKeys++
|
||||
this.selectedApiKey = apiKey
|
||||
this.showApiKeyCreatedModal = true
|
||||
if (this.$refs.apiKeysTable) {
|
||||
console.log('apiKeyCreated', apiKey)
|
||||
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
apiKeyDeleted() {
|
||||
this.numApiKeys--
|
||||
},
|
||||
apiKeyUpdated(apiKey) {
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.updateApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
setShowApiKeyModal(selectedApiKey) {
|
||||
this.selectedApiKey = selectedApiKey
|
||||
this.showApiKeyModal = true
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"ButtonAdd": "Add",
|
||||
"ButtonAddApiKey": "Add API Key",
|
||||
"ButtonAddChapters": "Add Chapters",
|
||||
"ButtonAddDevice": "Add Device",
|
||||
"ButtonAddLibrary": "Add Library",
|
||||
|
@ -20,6 +21,7 @@
|
|||
"ButtonChooseAFolder": "Choose a folder",
|
||||
"ButtonChooseFiles": "Choose files",
|
||||
"ButtonClearFilter": "Clear Filter",
|
||||
"ButtonClose": "Close",
|
||||
"ButtonCloseFeed": "Close Feed",
|
||||
"ButtonCloseSession": "Close Open Session",
|
||||
"ButtonCollections": "Collections",
|
||||
|
@ -119,6 +121,7 @@
|
|||
"HeaderAccount": "Account",
|
||||
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
|
||||
"HeaderAdvanced": "Advanced",
|
||||
"HeaderApiKeys": "API Keys",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
|
@ -162,6 +165,7 @@
|
|||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
||||
"HeaderNewAccount": "New Account",
|
||||
"HeaderNewApiKey": "New API Key",
|
||||
"HeaderNewLibrary": "New Library",
|
||||
"HeaderNotificationCreate": "Create Notification",
|
||||
"HeaderNotificationUpdate": "Update Notification",
|
||||
|
@ -206,6 +210,7 @@
|
|||
"HeaderTableOfContents": "Table of Contents",
|
||||
"HeaderTools": "Tools",
|
||||
"HeaderUpdateAccount": "Update Account",
|
||||
"HeaderUpdateApiKey": "Update API Key",
|
||||
"HeaderUpdateAuthor": "Update Author",
|
||||
"HeaderUpdateDetails": "Update Details",
|
||||
"HeaderUpdateLibrary": "Update Library",
|
||||
|
@ -235,6 +240,8 @@
|
|||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
||||
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
||||
"LabelApiToken": "API Token",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
||||
|
@ -346,6 +353,9 @@
|
|||
"LabelExample": "Example",
|
||||
"LabelExpandSeries": "Expand Series",
|
||||
"LabelExpandSubSeries": "Expand Sub Series",
|
||||
"LabelExpiresAt": "Expires At",
|
||||
"LabelExpiresInSeconds": "Expires in (seconds)",
|
||||
"LabelExpiresNever": "Never",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelExplicitChecked": "Explicit (checked)",
|
||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||
|
@ -408,6 +418,7 @@
|
|||
"LabelLastSeen": "Last Seen",
|
||||
"LabelLastTime": "Last Time",
|
||||
"LabelLastUpdate": "Last Update",
|
||||
"LabelLastUsed": "Last Used",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLayoutSplitPage": "Split page",
|
||||
|
@ -455,6 +466,7 @@
|
|||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNoApiKeys": "No API keys",
|
||||
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
||||
"LabelNoEpisodesSelected": "No episodes selected",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
|
@ -730,6 +742,7 @@
|
|||
"MessageChaptersNotFound": "Chapters not found",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
|
@ -1000,6 +1013,8 @@
|
|||
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
|
||||
"ToastEpisodeUpdateSuccess": "{0} episodes updated",
|
||||
"ToastErrorCannotShare": "Cannot share natively on this device",
|
||||
"ToastFailedToCreate": "Failed to create",
|
||||
"ToastFailedToDelete": "Failed to delete",
|
||||
"ToastFailedToLoadData": "Failed to load data",
|
||||
"ToastFailedToMatch": "Failed to match",
|
||||
"ToastFailedToShare": "Failed to share",
|
||||
|
|
|
@ -13,6 +13,20 @@ const Database = require('../Database')
|
|||
class ApiKeyController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* GET: /api/api-keys
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const apiKeys = await Database.apiKeyModel.findAll()
|
||||
|
||||
return res.json({
|
||||
apiKeys: apiKeys.map((a) => a.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/api-keys
|
||||
*
|
||||
|
@ -47,18 +61,72 @@ class ApiKeyController {
|
|||
name: req.body.name,
|
||||
expiresAt,
|
||||
permissions,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
isActive: !!req.body.isActive
|
||||
})
|
||||
|
||||
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||
return res.json({
|
||||
id: apiKeyInstance.id,
|
||||
name: apiKeyInstance.name,
|
||||
apiKey,
|
||||
expiresAt: apiKeyInstance.expiresAt,
|
||||
permissions: apiKeyInstance.permissions
|
||||
apiKey: {
|
||||
apiKey, // Actual key only shown to user on creation
|
||||
...apiKeyInstance.toJSON()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/api-keys/:id
|
||||
* Only isActive and permissions can be updated because name and expiresIn are in the JWT
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.body.isActive !== undefined) {
|
||||
if (typeof req.body.isActive !== 'boolean') {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
apiKey.isActive = req.body.isActive
|
||||
}
|
||||
|
||||
if (req.body.permissions && Object.keys(req.body.permissions).length > 0) {
|
||||
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
|
||||
apiKey.permissions = permissions
|
||||
}
|
||||
|
||||
await apiKey.save()
|
||||
|
||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||
|
||||
return res.json({
|
||||
apiKey: apiKey.toJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/api-keys/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await apiKey.destroy()
|
||||
Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`)
|
||||
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
|
|
|
@ -329,7 +329,10 @@ class ApiRouter {
|
|||
//
|
||||
// API Key Routes
|
||||
//
|
||||
this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))
|
||||
this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))
|
||||
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
|
||||
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue