mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-26 03:01:14 +08:00
Currently, if creating an API key in "granular" mode, and not selecting any scopes, a globally scoped API key is created. This can be surprising and is not ideal. Having a key with no scopes isn't useful in the first place, so this PR adds client- and server side validations to check that at least one scope is selected if using "granular" mode.
342 lines
11 KiB
Text
Vendored
342 lines
11 KiB
Text
Vendored
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 { bind } from "discourse/lib/decorators";
|
|
import { i18n } from "discourse-i18n";
|
|
import ApiKeyUrlsModal from "admin/components/modal/api-key-urls";
|
|
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
|
|
|
|
export default class AdminConfigAreasApiKeysNew extends Component {
|
|
@service router;
|
|
@service modal;
|
|
@service store;
|
|
|
|
@tracked username;
|
|
@tracked loadingScopes = false;
|
|
@tracked scopes = null;
|
|
|
|
@tracked generatedApiKey = 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,
|
|
scope_mode: data.scope_mode,
|
|
};
|
|
|
|
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 {
|
|
const result = await this.store.createRecord("api-key").save(payload);
|
|
this.generatedApiKey = result.payload.key;
|
|
} 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();
|
|
}
|
|
|
|
@bind
|
|
atLeastOneGranularScope(data, { addError, removeError }) {
|
|
removeError("scopes");
|
|
|
|
if (
|
|
data.scope_mode === "granular" &&
|
|
this.#selectedScopes(data.scopes).length === 0
|
|
) {
|
|
addError("scopes", {
|
|
title: i18n("admin.api.scopes.title"),
|
|
message: i18n("admin.api.scopes.one_or_more"),
|
|
});
|
|
}
|
|
}
|
|
|
|
@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">
|
|
<div class="admin-config-area__primary-content">
|
|
<div class="admin-config-area-card">
|
|
{{#if this.generatedApiKey}}
|
|
<div>{{i18n "admin.api.not_shown_again"}}</div>
|
|
<div class="generated-api-key">{{this.generatedApiKey}}</div>
|
|
<DButton
|
|
@route="adminApiKeys.index"
|
|
@label="admin.api_keys.continue"
|
|
class="continue btn-danger"
|
|
/>
|
|
{{else}}
|
|
<ConditionalLoadingSection @isLoading={{this.loadingScopes}}>
|
|
<Form
|
|
@onSubmit={{this.save}}
|
|
@data={{this.formData}}
|
|
@validate={{this.atLeastOneGranularScope}}
|
|
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>{{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}}
|
|
@tooltip={{i18n
|
|
(concat
|
|
"admin.api.scopes.descriptions."
|
|
scopeName
|
|
"."
|
|
collectionData.key
|
|
)
|
|
}}
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</topicsCollection.Field>
|
|
</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>
|
|
{{/if}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
}
|