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