mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Compare commits
2 commits
4f5123e842
...
af1ff12dbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1ff12dbb | ||
|
|
d96ed01ce4 |
14 changed files with 997 additions and 116 deletions
|
|
@ -70,6 +70,11 @@ export default {
|
||||||
title: this.$strings.HeaderUsers,
|
title: this.$strings.HeaderUsers,
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'config-api-keys',
|
||||||
|
title: this.$strings.HeaderApiKeys,
|
||||||
|
path: '/config/api-keys'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'config-sessions',
|
id: 'config-sessions',
|
||||||
title: this.$strings.HeaderListeningSessions,
|
title: this.$strings.HeaderListeningSessions,
|
||||||
|
|
|
||||||
|
|
@ -351,9 +351,6 @@ export default {
|
||||||
this.$toast.error(errMsg || 'Failed to create account')
|
this.$toast.error(errMsg || 'Failed to create account')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleActive() {
|
|
||||||
this.newUser.isActive = !this.newUser.isActive
|
|
||||||
},
|
|
||||||
userTypeUpdated(type) {
|
userTypeUpdated(type) {
|
||||||
this.newUser.permissions = {
|
this.newUser.permissions = {
|
||||||
download: type !== 'guest',
|
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 === 'sessions') return this.$strings.HeaderListeningSessions
|
||||||
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
||||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
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 === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
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",
|
"ButtonAdd": "Add",
|
||||||
|
"ButtonAddApiKey": "Add API Key",
|
||||||
"ButtonAddChapters": "Add Chapters",
|
"ButtonAddChapters": "Add Chapters",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "Add Device",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "Add Library",
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
"ButtonChooseAFolder": "Choose a folder",
|
"ButtonChooseAFolder": "Choose a folder",
|
||||||
"ButtonChooseFiles": "Choose files",
|
"ButtonChooseFiles": "Choose files",
|
||||||
"ButtonClearFilter": "Clear Filter",
|
"ButtonClearFilter": "Clear Filter",
|
||||||
|
"ButtonClose": "Close",
|
||||||
"ButtonCloseFeed": "Close Feed",
|
"ButtonCloseFeed": "Close Feed",
|
||||||
"ButtonCloseSession": "Close Open Session",
|
"ButtonCloseSession": "Close Open Session",
|
||||||
"ButtonCollections": "Collections",
|
"ButtonCollections": "Collections",
|
||||||
|
|
@ -119,6 +121,7 @@
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
|
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
|
||||||
"HeaderAdvanced": "Advanced",
|
"HeaderAdvanced": "Advanced",
|
||||||
|
"HeaderApiKeys": "API Keys",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||||
"HeaderAudioTracks": "Audio Tracks",
|
"HeaderAudioTracks": "Audio Tracks",
|
||||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||||
|
|
@ -162,6 +165,7 @@
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
||||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
"HeaderMetadataToEmbed": "Metadata to embed",
|
||||||
"HeaderNewAccount": "New Account",
|
"HeaderNewAccount": "New Account",
|
||||||
|
"HeaderNewApiKey": "New API Key",
|
||||||
"HeaderNewLibrary": "New Library",
|
"HeaderNewLibrary": "New Library",
|
||||||
"HeaderNotificationCreate": "Create Notification",
|
"HeaderNotificationCreate": "Create Notification",
|
||||||
"HeaderNotificationUpdate": "Update Notification",
|
"HeaderNotificationUpdate": "Update Notification",
|
||||||
|
|
@ -206,6 +210,7 @@
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
"HeaderUpdateAccount": "Update Account",
|
"HeaderUpdateAccount": "Update Account",
|
||||||
|
"HeaderUpdateApiKey": "Update API Key",
|
||||||
"HeaderUpdateAuthor": "Update Author",
|
"HeaderUpdateAuthor": "Update Author",
|
||||||
"HeaderUpdateDetails": "Update Details",
|
"HeaderUpdateDetails": "Update Details",
|
||||||
"HeaderUpdateLibrary": "Update Library",
|
"HeaderUpdateLibrary": "Update Library",
|
||||||
|
|
@ -235,6 +240,8 @@
|
||||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
"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",
|
"LabelApiToken": "API Token",
|
||||||
"LabelAppend": "Append",
|
"LabelAppend": "Append",
|
||||||
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
||||||
|
|
@ -346,6 +353,9 @@
|
||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExpandSeries": "Expand Series",
|
"LabelExpandSeries": "Expand Series",
|
||||||
"LabelExpandSubSeries": "Expand Sub Series",
|
"LabelExpandSubSeries": "Expand Sub Series",
|
||||||
|
"LabelExpiresAt": "Expires At",
|
||||||
|
"LabelExpiresInSeconds": "Expires in (seconds)",
|
||||||
|
"LabelExpiresNever": "Never",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelExplicitChecked": "Explicit (checked)",
|
"LabelExplicitChecked": "Explicit (checked)",
|
||||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||||
|
|
@ -408,6 +418,7 @@
|
||||||
"LabelLastSeen": "Last Seen",
|
"LabelLastSeen": "Last Seen",
|
||||||
"LabelLastTime": "Last Time",
|
"LabelLastTime": "Last Time",
|
||||||
"LabelLastUpdate": "Last Update",
|
"LabelLastUpdate": "Last Update",
|
||||||
|
"LabelLastUsed": "Last Used",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Single page",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "Split page",
|
||||||
|
|
@ -455,6 +466,7 @@
|
||||||
"LabelNewestEpisodes": "Newest Episodes",
|
"LabelNewestEpisodes": "Newest Episodes",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoApiKeys": "No API keys",
|
||||||
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotFinished": "Not Finished",
|
"LabelNotFinished": "Not Finished",
|
||||||
|
|
@ -730,6 +742,7 @@
|
||||||
"MessageChaptersNotFound": "Chapters not found",
|
"MessageChaptersNotFound": "Chapters not found",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
"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}?",
|
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||||
"MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{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?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
|
|
@ -1000,6 +1013,8 @@
|
||||||
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
|
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
|
||||||
"ToastEpisodeUpdateSuccess": "{0} episodes updated",
|
"ToastEpisodeUpdateSuccess": "{0} episodes updated",
|
||||||
"ToastErrorCannotShare": "Cannot share natively on this device",
|
"ToastErrorCannotShare": "Cannot share natively on this device",
|
||||||
|
"ToastFailedToCreate": "Failed to create",
|
||||||
|
"ToastFailedToDelete": "Failed to delete",
|
||||||
"ToastFailedToLoadData": "Failed to load data",
|
"ToastFailedToLoadData": "Failed to load data",
|
||||||
"ToastFailedToMatch": "Failed to match",
|
"ToastFailedToMatch": "Failed to match",
|
||||||
"ToastFailedToShare": "Failed to share",
|
"ToastFailedToShare": "Failed to share",
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,9 @@ class Database {
|
||||||
return this.models.session
|
return this.models.session
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {typeof import('./models/ApiToken')} */
|
/** @type {typeof import('./models/ApiKey')} */
|
||||||
get apiTokenModel() {
|
get apiKeyModel() {
|
||||||
return this.models.apiToken
|
return this.models.apiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {typeof import('./models/Library')} */
|
/** @type {typeof import('./models/Library')} */
|
||||||
|
|
@ -322,7 +322,7 @@ class Database {
|
||||||
buildModels(force = false) {
|
buildModels(force = false) {
|
||||||
require('./models/User').init(this.sequelize)
|
require('./models/User').init(this.sequelize)
|
||||||
require('./models/Session').init(this.sequelize)
|
require('./models/Session').init(this.sequelize)
|
||||||
require('./models/ApiToken').init(this.sequelize)
|
require('./models/ApiKey').init(this.sequelize)
|
||||||
require('./models/Library').init(this.sequelize)
|
require('./models/Library').init(this.sequelize)
|
||||||
require('./models/LibraryFolder').init(this.sequelize)
|
require('./models/LibraryFolder').init(this.sequelize)
|
||||||
require('./models/Book').init(this.sequelize)
|
require('./models/Book').init(this.sequelize)
|
||||||
|
|
|
||||||
146
server/controllers/ApiKeyController.js
Normal file
146
server/controllers/ApiKeyController.js
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
const { Request, Response, NextFunction } = require('express')
|
||||||
|
const uuidv4 = require('uuid').v4
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RequestUserObject
|
||||||
|
* @property {import('../models/User')} user
|
||||||
|
*
|
||||||
|
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async create(req, res) {
|
||||||
|
if (!req.body.name || typeof req.body.name !== 'string') {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
||||||
|
|
||||||
|
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
|
||||||
|
const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
Logger.error(`[ApiKeyController] create: Error generating API key`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate expiration time for the api key
|
||||||
|
const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null
|
||||||
|
|
||||||
|
const apiKeyInstance = await Database.apiKeyModel.create({
|
||||||
|
id: keyId,
|
||||||
|
name: req.body.name,
|
||||||
|
expiresAt,
|
||||||
|
permissions,
|
||||||
|
userId: req.user.id,
|
||||||
|
isActive: !!req.body.isActive
|
||||||
|
})
|
||||||
|
|
||||||
|
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||||
|
return res.json({
|
||||||
|
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
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
middleware(req, res, next) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ApiKeyController()
|
||||||
|
|
@ -8,11 +8,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const migrationVersion = '2.26.0'
|
const migrationVersion = '2.26.0'
|
||||||
const migrationName = `${migrationVersion}-create-sessions-table`
|
const migrationName = `${migrationVersion}-create-auth-tables`
|
||||||
const loggerPrefix = `[${migrationVersion} migration]`
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This upward migration creates a sessions table and apiTokens table.
|
* This upward migration creates a sessions table and apiKeys table.
|
||||||
*
|
*
|
||||||
* @param {MigrationOptions} options - an object containing the migration context.
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
|
@ -68,23 +68,19 @@ async function up({ context: { queryInterface, logger } }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if table exists
|
// Check if table exists
|
||||||
if (await queryInterface.tableExists('apiTokens')) {
|
if (await queryInterface.tableExists('apiKeys')) {
|
||||||
logger.info(`${loggerPrefix} table "apiTokens" already exists`)
|
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
|
||||||
} else {
|
} else {
|
||||||
// Create table
|
// Create table
|
||||||
logger.info(`${loggerPrefix} creating table "apiTokens"`)
|
logger.info(`${loggerPrefix} creating table "apiKeys"`)
|
||||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||||
await queryInterface.createTable('apiTokens', {
|
await queryInterface.createTable('apiKeys', {
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true
|
primaryKey: true
|
||||||
},
|
},
|
||||||
name: DataTypes.STRING,
|
name: DataTypes.STRING,
|
||||||
tokenHash: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
expiresAt: DataTypes.DATE,
|
expiresAt: DataTypes.DATE,
|
||||||
lastUsedAt: DataTypes.DATE,
|
lastUsedAt: DataTypes.DATE,
|
||||||
isActive: {
|
isActive: {
|
||||||
|
|
@ -109,18 +105,17 @@ async function up({ context: { queryInterface, logger } }) {
|
||||||
},
|
},
|
||||||
key: 'id'
|
key: 'id'
|
||||||
},
|
},
|
||||||
allowNull: false,
|
onDelete: 'SET NULL'
|
||||||
onDelete: 'CASCADE'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
logger.info(`${loggerPrefix} created table "apiTokens"`)
|
logger.info(`${loggerPrefix} created table "apiKeys"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This downward migration script removes the sessions table and apiTokens table.
|
* This downward migration script removes the sessions table and apiKeys table.
|
||||||
*
|
*
|
||||||
* @param {MigrationOptions} options - an object containing the migration context.
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
|
@ -139,12 +134,12 @@ async function down({ context: { queryInterface, logger } }) {
|
||||||
logger.info(`${loggerPrefix} table "sessions" does not exist`)
|
logger.info(`${loggerPrefix} table "sessions" does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await queryInterface.tableExists('apiTokens')) {
|
if (await queryInterface.tableExists('apiKeys')) {
|
||||||
logger.info(`${loggerPrefix} dropping table "apiTokens"`)
|
logger.info(`${loggerPrefix} dropping table "apiKeys"`)
|
||||||
await queryInterface.dropTable('apiTokens')
|
await queryInterface.dropTable('apiKeys')
|
||||||
logger.info(`${loggerPrefix} dropped table "apiTokens"`)
|
logger.info(`${loggerPrefix} dropped table "apiKeys"`)
|
||||||
} else {
|
} else {
|
||||||
logger.info(`${loggerPrefix} table "apiTokens" does not exist`)
|
logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
191
server/models/ApiKey.js
Normal file
191
server/models/ApiKey.js
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
const { DataTypes, Model, Op } = require('sequelize')
|
||||||
|
const jwt = require('jsonwebtoken')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ApiKeyPermissions
|
||||||
|
* @property {boolean} download
|
||||||
|
* @property {boolean} update
|
||||||
|
* @property {boolean} delete
|
||||||
|
* @property {boolean} upload
|
||||||
|
* @property {boolean} createEreader
|
||||||
|
* @property {boolean} accessAllLibraries
|
||||||
|
* @property {boolean} accessAllTags
|
||||||
|
* @property {boolean} accessExplicitContent
|
||||||
|
* @property {boolean} selectedTagsNotAccessible
|
||||||
|
* @property {string[]} librariesAccessible
|
||||||
|
* @property {string[]} itemTagsSelected
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ApiKey extends Model {
|
||||||
|
constructor(values, options) {
|
||||||
|
super(values, options)
|
||||||
|
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.id
|
||||||
|
/** @type {string} */
|
||||||
|
this.name
|
||||||
|
/** @type {Date} */
|
||||||
|
this.expiresAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.lastUsedAt
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.isActive
|
||||||
|
/** @type {Object} */
|
||||||
|
this.permissions
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.userId
|
||||||
|
|
||||||
|
// Expanded properties
|
||||||
|
|
||||||
|
/** @type {import('./User').User} */
|
||||||
|
this.user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same properties as User.getDefaultPermissions
|
||||||
|
* @returns {ApiKeyPermissions}
|
||||||
|
*/
|
||||||
|
static getDefaultPermissions() {
|
||||||
|
return {
|
||||||
|
download: true,
|
||||||
|
update: true,
|
||||||
|
delete: true,
|
||||||
|
upload: true,
|
||||||
|
createEreader: true,
|
||||||
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true,
|
||||||
|
accessExplicitContent: true,
|
||||||
|
selectedTagsNotAccessible: false, // Inverts itemTagsSelected
|
||||||
|
librariesAccessible: [],
|
||||||
|
itemTagsSelected: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge permissions from request with default permissions
|
||||||
|
* @param {ApiKeyPermissions} reqPermissions
|
||||||
|
* @returns {ApiKeyPermissions}
|
||||||
|
*/
|
||||||
|
static mergePermissionsWithDefault(reqPermissions) {
|
||||||
|
const permissions = this.getDefaultPermissions()
|
||||||
|
|
||||||
|
if (!reqPermissions || typeof reqPermissions !== 'object') {
|
||||||
|
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`)
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in reqPermissions) {
|
||||||
|
if (reqPermissions[key] === undefined) {
|
||||||
|
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'librariesAccessible' || key === 'itemTagsSelected') {
|
||||||
|
if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) {
|
||||||
|
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions[key] = reqPermissions[key]
|
||||||
|
} else if (typeof reqPermissions[key] !== 'boolean') {
|
||||||
|
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions[key] = reqPermissions[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired api keys from the database
|
||||||
|
* @returns {Promise<number>} Number of api keys deleted
|
||||||
|
*/
|
||||||
|
static async cleanupExpiredApiKeys() {
|
||||||
|
const deletedCount = await ApiKey.destroy({
|
||||||
|
where: {
|
||||||
|
expiresAt: {
|
||||||
|
[Op.lt]: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return deletedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new api key
|
||||||
|
* @param {string} keyId
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
static async generateApiKey(keyId, name, expiresIn) {
|
||||||
|
const options = {}
|
||||||
|
if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {
|
||||||
|
options.expiresIn = expiresIn
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
jwt.sign(
|
||||||
|
{
|
||||||
|
keyId,
|
||||||
|
name,
|
||||||
|
type: 'api'
|
||||||
|
},
|
||||||
|
global.ServerSettings.tokenSecret,
|
||||||
|
options,
|
||||||
|
(err, token) => {
|
||||||
|
if (err) {
|
||||||
|
Logger.error(`[ApiKey] Error generating API key: ${err}`)
|
||||||
|
resolve(null)
|
||||||
|
} else {
|
||||||
|
resolve(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init(
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
expiresAt: DataTypes.DATE,
|
||||||
|
lastUsedAt: DataTypes.DATE,
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
permissions: DataTypes.JSON
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'apiKey'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { user } = sequelize.models
|
||||||
|
user.hasMany(ApiKey, {
|
||||||
|
onDelete: 'SET NULL'
|
||||||
|
})
|
||||||
|
ApiKey.belongsTo(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ApiKey
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
const { DataTypes, Model, Op } = require('sequelize')
|
|
||||||
|
|
||||||
class ApiToken extends Model {
|
|
||||||
constructor(values, options) {
|
|
||||||
super(values, options)
|
|
||||||
|
|
||||||
/** @type {UUIDV4} */
|
|
||||||
this.id
|
|
||||||
/** @type {string} */
|
|
||||||
this.name
|
|
||||||
/** @type {string} */
|
|
||||||
this.tokenHash
|
|
||||||
/** @type {Date} */
|
|
||||||
this.expiresAt
|
|
||||||
/** @type {Date} */
|
|
||||||
this.lastUsedAt
|
|
||||||
/** @type {boolean} */
|
|
||||||
this.isActive
|
|
||||||
/** @type {Object} */
|
|
||||||
this.permissions
|
|
||||||
/** @type {Date} */
|
|
||||||
this.createdAt
|
|
||||||
/** @type {UUIDV4} */
|
|
||||||
this.userId
|
|
||||||
|
|
||||||
// Expanded properties
|
|
||||||
|
|
||||||
/** @type {import('./User').User} */
|
|
||||||
this.user
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired api tokens from the database
|
|
||||||
* @returns {Promise<number>} Number of api tokens deleted
|
|
||||||
*/
|
|
||||||
static async cleanupExpiredApiTokens() {
|
|
||||||
const deletedCount = await ApiToken.destroy({
|
|
||||||
where: {
|
|
||||||
expiresAt: {
|
|
||||||
[Op.lt]: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return deletedCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize model
|
|
||||||
* @param {import('../Database').sequelize} sequelize
|
|
||||||
*/
|
|
||||||
static init(sequelize) {
|
|
||||||
super.init(
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true
|
|
||||||
},
|
|
||||||
name: DataTypes.STRING,
|
|
||||||
tokenHash: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
expiresAt: DataTypes.DATE,
|
|
||||||
lastUsedAt: DataTypes.DATE,
|
|
||||||
isActive: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false
|
|
||||||
},
|
|
||||||
permissions: DataTypes.JSON
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequelize,
|
|
||||||
modelName: 'apiToken'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const { user } = sequelize.models
|
|
||||||
user.hasMany(ApiToken, {
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
foreignKey: {
|
|
||||||
allowNull: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ApiToken.belongsTo(user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = ApiToken
|
|
||||||
|
|
@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP
|
||||||
const MiscController = require('../controllers/MiscController')
|
const MiscController = require('../controllers/MiscController')
|
||||||
const ShareController = require('../controllers/ShareController')
|
const ShareController = require('../controllers/ShareController')
|
||||||
const StatsController = require('../controllers/StatsController')
|
const StatsController = require('../controllers/StatsController')
|
||||||
|
const ApiKeyController = require('../controllers/ApiKeyController')
|
||||||
|
|
||||||
class ApiRouter {
|
class ApiRouter {
|
||||||
constructor(Server) {
|
constructor(Server) {
|
||||||
|
|
@ -325,6 +326,14 @@ class ApiRouter {
|
||||||
this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
|
this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
|
||||||
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))
|
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))
|
||||||
|
|
||||||
|
//
|
||||||
|
// 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
|
// Misc Routes
|
||||||
//
|
//
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue