From 2e1fb9df66a3dc7575b3614f26ff87adefad1e71 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 27 Nov 2023 23:06:51 +0100 Subject: [PATCH] Update design of login page (#18770) --- src/auth/ha-auth-flow.ts | 121 +++++++----- src/auth/ha-authorize.ts | 176 +++++++++++------ src/auth/ha-local-auth-flow.ts | 302 ++++++++++++++++++++++-------- src/auth/ha-pick-auth-provider.ts | 36 +++- src/data/auth.ts | 50 +++++ src/html/authorize.html.template | 49 +++-- src/translations/en.json | 10 +- 7 files changed, 535 insertions(+), 209 deletions(-) diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index d954417ca59f..35cbbaf81314 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -8,7 +8,14 @@ import "../components/ha-alert"; import "../components/ha-checkbox"; import { computeInitialHaFormData } from "../components/ha-form/compute-initial-ha-form-data"; import "../components/ha-formfield"; -import { AuthProvider, autocompleteLoginFields } from "../data/auth"; +import { + AuthProvider, + autocompleteLoginFields, + createLoginFlow, + deleteLoginFlow, + redirectWithAuthCode, + submitLoginFlow, +} from "../data/auth"; import { DataEntryFlowStep, DataEntryFlowStepForm, @@ -86,6 +93,25 @@ export class HaAuthFlow extends LitElement { margin-top: 10px; margin-left: -16px; } + a.forgot-password { + color: var(--primary-color); + text-decoration: none; + font-size: 0.875rem; + } + .space-between { + display: flex; + justify-content: space-between; + align-items: center; + } + form { + text-align: center; + max-width: 336px; + width: 100%; + } + ha-auth-form { + display: block; + margin-top: 16px; + }
${this._renderForm()}
`; @@ -189,6 +215,11 @@ export class HaAuthFlow extends LitElement { `; case "form": return html` +

+ ${!["select_mfa_module", "mfa"].includes(step.step_id) + ? this.localize("ui.panel.page-authorize.welcome_home") + : this.localize("ui.panel.page-authorize.just_checking")} +

${this._computeStepDescription(step)} - - + ` : ""} `; @@ -225,10 +269,7 @@ export class HaAuthFlow extends LitElement { 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) => { + deleteLoginFlow(this.step.flow_id).catch((err) => { // eslint-disable-next-line no-console console.error("Error delete obsoleted auth flow", err); }); @@ -243,22 +284,21 @@ export class HaAuthFlow extends LitElement { } 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 response = await createLoginFlow(this.clientId, this.redirectUri, [ + newProvider.type, + newProvider.id, + ]); const data = await response.json(); if (response.ok) { // allow auth provider bypass the login form if (data.type === "create_entry") { - this._redirect(data.result); + redirectWithAuthCode( + this.redirectUri!, + data.result, + this.oauth2State + ); return; } @@ -276,27 +316,6 @@ export class HaAuthFlow extends LitElement { } } - 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)}`; - } - if (this.storeToken) { - url += `&storeToken=true`; - } - - document.location.assign(url); - } - private _stepDataChanged(ev: CustomEvent) { this._stepData = ev.detail.value; } @@ -345,11 +364,7 @@ export class HaAuthFlow extends LitElement { 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 response = await submitLoginFlow(this.step.flow_id, postData); const newStep = await response.json(); @@ -360,7 +375,11 @@ export class HaAuthFlow extends LitElement { } if (newStep.type === "create_entry") { - this._redirect(newStep.result); + redirectWithAuthCode( + this.redirectUri!, + newStep.result, + this.oauth2State + ); return; } this.step = newStep; diff --git a/src/auth/ha-authorize.ts b/src/auth/ha-authorize.ts index 45e1e0ae8e95..5701354dc236 100644 --- a/src/auth/ha-authorize.ts +++ b/src/auth/ha-authorize.ts @@ -5,6 +5,7 @@ import punycode from "punycode"; import { applyThemesOnElement } from "../common/dom/apply_themes_on_element"; import { extractSearchParamsObject } from "../common/url/search-params"; import "../components/ha-alert"; +import "../components/ha-language-picker"; import { AuthProvider, AuthUrlSearchParams, @@ -71,19 +72,7 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { `; } - if (!this._authProviders) { - return html` - -

${this.localize("ui.panel.page-authorize.initializing")}

- `; - } - - const inactiveProviders = this._authProviders.filter( + const inactiveProviders = this._authProviders?.filter( (prv) => prv !== this._authProvider ); @@ -92,13 +81,16 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { return html` ${!this._ownInstance @@ -123,44 +164,58 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { >`, })} ` - : html`

${this.localize("ui.panel.page-authorize.authorizing")}

`} - ${!this._forceDefaultLogin && - this._authProvider!.users && - this.clientId != null && - this.redirectUri != null - ? html`` - : html`${inactiveProviders.length > 0 - ? html`

- ${this.localize("ui.panel.page-authorize.logging_in_with", { - authProviderName: html`${this._authProvider!.name}`, - })} -

` - : nothing} - - ${inactiveProviders.length > 0 - ? html` - - ` - : ""}`} + : nothing} + +
+ ${!this._authProvider + ? html`

+ ${this.localize("ui.panel.page-authorize.initializing")} +

` + : !this._forceDefaultLogin && + this._authProvider!.users && + this.clientId != null && + this.redirectUri != null + ? html`` + : html` + ${inactiveProviders!.length > 0 + ? html` + + ` + : ""}`} +
+ `; } @@ -266,4 +321,15 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { private async _handleAuthProviderPick(ev) { this._authProvider = ev.detail; } + + private _languageChanged(ev: CustomEvent) { + const language = ev.detail.value; + this.language = language; + + try { + localStorage.setItem("selectedLanguage", JSON.stringify(language)); + } catch (err: any) { + // Ignore + } + } } diff --git a/src/auth/ha-local-auth-flow.ts b/src/auth/ha-local-auth-flow.ts index ce15e0405679..eeae4c6d413d 100644 --- a/src/auth/ha-local-auth-flow.ts +++ b/src/auth/ha-local-auth-flow.ts @@ -1,16 +1,25 @@ /* eslint-disable lit/prefer-static-styles */ import "@material/mwc-button"; +import { mdiEye, mdiEyeOff } from "@mdi/js"; import { html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; import { LocalizeFunc } from "../common/translations/localize"; import "../components/ha-alert"; +import "../components/ha-button"; +import "../components/ha-icon-button"; import "../components/user/ha-person-badge"; -import { AuthProvider } from "../data/auth"; +import { + AuthProvider, + createLoginFlow, + deleteLoginFlow, + redirectWithAuthCode, + submitLoginFlow, +} from "../data/auth"; +import { DataEntryFlowStep } from "../data/data_entry_flow"; import { listPersons } from "../data/person"; import "./ha-auth-textfield"; import type { HaAuthTextField } from "./ha-auth-textfield"; -import { DataEntryFlowStep } from "../data/data_entry_flow"; -import { fireEvent } from "../common/dom/fire_event"; @customElement("ha-local-auth-flow") export class HaLocalAuthFlow extends LitElement { @@ -24,6 +33,8 @@ export class HaLocalAuthFlow extends LitElement { @property() public oauth2State?: string; + @property({ type: Boolean }) public ownInstance = false; + @property() public localize!: LocalizeFunc; @state() private _error?: string; @@ -36,6 +47,8 @@ export class HaLocalAuthFlow extends LitElement { @state() private _selectedUser?: string; + @state() private _unmaskedPassword = false; + createRenderRoot() { return this; } @@ -52,43 +65,122 @@ export class HaLocalAuthFlow extends LitElement { if (!this.authProvider?.users || !this._persons) { return nothing; } + const userIds = Object.keys(this.authProvider.users); return html` ${this._error ? html`${this._error}` @@ -117,12 +209,40 @@ export class HaLocalAuthFlow extends LitElement { .value=${this.authProvider.users[this._selectedUser]} /> + name="password" + .label=${this.localize( + "ui.panel.page-authorize.form.providers.homeassistant.step.init.data.password" + )} + required + autoValidate + autocomplete + iconTrailing + validationMessage="Required" + > + + -
- + + ${this.localize("ui.panel.page-authorize.form.previous")} + +
+ ` - : html`
- ${Object.keys(this.authProvider.users).map((userId) => { + : html`

+ ${this.localize("ui.panel.page-authorize.welcome_home")} +

+

+ ${this.localize("ui.panel.page-authorize.who_is_logging_in")} +

+
+ ${userIds.map((userId) => { const person = this._persons![userId]; return html`

${person.name}

`; })}
- - Other options - - `} +
+ +
`} `; } @@ -176,24 +320,41 @@ export class HaLocalAuthFlow extends LitElement { this._persons = await (await listPersons()).json(); } + private _restart() { + this._selectedUser = undefined; + } + + private _toggleUnmaskedPassword() { + this._unmaskedPassword = !this._unmaskedPassword; + } + + private _handleKeyUp(ev: KeyboardEvent) { + if (ev.key === "Enter" || ev.key === " ") { + this._personSelected(ev); + } + } + private async _personSelected(ev) { const userId = ev.currentTarget.userId; - if (this.authProviders?.find((prv) => prv.type === "trusted_networks")) { + if ( + this.ownInstance && + this.authProviders?.find((prv) => prv.type === "trusted_networks") + ) { try { - const flowResponse = await fetch("/auth/login_flow", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify({ - client_id: this.clientId, - handler: ["trusted_networks", null], - redirect_uri: this.redirectUri, - }), - }); + const flowResponse = await createLoginFlow( + this.clientId, + this.redirectUri, + ["trusted_networks", null] + ); const data = await flowResponse.json(); if (data.type === "create_entry") { - this._redirect(data.result); + redirectWithAuthCode( + this.redirectUri!, + data.result, + this.oauth2State + ); return; } @@ -204,27 +365,24 @@ export class HaLocalAuthFlow extends LitElement { const postData = { user: userId, client_id: this.clientId }; - const response = await fetch(`/auth/login_flow/${data.flow_id}`, { - method: "POST", - credentials: "same-origin", - body: JSON.stringify(postData), - }); + const response = await submitLoginFlow(data.flow_id, postData); if (response.ok) { const result = await response.json(); if (result.type === "create_entry") { - this._redirect(result.result); + redirectWithAuthCode( + this.redirectUri!, + result.result, + this.oauth2State + ); return; } } else { throw new Error("Invalid response"); } } catch { - fetch(`/auth/login_flow/${data.flow_id}`, { - method: "DELETE", - credentials: "same-origin", - }).catch((err) => { + deleteLoginFlow(data.flow_id).catch((err) => { // eslint-disable-next-line no-console console.error("Error delete obsoleted auth flow", err); }); @@ -243,17 +401,14 @@ export class HaLocalAuthFlow extends LitElement { return; } + this._error = undefined; this._submitting = true; - const flowResponse = await fetch("/auth/login_flow", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify({ - client_id: this.clientId, - handler: ["homeassistant", null], - redirect_uri: this.redirectUri, - }), - }); + const flowResponse = await createLoginFlow( + this.clientId, + this.redirectUri, + ["homeassistant", null] + ); const data = await flowResponse.json(); @@ -265,11 +420,7 @@ export class HaLocalAuthFlow extends LitElement { }; try { - const response = await fetch(`/auth/login_flow/${data.flow_id}`, { - method: "POST", - credentials: "same-origin", - body: JSON.stringify(postData), - }); + const response = await submitLoginFlow(data.flow_id, postData); const newStep = await response.json(); @@ -279,7 +430,11 @@ export class HaLocalAuthFlow extends LitElement { } if (newStep.type === "create_entry") { - this._redirect(newStep.result); + redirectWithAuthCode( + this.redirectUri!, + newStep.result, + this.oauth2State + ); return; } @@ -287,38 +442,25 @@ export class HaLocalAuthFlow extends LitElement { this._error = this.localize( `ui.panel.page-authorize.form.providers.homeassistant.error.${newStep.errors.base}` ); - return; + throw new Error(this._error); } this._step = newStep; - } catch (err: any) { - // eslint-disable-next-line no-console - console.error("Error submitting step", err); - this._error = this.localize("ui.panel.page-authorize.form.unknown_error"); + } catch { + deleteLoginFlow(data.flow_id).catch((err) => { + // eslint-disable-next-line no-console + console.error("Error delete obsoleted auth flow", err); + }); + if (!this._error) { + this._error = this.localize( + "ui.panel.page-authorize.form.unknown_error" + ); + } } finally { this._submitting = false; } } - 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)}`; - } - url += `&storeToken=true`; - - document.location.assign(url); - } - private _otherLogin() { fireEvent(this, "default-login-flow"); } diff --git a/src/auth/ha-pick-auth-provider.ts b/src/auth/ha-pick-auth-provider.ts index 01878a1c2236..90a5c238b67b 100644 --- a/src/auth/ha-pick-auth-provider.ts +++ b/src/auth/ha-pick-auth-provider.ts @@ -21,7 +21,11 @@ export class HaPickAuthProvider extends LitElement { protected render() { return html` -

${this.localize("ui.panel.page-authorize.pick_auth_provider")}:

+

+ ${this.localize("ui.panel.page-authorize.pick_auth_provider")} +

${this.authProviders.map( (provider) => html` @@ -45,12 +49,34 @@ export class HaPickAuthProvider extends LitElement { } static styles = css` - p { - margin-top: 0; + h3 { + margin: 0 -16px; + position: relative; + z-index: 1; + text-align: center; + font-size: 14px; + font-weight: 400; + line-height: 20px; + } + h3:before { + border-top: 1px solid var(--divider-color); + content: ""; + margin: 0 auto; + position: absolute; + top: 50%; + left: 0; + right: 0; + bottom: 0; + width: 100%; + z-index: -1; + } + h3 span { + background: var(--card-background-color); + padding: 0 15px; } mwc-list { - margin: 0 -16px; - --mdc-list-side-padding: 16px; + margin: 16px -16px 0; + --mdc-list-side-padding: 24px; } `; } diff --git a/src/data/auth.ts b/src/data/auth.ts index 17309f491be8..ea7d6f2666b3 100644 --- a/src/data/auth.ts +++ b/src/data/auth.ts @@ -49,6 +49,56 @@ export const fetchAuthProviders = () => credentials: "same-origin", }); +export const createLoginFlow = ( + client_id: string | undefined, + redirect_uri: string | undefined, + handler: (string | null)[] +) => + fetch("/auth/login_flow", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify({ + client_id, + handler, + redirect_uri, + }), + }); + +export const submitLoginFlow = (flow_id: string, data: Record) => + fetch(`/auth/login_flow/${flow_id}`, { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(data), + }); + +export const deleteLoginFlow = (flow_id) => + fetch(`/auth/login_flow/${flow_id}`, { + method: "DELETE", + credentials: "same-origin", + }); + +export const redirectWithAuthCode = ( + url: string, + authCode: string, + oauth2State: string | undefined +) => { + // OAuth 2: 3.1.2 we need to retain query component of a redirect URI + if (!url.includes("?")) { + url += "?"; + } else if (!url.endsWith("&")) { + url += "&"; + } + + url += `code=${encodeURIComponent(authCode)}`; + + if (oauth2State) { + url += `&state=${encodeURIComponent(oauth2State)}`; + } + url += `&storeToken=true`; + + document.location.assign(url); +}; + export const createAuthForUser = async ( hass: HomeAssistant, userId: string, diff --git a/src/html/authorize.html.template b/src/html/authorize.html.template index 2770e1a32a31..11724890bf73 100644 --- a/src/html/authorize.html.template +++ b/src/html/authorize.html.template @@ -5,26 +5,45 @@ <%= renderTemplate("_header.html.template") %> <%= renderTemplate("_style_base.html.template") %> @@ -32,11 +51,7 @@
- - - - - + Home Assistant
diff --git a/src/translations/en.json b/src/translations/en.json index 8392b17f2f7c..7367bb8bc8fb 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5579,16 +5579,24 @@ "authorizing": "Log in to your Home Assistant instance", "authorizing_app": "You're about to give the Home Assistant Companion app for {app} access to your Home Assistant instance.", "authorizing_client": "You're about to give {clientId} access to your Home Assistant instance.", - "logging_in_with": "Logging in with {authProviderName}.", "pick_auth_provider": "Or log in with", "abort_intro": "Login aborted", "store_token": "Keep me logged in", + "help": "Help", + "welcome_home": "Welcome home!", + "just_checking": "Just checking", + "who_is_logging_in": "Who is logging in?", + "other_options": "Other login options", + "forgot_password": "Forgot password?", "form": { "working": "Please wait", "unknown_error": "Something went wrong", "next": "Log in", + "previous": "Previous", "start_over": "Start over", "error": "Error: {error}", + "hide_password": "Hide password", + "show_password": "Show password", "providers": { "command_line": { "step": {