mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Compare commits
No commits in common. "af1ff12dbb95dbbc9321d9f8fb1dfafcbece5f35" and "4f5123e84260b8e1e1049d9ce82854c7ca7f6afa" have entirely different histories.
af1ff12dbb
...
4f5123e842
14 changed files with 116 additions and 997 deletions
|
|
@ -70,11 +70,6 @@ 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,6 +351,9 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
<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,7 +53,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<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,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"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",
|
||||||
|
|
@ -21,7 +20,6 @@
|
||||||
"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",
|
||||||
|
|
@ -121,7 +119,6 @@
|
||||||
"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",
|
||||||
|
|
@ -165,7 +162,6 @@
|
||||||
"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",
|
||||||
|
|
@ -210,7 +206,6 @@
|
||||||
"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",
|
||||||
|
|
@ -240,8 +235,6 @@
|
||||||
"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)",
|
||||||
|
|
@ -353,9 +346,6 @@
|
||||||
"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)",
|
||||||
|
|
@ -418,7 +408,6 @@
|
||||||
"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",
|
||||||
|
|
@ -466,7 +455,6 @@
|
||||||
"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",
|
||||||
|
|
@ -742,7 +730,6 @@
|
||||||
"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?",
|
||||||
|
|
@ -1013,8 +1000,6 @@
|
||||||
"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/ApiKey')} */
|
/** @type {typeof import('./models/ApiToken')} */
|
||||||
get apiKeyModel() {
|
get apiTokenModel() {
|
||||||
return this.models.apiKey
|
return this.models.apiToken
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @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/ApiKey').init(this.sequelize)
|
require('./models/ApiToken').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)
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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-auth-tables`
|
const migrationName = `${migrationVersion}-create-sessions-table`
|
||||||
const loggerPrefix = `[${migrationVersion} migration]`
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This upward migration creates a sessions table and apiKeys table.
|
* This upward migration creates a sessions table and apiTokens 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,19 +68,23 @@ async function up({ context: { queryInterface, logger } }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if table exists
|
// Check if table exists
|
||||||
if (await queryInterface.tableExists('apiKeys')) {
|
if (await queryInterface.tableExists('apiTokens')) {
|
||||||
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
|
logger.info(`${loggerPrefix} table "apiTokens" already exists`)
|
||||||
} else {
|
} else {
|
||||||
// Create table
|
// Create table
|
||||||
logger.info(`${loggerPrefix} creating table "apiKeys"`)
|
logger.info(`${loggerPrefix} creating table "apiTokens"`)
|
||||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||||
await queryInterface.createTable('apiKeys', {
|
await queryInterface.createTable('apiTokens', {
|
||||||
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: {
|
||||||
|
|
@ -105,17 +109,18 @@ async function up({ context: { queryInterface, logger } }) {
|
||||||
},
|
},
|
||||||
key: 'id'
|
key: 'id'
|
||||||
},
|
},
|
||||||
onDelete: 'SET NULL'
|
allowNull: false,
|
||||||
|
onDelete: 'CASCADE'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
logger.info(`${loggerPrefix} created table "apiKeys"`)
|
logger.info(`${loggerPrefix} created table "apiTokens"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This downward migration script removes the sessions table and apiKeys table.
|
* This downward migration script removes the sessions table and apiTokens 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.
|
||||||
|
|
@ -134,12 +139,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('apiKeys')) {
|
if (await queryInterface.tableExists('apiTokens')) {
|
||||||
logger.info(`${loggerPrefix} dropping table "apiKeys"`)
|
logger.info(`${loggerPrefix} dropping table "apiTokens"`)
|
||||||
await queryInterface.dropTable('apiKeys')
|
await queryInterface.dropTable('apiTokens')
|
||||||
logger.info(`${loggerPrefix} dropped table "apiKeys"`)
|
logger.info(`${loggerPrefix} dropped table "apiTokens"`)
|
||||||
} else {
|
} else {
|
||||||
logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
|
logger.info(`${loggerPrefix} table "apiTokens" does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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
|
|
||||||
90
server/models/ApiToken.js
Normal file
90
server/models/ApiToken.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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,7 +34,6 @@ 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) {
|
||||||
|
|
@ -326,14 +325,6 @@ 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