Add configuration panel for Application Credentials (#12344)

Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Zack <zackbarett@hey.com>
This commit is contained in:
Allen Porter 2022-05-09 08:03:59 -07:00 committed by GitHub
parent ca37aff47d
commit 00c5d3dbbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 696 additions and 41 deletions

View File

@ -269,8 +269,8 @@ export class HaDataTable extends LitElement {
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length ===
this._checkableRowsCount}
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>
</div>

View File

@ -0,0 +1,44 @@
import { HomeAssistant } from "../types";
export interface ApplicationCredentialsConfig {
domains: string[];
}
export interface ApplicationCredential {
id: string;
domain: string;
client_id: string;
client_secret: string;
}
export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) =>
hass.callWS<ApplicationCredentialsConfig>({
type: "application_credentials/config",
});
export const fetchApplicationCredentials = async (hass: HomeAssistant) =>
hass.callWS<ApplicationCredential[]>({
type: "application_credentials/list",
});
export const createApplicationCredential = async (
hass: HomeAssistant,
domain: string,
clientId: string,
clientSecret: string
) =>
hass.callWS<ApplicationCredential>({
type: "application_credentials/create",
domain,
client_id: clientId,
client_secret: clientSecret,
});
export const deleteApplicationCredential = async (
hass: HomeAssistant,
applicationCredentialsId: string
) =>
hass.callWS<void>({
type: "application_credentials/delete",
application_credentials_id: applicationCredentialsId,
});

View File

@ -0,0 +1,224 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ComboBoxLitRenderer } from "lit-vaadin-helpers";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-circular-progress";
import "../../../components/ha-combo-box";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-textfield";
import {
fetchApplicationCredentialsConfig,
createApplicationCredential,
ApplicationCredential,
} from "../../../data/application_credential";
import { domainToName } from "../../../data/integration";
import { PolymerChangedEvent } from "../../../polymer-types";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface Domain {
id: string;
name: string;
}
const rowRenderer: ComboBoxLitRenderer<Domain> = (item) => html`<mwc-list-item>
<span>${item.name}</span>
</mwc-list-item>`;
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
// Error message when can't talk to server etc
@state() private _error?: string;
@state() private _params?: AddApplicationCredentialDialogParams;
@state() private _domain?: string;
@state() private _clientId?: string;
@state() private _clientSecret?: string;
@state() private _domains?: Domain[];
public showDialog(params: AddApplicationCredentialDialogParams) {
this._params = params;
this._domain = "";
this._clientId = "";
this._clientSecret = "";
this._error = undefined;
this._loading = false;
this._fetchConfig();
}
private async _fetchConfig() {
const config = await fetchApplicationCredentialsConfig(this.hass);
this._domains = config.domains.map((domain) => ({
id: domain,
name: domainToName(this.hass.localize, domain),
}));
}
protected render(): TemplateResult {
if (!this._params || !this._domains) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)
)}
>
<div>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
<ha-combo-box
name="domain"
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain"
)}
.value=${this._domain}
.renderer=${rowRenderer}
.items=${this._domains}
item-id-path="id"
item-value-path="id"
item-label-path="name"
required
@value-changed=${this._handleDomainPicked}
></ha-combo-box>
<ha-textfield
class="clientId"
name="clientId"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id"
)}
.value=${this._clientId}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret"
)}
type="password"
name="clientSecret"
.value=${this._clientSecret}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
></ha-textfield>
</div>
${this._loading
? html`
<div slot="primaryAction" class="submit-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
`
: html`
<mwc-button
slot="primaryAction"
.disabled=${!this._domain ||
!this._clientId ||
!this._clientSecret}
@click=${this._createApplicationCredential}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.create"
)}
</mwc-button>
`}
</ha-dialog>
`;
}
public closeDialog() {
this._params = undefined;
this._domains = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _handleDomainPicked(ev: PolymerChangedEvent<string>) {
const target = ev.target as any;
if (target.selectedItem) {
this._domain = target.selectedItem.id;
}
}
private _handleValueChanged(ev: CustomEvent) {
this._error = undefined;
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
}
private async _createApplicationCredential(ev) {
ev.preventDefault();
if (!this._domain || !this._clientId || !this._clientSecret) {
return;
}
this._loading = true;
this._error = "";
let applicationCredential: ApplicationCredential;
try {
applicationCredential = await createApplicationCredential(
this.hass,
this._domain,
this._clientId,
this._clientSecret
);
} catch (err: any) {
this._loading = false;
this._error = err.message;
return;
}
this._params!.applicationCredentialAddedCallback(applicationCredential);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 500px;
--dialog-z-index: 10;
}
.row {
display: flex;
padding: 8px 0;
}
ha-combo-box {
display: block;
margin-bottom: 24px;
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-add-application-credential": DialogAddApplicationCredential;
}
}

View File

@ -0,0 +1,259 @@
import { mdiDelete, mdiPlus } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
DataTableColumnContainer,
SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-icon";
import "../../../components/ha-fab";
import "../../../components/ha-help-tooltip";
import "../../../components/ha-svg-icon";
import {
ApplicationCredential,
deleteApplicationCredential,
fetchApplicationCredentials,
} from "../../../data/application_credential";
import { domainToName } from "../../../data/integration";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
@customElement("ha-config-application-credentials")
export class HaConfigApplicationCredentials extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() public _applicationCredentials: ApplicationCredential[] = [];
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@state() private _selected: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _columns = memoizeOne(
(narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<ApplicationCredential> = {
clientId: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.client_id"
),
width: "25%",
direction: "asc",
grows: true,
template: (_, entry: ApplicationCredential) =>
html`${entry.client_id}`,
},
application: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.application"
),
sortable: true,
width: "20%",
direction: "asc",
hidden: narrow,
template: (_, entry) => html`${domainToName(localize, entry.domain)}`,
},
};
return columns;
}
);
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._loadTranslations();
this._fetchApplicationCredentials();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
backPath="/config"
.tabs=${configSections.devices}
.columns=${this._columns(this.narrow, this.hass.localize)}
.data=${this._applicationCredentials}
hasFab
selectable
@selection-changed=${this._handleSelectionChanged}
>
${this._selected.length
? html`
<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.application_credentials.picker.selected",
"number",
this._selected.length
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button
@click=${this._removeSelected}
class="warning"
>${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<ha-icon-button
class="warning"
id="remove-btn"
@click=${this._removeSelected}
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.remove")}
></ha-icon-button>
<ha-help-tooltip
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}
>
</ha-help-tooltip>
`}
</div>
</div>
`
: html``}
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.add_application_credential"
)}
extended
@click=${this._addApplicationCredential}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
this._selected = ev.detail.value;
}
private _removeSelected() {
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.application_credentials.picker.remove_selected.confirm_title`,
"number",
this._selected.length
),
text: this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.confirm_text"
),
confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize("ui.common.cancel"),
confirm: async () => {
await Promise.all(
this._selected.map(async (applicationCredential) => {
await deleteApplicationCredential(this.hass, applicationCredential);
})
);
this._dataTable.clearSelection();
this._fetchApplicationCredentials();
},
});
}
private async _loadTranslations() {
await this.hass.loadBackendTranslation("title", undefined, true);
}
private async _fetchApplicationCredentials() {
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
}
private _addApplicationCredential() {
showAddApplicationCredentialDialog(this, {
applicationCredentialAddedCallback: async (
applicationCredential: ApplicationCredential
) => {
if (applicationCredential) {
this._applicationCredentials = [
...this._applicationCredentials,
applicationCredential,
];
}
},
});
}
static get styles(): CSSResultGroup {
return css`
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-bottom: 1px solid
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));
box-sizing: border-box;
}
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--secondary-text-color);
position: relative;
top: -4px;
}
.selected-txt {
font-weight: bold;
padding-left: 16px;
}
.table-header .selected-txt {
margin-top: 20px;
}
.header-toolbar .selected-txt {
font-size: 16px;
}
.header-toolbar .header-btns {
margin-right: -12px;
}
.header-btns {
display: flex;
}
.header-btns > mwc-button,
.header-btns > ha-icon-button {
margin: 8px;
}
ha-button-menu {
margin-left: 8px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-application-credentials": HaConfigApplicationCredentials;
}
}

View File

@ -0,0 +1,22 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { ApplicationCredential } from "../../../data/application_credential";
export interface AddApplicationCredentialDialogParams {
applicationCredentialAddedCallback: (
applicationCredential: ApplicationCredential
) => void;
}
export const loadAddApplicationCredentialDialog = () =>
import("./dialog-add-application-credential");
export const showAddApplicationCredentialDialog = (
element: HTMLElement,
dialogParams: AddApplicationCredentialDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-add-application-credential",
dialogImport: loadAddApplicationCredentialDialog,
dialogParams,
});
};

View File

@ -16,9 +16,9 @@ import {
} from "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-button-menu";
import "../../../components/ha-check-list-item";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-check-list-item";
import { AreaRegistryEntry } from "../../../data/area_registry";
import { ConfigEntry } from "../../../data/config_entries";
import {
@ -36,6 +36,7 @@ import "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { showZWaveJSAddNodeDialog } from "../integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node";
interface DeviceRowData extends DeviceRegistryEntry {
@ -408,6 +409,10 @@ export class HaConfigDeviceDashboard extends LitElement {
(filteredConfigEntry.domain === "zha" ||
filteredConfigEntry.domain === "zwave_js")}
>
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
${!filteredConfigEntry
? ""
: filteredConfigEntry.domain === "zwave_js"

View File

@ -61,6 +61,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { DialogEntityEditor } from "./dialog-entity-editor";
import {
loadEntityEditorDialog,
@ -526,6 +527,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
id="entity_id"
.hasFab=${includeZHAFab}
>
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
${this._selectedEntities.length
? html`
<div

View File

@ -479,6 +479,11 @@ class HaPanelConfig extends HassRouterPage {
"./integrations/integration-panels/zwave_js/zwave_js-config-router"
),
},
application_credentials: {
tag: "ha-config-application-credentials",
load: () =>
import("./application_credentials/ha-config-application-credentials"),
},
},
};

View File

@ -35,6 +35,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types";
import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { HELPER_DOMAINS } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
@ -210,6 +211,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
"ui.panel.config.helpers.picker.no_helpers"
)}
>
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
<ha-fab
slot="fab"
.label=${this.hass.localize(

View File

@ -13,21 +13,20 @@ import {
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import "../../../components/search-input";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button-menu";
import "../../../components/ha-check-list-item";
import "../../../components/ha-checkbox";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-check-list-item";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/search-input";
import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import {
getConfigFlowHandlers,
@ -40,6 +39,7 @@ import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import { fetchDiagnosticHandlers } from "../../../data/diagnostics";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
@ -62,12 +62,12 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { HELPER_DOMAINS } from "../helpers/const";
import "./ha-config-flow-card";
import "./ha-ignored-config-entry-card";
import "./ha-integration-card";
import type { HaIntegrationCard } from "./ha-integration-card";
import { fetchDiagnosticHandlers } from "../../../data/diagnostics";
import { HELPER_DOMAINS } from "../helpers/const";
import "./ha-integration-overflow-menu";
export interface ConfigEntryUpdatedEvent {
entry: ConfigEntry;
@ -302,36 +302,46 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
this._filter
);
const filterMenu = html`<div
slot=${ifDefined(this.narrow ? "toolbar-icon" : "suffix")}
>
${!this._showDisabled && this.narrow && disabledCount
? html`<span class="badge">${disabledCount}</span>`
: ""}
<ha-button-menu
corner="BOTTOM_START"
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiFilterVariant}
>
</ha-icon-button>
<ha-check-list-item left .selected=${this._showIgnored}>
${this.hass.localize(
"ui.panel.config.integrations.ignore.show_ignored"
)}
</ha-check-list-item>
<ha-check-list-item left .selected=${this._showDisabled}>
${this.hass.localize(
"ui.panel.config.integrations.disable.show_disabled"
)}
</ha-check-list-item>
</ha-button-menu>
</div>`;
const filterMenu = html`
<div slot=${ifDefined(this.narrow ? "toolbar-icon" : "suffix")}>
<div class="menu-badge-container">
${!this._showDisabled && this.narrow && disabledCount
? html`<span class="badge">${disabledCount}</span>`
: ""}
<ha-button-menu
corner="BOTTOM_START"
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiFilterVariant}
>
</ha-icon-button>
<ha-check-list-item left .selected=${this._showIgnored}>
${this.hass.localize(
"ui.panel.config.integrations.ignore.show_ignored"
)}
</ha-check-list-item>
<ha-check-list-item left .selected=${this._showDisabled}>
${this.hass.localize(
"ui.panel.config.integrations.disable.show_disabled"
)}
</ha-check-list-item>
</ha-button-menu>
</div>
${this.narrow
? html`
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
`
: ""}
</div>
`;
return html`
<hass-tabs-subpage
@ -357,6 +367,10 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
${filterMenu}
`
: html`
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
<div class="search">
<search-input
.hass=${this.hass}
@ -797,10 +811,13 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
padding: 0px 4px;
color: var(--text-primary-color);
position: absolute;
right: 14px;
top: 8px;
right: 0px;
top: 4px;
font-size: 0.65em;
}
.menu-badge-container {
position: relative;
}
ha-button-menu {
color: var(--primary-text-color);
}

View File

@ -0,0 +1,45 @@
import { mdiDotsVertical } from "@mdi/js";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-button-menu";
import "../../../components/ha-clickable-list-item";
import "../../../components/ha-icon-button";
import type { HomeAssistant } from "../../../types";
@customElement("ha-integration-overflow-menu")
export class HaIntegrationOverflowMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
protected render() {
return html`
<ha-button-menu activatable corner="BOTTOM_START">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-clickable-list-item
@click=${this._entryClicked}
href="/config/application_credentials"
aria-label=${this.hass.localize(
"ui.panel.config.application_credentials.caption"
)}
>
${this.hass.localize(
"ui.panel.config.application_credentials.caption"
)}
</ha-clickable-list-item>
</ha-button-menu>
`;
}
private _entryClicked(ev) {
ev.currentTarget.blur();
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-overflow-menu": HaIntegrationOverflowMenu;
}
}

View File

@ -2855,6 +2855,30 @@
"create": "Create"
}
},
"application_credentials": {
"caption": "Application Credentials",
"description": "Manage the OAuth Application Credentials used by Integrations",
"editor": {
"caption": "Add Application Credential",
"create": "Create",
"domain": "Integration",
"client_id": "OAuth Client ID",
"client_secret": "OAuth Client Secret"
},
"picker": {
"add_application_credential": "Add Application Credential",
"headers": {
"client_id": "OAuth Client ID",
"application": "Integration"
},
"remove_selected": {
"button": "Remove selected",
"confirm_title": "Do you want to remove {number} {number, plural,\n one {credential}\n other {credentialss}\n}?",
"confirm_text": "Application Credentials in use by an integration may not be removed."
},
"selected": "{number} selected"
}
},
"mqtt": {
"title": "MQTT",
"description_publish": "Publish a packet",