Move auth components from shadow DOM to light DOM (#18015)

This commit is contained in:
Paul Bottein 2023-09-26 20:17:03 +02:00 committed by GitHub
parent acb32ae5c8
commit d8c98d8f96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 392 additions and 269 deletions

View File

@ -1,19 +1,12 @@
/* eslint-disable lit/prefer-static-styles */
import "@material/mwc-button";
import { genClientId } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-checkbox";
import { computeInitialHaFormData } from "../components/ha-form/compute-initial-ha-form-data";
import "../components/ha-form/ha-form";
import "../components/ha-formfield";
import "../components/ha-markdown";
import { AuthProvider, autocompleteLoginFields } from "../data/auth";
@ -21,7 +14,7 @@ import {
DataEntryFlowStep,
DataEntryFlowStepForm,
} from "../data/data_entry_flow";
import "./ha-password-manager-polyfill";
import "./ha-auth-form";
type State = "loading" | "error" | "step";
@ -49,6 +42,10 @@ export class HaAuthFlow extends LitElement {
@state() private _storeToken = false;
createRenderRoot() {
return this;
}
willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@ -79,13 +76,17 @@ export class HaAuthFlow extends LitElement {
protected render() {
return html`
<style>
.action {
margin: 24px 0 8px;
text-align: center;
}
.store-token {
margin-top: 10px;
margin-left: -16px;
}
</style>
<form>${this._renderForm()}</form>
<ha-password-manager-polyfill
.step=${this._step}
.stepData=${this._stepData}
@form-submitted=${this._handleSubmit}
@value-changed=${this._stepDataChanged}
></ha-password-manager-polyfill>
`;
}
@ -128,12 +129,6 @@ export class HaAuthFlow extends LitElement {
(form as any).focus();
}
}, 100);
setTimeout(() => {
this.renderRoot.querySelector(
"ha-password-manager-polyfill"
)!.boundingRect = this.getBoundingClientRect();
}, 500);
}
private _renderForm() {
@ -205,7 +200,7 @@ export class HaAuthFlow extends LitElement {
></ha-markdown>
`
: nothing}
<ha-form
<ha-auth-form
.data=${this._stepData}
.schema=${autocompleteLoginFields(step.data_schema)}
.error=${step.errors}
@ -213,7 +208,7 @@ export class HaAuthFlow extends LitElement {
.computeLabel=${this._computeLabelCallback(step)}
.computeError=${this._computeErrorCallback(step)}
@value-changed=${this._stepDataChanged}
></ha-form>
></ha-auth-form>
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
@ -395,20 +390,6 @@ export class HaAuthFlow extends LitElement {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return css`
.action {
margin: 24px 0 8px;
text-align: center;
}
/* Align with the rest of the form. */
.store-token {
margin-top: 10px;
margin-left: -16px;
}
`;
}
}
declare global {

View File

@ -0,0 +1,69 @@
/* eslint-disable lit/prefer-static-styles */
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators";
import { HaFormString } from "../components/ha-form/ha-form-string";
import "../components/ha-icon-button";
import "./ha-auth-textfield";
@customElement("ha-auth-form-string")
export class HaAuthFormString extends HaFormString {
protected createRenderRoot() {
// add parent style to light dom
const style = document.createElement("style");
style.innerHTML = HaFormString.elementStyles as unknown as string;
this.append(style);
return this;
}
protected render(): TemplateResult {
return html`
<style>
ha-auth-form-string {
display: block;
position: relative;
}
ha-auth-form-string[own-margin] {
margin-bottom: 5px;
}
ha-auth-textfield {
display: block !important;
}
</style>
<ha-auth-textfield
.type=${
!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"
}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
.suffix=${
this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix
}
.validationMessage=${this.schema.required ? "Required" : undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-auth-textfield>
${this.renderIcon()}
</ha-auth-textfield>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-auth-form-string": HaAuthFormString;
}
}

30
src/auth/ha-auth-form.ts Normal file
View File

@ -0,0 +1,30 @@
/* eslint-disable lit/prefer-static-styles */
import { customElement } from "lit/decorators";
import { HaForm } from "../components/ha-form/ha-form";
import "./ha-auth-form-string";
@customElement("ha-auth-form")
export class HaAuthForm extends HaForm {
protected fieldElementName(type: string): string {
if (type === "string") {
return `ha-auth-form-${type}`;
}
return super.fieldElementName(type);
}
protected createRenderRoot() {
// add parent style to light dom
const style = document.createElement("style");
style.innerHTML = HaForm.elementStyles as unknown as string;
this.append(style);
// attach it as soon as possible to make sure we fetch all events.
this.addValueChangedListener(this);
return this;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-auth-form": HaAuthForm;
}
}

View File

@ -0,0 +1,186 @@
/* eslint-disable lit/prefer-static-styles */
import { html } from "lit";
import { customElement } from "lit/decorators";
import { HaTextField } from "../components/ha-textfield";
import "@material/mwc-textfield/mwc-textfield.css";
@customElement("ha-auth-textfield")
export class HaAuthTextField extends HaTextField {
public render() {
return html`
<style>
ha-auth-textfield {
display: inline-flex;
flex-direction: column;
outline: none;
}
ha-auth-textfield:not([disabled]):hover
:not(.mdc-text-field--invalid):not(.mdc-text-field--focused)
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-outlined-hover-border-color,
rgba(0, 0, 0, 0.87)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--outlined) {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-error-color,
var(--mdc-theme-error, #b00020)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
+ .mdc-text-field-helper-line
.mdc-text-field-character-counter,
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
.mdc-text-field__icon {
color: var(
--mdc-text-field-error-color,
var(--mdc-theme-error, #b00020)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label,
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label::after {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused
mwc-notched-outline {
--mdc-notched-outline-stroke-width: 2px;
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-focused-label-color,
var(--mdc-theme-primary, rgba(98, 0, 238, 0.87))
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
.mdc-floating-label {
color: #6200ee;
color: var(--mdc-theme-primary, #6200ee);
}
ha-auth-textfield:not([disabled])
.mdc-text-field
.mdc-text-field__input {
color: var(--mdc-text-field-ink-color, rgba(0, 0, 0, 0.87));
}
ha-auth-textfield:not([disabled])
.mdc-text-field
.mdc-text-field__input::placeholder {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield:not([disabled])
.mdc-text-field-helper-line
.mdc-text-field-helper-text:not(
.mdc-text-field-helper-text--validation-msg
),
ha-auth-textfield:not([disabled])
.mdc-text-field-helper-line:not(.mdc-text-field--invalid)
.mdc-text-field-character-counter {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--outlined) {
background-color: var(--mdc-text-field-disabled-fill-color, #fafafa);
}
ha-auth-textfield[disabled]
.mdc-text-field.mdc-text-field--outlined
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-outlined-disabled-border-color,
rgba(0, 0, 0, 0.06)
);
}
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label,
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label::after {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield[disabled] .mdc-text-field .mdc-text-field__input,
ha-auth-textfield[disabled]
.mdc-text-field
.mdc-text-field__input::placeholder {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield[disabled]
.mdc-text-field-helper-line
.mdc-text-field-helper-text,
ha-auth-textfield[disabled]
.mdc-text-field-helper-line
.mdc-text-field-character-counter {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
.mdc-floating-label {
color: var(--mdc-theme-primary, #6200ee);
}
ha-auth-textfield[no-spinner] input::-webkit-outer-spin-button,
ha-auth-textfield[no-spinner] input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
ha-auth-textfield[no-spinner] input[type="number"] {
-moz-appearance: textfield;
}
</style>
${super.render()}
`;
}
protected createRenderRoot() {
// add parent style to light dom
const style = document.createElement("style");
style.innerHTML = HaTextField.elementStyles as unknown as string;
this.append(style);
return this;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-auth-textfield": HaAuthTextField;
}
}

View File

@ -1,13 +1,7 @@
import punycode from "punycode";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
/* eslint-disable lit/prefer-static-styles */
import { html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import punycode from "punycode";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import { extractSearchParamsObject } from "../common/url/search-params";
import "../components/ha-alert";
@ -61,13 +55,27 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
protected render() {
if (this._error) {
return html`<ha-alert alert-type="error"
>${this._error} ${this.redirectUri}</ha-alert
>`;
return html`
<style>
ha-authorize ha-alert {
display: block;
margin: 16px 0;
}
</style>
<ha-alert alert-type="error"
>${this._error} ${this.redirectUri}</ha-alert
>
`;
}
if (!this._authProviders) {
return html`
<style>
ha-authorize p {
font-size: 14px;
line-height: 20px;
}
</style>
<p>${this.localize("ui.panel.page-authorize.initializing")}</p>
`;
}
@ -79,6 +87,25 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
const app = this.clientId && this.clientId in appNames;
return html`
<style>
ha-pick-auth-provider {
display: block;
margin-top: 48px;
}
ha-auth-flow {
display: block;
margin-top: 24px;
}
ha-alert {
display: block;
margin: 16px 0;
}
p {
font-size: 14px;
line-height: 20px;
}
</style>
${!this._ownInstance
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
${app
@ -123,6 +150,10 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
`;
}
createRenderRoot() {
return this;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
@ -217,25 +248,4 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
private async _handleAuthProviderPick(ev) {
this._authProvider = ev.detail;
}
static get styles(): CSSResultGroup {
return css`
ha-pick-auth-provider {
display: block;
margin-top: 48px;
}
ha-auth-flow {
display: block;
margin-top: 24px;
}
ha-alert {
display: block;
margin: 16px 0;
}
p {
font-size: 14px;
line-height: 20px;
}
`;
}
}

View File

@ -1,172 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import type { HaFormSchema } from "../components/ha-form/types";
import { autocompleteLoginFields } from "../data/auth";
import type { DataEntryFlowStep } from "../data/data_entry_flow";
declare global {
interface HTMLElementTagNameMap {
"ha-password-manager-polyfill": HaPasswordManagerPolyfill;
}
interface HASSDomEvents {
"form-submitted": undefined;
}
}
const ENABLED_HANDLERS = [
"homeassistant",
"legacy_api_password",
"command_line",
];
@customElement("ha-password-manager-polyfill")
export class HaPasswordManagerPolyfill extends LitElement {
@property({ attribute: false }) public step?: DataEntryFlowStep;
@property({ attribute: false }) public stepData: any;
@property({ attribute: false }) public boundingRect?: DOMRect;
private _styleElement?: HTMLStyleElement;
public connectedCallback() {
super.connectedCallback();
this._styleElement = document.createElement("style");
this._styleElement.textContent = css`
/* Polyfill form is sized and vertically aligned with true form, then positioned offscreen
rather than hiding so it does not create a new stacking context */
.password-manager-polyfill {
position: absolute;
box-sizing: border-box;
}
/* Excluding our wrapper, move any children back on screen, including anything injected that might not already be positioned */
.password-manager-polyfill > *:not(.wrapper),
.password-manager-polyfill > .wrapper > * {
position: relative;
left: 10000px;
}
/* Size and hide our polyfill fields */
.password-manager-polyfill .underneath {
display: block;
box-sizing: border-box;
width: 100%;
padding: 0 16px;
border: 0;
z-index: -1;
height: 21px;
/* Transparency is only needed to hide during paint or in case of misalignment,
but LastPass will fail if it's 0, so we use 1% */
opacity: 0.01;
}
.password-manager-polyfill input.underneath {
height: 28px;
margin-bottom: 30.5px;
}
/* Button position is not important, but size should not be zero */
.password-manager-polyfill > input.underneath[type="submit"] {
width: 1px;
height: 1px;
margin: 0 auto;
overflow: hidden;
}
/* Ensure injected elements will be on top */
.password-manager-polyfill > *:not(.underneath, .wrapper),
.password-manager-polyfill > .wrapper > *:not(.underneath) {
isolation: isolate;
z-index: auto;
}
`.toString();
document.head.append(this._styleElement);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._styleElement?.remove();
delete this._styleElement;
}
protected createRenderRoot() {
// Add under document body so the element isn't placed inside any shadow roots
return document.body;
}
protected render() {
if (
this.step &&
this.step.type === "form" &&
this.step.step_id === "init" &&
ENABLED_HANDLERS.includes(this.step.handler[0])
) {
return html`
<form
class="password-manager-polyfill"
style=${styleMap({
top: `${this.boundingRect?.y || 148}px`,
left: `calc(50% - ${
(this.boundingRect?.width || 360) / 2
}px - 10000px)`,
width: `${this.boundingRect?.width || 360}px`,
})}
action="/auth"
method="post"
@submit=${this._handleSubmit}
>
${autocompleteLoginFields(this.step.data_schema).map((input) =>
this.render_input(input)
)}
<input
type="submit"
value="Login"
class="underneath"
tabindex="-2"
aria-hidden="true"
/>
</form>
`;
}
return nothing;
}
private render_input(schema: HaFormSchema) {
const inputType = schema.name.includes("password") ? "password" : "text";
if (schema.type !== "string") {
return "";
}
return html`
<!-- Label is a sibling so it can be stacked underneath without affecting injections adjacent to input (e.g. LastPass) -->
<label for=${schema.name} class="underneath" aria-hidden="true">
${schema.name}
</label>
<!-- LastPass fails if the input is hidden directly, so we trick it and hide a wrapper instead -->
<div class="wrapper" aria-hidden="true">
<!-- LastPass fails with tabindex of -1, so we trick with -2 -->
<input
class="underneath"
tabindex="-2"
.id=${schema.name}
.name=${schema.name}
.type=${inputType}
.value=${this.stepData[schema.name] || ""}
.autocomplete=${schema.autocomplete}
@input=${this._valueChanged}
@change=${this._valueChanged}
/>
</div>
`;
}
private _handleSubmit(ev: SubmitEvent) {
ev.preventDefault();
fireEvent(this, "form-submitted");
}
private _valueChanged(ev: Event) {
const target = ev.target as HTMLInputElement;
this.stepData = { ...this.stepData, [target.id]: target.value };
fireEvent(this, "value-changed", {
value: this.stepData,
});
}
}

View File

@ -1,11 +1,13 @@
/* eslint-disable lit/prefer-static-styles */
import { mdiEye, mdiEyeOff } from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
@ -32,7 +34,7 @@ export class HaFormString extends LitElement implements HaFormElement {
@property({ type: Boolean }) public disabled = false;
@state() private _unmaskedPassword = false;
@state() protected unmaskedPassword = false;
@query("ha-textfield") private _input?: HaTextField;
@ -43,14 +45,11 @@ export class HaFormString extends LitElement implements HaFormElement {
}
protected render(): TemplateResult {
const isPassword = MASKED_FIELDS.some((field) =>
this.schema.name.includes(field)
);
return html`
<ha-textfield
.type=${!isPassword
? this._stringType
: this._unmaskedPassword
.type=${!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"}
.label=${this.label}
@ -62,7 +61,7 @@ export class HaFormString extends LitElement implements HaFormElement {
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
.suffix=${isPassword
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
@ -70,14 +69,19 @@ export class HaFormString extends LitElement implements HaFormElement {
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-textfield>
${isPassword
? html`<ha-icon-button
toggles
.label=${`${this._unmaskedPassword ? "Hide" : "Show"} password`}
@click=${this._toggleUnmaskedPassword}
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>`
: ""}
${this.renderIcon()}
`;
}
protected renderIcon() {
if (!this.isPassword) return nothing;
return html`
<ha-icon-button
toggles
.label=${`${this.unmaskedPassword ? "Hide" : "Show"} password`}
@click=${this.toggleUnmaskedPassword}
.path=${this.unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>
`;
}
@ -87,11 +91,11 @@ export class HaFormString extends LitElement implements HaFormElement {
}
}
private _toggleUnmaskedPassword(): void {
this._unmaskedPassword = !this._unmaskedPassword;
protected toggleUnmaskedPassword(): void {
this.unmaskedPassword = !this.unmaskedPassword;
}
private _valueChanged(ev: Event): void {
protected _valueChanged(ev: Event): void {
let value: string | undefined = (ev.target as HaTextField).value;
if (this.data === value) {
return;
@ -104,7 +108,7 @@ export class HaFormString extends LitElement implements HaFormElement {
});
}
private get _stringType(): string {
protected get stringType(): string {
if (this.schema.format) {
if (["email", "url"].includes(this.schema.format)) {
return this.schema.format;
@ -116,6 +120,10 @@ export class HaFormString extends LitElement implements HaFormElement {
return "text";
}
protected get isPassword(): boolean {
return MASKED_FIELDS.some((field) => this.schema.name.includes(field));
}
static get styles(): CSSResultGroup {
return css`
:host {

View File

@ -1,3 +1,4 @@
/* eslint-disable lit/prefer-static-styles */
import {
css,
CSSResultGroup,
@ -135,7 +136,7 @@ export class HaForm extends LitElement implements HaFormElement {
.required=${item.required || false}
.context=${this._generateContext(item)}
></ha-selector>`
: dynamicElement(`ha-form-${item.type}`, {
: dynamicElement(this.fieldElementName(item.type), {
schema: item,
data: getValue(this.data, item),
label: this._computeLabel(item, this.data),
@ -152,6 +153,10 @@ export class HaForm extends LitElement implements HaFormElement {
`;
}
protected fieldElementName(type: string): string {
return `ha-form-${type}`;
}
private _generateContext(
schema: HaFormSchema
): Record<string, any> | undefined {
@ -169,10 +174,17 @@ export class HaForm extends LitElement implements HaFormElement {
protected createRenderRoot() {
const root = super.createRenderRoot();
// attach it as soon as possible to make sure we fetch all events.
root.addEventListener("value-changed", (ev) => {
this.addValueChangedListener(root);
return root;
}
protected addValueChangedListener(element: Element | ShadowRoot) {
element.addEventListener("value-changed", (ev) => {
ev.stopPropagation();
const schema = (ev.target as HaFormElement).schema as HaFormSchema;
if (ev.target === this) return;
const newValue = !schema.name
? ev.detail.value
: { [schema.name]: ev.detail.value };
@ -181,7 +193,6 @@ export class HaForm extends LitElement implements HaFormElement {
value: { ...this.data, ...newValue },
});
});
return root;
}
private _computeLabel(schema: HaFormSchema, data: HaFormDataContainer) {

View File

@ -53,7 +53,7 @@
<path d="M1824.98 272.606C1819.35 273.952 1813.59 274.696 1807.8 274.826C1797.84 274.826 1790.08 272.09 1784.51 266.616C1778.94 261.143 1776.16 253.006 1776.16 242.206V193.996H1761.02V173.996H1776.16V143.216H1799.59V173.976H1822.35V193.976H1799.59V237.976C1799.59 248.643 1803.73 253.976 1812 253.976C1815.42 253.833 1818.8 253.14 1822 251.926L1824.98 272.606Z" class="wordmark"/>
</svg>
</div>
<ha-authorize><p>Initializing</p></ha-authorize>
<ha-authorize></ha-authorize>
</div>
<%= renderTemplate("_js_base.html.template") %>
<%= renderTemplate("_preload_roboto.html.template") %>