Convert auth-flow to TypeScript/Lit (#3174)

* Change ha-pick-auth-provider to typescript

* Convert auth-flow to TypeScript/Lit
This commit is contained in:
Jason Hu 2019-05-08 19:52:55 -07:00 committed by Paulus Schoutsen
parent 32e68c1a4b
commit 8924a5f043
5 changed files with 341 additions and 325 deletions

View File

@ -1,270 +0,0 @@
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "@material/mwc-button";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import "../components/ha-form";
import "../components/ha-markdown";
import { localizeLiteMixin } from "../mixins/localize-lite-mixin";
class HaAuthFlow extends localizeLiteMixin(PolymerElement) {
static get template() {
return html`
<style>
:host {
/* So we can set min-height to avoid jumping during loading */
display: block;
}
.action {
margin: 24px 0 8px;
text-align: center;
}
.error {
color: red;
}
</style>
<form>
<template is="dom-if" if='[[_equals(_state, "loading")]]'>
[[localize('ui.panel.page-authorize.form.working')]]:
</template>
<template is="dom-if" if='[[_equals(_state, "error")]]'>
<div class="error">Error: [[_errorMsg]]</div>
</template>
<template is="dom-if" if='[[_equals(_state, "step")]]'>
<template is="dom-if" if='[[_equals(_step.type, "abort")]]'>
[[localize('ui.panel.page-authorize.abort_intro')]]:
<ha-markdown
content="[[_computeStepAbortedReason(localize, _step)]]"
></ha-markdown>
</template>
<template is="dom-if" if='[[_equals(_step.type, "form")]]'>
<template
is="dom-if"
if="[[_computeStepDescription(localize, _step)]]"
>
<ha-markdown
content="[[_computeStepDescription(localize, _step)]]"
allow-svg
></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>
<div class="action">
<mwc-button raised on-click="_handleSubmit"
>[[_computeSubmitCaption(_step.type)]]</mwc-button
>
</div>
</template>
</form>
`;
}
static get properties() {
return {
authProvider: {
type: Object,
observer: "_providerChanged",
},
clientId: String,
redirectUri: String,
oauth2State: String,
_state: {
type: String,
value: "loading",
},
_stepData: {
type: Object,
value: () => ({}),
},
_step: {
type: Object,
notify: true,
},
_errorMsg: String,
};
}
ready() {
super.ready();
this.addEventListener("keypress", (ev) => {
if (ev.keyCode === 13) {
this._handleSubmit(ev);
}
});
}
async _providerChanged(newProvider, oldProvider) {
if (oldProvider && this._step && this._step.type === "form") {
fetch(`/auth/login_flow/${this._step.flow_id}`, {
method: "DELETE",
credentials: "same-origin",
}).catch(() => {});
}
try {
const response = await fetch("/auth/login_flow", {
method: "POST",
credentials: "same-origin",
body: JSON.stringify({
client_id: this.clientId,
handler: [newProvider.type, newProvider.id],
redirect_uri: this.redirectUri,
}),
});
const data = await response.json();
if (response.ok) {
// allow auth provider bypass the login form
if (data.type === "create_entry") {
this._redirect(data.result);
return;
}
this._updateStep(data);
} else {
this.setProperties({
_state: "error",
_errorMsg: data.message,
});
}
} catch (err) {
// eslint-disable-next-line
console.error("Error starting auth flow", err);
this.setProperties({
_state: "error",
_errorMsg: this.localize("ui.panel.page-authorize.form.unknown_error"),
});
}
}
_redirect(authCode) {
// OAuth 2: 3.1.2 we need to retain query component of a redirect URI
let url = this.redirectUri;
if (!url.includes("?")) {
url += "?";
} else if (!url.endsWith("&")) {
url += "&";
}
url += `code=${encodeURIComponent(authCode)}`;
if (this.oauth2State) {
url += `&state=${encodeURIComponent(this.oauth2State)}`;
}
document.location = url;
}
_updateStep(step) {
const props = {
_step: step,
_state: "step",
};
if (
this._step &&
(step.flow_id !== this._step.flow_id ||
step.step_id !== this._step.step_id)
) {
props._stepData = {};
}
this.setProperties(props);
}
_equals(a, b) {
return a === b;
}
_computeSubmitCaption(stepType) {
return stepType === "form" ? "Next" : "Start over";
}
_computeStepAbortedReason(localize, step) {
return localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${
step.reason
}`
);
}
_computeStepDescription(localize, step) {
const args = [
`ui.panel.page-authorize.form.providers.${step.handler[0]}.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(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${
step.step_id
}.data.${schema.name}`
);
}
_computeErrorCallback(localize, step) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
localize(
`ui.panel.page-authorize.form.providers.${
step.handler[0]
}.error.${error}`
);
}
async _handleSubmit(ev) {
ev.preventDefault();
if (this._step.type !== "form") {
this._providerChanged(this.authProvider, null);
return;
}
this._state = "loading";
// To avoid a jumping UI.
this.style.setProperty("min-height", `${this.offsetHeight}px`);
const postData = Object.assign({}, this._stepData, {
client_id: this.clientId,
});
try {
const response = await fetch(`/auth/login_flow/${this._step.flow_id}`, {
method: "POST",
credentials: "same-origin",
body: JSON.stringify(postData),
});
const newStep = await response.json();
if (newStep.type === "create_entry") {
this._redirect(newStep.result);
return;
}
this._updateStep(newStep);
} catch (err) {
// eslint-disable-next-line
console.error("Error submitting step", err);
this._state = "error-loading";
} finally {
this.style.setProperty("min-height", "");
}
}
}
customElements.define("ha-auth-flow", HaAuthFlow);

296
src/auth/ha-auth-flow.ts Normal file
View File

@ -0,0 +1,296 @@
import { LitElement, html, property, PropertyValues } from "lit-element";
import "@material/mwc-button";
import "../components/ha-form";
import "../components/ha-markdown";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { AuthProvider } from "../data/auth";
import { ConfigFlowStep, ConfigFlowStepForm } from "../data/config_entries";
type State = "loading" | "error" | "step";
class HaAuthFlow extends litLocalizeLiteMixin(LitElement) {
@property() public authProvider?: AuthProvider;
@property() public clientId?: string;
@property() public redirectUri?: string;
@property() public oauth2State?: string;
@property() private _state: State = "loading";
@property() private _stepData: any = {};
@property() private _step?: ConfigFlowStep;
@property() private _errorMessage?: string;
protected render() {
return html`
<style>
:host {
/* So we can set min-height to avoid jumping during loading */
display: block;
}
.action {
margin: 24px 0 8px;
text-align: center;
}
.error {
color: red;
}
</style>
<form>
${this._renderForm()}
</form>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
if (this.clientId == null || this.redirectUri == null) {
// tslint:disable-next-line: no-console
console.error(
"clientId and redirectUri must not be null",
this.clientId,
this.redirectUri
);
this._state = "error";
this._errorMessage = this._unknownError();
return;
}
this.addEventListener("keypress", (ev) => {
if (ev.keyCode === 13) {
this._handleSubmit(ev);
}
});
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("authProvider")) {
this._providerChanged(this.authProvider);
}
}
private _renderForm() {
switch (this._state) {
case "step":
if (this._step == null) {
return html``;
}
return html`
${this._renderStep(this._step)}
<div class="action">
<mwc-button raised @click=${this._handleSubmit}
>${this._step.type === "form" ? "Next" : "Start over"}</mwc-button
>
</div>
`;
case "error":
return html`
<div class="error">Error: ${this._errorMessage}</div>
`;
case "loading":
return html`
${this.localize("ui.panel.page-authorize.form.working")}
`;
}
}
private _renderStep(step: ConfigFlowStep) {
switch (step.type) {
case "abort":
return html`
${this.localize("ui.panel.page-authorize.abort_intro")}:
<ha-markdown
.content=${this.localize(
`ui.panel.page-authorize.form.providers.${
step.handler[0]
}.abort.${step.reason}`
)}
></ha-markdown>
`;
case "form":
return html`
${this._computeStepDescription(step)
? html`
<ha-markdown
.content=${this._computeStepDescription(step)}
allow-svg
></ha-markdown>
`
: html``}
<ha-form
.data=${this._stepData}
.schema=${step.data_schema}
.error=${step.errors}
.computeLabel=${this._computeLabelCallback(step)}
.computeError=${this._computeErrorCallback(step)}
></ha-form>
`;
default:
return html``;
}
}
private async _providerChanged(newProvider?: AuthProvider) {
if (this._step && this._step.type === "form") {
fetch(`/auth/login_flow/${this._step.flow_id}`, {
method: "DELETE",
credentials: "same-origin",
}).catch((err) => {
// tslint:disable-next-line: no-console
console.error("Error delete obsoleted auth flow", err);
});
}
if (newProvider == null) {
// tslint:disable-next-line: no-console
console.error("No auth provider");
this._state = "error";
this._errorMessage = this._unknownError();
return;
}
try {
const response = await fetch("/auth/login_flow", {
method: "POST",
credentials: "same-origin",
body: JSON.stringify({
client_id: this.clientId,
handler: [newProvider.type, newProvider.id],
redirect_uri: this.redirectUri,
}),
});
const data = await response.json();
if (response.ok) {
// allow auth provider bypass the login form
if (data.type === "create_entry") {
this._redirect(data.result);
return;
}
this._updateStep(data);
} else {
this._state = "error";
this._errorMessage = data.message;
}
} catch (err) {
// tslint:disable-next-line: no-console
console.error("Error starting auth flow", err);
this._state = "error";
this._errorMessage = this._unknownError();
}
}
private _redirect(authCode: string) {
// OAuth 2: 3.1.2 we need to retain query component of a redirect URI
let url = this.redirectUri!!;
if (!url.includes("?")) {
url += "?";
} else if (!url.endsWith("&")) {
url += "&";
}
url += `code=${encodeURIComponent(authCode)}`;
if (this.oauth2State) {
url += `&state=${encodeURIComponent(this.oauth2State)}`;
}
document.location.assign(url);
}
private _updateStep(step: ConfigFlowStep) {
let stepData: any = null;
if (
this._step &&
(step.flow_id !== this._step.flow_id ||
(step.type === "form" &&
this._step.type === "form" &&
step.step_id !== this._step.step_id))
) {
stepData = {};
}
this._step = step;
this._state = "step";
if (stepData != null) {
this._stepData = stepData;
}
}
private _computeStepDescription(step: ConfigFlowStepForm) {
const resourceKey = `ui.panel.page-authorize.form.providers.${
step.handler[0]
}.step.${step.step_id}.description`;
const args: string[] = [];
const placeholders = step.description_placeholders || {};
Object.keys(placeholders).forEach((key) => {
args.push(key);
args.push(placeholders[key]);
});
return this.localize(resourceKey, ...args);
}
private _computeLabelCallback(step: ConfigFlowStepForm) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
this.localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${
step.step_id
}.data.${schema.name}`
);
}
private _computeErrorCallback(step: ConfigFlowStepForm) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
this.localize(
`ui.panel.page-authorize.form.providers.${
step.handler[0]
}.error.${error}`
);
}
private _unknownError() {
return this.localize("ui.panel.page-authorize.form.unknown_error");
}
private async _handleSubmit(ev: Event) {
ev.preventDefault();
if (this._step == null) {
return;
}
if (this._step.type !== "form") {
this._providerChanged(this.authProvider);
return;
}
this._state = "loading";
// To avoid a jumping UI.
this.style.setProperty("min-height", `${this.offsetHeight}px`);
const postData = { ...this._stepData, client_id: this.clientId };
try {
const response = await fetch(`/auth/login_flow/${this._step.flow_id}`, {
method: "POST",
credentials: "same-origin",
body: JSON.stringify(postData),
});
const newStep = await response.json();
if (newStep.type === "create_entry") {
this._redirect(newStep.result);
return;
}
this._updateStep(newStep);
} catch (err) {
// tslint:disable-next-line: no-console
console.error("Error submitting step", err);
this._state = "error";
this._errorMessage = this._unknownError();
} finally {
this.style.setProperty("min-height", "");
}
}
}
customElements.define("ha-auth-flow", HaAuthFlow);

View File

@ -108,7 +108,7 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
.resources="${this.resources}"
.clientId="${this.clientId}"
.authProviders="${inactiveProviders}"
@pick="${this._handleAuthProviderPick}"
@pick-auth-provider="${this._handleAuthProviderPick}"
></ha-pick-auth-provider>
`
: ""}

View File

@ -1,54 +0,0 @@
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { EventsMixin } from "../mixins/events-mixin";
import { localizeLiteMixin } from "../mixins/localize-lite-mixin";
import "../components/ha-icon-next";
/*
* @appliesMixin EventsMixin
*/
class HaPickAuthProvider extends EventsMixin(
localizeLiteMixin(PolymerElement)
) {
static get template() {
return html`
<style>
paper-item {
cursor: pointer;
}
p {
margin-top: 0;
}
</style>
<p>[[localize('ui.panel.page-authorize.pick_auth_provider')]]:</p>
<template is="dom-repeat" items="[[authProviders]]">
<paper-item on-click="_handlePick">
<paper-item-body>[[item.name]]</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</template>
`;
}
static get properties() {
return {
_state: {
type: String,
value: "loading",
},
authProviders: Array,
};
}
_handlePick(ev) {
this.fire("pick", ev.model.item);
}
_equal(a, b) {
return a === b;
}
}
customElements.define("ha-pick-auth-provider", HaPickAuthProvider);

View File

@ -0,0 +1,44 @@
import { LitElement, html, property } from "lit-element";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { fireEvent } from "../common/dom/fire_event";
import "../components/ha-icon-next";
import { AuthProvider } from "../data/auth";
declare global {
interface HASSDomEvents {
"pick-auth-provider": AuthProvider;
}
}
class HaPickAuthProvider extends litLocalizeLiteMixin(LitElement) {
@property() public authProviders: AuthProvider[] = [];
protected render() {
return html`
<style>
paper-item {
cursor: pointer;
}
p {
margin-top: 0;
}
</style>
<p>${this.localize("ui.panel.page-authorize.pick_auth_provider")}:</p>
${this.authProviders.map(
(provider) => html`
<paper-item .auth_provider=${provider} @click=${this._handlePick}>
<paper-item-body>${provider.name}</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
`
)}
`;
}
private _handlePick(ev) {
fireEvent(this, "pick-auth-provider", ev.currentTarget.auth_provider);
}
}
customElements.define("ha-pick-auth-provider", HaPickAuthProvider);