REVERT: "DEV: Convert admin API keys to conform to UI guidelines"

This reverts commit d9ddc25808.

I noticed that Webhook admin UI is now inaccessible through the subheader
This commit is contained in:
Mark VanLandingham 2025-01-08 11:03:40 -06:00 committed by GitHub
parent 2ff511a4e4
commit 4da7904ffd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 514 additions and 948 deletions

View File

@ -1,25 +0,0 @@
<div class="container admin-api_keys">
{{#if @apiKeys}}
<table class="d-admin-table admin-api_keys__items">
<thead>
<th>{{i18n "admin.api.key"}}</th>
<th>{{i18n "admin.api.description"}}</th>
<th>{{i18n "admin.api.user"}}</th>
<th>{{i18n "admin.api.created"}}</th>
<th>{{i18n "admin.api.last_used"}}</th>
</thead>
<tbody>
{{#each @apiKeys as |apiKey|}}
<ApiKeyItem @apiKey={{apiKey}} />
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="admin.api_keys.add"
@ctaRoute="adminApiKeys.new"
@ctaClass="admin-api_keys__add-api_key"
@emptyLabel="admin.api_keys.no_api_keys"
/>
{{/if}}
</div>

View File

@ -1,318 +0,0 @@
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;
}
}
<template>
<BackButton @route="adminApiKeys.index" @label="admin.api_keys.back" />
<div class="admin-config-area user-field">
<div class="admin-config-area__primary-content">
<div class="admin-config-area-card">
<ConditionalLoadingSection @isLoading={{this.loadingScopes}}>
<Form
@onSubmit={{this.save}}
@data={{this.formData}}
as |form transientData|
>
<form.Field
@name="description"
@title={{i18n "admin.api.description"}}
@format="large"
@validation="required"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="user_mode"
@title={{i18n "admin.api.user_mode"}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.userModes as |userMode|}}
<select.Option
@value={{userMode.id}}
>{{userMode.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{#if (eq transientData.user_mode "single")}}
<form.Field
@name="user"
@title={{i18n "admin.api.user"}}
@format="large"
@validation="required"
as |field|
>
<field.Custom>
<EmailGroupUserChooser
@value={{this.username}}
@onChange={{fn this.updateUsername field}}
@options={{hash
maximum=1
filterPlaceholder="admin.api.user_placeholder"
}}
/>
</field.Custom>
</form.Field>
{{/if}}
<form.Field
@name="scope_mode"
@title={{i18n "admin.api.scope_mode"}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.scopeModes as |scopeMode|}}
<select.Option
@value={{scopeMode.id}}
>{{scopeMode.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{#if (eq transientData.scope_mode "granular")}}
<h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
<p>{{i18n "admin.api.scopes.description"}}</p>
<table class="scopes-table grid">
<thead>
<tr>
<td></td>
<td></td>
<td>{{i18n "admin.api.scopes.allowed_urls"}}</td>
<td>{{i18n
"admin.api.scopes.optional_allowed_parameters"
}}</td>
</tr>
</thead>
<tbody>
<form.Object @name="scopes" as |scopesObject scopeName|>
<tr class="scope-resource-name">
<td><b>{{scopeName}}</b></td>
<td></td>
<td></td>
<td></td>
</tr>
<scopesObject.Collection
@name={{scopeName}}
@tagName="tr"
as |topicsCollection index collectionData|
>
<td>
<topicsCollection.Field
@name="enabled"
@title={{collectionData.key}}
@showTitle={{false}}
as |field|
>
<field.Checkbox />
</topicsCollection.Field>
</td>
<td>
<div class="scope-name">{{collectionData.name}}</div>
<DTooltip
@icon="circle-question"
@content={{i18n
(concat
"admin.api.scopes.descriptions."
scopeName
"."
collectionData.key
)
class="scope-tooltip"
}}
/>
</td>
<td>
<DButton
@icon="link"
@action={{fn this.showURLs collectionData.urls}}
class="btn-info"
/>
</td>
<td>
<topicsCollection.Object
@name="params"
as |paramsObject name|
>
<paramsObject.Field
@name={{name}}
@title={{name}}
@showTitle={{false}}
as |field|
>
<field.Input placeholder={{name}} />
</paramsObject.Field>
</topicsCollection.Object>
</td>
</scopesObject.Collection>
</form.Object>
</tbody>
</table>
{{/if}}
<form.Actions>
<form.Submit class="save" @label="admin.api_keys.save" />
<form.Button
@route="adminApiKeys.index"
@label="admin.api_keys.cancel"
class="btn-default"
/>
</form.Actions>
</Form>
</ConditionalLoadingSection>
</div>
</div>
</div>
</template>
}

View File

@ -1,241 +0,0 @@
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;
}
<template>
<BackButton @route="adminApiKeys.index" @label="admin.api_keys.back" />
<div class="api-key api-key-show">
<AdminFormRow @label="admin.api.key">
{{@apiKey.truncatedKey}}
</AdminFormRow>
<AdminFormRow @label="admin.api.description">
{{#if this.editingDescription}}
<Input
@value={{@apiKey.description}}
{{on "input" this.setNewDescription}}
name="description"
maxlength="255"
placeholder={{i18n "admin.api.description_placeholder"}}
/>
{{else}}
<span>
{{if
@apiKey.description
@apiKey.description
(i18n "admin.api.no_description")
}}
</span>
{{/if}}
<div class="controls">
{{#if this.editingDescription}}
<DButton
@action={{this.saveDescription}}
@label="admin.api_keys.save"
/>
<DButton
@action={{this.toggleEditDescription}}
@label="admin.api_keys.cancel"
/>
{{else}}
<DButton
@action={{this.toggleEditDescription}}
@label="admin.api_keys.edit"
class="btn-default"
/>
{{/if}}
</div>
</AdminFormRow>
<AdminFormRow @label="admin.api.user">
{{#if @apiKey.user}}
<LinkTo @route="adminUser" @model={{@apiKey.user}}>
{{avatar @apiKey.user imageSize="small"}}
{{@apiKey.user.username}}
</LinkTo>
{{else}}
{{i18n "admin.api.all_users"}}
{{/if}}
</AdminFormRow>
<AdminFormRow @label="admin.api.created">
{{formatDate @apiKey.created_at leaveAgo="true"}}
</AdminFormRow>
<AdminFormRow @label="admin.api.updated">
{{formatDate @apiKey.updated_at leaveAgo="true"}}
</AdminFormRow>
<AdminFormRow @label="admin.api.last_used">
{{#if @apiKey.last_used_at}}
{{formatDate @apiKey.last_used_at leaveAgo="true"}}
{{else}}
{{i18n "admin.api.never_used"}}
{{/if}}
</AdminFormRow>
<AdminFormRow @label="admin.api.revoked">
{{#if @apiKey.revoked_at}}
{{formatDate @apiKey.revoked_at leaveAgo="true"}}
{{else}}
<span>{{i18n "no_value"}}</span>
{{/if}}
<div class="controls">
{{#if @apiKey.revoked_at}}
<DButton
@action={{fn this.undoRevokeKey @apiKey}}
@label="admin.api.undo_revoke"
/>
<DButton
@action={{fn this.deleteKey @apiKey}}
@label="admin.api.delete"
class="btn-danger"
/>
{{else}}
<DButton
@action={{fn this.revokeKey @apiKey}}
@label="admin.api.revoke"
class="btn-danger"
/>
{{/if}}
</div>
</AdminFormRow>
{{#if @apiKey.api_key_scopes.length}}
<h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
<table class="scopes-table grid">
<thead>
<tr>
<td>{{i18n "admin.api.scopes.resource"}}</td>
<td>{{i18n "admin.api.scopes.action"}}</td>
<td>{{i18n "admin.api.scopes.allowed_urls"}}</td>
<td>{{i18n "admin.api.scopes.allowed_parameters"}}</td>
</tr>
</thead>
<tbody>
{{#each @apiKey.api_key_scopes as |scope|}}
<tr>
<td>{{scope.resource}}</td>
<td>
{{scope.action}}
<DTooltip
@icon="circle-question"
@content={{i18n
(concat
"admin.api.scopes.descriptions."
scope.resource
"."
scope.key
)
}}
class="scope-tooltip"
/>
</td>
<td>
<DButton
@icon="link"
@action={{fn this.showURLs scope.urls}}
class="btn-info"
/>
</td>
<td>
{{#each scope.parameters as |p|}}
<div>
<b>{{p}}:</b>
{{#if (get scope.allowed_parameters p)}}
{{get scope.allowed_parameters p}}
{{else}}
{{i18n "admin.api.scopes.any_parameter"}}
{{/if}}
</div>
{{/each}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</div>
</template>
}

View File

@ -1,137 +0,0 @@
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);
}
<template>
<tr class="d-admin-row__content">
<td class="d-admin-row__overview key">
{{this.apiKey.truncatedKey}}
{{#if this.apiKey.revoked_at}}
<span class="d-admin-table__badge">{{i18n
"admin.api.revoked"
}}</span>{{/if}}
</td>
<td class="d-admin-row__detail key-description">
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.description"
}}</div>
{{this.apiKey.shortDescription}}
</td>
<td class="d-admin-row__detail key-user">
<div class="d-admin-row__mobile-label">{{i18n "admin.api.user"}}</div>
{{#if this.apiKey.user}}
<LinkTo @route="adminUser" @model={{this.apiKey.user}}>
{{avatar this.apiKey.user imageSize="small"}}
</LinkTo>
{{else}}
{{i18n "admin.api.all_users"}}
{{/if}}
</td>
<td class="d-admin-row__detail key-created">
<LinkTo @route="adminUser" @model={{this.apiKey.createdBy}}>
{{avatar this.apiKey.createdBy imageSize="small"}}
</LinkTo>
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.created"
}}</div>
{{formatDate this.apiKey.created_at}}
</td>
<td class="d-admin-row__detail key-last-used">
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.last_used"
}}</div>
{{#if this.apiKey.last_used_at}}
{{formatDate this.apiKey.last_used_at}}
{{else}}
{{i18n "admin.api.never_used"}}
{{/if}}
</td>
<td class="d-admin-row__controls key-controls">
<div class="d-admin-row__controls-options">
<DButton
@action={{this.edit}}
@label="admin.api_keys.edit"
@title="admin.api.show_details"
class="btn-small"
/>
<DMenu
@identifier="api_key-menu"
@title={{i18n "admin.config_areas.user_fields.more_options.title"}}
@icon="ellipsis-vertical"
@onRegisterApi={{this.onRegisterApi}}
>
<:content>
<DropdownMenu as |dropdown|>
{{#if this.apiKey.revoked_at}}
<dropdown.item>
<DButton
@action={{fn this.undoRevokeKey this.apiKey}}
@icon="arrow-rotate-left"
@label="admin.api_keys.undo_revoke"
@title="admin.api.undo_revoke"
/>
</dropdown.item>
{{else}}
<dropdown.item>
<DButton
@action={{fn this.revokeKey this.apiKey}}
@icon="xmark"
@label="admin.api_keys.revoke"
@title="admin.api.revoke"
class="btn-danger"
/>
</dropdown.item>
{{/if}}
</DropdownMenu>
</:content>
</DMenu>
</div>
</td>
</tr>
</template>
}

View File

@ -0,0 +1,77 @@
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,
},
});
}
}

View File

@ -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,19 +21,6 @@ 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) {

View File

@ -1,3 +1,7 @@
import Route from "@ember/routing/route";
export default class AdminApiKeysNewRoute extends Route {}
export default class AdminApiKeysNewRoute extends Route {
model() {
return this.store.createRecord("api-key");
}
}

View File

@ -1,3 +1,17 @@
import { action } from "@ember/object";
import Route from "@ember/routing/route";
import { service } from "@ember/service";
export default class AdminApiKeysRoute extends Route {}
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");
}
}

View File

@ -1 +1,104 @@
<AdminConfigAreas::ApiKeysList @apiKeys={{this.model}} />
<DButton
@action={{route-action "new"}}
@icon="plus"
@label="admin.api.new_key"
class="btn-primary"
/>
{{#if this.model}}
<LoadMore @selector=".api-keys tr" @action={{action "loadMore"}}>
<table class="d-admin-table api-keys">
<thead>
<th>{{i18n "admin.api.key"}}</th>
<th>{{i18n "admin.api.description"}}</th>
<th>{{i18n "admin.api.user"}}</th>
<th>{{i18n "admin.api.created"}}</th>
<th>{{i18n "admin.api.last_used"}}</th>
<th>{{i18n "admin.site_settings.table_column_heading.status"}}</th>
<th>&nbsp;</th>
</thead>
<tbody>
{{#each this.model as |k|}}
<tr class="d-admin-row__content {{if k.revoked_at 'revoked'}}">
<td class="d-admin-row__overview key">
{{k.truncatedKey}}
</td>
<td class="d-admin-row__detail key-description">
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.description"
}}</div>
{{k.shortDescription}}
</td>
<td class="d-admin-row__detail key-user">
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.user"
}}</div>
{{#if k.user}}
<LinkTo @route="adminUser" @model={{k.user}}>
{{avatar k.user imageSize="small"}}
</LinkTo>
{{else}}
{{i18n "admin.api.all_users"}}
{{/if}}
</td>
<td class="d-admin-row__detail key-created">
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.created"
}}</div>
{{format-date k.created_at}}
</td>
<td class="d-admin-row__detail key-last-used">
<div class="d-admin-row__mobile-label">{{i18n
"admin.api.last_used"
}}</div>
{{#if k.last_used_at}}
{{format-date k.last_used_at}}
{{else}}
{{i18n "admin.api.never_used"}}
{{/if}}
</td>
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">{{i18n
"admin.site_settings.table_column_heading.status"
}}</div>
{{#if k.revoked_at}}
<div role="status" class="status-label">
<div class="status-label-indicator">
</div>
<div class="status-label-text">
{{i18n "admin.api.revoked"}}
</div>
</div>
{{/if}}
</td>
<td class="d-admin-row__controls key-controls">
<DButton
@action={{route-action "show" k}}
@icon="far-eye"
@title="admin.api.show_details"
/>
{{#if k.revoked_at}}
<DButton
@action={{fn this.undoRevokeKey k}}
@icon="arrow-rotate-left"
@title="admin.api.undo_revoke"
/>
{{else}}
<DButton
@action={{fn this.revokeKey k}}
@icon="xmark"
@title="admin.api.revoke"
class="btn-danger"
/>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</LoadMore>
<ConditionalLoadingSpinner @condition={{this.loading}} />
{{else}}
<p>{{i18n "admin.api.none"}}</p>
{{/if}}

View File

@ -1 +1,132 @@
<AdminConfigAreas::ApiKeysNew />
<LinkTo @route="adminApiKeys.index" class="go-back">
{{d-icon "arrow-left"}}
<span>{{i18n "admin.api.all_api_keys"}}</span>
</LinkTo>
<div class="api-key api-key-new">
{{#if this.model.id}}
<AdminFormRow @label="admin.api.key">
<div>{{this.model.key}}</div>
</AdminFormRow>
<AdminFormRow>
{{i18n "admin.api.not_shown_again"}}
</AdminFormRow>
<AdminFormRow>
<DButton
@icon="angle-right"
@label="admin.api.continue"
@action={{this.continue}}
class="btn-primary"
/>
</AdminFormRow>
{{else}}
<AdminFormRow @label="admin.api.description">
<Input
@value={{this.model.description}}
maxlength="255"
placeholder={{i18n "admin.api.description_placeholder"}}
/>
</AdminFormRow>
<AdminFormRow @label="admin.api.user_mode">
<ComboBox
@content={{this.userModes}}
@value={{this.userMode}}
@onChange={{action "changeUserMode"}}
/>
</AdminFormRow>
{{#if this.showUserSelector}}
<AdminFormRow @label="admin.api.user">
<EmailGroupUserChooser
@value={{this.model.username}}
@onChange={{action "updateUsername"}}
@options={{hash
maximum=1
filterPlaceholder="admin.api.user_placeholder"
}}
/>
</AdminFormRow>
{{/if}}
<AdminFormRow @label="admin.api.scope_mode">
<ComboBox
@content={{this.scopeModes}}
@value={{this.scopeMode}}
@onChange={{action "changeScopeMode"}}
/>
{{#if (eq this.scopeMode "read_only")}}
<p>{{i18n "admin.api.scopes.descriptions.global.read"}}</p>
{{else if (eq this.scopeMode "global")}}
<p>{{i18n "admin.api.scopes.global_description"}}</p>
{{/if}}
</AdminFormRow>
{{#if (eq this.scopeMode "granular")}}
<h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
<p>{{i18n "admin.api.scopes.description"}}</p>
<table class="scopes-table grid">
<thead>
<tr>
<td></td>
<td></td>
<td>{{i18n "admin.api.scopes.allowed_urls"}}</td>
<td>{{i18n "admin.api.scopes.optional_allowed_parameters"}}</td>
</tr>
</thead>
<tbody>
{{#each-in this.scopes as |resource actions|}}
<tr class="scope-resource-name">
<td><b>{{resource}}</b></td>
<td></td>
<td></td>
<td></td>
</tr>
{{#each actions as |act|}}
<tr>
<td><Input @type="checkbox" @checked={{act.selected}} /></td>
<td>
<div class="scope-name">{{act.name}}</div>
<DTooltip
@icon="circle-question"
@content={{i18n
(concat
"admin.api.scopes.descriptions." resource "." act.key
)
class="scope-tooltip"
}}
/>
</td>
<td>
<DButton
@icon="link"
@action={{fn this.showURLs act.urls}}
class="btn-info"
/>
</td>
<td>
{{#each act.params as |p|}}
<Input
maxlength="255"
@value={{get act p}}
placeholder={{p}}
/>
{{/each}}
</td>
</tr>
{{/each}}
{{/each-in}}
</tbody>
</table>
{{/if}}
<DButton
@icon="check"
@label="admin.api.save"
@action={{this.save}}
@disabled={{this.saveDisabled}}
class="btn-primary"
/>
{{/if}}
</div>

View File

@ -1 +1,159 @@
<AdminConfigAreas::ApiKeysShow @apiKey={{this.model}} />
<LinkTo @route="adminApiKeys.index" class="go-back">
{{d-icon "arrow-left"}}
{{i18n "admin.api.all_api_keys"}}
</LinkTo>
<div class="api-key api-key-show">
<AdminFormRow @label="admin.api.key">
{{#if this.model.revoked_at}}{{d-icon "circle-xmark"}}{{/if}}
{{this.model.truncatedKey}}
</AdminFormRow>
<AdminFormRow @label="admin.api.description">
{{#if this.editingDescription}}
<Input
@value={{this.buffered.description}}
maxlength="255"
placeholder={{i18n "admin.api.description_placeholder"}}
/>
{{else}}
<span>
{{if
this.model.description
this.model.description
(i18n "admin.api.no_description")
}}
</span>
{{/if}}
<div class="controls">
{{#if this.editingDescription}}
<DButton @action={{this.saveDescription}} @icon="check" class="ok" />
<DButton
@action={{this.editDescription}}
@icon="xmark"
class="cancel"
/>
{{else}}
<DButton
@action={{this.editDescription}}
@icon="pencil"
class="btn-default"
/>
{{/if}}
</div>
</AdminFormRow>
<AdminFormRow @label="admin.api.user">
{{#if this.model.user}}
<LinkTo @route="adminUser" @model={{this.model.user}}>
{{avatar this.model.user imageSize="small"}}
{{this.model.user.username}}
</LinkTo>
{{else}}
{{i18n "admin.api.all_users"}}
{{/if}}
</AdminFormRow>
<AdminFormRow @label="admin.api.created">
{{format-date this.model.created_at leaveAgo="true"}}
</AdminFormRow>
<AdminFormRow @label="admin.api.updated">
{{format-date this.model.updated_at leaveAgo="true"}}
</AdminFormRow>
<AdminFormRow @label="admin.api.last_used">
{{#if this.model.last_used_at}}
{{format-date this.model.last_used_at leaveAgo="true"}}
{{else}}
{{i18n "admin.api.never_used"}}
{{/if}}
</AdminFormRow>
<AdminFormRow @label="admin.api.revoked">
{{#if this.model.revoked_at}}
{{format-date this.model.revoked_at leaveAgo="true"}}
{{else}}
<span>{{i18n "no_value"}}</span>
{{/if}}
<div class="controls">
{{#if this.model.revoked_at}}
<DButton
@action={{fn this.undoRevokeKey this.model}}
@icon="arrow-rotate-left"
@label="admin.api.undo_revoke"
/>
<DButton
@action={{fn this.deleteKey this.model}}
@icon="trash-can"
@label="admin.api.delete"
class="btn-danger"
/>
{{else}}
<DButton
@action={{fn this.revokeKey this.model}}
@icon="xmark"
@label="admin.api.revoke"
class="btn-danger"
/>
{{/if}}
</div>
</AdminFormRow>
{{#if this.model.api_key_scopes.length}}
<h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
<table class="scopes-table grid">
<thead>
<tr>
<td>{{i18n "admin.api.scopes.resource"}}</td>
<td>{{i18n "admin.api.scopes.action"}}</td>
<td>{{i18n "admin.api.scopes.allowed_urls"}}</td>
<td>{{i18n "admin.api.scopes.allowed_parameters"}}</td>
</tr>
</thead>
<tbody>
{{#each this.model.api_key_scopes as |scope|}}
<tr>
<td>{{scope.resource}}</td>
<td>
{{scope.action}}
<DTooltip
@icon="circle-question"
@content={{i18n
(concat
"admin.api.scopes.descriptions."
scope.resource
"."
scope.key
)
}}
class="scope-tooltip"
/>
</td>
<td>
<DButton
@icon="link"
@action={{fn this.showURLs scope.urls}}
class="btn-info"
/>
</td>
<td>
{{#each scope.parameters as |p|}}
<div>
<b>{{p}}:</b>
{{#if (get scope.allowed_parameters p)}}
{{get scope.allowed_parameters p}}
{{else}}
{{i18n "admin.api.scopes.any_parameter"}}
{{/if}}
</div>
{{/each}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</div>

View File

@ -1,21 +1,3 @@
<div class="admin-api-keys admin-config-page">
<DPageHeader
@titleLabel={{i18n "admin.api_keys.title"}}
@descriptionLabel={{i18n "admin.api_keys.description"}}
@hideTabs={{true}}
>
<:breadcrumbs>
<DBreadcrumbsItem
@path="/admin/api/keys"
@label={{i18n "admin.api_keys.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary @route="adminApiKeys.new" @label="admin.api_keys.add" />
</:actions>
</DPageHeader>
<div class="admin-container admin-config-page__main-area">
{{outlet}}
</div>
</div>
<PluginOutlet @name="admin-api-keys">
{{outlet}}
</PluginOutlet>

View File

@ -0,0 +1,8 @@
<AdminNav>
<NavItem @route="adminApiKeys" @label="admin.api.title" />
<NavItem @route="adminWebHooks" @label="admin.web_hooks.title" />
</AdminNav>
<div class="admin-container">
{{outlet}}
</div>

View File

@ -96,14 +96,6 @@
}
}
&__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);

View File

@ -14,7 +14,6 @@ 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)

View File

@ -11,7 +11,6 @@ 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?

View File

@ -4,5 +4,4 @@ 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

View File

@ -5396,20 +5396,6 @@ 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."
@ -5417,30 +5403,30 @@ en:
title: "API"
key: "Key"
keys: "Keys"
created: Created by
created: Created
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:
@ -5454,8 +5440,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:

View File

@ -1,65 +0,0 @@
#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

View File

@ -1,87 +0,0 @@
# 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