Merge remote-tracking branch 'origin/main' into feature/trashed-context

This commit is contained in:
Niels Lyngsø
2024-05-02 12:36:38 +02:00
20 changed files with 295 additions and 162 deletions

View File

@@ -36,7 +36,9 @@ There are two ways to use this:
"CMS": {
"Security":{
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/"
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error",
},
},
[...]

View File

@@ -1,19 +1,12 @@
import {
UMB_AUTH_CONTEXT,
UMB_MODAL_APP_AUTH,
UMB_STORAGE_REDIRECT_URL,
type UmbUserLoginState,
} from '@umbraco-cms/backoffice/auth';
import { UMB_AUTH_CONTEXT, UMB_MODAL_APP_AUTH, type UmbUserLoginState } from '@umbraco-cms/backoffice/auth';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { filter, firstValueFrom, skip } from '@umbraco-cms/backoffice/external/rxjs';
import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import type { ManifestAuthProvider } from '@umbraco-cms/backoffice/extension-registry';
export class UmbAppAuthController extends UmbControllerBase {
#authContext?: typeof UMB_AUTH_CONTEXT.TYPE;
#firstTimeLoggingIn = true;
constructor(host: UmbControllerHost) {
super(host);
@@ -23,14 +16,8 @@ export class UmbAppAuthController extends UmbControllerBase {
// Observe the user's authorization state and start the authorization flow if the user is not authorized
this.observe(
context.isAuthorized.pipe(
// Skip the first since it is always false
skip(1),
// Only continue if the value is false
filter((x) => !x),
),
context.timeoutSignal,
() => {
this.#firstTimeLoggingIn = false;
this.makeAuthorizationRequest('timedOut');
},
'_authState',
@@ -66,11 +53,6 @@ export class UmbAppAuthController extends UmbControllerBase {
throw new Error('[Fatal] Auth context is not available');
}
// Save location.href so we can redirect to it after login
if (location.href !== this.#authContext.getPostLogoutRedirectUrl()) {
window.sessionStorage.setItem(UMB_STORAGE_REDIRECT_URL, location.href);
}
// Figure out which providers are available
const availableProviders = await firstValueFrom(this.#authContext.getAuthProviders(umbExtensionsRegistry));
@@ -80,7 +62,7 @@ export class UmbAppAuthController extends UmbControllerBase {
// If the user is timed out, we can show the login modal directly
if (userLoginState === 'timedOut') {
const selected = await this.#showLoginModal(userLoginState, availableProviders);
const selected = await this.#showLoginModal(userLoginState);
if (!selected) {
return false;
@@ -91,7 +73,7 @@ export class UmbAppAuthController extends UmbControllerBase {
if (availableProviders.length === 1) {
// One provider available (most likely the Umbraco provider), so initiate the authorization request to the default provider
this.#authContext.makeAuthorizationRequest(availableProviders[0].forProviderName);
await this.#authContext.makeAuthorizationRequest(availableProviders[0].forProviderName, true);
return this.#updateState();
}
@@ -103,12 +85,12 @@ export class UmbAppAuthController extends UmbControllerBase {
if (redirectProvider) {
// Redirect directly to the provider
this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName);
await this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName, true);
return this.#updateState();
}
// Show the provider selection screen
const selected = await this.#showLoginModal(userLoginState, availableProviders);
const selected = await this.#showLoginModal(userLoginState);
if (!selected) {
return false;
@@ -117,45 +99,32 @@ export class UmbAppAuthController extends UmbControllerBase {
return this.#updateState();
}
async #showLoginModal(
userLoginState: UmbUserLoginState,
availableProviders: Array<ManifestAuthProvider>,
): Promise<boolean> {
async #showLoginModal(userLoginState: UmbUserLoginState): Promise<boolean> {
if (!this.#authContext) {
throw new Error('[Fatal] Auth context is not available');
}
// Check if any provider denies local login
const denyLocalLogin = availableProviders.some((provider) => provider.meta?.behavior?.denyLocalLogin);
if (denyLocalLogin) {
// Unregister the Umbraco provider
umbExtensionsRegistry.unregister('Umb.AuthProviders.Umbraco');
}
// Show the provider selection screen
const authModalKey = 'umbAuthModal';
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
modalManager.remove('umbAuthModal');
const selected = await modalManager
.open(this._host, UMB_MODAL_APP_AUTH, {
data: {
userLoginState,
},
modal: {
key: 'umbAuthModal',
backdropBackground: this.#firstTimeLoggingIn
? 'var(--umb-auth-backdrop, rgb(244, 244, 244))'
: 'var(--umb-auth-backdrop-timedout, rgba(244, 244, 244, 0.75))',
key: authModalKey,
backdropBackground: 'var(--umb-auth-backdrop, rgb(244, 244, 244))',
},
})
.onSubmit()
.catch(() => undefined);
if (!selected?.providerName) {
if (!selected?.success) {
return false;
}
this.#authContext.makeAuthorizationRequest(selected.providerName, selected.loginHint);
return true;
}
@@ -164,9 +133,6 @@ export class UmbAppAuthController extends UmbControllerBase {
throw new Error('[Fatal] Auth context is not available');
}
// Reinitialize the auth flow (load the state from local storage)
this.#authContext.setInitialState();
// The authorization flow is finished, so let the caller know if the user is authorized
return this.#authContext.getIsAuthorized();
}

View File

@@ -32,6 +32,14 @@ export class UmbAppErrorElement extends UmbLitElement {
@property()
error?: unknown;
/**
* Hide the back button
*
* @attr
*/
@property({ type: Boolean, attribute: 'hide-back-button' })
hideBackButton = false;
constructor() {
super();
@@ -168,11 +176,15 @@ export class UmbAppErrorElement extends UmbLitElement {
<div id="container" class="uui-text">
<uui-box id="box" headline-variant="h1">
<uui-button
slot="header-actions"
label=${this.localize.term('general_back')}
look="secondary"
@click=${() => (location.href = '')}></uui-button>
${this.hideBackButton
? nothing
: html`
<uui-button
slot="header-actions"
label=${this.localize.term('general_back')}
look="secondary"
@click=${() => (location.href = '')}></uui-button>
`}
<div slot="headline">
${this.errorHeadline
? this.errorHeadline

View File

@@ -17,6 +17,7 @@ import {
UmbAppEntryPointExtensionInitializer,
umbExtensionsRegistry,
} from '@umbraco-cms/backoffice/extension-registry';
import { filter, first, firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
@customElement('umb-app')
export class UmbAppElement extends UmbLitElement {
@@ -57,6 +58,27 @@ export class UmbAppElement extends UmbLitElement {
path: 'install',
component: () => import('../installer/installer.element.js'),
},
{
path: 'oauth_complete',
component: () => import('./app-error.element.js'),
setup: (component) => {
const searchParams = new URLSearchParams(window.location.search);
const hasCode = searchParams.has('code');
(component as UmbAppErrorElement).hideBackButton = true;
(component as UmbAppErrorElement).errorHeadline = this.localize.term('general_login');
(component as UmbAppErrorElement).errorMessage = hasCode
? this.localize.term('errors_externalLoginSuccess')
: this.localize.term('errors_externalLoginFailed');
// Complete the authorization request
this.#authContext?.completeAuthorizationRequest().finally(() => {
// If we don't have an opener, redirect to the root
if (!window.opener) {
//history.replaceState(null, '', '');
}
});
},
},
{
path: 'upgrade',
component: () => import('../upgrader/upgrader.element.js'),
@@ -67,6 +89,17 @@ export class UmbAppElement extends UmbLitElement {
resolve: () => {
this.#authContext?.clearTokenStorage();
this.#authController.makeAuthorizationRequest('loggedOut');
// Listen for the user to be authorized
this.#authContext?.isAuthorized
.pipe(
filter((x) => !!x),
first(),
)
.subscribe(() => {
// Redirect to the root
history.replaceState(null, '', '');
});
},
},
{
@@ -86,7 +119,6 @@ export class UmbAppElement extends UmbLitElement {
OpenAPI.BASE = window.location.origin;
new UmbBundleExtensionInitializer(this, umbExtensionsRegistry);
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
new UUIIconRegistryEssential().attach(this);
@@ -109,6 +141,8 @@ export class UmbAppElement extends UmbLitElement {
// Register public extensions (login extensions)
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
const initializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
await firstValueFrom(initializer.loaded);
// Try to initialise the auth flow and get the runtime status
try {
@@ -161,15 +195,6 @@ export class UmbAppElement extends UmbLitElement {
}
#redirect() {
// If there is a ?code parameter in the url, then we are in the middle of the oauth flow
// and we need to complete the login (the authorization notifier will redirect after this is done
// essentially hitting this method again)
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.has('code')) {
this.#authContext?.completeAuthorizationRequest();
return;
}
switch (this.#serverConnection?.getStatus()) {
case RuntimeLevelModel.INSTALL:
history.replaceState(null, '', 'install');

View File

@@ -712,7 +712,9 @@ export default {
unauthorized: 'Du har ikke tilladelse til at udføre denne handling',
userNotFound: 'Den angivne bruger blev ikke fundet i databasen',
externalInfoNotFound: 'Serveren kunne ikke kommunikere med den eksterne loginudbyder',
externalLoginFailed: 'Serveren mislykkedes i at logge ind med den eksterne loginudbyder',
externalLoginFailed:
'Serveren mislykkedes i at logge ind med den eksterne loginudbyder. Luk dette vindue og prøv igen.',
externalLoginSuccess: 'Du er nu logget ind. Du kan nu lukke dette vindue.',
},
openidErrors: {
accessDenied: 'Access denied',

View File

@@ -717,7 +717,9 @@ export default {
unauthorized: 'You were not authorized before performing this action',
userNotFound: 'The local user was not found in the database',
externalInfoNotFound: 'The server did not succeed in communicating with the external login provider',
externalLoginFailed: 'The server failed to authorize you against the external login provider',
externalLoginFailed:
'The server failed to authorize you against the external login provider. Please close the window and try again.',
externalLoginSuccess: 'You have successfully logged in. You may now close this window.',
},
openidErrors: {
accessDenied: 'Access denied',

View File

@@ -727,7 +727,9 @@ export default {
unauthorized: 'You were not authorized before performing this action',
userNotFound: 'The local user was not found in the database',
externalInfoNotFound: 'The server did not succeed in communicating with the external login provider',
externalLoginFailed: 'The server failed to authorize you against the external login provider',
externalLoginFailed:
'The server failed to authorize you against the external login provider. Please close the window and try again.',
externalLoginSuccess: 'You have successfully logged in. You may now close this window.',
},
openidErrors: {
accessDenied: 'Access denied',

View File

@@ -69,11 +69,11 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
this.storageBackend.setItem(authorizationServiceConfigurationKey(handle), JSON.stringify(configuration.toJson())),
]);
persisted.then(() => {
return persisted.then(() => {
// make the redirect request
const url = this.buildRequestUrl(configuration, request);
log('Making a request to ', request, url);
this.locationLike.assign(url);
return url;
});
}

View File

@@ -18,4 +18,5 @@ export {
filter,
startWith,
skip,
first,
} from 'rxjs';

View File

@@ -4,6 +4,7 @@ import type { SpecificManifestTypeOrManifestBase } from '../types/map.types.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbElement } from '@umbraco-cms/backoffice/element-api';
import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
import { ReplaySubject } from '@umbraco-cms/backoffice/external/rxjs';
/**
* Base class for extension initializers, which are responsible for loading and unloading extensions.
@@ -15,12 +16,14 @@ export abstract class UmbExtensionInitializerBase<
protected host;
protected extensionRegistry;
#extensionMap = new Map();
#loaded = new ReplaySubject<void>(1);
loaded = this.#loaded.asObservable();
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<T>, manifestType: Key) {
super(host);
this.host = host;
this.extensionRegistry = extensionRegistry;
this.observe(extensionRegistry.byType<Key, T>(manifestType), (extensions) => {
this.observe(extensionRegistry.byType<Key, T>(manifestType), async (extensions) => {
this.#extensionMap.forEach((existingExt) => {
if (!extensions.find((b) => b.alias === existingExt.alias)) {
this.unloadExtension(existingExt);
@@ -28,11 +31,15 @@ export abstract class UmbExtensionInitializerBase<
}
});
extensions.forEach((extension) => {
if (this.#extensionMap.has(extension.alias)) return;
this.#extensionMap.set(extension.alias, extension);
this.instantiateExtension(extension);
});
await Promise.all(
extensions.map((extension) => {
if (this.#extensionMap.has(extension.alias)) return;
this.#extensionMap.set(extension.alias, extension);
return this.instantiateExtension(extension);
}),
);
this.#loaded.next();
});
}

View File

@@ -13,7 +13,7 @@
* License for the specific language governing permissions and limitations under
* the License.
*/
import { UMB_STORAGE_REDIRECT_URL, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js';
import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js';
import type { LocationLike, StringMap } from '@umbraco-cms/backoffice/external/openid';
import {
BaseTokenRequestHandler,
@@ -30,6 +30,7 @@ import {
TokenRequest,
TokenResponse,
} from '@umbraco-cms/backoffice/external/openid';
import { Subject } from '@umbraco-cms/backoffice/external/rxjs';
const requestor = new FetchRequestor();
@@ -92,19 +93,28 @@ export class UmbAuthFlow {
readonly #postLogoutRedirectUri: string;
readonly #clientId: string;
readonly #scope: string;
readonly #timeoutSignal;
// tokens
#tokenResponse?: TokenResponse;
/**
* This signal will emit when the authorization flow is complete.
* @remark It will also emit if there is an error during the authorization flow.
*/
readonly authorizationSignal = new Subject<void>();
constructor(
openIdConnectUrl: string,
redirectUri: string,
postLogoutRedirectUri: string,
timeoutSignal: Subject<void>,
clientId = 'umbraco-back-office',
scope = 'offline_access',
) {
this.#redirectUri = redirectUri;
this.#postLogoutRedirectUri = postLogoutRedirectUri;
this.#timeoutSignal = timeoutSignal;
this.#clientId = clientId;
this.#scope = scope;
@@ -118,11 +128,7 @@ export class UmbAuthFlow {
this.#notifier = new AuthorizationNotifier();
this.#tokenHandler = new BaseTokenRequestHandler(requestor);
this.#storageBackend = new LocalStorageBackend();
this.#authorizationHandler = new RedirectRequestHandler(
this.#storageBackend,
new UmbNoHashQueryStringUtils(),
window.location,
);
this.#authorizationHandler = new RedirectRequestHandler(this.#storageBackend, new UmbNoHashQueryStringUtils());
// set notifier to deliver responses
this.#authorizationHandler.setAuthorizationNotifier(this.#notifier);
@@ -131,6 +137,7 @@ export class UmbAuthFlow {
this.#notifier.setAuthorizationListener(async (request, response, error) => {
if (error) {
console.error('Authorization error', error);
this.authorizationSignal.next();
throw error;
}
@@ -143,16 +150,9 @@ export class UmbAuthFlow {
await this.#makeTokenRequest(response.code, codeVerifier);
await this.performWithFreshTokens();
await this.#saveTokenState();
// Redirect to the saved state or root
let currentRoute = '/';
const savedRoute = sessionStorage.getItem(UMB_STORAGE_REDIRECT_URL);
if (savedRoute) {
sessionStorage.removeItem(UMB_STORAGE_REDIRECT_URL);
currentRoute = savedRoute;
}
history.replaceState(null, '', currentRoute);
}
this.authorizationSignal.next();
});
}
@@ -173,8 +173,6 @@ export class UmbAuthFlow {
const response = new TokenResponse(JSON.parse(tokenResponseJson));
if (response.isValid()) {
this.#tokenResponse = response;
} else {
this.signOut();
}
}
}
@@ -196,7 +194,7 @@ export class UmbAuthFlow {
* @param identityProvider The identity provider to use for the authorization request.
* @param usernameHint (Optional) The username to use for the authorization request. It will be provided to the OpenID server as a hint.
*/
makeAuthorizationRequest(identityProvider: string, usernameHint?: string): void {
makeAuthorizationRequest(identityProvider: string, usernameHint?: string) {
const extras: StringMap = { prompt: 'consent', access_type: 'offline' };
// If the identity provider is not 'Umbraco', we will add it to the extras.
@@ -223,7 +221,7 @@ export class UmbAuthFlow {
true,
);
this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request);
return this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request);
}
/**
@@ -311,9 +309,9 @@ export class UmbAuthFlow {
return Promise.resolve(this.#tokenResponse.accessToken);
}
// if the refresh token is not set (maybe the provider doesn't support them), sign out
// if the refresh token is not set (maybe the provider doesn't support them)
if (!this.#tokenResponse?.refreshToken) {
this.signOut();
this.#timeoutSignal.next();
return Promise.reject('Missing refreshToken.');
}
@@ -328,9 +326,12 @@ export class UmbAuthFlow {
await this.#performTokenRequest(request);
return this.#tokenResponse
? Promise.resolve(this.#tokenResponse.accessToken)
: Promise.reject('Missing accessToken.');
if (!this.#tokenResponse) {
this.#timeoutSignal.next();
return Promise.reject('Missing tokenResponse.');
}
return Promise.resolve(this.#tokenResponse.accessToken);
}
/**
@@ -373,9 +374,8 @@ export class UmbAuthFlow {
try {
this.#tokenResponse = await this.#tokenHandler.performTokenRequest(this.#configuration, request);
} catch (error) {
// If the token request fails, it means the refresh token is invalid, so we sign the user out.
// If the token request fails, it means the refresh token is invalid
console.error('Token request error', error);
this.signOut();
}
}
}

View File

@@ -1,41 +1,128 @@
import type { UmbBackofficeExtensionRegistry, ManifestAuthProvider } from '../extension-registry/index.js';
import { UmbAuthFlow } from './auth-flow.js';
import { UMB_AUTH_CONTEXT } from './auth.context.token.js';
import { UMB_AUTH_CONTEXT, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js';
import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js';
import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
import { ReplaySubject, filter, switchMap } from '@umbraco-cms/backoffice/external/rxjs';
import { ReplaySubject, Subject, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs';
export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
#isAuthorized = new UmbBooleanState<boolean>(false);
readonly isAuthorized = this.#isAuthorized.asObservable();
#isInitialized = new ReplaySubject<boolean>(1);
readonly isInitialized = this.#isInitialized.asObservable().pipe(filter((isInitialized) => isInitialized));
// Timeout is different from `isAuthorized` because it can occur repeatedly
#isTimeout = new Subject<void>();
/**
* Observable that emits true when the auth context is initialized.
* @remark It will only emit once and then complete itself.
*/
#isInitialized = new ReplaySubject<void>(1);
#isBypassed = false;
#serverUrl;
#backofficePath;
#authFlow;
#authWindowProxy?: WindowProxy | null;
#previousAuthUrl?: string;
/**
* Observable that emits true if the user is authorized, otherwise false.
* @remark It will only emit when the authorization state changes.
*/
readonly isAuthorized = this.#isAuthorized.asObservable();
/**
* Observable that acts as a signal and emits when the user has timed out, i.e. the token has expired.
* This can be used to show a timeout message to the user.
* @remark It can emit multiple times if more than one request is made after the token has expired.
*/
readonly timeoutSignal = this.#isTimeout.asObservable();
/**
* Observable that acts as a signal for when the authorization state changes.
*/
get authorizationSignal() {
return this.#authFlow.authorizationSignal;
}
constructor(host: UmbControllerHost, serverUrl: string, backofficePath: string, isBypassed: boolean) {
super(host, UMB_AUTH_CONTEXT);
this.#isBypassed = isBypassed;
this.#serverUrl = serverUrl;
this.#backofficePath = backofficePath;
this.#authFlow = new UmbAuthFlow(serverUrl, this.getRedirectUrl(), this.getPostLogoutRedirectUrl());
this.#authFlow = new UmbAuthFlow(
serverUrl,
this.getRedirectUrl(),
this.getPostLogoutRedirectUrl(),
this.#isTimeout,
);
// Observe the authorization signal and close the auth window
this.observe(
this.authorizationSignal,
() => {
// Update the authorization state
this.getIsAuthorized();
},
'_authFlowAuthorizationSignal',
);
// Observe changes to local storage and update the authorization state
// This establishes the tab-to-tab communication
window.addEventListener('storage', this.#onStorageEvent.bind(this));
}
destroy(): void {
super.destroy();
window.removeEventListener('storage', this.#onStorageEvent.bind(this));
}
async #onStorageEvent(evt: StorageEvent) {
if (evt.key === UMB_STORAGE_TOKEN_RESPONSE_NAME) {
// Close any open auth windows
this.#authWindowProxy?.close();
// Refresh the local storage state into memory
await this.setInitialState();
// Let any auth listeners (such as the auth modal) know that the auth state has changed
this.authorizationSignal.next();
}
}
/**
* Initiates the login flow.
* @param identityProvider The provider to use for login. Default is 'Umbraco'.
* @param redirect If true, the user will be redirected to the login page.
* @param usernameHint The username hint to use for login.
* @param manifest The manifest for the registered provider.
*/
makeAuthorizationRequest(identityProvider = 'Umbraco', usernameHint?: string) {
return this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint);
async makeAuthorizationRequest(
identityProvider = 'Umbraco',
redirect?: boolean,
usernameHint?: string,
manifest?: ManifestAuthProvider,
) {
const redirectUrl = await this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint);
if (redirect) {
location.href = redirectUrl;
return;
}
const popupTarget = manifest?.meta?.behavior?.popupTarget ?? 'umbracoAuthPopup';
const popupFeatures =
manifest?.meta?.behavior?.popupFeatures ??
'width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no';
if (!this.#authWindowProxy || this.#authWindowProxy.closed) {
this.#authWindowProxy = window.open(redirectUrl, popupTarget, popupFeatures);
} else if (this.#previousAuthUrl !== redirectUrl) {
this.#authWindowProxy = window.open(redirectUrl, popupTarget);
this.#authWindowProxy?.focus();
}
this.#previousAuthUrl = redirectUrl;
return firstValueFrom(this.authorizationSignal);
}
/**
@@ -103,6 +190,7 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
timeOut() {
this.clearTokenStorage();
this.#isAuthorized.setValue(false);
this.#isTimeout.next();
}
/**
@@ -159,17 +247,18 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
}
setInitialized() {
this.#isInitialized.next(true);
this.#isInitialized.next();
this.#isInitialized.complete();
}
getAuthProviders(extensionsRegistry: UmbBackofficeExtensionRegistry) {
return this.isInitialized.pipe(
return this.#isInitialized.pipe(
switchMap(() => extensionsRegistry.byType<'authProvider', ManifestAuthProvider>('authProvider')),
);
}
getRedirectUrl() {
return `${window.location.origin}${this.#backofficePath}`;
return `${window.location.origin}${this.#backofficePath}oauth_complete`;
}
getPostLogoutRedirectUrl() {

View File

@@ -1,5 +1,5 @@
import type { ManifestAuthProvider } from '../../extension-registry/models/index.js';
import type { UmbAuthProviderDefaultProps } from '../types.js';
import type { UmbAuthProviderDefaultProps, UmbUserLoginState } from '../types.js';
import { UmbLitElement } from '../../lit-element/lit-element.element.js';
import { UmbTextStyles } from '../../style/index.js';
import { css, customElement, html, nothing, property } from '@umbraco-cms/backoffice/external/lit';
@@ -7,10 +7,11 @@ import { css, customElement, html, nothing, property } from '@umbraco-cms/backof
@customElement('umb-auth-provider-default')
export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbAuthProviderDefaultProps {
@property({ attribute: false })
manifest!: ManifestAuthProvider;
userLoginState?: UmbUserLoginState | undefined;
@property({ attribute: false })
onSubmit!: (providerName: string, loginHint?: string) => void;
manifest!: ManifestAuthProvider;
@property({ attribute: false })
onSubmit!: (manifestOrProviderName: string | ManifestAuthProvider, loginHint?: string) => void;
connectedCallback(): void {
super.connectedCallback();
@@ -21,7 +22,7 @@ export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbA
return html`
<uui-button
type="button"
@click=${() => this.onSubmit(this.manifest.forProviderName)}
@click=${() => this.onSubmit(this.manifest)}
id="auth-provider-button"
.label=${this.manifest.meta?.label ?? this.manifest.forProviderName}
.look=${this.manifest.meta?.defaultView?.look ?? 'outline'}

View File

@@ -1,14 +1,20 @@
import type { ManifestAuthProvider } from '../../extension-registry/models/auth-provider.model.js';
import { UmbModalBaseElement } from '../../modal/index.js';
import { UmbTextStyles } from '../../style/text-style.style.js';
import { UMB_AUTH_CONTEXT } from '../auth.context.token.js';
import type { UmbAuthProviderDefaultProps } from '../types.js';
import type { UmbModalAppAuthConfig, UmbModalAppAuthValue } from './umb-app-auth-modal.token.js';
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-app-auth-modal')
export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthConfig, UmbModalAppAuthValue> {
get props() {
@state()
private _error?: string;
get props(): UmbAuthProviderDefaultProps {
return {
userLoginState: this.data?.userLoginState ?? 'loggingIn',
onSubmit: this.onSubmit,
onSubmit: this.onSubmit.bind(this),
};
}
@@ -30,7 +36,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
render() {
return html`
<div id="layout" modal-on-top=${this.data?.userLoginState === 'timedOut' ? 'true' : 'false'}>
<div id="layout">
<img
id="logo-on-background"
src="/umbraco/backoffice/assets/umbraco_logo_blue.svg"
@@ -63,6 +69,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
<header id="header">
<h1 id="greeting">${this.headline}</h1>
</header>
${this._error ? html`<p style="margin-top:0;color:red">${this._error}</p>` : ''}
${this.data?.userLoginState === 'timedOut'
? html`<p style="margin-top:0">${this.localize.term('login_timeout')}</p>`
: ''}
@@ -77,9 +84,30 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
`;
}
private onSubmit = (providerName: string, loginHint?: string) => {
this.value = { providerName, loginHint };
this._submitModal();
private onSubmit = async (providerOrManifest: string | ManifestAuthProvider, loginHint?: string) => {
try {
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
if (!authContext) {
throw new Error('Auth context not available');
}
const manifest = typeof providerOrManifest === 'string' ? undefined : providerOrManifest;
const providerName =
typeof providerOrManifest === 'string' ? providerOrManifest : providerOrManifest.forProviderName;
await authContext.makeAuthorizationRequest(providerName, false, loginHint, manifest);
const isAuthed = authContext.getIsAuthorized();
this.value = { success: isAuthed };
if (isAuthed) {
this._submitModal();
} else {
this._error = 'Failed to authenticate';
}
} catch (error) {
console.error('[AuthModal] Error submitting auth request', error);
this._error = error instanceof Error ? error.message : 'Unknown error (see console)';
}
};
static styles = [
@@ -182,23 +210,6 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
gap: var(--uui-size-space-5);
}
#layout[modal-on-top='true'] {
width: auto;
height: auto;
min-height: 327px;
background: #fff;
border: 1px solid var(--uui-color-border);
padding: 0;
}
[modal-on-top='true'] #graphic {
display: none;
}
[modal-on-top='true'] #greeting {
font-size: 2rem;
}
@media (max-width: 900px) {
#graphic {
display: none;

View File

@@ -7,16 +7,10 @@ export type UmbModalAppAuthConfig = {
export type UmbModalAppAuthValue = {
/**
* The name of the provider that the user has selected to authenticate with.
* An indicator of whether the authentication was successful.
* @required
*/
providerName?: string;
/**
* The login hint that the user has provided to the provider.
* @optional
*/
loginHint?: string;
success?: boolean;
};
export const UMB_MODAL_APP_AUTH = new UmbModalToken<UmbModalAppAuthConfig, UmbModalAppAuthValue>('Umb.Modal.AppAuth', {

View File

@@ -21,6 +21,13 @@ export interface UmbAuthProviderDefaultProps {
* Callback that is called when the user selects a provider.
* @param providerName The name of the provider that the user selected.
* @param loginHint The login hint to use for login if available.
* @deprecated Use the manifest parameter instead.
*/
onSubmit: (providerName: string, loginHint?: string) => void;
onSubmit(providerName: string, loginHint?: string): void;
/**
* Callback that is called when the user selects a provider.
* @param manifest The manifest of the provider that the user selected.
*/
onSubmit(manifest: ManifestAuthProvider, loginHint?: string): void;
}

View File

@@ -32,7 +32,7 @@ export class UmbAppEntryPointExtensionInitializer extends UmbExtensionInitialize
// If the extension has known exports, be sure to run those
if (hasInitExport(moduleInstance)) {
moduleInstance.onInit(this.host, this.extensionRegistry);
await moduleInstance.onInit(this.host, this.extensionRegistry);
}
}
}

View File

@@ -32,7 +32,7 @@ export class UmbBackofficeEntryPointExtensionInitializer extends UmbExtensionIni
// If the extension has known exports, be sure to run those
if (hasInitExport(moduleInstance)) {
moduleInstance.onInit(this.host, this.extensionRegistry);
await moduleInstance.onInit(this.host, this.extensionRegistry);
}
}
}

View File

@@ -54,17 +54,26 @@ export interface MetaAuthProvider {
* The behavior of the provider when it is used.
*/
behavior?: {
/**
* If true, the Umbraco backoffice login will be disabled.
* @default false
*/
denyLocalLogin?: boolean;
/**
* If true, the user will be redirected to the provider's login page immediately.
* @default false
*/
autoRedirect?: boolean;
/**
* The target of the popup that is opened when the user logs in.
* @default 'umbracoAuthPopup'
* @remarks This is the name of the window that is opened when the user logs in, use `_blank` to open in a new tab.
*/
popupTarget?: string;
/**
* The features of the popup that is opened when the user logs in.
* @default 'width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no'
* @remarks This is the features of the window that is opened when the user logs in.
* @seehref https://developer.mozilla.org/en-US/docs/Web/API/Window/open#features
*/
popupFeatures?: string;
};
/**

View File

@@ -60,9 +60,12 @@ export class UmbServerModelValidationContext
if (!this.#isValid) {
// We are missing some typing here, but we will just go wild with 'as any': [NL]
const readErrorBody = (error as any).body;
Object.keys(readErrorBody.errors).forEach((path) => {
this.#serverFeedback.push({ path, messages: readErrorBody.errors[path] });
});
// Check if there are validation errors, since the error might be a generic ApiError
if (readErrorBody?.errors) {
Object.keys(readErrorBody.errors).forEach((path) => {
this.#serverFeedback.push({ path, messages: readErrorBody.errors[path] });
});
}
}
this.#validatePromiseResolve?.();