Merge remote-tracking branch 'origin/v13/dev' into release/13.0

This commit is contained in:
Bjarke Berg
2023-10-31 15:10:49 +01:00
5 changed files with 412 additions and 263 deletions

View File

@@ -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));
}

View File

@@ -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`
<umb-auth-layout background-image=${ifDefined(this.backgroundImage)} logo-image=${ifDefined(this.logoImage)}>
(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`
<umb-auth-layout
background-image=${ifDefined(this.backgroundImage)}
logo-image=${ifDefined(this.logoImage)}>
${this._renderFlowAndStatus()}
</umb-auth-layout>
`;
}
}
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` <umb-error-layout
header="Hi there"
message=${until(umbLocalizationContext.localize('login_resetCodeExpired'), 'The link you have clicked on is invalid or has expired')}>
</umb-error-layout>`;
}
if (status === 'resetCodeExpired') {
return html` <umb-error-layout
header="Hi there"
message=${until(
umbLocalizationContext.localize('login_resetCodeExpired', undefined, 'The link you have clicked on is invalid or has expired')
)}>
</umb-error-layout>`;
}
if (flow === 'invite-user' && status === 'false') {
return html` <umb-error-layout
header="Hi there"
message=${until(umbLocalizationContext.localize('user_userinviteExpiredMessage'), 'Welcome to Umbraco! Unfortunately your invite has expired. Please contact your administrator and ask them to resend it.')}>
</umb-error-layout>`;
}
if (flow === 'invite-user' && status === 'false') {
return html` <umb-error-layout
header="Hi there"
message=${until(
umbLocalizationContext.localize('user_userinviteExpiredMessage', undefined, 'Welcome to Umbraco! Unfortunately your invite has expired. Please contact your administrator and ask them to resend it.'),
)}>
</umb-error-layout>`;
}
// 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`<umb-mfa-page></umb-mfa-page>`;
case 'reset':
return html`<umb-reset-password-page></umb-reset-password-page>`;
case 'reset-password':
return html`<umb-new-password-page></umb-new-password-page>`;
case 'invite-user':
return html`<umb-invite-page></umb-invite-page>`;
switch (flow) {
case 'mfa':
return html`<umb-mfa-page></umb-mfa-page>`;
case 'reset':
return html`<umb-reset-password-page></umb-reset-password-page>`;
case 'reset-password':
return html`<umb-new-password-page></umb-new-password-page>`;
case 'invite-user':
return html`<umb-invite-page></umb-invite-page>`;
default:
return html`<umb-login-page
?allow-password-reset=${this.allowPasswordReset}
?username-is-email=${this.usernameIsEmail}>
<slot name="subheadline" slot="subheadline"></slot>
<slot name="external" slot="external"></slot>
</umb-login-page>`;
}
}
default:
return html`<umb-login-page
?allow-password-reset=${this.allowPasswordReset}
?username-is-email=${this.usernameIsEmail}>
<slot></slot>
<slot name="subheadline" slot="subheadline"></slot>
<slot name="external" slot="external"></slot>
</umb-login-page>`;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-auth': UmbAuthElement;
}
interface HTMLElementTagNameMap {
'umb-auth': UmbAuthElement;
}
}

View File

@@ -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;
}
}

View File

@@ -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`
<h1 id="greeting" class="uui-h3">
<umb-localize .key=${this.#greetingLocalizationKey}></umb-localize>
</h1>
<slot name="subheadline"></slot>
${this.disableLocalLogin
? nothing
: html`
<uui-form>
<form id="LoginForm" name="login" @submit="${this.#handleSubmit}">
<uui-form-layout-item>
<uui-label id="emailLabel" for="umb-username" slot="label" required>
${this.usernameIsEmail
? html`<umb-localize key="general_email">Email</umb-localize>`
: html`<umb-localize key="user_username">Name</umb-localize>`}
</uui-label>
<uui-input
type=${this.usernameIsEmail ? 'email' : 'text'}
id="umb-username"
name="email"
autocomplete=${this.usernameIsEmail
? 'username'
: 'email'}
.label=${this.usernameIsEmail
? until(umbLocalizationContext.localize('general_email', undefined, 'Email'))
: until(umbLocalizationContext.localize('user_username', undefined, 'Username'))}
required
required-message=${until(umbLocalizationContext.localize('general_required', undefined, 'Required'))}></uui-input>
</uui-form-layout-item>
? nothing
: html`
<slot @slotchange=${this.#onSlotChanged}></slot>
<div id="secondary-actions">
${when(
umbAuthContext.supportsPersistLogin,
() => html`<uui-form-layout-item>
<uui-checkbox
name="persist"
.label=${until(umbLocalizationContext.localize('user_rememberMe', undefined, 'Remember me'))}>
<umb-localize key="user_rememberMe">Remember me</umb-localize>
</uui-checkbox>
</uui-form-layout-item>`
)}
${when(
this.allowPasswordReset,
() =>
html`<button type="button" id="forgot-password" @click=${this.#handleForgottenPassword}>
<umb-localize key="login_forgottenPassword">Forgotten password?</umb-localize>
</button>`
)}
</div>
<uui-button
type="submit"
id="umb-login-button"
look="primary"
@click=${this.#onSubmitClick}
.label=${until(umbLocalizationContext.localize('general_login', undefined, 'Login'), 'Login')}
color="default"
.state=${this._loginState}></uui-button>
<uui-form-layout-item>
<uui-label id="passwordLabel" for="umb-password" slot="label" required>
<umb-localize key="user_password">Password</umb-localize>
</uui-label>
<uui-input-password
id="umb-password"
name="password"
autocomplete="current-password"
.label=${until(umbLocalizationContext.localize('user_password', undefined, 'Password'))}
required
required-message=${until(umbLocalizationContext.localize('general_required', undefined, 'Required'))}></uui-input-password>
</uui-form-layout-item>
<div id="secondary-actions">
${when(
umbAuthContext.supportsPersistLogin,
() => html`<uui-form-layout-item>
<uui-checkbox name="persist" .label=${until(umbLocalizationContext.localize('user_rememberMe', undefined, 'Remember me'))}>
<umb-localize key="user_rememberMe">Remember me</umb-localize>
</uui-checkbox>
</uui-form-layout-item>`
)}
${when(
this.allowPasswordReset,
() =>
html`<button type="button" id="forgot-password" @click=${this.#handleForgottenPassword}>
<umb-localize key="login_forgottenPassword">Forgotten password?</umb-localize>
</button>`
)}
</div>
${this.#renderErrorMessage()}
<uui-button
type="submit"
id="umb-login-button"
look="primary"
.label=${until(umbLocalizationContext.localize('general_login', undefined, 'Login'))}
color="default"
.state=${this._loginState}></uui-button>
</form>
</uui-form>
${this.#renderErrorMessage()}
`}
<umb-external-login-providers-layout .showDivider=${!this.disableLocalLogin}>
<slot name="external"></slot>
</umb-external-login-providers-layout>
`;
}
}
#renderErrorMessage() {
if (!this._loginError || this._loginState !== 'failed') return nothing;
#renderErrorMessage() {
if (!this._loginError || this._loginState !== 'failed') return nothing;
return html`<span class="text-error text-danger">${this._loginError}</span>`;
}
return html`<span class="text-error text-danger">${this._loginError}</span>`;
}
#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;
}
}

View File

@@ -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';