diff --git a/src/Umbraco.Web.UI.Login/src/auth-styles.css b/src/Umbraco.Web.UI.Login/src/auth-styles.css new file mode 100644 index 0000000000..457c7b2c26 --- /dev/null +++ b/src/Umbraco.Web.UI.Login/src/auth-styles.css @@ -0,0 +1,34 @@ +body { + margin: 0; + padding: 0; +} +#umb-login-form umb-login-input { + width: 100%; + height: 38px; + box-sizing: border-box; + display: block; + border: 1px solid var(--uui-color-border); + border-radius: var(--uui-border-radius); + outline: none; + background-color: var(--uui-color-surface); +} +#umb-login-form umb-login-input input { + width: 100%; + height: 100%; + display: block; + box-sizing: border-box; + border: none; + background: none; + outline: none; + padding: var(--uui-size-1, 3px) var(--uui-size-space-3, 9px); +} +#umb-login-form uui-form-layout-item { + margin-top: var(--uui-size-space-4); + margin-bottom: var(--uui-size-space-4); +} +#umb-login-form umb-login-input:focus-within { + border-color: var(--uui-input-border-color-focus, var(--uui-color-border-emphasis, #a1a1a1)); +} +#umb-login-form umb-login-input:hover:not(:focus-within) { + border-color: var(--uui-input-border-color-hover, var(--uui-color-border-standalone, #c2c2c2)); +} diff --git a/src/Umbraco.Web.UI.Login/src/auth.element.ts b/src/Umbraco.Web.UI.Login/src/auth.element.ts index 96738588c7..dc3490a6a6 100644 --- a/src/Umbraco.Web.UI.Login/src/auth.element.ts +++ b/src/Umbraco.Web.UI.Login/src/auth.element.ts @@ -5,121 +5,247 @@ import { until } from 'lit/directives/until.js'; import { umbAuthContext } from './context/auth.context.js'; import { umbLocalizationContext } from './external/localization/localization-context.js'; +import { UmbLocalizeElement } from './external/localization/localize.element.js'; +import type { UmbLoginInputElement } from './components/login-input.element.js'; +import { InputType, UUIFormLayoutItemElement, UUILabelElement } from '@umbraco-ui/uui'; + +import authStyles from './auth-styles.css?inline'; + +const createInput = (opts: {id: string, type: InputType, name: string, autocomplete: AutoFill, requiredMessage: string, label: string, inputmode: string}) => { + const input = document.createElement('umb-login-input'); + input.type = opts.type; + input.name = opts.name; + input.autocomplete = opts.autocomplete; + input.id = opts.id; + input.required = true; + input.requiredMessage = opts.requiredMessage; + input.label = opts.label; + input.spellcheck = false; + input.inputMode = opts.inputmode; + + return input; +}; + +const createLabel = (opts: {forId: string, localizeAlias: string}) => { + const label = document.createElement('uui-label'); + const umbLocalize = document.createElement('umb-localize') as UmbLocalizeElement; + umbLocalize.key = opts.localizeAlias; + label.for = opts.forId; + label.appendChild(umbLocalize); + + return label; +}; + +const createFormLayoutItem = (label: UUILabelElement, input: UmbLoginInputElement) => { + const formLayoutItem = document.createElement('uui-form-layout-item') as UUIFormLayoutItemElement; + formLayoutItem.appendChild(label); + formLayoutItem.appendChild(input); + + return formLayoutItem; +}; + +const createForm = (elements: HTMLElement[]) => { + const styles = document.createElement('style'); + styles.innerHTML = authStyles; + const form = document.createElement('form'); + form.id = 'umb-login-form'; + form.name = 'login-form'; + + elements.push(styles); + elements.forEach((element) => form.appendChild(element)); + + return form; +}; @customElement('umb-auth') export default class UmbAuthElement extends LitElement { - #returnPath = ''; + #returnPath = ''; - /** - * Disables the local login form and only allows external login providers. - * - * @attr disable-local-login - */ - @property({ type: Boolean, attribute: 'disable-local-login' }) - set disableLocalLogin(value: boolean) { - umbAuthContext.disableLocalLogin = value; - } + /** + * Disables the local login form and only allows external login providers. + * + * @attr disable-local-login + */ + @property({ type: Boolean, attribute: 'disable-local-login' }) + set disableLocalLogin(value: boolean) { + umbAuthContext.disableLocalLogin = value; + } - @property({ type: String, attribute: 'background-image' }) - backgroundImage?: string; + @property({ attribute: 'background-image' }) + backgroundImage?: string; - @property({ type: String, attribute: 'logo-image' }) - logoImage?: string; + @property({ attribute: 'logo-image' }) + logoImage?: string; - @property({ type: Boolean, attribute: 'username-is-email' }) - usernameIsEmail = false; + @property({ type: Boolean, attribute: 'username-is-email' }) + usernameIsEmail = false; - @property({ type: Boolean, attribute: 'allow-password-reset' }) - allowPasswordReset = false; + @property({ type: Boolean, attribute: 'allow-password-reset' }) + allowPasswordReset = false; - @property({ type: Boolean, attribute: 'allow-user-invite' }) - allowUserInvite = false; + @property({ type: Boolean, attribute: 'allow-user-invite' }) + allowUserInvite = false; - @property({ type: String, attribute: 'return-url' }) - set returnPath(value: string) { - this.#returnPath = value; - umbAuthContext.returnPath = this.returnPath; - } - get returnPath() { - // Check if there is a ?redir querystring or else return the returnUrl attribute - return new URLSearchParams(window.location.search).get('returnPath') || this.#returnPath; - } + @property({ type: String, attribute: 'return-url' }) + set returnPath(value: string) { + this.#returnPath = value; + umbAuthContext.returnPath = this.returnPath; + } + get returnPath() { + // Check if there is a ?redir querystring or else return the returnUrl attribute + return new URLSearchParams(window.location.search).get('returnPath') || this.#returnPath; + } - /** - * Override the default flow. - */ - protected flow?: 'mfa' | 'reset-password' | 'invite-user'; + /** + * Override the default flow. + */ + protected flow?: 'mfa' | 'reset-password' | 'invite-user'; - constructor() { - super(); - this.classList.add('uui-text'); - this.classList.add('uui-font'); + _form?: HTMLFormElement; + _usernameLayoutItem?: UUIFormLayoutItemElement; + _passwordLayoutItem?: UUIFormLayoutItemElement; + _usernameInput?: UmbLoginInputElement; + _passwordInput?: UmbLoginInputElement; + _usernameLabel?: UUILabelElement; + _passwordLabel?: UUILabelElement; - (this as unknown as EventTarget).addEventListener('umb-login-flow', (e) => { - if (e instanceof CustomEvent) { - this.flow = e.detail.flow || undefined; - } - this.requestUpdate(); - }); - } + constructor() { + super(); + this.classList.add('uui-text'); + this.classList.add('uui-font'); - render() { - return html` - + (this as unknown as EventTarget).addEventListener('umb-login-flow', (e) => { + if (e instanceof CustomEvent) { + this.flow = e.detail.flow || undefined; + } + this.requestUpdate(); + }); + } + + connectedCallback() { + super.connectedCallback(); + + this.#initializeForm(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._usernameLayoutItem?.remove(); + this._passwordLayoutItem?.remove(); + this._usernameLabel?.remove(); + this._usernameInput?.remove(); + this._passwordLabel?.remove(); + this._passwordInput?.remove(); + } + + /** + * Creates the login form and adds it to the DOM in the default slot. + * This is done to avoid having to deal with the shadow DOM, which is not supported in Google Chrome for autocomplete/autofill. + * + * @see Track this intent-to-ship for Chrome https://groups.google.com/a/chromium.org/g/blink-dev/c/RY9leYMu5hI?pli=1 + * @private + */ + async #initializeForm() { + const labelUsername = + this.usernameIsEmail + ? await umbLocalizationContext.localize('general_username', undefined, 'Username') + : await umbLocalizationContext.localize('general_email', undefined, 'Email'); + const labelPassword = await umbLocalizationContext.localize('general_password', undefined, 'Password'); + const requiredMessage = await umbLocalizationContext.localize('general_required', undefined, 'Required'); + + this._usernameInput = createInput({ + id: 'username-input', + type: 'text', + name: 'username', + autocomplete: 'username', + requiredMessage, + label: labelUsername, + inputmode: this.usernameIsEmail ? 'email' : '' + }); + this._passwordInput = createInput({ + id: 'password-input', + type: 'password', + name: 'password', + autocomplete: 'current-password', + requiredMessage, + label: labelPassword, + inputmode: '' + }); + this._usernameLabel = createLabel({ forId: 'username-input', localizeAlias: this.usernameIsEmail ? 'general_email' : 'user_username' }); + this._passwordLabel = createLabel({ forId: 'password-input', localizeAlias: 'user_password' }); + + this._usernameLayoutItem = createFormLayoutItem(this._usernameLabel, this._usernameInput); + this._passwordLayoutItem = createFormLayoutItem(this._passwordLabel, this._passwordInput); + + this._form = createForm([this._usernameLayoutItem, this._passwordLayoutItem]); + + this.insertAdjacentElement('beforeend', this._form); + } + + render() { + return html` + ${this._renderFlowAndStatus()} `; - } + } - private _renderFlowAndStatus() { - const searchParams = new URLSearchParams(window.location.search); - let flow = this.flow || searchParams.get('flow')?.toLowerCase(); - const status = searchParams.get('status'); + private _renderFlowAndStatus() { + const searchParams = new URLSearchParams(window.location.search); + let flow = this.flow || searchParams.get('flow')?.toLowerCase(); + const status = searchParams.get('status'); - if (status === 'resetCodeExpired') { - return html` - `; - } + if (status === 'resetCodeExpired') { + return html` + `; + } - if (flow === 'invite-user' && status === 'false') { - return html` - `; - } + if (flow === 'invite-user' && status === 'false') { + return html` + `; + } - // validate - if (flow) { - if (flow === 'mfa' && !umbAuthContext.isMfaEnabled) { - flow = undefined; - } - } + // validate + if (flow) { + if (flow === 'mfa' && !umbAuthContext.isMfaEnabled) { + flow = undefined; + } + } - switch (flow) { - case 'mfa': - return html``; - case 'reset': - return html``; - case 'reset-password': - return html``; - case 'invite-user': - return html``; + switch (flow) { + case 'mfa': + return html``; + case 'reset': + return html``; + case 'reset-password': + return html``; + case 'invite-user': + return html``; - default: - return html` - - - `; - } - } + default: + return html` + + + + `; + } + } } declare global { - interface HTMLElementTagNameMap { - 'umb-auth': UmbAuthElement; - } + interface HTMLElementTagNameMap { + 'umb-auth': UmbAuthElement; + } } diff --git a/src/Umbraco.Web.UI.Login/src/components/login-input.element.ts b/src/Umbraco.Web.UI.Login/src/components/login-input.element.ts new file mode 100644 index 0000000000..4818c389fe --- /dev/null +++ b/src/Umbraco.Web.UI.Login/src/components/login-input.element.ts @@ -0,0 +1,18 @@ +// make new lit element that extends UUIInputElement + +import { UUIInputElement } from '@umbraco-ui/uui'; +import { customElement } from 'lit/decorators.js'; + +@customElement('umb-login-input') +export class UmbLoginInputElement extends UUIInputElement { + protected createRenderRoot() { + return this; + } + static styles = [...UUIInputElement.styles]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-login-input': UmbLoginInputElement; + } +} diff --git a/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts b/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts index 427aefa31c..ca42466c6b 100644 --- a/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts +++ b/src/Umbraco.Web.UI.Login/src/components/pages/login.page.element.ts @@ -1,6 +1,6 @@ import type { UUIButtonState } from '@umbraco-ui/uui'; import { css, CSSResultGroup, html, LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; import { until } from 'lit/directives/until.js'; @@ -9,225 +9,194 @@ import { umbLocalizationContext } from '../../external/localization/localization @customElement('umb-login-page') export default class UmbLoginPageElement extends LitElement { + @property({ type: Boolean, attribute: 'username-is-email' }) + usernameIsEmail = false; - @property({ type: Boolean, attribute: 'username-is-email' }) - usernameIsEmail = false; + @queryAssignedElements({ flatten: true }) + protected slottedElements?: HTMLFormElement[]; - @property({ type: Boolean, attribute: 'allow-password-reset' }) - allowPasswordReset = false; + @property({ type: Boolean, attribute: 'allow-password-reset' }) + allowPasswordReset = false; - @state() - private _loginState: UUIButtonState = undefined; + @state() + private _loginState: UUIButtonState = undefined; - @state() - private _loginError = ''; + @state() + private _loginError = ''; - @state() - private get disableLocalLogin() { - return umbAuthContext.disableLocalLogin; - } + @state() + private get disableLocalLogin() { + return umbAuthContext.disableLocalLogin; + } - #handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); + #formElement?: HTMLFormElement; - const form = e.target as HTMLFormElement; - if (!form) return; + async #onSlotChanged() { + this.#formElement = this.slottedElements?.[0]; - if (!form.checkValidity()) return; + if (!this.#formElement) return; - const formData = new FormData(form); + this.#formElement.onsubmit = this.#handleSubmit; + } - const username = formData.get('email') as string; - const password = formData.get('password') as string; - const persist = formData.has('persist'); + #handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); - this._loginState = 'waiting'; + const form = e.target as HTMLFormElement; + if (!form) return; - const response = await umbAuthContext.login({ - username, - password, - persist, - }); + if (!form.checkValidity()) return; - this._loginError = response.error || ''; - this._loginState = response.error ? 'failed' : 'success'; + const formData = new FormData(form); - // Check for 402 status code indicating that MFA is required - if (response.status === 402) { - umbAuthContext.isMfaEnabled = true; - if (response.twoFactorView) { - umbAuthContext.twoFactorView = response.twoFactorView; - } + const username = formData.get('username') as string; + const password = formData.get('password') as string; + const persist = formData.has('persist'); - this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'mfa' }})); - return; - } + this._loginState = 'waiting'; - if (response.error) { - this.dispatchEvent(new CustomEvent('umb-login-failed', { bubbles: true, composed: true, detail: response })); - return; - } + const response = await umbAuthContext.login({ + username, + password, + persist, + }); - const returnPath = umbAuthContext.returnPath; + this._loginError = response.error || ''; + this._loginState = response.error ? 'failed' : 'success'; - if (returnPath) { - location.href = returnPath; - } + // Check for 402 status code indicating that MFA is required + if (response.status === 402) { + umbAuthContext.isMfaEnabled = true; + if (response.twoFactorView) { + umbAuthContext.twoFactorView = response.twoFactorView; + } - this.dispatchEvent(new CustomEvent('umb-login-success', { bubbles: true, composed: true, detail: response.data })); - }; + this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'mfa' } })); + return; + } - get #greetingLocalizationKey() { - return [ - 'login_greeting0', - 'login_greeting1', - 'login_greeting2', - 'login_greeting3', - 'login_greeting4', - 'login_greeting5', - 'login_greeting6', - ][new Date().getDay()]; - } + if (response.error) { + this.dispatchEvent(new CustomEvent('umb-login-failed', { bubbles: true, composed: true, detail: response })); + return; + } - render() { - return html` + const returnPath = umbAuthContext.returnPath; + + if (returnPath) { + location.href = returnPath; + } + + this.dispatchEvent(new CustomEvent('umb-login-success', { bubbles: true, composed: true, detail: response.data })); + }; + + get #greetingLocalizationKey() { + return [ + 'login_greeting0', + 'login_greeting1', + 'login_greeting2', + 'login_greeting3', + 'login_greeting4', + 'login_greeting5', + 'login_greeting6', + ][new Date().getDay()]; + } + + #onSubmitClick = () => { + this.#formElement?.requestSubmit(); + }; + + render() { + return html`

${this.disableLocalLogin - ? nothing - : html` - -
- - - ${this.usernameIsEmail - ? html`Email` - : html`Name`} - - - + ? nothing + : html` + +
+ ${when( + umbAuthContext.supportsPersistLogin, + () => html` + + Remember me + + ` + )} + ${when( + this.allowPasswordReset, + () => + html`` + )} +
+ - - - Password - - - - -
- ${when( - umbAuthContext.supportsPersistLogin, - () => html` - - Remember me - - ` - )} - ${when( - this.allowPasswordReset, - () => - html`` - )} -
- - ${this.#renderErrorMessage()} - - -
-
+ ${this.#renderErrorMessage()} `} `; - } + } - #renderErrorMessage() { - if (!this._loginError || this._loginState !== 'failed') return nothing; + #renderErrorMessage() { + if (!this._loginError || this._loginState !== 'failed') return nothing; - return html`${this._loginError}`; - } + return html`${this._loginError}`; + } - #handleForgottenPassword() { - this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'reset' }})); - } + #handleForgottenPassword() { + this.dispatchEvent(new CustomEvent('umb-login-flow', { composed: true, detail: { flow: 'reset' } })); + } - static styles: CSSResultGroup = [ - css` + static styles: CSSResultGroup = [ + css` :host { display: flex; flex-direction: column; } #greeting { + color: var(--uui-color-interactive); text-align: center; - font-weight: 600; - font-size: 1.4rem; - margin: 0 0 var(--uui-size-space-6); - } - - form { - display: flex; - flex-direction: column; - gap: var(--uui-size-space-5); - } - - uui-form-layout-item { - margin: 0; - } - - uui-input, - uui-input-password { - width: 100%; - border-radius: var(--uui-border-radius); + font-weight: 400; + font-size: 1.5rem; + margin: 0 0 var(--uui-size-layout-1); + line-height: 1.2; } #umb-login-button { + margin-top: var(--uui-size-space-4); width: 100%; --uui-button-padding-top-factor: 1.5; --uui-button-padding-bottom-factor: 1.5; } #forgot-password { - cursor: pointer; - background: none; - border: 0; - height: 1rem; - color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */ - gap: var(--uui-size-space-1); - align-self: center; - text-decoration: none; - display: inline-flex; - line-height: 1; - font-size: 14px; - font-family: var(--uui-font-family); + cursor: pointer; + background: none; + border: 0; + height: 1rem; + color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */ + gap: var(--uui-size-space-1); + align-self: center; + text-decoration: none; + display: inline-flex; + line-height: 1; + font-size: 14px; + font-family: var(--uui-font-family); } #forgot-password:hover { @@ -244,11 +213,11 @@ export default class UmbLoginPageElement extends LitElement { justify-content: space-between; } `, - ]; + ]; } declare global { - interface HTMLElementTagNameMap { - 'umb-login-page': UmbLoginPageElement; - } + interface HTMLElementTagNameMap { + 'umb-login-page': UmbLoginPageElement; + } } diff --git a/src/Umbraco.Web.UI.Login/src/index.ts b/src/Umbraco.Web.UI.Login/src/index.ts index b972b395c5..610175a415 100644 --- a/src/Umbraco.Web.UI.Login/src/index.ts +++ b/src/Umbraco.Web.UI.Login/src/index.ts @@ -15,3 +15,5 @@ import './components/external-login-provider.element.js'; import './components/layouts/new-password-layout.element.js'; import './components/layouts/confirmation-layout.element.js'; import './components/layouts/error-layout.element.js'; + +import './components/login-input.element.js';