Merge pull request #1815 from umbraco/feature/link-external-login
Feature: Link and Unlink External Login Providers
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,8 +128,4 @@ export const mfaLoginProviders: Array<UserTwoFactorProviderModel> = [
|
||||
isEnabledOnUser: false,
|
||||
providerName: 'sms',
|
||||
},
|
||||
{
|
||||
isEnabledOnUser: true,
|
||||
providerName: 'Email',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user