Merge branch 'bugfix/document-recycle-bin' into feature/media-recycle-bin

This commit is contained in:
Mads Rasmussen
2024-04-05 14:54:51 +02:00
committed by GitHub
75 changed files with 745 additions and 194 deletions

View File

@@ -7,6 +7,9 @@ const IS_CI = process.env.CI === 'true';
const IS_AZURE_PIPELINES = process.env.TF_BUILD === 'true';
const IS_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true';
const FILE_PATH_COLOR = '\x1b[36m%s\x1b[0m';
const ERROR_COLOR = '\x1b[31m%s\x1b[0m';
const SUCCESS_COLOR = '\x1b[32m%s\x1b[0m';
const processExitCode = 1; // Default to 1 to fail the build, 0 to just log the issues
console.log(`Checking path length in ${PROJECT_DIR} for paths exceeding ${MAX_PATH_LENGTH}...`);
console.log('CI detected:', IS_CI);
@@ -17,14 +20,12 @@ console.log('-----------------------------------\n');
function checkPathLength(dir) {
const files = readdirSync(dir);
let hasError = false;
files.forEach(file => {
const filePath = join(dir, file);
if (filePath.length > MAX_PATH_LENGTH) {
if (IS_CI) {
//process.exitCode = 1; // TODO: Uncomment this line to fail the build
}
hasError = true;
if (IS_AZURE_PIPELINES) {
console.error(`##vso[task.logissue type=warning;sourcepath=${filePath};]Path exceeds maximum length of ${MAX_PATH_LENGTH} characters: ${filePath} with ${filePath.length} characters`);
@@ -36,9 +37,27 @@ function checkPathLength(dir) {
}
if (statSync(filePath).isDirectory()) {
checkPathLength(filePath, MAX_PATH_LENGTH);
const subHasError = checkPathLength(filePath);
if (subHasError) {
hasError = true;
}
}
});
return hasError;
}
checkPathLength(PROJECT_DIR, MAX_PATH_LENGTH);
const hasError = checkPathLength(PROJECT_DIR, MAX_PATH_LENGTH);
if (hasError) {
console.error('\n-----------------------------------');
console.error(ERROR_COLOR, 'Path length check failed');
console.error('-----------------------------------\n');
if (IS_CI && processExitCode) {
process.exit(processExitCode);
}
} else {
console.log('\n-----------------------------------');
console.log(SUCCESS_COLOR, 'Path length check passed');
console.log('-----------------------------------\n');
}

View File

@@ -1923,9 +1923,17 @@ export default {
noUserGroupsAdded: 'Ingen brugere er blevet tilføjet',
'2faDisableText': 'Hvis du ønsker at slå denne totrinsbekræftelse fra, så skal du nu indtaste koden fra din enhed:',
'2faProviderIsEnabled': 'Denne totrinsbekræftelse er slået til',
'2faProviderIsDisabledMsg': 'Den valgte totrinsbekræftelse er nu slået fra',
'2faProviderIsNotDisabledMsg': 'Der skete en ukendt fejl da denne totrinsbekræftelse skulles slåes fra',
'2faDisableForUser': 'Er du sikker på, at du vil fjerne denne totrinsbekræftelse for denne bruger?',
'2faProviderIsEnabledMsg': '{0} er nu slået til',
'2faProviderIsNotEnabledMsg': 'Der skete en fejl da {0} skulles slåes til',
'2faProviderIsDisabledMsg': '{0} er nu slået fra',
'2faProviderIsNotDisabledMsg': 'Der skete en fejl da {0} skulles slåes fra',
'2faDisableForUser': 'Er du sikker på, at du vil fjerne "{0}" for denne bruger?',
'2faQrCodeAlt': 'QR kode for totrinsbekræftelse med {0}',
'2faQrCodeTitle': 'QR kode for totrinsbekræftelse med {0}',
'2faQrCodeDescription': 'Scan QR koden med din autentificeringsapp',
'2faCodeInput': 'Indtast din verifikationskode',
'2faCodeInputHelp': 'Indtast din verifikationskode fra din autentificeringsapp',
'2faInvalidCode': 'Den indtastede kode er ugyldig',
emailRequired: 'Required - enter an email address for this user',
duplicateLogin: 'A user with this login already exists',
nameRequired: 'Required - enter a name for this user',

View File

@@ -1932,11 +1932,11 @@ export default {
'2faDisableText':
'If you wish to disable this two-factor provider, then you must enter the code shown on your authentication device:',
'2faProviderIsEnabled': 'This two-factor provider is enabled',
'2faProviderIsEnabledMsg': 'This two-factor provider is now enabled',
'2faProviderIsNotEnabledMsg': 'Something went wrong with trying to enable this two-factor provider',
'2faProviderIsDisabledMsg': 'This two-factor provider is now disabled',
'2faProviderIsNotDisabledMsg': 'Something went wrong with trying to disable this two-factor provider',
'2faDisableForUser': 'Do you want to disable this two-factor provider for this user?',
'2faProviderIsEnabledMsg': '{0} is now enabled',
'2faProviderIsNotEnabledMsg': 'Something went wrong with trying to enable {0}',
'2faProviderIsDisabledMsg': '{0} is now disabled',
'2faProviderIsNotDisabledMsg': 'Something went wrong with trying to disable {0}',
'2faDisableForUser': 'Do you want to disable "{0}" on this user?',
'2faQrCodeAlt': 'QR code for two-factor authentication with {0}',
'2faQrCodeTitle': 'QR code for two-factor authentication with {0}',
'2faQrCodeDescription': 'Scan this QR code with your authenticator app to enable two-factor authentication',

View File

@@ -60,10 +60,10 @@ export const data: Array<UmbMockUserModel> = [
updateDate: '2023-10-12T18:30:32.879Z',
createDate: '2023-10-12T18:30:32.879Z',
failedLoginAttempts: 0,
userGroupIds: ['user-group-administrators-id'],
userGroupIds: ['user-group-editors-id'],
userName: '',
avatarUrls: [],
isAdmin: true,
isAdmin: false,
},
{
id: 'ff2f4a50-d3d4-4bc4-869d-c7948c160e54',
@@ -79,10 +79,10 @@ export const data: Array<UmbMockUserModel> = [
updateDate: '2023-10-12T18:30:32.879Z',
createDate: '2023-10-12T18:30:32.879Z',
failedLoginAttempts: 0,
userGroupIds: ['user-group-administrators-id'],
userGroupIds: ['user-group-editors-id'],
userName: '',
avatarUrls: [],
isAdmin: true,
isAdmin: false,
},
{
id: 'c290c6d9-9f12-4838-8567-621b52a178de',
@@ -98,13 +98,17 @@ export const data: Array<UmbMockUserModel> = [
updateDate: '2023-10-12T18:30:32.879Z',
createDate: '2023-10-12T18:30:32.879Z',
failedLoginAttempts: 25,
userGroupIds: ['user-group-administrators-id'],
userGroupIds: ['user-group-editors-id', 'user-group-sensitive-data-id'],
userName: '',
avatarUrls: [],
isAdmin: true,
isAdmin: false,
},
];
/**
* Mock data for MFA login providers
* This is usually linked to a user, but for the sake of the mock, we're just going to have a list of providers
*/
export const mfaLoginProviders: Array<UserTwoFactorProviderModel> = [
{
isEnabledOnUser: true,

View File

@@ -204,7 +204,7 @@ const createMockMapper = (item: CreateUserRequestModel): UmbMockUserModel => {
lastLoginDate: null,
lastLockoutDate: null,
lastPasswordChangeDate: null,
isAdmin: false,
isAdmin: item.userGroupIds.includes(umbUserGroupMockDb.getAll()[0].id),
};
};

View File

@@ -81,8 +81,14 @@ export const manifestDevelopmentHandler = rest.get(umbracoPath('/package/manifes
extensions: [
{
type: 'mfaLoginProvider',
alias: 'My.MfaLoginProvider.Custom',
name: 'My Custom MFA Provider',
alias: 'My.MfaLoginProvider.Custom.Google',
name: 'My Custom Google MFA Provider',
forProviderName: 'Google Authenticator',
},
{
type: 'mfaLoginProvider',
alias: 'My.MfaLoginProvider.Custom.SMS',
name: 'My Custom SMS MFA Provider',
forProviderName: 'sms',
meta: {
label: 'Setup SMS Verification',

View File

@@ -8,6 +8,7 @@ import { handlers as changePasswordHandlers } from './change-password.handlers.j
import { handlers as unlockHandlers } from './unlock.handlers.js';
import { handlers as inviteHandlers } from './invite.handlers.js';
import { handlers as filterHandlers } from './filter.handlers.js';
import { handlers as mfaHandlers } from './mfa.handlers.js';
export const handlers = [
...itemHandlers,
@@ -20,4 +21,5 @@ export const handlers = [
...filterHandlers,
...inviteHandlers,
...detailHandlers,
...mfaHandlers,
];

View File

@@ -0,0 +1,21 @@
const { rest } = window.MockServiceWorker;
import { umbUserMockDb } from '../../data/user/user.db.js';
import { UMB_SLUG } from './slug.js';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
rest.get(umbracoPath(`${UMB_SLUG}/:id/2fa`), (_req, res, ctx) => {
const mfaLoginProviders = umbUserMockDb.getMfaLoginProviders();
return res(ctx.status(200), ctx.json(mfaLoginProviders));
}),
rest.delete(umbracoPath(`${UMB_SLUG}/:id/2fa/:providerName`), async (req, res, ctx) => {
const mfaLoginProviders = umbUserMockDb.getMfaLoginProviders();
const provider = mfaLoginProviders.find((p) => p.providerName === req.params.providerName);
if (!provider) {
return res(ctx.status(404));
}
provider.isEnabledOnUser = false;
return res(ctx.status(200));
}),
];

View File

@@ -6,7 +6,7 @@ export const workspaceViews: Array<ManifestWorkspaceView> = [
type: 'workspaceView',
alias: 'Umb.WorkspaceView.BlockGridAreaType.Settings',
name: 'Block Grid Area Type Workspace Settings View',
js: () => import('./block-grid-area-type-workspace-view-settings.element.js'),
js: () => import('./settings.element.js'),
weight: 1000,
meta: {
label: 'Settings',

View File

@@ -65,6 +65,9 @@ export class UmbDebugElement extends UmbLitElement {
}
private _openDialog() {
this._update();
this._modalContext?.open(this, UMB_CONTEXT_DEBUGGER_MODAL, {
data: {
content: html`${this._renderContextAliases()}`,
@@ -73,9 +76,10 @@ export class UmbDebugElement extends UmbLitElement {
}
private _renderDialog() {
return html` <div id="container">
return html`
<div id="container">
<uui-badge color="danger" look="primary" attention @click="${this._openDialog}">
<uui-icon name="icon-bug"></uui-icon> Debug
<uui-icon name="icon-bug"></uui-icon>&nbsp;Debug
</uui-badge>
</div>`;
}

View File

@@ -13,7 +13,9 @@ export default class UmbContextDebuggerModalElement extends UmbModalBaseElement<
return html`
<uui-dialog-layout>
<span slot="headline"> <uui-icon name="icon-bug"></uui-icon> Debug: Contexts </span>
<uui-scroll-container id="field-settings"> ${this.data?.content} </uui-scroll-container>
<uui-scroll-container id="field-settings">
${this.data?.content}
</uui-scroll-container>
<uui-button slot="actions" look="primary" label="Close sidebar" @click="${this._handleClose}">Close</uui-button>
</uui-dialog-layout>
`;

View File

@@ -16,24 +16,24 @@ This can help with the developer experience to quickly see what is available to
### Usage
The `<umb-debug>` component can be used in two different ways, either as a button or as a dialog. By default it is rendered as a button and the debug information about available contexts is dissplayed inline to where the element is placed.
The `<umb-debug>` component can be used in two different ways, either as a button or as a dialog. By default it is rendered as a button and the debug information about available contexts is displayed inline to where the `umb-debug` element is placed.
<img src={DebugImage} width="100%" />
```typescript
// This will add a Debug button to the UI and once clicked the information about avilable contextes will slide down
<umb-debug enabled></umb-debug>
<umb-debug visible></umb-debug>
```
#### Dialog
This example uses an additional property/attribute `dialog` which adds a smaller badge to the UI as opposed to a button and will open the information in a small dialog/modal from the right hand side, this may be more useful to use when space is limited in the UI to add a button and pane of information directly to where the element is placed.
This example uses an additional property/attribute `dialog` which adds a smaller badge to the UI as opposed to a button and will open the information in a dialog/modal on the right hand side, this may be more useful to use when space is limited in the UI to add a button and pane of information directly to where the element is placed.
<img src={DebugDialogImage} width="100%" />
```typescript
// This will open the debug information in a small dialog/modal from the right hand side
<umb-debug enabled dialog></umb-debug>
<umb-debug visible dialog></umb-debug>
```
#### Disable
@@ -41,6 +41,8 @@ This example uses an additional property/attribute `dialog` which adds a smaller
You may wish to temporarily hide or disable the debug information but return to it later on in the development process.
```typescript
// To hide or remove the button ensure you remove the enabled attribute or set the enabled property to false
// To hide or remove the button ensure you remove the `visible` attribute or set it to false
<umb-debug></umb-debug>
<umb-debug ?visible=${false}></umb-debug>
```

View File

@@ -4,7 +4,7 @@ export const manifest: ManifestPropertyEditorUi = {
type: 'propertyEditorUi',
alias: 'Umb.PropertyEditorUi.CollectionView.BulkActionPermissions',
name: 'Collection View Bulk Action Permissions Property Editor UI',
element: () => import('./property-editor-ui-collection-view-bulk-action-permissions.element.js'),
element: () => import('./permissions.element.js'),
meta: {
label: 'Collection View Bulk Action Permissions',
icon: 'icon-autofill',

View File

@@ -1,4 +1,4 @@
import type { UmbCollectionBulkActionPermissions } from '../../../../../../core/collection/types.js';
import type { UmbCollectionBulkActionPermissions } from '../../../../../collection/types.js';
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/extension-registry';
import { html, customElement, property, css } from '@umbraco-cms/backoffice/external/lit';
import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui';
@@ -15,10 +15,10 @@ type BulkActionPermissionType =
| 'allowBulkUnpublish';
/**
* @element umb-property-editor-ui-collection-view-bulk-action-permissions
* @element umb-property-editor-ui-collection-view-permissions
*/
@customElement('umb-property-editor-ui-collection-view-bulk-action-permissions')
export class UmbPropertyEditorUICollectionViewBulkActionPermissionsElement
@customElement('umb-property-editor-ui-collection-view-permissions')
export class UmbPropertyEditorUICollectionViewPermissionsElement
extends UmbLitElement
implements UmbPropertyEditorUiElement
{
@@ -98,10 +98,10 @@ export class UmbPropertyEditorUICollectionViewBulkActionPermissionsElement
];
}
export default UmbPropertyEditorUICollectionViewBulkActionPermissionsElement;
export default UmbPropertyEditorUICollectionViewPermissionsElement;
declare global {
interface HTMLElementTagNameMap {
'umb-property-editor-ui-collection-view-bulk-action-permissions': UmbPropertyEditorUICollectionViewBulkActionPermissionsElement;
'umb-property-editor-ui-collection-view-permissions': UmbPropertyEditorUICollectionViewPermissionsElement;
}
}

View File

@@ -0,0 +1,15 @@
import type { Meta, Story } from '@storybook/web-components';
import type { UmbPropertyEditorUICollectionViewPermissionsElement } from './permissions.element.js';
import { html } from '@umbraco-cms/backoffice/external/lit';
import './permissions.element.js';
export default {
title: 'Property Editor UIs/Collection View Bulk Action Permissions',
component: 'umb-property-editor-ui-collection-view-permissions',
id: 'umb-property-editor-ui-collection-view-permissions',
} as Meta;
export const AAAOverview: Story<UmbPropertyEditorUICollectionViewPermissionsElement> = () =>
html`<umb-property-editor-ui-collection-view-permissions></umb-property-editor-ui-collection-view-permissions>`;
AAAOverview.storyName = 'Overview';

View File

@@ -1,18 +1,18 @@
import { expect, fixture, html } from '@open-wc/testing';
import { UmbPropertyEditorUICollectionViewBulkActionPermissionsElement } from './property-editor-ui-collection-view-bulk-action-permissions.element.js';
import { UmbPropertyEditorUICollectionViewPermissionsElement } from './permissions.element.js';
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
describe('UmbPropertyEditorUICollectionViewBulkActionPermissionsElement', () => {
let element: UmbPropertyEditorUICollectionViewBulkActionPermissionsElement;
describe('UmbPropertyEditorUICollectionViewPermissionsElement', () => {
let element: UmbPropertyEditorUICollectionViewPermissionsElement;
beforeEach(async () => {
element = await fixture(html`
<umb-property-editor-ui-collection-view-bulk-action-permissions></umb-property-editor-ui-collection-view-bulk-action-permissions>
<umb-property-editor-ui-collection-view-permissions></umb-property-editor-ui-collection-view-permissions>
`);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbPropertyEditorUICollectionViewBulkActionPermissionsElement);
expect(element).to.be.instanceOf(UmbPropertyEditorUICollectionViewPermissionsElement);
});
if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {

View File

@@ -1,15 +0,0 @@
import type { Meta, Story } from '@storybook/web-components';
import type { UmbPropertyEditorUICollectionViewBulkActionPermissionsElement } from './property-editor-ui-collection-view-bulk-action-permissions.element.js';
import { html } from '@umbraco-cms/backoffice/external/lit';
import './property-editor-ui-collection-view-bulk-action-permissions.element.js';
export default {
title: 'Property Editor UIs/Collection View Bulk Action Permissions',
component: 'umb-property-editor-ui-collection-view-bulk-action-permissions',
id: 'umb-property-editor-ui-collection-view-bulk-action-permissions',
} as Meta;
export const AAAOverview: Story<UmbPropertyEditorUICollectionViewBulkActionPermissionsElement> = () =>
html`<umb-property-editor-ui-collection-view-bulk-action-permissions></umb-property-editor-ui-collection-view-bulk-action-permissions>`;
AAAOverview.storyName = 'Overview';

View File

@@ -1,8 +1,8 @@
import type { Meta, Story } from '@storybook/web-components';
import type { UmbPropertyEditorUICollectionViewColumnConfigurationElement } from './property-editor-ui-collection-view-column-configuration.element.js';
import type { UmbPropertyEditorUICollectionViewColumnConfigurationElement } from './column-configuration.element.js';
import { html } from '@umbraco-cms/backoffice/external/lit';
import './property-editor-ui-collection-view-column-configuration.element.js';
import './column-configuration.element.js';
export default {
title: 'Property Editor UIs/Collection View Column Configuration',

View File

@@ -1,5 +1,5 @@
import { expect, fixture, html } from '@open-wc/testing';
import { UmbPropertyEditorUICollectionViewColumnConfigurationElement } from './property-editor-ui-collection-view-column-configuration.element.js';
import { UmbPropertyEditorUICollectionViewColumnConfigurationElement } from './column-configuration.element.js';
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
describe('UmbPropertyEditorUICollectionViewColumnConfigurationElement', () => {

View File

@@ -4,7 +4,7 @@ export const manifest: ManifestPropertyEditorUi = {
type: 'propertyEditorUi',
alias: 'Umb.PropertyEditorUi.CollectionView.ColumnConfiguration',
name: 'Collection View Column Configuration Property Editor UI',
element: () => import('./property-editor-ui-collection-view-column-configuration.element.js'),
element: () => import('./column-configuration.element.js'),
meta: {
label: 'Collection View Column Configuration',
icon: 'icon-autofill',

View File

@@ -1,8 +1,8 @@
import type { Meta, Story } from '@storybook/web-components';
import type { UmbPropertyEditorUICollectionViewLayoutConfigurationElement } from './property-editor-ui-collection-view-layout-configuration.element.js';
import type { UmbPropertyEditorUICollectionViewLayoutConfigurationElement } from './layout-configuration.element.js';
import { html } from '@umbraco-cms/backoffice/external/lit';
import './property-editor-ui-collection-view-layout-configuration.element.js';
import './layout-configuration.element.js';
export default {
title: 'Property Editor UIs/Collection View Layout Configuration',

View File

@@ -1,5 +1,5 @@
import { expect, fixture, html } from '@open-wc/testing';
import { UmbPropertyEditorUICollectionViewLayoutConfigurationElement } from './property-editor-ui-collection-view-layout-configuration.element.js';
import { UmbPropertyEditorUICollectionViewLayoutConfigurationElement } from './layout-configuration.element.js';
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
describe('UmbPropertyEditorUICollectionViewLayoutConfigurationElement', () => {

View File

@@ -4,7 +4,7 @@ export const manifest: ManifestPropertyEditorUi = {
type: 'propertyEditorUi',
alias: 'Umb.PropertyEditorUi.CollectionView.LayoutConfiguration',
name: 'Collection View Column Configuration Property Editor UI',
element: () => import('./property-editor-ui-collection-view-layout-configuration.element.js'),
element: () => import('./layout-configuration.element.js'),
meta: {
label: 'Collection View Layout Configuration',
icon: 'icon-autofill',

View File

@@ -4,7 +4,7 @@ export const manifest: ManifestPropertyEditorUi = {
type: 'propertyEditorUi',
alias: 'Umb.PropertyEditorUi.CollectionView.OrderBy',
name: 'Collection View Column Configuration Property Editor UI',
element: () => import('./property-editor-ui-collection-view-order-by.element.js'),
element: () => import('./order-by.element.js'),
meta: {
label: 'Collection View Order By',
icon: 'icon-autofill',

View File

@@ -1,8 +1,8 @@
import type { Meta, Story } from '@storybook/web-components';
import type { UmbPropertyEditorUICollectionViewOrderByElement } from './property-editor-ui-collection-view-order-by.element.js';
import type { UmbPropertyEditorUICollectionViewOrderByElement } from './order-by.element.js';
import { html } from '@umbraco-cms/backoffice/external/lit';
import './property-editor-ui-collection-view-order-by.element.js';
import './order-by.element.js';
export default {
title: 'Property Editor UIs/Collection View Order By',

View File

@@ -1,5 +1,5 @@
import { expect, fixture, html } from '@open-wc/testing';
import { UmbPropertyEditorUICollectionViewOrderByElement } from './property-editor-ui-collection-view-order-by.element.js';
import { UmbPropertyEditorUICollectionViewOrderByElement } from './order-by.element.js';
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
describe('UmbPropertyEditorUICollectionViewOrderByElement', () => {

View File

@@ -1,6 +1,6 @@
import { manifest as bulkActionPermissions } from './config/bulk-action-permissions/manifests.js';
import { manifest as columnConfiguration } from './config/column-configuration/manifests.js';
import { manifest as layoutConfiguration } from './config/layout-configuration/manifests.js';
import { manifest as columnConfiguration } from './config/column/manifests.js';
import { manifest as layoutConfiguration } from './config/layout/manifests.js';
import { manifest as orderBy } from './config/order-by/manifests.js';
import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/extension-registry';

View File

@@ -2,3 +2,4 @@ export * from './resource.controller.js';
export * from './tryExecute.function.js';
export * from './tryExecuteAndNotify.function.js';
export * from './extractUmbColorVariable.function.js';
export * from './apiTypeValidators.function.js';

View File

@@ -91,7 +91,7 @@ export class UmbInputSectionElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -89,7 +89,7 @@ export class UmbDataTypeInputElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -121,7 +121,7 @@ export class UmbInputDocumentTypeElement extends FormControlMixin(UmbLitElement)
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -120,7 +120,7 @@ export class UmbInputDocumentElement extends FormControlMixin(UmbLitElement) {
this._editDocumentPath = routeBuilder({});
});
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -92,7 +92,7 @@ export class UmbInputLanguageElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -89,7 +89,7 @@ export class UmbInputMediaTypeElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -120,7 +120,7 @@ export class UmbInputMediaElement extends FormControlMixin(UmbLitElement) {
this._editMediaPath = routeBuilder({});
});
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
this.addValidator(

View File

@@ -122,7 +122,7 @@ export class UmbInputMemberGroupElement extends FormControlMixin(UmbLitElement)
this._editMemberGroupPath = routeBuilder({});
});
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => {
this._items = selectedItems;
});

View File

@@ -89,7 +89,7 @@ export class UmbInputMemberTypeElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -121,7 +121,7 @@ export class UmbInputMemberElement extends FormControlMixin(UmbLitElement) {
this._editMemberPath = routeBuilder({});
});
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => {
this._items = selectedItems;
});

View File

@@ -90,7 +90,7 @@ export class UmbInputStaticFileElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -89,7 +89,7 @@ export class UmbStylesheetInputElement extends FormControlMixin(UmbLitElement) {
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
);
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')));
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
}

View File

@@ -1,8 +1,8 @@
import type { UmbMfaProviderConfigurationElementProps } from '../types.js';
import type { UmbMfaProviderConfigurationCallback, UmbMfaProviderConfigurationElementProps } from '../types.js';
import { UserResource } from '@umbraco-cms/backoffice/external/backend-api';
import { css, customElement, html, property, state, query } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import { isApiError, tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import { UMB_NOTIFICATION_CONTEXT, type UmbNotificationColor } from '@umbraco-cms/backoffice/notification';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui';
@@ -20,7 +20,8 @@ export class UmbMfaProviderDefaultElement extends UmbLitElement implements UmbMf
displayName = '';
@property({ attribute: false })
callback: (providerName: string, code: string, secret: string) => Promise<boolean> = async () => false;
callback: (providerName: string, code: string, secret: string) => UmbMfaProviderConfigurationCallback =
async () => ({});
@property({ attribute: false })
close = () => {};
@@ -174,16 +175,27 @@ export class UmbMfaProviderDefaultElement extends UmbLitElement implements UmbMf
if (!code) return;
this._buttonState = 'waiting';
const successful = await this.callback(this.providerName, code, this._secret);
const { error } = await this.callback(this.providerName, code, this._secret);
if (successful) {
this.peek(this.localize.term('user_2faProviderIsEnabled'));
if (!error) {
this.peek(this.localize.term('user_2faProviderIsEnabledMsg', this.displayName ?? this.providerName));
this._buttonState = 'success';
this.close();
} else {
this.codeField?.setCustomValidity(this.localize.term('user_2faInvalidCode'));
this.codeField?.focus();
this._buttonState = 'failed';
if (isApiError(error)) {
if (error.body.operationStatus === 'InvalidCode') {
this.codeField?.setCustomValidity(this.localize.term('user_2faInvalidCode'));
this.codeField?.focus();
} else {
this.peek(
this.localize.term('user_2faProviderIsNotEnabledMsg', this.displayName ?? this.providerName),
'warning',
);
}
} else {
this.peek(error.message, 'warning');
}
}
}

View File

@@ -55,6 +55,15 @@ export class UmbCurrentUserContext extends UmbContextBase<UmbCurrentUserContext>
return currentUser?.unique === userUnique;
}
/**
* Checks if the current user is an admin.
* @returns True if the current user is an admin, otherwise false
*/
async isCurrentUserAdmin(): Promise<boolean> {
const currentUser = await firstValueFrom(this.currentUser);
return currentUser?.isAdmin ?? false;
}
#observeIsAuthorized() {
if (!this.#authContext) return;
this.observe(this.#authContext.isAuthorized, (isAuthorized) => {

View File

@@ -5,6 +5,7 @@ import { css, customElement, html, query, state } from '@umbraco-cms/backoffice/
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { isApiError } from '@umbraco-cms/backoffice/resources';
@customElement('umb-current-user-mfa-disable-provider-modal')
export class UmbCurrentUserMfaDisableProviderModalElement extends UmbModalBaseElement<
@@ -99,16 +100,27 @@ export class UmbCurrentUserMfaDisableProviderModalElement extends UmbModalBaseEl
if (!code) return;
this._buttonState = 'waiting';
const success = await this.#currentUserRepository.disableMfaProvider(this.data.providerName, code);
const { error } = await this.#currentUserRepository.disableMfaProvider(this.data.providerName, code);
if (success) {
this.#peek(this.localize.term('user_2faProviderIsDisabledMsg'));
if (!error) {
this.#peek(this.localize.term('user_2faProviderIsDisabledMsg', this.data.displayName ?? this.data.providerName));
this.modalContext?.submit();
this._buttonState = 'success';
} else {
this._codeInput.setCustomValidity(this.localize.term('user_2faInvalidCode'));
this._codeInput.focus();
this._buttonState = 'failed';
if (isApiError(error)) {
if (error.body.operationStatus === 'InvalidCode') {
this._codeInput.setCustomValidity(this.localize.term('user_2faInvalidCode'));
this._codeInput.focus();
} else {
this.#peek(
this.localize.term('user_2faProviderIsNotDisabledMsg', this.data.displayName ?? this.data.providerName),
'warning',
);
}
} else {
this.#peek(error.message, 'warning');
}
}
}

View File

@@ -1,10 +1,7 @@
import type { UmbCurrentUserMfaProviderModel } from '../types.js';
import { UmbCurrentUserServerDataSource } from './current-user.server.data-source.js';
import { UMB_CURRENT_USER_STORE_CONTEXT } from './current-user.store.js';
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
import { UserResource } from '@umbraco-cms/backoffice/external/backend-api';
/**
* A repository for the current user
@@ -13,15 +10,13 @@ import { UserResource } from '@umbraco-cms/backoffice/external/backend-api';
* @extends {UmbRepositoryBase}
*/
export class UmbCurrentUserRepository extends UmbRepositoryBase {
#currentUserSource: UmbCurrentUserServerDataSource;
#currentUserSource = new UmbCurrentUserServerDataSource(this._host);
#currentUserStore?: typeof UMB_CURRENT_USER_STORE_CONTEXT.TYPE;
#init: Promise<unknown>;
constructor(host: UmbControllerHost) {
super(host);
this.#currentUserSource = new UmbCurrentUserServerDataSource(host);
this.#init = Promise.all([
this.consumeContext(UMB_CURRENT_USER_STORE_CONTEXT, (instance) => {
this.#currentUserStore = instance;
@@ -67,19 +62,16 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase {
* @param code The activation code of the provider to enable
* @memberof UmbCurrentUserRepository
*/
async enableMfaProvider(providerName: string, code: string, secret: string): Promise<boolean> {
const { error } = await tryExecuteAndNotify(
this._host,
UserResource.postUserCurrent2FaByProviderName({ providerName, requestBody: { code, secret } }),
);
async enableMfaProvider(providerName: string, code: string, secret: string) {
const { error } = await this.#currentUserSource.enableMfaProvider(providerName, code, secret);
if (error) {
return false;
return { error };
}
this.#currentUserStore?.updateMfaProvider({ providerName, isEnabledOnUser: true });
return true;
return {};
}
/**
@@ -88,19 +80,16 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase {
* @param code The activation code of the provider to disable
* @memberof UmbCurrentUserRepository
*/
async disableMfaProvider(providerName: string, code: string): Promise<boolean> {
const { error } = await tryExecuteAndNotify(
this._host,
UserResource.deleteUserCurrent2FaByProviderName({ providerName, code }),
);
async disableMfaProvider(providerName: string, code: string) {
const { error } = await this.#currentUserSource.disableMfaProvider(providerName, code);
if (error) {
return false;
return { error };
}
this.#currentUserStore?.updateMfaProvider({ providerName, isEnabledOnUser: false });
return true;
return {};
}
}

View File

@@ -1,7 +1,7 @@
import type { UmbCurrentUserModel } from '../types.js';
import { SecurityResource, UserResource } from '@umbraco-cms/backoffice/external/backend-api';
import { UserResource } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import { tryExecute, tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
/**
* A data source for the current user that fetches data from the server
@@ -43,6 +43,7 @@ export class UmbCurrentUserServerDataSource {
fallbackPermissions: data.fallbackPermissions,
permissions: data.permissions,
allowedSections: data.allowedSections,
isAdmin: data.isAdmin,
};
return { data: user };
}
@@ -63,4 +64,32 @@ export class UmbCurrentUserServerDataSource {
return { error };
}
/**
* Enable an MFA provider
*/
async enableMfaProvider(providerName: string, code: string, secret: string) {
const { error } = await tryExecute(
UserResource.postUserCurrent2FaByProviderName({ providerName, requestBody: { code, secret } }),
);
if (error) {
return { error };
}
return {};
}
/**
* Disable an MFA provider
*/
async disableMfaProvider(providerName: string, code: string) {
const { error } = await tryExecute(UserResource.deleteUserCurrent2FaByProviderName({ providerName, code }));
if (error) {
return { error };
}
return {};
}
}

View File

@@ -72,6 +72,7 @@ export class UmbCurrentUserStore extends UmbContextBase<UmbCurrentUserStore> {
documentStartNodeUniques: updatedCurrentUser.documentStartNodeUniques,
mediaStartNodeUniques: updatedCurrentUser.mediaStartNodeUniques,
avatarUrls: updatedCurrentUser.avatarUrls,
isAdmin: updatedCurrentUser.isAdmin,
};
this.update(mappedCurrentUser);

View File

@@ -1,4 +1,6 @@
import type {
ApiError,
CancelError,
DocumentPermissionPresentationModel,
UnknownTypePermissionPresentationModel,
UserTwoFactorProviderModel,
@@ -18,10 +20,13 @@ export interface UmbCurrentUserModel {
allowedSections: Array<string>;
fallbackPermissions: Array<string>;
permissions: Array<DocumentPermissionPresentationModel | UnknownTypePermissionPresentationModel>;
isAdmin: boolean;
}
export type UmbCurrentUserMfaProviderModel = UserTwoFactorProviderModel;
export type UmbMfaProviderConfigurationCallback = Promise<{ error?: ApiError | CancelError }>;
export interface UmbMfaProviderConfigurationElementProps {
/**
* The name of the provider reflecting the provider name in the backend.
@@ -38,9 +43,9 @@ export interface UmbMfaProviderConfigurationElementProps {
* @param providerName The name of the provider to enable.
* @param code The authentication code from the authentication method.
* @param secret The secret from the authentication backend.
* @returns True if the provider action was executed successfully.
* @returns A promise that resolves when the action is completed with an error if the action failed.
*/
callback: (providerName: string, code: string, secret: string) => Promise<boolean>;
callback: (providerName: string, code: string, secret: string) => UmbMfaProviderConfigurationCallback;
/**
* Call this function to close the modal.

View File

@@ -2,18 +2,24 @@ import { UMB_CURRENT_USER_CONTEXT } from '../current-user.context.js';
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/**
* Check if the current user is the user with the given unique id
*/
export const isCurrentUser = async (host: UmbControllerHost, userUnique: string) => {
const ctrl = new UmbContextConsumerController(host, UMB_CURRENT_USER_CONTEXT);
let currentUserContext = await ctrl.asPromise();
const currentUserContext = await ctrl.asPromise();
ctrl.destroy();
const controller = new UmbContextConsumerController(host, UMB_CURRENT_USER_CONTEXT, (context) => {
currentUserContext = context;
});
await controller.asPromise();
controller.destroy();
return await currentUserContext!.isUserCurrentUser(userUnique);
return currentUserContext!.isUserCurrentUser(userUnique);
};
/**
* Check if the current user is an admin
*/
export const isCurrentUserAnAdmin = async (host: UmbControllerHost) => {
const ctrl = new UmbContextConsumerController(host, UMB_CURRENT_USER_CONTEXT);
const currentUserContext = await ctrl.asPromise();
ctrl.destroy();
return currentUserContext!.isCurrentUserAdmin();
};

View File

@@ -91,7 +91,7 @@ export class UmbUserGroupInputElement extends FormControlMixin(UmbLitElement) {
this.observe(
this.#pickerContext.selection,
(selection) => (super.value = selection.join(',')),
(selection) => (this.value = selection.join(',')),
'umbUserGroupInputSelectionObserver',
);
this.observe(

View File

@@ -56,6 +56,7 @@ export class UmbUserCollectionServerDataSource implements UmbCollectionDataSourc
lastLoginDate: item.lastLoginDate || null,
lastLockoutDate: item.lastLockoutDate || null,
lastPasswordChangeDate: item.lastPasswordChangeDate || null,
isAdmin: item.isAdmin,
};
return userDetail;

View File

@@ -93,7 +93,7 @@ export class UmbUserInputElement extends FormControlMixin(UmbLitElement) {
this.observe(
this.#pickerContext.selection,
(selection) => (super.value = selection.join(',')),
(selection) => (this.value = selection.join(',')),
'umbUserInputSelectionObserver',
);
this.observe(

View File

@@ -1,11 +1,13 @@
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 userAllowMfaActionManifest } from './user-allow-mfa-action.condition.js';
import { manifest as userAllowDeleteActionManifest } from './user-allow-delete-action.condition.js';
export const manifests = [
userAllowDisableActionManifest,
userAllowEnableActionManifest,
userAllowUnlockActionManifest,
userAllowMfaActionManifest,
userAllowDeleteActionManifest,
];

View File

@@ -8,12 +8,20 @@ import type {
UmbExtensionCondition,
} from '@umbraco-cms/backoffice/extension-api';
import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
export abstract class UmbUserActionConditionBase
extends UmbConditionBase<UmbConditionConfigBase>
implements UmbExtensionCondition
{
/**
* The unique identifier of the user being edited
*/
protected userUnique?: string;
/**
* The state of the user being edited
*/
protected userState?: UmbUserStateEnum | null;
constructor(host: UmbControllerHost, args: UmbConditionControllerArguments<UmbConditionConfigBase>) {
@@ -21,23 +29,13 @@ export abstract class UmbUserActionConditionBase
this.consumeContext(UMB_USER_WORKSPACE_CONTEXT, (context) => {
this.observe(
context.unique,
(unique) => {
observeMultiple([context.unique, context.state]),
([unique, state]) => {
this.userUnique = unique;
this._onUserDataChange();
},
'umbUserUnique',
);
this.observe(
context.state,
(state) => {
this.userState = state;
// TODO: Investigate if we can remove this observation and just use the unique change to trigger the state change. [NL]
// Can user state change over time? if not then this observation is not needed and then we just need to retrieve the state when the unique has changed. [NL]
// These two could also be combined via the observeMultiple method, that could prevent triggering onUserDataChanged twice. [NL]
this._onUserDataChange();
},
'umbUserState',
'_umbActiveUser',
);
});
}
@@ -51,6 +49,7 @@ export abstract class UmbUserActionConditionBase
protected async isCurrentUser() {
return this.userUnique ? isCurrentUser(this._host, this.userUnique) : false;
}
/**
* Called when the user data changes
* @protected

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 UmbUserAllowMfaActionCondition 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('mfaLoginProvider'),
(exts) => (this.permitted = exts.length > 0),
'_userAllowMfaActionConditionProviders',
);
}
}
export const manifest: ManifestCondition = {
type: 'condition',
name: 'User Allow Mfa Action Condition',
alias: 'Umb.Condition.User.AllowMfaAction',
api: UmbUserAllowMfaActionCondition,
};

View File

@@ -16,18 +16,20 @@ export class UmbDisableUserEntityAction extends UmbEntityActionBase<never> {
const itemRepository = new UmbUserItemRepository(this);
const { data } = await itemRepository.requestItems([this.args.unique]);
if (data) {
const item = data[0];
await umbConfirmModal(this._host, {
headline: `Disable ${item.name}`,
content: 'Are you sure you want to disable this user?',
color: 'danger',
confirmLabel: 'Disable',
});
const disableUserRepository = new UmbDisableUserRepository(this);
await disableUserRepository.disable([this.args.unique]);
if (!data?.length) {
throw new Error('Item not found.');
}
const item = data[0];
await umbConfirmModal(this._host, {
headline: `Disable ${item.name}`,
content: 'Are you sure you want to disable this user?',
color: 'danger',
confirmLabel: 'Disable',
});
const disableUserRepository = new UmbDisableUserRepository(this);
await disableUserRepository.disable([this.args.unique]);
}
}

View File

@@ -16,17 +16,19 @@ export class UmbEnableUserEntityAction extends UmbEntityActionBase<never> {
const itemRepository = new UmbUserItemRepository(this);
const { data } = await itemRepository.requestItems([this.args.unique]);
if (data) {
const item = data[0];
await umbConfirmModal(this._host, {
headline: `Enable ${item.name}`,
content: 'Are you sure you want to enable this user?',
confirmLabel: 'Enable',
});
const enableRepository = new UmbEnableUserRepository(this);
await enableRepository.enable([this.args.unique]);
if (!data?.length) {
throw new Error('Item not found.');
}
const item = data[0];
await umbConfirmModal(this._host, {
headline: `Enable ${item.name}`,
content: 'Are you sure you want to enable this user?',
confirmLabel: 'Enable',
});
const enableRepository = new UmbEnableUserRepository(this);
await enableRepository.enable([this.args.unique]);
}
}

View File

@@ -4,6 +4,7 @@ import { UmbDisableUserEntityAction } from './disable/disable-user.action.js';
import { UmbEnableUserEntityAction } from './enable/enable-user.action.js';
import { UmbChangeUserPasswordEntityAction } from './change-password/change-user-password.action.js';
import { UmbUnlockUserEntityAction } from './unlock/unlock-user.action.js';
import { UmbMfaUserEntityAction } from './mfa/mfa-user.action.js';
import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
const entityActions: Array<ManifestTypes> = [
@@ -90,6 +91,24 @@ const entityActions: Array<ManifestTypes> = [
},
],
},
{
type: 'entityAction',
kind: 'default',
alias: 'Umb.EntityAction.User.ConfigureMfa',
name: 'Configure MFA Entity Action',
weight: 500,
api: UmbMfaUserEntityAction,
forEntityTypes: [UMB_USER_ENTITY_TYPE],
meta: {
icon: 'icon-settings',
label: 'Configure Two-Factor',
},
conditions: [
{
alias: 'Umb.Condition.User.AllowMfaAction',
},
],
},
];
export const manifests = [...entityActions];

View File

@@ -0,0 +1,42 @@
import { UMB_USER_MFA_MODAL } from '../../modals/user-mfa/user-mfa-modal.token.js';
import { UMB_CURRENT_USER_MFA_MODAL } from '../../../current-user/modals/current-user-mfa/current-user-mfa-modal.token.js';
import type { UmbEntityActionArgs } from '@umbraco-cms/backoffice/entity-action';
import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
export class UmbMfaUserEntityAction extends UmbEntityActionBase<never> {
constructor(host: UmbControllerHost, args: UmbEntityActionArgs<never>) {
super(host, args);
}
async execute() {
const { unique } = this.args;
if (!unique) throw new Error('Unique is not available');
const currentUserContext = await this.getContext(UMB_CURRENT_USER_CONTEXT);
const currentUserModel = await firstValueFrom(currentUserContext.currentUser);
if (!currentUserModel) throw new Error('Current user is not available');
// If you clicked on yourself, we can just use the current user modal
const modalManagerContext = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
if (currentUserModel.unique === unique) {
await modalManagerContext
.open(this, UMB_CURRENT_USER_MFA_MODAL)
.onSubmit()
.catch(() => undefined);
return;
}
// Otherwise we will show the generic mfa modal
await modalManagerContext
.open(this, UMB_USER_MFA_MODAL, {
data: { unique },
})
.onSubmit()
.catch(() => undefined);
}
}

View File

@@ -16,17 +16,19 @@ export class UmbUnlockUserEntityAction extends UmbEntityActionBase<never> {
const itemRepository = new UmbUserItemRepository(this);
const { data } = await itemRepository.requestItems([this.args.unique]);
if (data) {
const item = data[0];
await umbConfirmModal(this._host, {
headline: `Unlock ${item.name}`,
content: 'Are you sure you want to unlock this user?',
confirmLabel: 'Unlock',
});
const unlockUserRepository = new UmbUnlockUserRepository(this);
await unlockUserRepository?.unlock([this.args.unique]);
if (!data?.length) {
throw new Error('Item not found.');
}
const item = data[0];
await umbConfirmModal(this._host, {
headline: `Unlock ${item.name}`,
content: 'Are you sure you want to unlock this user?',
confirmLabel: 'Unlock',
});
const unlockUserRepository = new UmbUnlockUserRepository(this);
await unlockUserRepository?.unlock([this.args.unique]);
}
}

View File

@@ -2,4 +2,5 @@ export * from './collection/index.js';
export * from './components/index.js';
export * from './invite/index.js';
export * from './repository/index.js';
export * from './types.js';
export type * from './types.js';
export * from './utils/index.js';

View File

@@ -19,6 +19,12 @@ const modals: Array<ManifestModal> = [
name: 'User Picker Modal',
js: () => import('./user-picker/user-picker-modal.element.js'),
},
{
type: 'modal',
alias: 'Umb.Modal.User.Mfa',
name: 'User Mfa Modal',
js: () => import('./user-mfa/user-mfa-modal.element.js'),
},
];
export const manifests = [...modals];

View File

@@ -0,0 +1,155 @@
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 { 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';
type UmbMfaLoginProviderOption = UmbUserMfaProviderModel & {
displayName: string;
};
@customElement('umb-user-mfa-modal')
export class UmbUserMfaModalElement extends UmbLitElement {
@property({ attribute: false })
modalContext?: UmbModalContext<UmbUserMfaModalConfiguration, never>;
@state()
_items: Array<UmbMfaLoginProviderOption> = [];
#unique = '';
#userRepository = new UmbUserRepository(this);
firstUpdated() {
this.#unique = this.modalContext?.data.unique ?? '';
this.#loadProviders();
}
async #loadProviders() {
const serverLoginProviders$ = (await this.#userRepository.requestMfaProviders(this.#unique)).asObservable();
const manifestLoginProviders$ = umbExtensionsRegistry.byType('mfaLoginProvider');
// Merge the server and manifest providers to get the final list of providers
const mfaLoginProviders$ = mergeObservables(
[serverLoginProviders$, manifestLoginProviders$],
([serverLoginProviders, manifestLoginProviders]) => {
return manifestLoginProviders.map((manifestLoginProvider) => {
const serverLoginProvider = serverLoginProviders.find(
(serverLoginProvider) => serverLoginProvider.providerName === manifestLoginProvider.forProviderName,
);
return {
isEnabledOnUser: serverLoginProvider?.isEnabledOnUser ?? false,
providerName: serverLoginProvider?.providerName ?? manifestLoginProvider.forProviderName,
displayName:
manifestLoginProvider.meta?.label ?? serverLoginProvider?.providerName ?? manifestLoginProvider.name,
} satisfies UmbMfaLoginProviderOption;
});
},
);
this.observe(
mfaLoginProviders$,
(providers) => {
this._items = providers;
},
'_mfaLoginProviders',
);
}
#close() {
this.modalContext?.submit();
}
render() {
return html`
<umb-body-layout headline="${this.localize.term('member_2fa')}">
<div id="main">
${when(
this._items.length > 0,
() => html`
${repeat(
this._items,
(item) => item.providerName,
(item) => this.#renderProvider(item),
)}
`,
)}
</div>
<div slot="actions">
<uui-button @click=${this.#close} look="secondary" .label=${this.localize.term('general_close')}>
${this.localize.term('general_close')}
</uui-button>
</div>
</umb-body-layout>
`;
}
/**
* Render a provider with a toggle to enable/disable it
*/
#renderProvider(item: UmbMfaLoginProviderOption) {
return html`
<uui-box headline=${item.displayName}>
${when(
item.isEnabledOnUser,
() => html`
<p style="margin-top:0">
<umb-localize key="user_2faProviderIsEnabled">This two-factor provider is enabled</umb-localize>
<uui-icon icon="check"></uui-icon>
</p>
<uui-button
type="button"
look="secondary"
color="danger"
.label=${this.localize.term('actions_disable')}
@click=${() => this.#onProviderDisable(item)}></uui-button>
`,
() => html`
<uui-button
disabled
type="button"
look="secondary"
.label=${this.localize.term('actions_enable')}></uui-button>
`,
)}
</uui-box>
`;
}
/**
* This method is called when the user clicks the disable button on a provider.
* It will show a confirmation dialog and then disable the provider if the user confirms.
* NB! The user must have administrative rights before doing so.
*/
async #onProviderDisable(item: UmbMfaLoginProviderOption) {
await umbConfirmModal(this, {
headline: this.localize.term('actions_disable'),
content: this.localize.term('user_2faDisableForUser', item.displayName),
confirmLabel: this.localize.term('actions_disable'),
color: 'danger',
});
await this.#userRepository.disableMfaProvider(this.#unique, item.providerName, item.displayName);
this.#loadProviders();
}
static styles = [
UmbTextStyles,
css`
uui-box {
margin-bottom: var(--uui-size-space-3);
}
`,
];
}
export default UmbUserMfaModalElement;
declare global {
interface HTMLElementTagNameMap {
'umb-user-mfa-modal': UmbUserMfaModalElement;
}
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import type { UmbUserMfaModalElement } from './user-mfa-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 './user-mfa-modal.element.js';
class UmbServerExtensionsHostElement extends UmbLitElement {
constructor() {
super();
new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerAllExtensions();
}
render() {
return html`<slot></slot>`;
}
}
if (window.customElements.get('umb-server-extensions-host') === undefined) {
customElements.define('umb-server-extensions-host', UmbServerExtensionsHostElement);
}
const meta: Meta<UmbUserMfaModalElement> = {
title: 'User/MFA/Configure MFA Providers',
component: 'umb-user-mfa-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<UmbUserMfaModalElement>;
export const Overview: Story = {};

View File

@@ -0,0 +1,12 @@
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export type UmbUserMfaModalConfiguration = {
unique: string;
};
export const UMB_USER_MFA_MODAL = new UmbModalToken<UmbUserMfaModalConfiguration, never>('Umb.Modal.User.Mfa', {
modal: {
type: 'sidebar',
size: 'small',
},
});

View File

@@ -1,11 +1,9 @@
import { UmbUserRepositoryBase } from '../user-repository-base.js';
import { UmbChangeUserPasswordServerDataSource } from './change-user-password.server.data-source.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbNotificationContext } from '@umbraco-cms/backoffice/notification';
export class UmbChangeUserPasswordRepository extends UmbUserRepositoryBase {
#changePasswordSource: UmbChangeUserPasswordServerDataSource;
#notificationContext?: UmbNotificationContext;
constructor(host: UmbControllerHost) {
super(host);
@@ -21,7 +19,7 @@ export class UmbChangeUserPasswordRepository extends UmbUserRepositoryBase {
if (!error) {
const notification = { data: { message: `Password changed` } };
this.#notificationContext?.peek('positive', notification);
this.notificationContext?.peek('positive', notification);
}
return { data, error };

View File

@@ -50,6 +50,7 @@ export class UmbUserServerDataSource implements UmbDetailDataSource<UmbUserDetai
updateDate: null,
userGroupUniques: [],
userName: '',
isAdmin: false,
};
return { data };
@@ -89,6 +90,7 @@ export class UmbUserServerDataSource implements UmbDetailDataSource<UmbUserDetai
updateDate: data.updateDate,
userGroupUniques: data.userGroupIds,
userName: data.userName,
isAdmin: data.isAdmin,
};
return { data: dataType };

View File

@@ -0,0 +1,55 @@
import { UserResource } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { tryExecute, tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
/**
* A data source for User MFA items that fetches data from the server
* @export
* @class UmbMfaServerDataSource
*/
export class UmbUserMfaServerDataSource {
#host: UmbControllerHost;
/**
* Creates an instance of UmbMfaServerDataSource.
* @param {UmbControllerHost} host
* @memberof UmbMfaServerDataSource
*/
constructor(host: UmbControllerHost) {
this.#host = host;
}
/**
* Request the MFA providers for a user
* @param unique The unique id of the user
* @memberof UmbMfaServerDataSource
*/
requestMfaProviders(unique: string) {
if (!unique) throw new Error('User id is missing');
return tryExecuteAndNotify(
this.#host,
UserResource.getUserById2Fa({
id: unique,
}),
);
}
/**
* Disables a MFA provider for a user
* @param unique The unique id of the user
* @param providerName The name of the provider
* @memberof UmbMfaServerDataSource
*/
disableMfaProvider(unique: string, providerName: string) {
if (!unique) throw new Error('User id is missing');
if (!providerName) throw new Error('Provider is missing');
return tryExecute(
UserResource.deleteUserById2FaByProviderName({
id: unique,
providerName,
}),
);
}
}

View File

@@ -1,15 +1,12 @@
import { UmbUserMfaServerDataSource } from './sources/user-mfa.server.data-source.js';
import { UmbUserSetGroupsServerDataSource } from './sources/user-set-group.server.data-source.js';
import { UmbUserRepositoryBase } from './user-repository-base.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { of } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
export class UmbUserRepository extends UmbUserRepositoryBase {
#setUserGroupsSource: UmbUserSetGroupsServerDataSource;
constructor(host: UmbControllerHost) {
super(host);
this.#setUserGroupsSource = new UmbUserSetGroupsServerDataSource(host);
}
#setUserGroupsSource = new UmbUserSetGroupsServerDataSource(this._host);
#userMfaSource = new UmbUserMfaServerDataSource(this._host);
async setUserGroups(userIds: Array<string>, userGroupIds: Array<string>) {
if (userGroupIds.length === 0) throw new Error('User group ids are missing');
@@ -23,4 +20,42 @@ export class UmbUserRepository extends UmbUserRepositoryBase {
return { error };
}
/**
* Request the MFA providers for a user
* @param unique The unique id of the user
* @memberof UmbUserRepository
*/
async requestMfaProviders(unique: string) {
const { data, error } = await this.#userMfaSource.requestMfaProviders(unique);
return { data, error, asObservable: () => of(data ?? []) };
}
/**
* Disables a MFA provider for a user
* @param unique The unique id of the user
* @param providerName The name of the provider
* @param displayName The display name of the provider to show in the notification (optional)
* @memberof UmbUserRepository
*/
async disableMfaProvider(unique: string, providerName: string, displayName?: string) {
const { data, error } = await this.#userMfaSource.disableMfaProvider(unique, providerName);
const localize = new UmbLocalizationController(this._host);
if (!error) {
const notification = {
data: { message: localize.term('user_2faProviderIsDisabledMsg', displayName ?? providerName) },
};
this.notificationContext?.peek('positive', notification);
} else {
console.error('Failed to disable MFA provider', error);
const notification = {
data: { message: localize.term('user_2faProviderIsNotDisabledMsg', displayName ?? providerName) },
};
this.notificationContext?.peek('warning', notification);
}
return { data, error };
}
}

View File

@@ -1,5 +1,5 @@
import type { UmbUserEntityType } from './entity.js';
import { UserStateModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UserStateModel, type UserTwoFactorProviderModel } from '@umbraco-cms/backoffice/external/backend-api';
export type UmbUserStateEnum = UserStateModel;
export const UmbUserStateEnum = UserStateModel;
@@ -22,4 +22,7 @@ export interface UmbUserDetailModel {
lastLoginDate: string | null;
lastLockoutDate: string | null;
lastPasswordChangeDate: string | null;
isAdmin: boolean;
}
export type UmbUserMfaProviderModel = UserTwoFactorProviderModel;

View File

@@ -0,0 +1 @@
export * from './is-user.function.js';

View File

@@ -0,0 +1,12 @@
import { UmbUserDetailRepository } from '../repository/index.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/**
* Check if the user is an admin
*/
export const isUserAdmin = async (host: UmbControllerHost, userUnique: string) => {
const repository = new UmbUserDetailRepository(host);
const { data: user } = await repository.requestByUnique(userUnique);
return user?.isAdmin ?? false;
};