Migrate mfa to Lit (#8276)

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
This commit is contained in:
Bram Kragten 2021-02-22 19:53:37 +01:00 committed by GitHub
parent e33aff7cf3
commit 627424b8b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 403 additions and 452 deletions

View File

@ -0,0 +1,281 @@
import "@material/mwc-button";
import {
css,
CSSResult,
customElement,
internalProperty,
LitElement,
property,
} from "lit-element";
import { html, TemplateResult } from "lit-html";
import { localizeKey } from "../../common/translations/localize";
import "../../components/ha-circular-progress";
import "../../components/ha-form/ha-form";
import "../../components/ha-markdown";
import {
DataEntryFlowStep,
DataEntryFlowStepForm,
} from "../../data/data_entry_flow";
import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import "../../components/ha-dialog";
let instance = 0;
@customElement("ha-mfa-module-setup-flow")
class HaMfaModuleSetupFlow extends LitElement {
@property() public hass!: HomeAssistant;
@internalProperty() private _dialogClosedCallback?: (params: {
flowFinished: boolean;
}) => void;
@internalProperty() private _instance?: number;
@internalProperty() private _loading = false;
@internalProperty() private _opened = false;
@internalProperty() private _stepData: any = {};
@internalProperty() private _step?: DataEntryFlowStep;
@internalProperty() private _errorMessage?: string;
public showDialog({ continueFlowId, mfaModuleId, dialogClosedCallback }) {
this._instance = instance++;
this._dialogClosedCallback = dialogClosedCallback;
this._opened = true;
const fetchStep = continueFlowId
? this.hass.callWS({
type: "auth/setup_mfa",
flow_id: continueFlowId,
})
: this.hass.callWS({
type: "auth/setup_mfa",
mfa_module_id: mfaModuleId,
});
const curInstance = this._instance;
fetchStep.then((step) => {
if (curInstance !== this._instance) return;
this._processStep(step);
});
}
public closeDialog() {
// Closed dialog by clicking on the overlay
if (this._step) {
this._flowDone();
}
this._opened = false;
}
protected render(): TemplateResult {
if (!this._opened) {
return html``;
}
return html`
<ha-dialog
open
.heading=${this._computeStepTitle()}
@closing=${this.closeDialog}
>
<div>
${this._errorMessage
? html`<div class="error">${this._errorMessage}</div>`
: ""}
${!this._step
? html`<div class="init-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>`
: html`${this._step.type === "abort"
? html` <ha-markdown
allowsvg
breaks
.content=${this.hass.localize(
`component.auth.mfa_setup.${this._step.handler}.abort.${this._step.reason}`
)}
></ha-markdown>`
: this._step.type === "create_entry"
? html`<p>
${this.hass.localize(
"ui.panel.profile.mfa_setup.step_done",
"step",
this._step.title
)}
</p>`
: this._step.type === "form"
? html` <ha-markdown
allowsvg
breaks
.content=${localizeKey(
this.hass.localize,
`component.auth.mfa_setup.${this._step!.handler}.step.${
(this._step! as DataEntryFlowStepForm).step_id
}.description`,
this._step!.description_placeholders
)}
></ha-markdown>
<ha-form
.data=${this._stepData}
.schema=${this._step.data_schema}
.error=${this._step.errors}
.computeLabel=${this._computeLabel}
.computeError=${this._computeError}
@value-changed=${this._stepDataChanged}
></ha-form>`
: ""}`}
</div>
${["abort", "create_entry"].includes(this._step?.type || "")
? html`<mwc-button slot="primaryAction" @click=${this.closeDialog}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.close"
)}</mwc-button
>`
: ""}
${this._step?.type === "form"
? html`<mwc-button
slot="primaryAction"
.disabled=${this._loading}
@click=${this._submitStep}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.submit"
)}</mwc-button
>`
: ""}
</ha-dialog>
`;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.error {
color: red;
}
ha-dialog {
max-width: 500px;
}
ha-markdown {
--markdown-svg-background-color: white;
--markdown-svg-color: black;
display: block;
margin: 0 auto;
}
ha-markdown a {
color: var(--primary-color);
}
.init-spinner {
padding: 10px 100px 34px;
text-align: center;
}
.submit-spinner {
margin-right: 16px;
}
`,
];
}
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("mfa_setup", "auth");
this.addEventListener("keypress", (ev) => {
if (ev.key === "Enter") {
this._submitStep();
}
});
}
private _stepDataChanged(ev: CustomEvent) {
this._stepData = ev.detail.value;
}
private _submitStep() {
this._loading = true;
this._errorMessage = undefined;
const curInstance = this._instance;
this.hass
.callWS({
type: "auth/setup_mfa",
flow_id: this._step!.flow_id,
user_input: this._stepData,
})
.then(
(step) => {
if (curInstance !== this._instance) {
return;
}
this._processStep(step);
this._loading = false;
},
(err) => {
this._errorMessage =
(err && err.body && err.body.message) || "Unknown error occurred";
this._loading = false;
}
);
}
private _processStep(step) {
if (!step.errors) step.errors = {};
this._step = step;
// We got a new form if there are no errors.
if (Object.keys(step.errors).length === 0) {
this._stepData = {};
}
}
private _flowDone() {
const flowFinished = Boolean(
this._step && ["create_entry", "abort"].includes(this._step.type)
);
this._dialogClosedCallback!({
flowFinished,
});
this._errorMessage = undefined;
this._step = undefined;
this._stepData = {};
this._dialogClosedCallback = undefined;
this.closeDialog();
}
private _computeStepTitle() {
return this._step?.type === "abort"
? this.hass.localize("ui.panel.profile.mfa_setup.title_aborted")
: this._step?.type === "create_entry"
? this.hass.localize("ui.panel.profile.mfa_setup.title_success")
: this._step?.type === "form"
? this.hass.localize(
`component.auth.mfa_setup.${this._step.handler}.step.${this._step.step_id}.title`
)
: "";
}
private _computeLabel = (schema) =>
this.hass.localize(
`component.auth.mfa_setup.${this._step!.handler}.step.${
(this._step! as DataEntryFlowStepForm).step_id
}.data.${schema.name}`
) || schema.name;
private _computeError = (error) =>
this.hass.localize(
`component.auth.mfa_setup.${this._step!.handler}.error.${error}`
) || error;
}
declare global {
interface HTMLElementTagNameMap {
"ha-mfa-module-setup-flow": HaMfaModuleSetupFlow;
}
}

View File

@ -1,322 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../components/dialog/ha-paper-dialog";
import "../../components/ha-circular-progress";
import "../../components/ha-form/ha-form";
import "../../components/ha-markdown";
import { EventsMixin } from "../../mixins/events-mixin";
import LocalizeMixin from "../../mixins/localize-mixin";
import "../../styles/polymer-ha-style-dialog";
let instance = 0;
/*
* @appliesMixin LocalizeMixin
* @appliesMixin EventsMixin
*/
class HaMfaModuleSetupFlow extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="ha-style-dialog">
.error {
color: red;
}
ha-paper-dialog {
max-width: 500px;
}
h2 {
white-space: normal;
}
ha-markdown {
--markdown-svg-background-color: white;
--markdown-svg-color: black;
display: block;
margin: 0 auto;
}
ha-markdown a {
color: var(--primary-color);
}
.init-spinner {
padding: 10px 100px 34px;
text-align: center;
}
.submit-spinner {
margin-right: 16px;
}
</style>
<ha-paper-dialog
id="dialog"
with-backdrop=""
opened="{{_opened}}"
on-opened-changed="_openedChanged"
>
<h2>
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
[[localize('ui.panel.profile.mfa_setup.title_aborted')]]
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
[[localize('ui.panel.profile.mfa_setup.title_success')]]
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
[[_computeStepTitle(localize, _step)]]
</template>
</h2>
<paper-dialog-scrollable>
<template is="dom-if" if="[[_errorMsg]]">
<div class="error">[[_errorMsg]]</div>
</template>
<template is="dom-if" if="[[!_step]]">
<div class="init-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
</template>
<template is="dom-if" if="[[_step]]">
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
<ha-markdown
allowsvg
breaks
content="[[_computeStepAbortedReason(localize, _step)]]"
></ha-markdown>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
<p>
[[localize('ui.panel.profile.mfa_setup.step_done', 'step',
_step.title)]]
</p>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
<template
is="dom-if"
if="[[_computeStepDescription(localize, _step)]]"
>
<ha-markdown
allowsvg
breaks
content="[[_computeStepDescription(localize, _step)]]"
></ha-markdown>
</template>
<ha-form
data="{{_stepData}}"
schema="[[_step.data_schema]]"
error="[[_step.errors]]"
compute-label="[[_computeLabelCallback(localize, _step)]]"
compute-error="[[_computeErrorCallback(localize, _step)]]"
></ha-form>
</template>
</template>
</paper-dialog-scrollable>
<div class="buttons">
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
<mwc-button on-click="_flowDone"
>[[localize('ui.panel.profile.mfa_setup.close')]]</mwc-button
>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
<mwc-button on-click="_flowDone"
>[[localize('ui.panel.profile.mfa_setup.close')]]</mwc-button
>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
<template is="dom-if" if="[[_loading]]">
<div class="submit-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
</template>
<template is="dom-if" if="[[!_loading]]">
<mwc-button on-click="_submitStep"
>[[localize('ui.panel.profile.mfa_setup.submit')]]</mwc-button
>
</template>
</template>
</div>
</ha-paper-dialog>
`;
}
static get properties() {
return {
_hass: Object,
_dialogClosedCallback: Function,
_instance: Number,
_loading: {
type: Boolean,
value: false,
},
// Error message when can't talk to server etc
_errorMsg: String,
_opened: {
type: Boolean,
value: false,
},
_step: {
type: Object,
value: null,
},
/*
* Store user entered data.
*/
_stepData: Object,
};
}
ready() {
super.ready();
this.hass.loadBackendTranslation("mfa_setup", "auth");
this.addEventListener("keypress", (ev) => {
if (ev.keyCode === 13) {
this._submitStep();
}
});
}
showDialog({ hass, continueFlowId, mfaModuleId, dialogClosedCallback }) {
this.hass = hass;
this._instance = instance++;
this._dialogClosedCallback = dialogClosedCallback;
this._createdFromHandler = !!mfaModuleId;
this._loading = true;
this._opened = true;
const fetchStep = continueFlowId
? this.hass.callWS({
type: "auth/setup_mfa",
flow_id: continueFlowId,
})
: this.hass.callWS({
type: "auth/setup_mfa",
mfa_module_id: mfaModuleId,
});
const curInstance = this._instance;
fetchStep.then((step) => {
if (curInstance !== this._instance) return;
this._processStep(step);
this._loading = false;
// When the flow changes, center the dialog.
// Don't do it on each step or else the dialog keeps bouncing.
setTimeout(() => this.$.dialog.center(), 0);
});
}
_submitStep() {
this._loading = true;
this._errorMsg = null;
const curInstance = this._instance;
this.hass
.callWS({
type: "auth/setup_mfa",
flow_id: this._step.flow_id,
user_input: this._stepData,
})
.then(
(step) => {
if (curInstance !== this._instance) return;
this._processStep(step);
this._loading = false;
},
(err) => {
this._errorMsg =
(err && err.body && err.body.message) || "Unknown error occurred";
this._loading = false;
}
);
}
_processStep(step) {
if (!step.errors) step.errors = {};
this._step = step;
// We got a new form if there are no errors.
if (Object.keys(step.errors).length === 0) {
this._stepData = {};
}
}
_flowDone() {
this._opened = false;
const flowFinished =
this._step && ["create_entry", "abort"].includes(this._step.type);
if (this._step && !flowFinished && this._createdFromHandler) {
// console.log('flow not finish');
}
this._dialogClosedCallback({
flowFinished,
});
this._errorMsg = null;
this._step = null;
this._stepData = {};
this._dialogClosedCallback = null;
}
_equals(a, b) {
return a === b;
}
_openedChanged(ev) {
// Closed dialog by clicking on the overlay
if (this._step && !ev.detail.value) {
this._flowDone();
}
}
_computeStepAbortedReason(localize, step) {
return localize(
`component.auth.mfa_setup.${step.handler}.abort.${step.reason}`
);
}
_computeStepTitle(localize, step) {
return (
localize(
`component.auth.mfa_setup.${step.handler}.step.${step.step_id}.title`
) || "Setup Multi-factor Authentication"
);
}
_computeStepDescription(localize, step) {
const args = [
`component.auth.mfa_setup.${step.handler}.step.${step.step_id}.description`,
];
const placeholders = step.description_placeholders || {};
Object.keys(placeholders).forEach((key) => {
args.push(key);
args.push(placeholders[key]);
});
return localize(...args);
}
_computeLabelCallback(localize, step) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
localize(
`component.auth.mfa_setup.${step.handler}.step.${step.step_id}.data.${schema.name}`
) || schema.name;
}
_computeErrorCallback(localize, step) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
localize(`component.auth.mfa_setup.${step.handler}.error.${error}`) ||
error;
}
}
customElements.define("ha-mfa-module-setup-flow", HaMfaModuleSetupFlow);

View File

@ -1,130 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../components/ha-card";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { EventsMixin } from "../../mixins/events-mixin";
import LocalizeMixin from "../../mixins/localize-mixin";
import "../../styles/polymer-ha-style";
let registeredDialog = false;
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class HaMfaModulesCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
.error {
color: red;
}
.status {
color: var(--primary-color);
}
.error,
.status {
position: absolute;
top: -4px;
}
mwc-button {
margin-right: -0.57em;
}
</style>
<ha-card header="[[localize('ui.panel.profile.mfa.header')]]">
<template is="dom-repeat" items="[[mfaModules]]" as="module">
<paper-item>
<paper-item-body two-line="">
<div>[[module.name]]</div>
<div secondary="">[[module.id]]</div>
</paper-item-body>
<template is="dom-if" if="[[module.enabled]]">
<mwc-button on-click="_disable"
>[[localize('ui.panel.profile.mfa.disable')]]</mwc-button
>
</template>
<template is="dom-if" if="[[!module.enabled]]">
<mwc-button on-click="_enable"
>[[localize('ui.panel.profile.mfa.enable')]]</mwc-button
>
</template>
</paper-item>
</template>
</ha-card>
`;
}
static get properties() {
return {
hass: Object,
_loading: {
type: Boolean,
value: false,
},
// Error message when can't talk to server etc
_statusMsg: String,
_errorMsg: String,
mfaModules: Array,
};
}
connectedCallback() {
super.connectedCallback();
if (!registeredDialog) {
registeredDialog = true;
this.fire("register-dialog", {
dialogShowEvent: "show-mfa-module-setup-flow",
dialogTag: "ha-mfa-module-setup-flow",
dialogImport: () => import("./ha-mfa-module-setup-flow"),
});
}
}
_enable(ev) {
this.fire("show-mfa-module-setup-flow", {
hass: this.hass,
mfaModuleId: ev.model.module.id,
dialogClosedCallback: () => this._refreshCurrentUser(),
});
}
async _disable(ev) {
const mfamodule = ev.model.module;
if (
!(await showConfirmationDialog(this, {
text: this.localize(
"ui.panel.profile.mfa.confirm_disable",
"name",
mfamodule.name
),
}))
) {
return;
}
const mfaModuleId = mfamodule.id;
this.hass
.callWS({
type: "auth/depose_mfa",
mfa_module_id: mfaModuleId,
})
.then(() => {
this._refreshCurrentUser();
});
}
_refreshCurrentUser() {
this.fire("hass-refresh-current-user");
}
}
customElements.define("ha-mfa-modules-card", HaMfaModulesCard);

View File

@ -0,0 +1,101 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-card";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { HomeAssistant, MFAModule } from "../../types";
import { showMfaModuleSetupFlowDialog } from "./show-ha-mfa-module-setup-flow-dialog";
@customElement("ha-mfa-modules-card")
class HaMfaModulesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public mfaModules!: MFAModule[];
protected render(): TemplateResult {
return html`
<ha-card .header=${this.hass.localize("ui.panel.profile.mfa.header")}>
${this.mfaModules.map(
(module) => html`<paper-item>
<paper-item-body two-line="">
<div>${module.name}</div>
<div secondary>${module.id}</div>
</paper-item-body>
${module.enabled
? html`<mwc-button .module=${module} @click=${this._disable}
>${this.hass.localize(
"ui.panel.profile.mfa.disable"
)}</mwc-button
>`
: html`<mwc-button .module=${module} @click=${this._enable}
>${this.hass.localize(
"ui.panel.profile.mfa.enable"
)}</mwc-button
>`}
</paper-item>`
)}
</ha-card>
`;
}
static get styles(): CSSResult {
return css`
mwc-button {
margin-right: -0.57em;
}
`;
}
private _enable(ev) {
showMfaModuleSetupFlowDialog(this, {
mfaModuleId: ev.currentTarget.module.id,
dialogClosedCallback: () => this._refreshCurrentUser(),
});
}
private async _disable(ev) {
const mfamodule = ev.currentTarget.module;
if (
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.profile.mfa.confirm_disable",
"name",
mfamodule.name
),
}))
) {
return;
}
const mfaModuleId = mfamodule.id;
this.hass
.callWS({
type: "auth/depose_mfa",
mfa_module_id: mfaModuleId,
})
.then(() => {
this._refreshCurrentUser();
});
}
private _refreshCurrentUser() {
fireEvent(this, "hass-refresh-current-user");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-mfa-modules-card": HaMfaModulesCard;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface MfaModuleSetupFlowDialogParams {
continueFlowId?: string;
mfaModuleId?: string;
dialogClosedCallback: (params: { flowFinished: boolean }) => void;
}
export const loadMfaModuleSetupFlowDialog = () =>
import("./dialog-ha-mfa-module-setup-flow");
export const showMfaModuleSetupFlowDialog = (
element: HTMLElement,
dialogParams: MfaModuleSetupFlowDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-mfa-module-setup-flow",
dialogImport: loadMfaModuleSetupFlowDialog,
dialogParams,
});
};