Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7ad3ed6
add users section into user group
LanThuyNguyen Mar 23, 2026
be35cf3
fix test failed
LanThuyNguyen Mar 23, 2026
d86a38b
fix unchange issue
LanThuyNguyen Mar 24, 2026
4bea90a
Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into v1…
LanThuyNguyen Mar 24, 2026
9446192
add notification
LanThuyNguyen Mar 24, 2026
5a3ec5e
add remainging count
LanThuyNguyen Mar 31, 2026
cf49815
update take 100
LanThuyNguyen Mar 31, 2026
4b06947
split user list into separate element
LanThuyNguyen Apr 1, 2026
772e6be
Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into v1…
LanThuyNguyen Apr 1, 2026
5f0fd9e
Merge branch 'main' into v17/feature/manage-users-from-group
AndyButland Apr 1, 2026
4d191c3
add localization for text
LanThuyNguyen Apr 6, 2026
0074244
Merge branch 'v17/feature/manage-users-from-group' of https://github.…
LanThuyNguyen Apr 6, 2026
e525b37
Merge branch 'main' into v17/feature/manage-users-from-group
NguyenThuyLan Apr 6, 2026
445a979
add repository for user list in user group
LanThuyNguyen Apr 6, 2026
b324a8e
Merge branch 'v17/feature/manage-users-from-group' of https://github.…
LanThuyNguyen Apr 6, 2026
9ba4404
Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into v1…
LanThuyNguyen Apr 7, 2026
321eba0
Merge branch 'main' into v17/feature/manage-users-from-group
AndyButland Apr 8, 2026
c3d6e00
update key message
LanThuyNguyen Apr 9, 2026
aa68e7d
Merge branch 'v17/feature/manage-users-from-group' of https://github.…
LanThuyNguyen Apr 9, 2026
1e099e5
remove remainingCount from user-input
LanThuyNguyen Apr 15, 2026
77657cc
Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into v1…
LanThuyNguyen Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,10 @@ export default {
'2faCodeInput': 'Verification code',
'2faCodeInputHelp': 'Please enter the verification code',
'2faInvalidCode': 'Invalid code entered',
addUsersToGroupError: 'Could not add users to the group.',
removeUsersFromGroupError: 'Could not remove users from the group.',
andMore: 'and %0% more',
usersNotManagedFromGroup: 'not manageable from this screen.',
},
validation: {
validation: 'Validation',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './detail/index.js';
export * from './item/index.js';
export * from './users/index.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './user-group-users.repository.js';
export * from './user-group-users.server.data-source.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { UmbUserGroupUsersServerDataSource } from './user-group-users.server.data-source.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';

export class UmbUserGroupUsersRepository extends UmbRepositoryBase {
#source: UmbUserGroupUsersServerDataSource;

constructor(host: UmbControllerHost) {
super(host);
this.#source = new UmbUserGroupUsersServerDataSource(host);
}

async requestUsersInGroup(groupId: string, take = 100) {
return this.#source.getUsersInGroup(groupId, take);
}

async addUsersToGroup(groupId: string, userIds: string[]) {
return this.#source.addUsersToGroup(groupId, userIds);
}

async removeUsersFromGroup(groupId: string, userIds: string[]) {
return this.#source.removeUsersFromGroup(groupId, userIds);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { UserGroupService, UserService } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { tryExecute } from '@umbraco-cms/backoffice/resources';

export class UmbUserGroupUsersServerDataSource {
#host: UmbControllerHost;

constructor(host: UmbControllerHost) {
this.#host = host;
}

async getUsersInGroup(groupId: string, take: number) {
if (!groupId) throw new Error('Group id is missing');

const { data, error } = await tryExecute(
this.#host,
UserService.getFilterUser({ query: { userGroupIds: [groupId], take } }),
);

if (error || !data) return { error };

return {
data: {
uniques: data.items.map((u) => u.id),
total: data.total,
},
};
}

async addUsersToGroup(groupId: string, userIds: string[]) {
if (!groupId) throw new Error('Group id is missing');
if (!userIds.length) return { data: undefined, error: undefined };

return tryExecute(
this.#host,
UserGroupService.postUserGroupByIdUsers({
path: { id: groupId },
body: userIds.map((id) => ({ id })),
}),
{ disableNotifications: true },
);
}

async removeUsersFromGroup(groupId: string, userIds: string[]) {
if (!groupId) throw new Error('Group id is missing');
if (!userIds.length) return { data: undefined, error: undefined };

return tryExecute(
this.#host,
UserGroupService.deleteUserGroupByIdUsers({
path: { id: groupId },
body: userIds.map((id) => ({ id })),
}),
{ disableNotifications: true },
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from '../user-group-workspace.context-token.js';
import { UmbUserGroupUsersRepository } from '../../../repository/users/user-group-users.repository.js';
import type { UmbUserInputElement } from '../../../../user/components/user-input/user-input.element.js';
import type { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';

import '../../../../user/components/user-input/user-input.element.js';

@customElement('umb-user-group-workspace-users')
export class UmbUserGroupWorkspaceUsersElement extends UmbLitElement {
@state()
private _userUniques: string[] = [];

@state()
private _usersRemainingCount = 0;

#unique?: string;
#isNew = false;
#persistedUserUniques: string[] = [];
#notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE;
#usersRepository = new UmbUserGroupUsersRepository(this);
#workspaceContext?: typeof UMB_USER_GROUP_WORKSPACE_CONTEXT.TYPE;

constructor() {
super();

this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
Comment thread
NguyenThuyLan marked this conversation as resolved.
Outdated
this.#notificationContext = context;
});

this.consumeContext(UMB_USER_GROUP_WORKSPACE_CONTEXT, (instance) => {
this.#workspaceContext = instance;
this.#observe();
});
}

#observe() {
this.observe(
this.#workspaceContext?.unique,
(value) => {
Comment thread
NguyenThuyLan marked this conversation as resolved.
Outdated
this.#unique = value ?? undefined;
if (value && !this.#isNew) {
this.#loadUsers(value);
}
},
'_observeUnique',
);

this.observe(
this.#workspaceContext?.isNew,
(isNew) => {
const wasNew = this.#isNew;
this.#isNew = isNew ?? false;

// When the group transitions from newly created to persisted, save any pending user changes.
if (wasNew === true && isNew === false) {
this.#savePendingUserChanges();
}
},
'_observeIsNew',
);
}

async #loadUsers(unique: string) {
const { data } = await this.#usersRepository.requestUsersInGroup(unique);
this.#persistedUserUniques = [...(data?.uniques ?? [])];
this._userUniques = [...(data?.uniques ?? [])];
const total = data?.total ?? 0;
this._usersRemainingCount = Math.max(0, total - this._userUniques.length);
}

async #savePendingUserChanges() {
const unique = this.#unique;
if (!unique || this._userUniques.length === 0) return;
await this.#persistUserChanges(unique, this._userUniques);
}

async #persistUserChanges(unique: string, newSelection: string[]): Promise<boolean> {
const toAdd = newSelection.filter((u) => !this.#persistedUserUniques.includes(u));
const toRemove = this.#persistedUserUniques.filter((u) => !newSelection.includes(u));

const [{ error: addError }, { error: removeError }] = await Promise.all([
this.#usersRepository.addUsersToGroup(unique, toAdd),
this.#usersRepository.removeUsersFromGroup(unique, toRemove),
]);

if (addError) {
this.#notificationContext?.peek('danger', {
data: {
headline: this.localize.term('speechBubbles_operationFailedHeader'),
message: this.localize.term('user_addUsersToGroupError'),
},
});
}

if (removeError) {
this.#notificationContext?.peek('danger', {
data: {
headline: this.localize.term('speechBubbles_operationFailedHeader'),
message: this.localize.term('user_removeUsersFromGroupError'),
},
});
}

if (!addError && !removeError) {
this.#persistedUserUniques = [...newSelection];
return true;
}

return false;
}

async #onUsersChange(event: UmbChangeEvent) {
event.stopPropagation();
const target = event.target as UmbUserInputElement;
const newSelection = target.selection;

// For new (unsaved) groups, track locally — users will be persisted when the group is saved.
if (this.#isNew) {
this._userUniques = newSelection;
return;
}

const unique = this.#unique;
if (!unique) return;

const previousSelection = [...this._userUniques];
this._userUniques = newSelection; // optimistic update

const success = await this.#persistUserChanges(unique, newSelection);
if (!success) {
this._userUniques = previousSelection; // revert on error
}
}

override render() {
return html`
<uui-box>
<div slot="headline"><umb-localize key="general_users"></umb-localize></div>
<umb-user-input
.selection=${this._userUniques}
.remainingCount=${this._usersRemainingCount}
@change=${this.#onUsersChange}></umb-user-input>
</uui-box>
`;
}

static override styles = [
UmbTextStyles,
css`
:host {
display: block;
}
`,
];
}

export { UmbUserGroupWorkspaceUsersElement as element };

declare global {
interface HTMLElementTagNameMap {
'umb-user-group-workspace-users': UmbUserGroupWorkspaceUsersElement;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui';

import '../components/user-group-entity-type-permission-groups.element.js';
import '../components/user-group-workspace-users.element.js';

@customElement('umb-user-group-details-workspace-view')
export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement implements UmbWorkspaceViewElement {
Expand Down Expand Up @@ -163,6 +164,9 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple

${this.#renderPermissionGroups()}
</umb-stack>
<div>
Comment thread
NguyenThuyLan marked this conversation as resolved.
Outdated
<umb-user-group-workspace-users></umb-user-group-workspace-users>
</div>
</div>
`;
}
Expand Down Expand Up @@ -257,6 +261,9 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple
}

#main {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--uui-size-layout-1);
padding: var(--uui-size-layout-1);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export class UmbUserInputElement extends UmbFormControlMixin<string, typeof UmbL
@state()
private _statuses?: Array<UmbRepositoryItemsStatus>;

@property({ type: Number, attribute: 'remaining-count' })
Comment thread
NguyenThuyLan marked this conversation as resolved.
Outdated
remainingCount = 0;

@state()
private _modalRoute?: string;

Expand Down Expand Up @@ -200,9 +203,17 @@ export class UmbUserInputElement extends UmbFormControlMixin<string, typeof UmbL
(status) => this.#renderItem(status),
)}
</uui-ref-list>
${this.#renderRemainingCount()}
`;
}

#renderRemainingCount() {
if (!this.remainingCount) return nothing;
return html`<div class="remaining-count">
${this.localize.term('user_andMore', this.remainingCount)} - <i>${this.localize.term('user_usersNotManagedFromGroup')}</i>
</div>`;
}

#renderItem(status: UmbRepositoryItemsStatus) {
const unique = status.unique;
const item = this._items?.find((x) => x.unique === unique);
Expand Down Expand Up @@ -230,6 +241,9 @@ export class UmbUserInputElement extends UmbFormControlMixin<string, typeof UmbL
#btn-add {
width: 100%;
}
.remaining-count {
padding: 0px 0px 8px 12px;
}
`,
];
}
Expand Down
Loading