From af1ff12dbb95dbbc9321d9f8fb1dfafcbece5f35 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 11:32:02 -0500 Subject: [PATCH] Add get all, update and delete endpoints. Add api keys config page --- client/components/app/ConfigSideNav.vue | 5 + client/components/modals/AccountModal.vue | 3 - .../components/modals/ApiKeyCreatedModal.vue | 60 ++++ client/components/modals/ApiKeyModal.vue | 317 ++++++++++++++++++ client/components/tables/ApiKeysTable.vue | 167 +++++++++ client/pages/config.vue | 1 + client/pages/config/api-keys/index.vue | 68 ++++ client/strings/en-us.json | 15 + server/controllers/ApiKeyController.js | 80 ++++- server/routers/ApiRouter.js | 3 + 10 files changed, 710 insertions(+), 9 deletions(-) create mode 100644 client/components/modals/ApiKeyCreatedModal.vue create mode 100644 client/components/modals/ApiKeyModal.vue create mode 100644 client/components/tables/ApiKeysTable.vue create mode 100644 client/pages/config/api-keys/index.vue diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50fa7a06..32e7e694 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -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, diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 7cf46567..9293a6d1 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -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', diff --git a/client/components/modals/ApiKeyCreatedModal.vue b/client/components/modals/ApiKeyCreatedModal.vue new file mode 100644 index 00000000..96442a17 --- /dev/null +++ b/client/components/modals/ApiKeyCreatedModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/client/components/modals/ApiKeyModal.vue b/client/components/modals/ApiKeyModal.vue new file mode 100644 index 00000000..c00de195 --- /dev/null +++ b/client/components/modals/ApiKeyModal.vue @@ -0,0 +1,317 @@ + + + diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue new file mode 100644 index 00000000..72fbe691 --- /dev/null +++ b/client/components/tables/ApiKeysTable.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/client/pages/config.vue b/client/pages/config.vue index 5fa145e5..c4fe2446 100644 --- a/client/pages/config.vue +++ b/client/pages/config.vue @@ -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 diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue new file mode 100644 index 00000000..99ae9c52 --- /dev/null +++ b/client/pages/config/api-keys/index.vue @@ -0,0 +1,68 @@ + + + diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f6288912..62443e0b 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -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", diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 0f995263..776ddcbe 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -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 diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index a4ec7d3c..8966ff66 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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