Merge pull request #1815 from umbraco/feature/link-external-login

Feature: Link and Unlink External Login Providers
This commit is contained in:
Lee Kelleher
2024-05-21 10:01:40 +01:00
committed by GitHub
30 changed files with 660 additions and 81 deletions

View File

@@ -4,7 +4,7 @@ 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 { UMB_STORAGE_REDIRECT_URL, 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';
@@ -86,7 +86,14 @@ export class UmbAppElement extends UmbLitElement {
: this.localize.term('errors_externalLoginFailed');
this.observe(this.#authContext.authorizationSignal, () => {
history.replaceState(null, '', '');
// 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.endsWith('logout') ? currentRoute : savedRoute;
}
history.replaceState(null, '', currentRoute);
});
}

View File

@@ -548,13 +548,15 @@ export default {
noIconsFound: 'Ingen ikoner blev fundet',
noMacroParams: 'Der er ingen parametre for denne makro',
noMacros: 'Der er ikke tilføjet nogen makroer',
externalLoginProviders: 'Eksterne login-udbydere',
externalLoginProviders: 'Eksternt login',
exceptionDetail: 'Undtagelsesdetaljer',
stacktrace: 'Stacktrace',
innerException: 'Indre undtagelse',
linkYour: 'Link dit',
unLinkYour: 'Fjern link fra dit',
account: 'konto',
linkYour: 'Link din {0} konto',
linkYourConfirm: 'For at linke dine Umbraco og {0} konti, vil du blive sendt til {0} for at bekræfte.',
unLinkYour: 'Fjern link fra din {0} konto',
unLinkYourConfirm: 'Du er ved at fjerne linket mellem dine Umbraco og {0} konti og du vil blive logget ud.',
linkedToService: 'Din konto er linket til denne service',
selectEditor: 'Vælg editor',
selectEditorConfiguration: 'Vælg konfiguration',
selectSnippet: 'Vælg snippet',

View File

@@ -559,13 +559,16 @@ export default {
noIconsFound: 'No icons were found',
noMacroParams: 'There are no parameters for this macro',
noMacros: 'There are no macros available to insert',
externalLoginProviders: 'External login providers',
externalLoginProviders: 'External logins',
exceptionDetail: 'Exception Details',
stacktrace: 'Stacktrace',
innerException: 'Inner Exception',
linkYour: 'Link your',
unLinkYour: 'Un-link your',
account: 'account',
linkYour: 'Link your {0} account',
linkYourConfirm:
'You are about to link your Umbraco and {0} accounts and you will be redirected to {0} to confirm.',
unLinkYour: 'Un-link your {0} account',
unLinkYourConfirm: 'You are about to un-link your Umbraco and {0} accounts and you will be logged out.',
linkedToService: 'Your account is linked to this service',
selectEditor: 'Select editor',
selectEditorConfiguration: 'Select configuration',
selectSnippet: 'Select snippet',

View File

@@ -568,13 +568,16 @@ export default {
noIconsFound: 'No icons were found',
noMacroParams: 'There are no parameters for this macro',
noMacros: 'There are no macros available to insert',
externalLoginProviders: 'External login providers',
externalLoginProviders: 'External logins',
exceptionDetail: 'Exception Details',
stacktrace: 'Stacktrace',
innerException: 'Inner Exception',
linkYour: 'Link your',
unLinkYour: 'Un-link your',
account: 'account',
linkYour: 'Link your {0} account',
linkYourConfirm:
'You are about to link your Umbraco and {0} accounts and you will be redirected to {0} to confirm.',
unLinkYour: 'Un-link your {0} account',
unLinkYourConfirm: 'You are about to un-link your Umbraco and {0} accounts and you will be logged out.',
linkedToService: 'Your account is linked to this service',
selectEditor: 'Select editor',
selectSnippet: 'Select snippet',
variantdeletewarning:

View File

@@ -1057,15 +1057,6 @@ fallbackIsoCode?: string | null
isoCode: string
};
export type LinkedLoginModel = {
providerName: string
providerKey: string
};
export type LinkedLoginsRequestModel = {
linkedLogins: Array<LinkedLoginModel>
};
export type LogLevelCountsReponseModel = {
information: number
debug: number
@@ -2585,6 +2576,7 @@ key: string
export type UserExternalLoginProviderModel = {
providerSchemeName: string
providerKey?: string | null
isLinkedOnUser: boolean
hasManualLinkingEnabled: boolean
};
@@ -5241,7 +5233,6 @@ PostUserUnlock: {
,PostUserCurrentChangePassword: string
,GetUserCurrentConfiguration: CurrenUserConfigurationResponseModel
,GetUserCurrentLoginProviders: Array<UserExternalLoginProviderModel>
,GetUserCurrentLogins: LinkedLoginsRequestModel
,GetUserCurrentPermissions: UserPermissionsResponseModel
,GetUserCurrentPermissionsDocument: Array<UserPermissionsResponseModel>
,GetUserCurrentPermissionsMedia: UserPermissionsResponseModel

View File

@@ -8701,21 +8701,6 @@ requestBody
});
}
/**
* @returns unknown Success
* @throws ApiError
*/
public static getUserCurrentLogins(): CancelablePromise<UserData['responses']['GetUserCurrentLogins']> {
return __request(OpenAPI, {
method: 'GET',
url: '/umbraco/management/api/v1/user/current/logins',
errors: {
401: `The resource is protected and requires an authentication token`,
},
});
}
/**
* @returns unknown Success
* @throws ApiError

View File

@@ -128,8 +128,4 @@ export const mfaLoginProviders: Array<UserTwoFactorProviderModel> = [
isEnabledOnUser: false,
providerName: 'sms',
},
{
isEnabledOnUser: true,
providerName: 'Email',
},
];

View File

@@ -76,6 +76,15 @@ const privateManifests: PackageManifestResponse = [
label: 'Setup SMS Verification',
},
},
{
type: 'mfaLoginProvider',
alias: 'My.MfaLoginProvider.Custom.Email',
name: 'My Custom Email MFA Provider',
forProviderName: 'email',
meta: {
label: 'Setup Email Verification',
},
},
],
},
{
@@ -92,20 +101,6 @@ const privateManifests: PackageManifestResponse = [
},
],
},
{
name: 'My MFA Package',
extensions: [
{
type: 'mfaLoginProvider',
alias: 'My.MfaLoginProvider.Custom',
name: 'My Custom MFA Provider',
forProviderName: 'sms',
meta: {
label: 'Setup SMS Verification',
},
},
],
},
];
const publicManifests: PackageManifestResponse = [

View File

@@ -1,7 +1,7 @@
const { rest } = window.MockServiceWorker;
import { umbUserMockDb } from '../../data/user/user.db.js';
import { UMB_SLUG } from './slug.js';
import type { LinkedLoginsRequestModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UserData } from '@umbraco-cms/backoffice/external/backend-api';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
@@ -9,19 +9,22 @@ export const handlers = [
const loggedInUser = umbUserMockDb.getCurrentUser();
return res(ctx.status(200), ctx.json(loggedInUser));
}),
rest.get<LinkedLoginsRequestModel>(umbracoPath(`${UMB_SLUG}/current/logins`), (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json<LinkedLoginsRequestModel>({
linkedLogins: [
rest.get<UserData['responses']['GetUserCurrentLoginProviders']>(
umbracoPath(`${UMB_SLUG}/current/login-providers`),
(_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json<UserData['responses']['GetUserCurrentLoginProviders']>([
{
hasManualLinkingEnabled: true,
isLinkedOnUser: true,
providerKey: 'google',
providerName: 'Umbraco.Google',
providerSchemeName: 'Umbraco.Google',
},
],
}),
);
}),
]),
);
},
),
rest.get(umbracoPath(`${UMB_SLUG}/current/2fa`), (_req, res, ctx) => {
const mfaLoginProviders = umbUserMockDb.getMfaLoginProviders();
return res(ctx.status(200), ctx.json(mfaLoginProviders));

View File

@@ -98,6 +98,11 @@ export class UmbAuthFlow {
// tokens
#tokenResponse?: TokenResponse;
// external login
#link_endpoint;
#link_key_endpoint;
#unlink_endpoint;
/**
* This signal will emit when the authorization flow is complete.
* @remark It will also emit if there is an error during the authorization flow.
@@ -125,6 +130,10 @@ export class UmbAuthFlow {
end_session_endpoint: `${openIdConnectUrl}/umbraco/management/api/v1/security/back-office/signout`,
});
this.#link_endpoint = `${openIdConnectUrl}/umbraco/management/api/v1/security/back-office/link-login`;
this.#link_key_endpoint = `${openIdConnectUrl}/umbraco/management/api/v1/security/back-office/link-login-key`;
this.#unlink_endpoint = `${openIdConnectUrl}/umbraco/management/api/v1/security/back-office/unlink-login`;
this.#notifier = new AuthorizationNotifier();
this.#tokenHandler = new BaseTokenRequestHandler(requestor);
this.#storageBackend = new LocalStorageBackend();
@@ -320,6 +329,55 @@ export class UmbAuthFlow {
: Promise.reject('Missing tokenResponse.');
}
/**
* This method will link the current user to the specified provider by redirecting the user to the link endpoint.
* @param provider The provider to link to.
*/
async linkLogin(provider: string): Promise<void> {
const linkKey = await this.#makeLinkTokenRequest(provider);
const form = document.createElement('form');
form.method = 'POST';
form.action = this.#link_endpoint;
form.style.display = 'none';
const providerInput = document.createElement('input');
providerInput.name = 'provider';
providerInput.value = provider;
form.appendChild(providerInput);
const linkKeyInput = document.createElement('input');
linkKeyInput.name = 'linkKey';
linkKeyInput.value = linkKey;
form.appendChild(linkKeyInput);
document.body.appendChild(form);
form.submit();
}
/**
* This method will unlink the current user from the specified provider.
*/
async unlinkLogin(loginProvider: string, providerKey: string): Promise<boolean> {
const token = await this.performWithFreshTokens();
const request = new Request(this.#unlink_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ loginProvider, providerKey }),
});
const result = await fetch(request);
if (!result.ok) {
const error = await result.json();
throw error;
}
await this.signOut();
return true;
}
/**
* Save the current token response to local storage.
*/
@@ -384,4 +442,21 @@ export class UmbAuthFlow {
return false;
}
}
async #makeLinkTokenRequest(provider: string) {
const token = await this.performWithFreshTokens();
const request = await fetch(`${this.#link_key_endpoint}?provider=${provider}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!request.ok) {
throw new Error('Failed to link login');
}
return request.json();
}
}

View File

@@ -1,6 +1,6 @@
import type { UmbBackofficeExtensionRegistry, ManifestAuthProvider } from '../extension-registry/index.js';
import { UmbAuthFlow } from './auth-flow.js';
import { UMB_AUTH_CONTEXT, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js';
import { UMB_AUTH_CONTEXT, UMB_STORAGE_REDIRECT_URL, 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';
@@ -12,12 +12,8 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
#isAuthorized = new UmbBooleanState<boolean>(false);
// 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;
#isBypassed;
#serverUrl;
#backofficePath;
#authFlow;
@@ -25,6 +21,12 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
#authWindowProxy?: WindowProxy | null;
#previousAuthUrl?: string;
/**
* Observable that emits true when the auth context is initialized.
* @remark It will only emit once and then complete itself.
*/
readonly isInitialized = this.#isInitialized.asObservable();
/**
* Observable that emits true if the user is authorized, otherwise false.
* @remark It will only emit when the authorization state changes.
@@ -104,6 +106,9 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
) {
const redirectUrl = await this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint);
if (redirect) {
// Save the current state
sessionStorage.setItem(UMB_STORAGE_REDIRECT_URL, window.location.href);
location.href = redirectUrl;
return;
}
@@ -254,22 +259,51 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
};
}
/**
* Sets the auth context as initialized, which means that the auth context is ready to be used.
* @remark This is used to let the app context know that the core module is ready, which means that the core auth providers are available.
*/
setInitialized() {
this.#isInitialized.next();
this.#isInitialized.complete();
}
/**
* Gets all registered auth providers.
*/
getAuthProviders(extensionsRegistry: UmbBackofficeExtensionRegistry) {
return this.#isInitialized.pipe(
switchMap(() => extensionsRegistry.byType<'authProvider', ManifestAuthProvider>('authProvider')),
);
}
/**
* Gets the authorized redirect url.
* @returns The redirect url, which is the backoffice path.
*/
getRedirectUrl() {
return `${window.location.origin}${this.#backofficePath}${this.#backofficePath.endsWith('/') ? '' : '/'}oauth_complete`;
}
/**
* Gets the post logout redirect url.
* @returns The post logout redirect url, which is the backoffice path with the logout path appended.
*/
getPostLogoutRedirectUrl() {
return `${window.location.origin}${this.#backofficePath}${this.#backofficePath.endsWith('/') ? '' : '/'}logout`;
}
/**
* @see UmbAuthFlow#linkLogin
*/
linkLogin(provider: string) {
return this.#authFlow.linkLogin(provider);
}
/**
* @see UmbAuthFlow#unlinkLogin
*/
unlinkLogin(providerName: string, providerKey: string) {
return this.#authFlow.unlinkLogin(providerName, providerKey);
}
}

View File

@@ -18,19 +18,25 @@ export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbA
this.setAttribute('part', 'auth-provider-default');
}
get #label() {
const label = this.manifest.meta?.label ?? this.manifest.forProviderName;
const labelLocalized = this.localize.string(label);
return this.localize.term('login_signInWith', labelLocalized);
}
render() {
return html`
<uui-button
type="button"
@click=${() => this.onSubmit(this.manifest)}
id="auth-provider-button"
.label=${this.manifest.meta?.label ?? this.manifest.forProviderName}
.label=${this.#label}
.look=${this.manifest.meta?.defaultView?.look ?? 'outline'}
.color=${this.manifest.meta?.defaultView?.color ?? 'default'}>
${this.manifest.meta?.defaultView?.icon
? html`<uui-icon .name=${this.manifest.meta?.defaultView?.icon}></uui-icon>`
? html`<uui-icon id="icon" .name=${this.manifest.meta?.defaultView?.icon}></uui-icon>`
: nothing}
${this.manifest.meta?.label ?? this.manifest.forProviderName}
${this.#label}
</uui-button>
`;
}
@@ -45,6 +51,10 @@ export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbA
#auth-provider-button {
width: 100%;
}
#icon {
margin-right: var(--uui-size-space-1);
}
`,
];
}

View File

@@ -8,7 +8,7 @@ export const manifests: Array<ManifestAuthProvider> = [
forProviderName: 'Umbraco',
weight: 1000,
meta: {
label: 'Sign in with Umbraco',
label: 'Umbraco',
defaultView: {
icon: 'icon-umbraco',
look: 'primary',

View File

@@ -2404,6 +2404,10 @@
"name": "icon-window-popin",
"file": "square-arrow-down-left.svg"
},
{
"name": "icon-window-popout",
"file": "square-arrow-up-right.svg"
},
{
"name": "icon-window-sizes",
"file": "scaling.svg"

View File

@@ -0,0 +1,16 @@
export default `<!-- @license lucide-static v0.367.0 - ISC -->
<svg
class="lucide lucide-square-arrow-up-right"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M8 8h8v8" />
<path d="m8 16 8-8" />
</svg>
`;

View File

@@ -2055,6 +2055,10 @@ name: "icon-window-popin",
path: "./icons/icon-window-popin.js",
},{
name: "icon-window-popout",
path: "./icons/icon-window-popout.js",
},{
name: "icon-window-sizes",
path: "./icons/icon-window-sizes.js",

View File

@@ -0,0 +1,18 @@
import { UMB_CURRENT_USER_EXTERNAL_LOGIN_MODAL } from './modals/external-login-modal.token.js';
import { UmbActionBase } from '@umbraco-cms/backoffice/action';
import type { UmbCurrentUserAction, UmbCurrentUserActionArgs } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
export class UmbConfigureExternalLoginProvidersApi<ArgsMetaType = never>
extends UmbActionBase<UmbCurrentUserActionArgs<ArgsMetaType>>
implements UmbCurrentUserAction<ArgsMetaType>
{
async getHref() {
return undefined;
}
async execute() {
const modalManagerContext = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
await modalManagerContext.open(this, UMB_CURRENT_USER_EXTERNAL_LOGIN_MODAL).onSubmit();
}
}

View File

@@ -0,0 +1,33 @@
import { UmbConfigureExternalLoginProvidersApi } from './configure-external-login-providers-action.js';
import type { ManifestCurrentUserActionDefaultKind, ManifestModal } from '@umbraco-cms/backoffice/extension-registry';
export const modals: Array<ManifestModal> = [
{
type: 'modal',
alias: 'Umb.Modal.CurrentUserExternalLogin',
name: 'External Login Modal',
js: () => import('./modals/external-login-modal.element.js'),
},
];
export const userProfileApps: Array<ManifestCurrentUserActionDefaultKind> = [
{
type: 'currentUserAction',
kind: 'default',
alias: 'Umb.CurrentUser.App.ExternalLoginProviders',
name: 'External Login Providers Current User App',
weight: 700,
api: UmbConfigureExternalLoginProvidersApi,
meta: {
label: '#defaultdialogs_externalLoginProviders',
icon: 'icon-lock',
look: 'secondary',
},
conditions: [
{
alias: 'Umb.Condition.User.AllowExternalLoginAction',
},
],
},
];
export const manifests = [...modals, ...userProfileApps];

View File

@@ -0,0 +1,241 @@
import { UmbCurrentUserRepository } from '../../repository/index.js';
import type { UmbCurrentUserExternalLoginProviderModel } from '../../types.js';
import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbConfirmModal, type UmbModalContext } from '@umbraco-cms/backoffice/modal';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { mergeObservables } from '@umbraco-cms/backoffice/observable-api';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
import type { ProblemDetails } from '@umbraco-cms/backoffice/external/backend-api';
type UmbExternalLoginProviderOption = UmbCurrentUserExternalLoginProviderModel & {
displayName: string;
icon?: string;
existsOnServer: boolean;
};
@customElement('umb-current-user-external-login-modal')
export class UmbCurrentUserExternalLoginModalElement extends UmbLitElement {
@property({ attribute: false })
modalContext?: UmbModalContext;
@state()
_items: Array<UmbExternalLoginProviderOption> = [];
#currentUserRepository = new UmbCurrentUserRepository(this);
#notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE;
constructor() {
super();
this.#loadProviders();
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
this.#notificationContext = context;
});
}
async #loadProviders() {
const serverLoginProviders$ = (await this.#currentUserRepository.requestExternalLoginProviders()).asObservable();
const manifestLoginProviders$ = umbExtensionsRegistry.byTypeAndFilter(
'authProvider',
(ext) => !!ext.meta?.linking?.allowManualLinking,
);
// Merge the server and manifest providers to get the final list of providers
const externalLoginProviders$ = mergeObservables(
[serverLoginProviders$, manifestLoginProviders$],
([serverLoginProviders, manifestLoginProviders]) => {
const providers: UmbExternalLoginProviderOption[] = manifestLoginProviders.map((manifestLoginProvider) => {
const serverLoginProvider = serverLoginProviders.find(
(serverLoginProvider) => serverLoginProvider.providerSchemeName === manifestLoginProvider.forProviderName,
);
return {
existsOnServer: !!serverLoginProvider,
hasManualLinkingEnabled: serverLoginProvider?.hasManualLinkingEnabled ?? false,
isLinkedOnUser: serverLoginProvider?.isLinkedOnUser ?? false,
providerKey: serverLoginProvider?.providerKey ?? '',
providerSchemeName: manifestLoginProvider.forProviderName,
icon: manifestLoginProvider.meta?.defaultView?.icon,
displayName:
manifestLoginProvider.meta?.label ?? manifestLoginProvider.forProviderName ?? manifestLoginProvider.name,
} satisfies UmbExternalLoginProviderOption;
});
return providers;
},
);
this.observe(
externalLoginProviders$,
(providers) => {
this._items = providers;
},
'_externalLoginProviders',
);
}
#close() {
this.modalContext?.submit();
}
render() {
return html`
<umb-body-layout headline="${this.localize.term('defaultdialogs_externalLoginProviders')}">
<div id="main">
${repeat(
this._items,
(item) => item.providerSchemeName,
(item) => this.#renderProvider(item),
)}
</div>
<div slot="actions">
<uui-button @click=${this.#close} look="secondary" .label=${this.localize.term('general_close')}></uui-button>
</div>
</umb-body-layout>
`;
}
/**
* Render a provider with a toggle to enable/disable it
*/
#renderProvider(item: UmbExternalLoginProviderOption) {
return html`
<uui-box>
<div class="header" slot="header">
<uui-icon class="header-icon" name=${item.icon ?? 'icon-cloud'}></uui-icon>
${this.localize.string(item.displayName)}
</div>
${when(
item.existsOnServer,
() => nothing,
() =>
html`<div style="position:relative" slot="header-actions">
<uui-badge
style="cursor:default"
title="Warning: This provider is not configured on the server"
color="danger"
look="primary">
!
</uui-badge>
</div>`,
)}
${when(
item.isLinkedOnUser,
() => html`
<p style="margin-top:0">
<umb-localize key="defaultdialogs_linkedToService">Your account is linked to this service</umb-localize>
</p>
<uui-button
type="button"
look="secondary"
color="danger"
.label=${this.localize.term('defaultdialogs_unLinkYour', this.localize.string(item.displayName))}
@click=${() => this.#onProviderDisable(item)}>
<umb-localize key="defaultdialogs_unLinkYour" .args=${[this.localize.string(item.displayName)]}>
Unlink your ${this.localize.string(item.displayName)} account
</umb-localize>
<uui-icon name="icon-window-popout"></uui-icon>
</uui-button>
`,
() => html`
<uui-button
type="button"
look="secondary"
color="success"
.label=${this.localize.term('defaultdialogs_linkYour', item.displayName)}
?disabled=${!item.existsOnServer || !item.hasManualLinkingEnabled}
@click=${() => this.#onProviderEnable(item)}>
<umb-localize key="defaultdialogs_linkYour" .args=${[this.localize.string(item.displayName)]}>
Link your ${this.localize.string(item.displayName)} account
</umb-localize>
<uui-icon name="icon-window-popout"></uui-icon>
</uui-button>
`,
)}
</uui-box>
`;
}
async #onProviderEnable(item: UmbExternalLoginProviderOption) {
const providerDisplayName = this.localize.string(item.displayName);
try {
await umbConfirmModal(this, {
headline: this.localize.term('defaultdialogs_linkYour', providerDisplayName),
content: this.localize.term('defaultdialogs_linkYourConfirm', providerDisplayName),
confirmLabel: this.localize.term('general_continue'),
color: 'positive',
});
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
await authContext.linkLogin(item.providerSchemeName);
} catch (error) {
if (error instanceof Error) {
this.#notificationContext?.peek('danger', {
data: {
headline: this.localize.term('defaultdialogs_linkYour', providerDisplayName),
message: error.message,
},
});
}
}
}
async #onProviderDisable(item: UmbExternalLoginProviderOption) {
if (!item.providerKey) {
throw new Error('Provider key is missing');
}
const providerDisplayName = this.localize.string(item.displayName);
try {
await umbConfirmModal(this, {
headline: this.localize.term('defaultdialogs_unLinkYour', providerDisplayName),
content: this.localize.term('defaultdialogs_unLinkYourConfirm', providerDisplayName),
confirmLabel: this.localize.term('general_continue'),
color: 'danger',
});
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
await authContext.unlinkLogin(item.providerSchemeName, item.providerKey);
} catch (error) {
let message = this.localize.term('errors_receivedErrorFromServer');
if (error instanceof Error) {
message = error.message;
} else if (typeof error === 'object' && (error as ProblemDetails).title) {
message = (error as ProblemDetails).title ?? message;
}
console.error('[External Login] Error unlinking provider: ', error);
this.#notificationContext?.peek('danger', {
data: {
headline: this.localize.term('defaultdialogs_unLinkYour', providerDisplayName),
message,
},
});
}
}
static styles = [
UmbTextStyles,
css`
uui-box {
margin-bottom: var(--uui-size-space-3);
}
.header {
display: flex;
align-items: center;
}
.header-icon {
margin-right: var(--uui-size-space-4);
}
`,
];
}
export default UmbCurrentUserExternalLoginModalElement;
declare global {
interface HTMLElementTagNameMap {
'umb-current-user-external-login-modal': UmbCurrentUserExternalLoginModalElement;
}
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import type { UmbCurrentUserExternalLoginModalElement } from './external-login-modal.element.js';
import { html } from '@umbraco-cms/backoffice/external/lit';
import { UmbServerExtensionRegistrator } from '@umbraco-cms/backoffice/extension-api';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import './external-login-modal.element.js';
class UmbServerExtensionsHostElement extends UmbLitElement {
constructor() {
super();
new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
}
render() {
return html`<slot></slot>`;
}
}
if (window.customElements.get('umb-server-extensions-host') === undefined) {
customElements.define('umb-server-extensions-host', UmbServerExtensionsHostElement);
}
const meta: Meta<UmbCurrentUserExternalLoginModalElement> = {
title: 'Current User/External Login/Configure External Login Providers',
component: 'umb-current-user-external-login-modal',
decorators: [
(Story) =>
html`<umb-server-extensions-host style="display: block; width: 500px; height: 500px;">
${Story()}
</umb-server-extensions-host>`,
],
parameters: {
layout: 'centered',
actions: {
disabled: true,
},
},
};
export default meta;
type Story = StoryObj<UmbCurrentUserExternalLoginModalElement>;
export const Overview: Story = {};

View File

@@ -0,0 +1,8 @@
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export const UMB_CURRENT_USER_EXTERNAL_LOGIN_MODAL = new UmbModalToken('Umb.Modal.CurrentUserExternalLogin', {
modal: {
type: 'sidebar',
size: 'small',
},
});

View File

@@ -1,6 +1,7 @@
import { manifest as actionDefaultKindManifest } from './action/default.kind.js';
import { manifests as modalManifests } from './modals/manifests.js';
import { manifests as historyManifests } from './history/manifests.js';
import { manifests as externalLoginProviderManifests } from './external-login/manifests.js';
import { manifests as mfaLoginProviderManifests } from './mfa-login/manifests.js';
import { manifests as profileManifests } from './profile/manifests.js';
import { manifests as themeManifests } from './theme/manifests.js';
@@ -32,6 +33,7 @@ export const manifests = [
actionDefaultKindManifest,
...headerApps,
...historyManifests,
...externalLoginProviderManifests,
...mfaLoginProviderManifests,
...modalManifests,
...profileManifests,

View File

@@ -2,7 +2,7 @@ import { UMB_CURRENT_USER_MFA_ENABLE_PROVIDER_MODAL } from '../current-user-mfa-
import { UmbCurrentUserRepository } from '../../repository/index.js';
import { UMB_CURRENT_USER_MFA_DISABLE_PROVIDER_MODAL } from '../current-user-mfa-disable-provider/current-user-mfa-disable-provider-modal.token.js';
import type { UmbCurrentUserMfaProviderModel } from '../../types.js';
import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_MODAL_MANAGER_CONTEXT, type UmbModalContext } from '@umbraco-cms/backoffice/modal';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
@@ -11,6 +11,7 @@ import { mergeObservables } from '@umbraco-cms/backoffice/observable-api';
type UmbMfaLoginProviderOption = UmbCurrentUserMfaProviderModel & {
displayName: string;
existsOnServer: boolean;
};
@customElement('umb-current-user-mfa-modal')
@@ -41,6 +42,7 @@ export class UmbCurrentUserMfaModalElement extends UmbLitElement {
(serverLoginProvider) => serverLoginProvider.providerName === manifestLoginProvider.forProviderName,
);
return {
existsOnServer: !!serverLoginProvider,
isEnabledOnUser: serverLoginProvider?.isEnabledOnUser ?? false,
providerName: serverLoginProvider?.providerName ?? manifestLoginProvider.forProviderName,
displayName:
@@ -91,6 +93,20 @@ export class UmbCurrentUserMfaModalElement extends UmbLitElement {
#renderProvider(item: UmbMfaLoginProviderOption) {
return html`
<uui-box headline=${item.displayName}>
${when(
item.existsOnServer,
() => nothing,
() =>
html`<div style="position:relative" slot="header-actions">
<uui-badge
style="cursor:default"
title="Warning: This provider is not configured on the server"
color="danger"
look="primary">
!
</uui-badge>
</div>`,
)}
${when(
item.isEnabledOnUser,
() => html`
@@ -108,6 +124,7 @@ export class UmbCurrentUserMfaModalElement extends UmbLitElement {
<uui-button
type="button"
look="secondary"
?disabled=${!item.existsOnServer}
.label=${this.localize.term('actions_enable')}
@click=${() => this.#onProviderEnable(item)}></uui-button>
`,

View File

@@ -40,6 +40,21 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase {
return { data, error, asObservable: () => this.#currentUserStore!.data };
}
/**
* Request the current user's external login providers
* @memberof UmbCurrentUserRepository
*/
async requestExternalLoginProviders() {
await this.#init;
const { data, error } = await this.#currentUserSource.getExternalLoginProviders();
if (data) {
this.#currentUserStore?.setExternalLoginProviders(data);
}
return { data, error, asObservable: () => this.#currentUserStore!.externalLoginProviders };
}
/**
* Request the current user's available MFA login providers
* @memberof UmbCurrentUserRepository

View File

@@ -61,6 +61,14 @@ export class UmbCurrentUserServerDataSource {
return { error };
}
/**
* Get the current user's external login providers
* @memberof UmbCurrentUserServerDataSource
*/
async getExternalLoginProviders() {
return tryExecuteAndNotify(this.#host, UserService.getUserCurrentLoginProviders());
}
/**
* Get the current user's available MFA login providers
* @memberof UmbCurrentUserServerDataSource

View File

@@ -1,4 +1,8 @@
import type { UmbCurrentUserMfaProviderModel, UmbCurrentUserModel } from '../types.js';
import type {
UmbCurrentUserExternalLoginProviderModel,
UmbCurrentUserMfaProviderModel,
UmbCurrentUserModel,
} from '../types.js';
import type { UmbUserDetailModel } from '@umbraco-cms/backoffice/user';
import { UMB_USER_DETAIL_STORE_CONTEXT } from '@umbraco-cms/backoffice/user';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
@@ -13,6 +17,12 @@ export class UmbCurrentUserStore extends UmbContextBase<UmbCurrentUserStore> {
#mfaProviders = new UmbArrayState<UmbCurrentUserMfaProviderModel>([], (e) => e.providerName);
readonly mfaProviders = this.#mfaProviders.asObservable();
#externalLoginProviders = new UmbArrayState<UmbCurrentUserExternalLoginProviderModel>(
[],
(e) => e.providerSchemeName,
);
readonly externalLoginProviders = this.#externalLoginProviders.asObservable();
constructor(host: UmbControllerHost) {
super(host, UMB_CURRENT_USER_STORE_CONTEXT);
@@ -85,6 +95,14 @@ export class UmbCurrentUserStore extends UmbContextBase<UmbCurrentUserStore> {
updateMfaProvider(data: Partial<UmbCurrentUserMfaProviderModel>) {
this.#mfaProviders.updateOne(data.providerName, data);
}
setExternalLoginProviders(data: Array<UmbCurrentUserExternalLoginProviderModel>) {
this.#externalLoginProviders.setValue(data);
}
updateExternalLoginProvider(data: Partial<UmbCurrentUserExternalLoginProviderModel>) {
this.#externalLoginProviders.updateOne(data.providerSchemeName, data);
}
}
export const UMB_CURRENT_USER_STORE_CONTEXT = new UmbContextToken<UmbCurrentUserStore>('UmbCurrentUserStore');

View File

@@ -3,6 +3,7 @@ import type {
CancelError,
DocumentPermissionPresentationModel,
UnknownTypePermissionPresentationModel,
UserExternalLoginProviderModel,
UserTwoFactorProviderModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models';
@@ -26,6 +27,8 @@ export interface UmbCurrentUserModel {
userName: string;
}
export type UmbCurrentUserExternalLoginProviderModel = UserExternalLoginProviderModel;
export type UmbCurrentUserMfaProviderModel = UserTwoFactorProviderModel;
export type UmbMfaProviderConfigurationCallback = Promise<{ error?: ApiError | CancelError }>;

View File

@@ -1,6 +1,7 @@
import { manifest as userAllowDisableActionManifest } from './user-allow-disable-action.condition.js';
import { manifest as userAllowEnableActionManifest } from './user-allow-enable-action.condition.js';
import { manifest as userAllowUnlockActionManifest } from './user-allow-unlock-action.condition.js';
import { manifest as userAllowExternalLoginActionManifest } from './user-allow-external-login-action.condition.js';
import { manifest as userAllowMfaActionManifest } from './user-allow-mfa-action.condition.js';
import { manifest as userAllowDeleteActionManifest } from './user-allow-delete-action.condition.js';
import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
@@ -9,6 +10,7 @@ export const manifests: Array<ManifestTypes> = [
userAllowDisableActionManifest,
userAllowEnableActionManifest,
userAllowUnlockActionManifest,
userAllowExternalLoginActionManifest,
userAllowMfaActionManifest,
userAllowDeleteActionManifest,
];

View File

@@ -0,0 +1,24 @@
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { ManifestCondition } from '@umbraco-cms/backoffice/extension-api';
import { UmbConditionBase, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
export class UmbUserAllowExternalLoginActionCondition extends UmbConditionBase<never> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(host: UmbControllerHost, args: any) {
super(host, args);
// Check if there are any MFA providers available
this.observe(
umbExtensionsRegistry.byType('authProvider'),
(exts) => (this.permitted = exts.length > 0 && exts.some((ext) => ext.meta?.linking?.allowManualLinking)),
'_userAllowExternalLoginActionConditionProviders',
);
}
}
export const manifest: ManifestCondition = {
type: 'condition',
name: 'User Allow ExternalLogin Action Condition',
alias: 'Umb.Condition.User.AllowExternalLoginAction',
api: UmbUserAllowExternalLoginActionCondition,
};

View File

@@ -1,7 +1,7 @@
import { UmbUserRepository } from '../../repository/index.js';
import type { UmbUserMfaProviderModel } from '../../types.js';
import type { UmbUserMfaModalConfiguration } from './user-mfa-modal.token.js';
import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbConfirmModal, type UmbModalContext } from '@umbraco-cms/backoffice/modal';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
@@ -10,6 +10,7 @@ import { mergeObservables } from '@umbraco-cms/backoffice/observable-api';
type UmbMfaLoginProviderOption = UmbUserMfaProviderModel & {
displayName: string;
existsOnServer: boolean;
};
@customElement('umb-user-mfa-modal')
@@ -41,6 +42,7 @@ export class UmbUserMfaModalElement extends UmbLitElement {
(serverLoginProvider) => serverLoginProvider.providerName === manifestLoginProvider.forProviderName,
);
return {
existsOnServer: !!serverLoginProvider,
isEnabledOnUser: serverLoginProvider?.isEnabledOnUser ?? false,
providerName: serverLoginProvider?.providerName ?? manifestLoginProvider.forProviderName,
displayName:
@@ -91,6 +93,20 @@ export class UmbUserMfaModalElement extends UmbLitElement {
#renderProvider(item: UmbMfaLoginProviderOption) {
return html`
<uui-box headline=${item.displayName}>
${when(
item.existsOnServer,
() => nothing,
() =>
html`<div style="position:relative" slot="header-actions">
<uui-badge
style="cursor:default"
title="Warning: This provider is not configured on the server"
color="danger"
look="primary">
!
</uui-badge>
</div>`,
)}
${when(
item.isEnabledOnUser,
() => html`