From 590b3e11fbe6af13697dc4cb34cdb849355f060e Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Thu, 9 Jan 2025 10:57:40 +0800 Subject: [PATCH] DEV: Convert admin API keys to conform to UI guidelines (#30660) Re-opening of #30096. It was reverted because it was missing a sidebar link to Webhooks in the admin panel. --- .../admin-config-areas/api-keys-list.hbs | 25 ++ .../admin-config-areas/api-keys-new.gjs | 318 ++++++++++++++++++ .../admin-config-areas/api-keys-show.gjs | 241 +++++++++++++ .../admin/addon/components/api-key-item.gjs | 137 ++++++++ .../addon/controllers/admin-api-keys-show.js | 77 ----- .../javascripts/admin/addon/models/api-key.js | 15 +- .../admin/addon/routes/admin-api-keys-new.js | 6 +- .../admin/addon/routes/admin-api-keys.js | 16 +- .../admin/addon/templates/admin.hbs | 3 +- .../admin/addon/templates/api-keys-index.hbs | 105 +----- .../admin/addon/templates/api-keys-new.hbs | 133 +------- .../admin/addon/templates/api-keys-show.hbs | 160 +-------- .../admin/addon/templates/api-keys.hbs | 24 +- .../javascripts/admin/addon/templates/api.hbs | 8 - .../app/lib/sidebar/admin-nav-map.js | 6 + .../stylesheets/common/admin/admin_table.scss | 8 + app/controllers/admin/api_controller.rb | 1 + app/serializers/api_key_serializer.rb | 1 + app/serializers/basic_api_key_serializer.rb | 1 + config/locales/client.en.yml | 37 +- spec/system/admin_api_keys_spec.rb | 65 ++++ spec/system/page_objects/admin_api_keys.rb | 87 +++++ 22 files changed, 958 insertions(+), 516 deletions(-) create mode 100644 app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-list.hbs create mode 100644 app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-new.gjs create mode 100644 app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-show.gjs create mode 100644 app/assets/javascripts/admin/addon/components/api-key-item.gjs delete mode 100644 app/assets/javascripts/admin/addon/controllers/admin-api-keys-show.js delete mode 100644 app/assets/javascripts/admin/addon/templates/api.hbs create mode 100644 spec/system/admin_api_keys_spec.rb create mode 100644 spec/system/page_objects/admin_api_keys.rb diff --git a/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-list.hbs b/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-list.hbs new file mode 100644 index 00000000000..3ac4ac8d9ba --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-list.hbs @@ -0,0 +1,25 @@ +
+ {{#if @apiKeys}} + + + + + + + + + + {{#each @apiKeys as |apiKey|}} + + {{/each}} + +
{{i18n "admin.api.key"}}{{i18n "admin.api.description"}}{{i18n "admin.api.user"}}{{i18n "admin.api.created"}}{{i18n "admin.api.last_used"}}
+ {{else}} + + {{/if}} +
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-new.gjs b/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-new.gjs new file mode 100644 index 00000000000..923c97e5ee6 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-new.gjs @@ -0,0 +1,318 @@ +import Component from "@glimmer/component"; +import { cached, tracked } from "@glimmer/tracking"; +import { concat, fn, hash } from "@ember/helper"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { eq } from "truth-helpers"; +import BackButton from "discourse/components/back-button"; +import ConditionalLoadingSection from "discourse/components/conditional-loading-section"; +import DButton from "discourse/components/d-button"; +import Form from "discourse/components/form"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import ApiKeyUrlsModal from "admin/components/modal/api-key-urls"; +import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser"; +import DTooltip from "float-kit/components/d-tooltip"; + +export default class AdminConfigAreasApiKeysNew extends Component { + @service router; + @service modal; + @service store; + + @tracked username; + @tracked loadingScopes = false; + @tracked scopes = null; + + userModes = [ + { id: "all", name: i18n("admin.api.all_users") }, + { id: "single", name: i18n("admin.api.single_user") }, + ]; + + scopeModes = [ + { id: "global", name: i18n("admin.api.scopes.global") }, + { id: "read_only", name: i18n("admin.api.scopes.read_only") }, + { id: "granular", name: i18n("admin.api.scopes.granular") }, + ]; + + globalScopes = null; + + constructor() { + super(...arguments); + this.#loadScopes(); + } + + @cached + get formData() { + let scopes = Object.keys(this.scopes).reduce((result, resource) => { + result[resource] = this.scopes[resource].map((scope) => { + const params = scope.params + ? scope.params.reduce((acc, param) => { + acc[param] = undefined; + return acc; + }, {}) + : {}; + + return { + key: scope.key, + enabled: undefined, + urls: scope.urls, + ...(params && { params }), + }; + }); + return result; + }, {}); + + return { + user_mode: "all", + scope_mode: "global", + scopes, + }; + } + + @action + updateUsername(field, selected) { + this.username = selected[0]; + field.set(this.username); + } + + @action + async save(data) { + const payload = { description: data.description }; + + if (data.user_mode === "single") { + payload.username = data.user; + } + + if (data.scope_mode === "granular") { + payload.scopes = this.#selectedScopes(data.scopes); + } else if (data.scope_mode === "read_only") { + payload.scopes = this.globalScopes.filter( + (scope) => scope.key === "read" + ); + } + + try { + await this.store.createRecord("api-key").save(payload); + this.router.transitionTo("adminApiKeys"); + } catch (error) { + popupAjaxError(error); + } + } + + #selectedScopes(scopes) { + const enabledScopes = []; + + for (const [resource, resourceScopes] of Object.entries(scopes)) { + enabledScopes.push( + resourceScopes + .filter((s) => s.enabled) + .map((s) => { + return { + scope_id: `${resource}:${s.key}`, + key: s.key, + name: s.key, + params: Object.keys(s.params), + ...s.params, + }; + }) + ); + } + + return enabledScopes.flat(); + } + + @action + async showURLs(urls) { + await this.modal.show(ApiKeyUrlsModal, { + model: { urls }, + }); + } + + async #loadScopes() { + try { + this.loadingScopes = true; + const data = await ajax("/admin/api/keys/scopes.json"); + + this.globalScopes = data.scopes.global; + delete data.scopes.global; + + this.scopes = data.scopes; + } catch (error) { + popupAjaxError(error); + } finally { + this.loadingScopes = false; + } + } + + +} diff --git a/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-show.gjs b/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-show.gjs new file mode 100644 index 00000000000..6c94f4a2ec7 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-config-areas/api-keys-show.gjs @@ -0,0 +1,241 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { concat, fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action, get } from "@ember/object"; +import { LinkTo } from "@ember/routing"; +import { service } from "@ember/service"; +import BackButton from "discourse/components/back-button"; +import DButton from "discourse/components/d-button"; +import avatar from "discourse/helpers/avatar"; +import formatDate from "discourse/helpers/format-date"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import AdminFormRow from "admin/components/admin-form-row"; +import ApiKeyUrlsModal from "admin/components/modal/api-key-urls"; +import DTooltip from "float-kit/components/d-tooltip"; + +export default class AdminConfigAreasApiKeysShow extends Component { + @service modal; + @service router; + + @tracked editingDescription = false; + @tracked scopes = this.args.apiKey.api_key_scopes; + newDescription = ""; + + @action + async revokeKey(key) { + try { + await key.revoke(); + } catch (error) { + popupAjaxError(error); + } + } + + @action + async undoRevokeKey(key) { + try { + await key.undoRevoke(); + } catch (error) { + popupAjaxError(error); + } + } + + @action + async deleteKey(key) { + try { + await key.destroyRecord(); + this.router.transitionTo("adminApiKeys.index"); + } catch (error) { + popupAjaxError(error); + } + } + + @action + async showURLs(urls) { + await this.modal.show(ApiKeyUrlsModal, { + model: { urls }, + }); + } + + @action + toggleEditDescription() { + this.editingDescription = !this.editingDescription; + this.newDescription = this.args.apiKey.description; + } + + @action + async saveDescription() { + try { + await this.args.apiKey.save({ description: this.newDescription }); + this.editingDescription = false; + } catch (error) { + popupAjaxError(error); + } + } + + @action + setNewDescription(event) { + this.newDescription = event.currentTarget.value; + } + + +} diff --git a/app/assets/javascripts/admin/addon/components/api-key-item.gjs b/app/assets/javascripts/admin/addon/components/api-key-item.gjs new file mode 100644 index 00000000000..9407dade744 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/api-key-item.gjs @@ -0,0 +1,137 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { action } from "@ember/object"; +import { LinkTo } from "@ember/routing"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import DropdownMenu from "discourse/components/dropdown-menu"; +import avatar from "discourse/helpers/avatar"; +import formatDate from "discourse/helpers/format-date"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import DMenu from "float-kit/components/d-menu"; + +export default class ApiKeysList extends Component { + @service router; + + @tracked apiKey = this.args.apiKey; + + @action + onRegisterApi(api) { + this.dMenu = api; + } + + @action + async revokeKey(key) { + try { + await key.revoke(); + await this.dMenu.close(); + } catch (error) { + popupAjaxError(error); + } + } + + @action + async undoRevokeKey(key) { + try { + await key.undoRevoke(); + await this.dMenu.close(); + } catch (error) { + popupAjaxError(error); + } + } + + @action + edit() { + this.router.transitionTo("adminApiKeys.show", this.apiKey); + } + + +} diff --git a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-show.js b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-show.js deleted file mode 100644 index bcfcc4117fd..00000000000 --- a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-show.js +++ /dev/null @@ -1,77 +0,0 @@ -import Controller from "@ember/controller"; -import { action } from "@ember/object"; -import { empty } from "@ember/object/computed"; -import { service } from "@ember/service"; -import { isEmpty } from "@ember/utils"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { bufferedProperty } from "discourse/mixins/buffered-content"; -import ApiKeyUrlsModal from "../components/modal/api-key-urls"; - -export default class AdminApiKeysShowController extends Controller.extend( - bufferedProperty("model") -) { - @service router; - @service modal; - - @empty("model.id") isNew; - - @action - saveDescription() { - const buffered = this.buffered; - const attrs = buffered.getProperties("description"); - - this.model - .save(attrs) - .then(() => { - this.set("editingDescription", false); - this.rollbackBuffer(); - }) - .catch(popupAjaxError); - } - - @action - cancel() { - const id = this.get("userField.id"); - if (isEmpty(id)) { - this.destroyAction(this.userField); - } else { - this.rollbackBuffer(); - this.set("editing", false); - } - } - - @action - editDescription() { - this.toggleProperty("editingDescription"); - if (!this.editingDescription) { - this.rollbackBuffer(); - } - } - - @action - revokeKey(key) { - key.revoke().catch(popupAjaxError); - } - - @action - deleteKey(key) { - key - .destroyRecord() - .then(() => this.router.transitionTo("adminApiKeys.index")) - .catch(popupAjaxError); - } - - @action - undoRevokeKey(key) { - key.undoRevoke().catch(popupAjaxError); - } - - @action - showURLs(urls) { - this.modal.show(ApiKeyUrlsModal, { - model: { - urls, - }, - }); - } -} diff --git a/app/assets/javascripts/admin/addon/models/api-key.js b/app/assets/javascripts/admin/addon/models/api-key.js index fb82d7873f3..228682544fd 100644 --- a/app/assets/javascripts/admin/addon/models/api-key.js +++ b/app/assets/javascripts/admin/addon/models/api-key.js @@ -6,7 +6,7 @@ import discourseComputed from "discourse-common/utils/decorators"; import AdminUser from "admin/models/admin-user"; export default class ApiKey extends RestModel { - @fmt("truncated_key", "%@...") truncatedKey; + @fmt("truncated_key", "%@ ...") truncatedKey; @computed("_user") get user() { @@ -21,6 +21,19 @@ export default class ApiKey extends RestModel { } } + @computed("_created_by") + get createdBy() { + return this._created_by; + } + + set created_by(value) { + if (value && !(value instanceof AdminUser)) { + this.set("_created_by", AdminUser.create(value)); + } else { + this.set("_created_by", value); + } + } + @discourseComputed("description") shortDescription(description) { if (!description || description.length < 40) { diff --git a/app/assets/javascripts/admin/addon/routes/admin-api-keys-new.js b/app/assets/javascripts/admin/addon/routes/admin-api-keys-new.js index 44ebc9066b5..86aaabcecab 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-api-keys-new.js +++ b/app/assets/javascripts/admin/addon/routes/admin-api-keys-new.js @@ -1,7 +1,3 @@ import Route from "@ember/routing/route"; -export default class AdminApiKeysNewRoute extends Route { - model() { - return this.store.createRecord("api-key"); - } -} +export default class AdminApiKeysNewRoute extends Route {} diff --git a/app/assets/javascripts/admin/addon/routes/admin-api-keys.js b/app/assets/javascripts/admin/addon/routes/admin-api-keys.js index 4467bca5c1a..56618863d6d 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-api-keys.js +++ b/app/assets/javascripts/admin/addon/routes/admin-api-keys.js @@ -1,17 +1,3 @@ -import { action } from "@ember/object"; import Route from "@ember/routing/route"; -import { service } from "@ember/service"; -export default class AdminApiKeysRoute extends Route { - @service router; - - @action - show(apiKey) { - this.router.transitionTo("adminApiKeys.show", apiKey.id); - } - - @action - new() { - this.router.transitionTo("adminApiKeys.new"); - } -} +export default class AdminApiKeysRoute extends Route {} diff --git a/app/assets/javascripts/admin/addon/templates/admin.hbs b/app/assets/javascripts/admin/addon/templates/admin.hbs index 7742b71b971..5a345cf607d 100644 --- a/app/assets/javascripts/admin/addon/templates/admin.hbs +++ b/app/assets/javascripts/admin/addon/templates/admin.hbs @@ -31,7 +31,8 @@ @label="admin.customize.title" /> {{#if this.currentUser.admin}} - + + {{#if this.siteSettings.enable_backups}} {{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs index 169bf25277a..a6c7054b341 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs @@ -1,104 +1 @@ - - -{{#if this.model}} - - - - - - - - - - - - - {{#each this.model as |k|}} - - - - - - - - - - {{/each}} - -
{{i18n "admin.api.key"}}{{i18n "admin.api.description"}}{{i18n "admin.api.user"}}{{i18n "admin.api.created"}}{{i18n "admin.api.last_used"}}{{i18n "admin.site_settings.table_column_heading.status"}} 
- {{k.truncatedKey}} - -
{{i18n - "admin.api.description" - }}
- {{k.shortDescription}} -
-
{{i18n - "admin.api.user" - }}
- {{#if k.user}} - - {{avatar k.user imageSize="small"}} - - {{else}} - {{i18n "admin.api.all_users"}} - {{/if}} -
-
{{i18n - "admin.api.created" - }}
- {{format-date k.created_at}} -
-
{{i18n - "admin.api.last_used" - }}
- {{#if k.last_used_at}} - {{format-date k.last_used_at}} - {{else}} - {{i18n "admin.api.never_used"}} - {{/if}} -
-
{{i18n - "admin.site_settings.table_column_heading.status" - }}
- {{#if k.revoked_at}} -
-
-
-
- {{i18n "admin.api.revoked"}} -
-
- {{/if}} -
- - {{#if k.revoked_at}} - - {{else}} - - {{/if}} -
-
- - -{{else}} -

{{i18n "admin.api.none"}}

-{{/if}} \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs index f5176a87d01..d910ce0ca18 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs @@ -1,132 +1 @@ - - {{d-icon "arrow-left"}} - {{i18n "admin.api.all_api_keys"}} - - -
- {{#if this.model.id}} - -
{{this.model.key}}
-
- - {{i18n "admin.api.not_shown_again"}} - - - - - {{else}} - - - - - - - - - {{#if this.showUserSelector}} - - - - {{/if}} - - - - - {{#if (eq this.scopeMode "read_only")}} -

{{i18n "admin.api.scopes.descriptions.global.read"}}

- {{else if (eq this.scopeMode "global")}} -

{{i18n "admin.api.scopes.global_description"}}

- {{/if}} -
- - {{#if (eq this.scopeMode "granular")}} -

{{i18n "admin.api.scopes.title"}}

-

{{i18n "admin.api.scopes.description"}}

- - - - - - - - - - - {{#each-in this.scopes as |resource actions|}} - - - - - - - {{#each actions as |act|}} - - - - - - - {{/each}} - {{/each-in}} - -
{{i18n "admin.api.scopes.allowed_urls"}}{{i18n "admin.api.scopes.optional_allowed_parameters"}}
{{resource}}
-
{{act.name}}
- -
- - - {{#each act.params as |p|}} - - {{/each}} -
- {{/if}} - - - {{/if}} -
\ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs index 342e8f1d729..1b141dd3301 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs @@ -1,159 +1 @@ - - {{d-icon "arrow-left"}} - {{i18n "admin.api.all_api_keys"}} - - -
- - {{#if this.model.revoked_at}}{{d-icon "circle-xmark"}}{{/if}} - {{this.model.truncatedKey}} - - - - {{#if this.editingDescription}} - - {{else}} - - {{if - this.model.description - this.model.description - (i18n "admin.api.no_description") - }} - - {{/if}} - -
- {{#if this.editingDescription}} - - - {{else}} - - {{/if}} -
-
- - - {{#if this.model.user}} - - {{avatar this.model.user imageSize="small"}} - {{this.model.user.username}} - - {{else}} - {{i18n "admin.api.all_users"}} - {{/if}} - - - - {{format-date this.model.created_at leaveAgo="true"}} - - - - {{format-date this.model.updated_at leaveAgo="true"}} - - - - {{#if this.model.last_used_at}} - {{format-date this.model.last_used_at leaveAgo="true"}} - {{else}} - {{i18n "admin.api.never_used"}} - {{/if}} - - - - {{#if this.model.revoked_at}} - {{format-date this.model.revoked_at leaveAgo="true"}} - {{else}} - {{i18n "no_value"}} - {{/if}} -
- {{#if this.model.revoked_at}} - - - {{else}} - - {{/if}} -
-
- - {{#if this.model.api_key_scopes.length}} -

{{i18n "admin.api.scopes.title"}}

- - - - - - - - - - - - {{#each this.model.api_key_scopes as |scope|}} - - - - - - - {{/each}} - -
{{i18n "admin.api.scopes.resource"}}{{i18n "admin.api.scopes.action"}}{{i18n "admin.api.scopes.allowed_urls"}}{{i18n "admin.api.scopes.allowed_parameters"}}
{{scope.resource}} - {{scope.action}} - - - - - {{#each scope.parameters as |p|}} -
- {{p}}: - {{#if (get scope.allowed_parameters p)}} - {{get scope.allowed_parameters p}} - {{else}} - {{i18n "admin.api.scopes.any_parameter"}} - {{/if}} -
- {{/each}} -
- {{/if}} -
\ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/api-keys.hbs b/app/assets/javascripts/admin/addon/templates/api-keys.hbs index 23cc9343fcb..820565bb47d 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys.hbs @@ -1,3 +1,21 @@ - - {{outlet}} - \ No newline at end of file +
+ + <:breadcrumbs> + + + <:actions as |actions|> + + + + +
+ {{outlet}} +
+
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/api.hbs b/app/assets/javascripts/admin/addon/templates/api.hbs deleted file mode 100644 index 00c46903ac1..00000000000 --- a/app/assets/javascripts/admin/addon/templates/api.hbs +++ /dev/null @@ -1,8 +0,0 @@ - - - - - -
- {{outlet}} -
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/admin-nav-map.js b/app/assets/javascripts/discourse/app/lib/sidebar/admin-nav-map.js index 1de4d62f52b..95522804dc6 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/admin-nav-map.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/admin-nav-map.js @@ -224,6 +224,12 @@ export const ADMIN_NAV_MAP = [ label: "admin.advanced.sidebar_link.api_keys.title", keywords: "admin.advanced.sidebar_link.api_keys.keywords", }, + { + name: "admin_webhooks", + route: "adminWebHooks", + icon: "arrows-rotate", + label: "admin.advanced.sidebar_link.webhooks", + }, { name: "admin_developer", route: "adminConfig.developer.settings", diff --git a/app/assets/stylesheets/common/admin/admin_table.scss b/app/assets/stylesheets/common/admin/admin_table.scss index 1fb5876b86f..ea04fbad7e9 100644 --- a/app/assets/stylesheets/common/admin/admin_table.scss +++ b/app/assets/stylesheets/common/admin/admin_table.scss @@ -96,6 +96,14 @@ } } + &__badge { + background-color: var(--primary-low); + border-radius: var(--d-border-radius); + font-size: var(--font-down-1); + margin-left: var(--space-1); + padding: var(--space-2); + } + // Success badge .status-label.--success { background-color: var(--success-low); diff --git a/app/controllers/admin/api_controller.rb b/app/controllers/admin/api_controller.rb index f02b2464bcd..04ed1684c92 100644 --- a/app/controllers/admin/api_controller.rb +++ b/app/controllers/admin/api_controller.rb @@ -14,6 +14,7 @@ class Admin::ApiController < Admin::AdminController ApiKey .where(hidden: false) .includes(:user) + .includes(:created_by) .order("revoked_at DESC NULLS FIRST, created_at DESC") .offset(offset) .limit(limit) diff --git a/app/serializers/api_key_serializer.rb b/app/serializers/api_key_serializer.rb index d1ccac58de0..f5de99ff410 100644 --- a/app/serializers/api_key_serializer.rb +++ b/app/serializers/api_key_serializer.rb @@ -11,6 +11,7 @@ class ApiKeySerializer < ApplicationSerializer :revoked_at has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :created_by, serializer: BasicUserSerializer, embed: :objects has_many :api_key_scopes, serializer: ApiKeyScopeSerializer, embed: :objects def include_user_id? diff --git a/app/serializers/basic_api_key_serializer.rb b/app/serializers/basic_api_key_serializer.rb index e8f194eccd3..cfecc068c8e 100644 --- a/app/serializers/basic_api_key_serializer.rb +++ b/app/serializers/basic_api_key_serializer.rb @@ -4,4 +4,5 @@ class BasicApiKeySerializer < ApplicationSerializer attributes :id, :truncated_key, :description, :created_at, :last_used_at, :revoked_at has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :created_by, serializer: BasicUserSerializer, embed: :objects end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a55417b9ac2..0c470888999 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -5396,6 +5396,20 @@ en: none_selected: "Select a group to get started" no_custom_groups: "Create a new custom group" + api_keys: + title: "API Keys" + description: "The API keys feature lets you securely integrate Discourse with external systems and automate actions. Admins can create keys with specific scopes to control access to resources and sensitive data. Scopes limit functionality, ensuring enhanced security." + add: "Add API key" + edit: "Edit" + save: "Save" + cancel: "Cancel" + back: "Back to API keys" + revoke: "Revoke" + undo_revoke: "Undo revoke" + revoked: "Revoked" + delete: Permanently delete + no_api_keys: "You don't have any API keys yet." + api: generate_master: "Generate Master API Key" none: "There are no active API keys right now." @@ -5403,30 +5417,30 @@ en: title: "API" key: "Key" keys: "Keys" - created: Created + created: Created by updated: Updated - last_used: Last Used - never_used: (never) + last_used: Last used + never_used: Never generate: "Generate" - undo_revoke: "Undo Revoke" + undo_revoke: "Undo revoke" revoke: "Revoke" - all_users: "All Users" + all_users: "All users" active_keys: "Active API Keys" manage_keys: Manage Keys show_details: Details description: Description no_description: (no description) all_api_keys: All API Keys - user_mode: User Level + user_mode: User level scope_mode: Scope impersonate_all_users: Impersonate any user - single_user: "Single User" + single_user: "Single user" user_placeholder: Enter username description_placeholder: What will this key be used for? save: Save new_key: New API Key revoked: Revoked - delete: Permanently Delete + delete: Permanently delete not_shown_again: This key will not be displayed again. Make sure you take a copy before continuing. continue: Continue scopes: @@ -5440,8 +5454,8 @@ en: global_description: API key has no restriction and all endpoints are accessible. resource: Resource action: Action - allowed_parameters: Allowed Parameters - optional_allowed_parameters: Allowed Parameters (optional) + allowed_parameters: Allowed parameters + optional_allowed_parameters: Allowed parameters (optional) any_parameter: (any parameter) allowed_urls: Allowed URLs descriptions: @@ -5864,7 +5878,8 @@ en: sidebar_link: api_keys: title: "API keys" - keywords: "token|webhook" + keywords: "token" + webhooks: "Webhooks" developer: "Developer" embedding: "Embedding" rate_limits: "Rate limits" diff --git a/spec/system/admin_api_keys_spec.rb b/spec/system/admin_api_keys_spec.rb new file mode 100644 index 00000000000..c4a0e0146b4 --- /dev/null +++ b/spec/system/admin_api_keys_spec.rb @@ -0,0 +1,65 @@ +#frozen_string_literal: true + +describe "Admin API Keys Page", type: :system do + fab!(:current_user) { Fabricate(:admin) } + + let(:api_keys_page) { PageObjects::Pages::AdminApiKeys.new } + let(:dialog) { PageObjects::Components::Dialog.new } + + before do + Fabricate(:api_key, description: "Integration") + + sign_in(current_user) + end + + it "shows a list of API keys" do + api_keys_page.visit_page + + expect(api_keys_page).to have_api_key_listed("Integration") + end + + it "can add a new API key" do + api_keys_page.visit_page + api_keys_page.add_api_key(description: "Second Integration") + + expect(api_keys_page).to have_api_key_listed("Second Integration") + end + + it "can edit existing API keys" do + api_keys_page.visit_page + api_keys_page.click_edit("Integration") + api_keys_page.edit_description("Old Integration") + api_keys_page.click_back + + expect(api_keys_page).to have_api_key_listed("Old Integration") + end + + it "can revoke API keys" do + api_keys_page.visit_page + api_keys_page.click_edit("Integration") + api_keys_page.click_revoke + api_keys_page.click_back + + expect(api_keys_page).to have_revoked_api_key_listed("Integration") + end + + it "can undo revokation of API keys" do + api_keys_page.visit_page + api_keys_page.click_edit("Integration") + api_keys_page.click_revoke + api_keys_page.click_unrevoke + api_keys_page.click_back + + expect(api_keys_page).to have_unrevoked_api_key_listed("Integration") + end + + it "can permanently delete revoked API keys" do + api_keys_page.visit_page + api_keys_page.click_edit("Integration") + api_keys_page.click_revoke + api_keys_page.click_delete + + expect(api_keys_page).to have_current_path("/admin/api/keys") + expect(api_keys_page).to have_no_api_key_listed("Integration") + end +end diff --git a/spec/system/page_objects/admin_api_keys.rb b/spec/system/page_objects/admin_api_keys.rb new file mode 100644 index 00000000000..1696f442bac --- /dev/null +++ b/spec/system/page_objects/admin_api_keys.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class AdminApiKeys < PageObjects::Pages::Base + def visit_page + page.visit "/admin/api/keys" + self + end + + def has_api_key_listed?(name) + page.has_css?(table_selector, text: name) + end + + def has_revoked_api_key_listed?(name) + row = page.find(table_selector, text: name) + row.has_css?(badge_selector, text: I18n.t("admin_js.admin.api_keys.revoked")) + end + + def has_unrevoked_api_key_listed?(name) + row = page.find(table_selector, text: name) + row.has_no_css?(badge_selector, text: I18n.t("admin_js.admin.api_keys.revoked")) + end + + def has_no_api_key_listed?(name) + page.has_no_css?(table_selector, text: name) + end + + def add_api_key(description:) + page.find(header_actions_selector, text: I18n.t("admin_js.admin.api_keys.add")).click + + form = page.find(".form-kit") + form.find(description_field_selector).fill_in(with: description) + form.find(".save").click + end + + def click_edit(description) + row = page.find(row_selector, text: description) + row.find("button", text: I18n.t("admin_js.admin.api_keys.edit")).click + end + + def click_revoke + page.find("button", text: I18n.t("admin_js.admin.api_keys.revoke")).click + end + + def click_unrevoke + page.find("button", text: I18n.t("admin_js.admin.api_keys.undo_revoke")).click + end + + def click_delete + page.find("button", text: I18n.t("admin_js.admin.api_keys.delete")).click + end + + def edit_description(new_description) + page.find("button", text: I18n.t("admin_js.admin.api_keys.edit")).click + page.find(description_field_selector).fill_in(with: new_description) + page.find("button", text: I18n.t("admin_js.admin.api_keys.save")).click + end + + def click_back + page.find("a.back-button").click + end + + private + + def table_selector + ".admin-api_keys__items" + end + + def row_selector + ".d-admin-row__content" + end + + def badge_selector + ".d-admin-table__badge" + end + + def header_actions_selector + ".d-page-header__actions" + end + + def description_field_selector + "input[name='description']" + end + end + end +end