Add get all, update and delete endpoints. Add api keys config page
Some checks are pending
Run Component Tests / Run Component Tests (push) Waiting to run
Verify all i18n files are alphabetized / update_translations (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run

This commit is contained in:
advplyr 2025-06-30 11:32:02 -05:00
parent d96ed01ce4
commit af1ff12dbb
10 changed files with 710 additions and 9 deletions

View file

@ -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,

View file

@ -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',

View 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>

View 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>

View 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>

View file

@ -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

View 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>

View file

@ -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",

View file

@ -13,6 +13,20 @@ const Database = require('../Database')
class ApiKeyController { class ApiKeyController {
constructor() {} 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 * POST: /api/api-keys
* *
@ -47,18 +61,72 @@ class ApiKeyController {
name: req.body.name, name: req.body.name,
expiresAt, expiresAt,
permissions, permissions,
userId: req.user.id userId: req.user.id,
isActive: !!req.body.isActive
}) })
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
return res.json({ return res.json({
id: apiKeyInstance.id, apiKey: {
name: apiKeyInstance.name, apiKey, // Actual key only shown to user on creation
apiKey, ...apiKeyInstance.toJSON()
expiresAt: apiKeyInstance.expiresAt, }
permissions: apiKeyInstance.permissions
}) })
} }
/**
* 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 {RequestWithUser} req

View file

@ -329,7 +329,10 @@ class ApiRouter {
// //
// API Key Routes // 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.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