diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index e6cd9a1979..c689e1e66a 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -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); }); } diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts index fa0a3caba6..5c068d7bc7 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts @@ -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', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index a1f3c17630..0f840ec4cd 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -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', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index af418baa1b..cfef63d647 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -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: diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts index 18dc20cc0f..78495eea2c 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts @@ -1057,15 +1057,6 @@ fallbackIsoCode?: string | null isoCode: string }; -export type LinkedLoginModel = { - providerName: string -providerKey: string - }; - -export type LinkedLoginsRequestModel = { - linkedLogins: Array - }; - 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 - ,GetUserCurrentLogins: LinkedLoginsRequestModel ,GetUserCurrentPermissions: UserPermissionsResponseModel ,GetUserCurrentPermissionsDocument: Array ,GetUserCurrentPermissionsMedia: UserPermissionsResponseModel diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts index c634a46642..861e3352db 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts @@ -8701,21 +8701,6 @@ requestBody }); } - /** - * @returns unknown Success - * @throws ApiError - */ - public static getUserCurrentLogins(): CancelablePromise { - - 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 diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts index ddbd634a65..a1657a2531 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts @@ -128,8 +128,4 @@ export const mfaLoginProviders: Array = [ isEnabledOnUser: false, providerName: 'sms', }, - { - isEnabledOnUser: true, - providerName: 'Email', - }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts index 4806994c0d..b8a500f4bb 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts @@ -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 = [ diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/user/current.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/user/current.handlers.ts index 9454d23c56..cff60866fd 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/handlers/user/current.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/user/current.handlers.ts @@ -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(umbracoPath(`${UMB_SLUG}/current/logins`), (_req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - linkedLogins: [ + rest.get( + umbracoPath(`${UMB_SLUG}/current/login-providers`), + (_req, res, ctx) => { + return res( + ctx.status(200), + ctx.json([ { + 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)); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index 1c139bf595..b359ddf352 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -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 { + 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 { + 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(); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index f4a418727d..d405753f15 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -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 { #isAuthorized = new UmbBooleanState(false); // Timeout is different from `isAuthorized` because it can occur repeatedly #isTimeout = new Subject(); - /** - * Observable that emits true when the auth context is initialized. - * @remark It will only emit once and then complete itself. - */ #isInitialized = new ReplaySubject(1); - #isBypassed = false; + #isBypassed; #serverUrl; #backofficePath; #authFlow; @@ -25,6 +21,12 @@ export class UmbAuthContext extends UmbContextBase { #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 { ) { 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 { }; } + /** + * 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); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts index eaa9cfe801..1a7bdec3fb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts @@ -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` 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`` + ? html`` : nothing} - ${this.manifest.meta?.label ?? this.manifest.forProviderName} + ${this.#label} `; } @@ -45,6 +51,10 @@ export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbA #auth-provider-button { width: 100%; } + + #icon { + margin-right: var(--uui-size-space-1); + } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/providers/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/providers/manifests.ts index 5c8e7dc2de..bab8a1ac83 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/providers/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/providers/manifests.ts @@ -8,7 +8,7 @@ export const manifests: Array = [ forProviderName: 'Umbraco', weight: 1000, meta: { - label: 'Sign in with Umbraco', + label: 'Umbraco', defaultView: { icon: 'icon-umbraco', look: 'primary', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index b573765d43..1306241f52 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -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" diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-window-popout.js b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-window-popout.js new file mode 100644 index 0000000000..16eeec336b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-window-popout.js @@ -0,0 +1,16 @@ +export default ` + + + + + +`; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icons.ts index 6a79e57053..ef496478b3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icons.ts @@ -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", diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/configure-external-login-providers-action.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/configure-external-login-providers-action.ts new file mode 100644 index 0000000000..a3706c2988 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/configure-external-login-providers-action.ts @@ -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 + extends UmbActionBase> + implements UmbCurrentUserAction +{ + 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(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/manifests.ts new file mode 100644 index 0000000000..bef139a91b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/manifests.ts @@ -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 = [ + { + type: 'modal', + alias: 'Umb.Modal.CurrentUserExternalLogin', + name: 'External Login Modal', + js: () => import('./modals/external-login-modal.element.js'), + }, +]; + +export const userProfileApps: Array = [ + { + 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]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.element.ts new file mode 100644 index 0000000000..7804f8ab92 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.element.ts @@ -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 = []; + + #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` + +
+ ${repeat( + this._items, + (item) => item.providerSchemeName, + (item) => this.#renderProvider(item), + )} +
+
+ +
+
+ `; + } + + /** + * Render a provider with a toggle to enable/disable it + */ + #renderProvider(item: UmbExternalLoginProviderOption) { + return html` + +
+ + ${this.localize.string(item.displayName)} +
+ ${when( + item.existsOnServer, + () => nothing, + () => + html`
+ + ! + +
`, + )} + ${when( + item.isLinkedOnUser, + () => html` +

+ Your account is linked to this service +

+ this.#onProviderDisable(item)}> + + Unlink your ${this.localize.string(item.displayName)} account + + + + `, + () => html` + this.#onProviderEnable(item)}> + + Link your ${this.localize.string(item.displayName)} account + + + + `, + )} +
+ `; + } + + 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; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.stories.ts new file mode 100644 index 0000000000..0cb3740eee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.stories.ts @@ -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``; + } +} + +if (window.customElements.get('umb-server-extensions-host') === undefined) { + customElements.define('umb-server-extensions-host', UmbServerExtensionsHostElement); +} + +const meta: Meta = { + title: 'Current User/External Login/Configure External Login Providers', + component: 'umb-current-user-external-login-modal', + decorators: [ + (Story) => + html` + ${Story()} + `, + ], + parameters: { + layout: 'centered', + actions: { + disabled: true, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Overview: Story = {}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.token.ts new file mode 100644 index 0000000000..52f0adae32 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/modals/external-login-modal.token.ts @@ -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', + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts index e3212adbe1..dce911901e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts @@ -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, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user-mfa/current-user-mfa-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user-mfa/current-user-mfa-modal.element.ts index ffa32541ae..a740321f32 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user-mfa/current-user-mfa-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user-mfa/current-user-mfa-modal.element.ts @@ -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` + ${when( + item.existsOnServer, + () => nothing, + () => + html`
+ + ! + +
`, + )} ${when( item.isEnabledOnUser, () => html` @@ -108,6 +124,7 @@ export class UmbCurrentUserMfaModalElement extends UmbLitElement { this.#onProviderEnable(item)}> `, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts index 40026ffe1a..ca0db09ecb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts @@ -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 diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts index 389d856f9a..a118af77b0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts @@ -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 diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts index 1e6a9ffa1f..684e41e7dd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts @@ -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 { #mfaProviders = new UmbArrayState([], (e) => e.providerName); readonly mfaProviders = this.#mfaProviders.asObservable(); + #externalLoginProviders = new UmbArrayState( + [], + (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 { updateMfaProvider(data: Partial) { this.#mfaProviders.updateOne(data.providerName, data); } + + setExternalLoginProviders(data: Array) { + this.#externalLoginProviders.setValue(data); + } + + updateExternalLoginProvider(data: Partial) { + this.#externalLoginProviders.updateOne(data.providerSchemeName, data); + } } export const UMB_CURRENT_USER_STORE_CONTEXT = new UmbContextToken('UmbCurrentUserStore'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts index 6a1363cc9a..d5c3a7d901 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts @@ -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 }>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/manifests.ts index d1348a4254..04150acb7c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/manifests.ts @@ -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 = [ userAllowDisableActionManifest, userAllowEnableActionManifest, userAllowUnlockActionManifest, + userAllowExternalLoginActionManifest, userAllowMfaActionManifest, userAllowDeleteActionManifest, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/user-allow-external-login-action.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/user-allow-external-login-action.condition.ts new file mode 100644 index 0000000000..05af0c57d0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/conditions/user-allow-external-login-action.condition.ts @@ -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 { + // 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, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-mfa/user-mfa-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-mfa/user-mfa-modal.element.ts index c10e921421..74805b9d30 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-mfa/user-mfa-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-mfa/user-mfa-modal.element.ts @@ -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` + ${when( + item.existsOnServer, + () => nothing, + () => + html`
+ + ! + +
`, + )} ${when( item.isEnabledOnUser, () => html`