DEV: Convert API keys to Admin UI guidelines

This commit is contained in:
Ted Johansson 2024-11-25 16:22:17 +08:00
parent e04f535601
commit 358932c6d6
No known key found for this signature in database
GPG Key ID: 2E801F82D9A4C6E9
17 changed files with 832 additions and 511 deletions

View File

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

@ -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
showURLs(urls) {
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

@ -0,0 +1,240 @@
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
showURLs(urls) {
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}}
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

@ -0,0 +1,134 @@
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();
this.dMenu.close();
} catch (error) {
popupAjaxError(error);
}
}
@action
async undoRevokeKey(key) {
try {
await key.undoRevoke();
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">
<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

@ -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,
},
});
}
}

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() {

View File

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

View File

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

View File

@ -1,104 +1 @@
<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}}
<AdminConfigAreas::ApiKeysList @apiKeys={{this.model}} />

View File

@ -1,132 +1 @@
<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>
<AdminConfigAreas::ApiKeysNew />

View File

@ -1,159 +1 @@
<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>
<AdminConfigAreas::ApiKeysShow @apiKey={{this.model}} />

View File

@ -1,3 +1,21 @@
<PluginOutlet @name="admin-api-keys">
{{outlet}}
</PluginOutlet>
<div class="admin-api-keys admin-config-page">
<AdminPageHeader
@titleLabel="admin.api_keys.title"
@descriptionLabel="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>
</AdminPageHeader>
<div class="admin-container admin-config-page__main-area">
{{outlet}}
</div>
</div>

View File

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

@ -92,6 +92,13 @@
color: var(--primary-high);
font-size: var(--font-down-1);
}
&__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);
}
}
}

View File

@ -5320,6 +5320,19 @@ 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"
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."
@ -5332,25 +5345,25 @@ en:
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:
@ -5364,8 +5377,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

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

View File

@ -0,0 +1,34 @@
# 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_no_api_key_listed?(name)
page.has_no_css?(table_selector, text: name)
end
def add_api_key(description:)
page.find(".admin-page-header__actions", text: "Add API key").click
form = page.find(".form-kit")
form.find("input[name='description']").fill_in(with: description)
form.find(".save").click
end
private
def table_selector
".admin-api_keys__items"
end
end
end
end