Files
Umbraco-CMS/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts

298 lines
9.3 KiB
TypeScript

import { onInit } from '../../packages/core/entry-point.js';
import type { UmbAppErrorElement } from './app-error.element.js';
import { UmbAppContext } from './app.context.js';
import { UmbServerConnection } from './server-connection.js';
import { UmbAppAuthController } from './app-auth.controller.js';
import type { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import { UmbAuthContext } from '@umbraco-cms/backoffice/auth';
import { css, html, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UUIIconRegistryEssential } from '@umbraco-cms/backoffice/external/uui';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { Guard, UmbRoute } from '@umbraco-cms/backoffice/router';
import { pathWithoutBasePath } from '@umbraco-cms/backoffice/router';
import { OpenAPI, RuntimeLevelModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbContextDebugController } from '@umbraco-cms/backoffice/debug';
import { UmbBundleExtensionInitializer, UmbServerExtensionRegistrator } from '@umbraco-cms/backoffice/extension-api';
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 {
/**
* The base URL of the configured Umbraco server.
*
* @attr
* @remarks This is the base URL of the Umbraco server, not the base URL of the backoffice.
*/
@property({ type: String })
set serverUrl(url: string) {
OpenAPI.BASE = url;
}
get serverUrl() {
return OpenAPI.BASE;
}
/**
* The base path of the backoffice.
*
* @attr
*/
@property({ type: String })
backofficePath = '/umbraco';
/**
* Bypass authentication.
*/
@property({ type: Boolean })
bypassAuth = false;
private _routes: UmbRoute[] = [
{
path: 'error',
component: () => import('./app-error.element.js'),
},
{
path: 'install',
component: () => import('../installer/installer.element.js'),
},
{
path: 'oauth_complete',
component: () => import('./app-error.element.js'),
setup: (component) => {
if (!this.#authContext) {
throw new Error('[Fatal] Auth context is not available');
}
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');
// If there is an opener, we are in a popup window, and we should show a different message
// than if we are in the main window. If we are in the main window, we should redirect to the root.
// The authorization request will be completed in the active window (main or popup) and the authorization signal will be sent.
// If we are in a popup window, the storage event in UmbAuthContext will catch the signal and close the window.
// If we are in the main window, the signal will be caught right here and the user will be redirected to the root.
if (window.opener) {
(component as UmbAppErrorElement).errorMessage = hasCode
? this.localize.term('errors_externalLoginSuccess')
: this.localize.term('errors_externalLoginFailed');
} else {
(component as UmbAppErrorElement).errorMessage = hasCode
? 'Login successful, you will be redirected shortly'
: this.localize.term('errors_externalLoginFailed');
this.observe(this.#authContext.authorizationSignal, () => {
window.location.href = '/';
});
}
// Complete the authorization request, which will send the authorization signal
this.#authContext.completeAuthorizationRequest();
},
},
{
path: 'upgrade',
component: () => import('../upgrader/upgrader.element.js'),
guards: [this.#isAuthorizedGuard()],
},
{
path: 'logout',
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, '', '');
});
},
},
{
path: '**',
component: () => import('../backoffice/backoffice.element.js'),
guards: [this.#isAuthorizedGuard()],
},
];
#authContext?: typeof UMB_AUTH_CONTEXT.TYPE;
#serverConnection?: UmbServerConnection;
#authController = new UmbAppAuthController(this);
constructor() {
super();
OpenAPI.BASE = window.location.origin;
new UmbBundleExtensionInitializer(this, umbExtensionsRegistry);
new UUIIconRegistryEssential().attach(this);
new UmbContextDebugController(this);
}
connectedCallback(): void {
super.connectedCallback();
this.#setup();
}
async #setup() {
this.#serverConnection = await new UmbServerConnection(this.serverUrl).connect();
this.#authContext = new UmbAuthContext(this, this.serverUrl, this.backofficePath, this.bypassAuth);
new UmbAppContext(this, { backofficePath: this.backofficePath, serverUrl: this.serverUrl });
// Register Core extensions (this is specifically done here because we need these extensions to be registered before the application is initialized)
onInit(this, umbExtensionsRegistry);
// 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 {
// If the runtime level is "install" we should clear any cached tokens
// else we should try and set the auth status
if (this.#serverConnection.getStatus() === RuntimeLevelModel.INSTALL) {
await this.#authContext.clearTokenStorage();
} else {
await this.#setAuthStatus();
}
// Initialise the router
this.#redirect();
} catch (error) {
// If the auth flow fails, there is most likely something wrong with the connection to the backend server
// and we should redirect to the error page
let errorMsg =
'An error occurred while trying to initialize the connection to the Umbraco server (check console for details)';
// Get the type of the error and check http status codes
if (error instanceof Error) {
// If the error is a "TypeError" it means that the server is not reachable
if (error.name === 'TypeError') {
errorMsg = 'The Umbraco server is unreachable (check console for details)';
}
}
// Log the error
console.error(errorMsg, error);
// Redirect to the error page
this.#errorPage(errorMsg, error);
}
}
// TODO: move set initial auth state into auth context
async #setAuthStatus() {
if (this.bypassAuth) return;
if (!this.#authContext) {
throw new Error('[Fatal] AuthContext requested before it was initialized');
}
// Get service configuration from authentication server
await this.#authContext?.setInitialState();
// Instruct all requests to use the auth flow to get and use the access_token for all subsequent requests
OpenAPI.TOKEN = () => this.#authContext!.getLatestToken();
OpenAPI.WITH_CREDENTIALS = true;
}
#redirect() {
const pathname = pathWithoutBasePath({ start: true, end: false });
// If we are on the oauth_complete or error page, we should not redirect
if (pathname === '/oauth_complete' || pathname === '/error') {
// Initialize the router
history.replaceState(null, '', location.href);
return;
}
switch (this.#serverConnection?.getStatus()) {
case RuntimeLevelModel.INSTALL:
history.replaceState(null, '', 'install');
break;
case RuntimeLevelModel.UPGRADE:
history.replaceState(null, '', 'upgrade');
break;
case RuntimeLevelModel.BOOT_FAILED:
this.#errorPage('The Umbraco server failed to boot');
break;
case RuntimeLevelModel.RUN: {
// If we are on installer or upgrade page, redirect to the root since we are in the RUN state
if (pathname === '/install' || pathname === '/upgrade') {
history.replaceState(null, '', '/');
break;
}
// Keep the current path but replace state anyway to initialize the router
// because the router will not initialize a wildcard route by itself
history.replaceState(null, '', location.href);
break;
}
default:
// Redirect to the error page
this.#errorPage(`Unsupported runtime level: ${this.#serverConnection?.getStatus()}`);
}
}
#isAuthorizedGuard(): Guard {
return () => this.#authController.isAuthorized() ?? false;
}
#errorPage(errorMsg: string, error?: unknown) {
// Redirect to the error page
this._routes = [
{
path: '**',
component: () => import('./app-error.element.js'),
setup: (component) => {
(component as UmbAppErrorElement).errorMessage = errorMsg;
(component as UmbAppErrorElement).error = error;
},
},
];
// Re-render the router
this.requestUpdate();
}
render() {
return html`<umb-router-slot id="router-slot" .routes=${this._routes}></umb-router-slot>`;
}
static styles = css`
:host {
overflow: hidden;
}
:host,
#router-slot {
display: block;
width: 100%;
height: 100vh;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
'umb-app': UmbAppElement;
}
}