@yuuvis/user-management-app 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/extensions/README.md +1 -1
- package/extensions/lib/assets/i18n/de.json +1 -1
- package/extensions/lib/assets/i18n/en.json +1 -1
- package/fesm2022/yuuvis-user-management-app-extensions.mjs +3 -3
- package/fesm2022/yuuvis-user-management-app-extensions.mjs.map +1 -1
- package/fesm2022/yuuvis-user-management-app.mjs +2064 -1064
- package/fesm2022/yuuvis-user-management-app.mjs.map +1 -1
- package/lib/assets/i18n/de.json +155 -110
- package/lib/assets/i18n/en.json +155 -110
- package/package.json +1 -1
- package/types/yuuvis-user-management-app.d.ts +84 -21
- package/yuv-manifest.json +1 -1
|
@@ -1,59 +1,51 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { inject, Injectable,
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import * as
|
|
2
|
+
import { inject, Injectable, TemplateRef, Input, ContentChild, Component, Pipe, input, output, signal, computed, effect, linkedSignal, Inject, ElementRef, Directive, DestroyRef, Injector, EventEmitter, viewChildren, Output, ViewEncapsulation, ViewChild } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { Title } from '@angular/platform-browser';
|
|
5
|
+
import * as i1$1 from '@angular/material/button';
|
|
6
6
|
import { MatButtonModule } from '@angular/material/button';
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import { MatDialogRef, MatDialog, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
|
10
|
-
import * as i1$1 from '@angular/material/icon';
|
|
7
|
+
import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog';
|
|
8
|
+
import * as i1$2 from '@angular/material/icon';
|
|
11
9
|
import { MatIconModule } from '@angular/material/icon';
|
|
12
|
-
import * as
|
|
10
|
+
import * as i3$2 from '@angular/material/menu';
|
|
11
|
+
import { MatMenuModule } from '@angular/material/menu';
|
|
12
|
+
import * as i4$1 from '@angular/material/paginator';
|
|
13
|
+
import { MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator';
|
|
14
|
+
import * as i5 from '@angular/material/table';
|
|
15
|
+
import { MatTableModule } from '@angular/material/table';
|
|
16
|
+
import { FocusKeyManager, ActiveDescendantKeyManager } from '@angular/cdk/a11y';
|
|
17
|
+
import * as i4 from '@angular/material/tooltip';
|
|
13
18
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { BackendService, TranslateService, ApiBase,
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import * as i6 from '@yuuvis/client-shell';
|
|
20
|
-
import { YuvClientShellModule } from '@yuuvis/client-shell';
|
|
19
|
+
import { Router, ActivatedRoute } from '@angular/router';
|
|
20
|
+
import { expand, EMPTY, reduce, switchMap, map, of, forkJoin, shareReplay, catchError, throwError, debounceTime, distinctUntilChanged, startWith, filter, tap } from 'rxjs';
|
|
21
|
+
import { BackendService, TranslateService, ApiBase, LocalizationService, Logger, NotificationService, TranslatePipe, TabGuardDirective, UserService } from '@yuuvis/client-core';
|
|
22
|
+
import { ConfirmService, AbstractMatFormField, BusyOverlayDirective, DialogComponent, LayoutSettingsService, ClickDoubleDirective } from '@yuuvis/client-components/common';
|
|
23
|
+
import { SimpleSearchComponent } from '@yuuvis/client-framework/simple-search';
|
|
21
24
|
import { ShellService } from '@yuuvis/client-shell-core';
|
|
22
|
-
import * as
|
|
23
|
-
import {
|
|
24
|
-
import * as i1 from '@angular/forms';
|
|
25
|
-
import { ReactiveFormsModule, FormBuilder, FormControl, Validators } from '@angular/forms';
|
|
26
|
-
import * as i2 from '@angular/cdk/clipboard';
|
|
27
|
-
import { ClipboardModule } from '@angular/cdk/clipboard';
|
|
28
|
-
import * as i4 from '@angular/material/form-field';
|
|
29
|
-
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
30
|
-
import * as i3$1 from '@angular/material/input';
|
|
31
|
-
import { MatInputModule } from '@angular/material/input';
|
|
32
|
-
import { map, switchMap, of, forkJoin } from 'rxjs';
|
|
25
|
+
import * as i8$1 from '@yuuvis/client-shell';
|
|
26
|
+
import { YuvClientShellModule } from '@yuuvis/client-shell';
|
|
33
27
|
import { YmtButtonDirective, YmtIconButtonDirective } from '@yuuvis/material';
|
|
34
|
-
import
|
|
28
|
+
import * as i7 from '@yuuvis/material/panes';
|
|
29
|
+
import { YmtPanesModule } from '@yuuvis/material/panes';
|
|
30
|
+
import * as i9 from '@yuuvis/client-components/split-view';
|
|
31
|
+
import { YuvSplitViewModule } from '@yuuvis/client-components/split-view';
|
|
35
32
|
import { HttpParams } from '@angular/common/http';
|
|
36
|
-
import * as
|
|
37
|
-
import {
|
|
38
|
-
import
|
|
39
|
-
import
|
|
40
|
-
import
|
|
41
|
-
import {
|
|
42
|
-
import { SelectionModel } from '@angular/cdk/collections';
|
|
43
|
-
import { SimpleSearchComponent } from '@yuuvis/client-framework/simple-search';
|
|
44
|
-
import * as i8 from '@yuuvis/client-components/overflow-menu';
|
|
45
|
-
import { YuvOverflowMenuModule } from '@yuuvis/client-components/overflow-menu';
|
|
33
|
+
import * as i1 from '@angular/cdk/clipboard';
|
|
34
|
+
import { ClipboardModule } from '@angular/cdk/clipboard';
|
|
35
|
+
import * as i2 from '@angular/material/form-field';
|
|
36
|
+
import { MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field';
|
|
37
|
+
import * as i3 from '@angular/material/input';
|
|
38
|
+
import { MatInputModule } from '@angular/material/input';
|
|
46
39
|
import { YmtBadge } from '@yuuvis/material/badge';
|
|
47
|
-
import * as
|
|
48
|
-
import {
|
|
49
|
-
import
|
|
50
|
-
import
|
|
51
|
-
import
|
|
52
|
-
import * as i7 from '@angular/material/slide-toggle';
|
|
53
|
-
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|
54
|
-
import * as i3$3 from '@angular/material/list';
|
|
40
|
+
import * as i1$4 from '@angular/common';
|
|
41
|
+
import { NgTemplateOutlet, CommonModule } from '@angular/common';
|
|
42
|
+
import * as i1$3 from '@angular/forms';
|
|
43
|
+
import { ReactiveFormsModule, FormBuilder, FormControl, Validators } from '@angular/forms';
|
|
44
|
+
import * as i3$1 from '@angular/material/list';
|
|
55
45
|
import { MatListModule } from '@angular/material/list';
|
|
56
|
-
import {
|
|
46
|
+
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|
47
|
+
import * as i8 from '@angular/material/tabs';
|
|
48
|
+
import { MatTabsModule } from '@angular/material/tabs';
|
|
57
49
|
|
|
58
50
|
const APP_ID = 'io.yuuvis.app.user-management';
|
|
59
51
|
/**
|
|
@@ -65,58 +57,135 @@ const SCIM_MANAGED_ROLE = 'scim-managed';
|
|
|
65
57
|
const APP_ICONS = {
|
|
66
58
|
refresh: 'refresh',
|
|
67
59
|
stateActive: 'account_circle',
|
|
68
|
-
stateInactive: '
|
|
60
|
+
stateInactive: 'block',
|
|
69
61
|
sourceExternal: 'cloud',
|
|
70
|
-
actionCreateNewUser: '
|
|
62
|
+
actionCreateNewUser: 'person',
|
|
63
|
+
actionCreateNewGroup: 'group',
|
|
71
64
|
actionEditUser: 'person_edit',
|
|
72
65
|
actionActivateUser: 'person_check',
|
|
73
66
|
actionDeactivateUser: 'person_off',
|
|
74
67
|
actionDeleteUser: 'delete',
|
|
75
68
|
actionEditGroup: 'edit',
|
|
76
|
-
|
|
69
|
+
actionDeleteGroup: 'delete',
|
|
70
|
+
actionRemoveGroupMember: 'group_remove',
|
|
71
|
+
actionAddGroupMembers: 'group_add',
|
|
72
|
+
actionSettings: 'manage_accounts',
|
|
77
73
|
};
|
|
78
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Walks every page of a paginated endpoint and concatenates the results. Used where the API
|
|
77
|
+
* only offers server-side pagination but a feature needs the full, unpaginated list (e.g. the
|
|
78
|
+
* groups rail, which needs every group at once rather than one page at a time).
|
|
79
|
+
*/
|
|
80
|
+
function aggregateAllPages(fetchPage) {
|
|
81
|
+
return fetchPage(0).pipe(expand((response, index) => (response.hasMoreItems ? fetchPage(index + 1) : EMPTY)), reduce((acc, response) => [...acc, ...response.objects], []));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var _a;
|
|
85
|
+
/**
|
|
86
|
+
* Maps an HTTP error status to an i18n key under `${keyPrefix}.error.*`, e.g.
|
|
87
|
+
* "yuv.app.user-management.users.action.create.error.forbidden" for a 403.
|
|
88
|
+
* `extraStatusSuffixes` lets a caller opt in to additional statuses beyond the
|
|
89
|
+
* universal 403/"forbidden" and default/"unknown" (e.g. `{ 409: 'conflict' }`).
|
|
90
|
+
*/
|
|
91
|
+
function resolveErrorMessageKey(keyPrefix, status, extraStatusSuffixes = {}) {
|
|
92
|
+
if (status === 403) {
|
|
93
|
+
return `${keyPrefix}.error.forbidden`;
|
|
94
|
+
}
|
|
95
|
+
const extraSuffix = extraStatusSuffixes[status];
|
|
96
|
+
return `${keyPrefix}.error.${extraSuffix || 'unknown'}`;
|
|
97
|
+
}
|
|
79
98
|
class YuvUserManagementService {
|
|
80
|
-
|
|
99
|
+
/** Also the default in the page-size selector on the users/group-membership list - see `YuvUserManagementComponent.pageSizeOptions`. */
|
|
100
|
+
static DEFAULT_PAGE_SIZE = 25;
|
|
101
|
+
/**
|
|
102
|
+
* Page size used when walking *every* page of a list (see `aggregateAllPages`), which the
|
|
103
|
+
* backend only exposes paginated. Deliberately much larger than `DEFAULT_PAGE_SIZE`: those walks
|
|
104
|
+
* are sequential (each page's request only goes out once the previous one has answered), so the
|
|
105
|
+
* page size is what decides how many round trips a full list costs.
|
|
106
|
+
*/
|
|
107
|
+
static AGGREGATE_PAGE_SIZE = 200;
|
|
81
108
|
#backendService = inject(BackendService);
|
|
82
109
|
#translateService = inject(TranslateService);
|
|
83
110
|
#confirmService = inject(ConfirmService);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
/** Common `page`/`size`/`sort` params shared by every paginated `/idm/` list endpoint. */
|
|
112
|
+
#pageParams(page, sort, size = _a.DEFAULT_PAGE_SIZE) {
|
|
113
|
+
return new HttpParams().set('page', page).set('size', size).set('sort', sort);
|
|
114
|
+
}
|
|
115
|
+
/** `enabled` filters by user state: `true` for enabled-only, `false` for disabled-only, omitted for both. */
|
|
116
|
+
fetchUsers(page = 0, search, enabled, pageSize) {
|
|
117
|
+
let filterParams = this.#pageParams(page, 'username%2Casc', pageSize).set('search', search || '');
|
|
118
|
+
if (enabled !== undefined) {
|
|
119
|
+
filterParams = filterParams.set('enabled', enabled);
|
|
120
|
+
}
|
|
121
|
+
return this.#backendService.get(`/tenant-management/api/idm/users?${filterParams}`, ApiBase.none);
|
|
122
|
+
}
|
|
123
|
+
fetchGroups(page = 0, search, pageSize) {
|
|
124
|
+
const filterParams = this.#pageParams(page, 'name%2Casc', pageSize).set('search', search || '');
|
|
125
|
+
return this.#backendService.get(`/tenant-management/api/idm/groups?${filterParams}`, ApiBase.none);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Fetches every group across all pages. Used for the groups rail, which needs the full list
|
|
129
|
+
* rather than one page at a time.
|
|
130
|
+
*/
|
|
131
|
+
fetchAllGroups() {
|
|
132
|
+
return aggregateAllPages((page) => this.fetchGroups(page, undefined, _a.AGGREGATE_PAGE_SIZE));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Fetches one server-paginated page of a group's members as full user records. Group
|
|
136
|
+
* membership search isn't supported by the backend - there's no `search` param here, see
|
|
137
|
+
* `YuvUserManagementComponent` for how the UI enforces that a search always scopes to "All users"
|
|
138
|
+
* instead.
|
|
139
|
+
*/
|
|
140
|
+
fetchGroupMembers(groupId, page = 0, pageSize) {
|
|
141
|
+
const filterParams = this.#pageParams(page, 'username%2Casc', pageSize);
|
|
142
|
+
return this.#backendService.get(`/tenant-management/api/idm/groups/${groupId}/members?${filterParams}`, ApiBase.none);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Removes a user from a group (the user account itself is unaffected), after the user
|
|
146
|
+
* confirms the action.
|
|
147
|
+
*/
|
|
148
|
+
removeGroupMember(group, user) {
|
|
149
|
+
return this.#confirmService
|
|
150
|
+
.confirm({
|
|
151
|
+
title: this.#translateService.instant('yuv.app.user-management.groups.action.removeMember.label'),
|
|
152
|
+
message: this.#translateService.instant('yuv.app.user-management.groups.action.removeMember.confirm.message', { username: user.username, name: group.name }),
|
|
153
|
+
confirmLabel: this.#translateService.instant('yuv.app.user-management.groups.action.removeMember.confirm.label'),
|
|
154
|
+
})
|
|
155
|
+
.pipe(switchMap((confirmed) => {
|
|
156
|
+
if (confirmed) {
|
|
157
|
+
return this.#backendService
|
|
158
|
+
.delete(`/tenant-management/api/idm/groups/${group.id}/members/${user.id}`, ApiBase.none)
|
|
159
|
+
.pipe(map(() => true));
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
return of(false);
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Adds a user to a group (the user account itself is unaffected). Unlike `removeGroupMember`,
|
|
167
|
+
* adding isn't destructive, so no confirmation dialog - consistent with createUser/createGroup.
|
|
168
|
+
*/
|
|
169
|
+
addGroupMember(group, userId) {
|
|
170
|
+
return this.#backendService.put(`/tenant-management/api/idm/groups/${group.id}/members/${userId}`, null, ApiBase.none);
|
|
101
171
|
}
|
|
102
172
|
fetchAllRoles() {
|
|
103
173
|
return this.#backendService
|
|
104
174
|
.get(`/tenant-management/api/idm/roles`, ApiBase.none)
|
|
105
|
-
.pipe(map((roles) => roles
|
|
106
|
-
.map(role => role.name)
|
|
107
|
-
.filter(name => name !== SCIM_MANAGED_ROLE)));
|
|
175
|
+
.pipe(map((roles) => roles.map((role) => role.name).filter((name) => name !== SCIM_MANAGED_ROLE)));
|
|
108
176
|
}
|
|
109
177
|
createUser(createUserRequest, withInvitation) {
|
|
110
178
|
const createParams = new HttpParams()
|
|
111
179
|
.set('withInvitation', withInvitation || false)
|
|
112
180
|
.set('temporaryPassword', true);
|
|
113
181
|
return this.#backendService
|
|
114
|
-
.post(`/tenant-management/api/idm/users?${createParams}`, createUserRequest, ApiBase.none, {
|
|
182
|
+
.post(`/tenant-management/api/idm/users?${createParams}`, createUserRequest, ApiBase.none, {
|
|
183
|
+
responseType: 'text',
|
|
184
|
+
})
|
|
115
185
|
.pipe(map((createdUserId) => createdUserId));
|
|
116
186
|
}
|
|
117
187
|
updateUser(userId, updateUserRequest) {
|
|
118
|
-
return this.#backendService
|
|
119
|
-
.put(`/tenant-management/api/idm/users/${userId}`, updateUserRequest, ApiBase.none);
|
|
188
|
+
return this.#backendService.put(`/tenant-management/api/idm/users/${userId}`, updateUserRequest, ApiBase.none);
|
|
120
189
|
}
|
|
121
190
|
deleteUser(user) {
|
|
122
191
|
return this.#confirmService
|
|
@@ -138,12 +207,13 @@ class YuvUserManagementService {
|
|
|
138
207
|
createGroup(createGroupRequest) {
|
|
139
208
|
const createParams = new HttpParams();
|
|
140
209
|
return this.#backendService
|
|
141
|
-
.post(`/tenant-management/api/idm/groups?${createParams}`, createGroupRequest, ApiBase.none, {
|
|
210
|
+
.post(`/tenant-management/api/idm/groups?${createParams}`, createGroupRequest, ApiBase.none, {
|
|
211
|
+
responseType: 'text',
|
|
212
|
+
})
|
|
142
213
|
.pipe(map((createdGroupId) => createdGroupId));
|
|
143
214
|
}
|
|
144
215
|
updateGroup(groupId, updateGroupRequest) {
|
|
145
|
-
return this.#backendService
|
|
146
|
-
.put(`/tenant-management/api/idm/groups/${groupId}`, updateGroupRequest, ApiBase.none);
|
|
216
|
+
return this.#backendService.put(`/tenant-management/api/idm/groups/${groupId}`, updateGroupRequest, ApiBase.none);
|
|
147
217
|
}
|
|
148
218
|
deleteGroup(group) {
|
|
149
219
|
return this.#confirmService
|
|
@@ -162,20 +232,17 @@ class YuvUserManagementService {
|
|
|
162
232
|
}));
|
|
163
233
|
}
|
|
164
234
|
getUserDetails(id) {
|
|
165
|
-
const queryParams = new HttpParams()
|
|
166
|
-
.set('briefRepresentation', false);
|
|
167
235
|
const combined$ = forkJoin({
|
|
168
236
|
userDetails: this.#backendService
|
|
169
|
-
.get(`/tenant-management/api/idm/users/${id}
|
|
170
|
-
.pipe(map(user => {
|
|
171
|
-
const { roles: effectiveRoles, ...rest } = user;
|
|
237
|
+
.get(`/tenant-management/api/idm/users/${id}`, ApiBase.none)
|
|
238
|
+
.pipe(map((user) => {
|
|
239
|
+
const { roles: effectiveRoles = [], ...rest } = user;
|
|
172
240
|
return { effectiveRoles, ...rest };
|
|
173
241
|
})),
|
|
174
|
-
userRoles: this.#backendService
|
|
175
|
-
.get(`/tenant-management/api/idm/users/${id}/roles`, ApiBase.none)
|
|
242
|
+
userRoles: this.#backendService.get(`/tenant-management/api/idm/users/${id}/roles`, ApiBase.none),
|
|
176
243
|
}).pipe(map((response) => ({
|
|
177
244
|
...response.userDetails,
|
|
178
|
-
roles: response.userRoles
|
|
245
|
+
roles: response.userRoles,
|
|
179
246
|
})));
|
|
180
247
|
return combined$;
|
|
181
248
|
}
|
|
@@ -184,8 +251,16 @@ class YuvUserManagementService {
|
|
|
184
251
|
* tenant. Emits a 404 error if no OIDC identity provider is configured.
|
|
185
252
|
*/
|
|
186
253
|
fetchOIDCProviderConfig() {
|
|
187
|
-
return this.#backendService
|
|
188
|
-
|
|
254
|
+
return this.#backendService.get(`/tenant-management/api/admin/identity-provider/oidc`, ApiBase.none);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Fetches the tenant-specific values needed to configure an external identity provider (the
|
|
258
|
+
* OIDC redirect URI and the SCIM provisioning endpoint/credentials). Available independent of
|
|
259
|
+
* whether an OIDC identity provider has already been configured, since both values are derived
|
|
260
|
+
* from the tenant alone.
|
|
261
|
+
*/
|
|
262
|
+
fetchIdentityProviderSetupInfo() {
|
|
263
|
+
return this.#backendService.get(`/tenant-management/api/admin/identity-provider/setup-info`, ApiBase.none);
|
|
189
264
|
}
|
|
190
265
|
/**
|
|
191
266
|
* Creates (or updates) the OIDC identity provider configuration of the current tenant. The
|
|
@@ -193,8 +268,7 @@ class YuvUserManagementService {
|
|
|
193
268
|
* OIDC endpoints (authorizationUrl, tokenUrl, jwksUrl, ...) from the discovery document.
|
|
194
269
|
*/
|
|
195
270
|
setOIDCProviderConfig(providerConfig) {
|
|
196
|
-
return this.#backendService
|
|
197
|
-
.post(`/tenant-management/api/admin/identity-provider/oidc`, providerConfig, ApiBase.none);
|
|
271
|
+
return this.#backendService.post(`/tenant-management/api/admin/identity-provider/oidc`, providerConfig, ApiBase.none);
|
|
198
272
|
}
|
|
199
273
|
/**
|
|
200
274
|
* Deletes the OIDC identity provider configuration of the current tenant, after the user
|
|
@@ -203,8 +277,8 @@ class YuvUserManagementService {
|
|
|
203
277
|
deleteOIDCProviderConfig() {
|
|
204
278
|
return this.#confirmService
|
|
205
279
|
.confirm({
|
|
206
|
-
title: this.#translateService.instant('yuv.app.user-management.settings.oidc.action.
|
|
207
|
-
message: this.#translateService.instant('yuv.app.user-management.settings.oidc.action.
|
|
280
|
+
title: this.#translateService.instant('yuv.app.user-management.settings.oidc.action.reset.label'),
|
|
281
|
+
message: this.#translateService.instant('yuv.app.user-management.settings.oidc.action.reset.confirm.message'),
|
|
208
282
|
})
|
|
209
283
|
.pipe(switchMap((confirmed) => {
|
|
210
284
|
if (confirmed) {
|
|
@@ -217,249 +291,128 @@ class YuvUserManagementService {
|
|
|
217
291
|
}));
|
|
218
292
|
}
|
|
219
293
|
getGroupDetails(id) {
|
|
220
|
-
|
|
221
|
-
.set('briefRepresentation', false);
|
|
222
|
-
return this.#backendService
|
|
223
|
-
.get(`/tenant-management/api/idm/groups/${id}?${queryParams}`, ApiBase.none);
|
|
294
|
+
return this.#backendService.get(`/tenant-management/api/idm/groups/${id}`, ApiBase.none);
|
|
224
295
|
}
|
|
225
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
226
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.
|
|
296
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: YuvUserManagementService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
297
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: YuvUserManagementService, providedIn: 'root' });
|
|
227
298
|
}
|
|
228
|
-
|
|
299
|
+
_a = YuvUserManagementService;
|
|
300
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: YuvUserManagementService, decorators: [{
|
|
229
301
|
type: Injectable,
|
|
230
302
|
args: [{
|
|
231
303
|
providedIn: 'root',
|
|
232
304
|
}]
|
|
233
305
|
}] });
|
|
234
306
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
307
|
+
let nextId = 0;
|
|
308
|
+
/**
|
|
309
|
+
* A read-only `MatFormFieldControl` that renders a *list* of values inside a real
|
|
310
|
+
* `<mat-form-field>` - for "fields" that are inherently a list (assigned roles, group membership)
|
|
311
|
+
* rather than a single text value, but should still look and behave like every other field
|
|
312
|
+
* (outline, floating label, spacing) without a hand-matched CSS mock: `mat-form-field` throws at
|
|
313
|
+
* runtime unless its content is a real `MatFormFieldControl`, which is exactly what this is.
|
|
314
|
+
*
|
|
315
|
+
* The `MatFormFieldControl` contract itself comes from `AbstractMatFormField`, the framework's own
|
|
316
|
+
* base for this (it's what `yuv-autocomplete` extends) - so `stateChanges`, `value`, `placeholder`,
|
|
317
|
+
* `required`, `disabled`, `focused`, `errorState`, `ngControl` and `shouldLabelFloat` are all
|
|
318
|
+
* inherited rather than re-implemented here. Two of its behaviours are deliberately opted out of;
|
|
319
|
+
* both are noted where that happens below.
|
|
320
|
+
*
|
|
321
|
+
* `[value]` is the list itself. Each item's markup comes entirely from the `<ng-template let-item>`
|
|
322
|
+
* projected as this component's content - deliberately not a fixed "badge" shape with a growing
|
|
323
|
+
* pile of `displayWith`/`tooltipWith`/`severityWith`/... inputs, since what a list item actually
|
|
324
|
+
* needs to show (a plain badge, an icon+badge, a clickable button wrapping one) varies enough
|
|
325
|
+
* between usages that a template the caller fully controls is simpler than anticipating every
|
|
326
|
+
* variation through more inputs - and the caller's own pipes/translations/click handlers are
|
|
327
|
+
* already in scope inside that template, with no need to re-expose them as function inputs.
|
|
328
|
+
* `[emptyLabel]` is shown as plain text when the list is empty - pass it already translated (e.g.
|
|
329
|
+
* `[emptyLabel]="'...' | translate"`), this component has no i18n dependency of its own.
|
|
330
|
+
*
|
|
331
|
+
* Note the empty state is driven by the template's own `@if (value && value.length)`, not by the
|
|
332
|
+
* inherited `empty` - which the base derives as `!value`, i.e. `false` for an empty array. That
|
|
333
|
+
* suits this control: `shouldLabelFloat` is `focused || !empty`, so the label floats permanently,
|
|
334
|
+
* which is what a labelled list wants (there is no "empty native input" state for a label to sit
|
|
335
|
+
* over as placeholder text).
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```html
|
|
339
|
+
* <mat-form-field>
|
|
340
|
+
* <mat-label>Assigned roles</mat-label>
|
|
341
|
+
* <ymusrmgmt-list-field [value]="roles" [emptyLabel]="'None' | translate">
|
|
342
|
+
* <ng-template let-role>
|
|
343
|
+
* <ymt-badge [matTooltip]="role | roleTooltip">{{ role | roleLabel }}</ymt-badge>
|
|
344
|
+
* </ng-template>
|
|
345
|
+
* </ymusrmgmt-list-field>
|
|
346
|
+
* </mat-form-field>
|
|
347
|
+
* ```
|
|
348
|
+
*/
|
|
349
|
+
class ListFieldComponent extends AbstractMatFormField {
|
|
350
|
+
/** The per-item template, projected as this component's content - receives each item as its
|
|
351
|
+
* implicit context (`<ng-template let-item>`). */
|
|
352
|
+
itemTemplate;
|
|
353
|
+
emptyLabel = '';
|
|
251
354
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
355
|
+
* The list to render. Declared here because the base doesn't do it itself:
|
|
356
|
+
* `AbstractMatFormField` marks `placeholder`/`required`/`disabled` as inputs but not `value`,
|
|
357
|
+
* since its other subclass (`yuv-autocomplete`) receives values through
|
|
358
|
+
* `ControlValueAccessor.writeValue` rather than a template binding. This control has no
|
|
359
|
+
* `NgControl`, so a binding is exactly what it needs - the accessor itself just forwards to the
|
|
360
|
+
* base, which keeps `empty` and `stateChanges` up to date.
|
|
254
361
|
*/
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
discoveryUrl: this.discoveryUrl,
|
|
270
|
-
clientId: this.clientId,
|
|
271
|
-
clientSecret: this.clientSecret,
|
|
272
|
-
});
|
|
273
|
-
/** Only used to update the client secret of an already configured OIDC identity provider, e.g. after it was rotated at the IdP. Pre-filled with a fake value; the update button unlocks once the user edits it. */
|
|
274
|
-
newClientSecret = new FormControl('');
|
|
275
|
-
constructor() {
|
|
276
|
-
this.#loadProviderConfig();
|
|
277
|
-
}
|
|
278
|
-
/** Resets the client secret field back to its fake placeholder value, pristine, so the update button locks again. */
|
|
279
|
-
#resetNewClientSecretField() {
|
|
280
|
-
this.newClientSecret.setValue(FAKE_CLIENT_SECRET);
|
|
281
|
-
this.newClientSecret.markAsPristine();
|
|
282
|
-
}
|
|
283
|
-
#loadProviderConfig() {
|
|
284
|
-
this.#userManagementService.fetchOIDCProviderConfig().subscribe({
|
|
285
|
-
next: (provider) => {
|
|
286
|
-
this.provider.set(provider);
|
|
287
|
-
this.notConfigured.set(false);
|
|
288
|
-
this.#resetNewClientSecretField();
|
|
289
|
-
this.busy.set(false);
|
|
290
|
-
},
|
|
291
|
-
error: (e) => {
|
|
292
|
-
if (e.status === 404) {
|
|
293
|
-
this.notConfigured.set(true);
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
this.error.set(this.#translateService.instant('yuv.app.user-management.settings.oidc.error.unknown'));
|
|
297
|
-
this.#logger.error('Failed to fetch OIDC identity provider configuration', e);
|
|
298
|
-
}
|
|
299
|
-
this.busy.set(false);
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
configure() {
|
|
304
|
-
this.busy.set(true);
|
|
305
|
-
this.error.set(undefined);
|
|
306
|
-
const providerConfig = {
|
|
307
|
-
displayName: this.displayName.value?.trim() ?? '',
|
|
308
|
-
config: {
|
|
309
|
-
// The discovery URL must be sent as `metadataDescriptorUrl` — the backend resolves the
|
|
310
|
-
// OIDC endpoints (authorizationUrl, tokenUrl, jwksUrl, ...) from the discovery document.
|
|
311
|
-
metadataDescriptorUrl: this.discoveryUrl.value?.trim() ?? '',
|
|
312
|
-
clientId: this.clientId.value?.trim() ?? '',
|
|
313
|
-
clientSecret: this.clientSecret.value ?? '',
|
|
314
|
-
defaultScope: 'openid profile',
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
this.#userManagementService
|
|
318
|
-
.setOIDCProviderConfig(providerConfig)
|
|
319
|
-
.pipe(
|
|
320
|
-
// Re-fetch so the overlay shows exactly what a fresh GET delivers (secrets stripped etc.).
|
|
321
|
-
switchMap(() => this.#userManagementService.fetchOIDCProviderConfig()))
|
|
322
|
-
.subscribe({
|
|
323
|
-
next: (provider) => {
|
|
324
|
-
this.provider.set(provider);
|
|
325
|
-
this.notConfigured.set(false);
|
|
326
|
-
this.#resetNewClientSecretField();
|
|
327
|
-
this.busy.set(false);
|
|
328
|
-
},
|
|
329
|
-
error: (e) => {
|
|
330
|
-
// 422: the backend could not resolve the OIDC endpoints from the discovery URL
|
|
331
|
-
this.error.set(this.#translateService.instant(e.status === 422
|
|
332
|
-
? 'yuv.app.user-management.settings.oidc.configure.error.discovery'
|
|
333
|
-
: 'yuv.app.user-management.settings.oidc.configure.error.unknown'));
|
|
334
|
-
this.#logger.error('Failed to configure OIDC identity provider', e);
|
|
335
|
-
this.busy.set(false);
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
/** Updates the client secret of the already configured OIDC identity provider, keeping all other config values unchanged. */
|
|
340
|
-
update() {
|
|
341
|
-
const oidcProvider = this.provider();
|
|
342
|
-
const newClientSecret = this.newClientSecret.value?.trim();
|
|
343
|
-
if (!oidcProvider || !this.newClientSecret.dirty || !newClientSecret)
|
|
344
|
-
return;
|
|
345
|
-
this.busy.set(true);
|
|
346
|
-
this.error.set(undefined);
|
|
347
|
-
const providerConfig = {
|
|
348
|
-
displayName: oidcProvider.displayName,
|
|
349
|
-
config: {
|
|
350
|
-
metadataDescriptorUrl: oidcProvider.config?.['metadataDescriptorUrl'] ?? '',
|
|
351
|
-
clientId: oidcProvider.config?.['clientId'] ?? '',
|
|
352
|
-
clientSecret: newClientSecret,
|
|
353
|
-
defaultScope: oidcProvider.config?.['defaultScope'] ?? '',
|
|
354
|
-
},
|
|
355
|
-
};
|
|
356
|
-
this.#userManagementService
|
|
357
|
-
.setOIDCProviderConfig(providerConfig)
|
|
358
|
-
.pipe(
|
|
359
|
-
// Re-fetch so the overlay shows exactly what a fresh GET delivers (secrets stripped etc.).
|
|
360
|
-
switchMap(() => this.#userManagementService.fetchOIDCProviderConfig()))
|
|
361
|
-
.subscribe({
|
|
362
|
-
next: (provider) => {
|
|
363
|
-
this.provider.set(provider);
|
|
364
|
-
this.#resetNewClientSecretField();
|
|
365
|
-
this.busy.set(false);
|
|
366
|
-
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.update.success'));
|
|
367
|
-
},
|
|
368
|
-
error: (e) => {
|
|
369
|
-
this.error.set(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.update.error'));
|
|
370
|
-
this.#logger.error('Failed to update the OIDC identity provider client secret', e);
|
|
371
|
-
this.busy.set(false);
|
|
372
|
-
},
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
/** Asks the user to confirm, then deletes the OIDC identity provider configuration. */
|
|
376
|
-
delete() {
|
|
377
|
-
this.#userManagementService.deleteOIDCProviderConfig().subscribe({
|
|
378
|
-
next: (deleted) => {
|
|
379
|
-
if (deleted) {
|
|
380
|
-
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.delete.success'));
|
|
381
|
-
this.#dialogRef.close();
|
|
382
|
-
}
|
|
383
|
-
},
|
|
384
|
-
error: (e) => {
|
|
385
|
-
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.delete.error'));
|
|
386
|
-
this.#logger.error('Failed to delete OIDC identity provider configuration', e);
|
|
387
|
-
},
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
/** Handles the `cdkCopyToClipboardCopied` output, which reports whether the copy succeeded. */
|
|
391
|
-
onCopied(success) {
|
|
392
|
-
if (success) {
|
|
393
|
-
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.copy.success'));
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
396
|
-
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.copy.error'));
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
close() {
|
|
400
|
-
this.#dialogRef.close();
|
|
401
|
-
}
|
|
402
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
403
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: SettingsComponent, isStandalone: true, selector: "ymusrmgmt-settings", ngImport: i0, template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.settings.dialog.title' | translate\">\n <main class=\"yuv-dialog-content\">\n @if (notConfigured()) {\n <p class=\"hint\">{{ 'yuv.app.user-management.settings.oidc.notConfigured' | translate }}</p>\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.displayName' | translate }}</mat-label>\n <input matInput [formControl]=\"displayName\" type=\"text\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.discoveryUrl' | translate }}</mat-label>\n <input matInput [formControl]=\"discoveryUrl\" type=\"text\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.clientId' | translate }}</mat-label>\n <input matInput [formControl]=\"clientId\" type=\"text\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.clientSecret' | translate }}</mat-label>\n <input matInput [formControl]=\"clientSecret\" type=\"password\" autocomplete=\"new-password\" />\n </mat-form-field>\n </form>\n } @else if (provider(); as oidcProvider) {\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.displayName' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"oidcProvider.displayName || '\u2014'\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.discoveryUrl' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"oidcProvider.config?.['metadataDescriptorUrl'] || '\u2014'\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.clientId' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"oidcProvider.config?.['clientId'] || '\u2014'\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.clientSecret' | translate }}</mat-label>\n <input matInput [formControl]=\"newClientSecret\" type=\"password\" autocomplete=\"new-password\" />\n </mat-form-field>\n @if (oidcProvider.redirectUri; as redirectUri) {\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.redirectUri' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"redirectUri\" />\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"redirectUri\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n </mat-form-field>\n }\n\n @if (oidcProvider.scimProvisioning; as scimProvisioning) {\n <h4>{{ 'yuv.app.user-management.settings.oidc.section.provisioning' | translate }}</h4>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.tenantUrl' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"tenantUrl() || '\u2014'\" />\n @if (tenantUrl(); as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.tokenUrl' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"scimProvisioning.tokenEndpoint || '\u2014'\" />\n @if (scimProvisioning.tokenEndpoint; as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.clientName' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"scimProvisioning.clientId || '\u2014'\" />\n @if (scimProvisioning.clientId; as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.clientSecret' | translate }}</mat-label>\n <input matInput type=\"password\" readonly [attr.tabindex]=\"-1\" [value]=\"scimProvisioning.clientSecret || '\u2014'\" />\n @if (scimProvisioning.clientSecret; as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n }\n }\n\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n @if (provider()) {\n <button ymtButton=\"danger\" class=\"delete-button\" [disabled]=\"busy()\" (click)=\"delete()\">\n {{ 'yuv.app.user-management.settings.oidc.action.delete.label' | translate }}\n </button>\n }\n <button [ymtButton]=\"notConfigured() ? 'secondary' : 'primary'\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.settings.action.button.close' | translate }}\n </button>\n @if (notConfigured()) {\n <button ymtButton=\"primary\" [disabled]=\"busy() || form.invalid\" (click)=\"configure()\">\n {{ 'yuv.app.user-management.settings.action.button.configure' | translate }}\n </button>\n } @else if (provider()) {\n <button ymtButton=\"secondary\" [disabled]=\"busy() || !newClientSecret.dirty || !newClientSecret.value?.trim()\" (click)=\"update()\">\n {{ 'yuv.app.user-management.settings.oidc.action.update.label' | translate }}\n </button>\n }\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{padding:var(--ymt-spacing-m);min-height:120px}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0}:host main h4{margin:var(--ymt-spacing-m) 0 var(--ymt-spacing-s)}:host footer .delete-button{margin-inline-end:auto}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: ClipboardModule }, { kind: "directive", type: i2.CdkCopyToClipboard, selector: "[cdkCopyToClipboard]", inputs: ["cdkCopyToClipboard", "cdkCopyToClipboardAttempts"], outputs: ["cdkCopyToClipboardCopied"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3$1.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
404
|
-
}
|
|
405
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SettingsComponent, decorators: [{
|
|
406
|
-
type: Component,
|
|
407
|
-
args: [{ selector: 'ymusrmgmt-settings', standalone: true, imports: [...modules$4, ...components$4], template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.settings.dialog.title' | translate\">\n <main class=\"yuv-dialog-content\">\n @if (notConfigured()) {\n <p class=\"hint\">{{ 'yuv.app.user-management.settings.oidc.notConfigured' | translate }}</p>\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.displayName' | translate }}</mat-label>\n <input matInput [formControl]=\"displayName\" type=\"text\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.discoveryUrl' | translate }}</mat-label>\n <input matInput [formControl]=\"discoveryUrl\" type=\"text\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.clientId' | translate }}</mat-label>\n <input matInput [formControl]=\"clientId\" type=\"text\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.clientSecret' | translate }}</mat-label>\n <input matInput [formControl]=\"clientSecret\" type=\"password\" autocomplete=\"new-password\" />\n </mat-form-field>\n </form>\n } @else if (provider(); as oidcProvider) {\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.displayName' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"oidcProvider.displayName || '\u2014'\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.discoveryUrl' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"oidcProvider.config?.['metadataDescriptorUrl'] || '\u2014'\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.clientId' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"oidcProvider.config?.['clientId'] || '\u2014'\" />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.configure.property.clientSecret' | translate }}</mat-label>\n <input matInput [formControl]=\"newClientSecret\" type=\"password\" autocomplete=\"new-password\" />\n </mat-form-field>\n @if (oidcProvider.redirectUri; as redirectUri) {\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.property.redirectUri' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"redirectUri\" />\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"redirectUri\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n </mat-form-field>\n }\n\n @if (oidcProvider.scimProvisioning; as scimProvisioning) {\n <h4>{{ 'yuv.app.user-management.settings.oidc.section.provisioning' | translate }}</h4>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.tenantUrl' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"tenantUrl() || '\u2014'\" />\n @if (tenantUrl(); as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.tokenUrl' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"scimProvisioning.tokenEndpoint || '\u2014'\" />\n @if (scimProvisioning.tokenEndpoint; as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.clientName' | translate }}</mat-label>\n <input matInput type=\"text\" readonly [attr.tabindex]=\"-1\" [value]=\"scimProvisioning.clientId || '\u2014'\" />\n @if (scimProvisioning.clientId; as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.settings.oidc.provisioning.property.clientSecret' | translate }}</mat-label>\n <input matInput type=\"password\" readonly [attr.tabindex]=\"-1\" [value]=\"scimProvisioning.clientSecret || '\u2014'\" />\n @if (scimProvisioning.clientSecret; as value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\">\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n }\n }\n\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n @if (provider()) {\n <button ymtButton=\"danger\" class=\"delete-button\" [disabled]=\"busy()\" (click)=\"delete()\">\n {{ 'yuv.app.user-management.settings.oidc.action.delete.label' | translate }}\n </button>\n }\n <button [ymtButton]=\"notConfigured() ? 'secondary' : 'primary'\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.settings.action.button.close' | translate }}\n </button>\n @if (notConfigured()) {\n <button ymtButton=\"primary\" [disabled]=\"busy() || form.invalid\" (click)=\"configure()\">\n {{ 'yuv.app.user-management.settings.action.button.configure' | translate }}\n </button>\n } @else if (provider()) {\n <button ymtButton=\"secondary\" [disabled]=\"busy() || !newClientSecret.dirty || !newClientSecret.value?.trim()\" (click)=\"update()\">\n {{ 'yuv.app.user-management.settings.oidc.action.update.label' | translate }}\n </button>\n }\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{padding:var(--ymt-spacing-m);min-height:120px}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0}:host main h4{margin:var(--ymt-spacing-m) 0 var(--ymt-spacing-s)}:host footer .delete-button{margin-inline-end:auto}\n"] }]
|
|
408
|
-
}], ctorParameters: () => [] });
|
|
409
|
-
|
|
410
|
-
class YuvUserManagementComponent {
|
|
411
|
-
#shell = inject(ShellService);
|
|
412
|
-
#titleService = inject(Title);
|
|
413
|
-
#translateService = inject(TranslateService);
|
|
414
|
-
#router = inject(Router);
|
|
415
|
-
#activatedRoute = inject(ActivatedRoute);
|
|
416
|
-
#dialog = inject(MatDialog);
|
|
417
|
-
#app = this.#shell.getApp(APP_ID);
|
|
418
|
-
appName = this.#app?.title;
|
|
419
|
-
icons = APP_ICONS;
|
|
420
|
-
views = [
|
|
421
|
-
{ id: 'users', title: 'yuv.app.user-management.navigation.users.label', link: 'users', tooltip: 'yuv.app.user-management.navigation.users.tooltip' },
|
|
422
|
-
{ id: 'groups', title: 'yuv.app.user-management.navigation.groups.label', link: 'groups', tooltip: 'yuv.app.user-management.navigation.groups.tooltip' },
|
|
423
|
-
];
|
|
424
|
-
activeLink = this.views[0].link;
|
|
362
|
+
set value(items) {
|
|
363
|
+
super.value = items;
|
|
364
|
+
}
|
|
365
|
+
get value() {
|
|
366
|
+
return super.value;
|
|
367
|
+
}
|
|
368
|
+
/** Nothing focusable/labelable lives inside by default - there's no native control for a
|
|
369
|
+
* `<label for>` to point at, so automatic labeling would just be wrong (and Material would warn
|
|
370
|
+
* about it). An item template with its own focusable content (e.g. a clickable group badge)
|
|
371
|
+
* still gets focus/click normally - this only affects the form field's own label association. */
|
|
372
|
+
disableAutomaticLabeling = true;
|
|
373
|
+
/** Ids of any `mat-hint`/`mat-error` describing this control, reflected onto the host by the
|
|
374
|
+
* `aria-describedby` binding above - see `setDescribedByIds`. */
|
|
375
|
+
describedBy = '';
|
|
425
376
|
constructor() {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
static
|
|
377
|
+
super();
|
|
378
|
+
// The base starts every instance at `id: ''`, and `mat-form-field` needs a unique one to
|
|
379
|
+
// reference this control from its label.
|
|
380
|
+
this.id = `ymusrmgmt-list-field-${nextId++}`;
|
|
381
|
+
// Opts out of the base's `onContainerClick`, which forwards a click anywhere in the form field
|
|
382
|
+
// to the first focusable element inside the control. For a read-only list that would mean
|
|
383
|
+
// clicking the field's padding jumps focus onto the first group badge - a plain read-only
|
|
384
|
+
// field does nothing on such a click, and so does this one. `focusHandled` is the base's own
|
|
385
|
+
// flag for "the subclass manages its own focus", so this needs no override.
|
|
386
|
+
this.focusHandled.set(true);
|
|
387
|
+
}
|
|
388
|
+
/** The base's implementation is an empty stub (its body is commented out), so `aria-describedby`
|
|
389
|
+
* has to be wired here or a `mat-hint`/`mat-error` alongside this control would never be
|
|
390
|
+
* announced. Harmless today - no usage has either - but it would be a silent trap later. */
|
|
391
|
+
setDescribedByIds(ids) {
|
|
392
|
+
this.describedBy = ids.join(' ');
|
|
393
|
+
}
|
|
394
|
+
/** The base exposes its teardown as `onNgOnDestroy`, not as a lifecycle hook of its own, so it
|
|
395
|
+
* only runs if a subclass calls it - without this, `stateChanges` would never complete. */
|
|
396
|
+
ngOnDestroy() {
|
|
397
|
+
this.onNgOnDestroy();
|
|
398
|
+
}
|
|
399
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ListFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
400
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ListFieldComponent, isStandalone: true, selector: "ymusrmgmt-list-field", inputs: { emptyLabel: "emptyLabel", value: "value" }, host: { properties: { "id": "id", "attr.aria-describedby": "describedBy || null" } }, providers: [{ provide: MatFormFieldControl, useExisting: ListFieldComponent }], queries: [{ propertyName: "itemTemplate", first: true, predicate: TemplateRef, descendants: true }], usesInheritance: true, ngImport: i0, template: "@if (value && value.length) {\n<div class=\"list-field-items\">\n @for (item of value; track item) {\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item }\"></ng-container>\n }\n</div>\n} @else {\n<span class=\"empty\">{{ emptyLabel }}</span>\n}\n", styles: [":host{display:block}.list-field-items{display:flex;flex-wrap:wrap;align-items:center;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-s) 0;overflow:hidden}.empty{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
|
|
449
401
|
}
|
|
450
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
402
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ListFieldComponent, decorators: [{
|
|
451
403
|
type: Component,
|
|
452
|
-
args: [{ selector: 'ymusrmgmt-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
404
|
+
args: [{ selector: 'ymusrmgmt-list-field', standalone: true, imports: [NgTemplateOutlet], providers: [{ provide: MatFormFieldControl, useExisting: ListFieldComponent }], host: {
|
|
405
|
+
'[id]': 'id',
|
|
406
|
+
'[attr.aria-describedby]': 'describedBy || null',
|
|
407
|
+
}, template: "@if (value && value.length) {\n<div class=\"list-field-items\">\n @for (item of value; track item) {\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item }\"></ng-container>\n }\n</div>\n} @else {\n<span class=\"empty\">{{ emptyLabel }}</span>\n}\n", styles: [":host{display:block}.list-field-items{display:flex;flex-wrap:wrap;align-items:center;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-s) 0;overflow:hidden}.empty{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}\n"] }]
|
|
408
|
+
}], ctorParameters: () => [], propDecorators: { itemTemplate: [{
|
|
409
|
+
type: ContentChild,
|
|
410
|
+
args: [TemplateRef]
|
|
411
|
+
}], emptyLabel: [{
|
|
412
|
+
type: Input
|
|
413
|
+
}], value: [{
|
|
414
|
+
type: Input
|
|
415
|
+
}] } });
|
|
463
416
|
|
|
464
417
|
const ROLE_KEY_PREFIX = 'role:';
|
|
465
418
|
/** Localized role name, falling back to the technical name when no `_label` entry exists. */
|
|
@@ -484,10 +437,10 @@ class RoleLabelPipe {
|
|
|
484
437
|
transform(role) {
|
|
485
438
|
return resolveRoleLabel(this.#localization, role);
|
|
486
439
|
}
|
|
487
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
488
|
-
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.
|
|
440
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: RoleLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
441
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.23", ngImport: i0, type: RoleLabelPipe, isStandalone: true, name: "roleLabel" });
|
|
489
442
|
}
|
|
490
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
443
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: RoleLabelPipe, decorators: [{
|
|
491
444
|
type: Pipe,
|
|
492
445
|
args: [{ name: 'roleLabel', standalone: true }]
|
|
493
446
|
}] });
|
|
@@ -496,10 +449,10 @@ class RoleTooltipPipe {
|
|
|
496
449
|
transform(role) {
|
|
497
450
|
return resolveRoleTooltip(this.#localization, role);
|
|
498
451
|
}
|
|
499
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
500
|
-
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.
|
|
452
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: RoleTooltipPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
453
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.23", ngImport: i0, type: RoleTooltipPipe, isStandalone: true, name: "roleTooltip" });
|
|
501
454
|
}
|
|
502
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
455
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: RoleTooltipPipe, decorators: [{
|
|
503
456
|
type: Pipe,
|
|
504
457
|
args: [{ name: 'roleTooltip', standalone: true }]
|
|
505
458
|
}] });
|
|
@@ -510,36 +463,90 @@ class SortRolesByLabelPipe {
|
|
|
510
463
|
return [];
|
|
511
464
|
return [...roles].sort((a, b) => resolveRoleLabel(this.#localization, a).localeCompare(resolveRoleLabel(this.#localization, b)));
|
|
512
465
|
}
|
|
513
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
514
|
-
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.
|
|
466
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: SortRolesByLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
467
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.23", ngImport: i0, type: SortRolesByLabelPipe, isStandalone: true, name: "sortRolesByLabel" });
|
|
515
468
|
}
|
|
516
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
469
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: SortRolesByLabelPipe, decorators: [{
|
|
517
470
|
type: Pipe,
|
|
518
471
|
args: [{ name: 'sortRolesByLabel', standalone: true }]
|
|
519
472
|
}] });
|
|
520
473
|
|
|
521
474
|
class UserDetailsComponent {
|
|
522
|
-
userService;
|
|
523
|
-
|
|
475
|
+
#userService = inject(YuvUserManagementService);
|
|
476
|
+
#logger = inject(Logger);
|
|
477
|
+
#notificationService = inject(NotificationService);
|
|
478
|
+
#translateService = inject(TranslateService);
|
|
479
|
+
#router = inject(Router);
|
|
480
|
+
icons = APP_ICONS;
|
|
481
|
+
/** The row the pane header is built from - available synchronously from the table selection, so the header doesn't wait on `userDetails`. */
|
|
482
|
+
user = input(undefined, ...(ngDevMode ? [{ debugName: "user" }] : /* istanbul ignore next */ []));
|
|
483
|
+
/** Roles granted by the group currently scoping the page's user list (empty when viewing "All users"), used to highlight which of the user's inherited roles come from it specifically. */
|
|
484
|
+
groupRoles = input([], ...(ngDevMode ? [{ debugName: "groupRoles" }] : /* istanbul ignore next */ []));
|
|
485
|
+
/** Forwarded to the internal `ymt-pane`'s top bar - see `YmtPaneComponent.topBarActions`. */
|
|
486
|
+
topBarActions = input(...(ngDevMode ? [undefined, { debugName: "topBarActions" }] : /* istanbul ignore next */ []));
|
|
487
|
+
/** Emitted with the displayed user when the header's edit action is triggered - editing itself is owned by the page, this only surfaces the trigger. */
|
|
488
|
+
editUser = output();
|
|
489
|
+
/** Groups/roles overview content - fetched separately since it isn't available on the plain table row. */
|
|
524
490
|
userDetails = signal(undefined, ...(ngDevMode ? [{ debugName: "userDetails" }] : /* istanbul ignore next */ []));
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
491
|
+
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
492
|
+
/** Only reloads `userDetails` when the id actually changes, not on every new object reference for the same user. */
|
|
493
|
+
#userId = computed(() => this.user()?.id, ...(ngDevMode ? [{ debugName: "#userId" }] : /* istanbul ignore next */ []));
|
|
494
|
+
/** Groups the user belongs to, alphabetically - the raw `groups` array has no guaranteed order. */
|
|
495
|
+
sortedGroups = computed(() => [...(this.userDetails()?.groups ?? [])].sort((a, b) => a.localeCompare(b)), ...(ngDevMode ? [{ debugName: "sortedGroups" }] : /* istanbul ignore next */ []));
|
|
496
|
+
/** Roles assigned to the user directly. */
|
|
497
|
+
directRoles = computed(() => this.userDetails()?.roles ?? [], ...(ngDevMode ? [{ debugName: "directRoles" }] : /* istanbul ignore next */ []));
|
|
498
|
+
/** Roles the user only holds through group membership - present in `effectiveRoles` but not directly assigned. */
|
|
499
|
+
inheritedRoles = computed(() => {
|
|
500
|
+
const details = this.userDetails();
|
|
501
|
+
if (!details) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
const direct = new Set(details.roles);
|
|
505
|
+
return details.effectiveRoles.filter((role) => !direct.has(role));
|
|
506
|
+
}, ...(ngDevMode ? [{ debugName: "inheritedRoles" }] : /* istanbul ignore next */ []));
|
|
507
|
+
/** Fast lookup for `isFromCurrentGroup` - which of `groupRoles` (the currently scoped group's roles) is being checked per inherited-role chip. */
|
|
508
|
+
#currentGroupRoles = computed(() => new Set(this.groupRoles()), ...(ngDevMode ? [{ debugName: "#currentGroupRoles" }] : /* istanbul ignore next */ []));
|
|
509
|
+
/** Whether an inherited role is granted by the group currently scoping the page's user list, rather than some other group the user belongs to. */
|
|
510
|
+
isFromCurrentGroup(role) {
|
|
511
|
+
return this.#currentGroupRoles().has(role);
|
|
512
|
+
}
|
|
513
|
+
/** Cached, shared across every `openGroup` call - group badges can be clicked repeatedly, and
|
|
514
|
+
* `fetchAllGroups()` paginates through the entire group directory, so re-fetching it fresh on
|
|
515
|
+
* every click would be wasteful once the first click already has it. Lazily created on first
|
|
516
|
+
* use rather than eagerly in the constructor, since most users never click a group badge. */
|
|
517
|
+
#allGroups$;
|
|
518
|
+
/** Navigates to the given group in the groups pane. `userDetails().groups` is just a list of
|
|
519
|
+
* group names (the backend's user-details endpoint doesn't return group ids), so the name has
|
|
520
|
+
* to be resolved against the full groups list first. */
|
|
521
|
+
openGroup(groupName) {
|
|
522
|
+
(this.#allGroups$ ??= this.#userService.fetchAllGroups().pipe(shareReplay(1))).subscribe({
|
|
523
|
+
next: (groups) => {
|
|
524
|
+
const group = groups.find((g) => g.name === groupName);
|
|
525
|
+
if (!group) {
|
|
526
|
+
this.#logger.error(`Could not resolve group '${groupName}' to a group id`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
this.#router.navigate(['/identities'], { queryParams: { group: group.id } });
|
|
530
|
+
},
|
|
531
|
+
error: (e) => this.#logger.error(`Failed to resolve group '${groupName}'`, e),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
constructor() {
|
|
535
|
+
effect(() => this.loadUserDetails(this.#userId()));
|
|
536
|
+
}
|
|
537
|
+
loadUserDetails(userId) {
|
|
538
|
+
if (userId) {
|
|
539
|
+
// Clears the previous user's groups/roles immediately rather than leaving them up (stale)
|
|
540
|
+
// until the new response arrives - `busy` alone wouldn't make that obvious enough.
|
|
541
|
+
this.userDetails.set(undefined);
|
|
542
|
+
this.busy.set(true);
|
|
543
|
+
this.#userService.getUserDetails(userId).subscribe({
|
|
544
|
+
next: (user) => this.userDetails.set(user),
|
|
545
|
+
error: (e) => {
|
|
546
|
+
this.#logger.error(`Failed to load details for user with id '${userId}'`, e);
|
|
547
|
+
this.busy.set(false);
|
|
548
|
+
},
|
|
549
|
+
complete: () => this.busy.set(false),
|
|
543
550
|
});
|
|
544
551
|
}
|
|
545
552
|
else {
|
|
@@ -550,145 +557,49 @@ class UserDetailsComponent {
|
|
|
550
557
|
* Refresh the user details by reloading the data from the server
|
|
551
558
|
*/
|
|
552
559
|
refresh() {
|
|
553
|
-
this.loadUserDetails();
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
email: [{ value: '' }],
|
|
564
|
-
enabled: [{ value: false, disabled: true }]
|
|
565
|
-
});
|
|
560
|
+
this.loadUserDetails(this.user()?.id);
|
|
561
|
+
}
|
|
562
|
+
/** Handles the `cdkCopyToClipboardCopied` output, which reports whether the copy succeeded. */
|
|
563
|
+
onCopied(success) {
|
|
564
|
+
if (success) {
|
|
565
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.common.action.copy.success'));
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.common.action.copy.error'));
|
|
569
|
+
}
|
|
566
570
|
}
|
|
567
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
568
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: UserDetailsComponent, isStandalone: true, selector: "ymusrmgmt-user-details", inputs: { userId: "userId" }, ngImport: i0, template: "@let currentUserDetails = userDetails();\n@if (currentUserDetails) {\n <form [formGroup]=\"userDetailsForm\" class=\"user-details-form\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{('yuv.app.user-management.users.object.property.id' | translate)}}</mat-label>\n <input matInput formControlName=\"id\" readonly>\n </mat-form-field>\n <mat-form-field>\n <mat-label>{{('yuv.app.user-management.users.object.property.username' | translate)}}</mat-label>\n <input matInput formControlName=\"username\" readonly>\n <mat-icon matPrefix>account_box</mat-icon>\n </mat-form-field>\n <mat-form-field>\n <mat-label>{{('yuv.app.user-management.users.object.property.email' | translate)}}</mat-label>\n <input matInput formControlName=\"email\" readonly>\n <mat-icon matPrefix>email</mat-icon>\n </mat-form-field> \n <mat-form-field>\n <mat-label>{{('yuv.app.user-management.users.object.property.firstname' | translate)}}</mat-label>\n <input matInput formControlName=\"firstName\" readonly>\n </mat-form-field>\n <mat-form-field>\n <mat-label>{{('yuv.app.user-management.users.object.property.lastname' | translate)}}</mat-label>\n <input matInput formControlName=\"lastName\" readonly>\n </mat-form-field>\n <mat-form-field class=\"groups-field\">\n <mat-label>{{('yuv.app.user-management.users.object.property.groups' | translate)}}</mat-label>\n <mat-chip-grid #groupChipGrid>\n @if (currentUserDetails.groups) {\n @for (group of currentUserDetails.groups.sort(); track group) {\n <mat-chip-row [editable]=\"false\"><div class=\"chip\">{{group}}</div></mat-chip-row>\n }\n }\n <input [matChipInputFor]=\"groupChipGrid\" readonly/>\n </mat-chip-grid>\n </mat-form-field>\n <mat-form-field class=\"roles-field\">\n <mat-label>{{('yuv.app.user-management.users.object.property.roles' | translate)}}</mat-label>\n <mat-chip-grid #roleChipGrid>\n @if (currentUserDetails.effectiveRoles) {\n @for (role of currentUserDetails.effectiveRoles | sortRolesByLabel; track role) {\n <mat-chip-row [editable]=\"false\" [disabled]=\"currentUserDetails.roles.indexOf(role) === -1\" [matTooltip]=\"role | roleTooltip\"><div class=\"chip\">{{role | roleLabel}}</div></mat-chip-row>\n }\n }\n <input [matChipInputFor]=\"roleChipGrid\" readonly/>\n </mat-chip-grid>\n </mat-form-field>\n <mat-slide-toggle formControlName=\"enabled\">\n {{ userDetailsForm.get('enabled')?.value ? ('yuv.app.user-management.users.object.property.enabled' | translate) :\n ('yuv.app.user-management.users.object.property.disabled' | translate) }}\n </mat-slide-toggle> \n </form>\n}", styles: [":host{height:100%;overflow:hidden;display:flex;flex-flow:column}:host .user-details-form{flex:1;overflow-y:auto;display:grid;align-items:start;align-content:start;gap:var(--ymt-spacing-m);padding:1rem}:host .user-details-form mat-chip-row{overflow:hidden}:host .user-details-form .groups-field mat-chip-grid,:host .user-details-form .roles-field mat-chip-grid{max-height:300px;overflow-y:auto;display:block}\n"], dependencies: [{ kind: "ngmodule", type: MatChipsModule }, { kind: "component", type: i3$2.MatChipGrid, selector: "mat-chip-grid", inputs: ["disabled", "placeholder", "required", "value", "errorStateMatcher"], outputs: ["change", "valueChange"] }, { kind: "directive", type: i3$2.MatChipInput, selector: "input[matChipInputFor]", inputs: ["matChipInputFor", "matChipInputAddOnBlur", "matChipInputSeparatorKeyCodes", "placeholder", "id", "disabled", "readonly", "matChipInputDisabledInteractive"], outputs: ["matChipInputTokenEnd"], exportAs: ["matChipInput", "matChipInputFor"] }, { kind: "component", type: i3$2.MatChipRow, selector: "mat-chip-row, [mat-chip-row], mat-basic-chip-row, [mat-basic-chip-row]", inputs: ["editable"], outputs: ["edited"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3$1.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i7.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }, { kind: "pipe", type: RoleLabelPipe, name: "roleLabel" }, { kind: "pipe", type: RoleTooltipPipe, name: "roleTooltip" }, { kind: "pipe", type: SortRolesByLabelPipe, name: "sortRolesByLabel" }] });
|
|
571
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserDetailsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
572
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: UserDetailsComponent, isStandalone: true, selector: "ymusrmgmt-user-details", inputs: { user: { classPropertyName: "user", publicName: "user", isSignal: true, isRequired: false, transformFunction: null }, groupRoles: { classPropertyName: "groupRoles", publicName: "groupRoles", isSignal: true, isRequired: false, transformFunction: null }, topBarActions: { classPropertyName: "topBarActions", publicName: "topBarActions", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { editUser: "editUser" }, ngImport: i0, template: "@let displayUser = user(); @let detailsTitle = 'yuv.app.user-management.users.label.details.title' |\ntranslate; @if (displayUser) {\n<ymt-pane [topBarActions]=\"topBarActions()\" role=\"region\" [attr.aria-label]=\"detailsTitle\">\n <ymt-pane-header\n [icon]=\"'badge'\"\n [title]=\"'yuv.app.user-management.users.label.details.title' | translate\"\n [subtitle]=\"displayUser.username\"\n [actions]=\"headerActions\"\n [badges]=\"badges\"\n ></ymt-pane-header>\n <ymt-pane-body [yuvBusyOverlay]=\"busy()\">\n @let currentUserDetails = userDetails(); @if (currentUserDetails) {\n <form class=\"properties-form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.id' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"displayUser.id\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.id' | translate\"\n />\n <button\n matSuffix\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.common.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"displayUser.id\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\"\n >\n <mat-icon>content_copy</mat-icon>\n </button>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.email' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"currentUserDetails.email\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.email' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.firstname' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"displayUser.firstName ?? ''\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.firstname' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.lastname' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"displayUser.lastName ?? ''\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.lastname' | translate\"\n />\n </mat-form-field>\n\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.users.label.details.roles.direct.title' | translate\n }}</mat-label>\n <ymusrmgmt-list-field\n [value]=\"directRoles() | sortRolesByLabel\"\n [emptyLabel]=\"\n 'yuv.app.user-management.users.label.details.roles.direct.empty' | translate\n \"\n >\n <ng-template let-role>\n <ymt-badge class=\"role-badge\" severity=\"primary\" [matTooltip]=\"role | roleTooltip\">\n <span class=\"badge-label\">{{ role | roleLabel }}</span>\n </ymt-badge>\n </ng-template>\n </ymusrmgmt-list-field>\n </mat-form-field>\n\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.users.label.details.roles.inherited.title' | translate\n }}</mat-label>\n <ymusrmgmt-list-field\n [value]=\"inheritedRoles() | sortRolesByLabel\"\n [emptyLabel]=\"\n 'yuv.app.user-management.users.label.details.roles.inherited.empty' | translate\n \"\n >\n <ng-template let-role>\n @let fromCurrentGroup = isFromCurrentGroup(role); @let tooltipText = role | roleTooltip;\n <ymt-badge\n class=\"role-badge\"\n [severity]=\"fromCurrentGroup ? 'primary' : 'neutral'\"\n [outline]=\"true\"\n [matTooltip]=\"\n fromCurrentGroup\n ? ('yuv.app.user-management.users.label.details.roles.inherited.currentGroup.tooltip'\n | translate) + (tooltipText ? ' \u2013 ' + tooltipText : '')\n : tooltipText\n \"\n >\n @if (fromCurrentGroup) {\n <mat-icon>group</mat-icon>\n }\n <span class=\"badge-label\">{{ role | roleLabel }}</span>\n </ymt-badge>\n </ng-template>\n </ymusrmgmt-list-field>\n </mat-form-field>\n\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.users.label.details.groups.title' | translate\n }}</mat-label>\n <ymusrmgmt-list-field\n [value]=\"sortedGroups()\"\n [emptyLabel]=\"'yuv.app.user-management.users.label.details.groups.empty' | translate\"\n >\n <ng-template let-group>\n <button\n type=\"button\"\n class=\"group-badge-button\"\n (click)=\"openGroup(group)\"\n [matTooltip]=\"\n 'yuv.app.user-management.users.label.details.groups.open.tooltip' | translate\n \"\n [attr.aria-label]=\"\n ('yuv.app.user-management.users.label.details.groups.open.tooltip' | translate) +\n ': ' +\n group\n \"\n >\n <ymt-badge severity=\"neutral\">\n <mat-icon>group</mat-icon>\n <span class=\"badge-label\">{{ group }}</span>\n </ymt-badge>\n </button>\n </ng-template>\n </ymusrmgmt-list-field>\n </mat-form-field>\n </form>\n }\n </ymt-pane-body>\n</ymt-pane>\n\n<ng-template #headerActions>\n <button\n mat-icon-button\n [matTooltip]=\"'yuv.app.user-management.users.action.edit.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.action.edit.label' | translate\"\n (click)=\"editUser.emit(displayUser)\"\n >\n <mat-icon>{{ icons.actionEditUser }}</mat-icon>\n </button>\n</ng-template>\n\n<ng-template #badges>\n @if (!displayUser.enabled) {\n <ymt-badge\n severity=\"neutral\"\n [aura]=\"true\"\n [matTooltip]=\"'yuv.app.user-management.users.property.disabled' | translate\"\n >\n <mat-icon aria-hidden=\"true\">{{ icons.stateInactive }}</mat-icon>\n <span>{{ 'yuv.app.user-management.users.property.disabled' | translate }}</span>\n </ymt-badge>\n } @if (displayUser.externallyManaged) {\n <ymt-badge\n severity=\"primary\"\n [aura]=\"true\"\n [matTooltip]=\"'yuv.app.user-management.users.label.identitySource.external.tooltip' | translate\"\n >\n <mat-icon>{{ icons.sourceExternal }}</mat-icon>\n <span>{{ 'yuv.app.user-management.users.property.identitySource.external' | translate }}</span>\n </ymt-badge>\n }\n</ng-template>\n} @else {\n<ymt-pane [topBarActions]=\"topBarActions()\" role=\"region\" [attr.aria-label]=\"detailsTitle\">\n <ymt-pane-body>\n <div class=\"details-empty-state\">\n <mat-icon>person_search</mat-icon>\n {{ 'yuv.app.user-management.users.label.details.empty' | translate }}\n </div>\n </ymt-pane-body>\n</ymt-pane>\n}\n", styles: ["ymt-badge{--ymt-badge-corner: var(--ymt-corner-xs);--badge-icon-size: var(--ymt-sizing-xs);display:inline-flex;align-items:center;gap:var(--ymt-spacing-xs);min-width:0;max-width:100%}ymt-badge mat-icon{flex-shrink:0;font-size:var(--badge-icon-size);width:var(--badge-icon-size);height:var(--badge-icon-size)}ymt-badge .badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}:host{display:block;height:100%;--main-area-padding: var(--ymt-spacing-m)}:host ymt-pane-body{display:flex;flex-flow:column;gap:var(--ymt-spacing-m)}:host .properties-form{display:flex;flex-direction:column;gap:var(--ymt-spacing-m)}:host .properties-form .yuv-form-field{width:100%}:host .properties-form .copy-button{border:none;padding:0;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;width:var(--ymt-sizing-xl);height:var(--ymt-sizing-xl);border-radius:var(--ymt-corner-full);color:var(--ymt-text-color-subtle)}:host .properties-form .copy-button:hover{background-color:var(--ymt-hover-background)}:host .properties-form .copy-button mat-icon{font-size:var(--ymt-sizing-m);width:var(--ymt-sizing-m);height:var(--ymt-sizing-m)}:host .role-badge{cursor:help}:host .group-badge-button{border:none;padding:0;background:transparent;cursor:pointer;display:inline-flex;font:inherit;border-radius:var(--ymt-badge-corner, var(--ymt-corner-xs));min-width:0;max-width:100%}:host .group-badge-button:hover ymt-badge{background-color:var(--ymt-hover-background)}:host .details-empty-state{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--ymt-spacing-m);text-align:center;color:var(--ymt-text-color-subtle);font:var(--ymt-font-body-subtle)}:host .details-empty-state mat-icon{font-size:var(--ymt-sizing-xl);width:var(--ymt-sizing-xl);height:var(--ymt-sizing-xl)}\n"], dependencies: [{ kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "ngmodule", type: ClipboardModule }, { kind: "directive", type: i1.CdkCopyToClipboard, selector: "[cdkCopyToClipboard]", inputs: ["cdkCopyToClipboard", "cdkCopyToClipboardAttempts"], outputs: ["cdkCopyToClipboardCopied"] }, { kind: "component", type: ListFieldComponent, selector: "ymusrmgmt-list-field", inputs: ["emptyLabel", "value"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "directive", type: i2.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: YmtBadge, selector: "ymt-badge", inputs: ["severity", "outline", "aura"] }, { kind: "ngmodule", type: YmtPanesModule }, { kind: "component", type: i7.YmtPaneComponent, selector: "ymt-pane", inputs: ["topBarActions", "busy", "noAnimation", "plain"] }, { kind: "component", type: i7.YmtPaneHeaderComponent, selector: "ymt-pane-header", inputs: ["title", "icon", "subtitle", "actions", "badges"] }, { kind: "component", type: i7.YmtPaneBodyComponent, selector: "ymt-pane-body" }, { kind: "pipe", type: TranslatePipe, name: "translate" }, { kind: "pipe", type: RoleLabelPipe, name: "roleLabel" }, { kind: "pipe", type: RoleTooltipPipe, name: "roleTooltip" }, { kind: "pipe", type: SortRolesByLabelPipe, name: "sortRolesByLabel" }] });
|
|
569
573
|
}
|
|
570
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
574
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserDetailsComponent, decorators: [{
|
|
571
575
|
type: Component,
|
|
572
576
|
args: [{ selector: 'ymusrmgmt-user-details', imports: [
|
|
573
|
-
|
|
577
|
+
BusyOverlayDirective,
|
|
578
|
+
ClipboardModule,
|
|
579
|
+
ListFieldComponent,
|
|
580
|
+
MatButtonModule,
|
|
574
581
|
MatFormFieldModule,
|
|
575
|
-
MatInputModule,
|
|
576
582
|
MatIconModule,
|
|
577
|
-
|
|
578
|
-
MatSlideToggleModule,
|
|
583
|
+
MatInputModule,
|
|
579
584
|
MatTooltipModule,
|
|
580
|
-
ReactiveFormsModule,
|
|
581
585
|
TranslatePipe,
|
|
586
|
+
YmtBadge,
|
|
587
|
+
YmtPanesModule,
|
|
582
588
|
RoleLabelPipe,
|
|
583
589
|
RoleTooltipPipe,
|
|
584
|
-
SortRolesByLabelPipe
|
|
585
|
-
], template: "@let
|
|
586
|
-
}], ctorParameters: () => [{ type:
|
|
587
|
-
type: Input
|
|
588
|
-
}] } });
|
|
589
|
-
|
|
590
|
-
const components$3 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent];
|
|
591
|
-
const modules$3 = [
|
|
592
|
-
CommonModule,
|
|
593
|
-
ReactiveFormsModule,
|
|
594
|
-
TranslatePipe,
|
|
595
|
-
MatFormFieldModule,
|
|
596
|
-
MatSlideToggleModule,
|
|
597
|
-
MatInputModule
|
|
598
|
-
];
|
|
599
|
-
class CreateUserComponent {
|
|
600
|
-
#fb = inject(FormBuilder);
|
|
601
|
-
#userManagementService = inject(YuvUserManagementService);
|
|
602
|
-
#dialogRef = inject((MatDialogRef));
|
|
603
|
-
#translateService = inject(TranslateService);
|
|
604
|
-
#logger = inject(Logger);
|
|
605
|
-
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
606
|
-
error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
607
|
-
username = new FormControl('', [Validators.required, Validators.minLength(3), Validators.maxLength(255), Validators.pattern('^[a-zA-Z0-9._-]*$')]);
|
|
608
|
-
email = new FormControl('', [Validators.required, Validators.email]);
|
|
609
|
-
firstname = new FormControl('', [Validators.minLength(1), Validators.maxLength(255), Validators.pattern("^[^#!<>&/%=;*§$()[\\]{}]*$")]);
|
|
610
|
-
lastname = new FormControl('', [Validators.minLength(1), Validators.maxLength(255), Validators.pattern("^[^#!<>&/%=;*§$()[\\]{}]*$")]);
|
|
611
|
-
enabled = new FormControl(true, []);
|
|
612
|
-
form = this.#fb.group({
|
|
613
|
-
username: this.username,
|
|
614
|
-
email: this.email,
|
|
615
|
-
firstname: this.firstname,
|
|
616
|
-
lastname: this.lastname,
|
|
617
|
-
enabled: this.enabled
|
|
618
|
-
});
|
|
619
|
-
changedInput = signal(undefined, ...(ngDevMode ? [{ debugName: "changedInput" }] : /* istanbul ignore next */ []));
|
|
620
|
-
disabledSubmit = linkedSignal({ ...(ngDevMode ? { debugName: "disabledSubmit" } : /* istanbul ignore next */ {}), source: this.changedInput,
|
|
621
|
-
computation: () => {
|
|
622
|
-
return this.busy() || this.form.invalid;
|
|
623
|
-
} });
|
|
624
|
-
createUser() {
|
|
625
|
-
let createdUserId;
|
|
626
|
-
const createParams = this.form.value;
|
|
627
|
-
this.busy.set(true);
|
|
628
|
-
this.error.set(undefined);
|
|
629
|
-
const createUserRequest = {
|
|
630
|
-
username: createParams.username,
|
|
631
|
-
email: createParams.email,
|
|
632
|
-
firstName: createParams.firstname ? createParams.firstname : null,
|
|
633
|
-
lastName: createParams.lastname ? createParams.lastname : null,
|
|
634
|
-
enabled: createParams.enabled,
|
|
635
|
-
roles: []
|
|
636
|
-
};
|
|
637
|
-
this.#userManagementService
|
|
638
|
-
.createUser(createUserRequest, true)
|
|
639
|
-
.subscribe({
|
|
640
|
-
error: (e) => {
|
|
641
|
-
switch (e.status) {
|
|
642
|
-
case 409: {
|
|
643
|
-
const errorMsg = JSON.parse(e.error).message;
|
|
644
|
-
if (errorMsg === "User exists with same username") {
|
|
645
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.users.action.create.error.usernameExists"));
|
|
646
|
-
}
|
|
647
|
-
else if (errorMsg === "User exists with same email") {
|
|
648
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.users.action.create.error.emailExists"));
|
|
649
|
-
}
|
|
650
|
-
else {
|
|
651
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.users.action.create.error.unknown"));
|
|
652
|
-
}
|
|
653
|
-
break;
|
|
654
|
-
}
|
|
655
|
-
default:
|
|
656
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.users.action.create.error.unknown"));
|
|
657
|
-
}
|
|
658
|
-
this.#logger.error(`Failed to create user with username ${createUserRequest.username}`, e);
|
|
659
|
-
this.busy.set(false);
|
|
660
|
-
},
|
|
661
|
-
next: (userId) => {
|
|
662
|
-
createdUserId = userId;
|
|
663
|
-
},
|
|
664
|
-
complete: () => {
|
|
665
|
-
this.busy.set(false);
|
|
666
|
-
this.#dialogRef.close(createdUserId);
|
|
667
|
-
},
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
close() {
|
|
671
|
-
this.#dialogRef.close();
|
|
672
|
-
}
|
|
673
|
-
ngAfterViewInit() {
|
|
674
|
-
this.form.valueChanges.subscribe((value) => {
|
|
675
|
-
this.changedInput.set(value);
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: CreateUserComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
679
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: CreateUserComponent, isStandalone: true, selector: "ymusrmgmt-create-user", host: { properties: { "class.busy": "!busy()" } }, ngImport: i0, template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.users.action.create.label' | translate\">\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.username\" | translate }}</mat-label>\n <input matInput [formControl]=\"username\" type=\"text\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.email\" | translate}}</mat-label>\n <input matInput [formControl]=\"email\" type=\"text\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.firstname\" | translate}}</mat-label>\n <input matInput [formControl]=\"firstname\" type=\"text\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.lastname\" | translate}}</mat-label>\n <input matInput [formControl]=\"lastname\" type=\"text\"/>\n </mat-form-field>\n <div class=\"yuv-form-field\" id=\"enabledPropDiv\">\n <mat-slide-toggle formControlName=\"enabled\">\n {{ form.get('enabled')?.value ? ('yuv.app.user-management.users.object.property.enabled' | translate) : ('yuv.app.user-management.users.object.property.disabled' | translate) }}\n </mat-slide-toggle>\n </div>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </form>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ \"yuv.app.user-management.users.action.button.cancel\" | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createUser()\">\n {{ 'yuv.app.user-management.users.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main form{padding:var(--ymt-spacing-m)}:host main form .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main form .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs)}:host main form #enabledPropDiv{display:flex;justify-content:center;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i7.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3$1.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
680
|
-
}
|
|
681
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: CreateUserComponent, decorators: [{
|
|
682
|
-
type: Component,
|
|
683
|
-
args: [{ selector: 'ymusrmgmt-create-user', standalone: true, imports: [...modules$3, ...components$3], host: {
|
|
684
|
-
'[class.busy]': '!busy()',
|
|
685
|
-
}, template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.users.action.create.label' | translate\">\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.username\" | translate }}</mat-label>\n <input matInput [formControl]=\"username\" type=\"text\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.email\" | translate}}</mat-label>\n <input matInput [formControl]=\"email\" type=\"text\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.firstname\" | translate}}</mat-label>\n <input matInput [formControl]=\"firstname\" type=\"text\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.users.object.property.lastname\" | translate}}</mat-label>\n <input matInput [formControl]=\"lastname\" type=\"text\"/>\n </mat-form-field>\n <div class=\"yuv-form-field\" id=\"enabledPropDiv\">\n <mat-slide-toggle formControlName=\"enabled\">\n {{ form.get('enabled')?.value ? ('yuv.app.user-management.users.object.property.enabled' | translate) : ('yuv.app.user-management.users.object.property.disabled' | translate) }}\n </mat-slide-toggle>\n </div>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </form>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ \"yuv.app.user-management.users.action.button.cancel\" | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createUser()\">\n {{ 'yuv.app.user-management.users.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main form{padding:var(--ymt-spacing-m)}:host main form .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main form .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs)}:host main form #enabledPropDiv{display:flex;justify-content:center;align-items:center}\n"] }]
|
|
686
|
-
}] });
|
|
590
|
+
SortRolesByLabelPipe,
|
|
591
|
+
], template: "@let displayUser = user(); @let detailsTitle = 'yuv.app.user-management.users.label.details.title' |\ntranslate; @if (displayUser) {\n<ymt-pane [topBarActions]=\"topBarActions()\" role=\"region\" [attr.aria-label]=\"detailsTitle\">\n <ymt-pane-header\n [icon]=\"'badge'\"\n [title]=\"'yuv.app.user-management.users.label.details.title' | translate\"\n [subtitle]=\"displayUser.username\"\n [actions]=\"headerActions\"\n [badges]=\"badges\"\n ></ymt-pane-header>\n <ymt-pane-body [yuvBusyOverlay]=\"busy()\">\n @let currentUserDetails = userDetails(); @if (currentUserDetails) {\n <form class=\"properties-form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.id' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"displayUser.id\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.id' | translate\"\n />\n <button\n matSuffix\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.common.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"displayUser.id\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\"\n >\n <mat-icon>content_copy</mat-icon>\n </button>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.email' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"currentUserDetails.email\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.email' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.firstname' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"displayUser.firstName ?? ''\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.firstname' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.lastname' | translate }}</mat-label>\n <input\n readonly\n matInput\n type=\"text\"\n [value]=\"displayUser.lastName ?? ''\"\n [attr.tabindex]=\"-1\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.lastname' | translate\"\n />\n </mat-form-field>\n\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.users.label.details.roles.direct.title' | translate\n }}</mat-label>\n <ymusrmgmt-list-field\n [value]=\"directRoles() | sortRolesByLabel\"\n [emptyLabel]=\"\n 'yuv.app.user-management.users.label.details.roles.direct.empty' | translate\n \"\n >\n <ng-template let-role>\n <ymt-badge class=\"role-badge\" severity=\"primary\" [matTooltip]=\"role | roleTooltip\">\n <span class=\"badge-label\">{{ role | roleLabel }}</span>\n </ymt-badge>\n </ng-template>\n </ymusrmgmt-list-field>\n </mat-form-field>\n\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.users.label.details.roles.inherited.title' | translate\n }}</mat-label>\n <ymusrmgmt-list-field\n [value]=\"inheritedRoles() | sortRolesByLabel\"\n [emptyLabel]=\"\n 'yuv.app.user-management.users.label.details.roles.inherited.empty' | translate\n \"\n >\n <ng-template let-role>\n @let fromCurrentGroup = isFromCurrentGroup(role); @let tooltipText = role | roleTooltip;\n <ymt-badge\n class=\"role-badge\"\n [severity]=\"fromCurrentGroup ? 'primary' : 'neutral'\"\n [outline]=\"true\"\n [matTooltip]=\"\n fromCurrentGroup\n ? ('yuv.app.user-management.users.label.details.roles.inherited.currentGroup.tooltip'\n | translate) + (tooltipText ? ' \u2013 ' + tooltipText : '')\n : tooltipText\n \"\n >\n @if (fromCurrentGroup) {\n <mat-icon>group</mat-icon>\n }\n <span class=\"badge-label\">{{ role | roleLabel }}</span>\n </ymt-badge>\n </ng-template>\n </ymusrmgmt-list-field>\n </mat-form-field>\n\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.users.label.details.groups.title' | translate\n }}</mat-label>\n <ymusrmgmt-list-field\n [value]=\"sortedGroups()\"\n [emptyLabel]=\"'yuv.app.user-management.users.label.details.groups.empty' | translate\"\n >\n <ng-template let-group>\n <button\n type=\"button\"\n class=\"group-badge-button\"\n (click)=\"openGroup(group)\"\n [matTooltip]=\"\n 'yuv.app.user-management.users.label.details.groups.open.tooltip' | translate\n \"\n [attr.aria-label]=\"\n ('yuv.app.user-management.users.label.details.groups.open.tooltip' | translate) +\n ': ' +\n group\n \"\n >\n <ymt-badge severity=\"neutral\">\n <mat-icon>group</mat-icon>\n <span class=\"badge-label\">{{ group }}</span>\n </ymt-badge>\n </button>\n </ng-template>\n </ymusrmgmt-list-field>\n </mat-form-field>\n </form>\n }\n </ymt-pane-body>\n</ymt-pane>\n\n<ng-template #headerActions>\n <button\n mat-icon-button\n [matTooltip]=\"'yuv.app.user-management.users.action.edit.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.action.edit.label' | translate\"\n (click)=\"editUser.emit(displayUser)\"\n >\n <mat-icon>{{ icons.actionEditUser }}</mat-icon>\n </button>\n</ng-template>\n\n<ng-template #badges>\n @if (!displayUser.enabled) {\n <ymt-badge\n severity=\"neutral\"\n [aura]=\"true\"\n [matTooltip]=\"'yuv.app.user-management.users.property.disabled' | translate\"\n >\n <mat-icon aria-hidden=\"true\">{{ icons.stateInactive }}</mat-icon>\n <span>{{ 'yuv.app.user-management.users.property.disabled' | translate }}</span>\n </ymt-badge>\n } @if (displayUser.externallyManaged) {\n <ymt-badge\n severity=\"primary\"\n [aura]=\"true\"\n [matTooltip]=\"'yuv.app.user-management.users.label.identitySource.external.tooltip' | translate\"\n >\n <mat-icon>{{ icons.sourceExternal }}</mat-icon>\n <span>{{ 'yuv.app.user-management.users.property.identitySource.external' | translate }}</span>\n </ymt-badge>\n }\n</ng-template>\n} @else {\n<ymt-pane [topBarActions]=\"topBarActions()\" role=\"region\" [attr.aria-label]=\"detailsTitle\">\n <ymt-pane-body>\n <div class=\"details-empty-state\">\n <mat-icon>person_search</mat-icon>\n {{ 'yuv.app.user-management.users.label.details.empty' | translate }}\n </div>\n </ymt-pane-body>\n</ymt-pane>\n}\n", styles: ["ymt-badge{--ymt-badge-corner: var(--ymt-corner-xs);--badge-icon-size: var(--ymt-sizing-xs);display:inline-flex;align-items:center;gap:var(--ymt-spacing-xs);min-width:0;max-width:100%}ymt-badge mat-icon{flex-shrink:0;font-size:var(--badge-icon-size);width:var(--badge-icon-size);height:var(--badge-icon-size)}ymt-badge .badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}:host{display:block;height:100%;--main-area-padding: var(--ymt-spacing-m)}:host ymt-pane-body{display:flex;flex-flow:column;gap:var(--ymt-spacing-m)}:host .properties-form{display:flex;flex-direction:column;gap:var(--ymt-spacing-m)}:host .properties-form .yuv-form-field{width:100%}:host .properties-form .copy-button{border:none;padding:0;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;width:var(--ymt-sizing-xl);height:var(--ymt-sizing-xl);border-radius:var(--ymt-corner-full);color:var(--ymt-text-color-subtle)}:host .properties-form .copy-button:hover{background-color:var(--ymt-hover-background)}:host .properties-form .copy-button mat-icon{font-size:var(--ymt-sizing-m);width:var(--ymt-sizing-m);height:var(--ymt-sizing-m)}:host .role-badge{cursor:help}:host .group-badge-button{border:none;padding:0;background:transparent;cursor:pointer;display:inline-flex;font:inherit;border-radius:var(--ymt-badge-corner, var(--ymt-corner-xs));min-width:0;max-width:100%}:host .group-badge-button:hover ymt-badge{background-color:var(--ymt-hover-background)}:host .details-empty-state{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--ymt-spacing-m);text-align:center;color:var(--ymt-text-color-subtle);font:var(--ymt-font-body-subtle)}:host .details-empty-state mat-icon{font-size:var(--ymt-sizing-xl);width:var(--ymt-sizing-xl);height:var(--ymt-sizing-xl)}\n"] }]
|
|
592
|
+
}], ctorParameters: () => [], propDecorators: { user: [{ type: i0.Input, args: [{ isSignal: true, alias: "user", required: false }] }], groupRoles: [{ type: i0.Input, args: [{ isSignal: true, alias: "groupRoles", required: false }] }], topBarActions: [{ type: i0.Input, args: [{ isSignal: true, alias: "topBarActions", required: false }] }], editUser: [{ type: i0.Output, args: ["editUser"] }] } });
|
|
687
593
|
|
|
688
594
|
class RolesSelectionComponent {
|
|
689
595
|
#localization = inject(LocalizationService);
|
|
690
|
-
/** Orders roles by their localized label so the lists read naturally in the active language.
|
|
691
|
-
|
|
596
|
+
/** Orders roles by their localized label so the lists read naturally in the active language.
|
|
597
|
+
* Decorate-sort-undecorate: `resolveRoleLabel` is a `LocalizationService` lookup, and a plain
|
|
598
|
+
* comparator would re-resolve both operands on every one of the O(n log n) comparisons. */
|
|
599
|
+
#sortByLabel = (roles) => roles
|
|
600
|
+
.map((role) => [resolveRoleLabel(this.#localization, role), role])
|
|
601
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
602
|
+
.map(([, role]) => role);
|
|
692
603
|
allRoles = input.required(...(ngDevMode ? [{ debugName: "allRoles" }] : /* istanbul ignore next */ []));
|
|
693
604
|
initialRoles = input.required(...(ngDevMode ? [{ debugName: "initialRoles" }] : /* istanbul ignore next */ []));
|
|
694
605
|
rolesChange = output();
|
|
@@ -698,12 +609,17 @@ class RolesSelectionComponent {
|
|
|
698
609
|
selectedAssigned = signal([], ...(ngDevMode ? [{ debugName: "selectedAssigned" }] : /* istanbul ignore next */ []));
|
|
699
610
|
canAssign = computed(() => this.selectedAvailable().length > 0, ...(ngDevMode ? [{ debugName: "canAssign" }] : /* istanbul ignore next */ []));
|
|
700
611
|
canRemove = computed(() => this.selectedAssigned().length > 0, ...(ngDevMode ? [{ debugName: "canRemove" }] : /* istanbul ignore next */ []));
|
|
612
|
+
/** Selection lookups for the two option lists. Both are read once per rendered option on every
|
|
613
|
+
* change-detection pass, so they're memoized `Set`s rather than an `Array#includes` scan each. */
|
|
614
|
+
#selectedAvailableSet = computed(() => new Set(this.selectedAvailable()), ...(ngDevMode ? [{ debugName: "#selectedAvailableSet" }] : /* istanbul ignore next */ []));
|
|
615
|
+
#selectedAssignedSet = computed(() => new Set(this.selectedAssigned()), ...(ngDevMode ? [{ debugName: "#selectedAssignedSet" }] : /* istanbul ignore next */ []));
|
|
701
616
|
constructor() {
|
|
702
617
|
effect(() => {
|
|
703
618
|
const all = this.allRoles();
|
|
704
619
|
const initial = this.initialRoles();
|
|
705
|
-
|
|
706
|
-
this.
|
|
620
|
+
const initialSet = new Set(initial);
|
|
621
|
+
this.assignedRoles.set(this.#sortByLabel(initial));
|
|
622
|
+
this.availableRoles.set(this.#sortByLabel(all.filter((role) => !initialSet.has(role))));
|
|
707
623
|
this.selectedAvailable.set([]);
|
|
708
624
|
this.selectedAssigned.set([]);
|
|
709
625
|
});
|
|
@@ -715,12 +631,27 @@ class RolesSelectionComponent {
|
|
|
715
631
|
onAssignedSelectedChange(role, selected) {
|
|
716
632
|
this.selectedAssigned.update((current) => selected ? [...current, role] : current.filter((r) => r !== role));
|
|
717
633
|
}
|
|
634
|
+
/** Moves a single role from available to assigned, bypassing the selection/assign-button flow (e.g. on double-click). */
|
|
635
|
+
assignRole(role) {
|
|
636
|
+
this.availableRoles.update((roles) => roles.filter((r) => r !== role));
|
|
637
|
+
this.assignedRoles.update((roles) => this.#sortByLabel([...roles, role]));
|
|
638
|
+
this.selectedAvailable.update((current) => current.filter((r) => r !== role));
|
|
639
|
+
this.rolesChange.emit(this.assignedRoles());
|
|
640
|
+
}
|
|
641
|
+
/** Moves a single role from assigned to available, bypassing the selection/remove-button flow (e.g. on double-click). */
|
|
642
|
+
removeRole(role) {
|
|
643
|
+
this.assignedRoles.update((roles) => roles.filter((r) => r !== role));
|
|
644
|
+
this.availableRoles.update((roles) => this.#sortByLabel([...roles, role]));
|
|
645
|
+
this.selectedAssigned.update((current) => current.filter((r) => r !== role));
|
|
646
|
+
this.rolesChange.emit(this.assignedRoles());
|
|
647
|
+
}
|
|
718
648
|
assignRoles() {
|
|
719
649
|
const toAssign = this.selectedAvailable();
|
|
720
650
|
if (toAssign.length === 0)
|
|
721
651
|
return;
|
|
722
|
-
|
|
723
|
-
this.
|
|
652
|
+
const toAssignSet = new Set(toAssign);
|
|
653
|
+
this.availableRoles.update((roles) => roles.filter((r) => !toAssignSet.has(r)));
|
|
654
|
+
this.assignedRoles.update((roles) => this.#sortByLabel([...roles, ...toAssign]));
|
|
724
655
|
this.selectedAvailable.set([]);
|
|
725
656
|
this.rolesChange.emit(this.assignedRoles());
|
|
726
657
|
}
|
|
@@ -728,8 +659,9 @@ class RolesSelectionComponent {
|
|
|
728
659
|
const toRemove = this.selectedAssigned();
|
|
729
660
|
if (toRemove.length === 0)
|
|
730
661
|
return;
|
|
731
|
-
|
|
732
|
-
this.
|
|
662
|
+
const toRemoveSet = new Set(toRemove);
|
|
663
|
+
this.assignedRoles.update((roles) => roles.filter((r) => !toRemoveSet.has(r)));
|
|
664
|
+
this.availableRoles.update((roles) => this.#sortByLabel([...roles, ...toRemove]));
|
|
733
665
|
this.selectedAssigned.set([]);
|
|
734
666
|
this.rolesChange.emit(this.assignedRoles());
|
|
735
667
|
}
|
|
@@ -737,7 +669,7 @@ class RolesSelectionComponent {
|
|
|
737
669
|
const allAvailable = this.availableRoles();
|
|
738
670
|
if (allAvailable.length === 0)
|
|
739
671
|
return;
|
|
740
|
-
this.assignedRoles.update((roles) => [...roles, ...allAvailable]
|
|
672
|
+
this.assignedRoles.update((roles) => this.#sortByLabel([...roles, ...allAvailable]));
|
|
741
673
|
this.availableRoles.set([]);
|
|
742
674
|
this.selectedAvailable.set([]);
|
|
743
675
|
this.rolesChange.emit(this.assignedRoles());
|
|
@@ -746,21 +678,21 @@ class RolesSelectionComponent {
|
|
|
746
678
|
const allAssigned = this.assignedRoles();
|
|
747
679
|
if (allAssigned.length === 0)
|
|
748
680
|
return;
|
|
749
|
-
this.availableRoles.update((roles) => [...roles, ...allAssigned]
|
|
681
|
+
this.availableRoles.update((roles) => this.#sortByLabel([...roles, ...allAssigned]));
|
|
750
682
|
this.assignedRoles.set([]);
|
|
751
683
|
this.selectedAssigned.set([]);
|
|
752
684
|
this.rolesChange.emit(this.assignedRoles());
|
|
753
685
|
}
|
|
754
686
|
isAvailableSelected(role) {
|
|
755
|
-
return this
|
|
687
|
+
return this.#selectedAvailableSet().has(role);
|
|
756
688
|
}
|
|
757
689
|
isAssignedSelected(role) {
|
|
758
|
-
return this
|
|
690
|
+
return this.#selectedAssignedSet().has(role);
|
|
759
691
|
}
|
|
760
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
761
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.
|
|
692
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: RolesSelectionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
693
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: RolesSelectionComponent, isStandalone: true, selector: "ymusrmgmt-role-selection", inputs: { allRoles: { classPropertyName: "allRoles", publicName: "allRoles", isSignal: true, isRequired: true, transformFunction: null }, initialRoles: { classPropertyName: "initialRoles", publicName: "initialRoles", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { rolesChange: "rolesChange" }, ngImport: i0, template: "<div class=\"role-selector\">\n <!-- Available Roles (Left) -->\n <div class=\"role-list-container\">\n <div class=\"role-list-header\">\n {{ 'yuv.app.user-management.common.label.roles.available' | translate }}\n </div>\n <mat-selection-list\n class=\"role-list\"\n #availableList\n hideSingleSelectionIndicator=\"true\"\n [attr.aria-label]=\"'yuv.app.user-management.common.label.roles.available' | translate\"\n >\n @for (role of availableRoles(); track role) {\n <mat-list-option\n [value]=\"role\"\n [matTooltip]=\"role | roleTooltip\"\n [selected]=\"isAvailableSelected(role)\"\n (selectedChange)=\"onAvailableSelectedChange(role, $event)\"\n (dblclick)=\"assignRole(role)\"\n (keydown.enter)=\"$event.stopPropagation(); assignRole(role)\"\n >\n {{ role | roleLabel }}\n </mat-list-option>\n } @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.common.label.roles.noAvailable' | translate }}\n </div>\n }\n </mat-selection-list>\n </div>\n\n <!-- Control Buttons (Center) -->\n <div class=\"control-buttons\">\n <button\n mat-icon-button\n [disabled]=\"availableRoles().length === 0\"\n (click)=\"assignAllRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.assignAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.assignAll' | translate\"\n >\n <mat-icon>keyboard_double_arrow_right</mat-icon>\n </button>\n <button\n mat-icon-button\n [disabled]=\"!canAssign()\"\n (click)=\"assignRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.assign' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.assign' | translate\"\n >\n <mat-icon>chevron_right</mat-icon>\n </button>\n <button\n mat-icon-button\n [disabled]=\"!canRemove()\"\n (click)=\"removeRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.remove' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.remove' | translate\"\n >\n <mat-icon>chevron_left</mat-icon>\n </button>\n <button\n mat-icon-button\n [disabled]=\"assignedRoles().length === 0\"\n (click)=\"removeAllRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.removeAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.removeAll' | translate\"\n >\n <mat-icon>keyboard_double_arrow_left</mat-icon>\n </button>\n </div>\n\n <!-- Assigned Roles (Right) -->\n <div class=\"role-list-container\">\n <div class=\"role-list-header\">\n {{ 'yuv.app.user-management.common.label.roles.assigned' | translate }}\n </div>\n <mat-selection-list\n class=\"role-list\"\n #assignedList\n hideSingleSelectionIndicator=\"true\"\n [attr.aria-label]=\"'yuv.app.user-management.common.label.roles.assigned' | translate\"\n >\n @for (role of assignedRoles(); track role) {\n <mat-list-option\n [value]=\"role\"\n [matTooltip]=\"role | roleTooltip\"\n [selected]=\"isAssignedSelected(role)\"\n (selectedChange)=\"onAssignedSelectedChange(role, $event)\"\n (dblclick)=\"removeRole(role)\"\n (keydown.enter)=\"$event.stopPropagation(); removeRole(role)\"\n >\n {{ role | roleLabel }}\n </mat-list-option>\n } @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.common.label.roles.noAssigned' | translate }}\n </div>\n }\n </mat-selection-list>\n </div>\n</div>\n", styles: [".role-selector{display:flex;gap:var(--ymt-spacing-m);align-items:stretch;min-height:300px;padding:var(--ymt-spacing-xs) 0}.role-list-container{flex:1;display:flex;flex-direction:column;border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-xs);overflow:hidden;background-color:var(--ymt-surface-container-lowest);max-height:300px}.role-list-header{padding:var(--ymt-spacing-s) var(--ymt-spacing-m);background-color:var(--ymt-surface-container-high);color:var(--ymt-on-surface);font:var(--ymt-font-title-smallest);border-bottom:var(--ymt-outline-width) solid var(--ymt-outline-variant)}.role-list{flex:1;overflow-y:auto;min-height:0}.role-list ::ng-deep .mat-mdc-list-option{font:var(--ymt-font-body-subtle);height:var(--ymt-sizing-l)}.role-list ::ng-deep .mdc-list-item__primary-text{font:var(--ymt-font-body-subtle);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.role-list ::ng-deep .mdc-checkbox{display:none}.role-list ::ng-deep .mat-mdc-list-option[aria-selected=true]{background-color:var(--ymt-selection-background)!important}.role-list ::ng-deep .mat-mdc-list-option[aria-selected=true] .mdc-list-item__primary-text{color:var(--ymt-on-selection-background);font-weight:500}.role-list ::ng-deep .mat-mdc-list-option:hover:not([aria-selected=true]){background-color:var(--ymt-hover-background)}.control-buttons{display:flex;flex-direction:column;justify-content:center;gap:var(--ymt-spacing-xs)}.empty-message{padding:var(--ymt-spacing-m);text-align:center;font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatListModule }, { kind: "component", type: i3$1.MatSelectionList, selector: "mat-selection-list", inputs: ["color", "compareWith", "multiple", "hideSingleSelectionIndicator", "disabled"], outputs: ["selectionChange"], exportAs: ["matSelectionList"] }, { kind: "component", type: i3$1.MatListOption, selector: "mat-list-option", inputs: ["togglePosition", "checkboxPosition", "color", "value", "selected"], outputs: ["selectedChange"], exportAs: ["matListOption"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }, { kind: "pipe", type: RoleLabelPipe, name: "roleLabel" }, { kind: "pipe", type: RoleTooltipPipe, name: "roleTooltip" }] });
|
|
762
694
|
}
|
|
763
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
695
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: RolesSelectionComponent, decorators: [{
|
|
764
696
|
type: Component,
|
|
765
697
|
args: [{ selector: 'ymusrmgmt-role-selection', imports: [
|
|
766
698
|
CommonModule,
|
|
@@ -771,19 +703,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
|
|
|
771
703
|
TranslatePipe,
|
|
772
704
|
RoleLabelPipe,
|
|
773
705
|
RoleTooltipPipe,
|
|
774
|
-
], standalone: true, template: "<div class=\"role-selector\">\n <!-- Available Roles (Left) -->\n <div class=\"role-list-container\">\n <div class=\"role-list-header\">\n {{ 'yuv.app.user-management.
|
|
706
|
+
], standalone: true, template: "<div class=\"role-selector\">\n <!-- Available Roles (Left) -->\n <div class=\"role-list-container\">\n <div class=\"role-list-header\">\n {{ 'yuv.app.user-management.common.label.roles.available' | translate }}\n </div>\n <mat-selection-list\n class=\"role-list\"\n #availableList\n hideSingleSelectionIndicator=\"true\"\n [attr.aria-label]=\"'yuv.app.user-management.common.label.roles.available' | translate\"\n >\n @for (role of availableRoles(); track role) {\n <mat-list-option\n [value]=\"role\"\n [matTooltip]=\"role | roleTooltip\"\n [selected]=\"isAvailableSelected(role)\"\n (selectedChange)=\"onAvailableSelectedChange(role, $event)\"\n (dblclick)=\"assignRole(role)\"\n (keydown.enter)=\"$event.stopPropagation(); assignRole(role)\"\n >\n {{ role | roleLabel }}\n </mat-list-option>\n } @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.common.label.roles.noAvailable' | translate }}\n </div>\n }\n </mat-selection-list>\n </div>\n\n <!-- Control Buttons (Center) -->\n <div class=\"control-buttons\">\n <button\n mat-icon-button\n [disabled]=\"availableRoles().length === 0\"\n (click)=\"assignAllRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.assignAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.assignAll' | translate\"\n >\n <mat-icon>keyboard_double_arrow_right</mat-icon>\n </button>\n <button\n mat-icon-button\n [disabled]=\"!canAssign()\"\n (click)=\"assignRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.assign' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.assign' | translate\"\n >\n <mat-icon>chevron_right</mat-icon>\n </button>\n <button\n mat-icon-button\n [disabled]=\"!canRemove()\"\n (click)=\"removeRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.remove' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.remove' | translate\"\n >\n <mat-icon>chevron_left</mat-icon>\n </button>\n <button\n mat-icon-button\n [disabled]=\"assignedRoles().length === 0\"\n (click)=\"removeAllRoles()\"\n [matTooltip]=\"'yuv.app.user-management.common.action.roles.removeAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.roles.removeAll' | translate\"\n >\n <mat-icon>keyboard_double_arrow_left</mat-icon>\n </button>\n </div>\n\n <!-- Assigned Roles (Right) -->\n <div class=\"role-list-container\">\n <div class=\"role-list-header\">\n {{ 'yuv.app.user-management.common.label.roles.assigned' | translate }}\n </div>\n <mat-selection-list\n class=\"role-list\"\n #assignedList\n hideSingleSelectionIndicator=\"true\"\n [attr.aria-label]=\"'yuv.app.user-management.common.label.roles.assigned' | translate\"\n >\n @for (role of assignedRoles(); track role) {\n <mat-list-option\n [value]=\"role\"\n [matTooltip]=\"role | roleTooltip\"\n [selected]=\"isAssignedSelected(role)\"\n (selectedChange)=\"onAssignedSelectedChange(role, $event)\"\n (dblclick)=\"removeRole(role)\"\n (keydown.enter)=\"$event.stopPropagation(); removeRole(role)\"\n >\n {{ role | roleLabel }}\n </mat-list-option>\n } @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.common.label.roles.noAssigned' | translate }}\n </div>\n }\n </mat-selection-list>\n </div>\n</div>\n", styles: [".role-selector{display:flex;gap:var(--ymt-spacing-m);align-items:stretch;min-height:300px;padding:var(--ymt-spacing-xs) 0}.role-list-container{flex:1;display:flex;flex-direction:column;border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-xs);overflow:hidden;background-color:var(--ymt-surface-container-lowest);max-height:300px}.role-list-header{padding:var(--ymt-spacing-s) var(--ymt-spacing-m);background-color:var(--ymt-surface-container-high);color:var(--ymt-on-surface);font:var(--ymt-font-title-smallest);border-bottom:var(--ymt-outline-width) solid var(--ymt-outline-variant)}.role-list{flex:1;overflow-y:auto;min-height:0}.role-list ::ng-deep .mat-mdc-list-option{font:var(--ymt-font-body-subtle);height:var(--ymt-sizing-l)}.role-list ::ng-deep .mdc-list-item__primary-text{font:var(--ymt-font-body-subtle);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.role-list ::ng-deep .mdc-checkbox{display:none}.role-list ::ng-deep .mat-mdc-list-option[aria-selected=true]{background-color:var(--ymt-selection-background)!important}.role-list ::ng-deep .mat-mdc-list-option[aria-selected=true] .mdc-list-item__primary-text{color:var(--ymt-on-selection-background);font-weight:500}.role-list ::ng-deep .mat-mdc-list-option:hover:not([aria-selected=true]){background-color:var(--ymt-hover-background)}.control-buttons{display:flex;flex-direction:column;justify-content:center;gap:var(--ymt-spacing-xs)}.empty-message{padding:var(--ymt-spacing-m);text-align:center;font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}\n"] }]
|
|
775
707
|
}], ctorParameters: () => [], propDecorators: { allRoles: [{ type: i0.Input, args: [{ isSignal: true, alias: "allRoles", required: true }] }], initialRoles: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialRoles", required: true }] }], rolesChange: [{ type: i0.Output, args: ["rolesChange"] }] } });
|
|
776
708
|
|
|
777
|
-
const components$
|
|
778
|
-
|
|
709
|
+
const components$5 = [
|
|
710
|
+
BusyOverlayDirective,
|
|
711
|
+
YmtButtonDirective,
|
|
712
|
+
DialogComponent,
|
|
713
|
+
RolesSelectionComponent,
|
|
714
|
+
];
|
|
715
|
+
const modules$5 = [
|
|
779
716
|
CommonModule,
|
|
780
717
|
ReactiveFormsModule,
|
|
781
718
|
TranslatePipe,
|
|
782
719
|
MatFormFieldModule,
|
|
783
720
|
MatInputModule,
|
|
784
721
|
];
|
|
785
|
-
class
|
|
722
|
+
class EditGroupComponent {
|
|
786
723
|
data;
|
|
724
|
+
/** Shared `MatDialog.open()` config, so every call site opening this dialog stays in sync. */
|
|
725
|
+
static DIALOG_CONFIG = {
|
|
726
|
+
width: '600px',
|
|
727
|
+
maxWidth: '90vw',
|
|
728
|
+
};
|
|
787
729
|
#fb = inject(FormBuilder);
|
|
788
730
|
#userManagementService = inject(YuvUserManagementService);
|
|
789
731
|
#dialogRef = inject((MatDialogRef));
|
|
@@ -792,20 +734,14 @@ class EditUserComponent {
|
|
|
792
734
|
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
793
735
|
error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
794
736
|
allAvailableRoles = signal([], ...(ngDevMode ? [{ debugName: "allAvailableRoles" }] : /* istanbul ignore next */ []));
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
username = new FormControl('');
|
|
799
|
-
email = new FormControl('', [Validators.required, Validators.email]);
|
|
800
|
-
firstName = new FormControl('', [Validators.minLength(1), Validators.maxLength(255), Validators.pattern("^[^#!<>&/%=;*§$()[\\]{}]*$")]);
|
|
801
|
-
lastName = new FormControl('', [Validators.minLength(1), Validators.maxLength(255), Validators.pattern("^[^#!<>&/%=;*§$()[\\]{}]*$")]);
|
|
737
|
+
groupRoles = signal([], ...(ngDevMode ? [{ debugName: "groupRoles" }] : /* istanbul ignore next */ []));
|
|
738
|
+
name = new FormControl('');
|
|
739
|
+
description = new FormControl('');
|
|
802
740
|
roles = new FormControl([]);
|
|
803
741
|
form = this.#fb.group({
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
lastName: this.lastName,
|
|
808
|
-
roles: this.roles
|
|
742
|
+
name: this.name,
|
|
743
|
+
description: this.description,
|
|
744
|
+
roles: this.roles,
|
|
809
745
|
});
|
|
810
746
|
changedInput = signal(undefined, ...(ngDevMode ? [{ debugName: "changedInput" }] : /* istanbul ignore next */ []));
|
|
811
747
|
disabledSubmit = linkedSignal({ ...(ngDevMode ? { debugName: "disabledSubmit" } : /* istanbul ignore next */ {}), source: this.changedInput,
|
|
@@ -815,27 +751,19 @@ class EditUserComponent {
|
|
|
815
751
|
constructor(data) {
|
|
816
752
|
this.data = data;
|
|
817
753
|
this.form.patchValue({
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
email: this.data.user.email,
|
|
822
|
-
roles: this.data.user.roles
|
|
754
|
+
name: this.data.group.name,
|
|
755
|
+
description: this.data.group.description,
|
|
756
|
+
roles: this.data.group.roles,
|
|
823
757
|
});
|
|
824
|
-
|
|
825
|
-
this.userRoles.set(this.data.user.roles || []);
|
|
826
|
-
// Externally managed users may only have roles assigned: their personal data fields are rendered
|
|
827
|
-
// read-only in the template (same mechanism as the username field) so they keep the identical look.
|
|
828
|
-
if (this.data.user.externallyManaged) {
|
|
829
|
-
this.externallyManaged.set(true);
|
|
830
|
-
}
|
|
758
|
+
this.groupRoles.set(this.data.group.roles || []);
|
|
831
759
|
// Fetch all available roles
|
|
832
760
|
this.#userManagementService.fetchAllRoles().subscribe({
|
|
833
761
|
next: (roles) => {
|
|
834
|
-
this.allAvailableRoles.set(roles);
|
|
762
|
+
this.allAvailableRoles.set(roles.sort());
|
|
835
763
|
},
|
|
836
764
|
error: (e) => {
|
|
837
765
|
this.#logger.error('Failed to fetch roles', e);
|
|
838
|
-
}
|
|
766
|
+
},
|
|
839
767
|
});
|
|
840
768
|
}
|
|
841
769
|
ngAfterViewInit() {
|
|
@@ -848,348 +776,522 @@ class EditUserComponent {
|
|
|
848
776
|
return !isDataUnchanged;
|
|
849
777
|
}
|
|
850
778
|
isEquals(key) {
|
|
851
|
-
const oldVal = this.data.
|
|
779
|
+
const oldVal = this.data.group?.[key];
|
|
852
780
|
const newVal = this.form.get(key)?.value;
|
|
853
781
|
return oldVal === newVal;
|
|
854
782
|
}
|
|
855
783
|
onRolesChange(roles) {
|
|
856
|
-
this.
|
|
784
|
+
this.groupRoles.set(roles);
|
|
857
785
|
this.form.patchValue({ roles: roles });
|
|
858
786
|
this.roles.markAsTouched(); // Mark as touched to trigger validation display
|
|
859
787
|
}
|
|
860
|
-
|
|
861
|
-
// For externally managed users the personal data fields are read-only, so their original values
|
|
862
|
-
// are preserved here (the backend replaces the representation from the request, so omitting them
|
|
863
|
-
// would wipe them).
|
|
788
|
+
updateGroup() {
|
|
864
789
|
const editParams = this.form.getRawValue();
|
|
865
790
|
this.busy.set(true);
|
|
866
791
|
this.error.set(undefined);
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
roles: editParams.roles
|
|
792
|
+
const updateGroupRequest = {
|
|
793
|
+
name: editParams.name,
|
|
794
|
+
description: editParams.description,
|
|
795
|
+
roles: editParams.roles,
|
|
872
796
|
};
|
|
873
|
-
this.#userManagementService
|
|
874
|
-
.updateUser(this.data.user.id, updateUserRequest)
|
|
875
|
-
.subscribe({
|
|
797
|
+
this.#userManagementService.updateGroup(this.data.group.id, updateGroupRequest).subscribe({
|
|
876
798
|
error: (e) => {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
break;
|
|
881
|
-
}
|
|
882
|
-
default:
|
|
883
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.users.action.edit.error.unknown"));
|
|
884
|
-
}
|
|
885
|
-
this.#logger.error(`Failed to update user with id ${this.data.user.id}`, e);
|
|
799
|
+
const errorKey = resolveErrorMessageKey('yuv.app.user-management.groups.action.edit', e.status);
|
|
800
|
+
this.error.set(this.#translateService.instant(errorKey));
|
|
801
|
+
this.#logger.error(`Failed to update group with id ${this.data.group.id}`, e);
|
|
886
802
|
this.busy.set(false);
|
|
887
803
|
},
|
|
888
804
|
complete: () => {
|
|
889
805
|
this.busy.set(false);
|
|
890
|
-
|
|
806
|
+
// Returns the updated group (not just its id) so the caller can reconcile its own state
|
|
807
|
+
// without a redundant `getGroupDetails` round trip for what was just written - mirrors
|
|
808
|
+
// `CreateGroupComponent`. Everything the backend owns and this form doesn't touch (e.g.
|
|
809
|
+
// `members`) carries over from the details this dialog was opened with.
|
|
810
|
+
this.#dialogRef.close({ ...this.data.group, ...updateGroupRequest });
|
|
891
811
|
},
|
|
892
812
|
});
|
|
893
813
|
}
|
|
894
814
|
close() {
|
|
895
815
|
this.#dialogRef.close();
|
|
896
816
|
}
|
|
897
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
898
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.
|
|
817
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: EditGroupComponent, deps: [{ token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component });
|
|
818
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: EditGroupComponent, isStandalone: true, selector: "ymusrmgmt-edit-group", ngImport: i0, template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"\n 'yuv.app.user-management.groups.action.edit.dialog.title'\n | translate : { name: data.group.name }\n \"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field readonly-field\">\n <mat-label class=\"readonly-label\">{{\n 'yuv.app.user-management.groups.property.name' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"name\"\n type=\"text\"\n [attr.tabindex]=\"-1\"\n readonly\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.name' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.groups.property.description' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"description\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.description' | translate\"\n />\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"groupRoles()\"\n (rolesChange)=\"onRolesChange($event)\"\n >\n </ymusrmgmt-role-selection>\n </div>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.users.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"updateGroup()\">\n {{ 'yuv.app.user-management.users.action.button.save' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}:host main .readonly-field{opacity:.5;pointer-events:none}:host main .readonly-label{pointer-events:none}:host main .roles-section{margin:var(--ymt-spacing-m) 0}:host main .roles-section.has-error ymusrmgmt-role-selection{border:var(--ymt-focus-indicator-size) solid var(--ymt-danger);border-radius:var(--ymt-corner-xs);display:block}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "component", type: RolesSelectionComponent, selector: "ymusrmgmt-role-selection", inputs: ["allRoles", "initialRoles"], outputs: ["rolesChange"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
899
819
|
}
|
|
900
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
820
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: EditGroupComponent, decorators: [{
|
|
901
821
|
type: Component,
|
|
902
|
-
args: [{ selector: 'ymusrmgmt-edit-
|
|
903
|
-
'[class.busy]': '!busy()',
|
|
904
|
-
}, template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.users.action.edit.label' | translate\">\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n @if (externallyManaged()) {\n <p class=\"externally-managed-hint\">{{ \"yuv.app.user-management.users.action.edit.identitySource.external.hint\" | translate }}</p>\n }\n <mat-form-field class=\"yuv-form-field readonly-field\">\n <mat-label class=\"readonly-label\">{{\"yuv.app.user-management.users.object.property.username\" | translate }}</mat-label>\n <input matInput [formControl]=\"username\" type=\"text\" [attr.tabindex]=\"-1\" readonly/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\"yuv.app.user-management.users.object.property.email\" | translate}}</mat-label>\n <input matInput [formControl]=\"email\" type=\"text\" #emailField [readonly]=\"externallyManaged()\" [attr.tabindex]=\"externallyManaged() ? -1 : null\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\"yuv.app.user-management.users.object.property.firstname\" | translate}}</mat-label>\n <input matInput [formControl]=\"firstName\" type=\"text\" [readonly]=\"externallyManaged()\" [attr.tabindex]=\"externallyManaged() ? -1 : null\"/>\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\"yuv.app.user-management.users.object.property.lastname\" | translate}}</mat-label>\n <input matInput [formControl]=\"lastName\" type=\"text\" [readonly]=\"externallyManaged()\" [attr.tabindex]=\"externallyManaged() ? -1 : null\"/>\n </mat-form-field>\n \n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"userRoles()\"\n (rolesChange)=\"onRolesChange($event)\">\n </ymusrmgmt-role-selection>\n </div>\n \n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </form>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ \"yuv.app.user-management.users.action.button.cancel\" | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"updateUser()\">\n {{ 'yuv.app.user-management.users.action.button.save' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main form{padding:var(--ymt-spacing-m)}:host main form .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main form .readonly-field{opacity:.5;pointer-events:none}:host main form .readonly-label{pointer-events:none}:host main form .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs)}:host main form .externally-managed-hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main form .roles-section{margin:var(--ymt-spacing-m) 0}:host main form .roles-section .roles-label{display:block;font-size:12px;font-weight:400;color:#0009;margin-bottom:var(--ymt-spacing-xs)}:host main form .roles-section.has-error .roles-label{color:var(--ymt-danger)}:host main form .roles-section.has-error ymusrmgmt-role-selection{border:2px solid var(--ymt-danger);border-radius:var(--ymt-corner-xs);display:block}:host main form .roles-section .roles-error{color:var(--ymt-danger);font-size:12px;margin-top:var(--ymt-spacing-xs);padding-left:var(--ymt-spacing-m)}\n"] }]
|
|
822
|
+
args: [{ selector: 'ymusrmgmt-edit-group', standalone: true, imports: [...modules$5, ...components$5], template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"\n 'yuv.app.user-management.groups.action.edit.dialog.title'\n | translate : { name: data.group.name }\n \"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field readonly-field\">\n <mat-label class=\"readonly-label\">{{\n 'yuv.app.user-management.groups.property.name' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"name\"\n type=\"text\"\n [attr.tabindex]=\"-1\"\n readonly\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.name' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.groups.property.description' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"description\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.description' | translate\"\n />\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"groupRoles()\"\n (rolesChange)=\"onRolesChange($event)\"\n >\n </ymusrmgmt-role-selection>\n </div>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.users.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"updateGroup()\">\n {{ 'yuv.app.user-management.users.action.button.save' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}:host main .readonly-field{opacity:.5;pointer-events:none}:host main .readonly-label{pointer-events:none}:host main .roles-section{margin:var(--ymt-spacing-m) 0}:host main .roles-section.has-error ymusrmgmt-role-selection{border:var(--ymt-focus-indicator-size) solid var(--ymt-danger);border-radius:var(--ymt-corner-xs);display:block}\n"] }]
|
|
905
823
|
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
906
824
|
type: Inject,
|
|
907
825
|
args: [MAT_DIALOG_DATA]
|
|
908
826
|
}] }] });
|
|
909
827
|
|
|
910
|
-
|
|
911
|
-
|
|
828
|
+
/**
|
|
829
|
+
* Marks an element as one keyboard-navigable row, so CDK's `FocusKeyManager` can drive Up/Down
|
|
830
|
+
* navigation over a `viewChildren()` query of them.
|
|
831
|
+
*
|
|
832
|
+
* Used by both keyboard-navigable lists on this page - the groups rail and the user table - which
|
|
833
|
+
* had grown separate copies of the same roving-tabindex plumbing (an index derived from the
|
|
834
|
+
* selection, a bounds-clamping `moveActive`, and a `viewChildren` query whose elements were
|
|
835
|
+
* focused by hand).
|
|
836
|
+
*
|
|
837
|
+
* `FocusKeyManager` rather than `ActiveDescendantKeyManager` (and rather than `yuv-list`, which
|
|
838
|
+
* builds on the latter): both lists put real DOM focus on the row itself, and each row also
|
|
839
|
+
* contains its own "more actions" button. `ActiveDescendantKeyManager` keeps focus on the list
|
|
840
|
+
* container instead, which is why `yuv-list` sets `tabindex="-1"` on every focusable element
|
|
841
|
+
* inside an item - reachable by mouse only. A roving tabindex over real focus keeps those buttons
|
|
842
|
+
* in the keyboard order.
|
|
843
|
+
*/
|
|
844
|
+
class FocusableRowDirective {
|
|
845
|
+
#elementRef = inject(ElementRef);
|
|
846
|
+
/**
|
|
847
|
+
* Called by the key manager, and directly on a row click: with a roving tabindex, a
|
|
848
|
+
* not-yet-active row's `tabindex` is still `-1` at the moment it's clicked, and Safari/older
|
|
849
|
+
* Firefox don't move focus to a `tabindex="-1"` element via a plain click (only script can), so
|
|
850
|
+
* without this a click would select a row but leave keyboard focus behind, breaking the next
|
|
851
|
+
* arrow press.
|
|
852
|
+
*/
|
|
853
|
+
focus() {
|
|
854
|
+
this.#elementRef.nativeElement.focus();
|
|
855
|
+
}
|
|
856
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: FocusableRowDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
857
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.23", type: FocusableRowDirective, isStandalone: true, selector: "[ymusrmgmt-focusable-row]", ngImport: i0 });
|
|
858
|
+
}
|
|
859
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: FocusableRowDirective, decorators: [{
|
|
860
|
+
type: Directive,
|
|
861
|
+
args: [{
|
|
862
|
+
selector: '[ymusrmgmt-focusable-row]',
|
|
863
|
+
standalone: true,
|
|
864
|
+
}]
|
|
865
|
+
}] });
|
|
866
|
+
|
|
867
|
+
class GroupsComponent {
|
|
912
868
|
#logger = inject(Logger);
|
|
913
869
|
#userManagementService = inject(YuvUserManagementService);
|
|
914
870
|
#translateService = inject(TranslateService);
|
|
915
|
-
#dialog = inject(MatDialog);
|
|
916
871
|
#notificationService = inject(NotificationService);
|
|
917
|
-
#
|
|
918
|
-
#
|
|
919
|
-
|
|
920
|
-
|
|
872
|
+
#dialog = inject(MatDialog);
|
|
873
|
+
#destroyRef = inject(DestroyRef);
|
|
874
|
+
#injector = inject(Injector);
|
|
875
|
+
selectedGroupId = input(undefined, ...(ngDevMode ? [{ debugName: "selectedGroupId" }] : /* istanbul ignore next */ []));
|
|
876
|
+
/** Forwarded to the internal `ymt-pane`'s top bar - see `YmtPaneComponent.topBarActions`. */
|
|
877
|
+
topBarActions;
|
|
878
|
+
groupSelected = new EventEmitter();
|
|
879
|
+
/** Emitted when the group currently scoping the page's user list was edited, so its name/roles can be picked up (e.g. for the subtitle, or the "inherited from current group" role highlight). */
|
|
880
|
+
groupUpdated = new EventEmitter();
|
|
881
|
+
/** Emitted when the group currently scoping the page's user list was removed, so the page can fall back to "All users". */
|
|
882
|
+
groupDeleted = new EventEmitter();
|
|
883
|
+
/** Emitted when a row's "add members" action was picked - the page owns that flow (dialog,
|
|
884
|
+
* notifications and, when the group is the one currently scoping the list, reloading it). */
|
|
885
|
+
addMembersRequested = new EventEmitter();
|
|
921
886
|
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
icon: APP_ICONS.actionActivateUser,
|
|
953
|
-
label: this.#translateService.instant('yuv.app.user-management.users.action.activate.label'),
|
|
954
|
-
disabled: selectedUser.id === currentUser.id || selectedUser.enabled,
|
|
955
|
-
callback: () => this.activateSelectedUser(),
|
|
956
|
-
};
|
|
957
|
-
const deactivateAction = {
|
|
958
|
-
id: 'yuv.app.users.action.deactivate',
|
|
959
|
-
icon: APP_ICONS.actionDeactivateUser,
|
|
960
|
-
label: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.label'),
|
|
961
|
-
disabled: selectedUser.id === currentUser.id || !selectedUser.enabled,
|
|
962
|
-
callback: () => this.deactivateSelectedUser(),
|
|
963
|
-
};
|
|
964
|
-
const deleteAction = {
|
|
965
|
-
id: 'yuv.app.users.action.delete',
|
|
966
|
-
icon: APP_ICONS.actionDeleteUser,
|
|
967
|
-
label: this.#translateService.instant('yuv.app.user-management.users.action.delete.label'),
|
|
968
|
-
disabled: selectedUser.id === currentUser.id,
|
|
969
|
-
callback: () => this.deleteSelectedUser(),
|
|
970
|
-
};
|
|
971
|
-
this.actions = [editAction, activateAction, deactivateAction, deleteAction];
|
|
972
|
-
this.detailsActive = true;
|
|
887
|
+
groups = signal([], ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
|
|
888
|
+
/** The rendered rows (index 0 = "All users", 1..n = `groups()`), in order - see
|
|
889
|
+
* `FocusableRowDirective`. */
|
|
890
|
+
rowItems = viewChildren(FocusableRowDirective, ...(ngDevMode ? [{ debugName: "rowItems" }] : /* istanbul ignore next */ []));
|
|
891
|
+
/** Drives Up/Down over the rows, moving real DOM focus with the roving tabindex. Shares its
|
|
892
|
+
* mechanism with the user table's own list - see `FocusableRowDirective`. */
|
|
893
|
+
#keyManager = new FocusKeyManager(this.rowItems, this.#injector);
|
|
894
|
+
constructor() {
|
|
895
|
+
// Selection follows keyboard focus, matching a native `<select>`'s Up/Down behavior rather
|
|
896
|
+
// than tracking a separate "highlighted but not yet selected" state.
|
|
897
|
+
this.#keyManager.change
|
|
898
|
+
.pipe(takeUntilDestroyed(this.#destroyRef))
|
|
899
|
+
.subscribe((index) => this.#selectAtIndex(index));
|
|
900
|
+
}
|
|
901
|
+
/** Roving-tabindex position in the combined "All users" (index 0) + groups (index i+1) list -
|
|
902
|
+
* the ARIA listbox/option pattern, where only the selected row is a real Tab stop and Up/Down
|
|
903
|
+
* move between rows instead. Without this, each row would need its own tabindex="0" alongside
|
|
904
|
+
* its "more actions" button - two independently-focusable interactive elements nested inside
|
|
905
|
+
* one another, which is what flagged this in the first place. Derived from `selectedGroupId`
|
|
906
|
+
* rather than tracked separately, so a selection made elsewhere (e.g. after creating a group)
|
|
907
|
+
* moves the roving position too. */
|
|
908
|
+
activeIndex = computed(() => {
|
|
909
|
+
const id = this.selectedGroupId();
|
|
910
|
+
if (!id)
|
|
911
|
+
return 0;
|
|
912
|
+
const i = this.groups().findIndex((g) => g.id === id);
|
|
913
|
+
return i === -1 ? 0 : i + 1;
|
|
914
|
+
}, ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
|
|
915
|
+
ngOnInit() {
|
|
916
|
+
this.refresh();
|
|
973
917
|
}
|
|
974
|
-
|
|
975
|
-
this
|
|
976
|
-
this.#
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
this.#logger.
|
|
982
|
-
this
|
|
983
|
-
|
|
984
|
-
|
|
918
|
+
refresh() {
|
|
919
|
+
this.busy.set(true);
|
|
920
|
+
this.#userManagementService.fetchAllGroups().subscribe({
|
|
921
|
+
next: (groups) => {
|
|
922
|
+
this.groups.set(groups);
|
|
923
|
+
},
|
|
924
|
+
error: (e) => {
|
|
925
|
+
this.#logger.error('Failed to fetch groups', e);
|
|
926
|
+
this.busy.set(false);
|
|
927
|
+
},
|
|
928
|
+
complete: () => {
|
|
929
|
+
this.busy.set(false);
|
|
930
|
+
},
|
|
985
931
|
});
|
|
986
932
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
}
|
|
1003
|
-
});
|
|
933
|
+
selectAllUsers() {
|
|
934
|
+
this.groupSelected.emit(undefined);
|
|
935
|
+
}
|
|
936
|
+
selectGroup(group) {
|
|
937
|
+
this.groupSelected.emit(group);
|
|
938
|
+
}
|
|
939
|
+
/** Arrow keys for the roving-tabindex rows - handed to the key manager, which moves focus; the
|
|
940
|
+
* `change` subscription above turns that into a selection. */
|
|
941
|
+
onListKeydown(event) {
|
|
942
|
+
// The selection also moves from outside this component (a deep link, or a group created
|
|
943
|
+
// elsewhere and selected straight away), so re-anchor the key manager on whatever is selected
|
|
944
|
+
// now - without emitting `change` or moving focus - before letting it handle the key.
|
|
945
|
+
const active = this.activeIndex();
|
|
946
|
+
if (active !== this.#keyManager.activeItemIndex) {
|
|
947
|
+
this.#keyManager.updateActiveItem(active);
|
|
1004
948
|
}
|
|
949
|
+
this.#keyManager.onKeydown(event);
|
|
1005
950
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
this.updateUserEnabledState(false);
|
|
1011
|
-
}
|
|
1012
|
-
updateUserEnabledState(enabled) {
|
|
1013
|
-
const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
|
|
1014
|
-
if (!selected)
|
|
1015
|
-
return;
|
|
1016
|
-
const action = enabled ? 'activate' : 'deactivate';
|
|
1017
|
-
const updateUserRequest = { enabled };
|
|
1018
|
-
const performUpdate = () => {
|
|
1019
|
-
this.busy.set(true);
|
|
1020
|
-
this.#userManagementService.updateUser(selected.id, updateUserRequest).subscribe({
|
|
1021
|
-
error: (e) => {
|
|
1022
|
-
this.#notificationService.error(this.#translateService.instant(`yuv.app.user-management.users.action.${action}.error`, { username: selected.username }));
|
|
1023
|
-
this.#logger.error(`Failed to ${action} user with id ${selected.id}`, e);
|
|
1024
|
-
this.busy.set(false);
|
|
1025
|
-
},
|
|
1026
|
-
complete: () => {
|
|
1027
|
-
this.#notificationService.success(this.#translateService.instant(`yuv.app.user-management.users.action.${action}.success`, { username: selected.username }));
|
|
1028
|
-
this.busy.set(false);
|
|
1029
|
-
this.refresh(true);
|
|
1030
|
-
},
|
|
1031
|
-
});
|
|
1032
|
-
};
|
|
1033
|
-
if (!enabled) {
|
|
1034
|
-
this.#confirmService
|
|
1035
|
-
.confirm({
|
|
1036
|
-
title: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.label'),
|
|
1037
|
-
message: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.confirm.message', { username: selected.username }),
|
|
1038
|
-
confirmLabel: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.confirm.label'),
|
|
1039
|
-
})
|
|
1040
|
-
.subscribe({
|
|
1041
|
-
next: (confirmed) => {
|
|
1042
|
-
if (confirmed) {
|
|
1043
|
-
performUpdate();
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
});
|
|
951
|
+
/** Row 0 is the pinned "All users" entry, 1..n are `groups()` - see `activeIndex`. */
|
|
952
|
+
#selectAtIndex(index) {
|
|
953
|
+
if (index === 0) {
|
|
954
|
+
this.selectAllUsers();
|
|
1047
955
|
}
|
|
1048
956
|
else {
|
|
1049
|
-
|
|
957
|
+
const group = this.groups()[index - 1];
|
|
958
|
+
if (group)
|
|
959
|
+
this.selectGroup(group);
|
|
1050
960
|
}
|
|
1051
961
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
962
|
+
getGroupRowActions(group) {
|
|
963
|
+
return [
|
|
964
|
+
{
|
|
965
|
+
id: 'yuv.app.groups.action.edit',
|
|
966
|
+
icon: APP_ICONS.actionEditGroup,
|
|
967
|
+
label: this.#translateService.instant('yuv.app.user-management.groups.action.edit.label'),
|
|
968
|
+
callback: () => this.editGroup(group),
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
id: 'yuv.app.groups.action.addMembers',
|
|
972
|
+
icon: APP_ICONS.actionAddGroupMembers,
|
|
973
|
+
label: this.#translateService.instant('yuv.app.user-management.groups.action.addMembers.label'),
|
|
974
|
+
callback: () => this.addMembersRequested.emit(group),
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
id: 'yuv.app.groups.action.delete',
|
|
978
|
+
icon: APP_ICONS.actionDeleteGroup,
|
|
979
|
+
label: this.#translateService.instant('yuv.app.user-management.groups.action.delete.label'),
|
|
980
|
+
callback: () => this.deleteGroup(group),
|
|
981
|
+
},
|
|
982
|
+
];
|
|
983
|
+
}
|
|
984
|
+
/** Edits any row's group - fetches its full details fresh rather than relying on already-loaded rail data. */
|
|
985
|
+
editGroup(group) {
|
|
986
|
+
this.#logger.debug(`Edit group with id '${group.id}'...`);
|
|
987
|
+
this.#userManagementService.getGroupDetails(group.id).subscribe({
|
|
988
|
+
next: (groupDetails) => {
|
|
989
|
+
this.#dialog
|
|
990
|
+
.open(EditGroupComponent, {
|
|
991
|
+
...EditGroupComponent.DIALOG_CONFIG,
|
|
992
|
+
data: { group: groupDetails },
|
|
993
|
+
})
|
|
994
|
+
.afterClosed()
|
|
995
|
+
.subscribe((editedGroup) => {
|
|
996
|
+
if (editedGroup) {
|
|
997
|
+
this.#logger.debug('Group edited with ID: ' + editedGroup.id);
|
|
998
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.edit.success', { name: group.name }));
|
|
999
|
+
this.refresh();
|
|
1000
|
+
this.groupUpdated.emit(editedGroup);
|
|
1062
1001
|
}
|
|
1063
|
-
}
|
|
1064
|
-
error: (e) => {
|
|
1065
|
-
this.#logger.error(`Failed to delete user with id '${selected.id}'`, e);
|
|
1066
|
-
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.users.action.delete.error', { username: selected.username }));
|
|
1067
|
-
}
|
|
1068
|
-
});
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
select(row) {
|
|
1072
|
-
if (this.selection().isSelected(row)) {
|
|
1073
|
-
this.deselect();
|
|
1074
|
-
}
|
|
1075
|
-
else {
|
|
1076
|
-
this.selection().select(row);
|
|
1077
|
-
this.#updateActions(row);
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
deselect() {
|
|
1081
|
-
this.selection().clear();
|
|
1082
|
-
this.#updateActions(undefined);
|
|
1083
|
-
}
|
|
1084
|
-
isSelected(row) {
|
|
1085
|
-
return this.selection().selected?.some(item => item.id === row.id);
|
|
1086
|
-
}
|
|
1087
|
-
refresh(refreshDetails = false) {
|
|
1088
|
-
this.#loadUserList(this.pagination?.page || 0);
|
|
1089
|
-
if (refreshDetails) {
|
|
1090
|
-
this.refreshUserDetails();
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
refreshUserDetails() {
|
|
1094
|
-
if (this.userDetailsComponent) {
|
|
1095
|
-
this.userDetailsComponent.refresh();
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
getPageSize() {
|
|
1099
|
-
return YuvUserManagementService.DEFAULT_PAGE_SIZE;
|
|
1100
|
-
}
|
|
1101
|
-
onChangePage($event) {
|
|
1102
|
-
const { pageIndex } = $event;
|
|
1103
|
-
this.#loadUserList(pageIndex);
|
|
1104
|
-
}
|
|
1105
|
-
onQueryChange(q) {
|
|
1106
|
-
this.#query.term = q.term || '';
|
|
1107
|
-
this.#loadUserList(0);
|
|
1108
|
-
}
|
|
1109
|
-
ngOnInit() {
|
|
1110
|
-
this.#loadUserList(0);
|
|
1111
|
-
}
|
|
1112
|
-
#loadUserList(page) {
|
|
1113
|
-
this.busy.set(true);
|
|
1114
|
-
this.#userManagementService.fetchUsers(page, this.#query.term).subscribe({
|
|
1115
|
-
next: (response) => {
|
|
1116
|
-
console.log(`fetched users: ${response}`);
|
|
1117
|
-
this.dataSource.set(response.objects);
|
|
1118
|
-
this.pagination = {
|
|
1119
|
-
total: response.totalNumItems,
|
|
1120
|
-
pages: Math.ceil(response.totalNumItems / YuvUserManagementService.DEFAULT_PAGE_SIZE),
|
|
1121
|
-
page: page
|
|
1122
|
-
};
|
|
1002
|
+
});
|
|
1123
1003
|
},
|
|
1124
1004
|
error: (e) => {
|
|
1125
|
-
this.#logger.error(
|
|
1126
|
-
this.busy.set(false);
|
|
1005
|
+
this.#logger.error(`Failed to load details for group with id '${group.id}'`, e);
|
|
1127
1006
|
},
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
if (
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
this.
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
deleteGroup(group) {
|
|
1010
|
+
this.#logger.debug(`Delete group with id '${group.id}'...`);
|
|
1011
|
+
this.#userManagementService.deleteGroup(group).subscribe({
|
|
1012
|
+
next: (result) => {
|
|
1013
|
+
if (result) {
|
|
1014
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.delete.success', {
|
|
1015
|
+
name: group.name,
|
|
1016
|
+
}));
|
|
1017
|
+
this.refresh();
|
|
1018
|
+
this.groupDeleted.emit(group.id);
|
|
1140
1019
|
}
|
|
1141
|
-
|
|
1020
|
+
},
|
|
1021
|
+
error: (e) => {
|
|
1022
|
+
this.#logger.error(`Failed to delete group with id '${group.id}'`, e);
|
|
1023
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.groups.action.delete.error', {
|
|
1024
|
+
name: group.name,
|
|
1025
|
+
}));
|
|
1142
1026
|
},
|
|
1143
1027
|
});
|
|
1144
1028
|
}
|
|
1145
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
1146
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: YuvUsersPageComponent, isStandalone: true, selector: "ymusrmgmt-users", host: { properties: { "class.busy": "!busy()", "class.pagination": "this.pagination" } }, viewQueries: [{ propertyName: "userDetailsComponent", first: true, predicate: UserDetailsComponent, descendants: true }], ngImport: i0, template: "<ng-template yuvAppHeaderSlot=\"search\">\n <yuv-simple-search (queryChange)=\"onQueryChange($event)\"></yuv-simple-search>\n</ng-template>\n\n<ymt-master-details-layout [yuvBusyOverlay]=\"busy()\" [(detailsActive)]=\"detailsActive\" [options]=\"layoutOptions\" [layoutSettingsID]=\"layoutSettingsID\">\n <ng-template ymtLayoutPane role=\"master\" [topBarActions]=\"masterPaneActions\" title=\"sss\">\n <ymt-pane-body>\n <mat-table [dataSource]=\"dataSource()\" class=\"user-table mat-elevation-z8\" row>\n <ng-container matColumnDef=\"state\">\n <mat-header-cell *matHeaderCellDef [attr.aria-label]=\"'yuv.app.user-management.users.object.property.state' | translate\" />\n <mat-cell *matCellDef=\"let element\">\n <mat-icon\n aria-hidden=\"false\"\n [attr.aria-label]=\"(element.enabled ? 'yuv.app.user-management.users.object.property.enabled' : 'yuv.app.user-management.users.object.property.disabled') | translate\">{{\n element.enabled ? icons.stateActive : icons.stateInactive }}</mat-icon>\n @if (element.externallyManaged) {\n <mat-icon class=\"external-source\"\n aria-hidden=\"false\"\n [matTooltip]=\"'yuv.app.user-management.users.identitySource.external.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.identitySource.external.tooltip' | translate\">{{\n icons.sourceExternal }}</mat-icon>\n }\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.users.object.property.username' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.username}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"firstName\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.users.object.property.firstname' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.firstName}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"lastName\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.users.object.property.lastname' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.lastName}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"email\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.users.object.property.email' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.email}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.users.object.property.id' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n <mat-header-row *matHeaderRowDef=\"displayedColumns\"></mat-header-row>\n <mat-row\n *matRowDef=\"let row; columns: displayedColumns;\"\n (click.single)=\"select(row)\"\n [attr.tabindex]=\"0\"\n (keydown.enter)=\"select(row)\"\n (keydown.space)=\"$event.preventDefault(); select(row)\"\n [class.selected-row]=\"this.isSelected(row)\" class=\"user-row\">\n </mat-row>\n </mat-table>\n </ymt-pane-body>\n </ng-template>\n <ng-template ymtLayoutPane role=\"details\">\n @let selectedUser = selection().selected.at(0);\n <ymt-pane-header [icon]=\"'person'\" [title]=\"'yuv.app.user-management.users.details.title' | translate\" [subtitle]=\"selectedUser?.id || ('yuv.app.user-management.users.details.empty' | translate)\" [actions]=\"listActions\" [badges]=\"selectedUser?.externallyManaged ? sourceBadge : undefined\"></ymt-pane-header>\n <ymt-pane-body>\n <ymusrmgmt-user-details [userId]=\"selectedUser?.id\" />\n </ymt-pane-body>\n </ng-template>\n</ymt-master-details-layout>\n\n<ng-template #masterPaneActions>\n <!--h2>{{('yuv.app.user-management.users.title' | translate)}}</h2-->\n <mat-paginator class=\"paginator\" [length]=\"pagination.total\" [pageIndex]=\"pagination.page\" [pageSize]=\"getPageSize()\" [attr.aria-label]=\"'yuv.app.user-management.users.navigation.pagination.aria' | translate\" (page)=\"onChangePage($event)\" hidePageSize></mat-paginator>\n <button ymt-icon-button (click)=\"refresh(true)\" [matTooltip]=\"'yuv.app.user-management.users.navigation.refresh.tooltip' | translate\" [attr.aria-label]=\"'yuv.app.user-management.users.navigation.refresh.tooltip' | translate\"><mat-icon>{{icons.refresh}}</mat-icon></button>\n <button ymtButton=\"primary\" (click)=\"createNewUser()\" [matTooltip]=\"'yuv.app.user-management.users.action.create.description' | translate\">\n <mat-icon>person_add</mat-icon>{{'yuv.app.user-management.users.action.create.label' | translate }}\n </button>\n</ng-template>\n\n<ng-template #listActions>\n <yuv-overflow-menu [menuItems]=\"actions\"></yuv-overflow-menu>\n</ng-template>\n\n<ng-template #sourceBadge>\n <ymt-badge severity=\"primary\" [aura]=\"true\" class=\"source-badge\"\n [matTooltip]=\"'yuv.app.user-management.users.identitySource.external.tooltip' | translate\">\n <mat-icon>{{ icons.sourceExternal }}</mat-icon>\n <span>{{ 'yuv.app.user-management.users.object.property.identitySource.external' | translate }}</span>\n </ymt-badge>\n</ng-template>", styles: [":host{--mat-table-row-item-outline-color: var(--ymt-outline-variant);height:100%;overflow:hidden;display:flex;flex-flow:column}:host ymt-master-details-layout{height:100%;padding:1rem}:host .paginator{flex:1;background:transparent}:host .user-table{overflow:auto;height:100%}:host .user-row:hover{background-color:var(--ymt-hover-background)}:host mat-cell mat-icon.external-source{color:var(--ymt-primary);margin-left:.25rem}:host .source-badge{display:inline-flex;align-items:center;gap:.25rem}:host .source-badge mat-icon{font-size:1rem;width:1rem;height:1rem}:host .selected-row{background-color:var(--ymt-selection-background)!important}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatPaginatorModule }, { kind: "component", type: i2$2.MatPaginator, selector: "mat-paginator", inputs: ["color", "pageIndex", "length", "pageSize", "pageSizeOptions", "hidePageSize", "showFirstLastButtons", "selectConfig", "disabled"], outputs: ["page"], exportAs: ["matPaginator"] }, { kind: "ngmodule", type: MatSortModule }, { kind: "ngmodule", type: MatTableModule }, { kind: "component", type: i3$4.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i3$4.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i3$4.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i3$4.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i3$4.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i3$4.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i3$4.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i3$4.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i3$4.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i3$4.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: RouterModule }, { kind: "component", type: SimpleSearchComponent, selector: "yuv-simple-search", inputs: ["query"], outputs: ["querySubmit", "clearInput", "targetSelectionChanged", "queryChange", "typeAggregation"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: ClickDoubleDirective, selector: "[click.single],[click.double]", inputs: ["debounceTime"], outputs: ["click.double", "click.single"] }, { kind: "component", type: UserDetailsComponent, selector: "ymusrmgmt-user-details", inputs: ["userId"] }, { kind: "component", type: YmtBadge, selector: "ymt-badge", inputs: ["severity", "outline", "aura"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "directive", type: YmtIconButtonDirective, selector: "button[ymtIconButton],button[ymt-icon-button],a[ymtIconButton],a[ymt-icon-button]", inputs: ["disabled", "disableRipple", "aria-disabled", "disabledInteractive", "icon-button-size"] }, { kind: "ngmodule", type: YmtLayoutModule }, { kind: "component", type: i5$1.MasterDetailsLayoutComponent, selector: "ymt-master-details-layout", inputs: ["layoutSettingsID", "options", "direction", "gutterSize", "detailsActive"], outputs: ["detailsActiveChange"] }, { kind: "directive", type: i5$1.YmtLayoutPaneDirective, selector: "[ymtLayoutPane]", inputs: ["role", "topBarActions"] }, { kind: "ngmodule", type: YmtPanesModule }, { kind: "component", type: i6$1.YmtPaneHeaderComponent, selector: "ymt-pane-header", inputs: ["title", "icon", "subtitle", "actions", "badges"] }, { kind: "component", type: i6$1.YmtPaneBodyComponent, selector: "ymt-pane-body" }, { kind: "ngmodule", type: YuvClientShellModule }, { kind: "directive", type: i6.YuvAppHeaderSlotDirective, selector: "ng-template[yuvAppHeaderSlot]", inputs: ["yuvAppHeaderSlot"] }, { kind: "ngmodule", type: YuvOverflowMenuModule }, { kind: "component", type: i8.OverflowMenuComponent, selector: "yuv-overflow-menu", inputs: ["overflowIcon", "groupLabels", "menuItems", "vertical"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1029
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: GroupsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1030
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: GroupsComponent, isStandalone: true, selector: "ymusrmgmt-groups", inputs: { selectedGroupId: { classPropertyName: "selectedGroupId", publicName: "selectedGroupId", isSignal: true, isRequired: false, transformFunction: null }, topBarActions: { classPropertyName: "topBarActions", publicName: "topBarActions", isSignal: false, isRequired: false, transformFunction: null } }, outputs: { groupSelected: "groupSelected", groupUpdated: "groupUpdated", groupDeleted: "groupDeleted", addMembersRequested: "addMembersRequested" }, viewQueries: [{ propertyName: "rowItems", predicate: FocusableRowDirective, descendants: true, isSignal: true }], ngImport: i0, template: "@let railTitle = 'yuv.app.user-management.label.rail.title' | translate;\n<ymt-pane\n [busy]=\"busy()\"\n [topBarActions]=\"topBarActions\"\n role=\"region\"\n [attr.aria-label]=\"railTitle\"\n>\n <ymt-pane-header\n [icon]=\"'groups'\"\n [title]=\"railTitle\"\n [subtitle]=\"\n 'yuv.app.user-management.label.rail.subtitle' | translate : { count: groups().length }\n \"\n ></ymt-pane-header>\n <ymt-pane-body>\n <div\n class=\"groups-list\"\n role=\"listbox\"\n tabindex=\"-1\"\n [attr.aria-label]=\"railTitle\"\n (keydown)=\"onListKeydown($event)\"\n >\n <div\n ymusrmgmt-focusable-row\n class=\"group-item\"\n role=\"option\"\n [attr.aria-selected]=\"!selectedGroupId()\"\n [class.active]=\"!selectedGroupId()\"\n [attr.tabindex]=\"activeIndex() === 0 ? 0 : -1\"\n (click)=\"selectAllUsers()\"\n (keydown.enter)=\"selectAllUsers()\"\n (keydown.space)=\"$event.preventDefault(); selectAllUsers()\"\n >\n <span class=\"group-icon\"><mat-icon>family_group</mat-icon></span>\n <span class=\"group-name\">{{\n 'yuv.app.user-management.label.rail.usersEntry' | translate\n }}</span>\n </div>\n\n <div class=\"groups-divider\"></div>\n\n @for (group of groups(); track group.id; let i = $index) {\n <div\n ymusrmgmt-focusable-row\n class=\"group-item\"\n role=\"option\"\n [attr.aria-selected]=\"selectedGroupId() === group.id\"\n [class.active]=\"selectedGroupId() === group.id\"\n [attr.tabindex]=\"activeIndex() === i + 1 ? 0 : -1\"\n [matTooltip]=\"group.description || ''\"\n (click)=\"selectGroup(group)\"\n (keydown.enter)=\"selectGroup(group)\"\n (keydown.space)=\"$event.preventDefault(); selectGroup(group)\"\n >\n <span class=\"group-icon\"><mat-icon>group</mat-icon></span>\n <span class=\"group-name\">{{ group.name }}</span>\n <button\n mat-icon-button\n class=\"group-actions\"\n [matMenuTriggerFor]=\"groupRowMenu\"\n [matMenuTriggerData]=\"{ group: group }\"\n [matTooltip]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [tabIndex]=\"activeIndex() === i + 1 ? 0 : -1\"\n (click)=\"$event.stopPropagation(); selectGroup(group)\"\n >\n <mat-icon>more_vert</mat-icon>\n </button>\n </div>\n }\n </div>\n </ymt-pane-body>\n</ymt-pane>\n\n<mat-menu #groupRowMenu=\"matMenu\">\n <ng-template matMenuContent let-group=\"group\">\n @for (action of getGroupRowActions(group); track action.id) {\n <button mat-menu-item [disabled]=\"action.disabled\" (click)=\"action.callback?.(action)\">\n <mat-icon>{{ action.icon }}</mat-icon>\n <span>{{ action.label }}</span>\n </button>\n }\n </ng-template>\n</mat-menu>\n", styles: [":host{display:block;height:100%}.groups-list{padding:var(--ymt-spacing-xs)}.group-item{display:flex;align-items:center;gap:.625rem;padding:var(--ymt-spacing-xs) .625rem;border-radius:var(--ymt-corner-xs);cursor:pointer;margin-bottom:var(--ymt-spacing-3xs)}.group-item:hover{background-color:var(--ymt-hover-background)}.group-item.active{background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container)}:host(:not(:focus-within)) .group-item.active{background-color:var(--ymt-selection-background);color:var(--ymt-on-selection-background)}.group-item .group-icon{width:var(--ymt-sizing-l);height:var(--ymt-sizing-l);border-radius:var(--ymt-corner-full);background-color:var(--ymt-surface-container-high);display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--ymt-text-color-subtle)}.group-item .group-icon mat-icon{font-size:var(--ymt-sizing-xs);width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs)}.group-item.active .group-icon{background-color:var(--ymt-surface);color:var(--ymt-primary)}:host(:not(:focus-within)) .group-item.active .group-icon{color:var(--ymt-text-color-subtle)}.group-item .group-name{flex:1;min-width:0;font:var(--ymt-font-title-smallest);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-item .group-actions{flex-shrink:0;color:inherit;width:var(--ymt-sizing-l);height:var(--ymt-sizing-l);padding:0}.group-item .group-actions mat-icon{font-size:1.125rem;width:1.125rem;height:1.125rem}.groups-divider{height:var(--ymt-outline-width);background-color:var(--ymt-outline-variant);margin:.375rem .625rem .625rem}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i3$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i3$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i3$2.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i3$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: YmtPanesModule }, { kind: "component", type: i7.YmtPaneComponent, selector: "ymt-pane", inputs: ["topBarActions", "busy", "noAnimation", "plain"] }, { kind: "component", type: i7.YmtPaneHeaderComponent, selector: "ymt-pane-header", inputs: ["title", "icon", "subtitle", "actions", "badges"] }, { kind: "component", type: i7.YmtPaneBodyComponent, selector: "ymt-pane-body" }, { kind: "directive", type: FocusableRowDirective, selector: "[ymusrmgmt-focusable-row]" }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1147
1031
|
}
|
|
1148
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
1032
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: GroupsComponent, decorators: [{
|
|
1149
1033
|
type: Component,
|
|
1150
|
-
args: [{ selector: 'ymusrmgmt-
|
|
1151
|
-
CommonModule,
|
|
1034
|
+
args: [{ selector: 'ymusrmgmt-groups', standalone: true, imports: [
|
|
1152
1035
|
MatButtonModule,
|
|
1153
1036
|
MatIconModule,
|
|
1154
|
-
|
|
1155
|
-
MatSortModule,
|
|
1156
|
-
MatTableModule,
|
|
1157
|
-
MatTabsModule,
|
|
1037
|
+
MatMenuModule,
|
|
1158
1038
|
MatTooltipModule,
|
|
1159
|
-
RouterModule,
|
|
1160
1039
|
TranslatePipe,
|
|
1161
|
-
SimpleSearchComponent,
|
|
1162
|
-
BusyOverlayDirective,
|
|
1163
|
-
ClickDoubleDirective,
|
|
1164
|
-
UserDetailsComponent,
|
|
1165
|
-
YmtBadge,
|
|
1166
|
-
YmtButtonDirective,
|
|
1167
|
-
YmtIconButtonDirective,
|
|
1168
|
-
YmtLayoutModule,
|
|
1169
1040
|
YmtPanesModule,
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
}],
|
|
1179
|
-
type:
|
|
1180
|
-
|
|
1181
|
-
|
|
1041
|
+
FocusableRowDirective,
|
|
1042
|
+
], template: "@let railTitle = 'yuv.app.user-management.label.rail.title' | translate;\n<ymt-pane\n [busy]=\"busy()\"\n [topBarActions]=\"topBarActions\"\n role=\"region\"\n [attr.aria-label]=\"railTitle\"\n>\n <ymt-pane-header\n [icon]=\"'groups'\"\n [title]=\"railTitle\"\n [subtitle]=\"\n 'yuv.app.user-management.label.rail.subtitle' | translate : { count: groups().length }\n \"\n ></ymt-pane-header>\n <ymt-pane-body>\n <div\n class=\"groups-list\"\n role=\"listbox\"\n tabindex=\"-1\"\n [attr.aria-label]=\"railTitle\"\n (keydown)=\"onListKeydown($event)\"\n >\n <div\n ymusrmgmt-focusable-row\n class=\"group-item\"\n role=\"option\"\n [attr.aria-selected]=\"!selectedGroupId()\"\n [class.active]=\"!selectedGroupId()\"\n [attr.tabindex]=\"activeIndex() === 0 ? 0 : -1\"\n (click)=\"selectAllUsers()\"\n (keydown.enter)=\"selectAllUsers()\"\n (keydown.space)=\"$event.preventDefault(); selectAllUsers()\"\n >\n <span class=\"group-icon\"><mat-icon>family_group</mat-icon></span>\n <span class=\"group-name\">{{\n 'yuv.app.user-management.label.rail.usersEntry' | translate\n }}</span>\n </div>\n\n <div class=\"groups-divider\"></div>\n\n @for (group of groups(); track group.id; let i = $index) {\n <div\n ymusrmgmt-focusable-row\n class=\"group-item\"\n role=\"option\"\n [attr.aria-selected]=\"selectedGroupId() === group.id\"\n [class.active]=\"selectedGroupId() === group.id\"\n [attr.tabindex]=\"activeIndex() === i + 1 ? 0 : -1\"\n [matTooltip]=\"group.description || ''\"\n (click)=\"selectGroup(group)\"\n (keydown.enter)=\"selectGroup(group)\"\n (keydown.space)=\"$event.preventDefault(); selectGroup(group)\"\n >\n <span class=\"group-icon\"><mat-icon>group</mat-icon></span>\n <span class=\"group-name\">{{ group.name }}</span>\n <button\n mat-icon-button\n class=\"group-actions\"\n [matMenuTriggerFor]=\"groupRowMenu\"\n [matMenuTriggerData]=\"{ group: group }\"\n [matTooltip]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [tabIndex]=\"activeIndex() === i + 1 ? 0 : -1\"\n (click)=\"$event.stopPropagation(); selectGroup(group)\"\n >\n <mat-icon>more_vert</mat-icon>\n </button>\n </div>\n }\n </div>\n </ymt-pane-body>\n</ymt-pane>\n\n<mat-menu #groupRowMenu=\"matMenu\">\n <ng-template matMenuContent let-group=\"group\">\n @for (action of getGroupRowActions(group); track action.id) {\n <button mat-menu-item [disabled]=\"action.disabled\" (click)=\"action.callback?.(action)\">\n <mat-icon>{{ action.icon }}</mat-icon>\n <span>{{ action.label }}</span>\n </button>\n }\n </ng-template>\n</mat-menu>\n", styles: [":host{display:block;height:100%}.groups-list{padding:var(--ymt-spacing-xs)}.group-item{display:flex;align-items:center;gap:.625rem;padding:var(--ymt-spacing-xs) .625rem;border-radius:var(--ymt-corner-xs);cursor:pointer;margin-bottom:var(--ymt-spacing-3xs)}.group-item:hover{background-color:var(--ymt-hover-background)}.group-item.active{background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container)}:host(:not(:focus-within)) .group-item.active{background-color:var(--ymt-selection-background);color:var(--ymt-on-selection-background)}.group-item .group-icon{width:var(--ymt-sizing-l);height:var(--ymt-sizing-l);border-radius:var(--ymt-corner-full);background-color:var(--ymt-surface-container-high);display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--ymt-text-color-subtle)}.group-item .group-icon mat-icon{font-size:var(--ymt-sizing-xs);width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs)}.group-item.active .group-icon{background-color:var(--ymt-surface);color:var(--ymt-primary)}:host(:not(:focus-within)) .group-item.active .group-icon{color:var(--ymt-text-color-subtle)}.group-item .group-name{flex:1;min-width:0;font:var(--ymt-font-title-smallest);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-item .group-actions{flex-shrink:0;color:inherit;width:var(--ymt-sizing-l);height:var(--ymt-sizing-l);padding:0}.group-item .group-actions mat-icon{font-size:1.125rem;width:1.125rem;height:1.125rem}.groups-divider{height:var(--ymt-outline-width);background-color:var(--ymt-outline-variant);margin:.375rem .625rem .625rem}\n"] }]
|
|
1043
|
+
}], ctorParameters: () => [], propDecorators: { selectedGroupId: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedGroupId", required: false }] }], topBarActions: [{
|
|
1044
|
+
type: Input
|
|
1045
|
+
}], groupSelected: [{
|
|
1046
|
+
type: Output
|
|
1047
|
+
}], groupUpdated: [{
|
|
1048
|
+
type: Output
|
|
1049
|
+
}], groupDeleted: [{
|
|
1050
|
+
type: Output
|
|
1051
|
+
}], addMembersRequested: [{
|
|
1052
|
+
type: Output
|
|
1053
|
+
}], rowItems: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => FocusableRowDirective), { isSignal: true }] }] } });
|
|
1182
1054
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
];
|
|
1055
|
+
/**
|
|
1056
|
+
* A small icon button for a `ymt-pane`'s top bar that hides or toggles a sibling area (e.g. in a
|
|
1057
|
+
* `yuv-split-view`). Rotates its icon 180° when `rotated` is set, so a single icon (e.g.
|
|
1058
|
+
* `left_panel_close`) can indicate both directions of a toggle.
|
|
1059
|
+
*/
|
|
1060
|
+
class PaneToggleButtonComponent {
|
|
1061
|
+
icon = input.required(...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
|
|
1062
|
+
tooltip = input.required(...(ngDevMode ? [{ debugName: "tooltip" }] : /* istanbul ignore next */ []));
|
|
1063
|
+
rotated = input(false, ...(ngDevMode ? [{ debugName: "rotated" }] : /* istanbul ignore next */ []));
|
|
1064
|
+
toggled = output();
|
|
1065
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: PaneToggleButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1066
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.23", type: PaneToggleButtonComponent, isStandalone: true, selector: "ymusrmgmt-pane-toggle-button", inputs: { icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: true, transformFunction: null }, tooltip: { classPropertyName: "tooltip", publicName: "tooltip", isSignal: true, isRequired: true, transformFunction: null }, rotated: { classPropertyName: "rotated", publicName: "rotated", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { toggled: "toggled" }, ngImport: i0, template: "<button\n ymtIconButton\n icon-button-size=\"small\"\n [style.transform]=\"rotated() ? 'rotate(180deg)' : null\"\n [matTooltip]=\"tooltip()\"\n [attr.aria-label]=\"tooltip()\"\n (click)=\"toggled.emit()\"\n>\n <mat-icon>{{ icon() }}</mat-icon>\n</button>\n", styles: ["button{opacity:calc(1 - var(--ymt-disabled-opacity))}button:focus,button:hover{opacity:1}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: YmtIconButtonDirective, selector: "button[ymtIconButton],button[ymt-icon-button],a[ymtIconButton],a[ymt-icon-button]", inputs: ["disabled", "disableRipple", "aria-disabled", "disabledInteractive", "icon-button-size"] }] });
|
|
1067
|
+
}
|
|
1068
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: PaneToggleButtonComponent, decorators: [{
|
|
1069
|
+
type: Component,
|
|
1070
|
+
args: [{ selector: 'ymusrmgmt-pane-toggle-button', standalone: true, imports: [MatIconModule, MatTooltipModule, YmtIconButtonDirective], template: "<button\n ymtIconButton\n icon-button-size=\"small\"\n [style.transform]=\"rotated() ? 'rotate(180deg)' : null\"\n [matTooltip]=\"tooltip()\"\n [attr.aria-label]=\"tooltip()\"\n (click)=\"toggled.emit()\"\n>\n <mat-icon>{{ icon() }}</mat-icon>\n</button>\n", styles: ["button{opacity:calc(1 - var(--ymt-disabled-opacity))}button:focus,button:hover{opacity:1}\n"] }]
|
|
1071
|
+
}], propDecorators: { icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: true }] }], tooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltip", required: true }] }], rotated: [{ type: i0.Input, args: [{ isSignal: true, alias: "rotated", required: false }] }], toggled: [{ type: i0.Output, args: ["toggled"] }] } });
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* The narrow icon+label strip a `yuv-split-view` area collapses to instead of disappearing
|
|
1075
|
+
* entirely (`hideArea()`/`showArea()` still drive the actual split-view visibility - this is
|
|
1076
|
+
* just the "way back in" rendered as a sibling of the split-view while an area is hidden).
|
|
1077
|
+
*
|
|
1078
|
+
* The gutter div is the whole interactive target (click, Enter, Space all expand it) - the inner
|
|
1079
|
+
* icon is `aria-hidden` and has no click handler of its own, so it's purely decorative rather
|
|
1080
|
+
* than a second, independently-focusable interactive control.
|
|
1081
|
+
*/
|
|
1082
|
+
class PaneGutterComponent {
|
|
1083
|
+
icon = input.required(...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
|
|
1084
|
+
label = input.required(...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1085
|
+
tooltip = input.required(...(ngDevMode ? [{ debugName: "tooltip" }] : /* istanbul ignore next */ []));
|
|
1086
|
+
/** Flips the label to read bottom-to-top (`vertical-rl` reads top-to-bottom by default) - for a
|
|
1087
|
+
* gutter whose collapsed pane sits to the left, so the label flows outward toward that edge. */
|
|
1088
|
+
flipLabel = input(false, ...(ngDevMode ? [{ debugName: "flipLabel" }] : /* istanbul ignore next */ []));
|
|
1089
|
+
expand = output();
|
|
1090
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: PaneGutterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1091
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.23", type: PaneGutterComponent, isStandalone: true, selector: "ymusrmgmt-pane-gutter", inputs: { icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, tooltip: { classPropertyName: "tooltip", publicName: "tooltip", isSignal: true, isRequired: true, transformFunction: null }, flipLabel: { classPropertyName: "flipLabel", publicName: "flipLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { expand: "expand" }, ngImport: i0, template: "<ymt-pane>\n <ymt-pane-body>\n <div\n class=\"pane-gutter\"\n role=\"button\"\n tabindex=\"0\"\n [matTooltip]=\"tooltip()\"\n [attr.aria-label]=\"tooltip()\"\n (click)=\"expand.emit()\"\n (keydown.enter)=\"expand.emit()\"\n (keydown.space)=\"$event.preventDefault(); expand.emit()\"\n >\n <mat-icon class=\"icon\" aria-hidden=\"true\">{{ icon() }}</mat-icon>\n <span class=\"label\" [class.label--btt]=\"flipLabel()\">{{ label() }}</span>\n </div>\n </ymt-pane-body>\n</ymt-pane>\n", styles: [":host{display:flex;flex:0 0 var(--ymt-sizing-3xl);width:var(--ymt-sizing-3xl)}ymt-pane{width:100%}.pane-gutter{height:100%;display:flex;flex-flow:column;align-items:center;gap:var(--ymt-spacing-s);padding-block-start:var(--ymt-spacing-xs);opacity:calc(1 - var(--ymt-disabled-opacity));cursor:pointer}.pane-gutter .icon{height:var(--ymt-sizing-s);width:var(--ymt-sizing-s);font-size:var(--ymt-sizing-s);line-height:1}.pane-gutter .label{font:var(--ymt-font-body-subtle);writing-mode:vertical-rl;white-space:nowrap}.pane-gutter .label--btt{transform:rotate(180deg)}.pane-gutter:focus,.pane-gutter:hover{opacity:1}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: YmtPanesModule }, { kind: "component", type: i7.YmtPaneComponent, selector: "ymt-pane", inputs: ["topBarActions", "busy", "noAnimation", "plain"] }, { kind: "component", type: i7.YmtPaneBodyComponent, selector: "ymt-pane-body" }] });
|
|
1092
|
+
}
|
|
1093
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: PaneGutterComponent, decorators: [{
|
|
1094
|
+
type: Component,
|
|
1095
|
+
args: [{ selector: 'ymusrmgmt-pane-gutter', standalone: true, imports: [MatIconModule, MatTooltipModule, YmtPanesModule], template: "<ymt-pane>\n <ymt-pane-body>\n <div\n class=\"pane-gutter\"\n role=\"button\"\n tabindex=\"0\"\n [matTooltip]=\"tooltip()\"\n [attr.aria-label]=\"tooltip()\"\n (click)=\"expand.emit()\"\n (keydown.enter)=\"expand.emit()\"\n (keydown.space)=\"$event.preventDefault(); expand.emit()\"\n >\n <mat-icon class=\"icon\" aria-hidden=\"true\">{{ icon() }}</mat-icon>\n <span class=\"label\" [class.label--btt]=\"flipLabel()\">{{ label() }}</span>\n </div>\n </ymt-pane-body>\n</ymt-pane>\n", styles: [":host{display:flex;flex:0 0 var(--ymt-sizing-3xl);width:var(--ymt-sizing-3xl)}ymt-pane{width:100%}.pane-gutter{height:100%;display:flex;flex-flow:column;align-items:center;gap:var(--ymt-spacing-s);padding-block-start:var(--ymt-spacing-xs);opacity:calc(1 - var(--ymt-disabled-opacity));cursor:pointer}.pane-gutter .icon{height:var(--ymt-sizing-s);width:var(--ymt-sizing-s);font-size:var(--ymt-sizing-s);line-height:1}.pane-gutter .label{font:var(--ymt-font-body-subtle);writing-mode:vertical-rl;white-space:nowrap}.pane-gutter .label--btt{transform:rotate(180deg)}.pane-gutter:focus,.pane-gutter:hover{opacity:1}\n"] }]
|
|
1096
|
+
}], propDecorators: { icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], tooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltip", required: true }] }], flipLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "flipLabel", required: false }] }], expand: [{ type: i0.Output, args: ["expand"] }] } });
|
|
1097
|
+
|
|
1098
|
+
/** Initials shown on a user's avatar - falls back to the first two letters of the username if no name is set. */
|
|
1099
|
+
function getInitials(user) {
|
|
1100
|
+
const initials = `${user.firstName?.trim()?.charAt(0) ?? ''}${user.lastName?.trim()?.charAt(0) ?? ''}`.toUpperCase();
|
|
1101
|
+
return initials || user.username?.slice(0, 2).toUpperCase() || '?';
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Deterministic avatar color derived from the user's id (or username as fallback) - the same user
|
|
1105
|
+
* always gets the same color across renders of the users table.
|
|
1106
|
+
*/
|
|
1107
|
+
function getAvatarColor(user) {
|
|
1108
|
+
const hue = hashString(user.id || user.username) % 360;
|
|
1109
|
+
return {
|
|
1110
|
+
background: `hsl(${hue}, 46%, 90%)`,
|
|
1111
|
+
color: `hsl(${hue}, 45%, 32%)`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
function hashString(value) {
|
|
1115
|
+
let hash = 0;
|
|
1116
|
+
for (let i = 0; i < value.length; i++) {
|
|
1117
|
+
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
|
1118
|
+
}
|
|
1119
|
+
return hash;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Shared avatar anatomy for a user - initials on a deterministic per-user color, plus corner
|
|
1124
|
+
* badges for "disabled" and (optionally) "externally managed" state. Used by both the main user
|
|
1125
|
+
* table and the add-members dialog's search-result/selected-users rows.
|
|
1126
|
+
*/
|
|
1127
|
+
class UserAvatarComponent {
|
|
1128
|
+
user = input.required(...(ngDevMode ? [{ debugName: "user" }] : /* istanbul ignore next */ []));
|
|
1129
|
+
/** Whether to show either corner badge (disabled/externally-managed) at all - both are gated
|
|
1130
|
+
* by this one flag rather than having a separate toggle per badge. */
|
|
1131
|
+
showBadges = input(true, ...(ngDevMode ? [{ debugName: "showBadges" }] : /* istanbul ignore next */ []));
|
|
1132
|
+
icons = APP_ICONS;
|
|
1133
|
+
/** Memoized rather than called directly in the template - both hash a string and allocate a
|
|
1134
|
+
* fresh object/do string work, and this component is instantiated once per row, so re-running
|
|
1135
|
+
* them on every change-detection pass (not just when `user()` changes) would be wasted work. */
|
|
1136
|
+
avatarColor = computed(() => getAvatarColor(this.user()), ...(ngDevMode ? [{ debugName: "avatarColor" }] : /* istanbul ignore next */ []));
|
|
1137
|
+
initials = computed(() => getInitials(this.user()), ...(ngDevMode ? [{ debugName: "initials" }] : /* istanbul ignore next */ []));
|
|
1138
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserAvatarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1139
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: UserAvatarComponent, isStandalone: true, selector: "ymusrmgmt-user-avatar", inputs: { user: { classPropertyName: "user", publicName: "user", isSignal: true, isRequired: true, transformFunction: null }, showBadges: { classPropertyName: "showBadges", publicName: "showBadges", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div\n class=\"user-avatar\"\n [class.is-disabled]=\"!user().enabled\"\n [style.background]=\"avatarColor().background\"\n [style.color]=\"avatarColor().color\"\n>\n <span>{{ initials() }}</span>\n @if (showBadges() && !user().enabled) { @let disabledLabel =\n 'yuv.app.user-management.users.property.disabled' | translate;\n <span\n class=\"avatar-disabled-badge\"\n [matTooltip]=\"disabledLabel\"\n [attr.aria-label]=\"disabledLabel\"\n >\n <mat-icon aria-hidden=\"true\">{{ icons.stateInactive }}</mat-icon>\n </span>\n } @if (showBadges() && user().externallyManaged) { @let externalLabel =\n 'yuv.app.user-management.users.label.identitySource.external.tooltip' | translate;\n <span class=\"avatar-source-badge\" [matTooltip]=\"externalLabel\" [attr.aria-label]=\"externalLabel\">\n <mat-icon aria-hidden=\"true\">{{ icons.sourceExternal }}</mat-icon>\n </span>\n }\n</div>\n", styles: [":host{display:contents}.user-avatar{position:relative;width:var(--ymt-sizing-2xl);height:var(--ymt-sizing-2xl);border-radius:var(--ymt-corner-full);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:.8125rem;font-weight:600;letter-spacing:.01em;-webkit-user-select:none;user-select:none}.user-avatar.is-disabled{filter:grayscale(1);opacity:.6}.avatar-disabled-badge{position:absolute;right:-.1875rem;bottom:-.1875rem;width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs);border-radius:var(--ymt-corner-full);border:.125rem solid var(--ymt-surface, #fff);display:flex;align-items:center;justify-content:center}.avatar-disabled-badge mat-icon{font-size:.625rem;width:.625rem;height:.625rem;line-height:.625rem}.avatar-disabled-badge{background-color:var(--ymt-surface-container-high)}.avatar-disabled-badge mat-icon{color:var(--ymt-text-color-subtle)}.avatar-source-badge{position:absolute;left:-.1875rem;bottom:-.1875rem;width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs);border-radius:var(--ymt-corner-full);border:.125rem solid var(--ymt-surface, #fff);display:flex;align-items:center;justify-content:center}.avatar-source-badge mat-icon{font-size:.625rem;width:.625rem;height:.625rem;line-height:.625rem}.avatar-source-badge{background-color:var(--ymt-primary-container)}.avatar-source-badge mat-icon{color:var(--ymt-on-primary-container)}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1140
|
+
}
|
|
1141
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserAvatarComponent, decorators: [{
|
|
1142
|
+
type: Component,
|
|
1143
|
+
args: [{ selector: 'ymusrmgmt-user-avatar', standalone: true, imports: [MatIconModule, MatTooltipModule, TranslatePipe], template: "<div\n class=\"user-avatar\"\n [class.is-disabled]=\"!user().enabled\"\n [style.background]=\"avatarColor().background\"\n [style.color]=\"avatarColor().color\"\n>\n <span>{{ initials() }}</span>\n @if (showBadges() && !user().enabled) { @let disabledLabel =\n 'yuv.app.user-management.users.property.disabled' | translate;\n <span\n class=\"avatar-disabled-badge\"\n [matTooltip]=\"disabledLabel\"\n [attr.aria-label]=\"disabledLabel\"\n >\n <mat-icon aria-hidden=\"true\">{{ icons.stateInactive }}</mat-icon>\n </span>\n } @if (showBadges() && user().externallyManaged) { @let externalLabel =\n 'yuv.app.user-management.users.label.identitySource.external.tooltip' | translate;\n <span class=\"avatar-source-badge\" [matTooltip]=\"externalLabel\" [attr.aria-label]=\"externalLabel\">\n <mat-icon aria-hidden=\"true\">{{ icons.sourceExternal }}</mat-icon>\n </span>\n }\n</div>\n", styles: [":host{display:contents}.user-avatar{position:relative;width:var(--ymt-sizing-2xl);height:var(--ymt-sizing-2xl);border-radius:var(--ymt-corner-full);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:.8125rem;font-weight:600;letter-spacing:.01em;-webkit-user-select:none;user-select:none}.user-avatar.is-disabled{filter:grayscale(1);opacity:.6}.avatar-disabled-badge{position:absolute;right:-.1875rem;bottom:-.1875rem;width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs);border-radius:var(--ymt-corner-full);border:.125rem solid var(--ymt-surface, #fff);display:flex;align-items:center;justify-content:center}.avatar-disabled-badge mat-icon{font-size:.625rem;width:.625rem;height:.625rem;line-height:.625rem}.avatar-disabled-badge{background-color:var(--ymt-surface-container-high)}.avatar-disabled-badge mat-icon{color:var(--ymt-text-color-subtle)}.avatar-source-badge{position:absolute;left:-.1875rem;bottom:-.1875rem;width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs);border-radius:var(--ymt-corner-full);border:.125rem solid var(--ymt-surface, #fff);display:flex;align-items:center;justify-content:center}.avatar-source-badge mat-icon{font-size:.625rem;width:.625rem;height:.625rem;line-height:.625rem}.avatar-source-badge{background-color:var(--ymt-primary-container)}.avatar-source-badge mat-icon{color:var(--ymt-on-primary-container)}\n"] }]
|
|
1144
|
+
}], propDecorators: { user: [{ type: i0.Input, args: [{ isSignal: true, alias: "user", required: true }] }], showBadges: [{ type: i0.Input, args: [{ isSignal: true, alias: "showBadges", required: false }] }] } });
|
|
1145
|
+
|
|
1146
|
+
class GlobalAddButtonComponent {
|
|
1147
|
+
#translateService = inject(TranslateService);
|
|
1148
|
+
createUser = output();
|
|
1149
|
+
createGroup = output();
|
|
1150
|
+
menuItems = [
|
|
1151
|
+
{
|
|
1152
|
+
icon: APP_ICONS.actionCreateNewUser,
|
|
1153
|
+
label: this.#translateService.instant('yuv.app.user-management.users.action.create.label'),
|
|
1154
|
+
action: () => this.createUser.emit(),
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
icon: APP_ICONS.actionCreateNewGroup,
|
|
1158
|
+
label: this.#translateService.instant('yuv.app.user-management.groups.action.create.label'),
|
|
1159
|
+
action: () => this.createGroup.emit(),
|
|
1160
|
+
},
|
|
1161
|
+
];
|
|
1162
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: GlobalAddButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1163
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: GlobalAddButtonComponent, isStandalone: true, selector: "ymusrmgmt-global-add-button", outputs: { createUser: "createUser", createGroup: "createGroup" }, host: { classAttribute: "ymusrmgmt-global-add" }, ngImport: i0, template: "<button\n mat-mini-fab\n [matTooltip]=\"'yuv.app.user-management.common.action.add.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.add.label' | translate\"\n [matMenuTriggerFor]=\"menu\"\n>\n <mat-icon>add</mat-icon>\n</button>\n<mat-menu #menu=\"matMenu\" class=\"global-add-button-menu\">\n @for (item of menuItems; track item.label) {\n <button mat-menu-item (click)=\"item.action()\">\n <mat-icon>{{ item.icon }}</mat-icon>\n <span>{{ item.label }}</span>\n </button>\n }\n</mat-menu>\n", styles: [".global-add-button-menu{border:var(--ymt-outline-width) solid var(--ymt-primary-container);--mat-menu-container-color: var(--ymt-primary-container);--mat-menu-item-hover-state-layer-color: color-mix(in srgb, var(--ymt-primary) calc(var(--mat-sys-hover-state-layer-opacity) * 100%), transparent);--mat-menu-item-label-text-color: var(--ymt-on-primary-container);--mat-menu-item-icon-color: var(--ymt-on-primary-container)}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$1.MatMiniFabButton, selector: "button[mat-mini-fab], a[mat-mini-fab], button[matMiniFab], a[matMiniFab]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i3$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i3$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i3$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], encapsulation: i0.ViewEncapsulation.None });
|
|
1164
|
+
}
|
|
1165
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: GlobalAddButtonComponent, decorators: [{
|
|
1166
|
+
type: Component,
|
|
1167
|
+
args: [{ selector: 'ymusrmgmt-global-add-button', standalone: true, imports: [TranslatePipe, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule], encapsulation: ViewEncapsulation.None, host: {
|
|
1168
|
+
class: 'ymusrmgmt-global-add',
|
|
1169
|
+
}, template: "<button\n mat-mini-fab\n [matTooltip]=\"'yuv.app.user-management.common.action.add.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.add.label' | translate\"\n [matMenuTriggerFor]=\"menu\"\n>\n <mat-icon>add</mat-icon>\n</button>\n<mat-menu #menu=\"matMenu\" class=\"global-add-button-menu\">\n @for (item of menuItems; track item.label) {\n <button mat-menu-item (click)=\"item.action()\">\n <mat-icon>{{ item.icon }}</mat-icon>\n <span>{{ item.label }}</span>\n </button>\n }\n</mat-menu>\n", styles: [".global-add-button-menu{border:var(--ymt-outline-width) solid var(--ymt-primary-container);--mat-menu-container-color: var(--ymt-primary-container);--mat-menu-item-hover-state-layer-color: color-mix(in srgb, var(--ymt-primary) calc(var(--mat-sys-hover-state-layer-opacity) * 100%), transparent);--mat-menu-item-label-text-color: var(--ymt-on-primary-container);--mat-menu-item-icon-color: var(--ymt-on-primary-container)}\n"] }]
|
|
1170
|
+
}], propDecorators: { createUser: [{ type: i0.Output, args: ["createUser"] }], createGroup: [{ type: i0.Output, args: ["createGroup"] }] } });
|
|
1171
|
+
|
|
1172
|
+
const components$4 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent];
|
|
1173
|
+
const modules$4 = [
|
|
1174
|
+
CommonModule,
|
|
1175
|
+
ReactiveFormsModule,
|
|
1176
|
+
TranslatePipe,
|
|
1177
|
+
MatFormFieldModule,
|
|
1178
|
+
MatInputModule,
|
|
1179
|
+
];
|
|
1180
|
+
class CreateUserComponent {
|
|
1181
|
+
/** Shared `MatDialog.open()` config, so every call site opening this dialog stays in sync. */
|
|
1182
|
+
static DIALOG_CONFIG = {
|
|
1183
|
+
width: '600px',
|
|
1184
|
+
maxWidth: '90vw',
|
|
1185
|
+
};
|
|
1186
|
+
#fb = inject(FormBuilder);
|
|
1187
|
+
#userManagementService = inject(YuvUserManagementService);
|
|
1188
|
+
#dialogRef = inject((MatDialogRef));
|
|
1189
|
+
#translateService = inject(TranslateService);
|
|
1190
|
+
#logger = inject(Logger);
|
|
1191
|
+
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1192
|
+
error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1193
|
+
username = new FormControl('', [
|
|
1194
|
+
Validators.required,
|
|
1195
|
+
Validators.minLength(3),
|
|
1196
|
+
Validators.maxLength(255),
|
|
1197
|
+
Validators.pattern('^[a-zA-Z0-9._-]*$'),
|
|
1198
|
+
]);
|
|
1199
|
+
email = new FormControl('', [Validators.required, Validators.email]);
|
|
1200
|
+
firstname = new FormControl('', [
|
|
1201
|
+
Validators.minLength(1),
|
|
1202
|
+
Validators.maxLength(255),
|
|
1203
|
+
Validators.pattern('^[^#!<>&/%=;*§$()[\\]{}]*$'),
|
|
1204
|
+
]);
|
|
1205
|
+
lastname = new FormControl('', [
|
|
1206
|
+
Validators.minLength(1),
|
|
1207
|
+
Validators.maxLength(255),
|
|
1208
|
+
Validators.pattern('^[^#!<>&/%=;*§$()[\\]{}]*$'),
|
|
1209
|
+
]);
|
|
1210
|
+
form = this.#fb.group({
|
|
1211
|
+
username: this.username,
|
|
1212
|
+
email: this.email,
|
|
1213
|
+
firstname: this.firstname,
|
|
1214
|
+
lastname: this.lastname,
|
|
1215
|
+
});
|
|
1216
|
+
changedInput = signal(undefined, ...(ngDevMode ? [{ debugName: "changedInput" }] : /* istanbul ignore next */ []));
|
|
1217
|
+
disabledSubmit = linkedSignal({ ...(ngDevMode ? { debugName: "disabledSubmit" } : /* istanbul ignore next */ {}), source: this.changedInput,
|
|
1218
|
+
computation: () => {
|
|
1219
|
+
return this.busy() || this.form.invalid;
|
|
1220
|
+
} });
|
|
1221
|
+
createUser() {
|
|
1222
|
+
let createdUserId;
|
|
1223
|
+
const createParams = this.form.value;
|
|
1224
|
+
this.busy.set(true);
|
|
1225
|
+
this.error.set(undefined);
|
|
1226
|
+
const createUserRequest = {
|
|
1227
|
+
username: createParams.username,
|
|
1228
|
+
email: createParams.email,
|
|
1229
|
+
firstName: createParams.firstname ? createParams.firstname : null,
|
|
1230
|
+
lastName: createParams.lastname ? createParams.lastname : null,
|
|
1231
|
+
enabled: true,
|
|
1232
|
+
roles: [],
|
|
1233
|
+
};
|
|
1234
|
+
this.#userManagementService.createUser(createUserRequest, true).subscribe({
|
|
1235
|
+
error: (e) => {
|
|
1236
|
+
const errorKey = resolveErrorMessageKey('yuv.app.user-management.users.action.create', e.status, { 409: 'conflict' });
|
|
1237
|
+
this.error.set(this.#translateService.instant(errorKey));
|
|
1238
|
+
this.#logger.error(`Failed to create user with username ${createUserRequest.username}`, e);
|
|
1239
|
+
this.busy.set(false);
|
|
1240
|
+
},
|
|
1241
|
+
next: (userId) => {
|
|
1242
|
+
createdUserId = userId;
|
|
1243
|
+
},
|
|
1244
|
+
complete: () => {
|
|
1245
|
+
this.busy.set(false);
|
|
1246
|
+
// Returns the full user (not just its id) so the caller can select it in the table
|
|
1247
|
+
// without a redundant fetch - the backend only echoes back the id on create.
|
|
1248
|
+
this.#dialogRef.close({
|
|
1249
|
+
id: createdUserId,
|
|
1250
|
+
username: createUserRequest.username,
|
|
1251
|
+
email: createUserRequest.email,
|
|
1252
|
+
firstName: createUserRequest.firstName ?? undefined,
|
|
1253
|
+
lastName: createUserRequest.lastName ?? undefined,
|
|
1254
|
+
enabled: createUserRequest.enabled,
|
|
1255
|
+
});
|
|
1256
|
+
},
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
close() {
|
|
1260
|
+
this.#dialogRef.close();
|
|
1261
|
+
}
|
|
1262
|
+
ngAfterViewInit() {
|
|
1263
|
+
this.form.valueChanges.subscribe((value) => {
|
|
1264
|
+
this.changedInput.set(value);
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: CreateUserComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1268
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: CreateUserComponent, isStandalone: true, selector: "ymusrmgmt-create-user", ngImport: i0, template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"'yuv.app.user-management.users.action.create.label' | translate\"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.username' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"username\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.username' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.email' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"email\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.email' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.firstname' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"firstname\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.firstname' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.lastname' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"lastname\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.lastname' | translate\"\n />\n </mat-form-field>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.users.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createUser()\">\n {{ 'yuv.app.user-management.users.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1269
|
+
}
|
|
1270
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: CreateUserComponent, decorators: [{
|
|
1271
|
+
type: Component,
|
|
1272
|
+
args: [{ selector: 'ymusrmgmt-create-user', standalone: true, imports: [...modules$4, ...components$4], template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"'yuv.app.user-management.users.action.create.label' | translate\"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.username' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"username\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.username' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.email' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"email\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.email' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.firstname' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"firstname\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.firstname' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.users.property.lastname' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"lastname\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.lastname' | translate\"\n />\n </mat-form-field>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.users.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createUser()\">\n {{ 'yuv.app.user-management.users.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}\n"] }]
|
|
1273
|
+
}] });
|
|
1274
|
+
|
|
1275
|
+
const components$3 = [
|
|
1276
|
+
BusyOverlayDirective,
|
|
1277
|
+
YmtButtonDirective,
|
|
1278
|
+
DialogComponent,
|
|
1279
|
+
RolesSelectionComponent,
|
|
1280
|
+
];
|
|
1281
|
+
const modules$3 = [
|
|
1282
|
+
CommonModule,
|
|
1283
|
+
ReactiveFormsModule,
|
|
1284
|
+
TranslatePipe,
|
|
1285
|
+
MatFormFieldModule,
|
|
1286
|
+
MatSlideToggleModule,
|
|
1287
|
+
MatInputModule,
|
|
1288
|
+
];
|
|
1192
1289
|
class CreateGroupComponent {
|
|
1290
|
+
/** Shared `MatDialog.open()` config, so every call site opening this dialog stays in sync. */
|
|
1291
|
+
static DIALOG_CONFIG = {
|
|
1292
|
+
width: '600px',
|
|
1293
|
+
maxWidth: '90vw',
|
|
1294
|
+
};
|
|
1193
1295
|
#fb = inject(FormBuilder);
|
|
1194
1296
|
#userManagementService = inject(YuvUserManagementService);
|
|
1195
1297
|
#dialogRef = inject((MatDialogRef));
|
|
@@ -1198,13 +1300,18 @@ class CreateGroupComponent {
|
|
|
1198
1300
|
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1199
1301
|
error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1200
1302
|
allAvailableRoles = signal([], ...(ngDevMode ? [{ debugName: "allAvailableRoles" }] : /* istanbul ignore next */ []));
|
|
1201
|
-
allUsers = signal([], ...(ngDevMode ? [{ debugName: "allUsers" }] : /* istanbul ignore next */ []));
|
|
1202
1303
|
groupRoles = signal([], ...(ngDevMode ? [{ debugName: "groupRoles" }] : /* istanbul ignore next */ []));
|
|
1203
|
-
|
|
1204
|
-
|
|
1304
|
+
name = new FormControl('', [
|
|
1305
|
+
Validators.required,
|
|
1306
|
+
Validators.minLength(3),
|
|
1307
|
+
Validators.maxLength(255),
|
|
1308
|
+
Validators.pattern('^[a-zA-Z0-9._-]*$'),
|
|
1309
|
+
]);
|
|
1310
|
+
description = new FormControl('');
|
|
1205
1311
|
roles = new FormControl([]);
|
|
1206
1312
|
form = this.#fb.group({
|
|
1207
1313
|
name: this.name,
|
|
1314
|
+
description: this.description,
|
|
1208
1315
|
roles: this.roles,
|
|
1209
1316
|
});
|
|
1210
1317
|
changedInput = signal(undefined, ...(ngDevMode ? [{ debugName: "changedInput" }] : /* istanbul ignore next */ []));
|
|
@@ -1219,7 +1326,7 @@ class CreateGroupComponent {
|
|
|
1219
1326
|
},
|
|
1220
1327
|
error: (e) => {
|
|
1221
1328
|
this.#logger.error('Failed to fetch roles', e);
|
|
1222
|
-
}
|
|
1329
|
+
},
|
|
1223
1330
|
});
|
|
1224
1331
|
}
|
|
1225
1332
|
createGroup() {
|
|
@@ -1229,36 +1336,28 @@ class CreateGroupComponent {
|
|
|
1229
1336
|
this.error.set(undefined);
|
|
1230
1337
|
const createGroupRequest = {
|
|
1231
1338
|
name: createParams.name,
|
|
1339
|
+
description: createParams.description,
|
|
1232
1340
|
roles: createParams.roles,
|
|
1233
|
-
members: []
|
|
1234
1341
|
};
|
|
1235
|
-
this.#userManagementService
|
|
1236
|
-
.createGroup(createGroupRequest)
|
|
1237
|
-
.subscribe({
|
|
1342
|
+
this.#userManagementService.createGroup(createGroupRequest).subscribe({
|
|
1238
1343
|
error: (e) => {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
if (errorMsg === "Group exists with same username") {
|
|
1243
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.groups.action.create.error.groupExists"));
|
|
1244
|
-
}
|
|
1245
|
-
else {
|
|
1246
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.groups.action.create.error.unknown"));
|
|
1247
|
-
}
|
|
1248
|
-
break;
|
|
1249
|
-
}
|
|
1250
|
-
default:
|
|
1251
|
-
this.error.set(this.#translateService.instant("yuv.app.user-management.groups.action.create.error.unknown"));
|
|
1252
|
-
}
|
|
1253
|
-
this.#logger.error(`Failed to create user with username ${createGroupRequest.name}`, e);
|
|
1344
|
+
const errorKey = resolveErrorMessageKey('yuv.app.user-management.groups.action.create', e.status);
|
|
1345
|
+
this.error.set(this.#translateService.instant(errorKey));
|
|
1346
|
+
this.#logger.error(`Failed to create group with name ${createGroupRequest.name}`, e);
|
|
1254
1347
|
this.busy.set(false);
|
|
1255
1348
|
},
|
|
1256
|
-
next: (
|
|
1257
|
-
createdGroupId =
|
|
1349
|
+
next: (groupId) => {
|
|
1350
|
+
createdGroupId = groupId;
|
|
1258
1351
|
},
|
|
1259
1352
|
complete: () => {
|
|
1260
1353
|
this.busy.set(false);
|
|
1261
|
-
|
|
1354
|
+
// Returns the full group (not just its id) so the caller can select it in the rail
|
|
1355
|
+
// without a redundant fetch - the backend only echoes back the id on create.
|
|
1356
|
+
this.#dialogRef.close({
|
|
1357
|
+
id: createdGroupId,
|
|
1358
|
+
name: createGroupRequest.name,
|
|
1359
|
+
description: createGroupRequest.description,
|
|
1360
|
+
});
|
|
1262
1361
|
},
|
|
1263
1362
|
});
|
|
1264
1363
|
}
|
|
@@ -1275,90 +1374,442 @@ class CreateGroupComponent {
|
|
|
1275
1374
|
this.form.patchValue({ roles: roles });
|
|
1276
1375
|
this.roles.markAsTouched(); // Mark as touched to trigger validation display
|
|
1277
1376
|
}
|
|
1278
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
1279
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.
|
|
1377
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: CreateGroupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1378
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: CreateGroupComponent, isStandalone: true, selector: "ymusrmgmt-create-group", ngImport: i0, template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"'yuv.app.user-management.groups.action.create.label' | translate\"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.groups.property.name' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"name\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.name' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.groups.property.description' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"description\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.description' | translate\"\n />\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"groupRoles()\"\n (rolesChange)=\"onRolesChange($event)\"\n >\n </ymusrmgmt-role-selection>\n </div>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.groups.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createGroup()\">\n {{ 'yuv.app.user-management.groups.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "component", type: RolesSelectionComponent, selector: "ymusrmgmt-role-selection", inputs: ["allRoles", "initialRoles"], outputs: ["rolesChange"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1280
1379
|
}
|
|
1281
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
1380
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: CreateGroupComponent, decorators: [{
|
|
1282
1381
|
type: Component,
|
|
1283
|
-
args: [{ selector: 'ymusrmgmt-create-group', standalone: true, imports: [...modules$
|
|
1284
|
-
'[class.busy]': '!busy()',
|
|
1285
|
-
}, template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.groups.action.create.label' | translate\">\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\"yuv.app.user-management.groups.object.property.name\" | translate }}</mat-label>\n <input matInput [formControl]=\"name\" type=\"text\"/>\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"groupRoles()\"\n (rolesChange)=\"onRolesChange($event)\">\n </ymusrmgmt-role-selection>\n </div>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </form>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ \"yuv.app.user-management.groups.action.button.cancel\" | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createGroup()\">\n {{ 'yuv.app.user-management.groups.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main form{padding:var(--ymt-spacing-m)}:host main form .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main form .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs)}:host main form #enabledPropDiv{display:flex;justify-content:center;align-items:center}\n"] }]
|
|
1382
|
+
args: [{ selector: 'ymusrmgmt-create-group', standalone: true, imports: [...modules$3, ...components$3], template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"'yuv.app.user-management.groups.action.create.label' | translate\"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ 'yuv.app.user-management.groups.property.name' | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"name\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.name' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{\n 'yuv.app.user-management.groups.property.description' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"description\"\n type=\"text\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.property.description' | translate\"\n />\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"groupRoles()\"\n (rolesChange)=\"onRolesChange($event)\"\n >\n </ymusrmgmt-role-selection>\n </div>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.groups.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"createGroup()\">\n {{ 'yuv.app.user-management.groups.action.button.create' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}\n"] }]
|
|
1286
1383
|
}], ctorParameters: () => [] });
|
|
1287
1384
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1385
|
+
const components$2 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent, TabGuardDirective];
|
|
1386
|
+
const modules$2 = [
|
|
1387
|
+
CommonModule,
|
|
1388
|
+
ReactiveFormsModule,
|
|
1389
|
+
TranslatePipe,
|
|
1390
|
+
ClipboardModule,
|
|
1391
|
+
MatButtonModule,
|
|
1392
|
+
MatFormFieldModule,
|
|
1393
|
+
MatIconModule,
|
|
1394
|
+
MatInputModule,
|
|
1395
|
+
MatTabsModule,
|
|
1396
|
+
MatTooltipModule,
|
|
1397
|
+
];
|
|
1398
|
+
/** Placeholder shown in the client secret field of an already configured provider; the backend never returns the real secret. */
|
|
1399
|
+
const FAKE_CLIENT_SECRET = '••••••••••••••••';
|
|
1400
|
+
class SettingsComponent {
|
|
1401
|
+
/** Shared `MatDialog.open()` config, so every call site opening this dialog stays in sync. */
|
|
1402
|
+
static DIALOG_CONFIG = {
|
|
1403
|
+
width: '600px',
|
|
1404
|
+
maxWidth: '90vw'
|
|
1405
|
+
};
|
|
1406
|
+
#fb = inject(FormBuilder);
|
|
1407
|
+
#userManagementService = inject(YuvUserManagementService);
|
|
1408
|
+
#dialogRef = inject((MatDialogRef));
|
|
1409
|
+
#translateService = inject(TranslateService);
|
|
1410
|
+
#notificationService = inject(NotificationService);
|
|
1411
|
+
#logger = inject(Logger);
|
|
1412
|
+
busy = signal(true, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1413
|
+
error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1414
|
+
/** True if the tenant has no OIDC identity provider configured (backend responds with 404). */
|
|
1415
|
+
notConfigured = signal(false, ...(ngDevMode ? [{ debugName: "notConfigured" }] : /* istanbul ignore next */ []));
|
|
1416
|
+
/** True once the user has asked to add a provider from the empty-state hint, revealing the blank setup form. */
|
|
1417
|
+
addStarted = signal(false, ...(ngDevMode ? [{ debugName: "addStarted" }] : /* istanbul ignore next */ []));
|
|
1418
|
+
/** The form (blank, for first setup, or pre-filled, for editing) is showing - authentication tab only. */
|
|
1419
|
+
showForm = computed(() => this.addStarted() || !!this.provider(), ...(ngDevMode ? [{ debugName: "showForm" }] : /* istanbul ignore next */ []));
|
|
1420
|
+
provider = signal(undefined, ...(ngDevMode ? [{ debugName: "provider" }] : /* istanbul ignore next */ []));
|
|
1421
|
+
/** Tenant-specific values needed to configure an external identity provider - available regardless of `provider()`. */
|
|
1422
|
+
setupInfo = signal(undefined, ...(ngDevMode ? [{ debugName: "setupInfo" }] : /* istanbul ignore next */ []));
|
|
1423
|
+
redirectUri = computed(() => this.setupInfo()?.redirectUri, ...(ngDevMode ? [{ debugName: "redirectUri" }] : /* istanbul ignore next */ []));
|
|
1424
|
+
/** Null if the tenant has no SCIM client provisioned - shown as an "unavailable" hint in the provisioning tab. */
|
|
1425
|
+
scimProvisioning = computed(() => this.setupInfo()?.scimProvisioning, ...(ngDevMode ? [{ debugName: "scimProvisioning" }] : /* istanbul ignore next */ []));
|
|
1426
|
+
tenantUrl = computed(() => this.scimProvisioning()?.tenantUrl, ...(ngDevMode ? [{ debugName: "tenantUrl" }] : /* istanbul ignore next */ []));
|
|
1427
|
+
displayName = new FormControl('', [Validators.required]);
|
|
1428
|
+
discoveryUrl = new FormControl('', [Validators.required]);
|
|
1429
|
+
clientId = new FormControl('', [Validators.required]);
|
|
1430
|
+
clientSecret = new FormControl('', [Validators.required]);
|
|
1431
|
+
form = this.#fb.group({
|
|
1432
|
+
displayName: this.displayName,
|
|
1433
|
+
discoveryUrl: this.discoveryUrl,
|
|
1434
|
+
clientId: this.clientId,
|
|
1435
|
+
clientSecret: this.clientSecret,
|
|
1436
|
+
});
|
|
1437
|
+
/**
|
|
1438
|
+
* Guards both the save button and `configure()` itself: the form must be valid, and editing an
|
|
1439
|
+
* already-configured provider additionally requires at least one field to be dirty (nothing to
|
|
1440
|
+
* save otherwise). On first setup, `clientSecret` starts blank and required, so `form.invalid`
|
|
1441
|
+
* alone already gates it. A plain method, not a `computed()`: `FormGroup.invalid`/`.dirty` aren't
|
|
1442
|
+
* signals, so a computed reading them would never be invalidated after its first evaluation.
|
|
1443
|
+
*/
|
|
1444
|
+
disabledSubmit() {
|
|
1445
|
+
return this.busy() || this.form.invalid || (!!this.provider() && !this.form.dirty);
|
|
1446
|
+
}
|
|
1447
|
+
constructor() {
|
|
1448
|
+
this.#reload();
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Fetches the OIDC identity provider configuration (null if none is configured, a 404) and the
|
|
1452
|
+
* tenant's setup info (redirect URI, SCIM provisioning) in parallel - the latter is available
|
|
1453
|
+
* independent of whether a provider is configured, so both tabs can always render.
|
|
1454
|
+
*/
|
|
1455
|
+
#load() {
|
|
1456
|
+
this.busy.set(true);
|
|
1457
|
+
this.error.set(undefined);
|
|
1458
|
+
return forkJoin({
|
|
1459
|
+
provider: this.#userManagementService.fetchOIDCProviderConfig().pipe(catchError((e) => (e.status === 404 ? of(null) : throwError(() => e)))),
|
|
1460
|
+
setupInfo: this.#userManagementService.fetchIdentityProviderSetupInfo(),
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
/** Runs `#load()` and applies its result, optionally continuing on success - used for the initial load and after first-time setup. */
|
|
1464
|
+
#reload(onLoaded) {
|
|
1465
|
+
this.#load().subscribe({
|
|
1466
|
+
next: ({ provider, setupInfo }) => {
|
|
1467
|
+
this.#applyLoadResult(provider, setupInfo);
|
|
1468
|
+
onLoaded?.();
|
|
1469
|
+
},
|
|
1470
|
+
error: (e) => this.#handleLoadError(e),
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
#applyLoadResult(provider, setupInfo) {
|
|
1474
|
+
this.setupInfo.set(setupInfo);
|
|
1475
|
+
if (provider) {
|
|
1476
|
+
this.provider.set(provider);
|
|
1477
|
+
this.notConfigured.set(false);
|
|
1478
|
+
this.addStarted.set(false);
|
|
1479
|
+
this.#populateForm(provider);
|
|
1307
1480
|
}
|
|
1308
1481
|
else {
|
|
1309
|
-
this.
|
|
1482
|
+
this.provider.set(undefined);
|
|
1483
|
+
this.notConfigured.set(true);
|
|
1310
1484
|
}
|
|
1485
|
+
this.busy.set(false);
|
|
1486
|
+
}
|
|
1487
|
+
#handleLoadError(e) {
|
|
1488
|
+
const errorKey = resolveErrorMessageKey('yuv.app.user-management.settings.oidc.label', e.status);
|
|
1489
|
+
this.error.set(this.#translateService.instant(errorKey));
|
|
1490
|
+
this.#logger.error('Failed to load identity provider settings', e);
|
|
1491
|
+
this.busy.set(false);
|
|
1311
1492
|
}
|
|
1312
1493
|
/**
|
|
1313
|
-
*
|
|
1494
|
+
* Fills the form, pristine, from an already-configured provider - or blanks it when called
|
|
1495
|
+
* without one, e.g. for a fresh "add". The client secret is never returned by the backend, so an
|
|
1496
|
+
* existing provider gets the placeholder instead, and re-typing it (marking it dirty) is what
|
|
1497
|
+
* unlocks saving again: since the backend takes a full replacement on every save, editing any of
|
|
1498
|
+
* the other fields still requires the real secret to be supplied alongside them.
|
|
1314
1499
|
*/
|
|
1315
|
-
|
|
1316
|
-
this.
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
this.
|
|
1320
|
-
this.
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1500
|
+
#populateForm(provider) {
|
|
1501
|
+
this.displayName.setValue(provider?.displayName ?? '');
|
|
1502
|
+
this.discoveryUrl.setValue(provider?.config?.['metadataDescriptorUrl'] ?? '');
|
|
1503
|
+
this.clientId.setValue(provider?.config?.['clientId'] ?? '');
|
|
1504
|
+
this.clientSecret.setValue(provider ? FAKE_CLIENT_SECRET : '');
|
|
1505
|
+
this.form.markAsPristine();
|
|
1506
|
+
}
|
|
1507
|
+
/** Reveals the blank setup form from the empty-state hint. */
|
|
1508
|
+
startAdd() {
|
|
1509
|
+
this.#populateForm();
|
|
1510
|
+
this.addStarted.set(true);
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Submits the form to `setOIDCProviderConfig` - both the very first setup and every later edit
|
|
1514
|
+
* of an already-configured provider go through this same call. `clientSecret` is only included
|
|
1515
|
+
* in the request when re-typed (dirty) - left out otherwise, so the backend keeps the existing
|
|
1516
|
+
* secret rather than overwriting it with the placeholder text.
|
|
1517
|
+
*/
|
|
1518
|
+
configure() {
|
|
1519
|
+
if (this.disabledSubmit())
|
|
1520
|
+
return;
|
|
1521
|
+
const isFirstSetup = !this.provider();
|
|
1522
|
+
this.busy.set(true);
|
|
1523
|
+
this.error.set(undefined);
|
|
1524
|
+
const config = {
|
|
1525
|
+
// The discovery URL must be sent as `metadataDescriptorUrl` — the backend resolves the
|
|
1526
|
+
// OIDC endpoints (authorizationUrl, tokenUrl, jwksUrl, ...) from the discovery document.
|
|
1527
|
+
metadataDescriptorUrl: this.discoveryUrl.value?.trim() ?? '',
|
|
1528
|
+
clientId: this.clientId.value?.trim() ?? '',
|
|
1529
|
+
defaultScope: 'openid profile',
|
|
1530
|
+
};
|
|
1531
|
+
if (isFirstSetup || this.clientSecret.dirty) {
|
|
1532
|
+
config['clientSecret'] = this.clientSecret.value ?? '';
|
|
1533
|
+
}
|
|
1534
|
+
const providerConfig = {
|
|
1535
|
+
displayName: this.displayName.value?.trim() ?? '',
|
|
1536
|
+
config,
|
|
1537
|
+
};
|
|
1538
|
+
this.#userManagementService.setOIDCProviderConfig(providerConfig).subscribe({
|
|
1539
|
+
next: () => {
|
|
1540
|
+
const notifySuccess = () => this.#notificationService.success(this.#translateService.instant(isFirstSetup
|
|
1541
|
+
? 'yuv.app.user-management.settings.oidc.action.configure.success'
|
|
1542
|
+
: 'yuv.app.user-management.settings.oidc.action.update.success'));
|
|
1543
|
+
if (isFirstSetup) {
|
|
1544
|
+
// First-time setup may newly link the SCIM extension, so setup-info needs a full
|
|
1545
|
+
// re-fetch too; re-use `#reload()` rather than duplicating its subscribe logic.
|
|
1546
|
+
this.#reload(notifySuccess);
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
// A later edit only ever changes the provider itself - setup-info stays the same.
|
|
1550
|
+
this.#userManagementService.fetchOIDCProviderConfig().subscribe({
|
|
1551
|
+
next: (provider) => {
|
|
1552
|
+
this.#applyLoadResult(provider, this.setupInfo());
|
|
1553
|
+
notifySuccess();
|
|
1554
|
+
},
|
|
1555
|
+
error: (e) => this.#handleLoadError(e),
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
error: (e) => {
|
|
1560
|
+
// 422: the backend could not resolve the OIDC endpoints from the discovery URL
|
|
1561
|
+
this.error.set(this.#translateService.instant(e.status === 422
|
|
1562
|
+
? 'yuv.app.user-management.settings.oidc.action.configure.error.discovery'
|
|
1563
|
+
: 'yuv.app.user-management.settings.oidc.action.configure.error.unknown'));
|
|
1564
|
+
this.#logger.error('Failed to configure OIDC identity provider', e);
|
|
1565
|
+
this.busy.set(false);
|
|
1566
|
+
},
|
|
1324
1567
|
});
|
|
1325
1568
|
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1569
|
+
/**
|
|
1570
|
+
* Asks the user to confirm, then reverts (deletes) the OIDC identity provider configuration -
|
|
1571
|
+
* the dialog stays open, falling back to the empty state. Only the setup info needs a re-fetch
|
|
1572
|
+
* (deleting unlinks the SCIM provisioning extension, so it may have changed) - the provider is
|
|
1573
|
+
* already known gone, so re-fetching it too would just be a second round trip for a 404.
|
|
1574
|
+
*/
|
|
1575
|
+
revert() {
|
|
1576
|
+
this.#userManagementService.deleteOIDCProviderConfig().subscribe({
|
|
1577
|
+
next: (deleted) => {
|
|
1578
|
+
if (!deleted)
|
|
1579
|
+
return;
|
|
1580
|
+
this.#userManagementService.fetchIdentityProviderSetupInfo().subscribe({
|
|
1581
|
+
next: (setupInfo) => {
|
|
1582
|
+
this.#applyLoadResult(null, setupInfo);
|
|
1583
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.reset.success'));
|
|
1584
|
+
},
|
|
1585
|
+
error: (e) => this.#handleLoadError(e),
|
|
1586
|
+
});
|
|
1587
|
+
},
|
|
1588
|
+
error: (e) => {
|
|
1589
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.reset.error'));
|
|
1590
|
+
this.#logger.error('Failed to reset OIDC identity provider configuration', e);
|
|
1591
|
+
},
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
/** Handles the `cdkCopyToClipboardCopied` output, which reports whether the copy succeeded. */
|
|
1595
|
+
onCopied(success) {
|
|
1596
|
+
if (success) {
|
|
1597
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.copy.success'));
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.settings.oidc.action.copy.error'));
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
close() {
|
|
1604
|
+
this.#dialogRef.close();
|
|
1605
|
+
}
|
|
1606
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: SettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1607
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: SettingsComponent, isStandalone: true, selector: "ymusrmgmt-settings", ngImport: i0, template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"'yuv.app.user-management.settings.label.dialog.title' | translate\"\n>\n <main class=\"yuv-dialog-content\">\n <ng-template\n #editableField\n let-labelKey=\"labelKey\"\n let-control=\"control\"\n let-type=\"type\"\n let-placeholder=\"placeholder\"\n let-autocomplete=\"autocomplete\"\n >\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ labelKey | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"control\"\n [type]=\"type || 'text'\"\n [placeholder]=\"placeholder || ''\"\n [autocomplete]=\"autocomplete || 'off'\"\n [attr.aria-label]=\"labelKey | translate\"\n />\n </mat-form-field>\n </ng-template>\n <ng-template\n #readonlyField\n let-labelKey=\"labelKey\"\n let-value=\"value\"\n let-type=\"type\"\n let-copyable=\"copyable\"\n >\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ labelKey | translate }}</mat-label>\n <input\n matInput\n readonly\n [type]=\"type || 'text'\"\n [attr.tabindex]=\"-1\"\n [value]=\"value || '\u2014'\"\n [attr.aria-label]=\"labelKey | translate\"\n />\n @if (copyable && value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\"\n >\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n </ng-template>\n\n @if (!showForm()) {\n <div class=\"empty-state\">\n <mat-icon class=\"empty-state-icon\" aria-hidden=\"true\">admin_panel_settings</mat-icon>\n <p class=\"empty-state-title\">\n {{ 'yuv.app.user-management.settings.oidc.label.notConfigured' | translate }}\n </p>\n <p class=\"empty-state-description\">\n {{ 'yuv.app.user-management.settings.oidc.label.setup.description' | translate }}\n </p>\n <button ymtButton=\"primary\" type=\"button\" (click)=\"startAdd()\">\n {{ 'yuv.app.user-management.settings.oidc.action.setup.label' | translate }}\n </button>\n </div>\n } @else {\n <mat-tab-group yuvTabGuardDisable>\n <mat-tab [label]=\"'yuv.app.user-management.settings.oidc.tab.authentication' | translate\">\n <p class=\"hint\">\n {{ 'yuv.app.user-management.settings.oidc.label.setup.hint' | translate }}\n </p>\n <form [formGroup]=\"form\">\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.displayName',\n control: displayName,\n placeholder: 'e.g. Microsoft Entra ID'\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.discoveryUrl',\n control: discoveryUrl,\n placeholder:\n 'e.g. https://login.microsoftonline.com/<entra-tenant-id>/v2.0/.well-known/openid-configuration'\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.clientId',\n control: clientId,\n placeholder: 'e.g. yuuvis'\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.clientSecret',\n control: clientSecret,\n type: 'password',\n autocomplete: 'new-password'\n }\n \"\n ></ng-container>\n @if (redirectUri(); as uri) {\n <hr class=\"divider\" />\n <p class=\"hint\">\n {{ 'yuv.app.user-management.settings.oidc.property.redirectUri.hint' | translate }}\n </p>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.redirectUri',\n value: uri,\n copyable: true\n }\n \"\n ></ng-container>\n }\n </form>\n </mat-tab>\n <mat-tab\n [label]=\"'yuv.app.user-management.settings.oidc.label.section.provisioning' | translate\"\n >\n @if (scimProvisioning(); as scimProvisioning) { @if (provider(); as oidcProvider) {\n <p class=\"hint\">\n {{\n 'yuv.app.user-management.settings.oidc.label.provisioning.hint'\n | translate : { provider: oidcProvider.displayName }\n }}\n </p>\n }\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.tenantUrl',\n value: tenantUrl(),\n copyable: true\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.tokenUrl',\n value: scimProvisioning.tokenEndpoint,\n copyable: true\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.clientName',\n value: scimProvisioning.clientId,\n copyable: true\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.clientSecret',\n value: scimProvisioning.clientSecret,\n type: 'password',\n copyable: true\n }\n \"\n ></ng-container>\n } @else {\n <p class=\"hint\">\n {{ 'yuv.app.user-management.settings.oidc.label.provisioning.unavailable' | translate }}\n </p>\n }\n </mat-tab>\n </mat-tab-group>\n } @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n @if (provider()) {\n <!--\n `ymt-button--danger` is added explicitly (not just relying on `ymtButton=\"danger\"`):\n YmtButtonDirective has a bug where its own dynamically-added `ymt-button--danger` class gets\n wiped when it creates the underlying MatButton (which overwrites the host's `class`\n attribute) and never restored, since the directive's `ngOnInit` snapshots \"classes to\n restore\" before adding it. A class already present in the template survives that wipe, so\n this is what actually makes the button render with danger colors. Don't remove it as a\n seeming duplicate.\n -->\n <button\n ymtButton=\"danger\"\n class=\"ymt-button--danger footer-start\"\n [disabled]=\"busy()\"\n (click)=\"revert()\"\n >\n <mat-icon>reset_wrench</mat-icon>\n {{ 'yuv.app.user-management.settings.oidc.action.reset.label' | translate }}\n </button>\n }\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.settings.action.button.cancel' | translate }}\n </button>\n @if (showForm()) {\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"configure()\">\n {{ 'yuv.app.user-management.settings.oidc.action.save.label' | translate }}\n </button>\n }\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}:host main{min-height:var(--ymt-sizing-7xl)}:host main .yuv-form-field input::placeholder{color:var(--ymt-text-color-subtle, inherit);font-size:.9em;opacity:1}:host main .empty-state{display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--ymt-spacing-m);width:100%;padding:var(--ymt-spacing-4xl) var(--ymt-spacing-l);justify-content:center;min-height:var(--ymt-sizing-7xl)}:host main .empty-state .empty-state-icon{width:var(--ymt-sizing-5xl);height:var(--ymt-sizing-5xl);border-radius:var(--ymt-corner-full);background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container);display:flex;align-items:center;justify-content:center;flex-shrink:0}:host main .empty-state .empty-state-title{font:var(--ymt-font-headline-small);color:var(--ymt-text-color);margin:0}:host main .empty-state .empty-state-description{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);max-width:80ch;margin:0}:host main .divider{border:none;border-top:var(--ymt-outline-width) solid var(--ymt-outline-variant);margin:var(--ymt-spacing-m) 0}:host main ::ng-deep .mat-mdc-tab-body-content{padding-top:var(--ymt-spacing-m)}:host footer{display:flex;flex:1;align-items:center;gap:calc(var(--ymt-font-body) / 2)}:host footer .footer-start{margin-inline-end:auto}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$4.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: ClipboardModule }, { kind: "directive", type: i1.CdkCopyToClipboard, selector: "[cdkCopyToClipboard]", inputs: ["cdkCopyToClipboard", "cdkCopyToClipboardAttempts"], outputs: ["cdkCopyToClipboardCopied"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "directive", type: i2.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i8.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i8.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "directive", type: TabGuardDirective, selector: "mat-tab-group[yuvTabGuardDisable]" }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1328
1608
|
}
|
|
1329
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
1609
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: SettingsComponent, decorators: [{
|
|
1330
1610
|
type: Component,
|
|
1331
|
-
args: [{ selector: 'ymusrmgmt-group-details', imports: [
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1611
|
+
args: [{ selector: 'ymusrmgmt-settings', standalone: true, imports: [...modules$2, ...components$2], template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"'yuv.app.user-management.settings.label.dialog.title' | translate\"\n>\n <main class=\"yuv-dialog-content\">\n <ng-template\n #editableField\n let-labelKey=\"labelKey\"\n let-control=\"control\"\n let-type=\"type\"\n let-placeholder=\"placeholder\"\n let-autocomplete=\"autocomplete\"\n >\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ labelKey | translate }}</mat-label>\n <input\n matInput\n [formControl]=\"control\"\n [type]=\"type || 'text'\"\n [placeholder]=\"placeholder || ''\"\n [autocomplete]=\"autocomplete || 'off'\"\n [attr.aria-label]=\"labelKey | translate\"\n />\n </mat-form-field>\n </ng-template>\n <ng-template\n #readonlyField\n let-labelKey=\"labelKey\"\n let-value=\"value\"\n let-type=\"type\"\n let-copyable=\"copyable\"\n >\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{ labelKey | translate }}</mat-label>\n <input\n matInput\n readonly\n [type]=\"type || 'text'\"\n [attr.tabindex]=\"-1\"\n [value]=\"value || '\u2014'\"\n [attr.aria-label]=\"labelKey | translate\"\n />\n @if (copyable && value) {\n <button\n matSuffix\n mat-icon-button\n class=\"copy-button\"\n type=\"button\"\n [matTooltip]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.settings.oidc.action.copy.label' | translate\"\n [cdkCopyToClipboard]=\"value\"\n (cdkCopyToClipboardCopied)=\"onCopied($event)\"\n >\n <mat-icon>content_copy</mat-icon>\n </button>\n }\n </mat-form-field>\n </ng-template>\n\n @if (!showForm()) {\n <div class=\"empty-state\">\n <mat-icon class=\"empty-state-icon\" aria-hidden=\"true\">admin_panel_settings</mat-icon>\n <p class=\"empty-state-title\">\n {{ 'yuv.app.user-management.settings.oidc.label.notConfigured' | translate }}\n </p>\n <p class=\"empty-state-description\">\n {{ 'yuv.app.user-management.settings.oidc.label.setup.description' | translate }}\n </p>\n <button ymtButton=\"primary\" type=\"button\" (click)=\"startAdd()\">\n {{ 'yuv.app.user-management.settings.oidc.action.setup.label' | translate }}\n </button>\n </div>\n } @else {\n <mat-tab-group yuvTabGuardDisable>\n <mat-tab [label]=\"'yuv.app.user-management.settings.oidc.tab.authentication' | translate\">\n <p class=\"hint\">\n {{ 'yuv.app.user-management.settings.oidc.label.setup.hint' | translate }}\n </p>\n <form [formGroup]=\"form\">\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.displayName',\n control: displayName,\n placeholder: 'e.g. Microsoft Entra ID'\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.discoveryUrl',\n control: discoveryUrl,\n placeholder:\n 'e.g. https://login.microsoftonline.com/<entra-tenant-id>/v2.0/.well-known/openid-configuration'\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.clientId',\n control: clientId,\n placeholder: 'e.g. yuuvis'\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n editableField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.clientSecret',\n control: clientSecret,\n type: 'password',\n autocomplete: 'new-password'\n }\n \"\n ></ng-container>\n @if (redirectUri(); as uri) {\n <hr class=\"divider\" />\n <p class=\"hint\">\n {{ 'yuv.app.user-management.settings.oidc.property.redirectUri.hint' | translate }}\n </p>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.property.redirectUri',\n value: uri,\n copyable: true\n }\n \"\n ></ng-container>\n }\n </form>\n </mat-tab>\n <mat-tab\n [label]=\"'yuv.app.user-management.settings.oidc.label.section.provisioning' | translate\"\n >\n @if (scimProvisioning(); as scimProvisioning) { @if (provider(); as oidcProvider) {\n <p class=\"hint\">\n {{\n 'yuv.app.user-management.settings.oidc.label.provisioning.hint'\n | translate : { provider: oidcProvider.displayName }\n }}\n </p>\n }\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.tenantUrl',\n value: tenantUrl(),\n copyable: true\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.tokenUrl',\n value: scimProvisioning.tokenEndpoint,\n copyable: true\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.clientName',\n value: scimProvisioning.clientId,\n copyable: true\n }\n \"\n ></ng-container>\n <ng-container\n *ngTemplateOutlet=\"\n readonlyField;\n context: {\n labelKey: 'yuv.app.user-management.settings.oidc.provisioning.property.clientSecret',\n value: scimProvisioning.clientSecret,\n type: 'password',\n copyable: true\n }\n \"\n ></ng-container>\n } @else {\n <p class=\"hint\">\n {{ 'yuv.app.user-management.settings.oidc.label.provisioning.unavailable' | translate }}\n </p>\n }\n </mat-tab>\n </mat-tab-group>\n } @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n @if (provider()) {\n <!--\n `ymt-button--danger` is added explicitly (not just relying on `ymtButton=\"danger\"`):\n YmtButtonDirective has a bug where its own dynamically-added `ymt-button--danger` class gets\n wiped when it creates the underlying MatButton (which overwrites the host's `class`\n attribute) and never restored, since the directive's `ngOnInit` snapshots \"classes to\n restore\" before adding it. A class already present in the template survives that wipe, so\n this is what actually makes the button render with danger colors. Don't remove it as a\n seeming duplicate.\n -->\n <button\n ymtButton=\"danger\"\n class=\"ymt-button--danger footer-start\"\n [disabled]=\"busy()\"\n (click)=\"revert()\"\n >\n <mat-icon>reset_wrench</mat-icon>\n {{ 'yuv.app.user-management.settings.oidc.action.reset.label' | translate }}\n </button>\n }\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.settings.action.button.cancel' | translate }}\n </button>\n @if (showForm()) {\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"configure()\">\n {{ 'yuv.app.user-management.settings.oidc.action.save.label' | translate }}\n </button>\n }\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}:host main{min-height:var(--ymt-sizing-7xl)}:host main .yuv-form-field input::placeholder{color:var(--ymt-text-color-subtle, inherit);font-size:.9em;opacity:1}:host main .empty-state{display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--ymt-spacing-m);width:100%;padding:var(--ymt-spacing-4xl) var(--ymt-spacing-l);justify-content:center;min-height:var(--ymt-sizing-7xl)}:host main .empty-state .empty-state-icon{width:var(--ymt-sizing-5xl);height:var(--ymt-sizing-5xl);border-radius:var(--ymt-corner-full);background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container);display:flex;align-items:center;justify-content:center;flex-shrink:0}:host main .empty-state .empty-state-title{font:var(--ymt-font-headline-small);color:var(--ymt-text-color);margin:0}:host main .empty-state .empty-state-description{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);max-width:80ch;margin:0}:host main .divider{border:none;border-top:var(--ymt-outline-width) solid var(--ymt-outline-variant);margin:var(--ymt-spacing-m) 0}:host main ::ng-deep .mat-mdc-tab-body-content{padding-top:var(--ymt-spacing-m)}:host footer{display:flex;flex:1;align-items:center;gap:calc(var(--ymt-font-body) / 2)}:host footer .footer-start{margin-inline-end:auto}\n"] }]
|
|
1612
|
+
}], ctorParameters: () => [] });
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Marks an element as one option of a listbox whose keystrokes arrive somewhere else - so CDK's
|
|
1616
|
+
* `ActiveDescendantKeyManager` can move a highlight through them while real DOM focus stays put.
|
|
1617
|
+
*
|
|
1618
|
+
* That is the `aria-activedescendant` pattern, and it's what a combobox needs: the user keeps
|
|
1619
|
+
* typing in the input while Up/Down move the highlighted suggestion. Contrast
|
|
1620
|
+
* `FocusableRowDirective`, which is for lists that take focus onto the row itself.
|
|
1621
|
+
*
|
|
1622
|
+
* The highlight is exposed as `active` (and as an `.active` class on the host), so a caller
|
|
1623
|
+
* declares this directive, binds `aria-selected` from it, and points the input's
|
|
1624
|
+
* `aria-activedescendant` at `activeOptionId`. `aria-selected` is deliberately left to the call
|
|
1625
|
+
* site rather than host-bound here: it keeps the option's ARIA visible in the template where it
|
|
1626
|
+
* gets reviewed - and `@angular-eslint`'s `role-has-required-aria` can only see it there.
|
|
1627
|
+
*/
|
|
1628
|
+
class HighlightableOptionDirective {
|
|
1629
|
+
#elementRef = inject(ElementRef);
|
|
1630
|
+
/** Whether the key manager currently has this option highlighted. */
|
|
1631
|
+
active = signal(false, ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
|
|
1632
|
+
/** This option's element id - what the input's `aria-activedescendant` has to point at. */
|
|
1633
|
+
get elementId() {
|
|
1634
|
+
return this.#elementRef.nativeElement.id;
|
|
1635
|
+
}
|
|
1636
|
+
setActiveStyles() {
|
|
1637
|
+
this.active.set(true);
|
|
1638
|
+
// Nothing scrolls this into view on its own: focus never leaves the input, so the browser has
|
|
1639
|
+
// no reason to. Without it, arrowing past the bottom of the list would highlight options the
|
|
1640
|
+
// user can no longer see - and Enter would add one of them.
|
|
1641
|
+
this.#elementRef.nativeElement.scrollIntoView({ block: 'nearest' });
|
|
1642
|
+
}
|
|
1643
|
+
setInactiveStyles() {
|
|
1644
|
+
this.active.set(false);
|
|
1645
|
+
}
|
|
1646
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: HighlightableOptionDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1647
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.23", type: HighlightableOptionDirective, isStandalone: true, selector: "[ymusrmgmt-highlightable-option]", host: { properties: { "class.active": "active()" } }, exportAs: ["ymusrmgmtHighlightableOption"], ngImport: i0 });
|
|
1648
|
+
}
|
|
1649
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: HighlightableOptionDirective, decorators: [{
|
|
1650
|
+
type: Directive,
|
|
1651
|
+
args: [{
|
|
1652
|
+
selector: '[ymusrmgmt-highlightable-option]',
|
|
1653
|
+
standalone: true,
|
|
1654
|
+
exportAs: 'ymusrmgmtHighlightableOption',
|
|
1655
|
+
host: {
|
|
1656
|
+
'[class.active]': 'active()',
|
|
1657
|
+
},
|
|
1658
|
+
}]
|
|
1659
|
+
}] });
|
|
1660
|
+
|
|
1661
|
+
const components$1 = [
|
|
1662
|
+
BusyOverlayDirective,
|
|
1663
|
+
YmtButtonDirective,
|
|
1664
|
+
DialogComponent,
|
|
1665
|
+
YmtBadge,
|
|
1666
|
+
UserAvatarComponent,
|
|
1667
|
+
HighlightableOptionDirective,
|
|
1668
|
+
];
|
|
1669
|
+
const modules$1 = [CommonModule, ReactiveFormsModule, TranslatePipe, MatIconModule, MatTooltipModule];
|
|
1670
|
+
class AddGroupMembersComponent {
|
|
1671
|
+
data;
|
|
1672
|
+
/** Shared `MatDialog.open()` config, so every call site opening this dialog stays in sync. */
|
|
1673
|
+
static DIALOG_CONFIG = {
|
|
1674
|
+
width: '800px',
|
|
1675
|
+
maxWidth: '90vw',
|
|
1676
|
+
height: '65vh',
|
|
1677
|
+
};
|
|
1678
|
+
#userManagementService = inject(YuvUserManagementService);
|
|
1679
|
+
#dialogRef = inject((MatDialogRef));
|
|
1680
|
+
#logger = inject(Logger);
|
|
1681
|
+
#injector = inject(Injector);
|
|
1682
|
+
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1683
|
+
searchControl = new FormControl('');
|
|
1684
|
+
/** Every current member's id, so search results can exclude users already in the group. */
|
|
1685
|
+
currentMemberIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "currentMemberIds" }] : /* istanbul ignore next */ []));
|
|
1686
|
+
/** The term the current searchResults() were fetched for - lets the empty-state message tell
|
|
1687
|
+
* "no term entered yet" apart from "this term matched nothing". */
|
|
1688
|
+
searchTerm = signal('', ...(ngDevMode ? [{ debugName: "searchTerm" }] : /* istanbul ignore next */ []));
|
|
1689
|
+
searchResults = signal([], ...(ngDevMode ? [{ debugName: "searchResults" }] : /* istanbul ignore next */ []));
|
|
1690
|
+
/** Accumulates picks across searches (dedup by id) - selecting a user moves them out of the
|
|
1691
|
+
* search results and into this list, shown as removable rows in the right-hand column. */
|
|
1692
|
+
selectedUsers = signal([], ...(ngDevMode ? [{ debugName: "selectedUsers" }] : /* istanbul ignore next */ []));
|
|
1693
|
+
candidateResults = computed(() => {
|
|
1694
|
+
const selectedIds = new Set(this.selectedUsers().map((u) => u.id));
|
|
1695
|
+
return this.searchResults().filter((u) => !this.currentMemberIds().has(u.id) && !selectedIds.has(u.id));
|
|
1696
|
+
}, ...(ngDevMode ? [{ debugName: "candidateResults" }] : /* istanbul ignore next */ []));
|
|
1697
|
+
/** One per rendered candidate row, in `candidateResults()` order. */
|
|
1698
|
+
candidateOptions = viewChildren(HighlightableOptionDirective, ...(ngDevMode ? [{ debugName: "candidateOptions" }] : /* istanbul ignore next */ []));
|
|
1699
|
+
/** Moves the highlighted candidate while focus stays in the search input - the
|
|
1700
|
+
* `aria-activedescendant` pattern, see `HighlightableOptionDirective`. Built from the
|
|
1701
|
+
* `viewChildren` signal directly, so it follows the candidates as the search results change;
|
|
1702
|
+
* that's what the `Injector` is for. */
|
|
1703
|
+
#keyManager = new ActiveDescendantKeyManager(this.candidateOptions, this.#injector);
|
|
1704
|
+
/** The highlighted option's element id, for the input's `aria-activedescendant`. Derived from the
|
|
1705
|
+
* options themselves rather than tracked alongside the key manager, so the two can't disagree. */
|
|
1706
|
+
activeOptionId = computed(() => this.candidateOptions().find((option) => option.active())?.elementId, ...(ngDevMode ? [{ debugName: "activeOptionId" }] : /* istanbul ignore next */ []));
|
|
1707
|
+
disabledSubmit = computed(() => this.busy() || this.selectedUsers().length === 0, ...(ngDevMode ? [{ debugName: "disabledSubmit" }] : /* istanbul ignore next */ []));
|
|
1708
|
+
constructor(data) {
|
|
1709
|
+
this.data = data;
|
|
1710
|
+
// Highlight the first candidate whenever the rendered set changes - a new search, or one
|
|
1711
|
+
// fewer row because the user just picked it. Keyed on the rendered options rather than on
|
|
1712
|
+
// `candidateResults()` so the key manager only ever sees rows that exist; arrow navigation
|
|
1713
|
+
// doesn't change them, so it never clobbers the user's position.
|
|
1714
|
+
effect(() => {
|
|
1715
|
+
if (this.candidateOptions().length > 0) {
|
|
1716
|
+
this.#keyManager.setActiveItem(0);
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
// Best-effort: used only to filter existing members out of search results - if this fails,
|
|
1720
|
+
// just don't filter (a redundant add is a harmless no-op), so only log, don't block.
|
|
1721
|
+
aggregateAllPages((page) => this.#userManagementService.fetchGroupMembers(this.data.group.id, page, YuvUserManagementService.AGGREGATE_PAGE_SIZE)).subscribe({
|
|
1722
|
+
next: (members) => this.currentMemberIds.set(new Set(members.map((m) => m.id))),
|
|
1723
|
+
error: (e) => this.#logger.error(`Failed to load members of group '${this.data.group.id}'`, e),
|
|
1724
|
+
});
|
|
1725
|
+
this.searchControl.valueChanges
|
|
1726
|
+
.pipe(debounceTime(500), distinctUntilChanged(),
|
|
1727
|
+
// After the debounce, so the initial (empty-term) list is requested immediately on open
|
|
1728
|
+
// rather than 500ms later - only the user's own typing should be debounced.
|
|
1729
|
+
startWith(''), switchMap((term) => this.#userManagementService
|
|
1730
|
+
.fetchUsers(0, term || '')
|
|
1731
|
+
.pipe(map((response) => ({ term: term || '', response })))))
|
|
1732
|
+
.subscribe({
|
|
1733
|
+
next: ({ term, response }) => {
|
|
1734
|
+
this.searchTerm.set(term);
|
|
1735
|
+
this.searchResults.set(response.objects);
|
|
1736
|
+
},
|
|
1737
|
+
error: (e) => this.#logger.error('Failed to search users', e),
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
select(user) {
|
|
1741
|
+
this.selectedUsers.update((current) => current.some((u) => u.id === user.id) ? current : [...current, user]);
|
|
1742
|
+
}
|
|
1743
|
+
deselect(user) {
|
|
1744
|
+
this.selectedUsers.update((current) => current.filter((u) => u.id !== user.id));
|
|
1745
|
+
}
|
|
1746
|
+
/** Arrow keys move the highlighted candidate, Enter picks it - so matches can be added without
|
|
1747
|
+
* ever leaving the search input.
|
|
1748
|
+
*
|
|
1749
|
+
* Only Enter is handled here; everything else goes to the key manager, which owns the bounds
|
|
1750
|
+
* clamping and leaves the keys a text input needs alone. Home/End are deliberately *not* wired
|
|
1751
|
+
* to it (`withHomeAndEnd()`): in a combobox those belong to the caret, not to the list. */
|
|
1752
|
+
onSearchKeydown(event) {
|
|
1753
|
+
if (event.key === 'Enter') {
|
|
1754
|
+
event.preventDefault();
|
|
1755
|
+
const index = this.#keyManager.activeItemIndex;
|
|
1756
|
+
const user = index === null ? undefined : this.candidateResults()[index];
|
|
1757
|
+
if (user)
|
|
1758
|
+
this.select(user);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
this.#keyManager.onKeydown(event);
|
|
1762
|
+
}
|
|
1763
|
+
addMembers() {
|
|
1764
|
+
const users = this.selectedUsers();
|
|
1765
|
+
if (users.length === 0)
|
|
1766
|
+
return;
|
|
1767
|
+
this.busy.set(true);
|
|
1768
|
+
forkJoin(users.map((user) => this.#userManagementService.addGroupMember(this.data.group, user.id).pipe(map(() => ({ success: true })), catchError((e) => {
|
|
1769
|
+
this.#logger.error(`Failed to add user '${user.id}' to group '${this.data.group.id}'`, e);
|
|
1770
|
+
return of({ success: false });
|
|
1771
|
+
})))).subscribe((results) => {
|
|
1772
|
+
this.busy.set(false);
|
|
1773
|
+
this.#dialogRef.close({
|
|
1774
|
+
addedCount: results.filter((r) => r.success).length,
|
|
1775
|
+
failedCount: results.filter((r) => !r.success).length,
|
|
1776
|
+
});
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
close() {
|
|
1780
|
+
this.#dialogRef.close();
|
|
1781
|
+
}
|
|
1782
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: AddGroupMembersComponent, deps: [{ token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component });
|
|
1783
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: AddGroupMembersComponent, isStandalone: true, selector: "ymusrmgmt-add-group-members", viewQueries: [{ propertyName: "candidateOptions", predicate: HighlightableOptionDirective, descendants: true, isSignal: true }], ngImport: i0, template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"\n 'yuv.app.user-management.groups.action.addMembers.dialog.title'\n | translate : { name: data.group.name }\n \"\n>\n <!-- Shared avatar + name/username anatomy for both the search-result and selected-users rows below. -->\n <ng-template #userInfo let-user>\n <ymusrmgmt-user-avatar [user]=\"user\" />\n <span class=\"names\">\n <span class=\"username\">{{ user.username }}</span>\n <span class=\"name-row\">\n <span class=\"name\">{{ user.firstName }} {{ user.lastName }}</span>\n </span>\n </span>\n </ng-template>\n\n <main class=\"yuv-dialog-content\">\n <div class=\"search-column\">\n <div class=\"search-row\">\n <mat-icon aria-hidden=\"true\" class=\"icon\">search</mat-icon>\n <input\n class=\"input\"\n type=\"text\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-expanded=\"true\"\n aria-controls=\"search-result-list\"\n [attr.aria-activedescendant]=\"activeOptionId() || null\"\n [formControl]=\"searchControl\"\n (keydown)=\"onSearchKeydown($event)\"\n [placeholder]=\"\n 'yuv.app.user-management.groups.action.addMembers.search.placeholder' | translate\n \"\n [attr.aria-label]=\"\n 'yuv.app.user-management.groups.action.addMembers.search.placeholder' | translate\n \"\n />\n </div>\n\n <div class=\"search-result\" id=\"search-result-list\" role=\"listbox\" tabindex=\"-1\">\n @for (user of candidateResults(); track user.id) {\n <button\n ymusrmgmt-highlightable-option\n #option=\"ymusrmgmtHighlightableOption\"\n [attr.aria-selected]=\"option.active()\"\n type=\"button\"\n id=\"add-group-members-option-{{ user.id }}\"\n role=\"option\"\n tabindex=\"-1\"\n class=\"user\"\n [class.user--inactive]=\"!user.enabled\"\n (click)=\"select(user)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"userInfo\"\n [ngTemplateOutletContext]=\"{ $implicit: user }\"\n />\n </button>\n } @empty {\n <div class=\"empty-message\">\n {{\n (searchTerm()\n ? 'yuv.app.user-management.groups.action.addMembers.empty.noResults'\n : 'yuv.app.user-management.groups.action.addMembers.empty.allAdded'\n ) | translate\n }}\n </div>\n }\n </div>\n </div>\n\n <div class=\"selected-users\">\n @for (user of selectedUsers(); track user.id) {\n <div class=\"user\" [class.user--inactive]=\"!user.enabled\">\n <ng-container\n [ngTemplateOutlet]=\"userInfo\"\n [ngTemplateOutletContext]=\"{ $implicit: user }\"\n />\n <button\n type=\"button\"\n class=\"chip-remove\"\n (click)=\"deselect(user)\"\n [matTooltip]=\"\n 'yuv.app.user-management.groups.action.addMembers.removeSelected.tooltip' | translate\n \"\n [attr.aria-label]=\"\n 'yuv.app.user-management.groups.action.addMembers.removeSelected.tooltip' | translate\n \"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n } @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.groups.action.addMembers.empty.noneSelected' | translate }}\n </div>\n }\n </div>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.groups.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"addMembers()\">\n {{ 'yuv.app.user-management.groups.action.addMembers.button.add' | translate }}\n @if (selectedUsers().length > 0) {\n <ymt-badge class=\"count\" severity=\"neutral\">{{ selectedUsers().length }}</ymt-badge>\n }\n </button>\n </footer>\n</yuv-dialog>\n", styles: ["ymt-badge{--ymt-badge-corner: var(--ymt-corner-xs);--badge-icon-size: var(--ymt-sizing-xs);display:inline-flex;align-items:center;gap:var(--ymt-spacing-xs);min-width:0;max-width:100%}ymt-badge mat-icon{flex-shrink:0;font-size:var(--badge-icon-size);width:var(--badge-icon-size);height:var(--badge-icon-size)}ymt-badge .badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}yuv-dialog{flex:1;min-height:0}:host{box-sizing:border-box;--ymt-dialog-content-display: flex}:host ::ng-deep mat-dialog-content{max-height:90vh}:host main{display:flex;flex-direction:row;height:100%;overflow:hidden;background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m)}:host main .user{display:flex;align-items:center;gap:var(--ymt-spacing-s);width:100%;font:inherit;color:var(--ymt-text-color)}:host main .user .names{display:flex;flex:1;flex-direction:column;gap:var(--ymt-spacing-4xs);min-width:0}:host main .user .name-row{display:flex;align-items:center;gap:var(--ymt-spacing-2xs);min-width:0}:host main .user .name{font:var(--ymt-font-title-smallest);color:var(--ymt-text-color);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host main .user .username{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}:host main .user.user--inactive .name,:host main .user.user--inactive .username{opacity:.6}:host main .search-column{display:flex;flex-direction:column;flex:1 1 0;min-width:0}:host main .search-column .search-row{position:relative;flex-shrink:0}:host main .search-column .search-row .icon{position:absolute;left:var(--ymt-spacing-s);top:50%;transform:translateY(-50%);pointer-events:none;color:var(--ymt-text-color-subtle);font-size:var(--ymt-sizing-s);width:var(--ymt-sizing-s);height:var(--ymt-sizing-s)}:host main .search-column .search-row .input{display:block;width:100%;border:none;outline:none;background:none;font:inherit;color:var(--ymt-text-color);padding:var(--ymt-spacing-s) var(--ymt-spacing-s) var(--ymt-spacing-s) calc(var(--ymt-sizing-s) + 2 * var(--ymt-spacing-s))}:host main .search-column .search-result{display:flex;flex-direction:column;flex:1;min-height:0;overflow:auto;border-top:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:0 0 0 var(--ymt-corner-s)}:host main .search-column .search-result .user{text-align:left;padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);background:none;border:none;border-bottom:var(--ymt-outline-width) solid var(--ymt-outline-variant);cursor:pointer}:host main .search-column .search-result .user:last-child{border-bottom:none}:host main .search-column .search-result .user:hover{background-color:var(--ymt-hover-background)}:host main .search-column .search-result .user.active{background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container)}:host main .search-column .search-result .user.active .name,:host main .search-column .search-result .user.active .username{color:var(--ymt-on-primary-container)}:host main .selected-users{display:flex;flex-direction:column;flex:1 1 0;min-width:0;overflow-y:auto;padding:var(--ymt-spacing-s);border-left:var(--ymt-outline-width) solid var(--ymt-outline-variant)}:host main .selected-users .user{padding:var(--ymt-spacing-xs) 0;border-bottom:var(--ymt-outline-width) solid var(--ymt-outline-variant)}:host main .selected-users .user:last-child{border-bottom:none}:host main .selected-users .chip-remove{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--ymt-sizing-s);height:var(--ymt-sizing-s);padding:0;border:none;border-radius:var(--ymt-corner-full);background:transparent;color:var(--ymt-text-color-subtle);cursor:pointer}:host main .selected-users .chip-remove:hover{background-color:var(--ymt-hover-background)}:host main .selected-users .chip-remove mat-icon{font-size:var(--ymt-sizing-xs);width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs)}:host main .empty-message{padding:var(--ymt-spacing-m);text-align:center;font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$4.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "component", type: YmtBadge, selector: "ymt-badge", inputs: ["severity", "outline", "aura"] }, { kind: "component", type: UserAvatarComponent, selector: "ymusrmgmt-user-avatar", inputs: ["user", "showBadges"] }, { kind: "directive", type: HighlightableOptionDirective, selector: "[ymusrmgmt-highlightable-option]", exportAs: ["ymusrmgmtHighlightableOption"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1784
|
+
}
|
|
1785
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: AddGroupMembersComponent, decorators: [{
|
|
1786
|
+
type: Component,
|
|
1787
|
+
args: [{ selector: 'ymusrmgmt-add-group-members', standalone: true, imports: [...modules$1, ...components$1], template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"\n 'yuv.app.user-management.groups.action.addMembers.dialog.title'\n | translate : { name: data.group.name }\n \"\n>\n <!-- Shared avatar + name/username anatomy for both the search-result and selected-users rows below. -->\n <ng-template #userInfo let-user>\n <ymusrmgmt-user-avatar [user]=\"user\" />\n <span class=\"names\">\n <span class=\"username\">{{ user.username }}</span>\n <span class=\"name-row\">\n <span class=\"name\">{{ user.firstName }} {{ user.lastName }}</span>\n </span>\n </span>\n </ng-template>\n\n <main class=\"yuv-dialog-content\">\n <div class=\"search-column\">\n <div class=\"search-row\">\n <mat-icon aria-hidden=\"true\" class=\"icon\">search</mat-icon>\n <input\n class=\"input\"\n type=\"text\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-expanded=\"true\"\n aria-controls=\"search-result-list\"\n [attr.aria-activedescendant]=\"activeOptionId() || null\"\n [formControl]=\"searchControl\"\n (keydown)=\"onSearchKeydown($event)\"\n [placeholder]=\"\n 'yuv.app.user-management.groups.action.addMembers.search.placeholder' | translate\n \"\n [attr.aria-label]=\"\n 'yuv.app.user-management.groups.action.addMembers.search.placeholder' | translate\n \"\n />\n </div>\n\n <div class=\"search-result\" id=\"search-result-list\" role=\"listbox\" tabindex=\"-1\">\n @for (user of candidateResults(); track user.id) {\n <button\n ymusrmgmt-highlightable-option\n #option=\"ymusrmgmtHighlightableOption\"\n [attr.aria-selected]=\"option.active()\"\n type=\"button\"\n id=\"add-group-members-option-{{ user.id }}\"\n role=\"option\"\n tabindex=\"-1\"\n class=\"user\"\n [class.user--inactive]=\"!user.enabled\"\n (click)=\"select(user)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"userInfo\"\n [ngTemplateOutletContext]=\"{ $implicit: user }\"\n />\n </button>\n } @empty {\n <div class=\"empty-message\">\n {{\n (searchTerm()\n ? 'yuv.app.user-management.groups.action.addMembers.empty.noResults'\n : 'yuv.app.user-management.groups.action.addMembers.empty.allAdded'\n ) | translate\n }}\n </div>\n }\n </div>\n </div>\n\n <div class=\"selected-users\">\n @for (user of selectedUsers(); track user.id) {\n <div class=\"user\" [class.user--inactive]=\"!user.enabled\">\n <ng-container\n [ngTemplateOutlet]=\"userInfo\"\n [ngTemplateOutletContext]=\"{ $implicit: user }\"\n />\n <button\n type=\"button\"\n class=\"chip-remove\"\n (click)=\"deselect(user)\"\n [matTooltip]=\"\n 'yuv.app.user-management.groups.action.addMembers.removeSelected.tooltip' | translate\n \"\n [attr.aria-label]=\"\n 'yuv.app.user-management.groups.action.addMembers.removeSelected.tooltip' | translate\n \"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n } @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.groups.action.addMembers.empty.noneSelected' | translate }}\n </div>\n }\n </div>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.groups.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"addMembers()\">\n {{ 'yuv.app.user-management.groups.action.addMembers.button.add' | translate }}\n @if (selectedUsers().length > 0) {\n <ymt-badge class=\"count\" severity=\"neutral\">{{ selectedUsers().length }}</ymt-badge>\n }\n </button>\n </footer>\n</yuv-dialog>\n", styles: ["ymt-badge{--ymt-badge-corner: var(--ymt-corner-xs);--badge-icon-size: var(--ymt-sizing-xs);display:inline-flex;align-items:center;gap:var(--ymt-spacing-xs);min-width:0;max-width:100%}ymt-badge mat-icon{flex-shrink:0;font-size:var(--badge-icon-size);width:var(--badge-icon-size);height:var(--badge-icon-size)}ymt-badge .badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}yuv-dialog{flex:1;min-height:0}:host{box-sizing:border-box;--ymt-dialog-content-display: flex}:host ::ng-deep mat-dialog-content{max-height:90vh}:host main{display:flex;flex-direction:row;height:100%;overflow:hidden;background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m)}:host main .user{display:flex;align-items:center;gap:var(--ymt-spacing-s);width:100%;font:inherit;color:var(--ymt-text-color)}:host main .user .names{display:flex;flex:1;flex-direction:column;gap:var(--ymt-spacing-4xs);min-width:0}:host main .user .name-row{display:flex;align-items:center;gap:var(--ymt-spacing-2xs);min-width:0}:host main .user .name{font:var(--ymt-font-title-smallest);color:var(--ymt-text-color);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host main .user .username{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}:host main .user.user--inactive .name,:host main .user.user--inactive .username{opacity:.6}:host main .search-column{display:flex;flex-direction:column;flex:1 1 0;min-width:0}:host main .search-column .search-row{position:relative;flex-shrink:0}:host main .search-column .search-row .icon{position:absolute;left:var(--ymt-spacing-s);top:50%;transform:translateY(-50%);pointer-events:none;color:var(--ymt-text-color-subtle);font-size:var(--ymt-sizing-s);width:var(--ymt-sizing-s);height:var(--ymt-sizing-s)}:host main .search-column .search-row .input{display:block;width:100%;border:none;outline:none;background:none;font:inherit;color:var(--ymt-text-color);padding:var(--ymt-spacing-s) var(--ymt-spacing-s) var(--ymt-spacing-s) calc(var(--ymt-sizing-s) + 2 * var(--ymt-spacing-s))}:host main .search-column .search-result{display:flex;flex-direction:column;flex:1;min-height:0;overflow:auto;border-top:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:0 0 0 var(--ymt-corner-s)}:host main .search-column .search-result .user{text-align:left;padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);background:none;border:none;border-bottom:var(--ymt-outline-width) solid var(--ymt-outline-variant);cursor:pointer}:host main .search-column .search-result .user:last-child{border-bottom:none}:host main .search-column .search-result .user:hover{background-color:var(--ymt-hover-background)}:host main .search-column .search-result .user.active{background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container)}:host main .search-column .search-result .user.active .name,:host main .search-column .search-result .user.active .username{color:var(--ymt-on-primary-container)}:host main .selected-users{display:flex;flex-direction:column;flex:1 1 0;min-width:0;overflow-y:auto;padding:var(--ymt-spacing-s);border-left:var(--ymt-outline-width) solid var(--ymt-outline-variant)}:host main .selected-users .user{padding:var(--ymt-spacing-xs) 0;border-bottom:var(--ymt-outline-width) solid var(--ymt-outline-variant)}:host main .selected-users .user:last-child{border-bottom:none}:host main .selected-users .chip-remove{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--ymt-sizing-s);height:var(--ymt-sizing-s);padding:0;border:none;border-radius:var(--ymt-corner-full);background:transparent;color:var(--ymt-text-color-subtle);cursor:pointer}:host main .selected-users .chip-remove:hover{background-color:var(--ymt-hover-background)}:host main .selected-users .chip-remove mat-icon{font-size:var(--ymt-sizing-xs);width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs)}:host main .empty-message{padding:var(--ymt-spacing-m);text-align:center;font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}\n"] }]
|
|
1788
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
1789
|
+
type: Inject,
|
|
1790
|
+
args: [MAT_DIALOG_DATA]
|
|
1791
|
+
}] }], propDecorators: { candidateOptions: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => HighlightableOptionDirective), { isSignal: true }] }] } });
|
|
1350
1792
|
|
|
1351
|
-
const components = [
|
|
1793
|
+
const components = [
|
|
1794
|
+
BusyOverlayDirective,
|
|
1795
|
+
YmtButtonDirective,
|
|
1796
|
+
DialogComponent,
|
|
1797
|
+
RolesSelectionComponent,
|
|
1798
|
+
];
|
|
1352
1799
|
const modules = [
|
|
1353
1800
|
CommonModule,
|
|
1354
1801
|
ReactiveFormsModule,
|
|
1355
1802
|
TranslatePipe,
|
|
1356
1803
|
MatFormFieldModule,
|
|
1357
1804
|
MatInputModule,
|
|
1358
|
-
MatChipsModule,
|
|
1359
1805
|
];
|
|
1360
|
-
class
|
|
1806
|
+
class EditUserComponent {
|
|
1361
1807
|
data;
|
|
1808
|
+
/** Shared `MatDialog.open()` config, so every call site opening this dialog stays in sync. */
|
|
1809
|
+
static DIALOG_CONFIG = {
|
|
1810
|
+
width: '600px',
|
|
1811
|
+
maxWidth: '90vw',
|
|
1812
|
+
};
|
|
1362
1813
|
#fb = inject(FormBuilder);
|
|
1363
1814
|
#userManagementService = inject(YuvUserManagementService);
|
|
1364
1815
|
#dialogRef = inject((MatDialogRef));
|
|
@@ -1367,17 +1818,28 @@ class EditGroupComponent {
|
|
|
1367
1818
|
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1368
1819
|
error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1369
1820
|
allAvailableRoles = signal([], ...(ngDevMode ? [{ debugName: "allAvailableRoles" }] : /* istanbul ignore next */ []));
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1821
|
+
userRoles = signal([], ...(ngDevMode ? [{ debugName: "userRoles" }] : /* istanbul ignore next */ []));
|
|
1822
|
+
/** Externally managed users (e.g. provisioned via SCIM) may only have roles assigned; their personal data is owned by the external IdP. */
|
|
1823
|
+
externallyManaged = signal(false, ...(ngDevMode ? [{ debugName: "externallyManaged" }] : /* istanbul ignore next */ []));
|
|
1824
|
+
username = new FormControl('');
|
|
1825
|
+
email = new FormControl('', [Validators.required, Validators.email]);
|
|
1826
|
+
firstName = new FormControl('', [
|
|
1827
|
+
Validators.minLength(1),
|
|
1828
|
+
Validators.maxLength(255),
|
|
1829
|
+
Validators.pattern('^[^#!<>&/%=;*§$()[\\]{}]*$'),
|
|
1830
|
+
]);
|
|
1831
|
+
lastName = new FormControl('', [
|
|
1832
|
+
Validators.minLength(1),
|
|
1833
|
+
Validators.maxLength(255),
|
|
1834
|
+
Validators.pattern('^[^#!<>&/%=;*§$()[\\]{}]*$'),
|
|
1835
|
+
]);
|
|
1374
1836
|
roles = new FormControl([]);
|
|
1375
|
-
members = new FormControl([]);
|
|
1376
|
-
userControl = new FormControl('');
|
|
1377
1837
|
form = this.#fb.group({
|
|
1378
|
-
|
|
1838
|
+
username: this.username,
|
|
1839
|
+
email: this.email,
|
|
1840
|
+
firstName: this.firstName,
|
|
1841
|
+
lastName: this.lastName,
|
|
1379
1842
|
roles: this.roles,
|
|
1380
|
-
members: this.members
|
|
1381
1843
|
});
|
|
1382
1844
|
changedInput = signal(undefined, ...(ngDevMode ? [{ debugName: "changedInput" }] : /* istanbul ignore next */ []));
|
|
1383
1845
|
disabledSubmit = linkedSignal({ ...(ngDevMode ? { debugName: "disabledSubmit" } : /* istanbul ignore next */ {}), source: this.changedInput,
|
|
@@ -1387,22 +1849,27 @@ class EditGroupComponent {
|
|
|
1387
1849
|
constructor(data) {
|
|
1388
1850
|
this.data = data;
|
|
1389
1851
|
this.form.patchValue({
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1852
|
+
username: this.data.user.username,
|
|
1853
|
+
firstName: this.data.user.firstName,
|
|
1854
|
+
lastName: this.data.user.lastName,
|
|
1855
|
+
email: this.data.user.email,
|
|
1856
|
+
roles: this.data.user.roles,
|
|
1393
1857
|
});
|
|
1394
|
-
|
|
1395
|
-
this.
|
|
1396
|
-
|
|
1397
|
-
|
|
1858
|
+
// Initialize userRoles from the data
|
|
1859
|
+
this.userRoles.set(this.data.user.roles || []);
|
|
1860
|
+
// Externally managed users may only have roles assigned: their personal data fields are rendered
|
|
1861
|
+
// read-only in the template (same mechanism as the username field) so they keep the identical look.
|
|
1862
|
+
if (this.data.user.externallyManaged) {
|
|
1863
|
+
this.externallyManaged.set(true);
|
|
1864
|
+
}
|
|
1398
1865
|
// Fetch all available roles
|
|
1399
1866
|
this.#userManagementService.fetchAllRoles().subscribe({
|
|
1400
1867
|
next: (roles) => {
|
|
1401
|
-
this.allAvailableRoles.set(roles
|
|
1868
|
+
this.allAvailableRoles.set(roles);
|
|
1402
1869
|
},
|
|
1403
1870
|
error: (e) => {
|
|
1404
1871
|
this.#logger.error('Failed to fetch roles', e);
|
|
1405
|
-
}
|
|
1872
|
+
},
|
|
1406
1873
|
});
|
|
1407
1874
|
}
|
|
1408
1875
|
ngAfterViewInit() {
|
|
@@ -1415,271 +1882,804 @@ class EditGroupComponent {
|
|
|
1415
1882
|
return !isDataUnchanged;
|
|
1416
1883
|
}
|
|
1417
1884
|
isEquals(key) {
|
|
1418
|
-
const oldVal = this.data.
|
|
1885
|
+
const oldVal = this.data.user?.[key];
|
|
1419
1886
|
const newVal = this.form.get(key)?.value;
|
|
1420
1887
|
return oldVal === newVal;
|
|
1421
1888
|
}
|
|
1422
1889
|
onRolesChange(roles) {
|
|
1423
|
-
this.
|
|
1890
|
+
this.userRoles.set(roles);
|
|
1424
1891
|
this.form.patchValue({ roles: roles });
|
|
1425
1892
|
this.roles.markAsTouched(); // Mark as touched to trigger validation display
|
|
1426
1893
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
}
|
|
1432
|
-
updateGroup() {
|
|
1894
|
+
updateUser() {
|
|
1895
|
+
// For externally managed users the personal data fields are read-only, so their original values
|
|
1896
|
+
// are preserved here (the backend replaces the representation from the request, so omitting them
|
|
1897
|
+
// would wipe them).
|
|
1433
1898
|
const editParams = this.form.getRawValue();
|
|
1434
1899
|
this.busy.set(true);
|
|
1435
1900
|
this.error.set(undefined);
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1901
|
+
const updateUserRequest = {
|
|
1902
|
+
email: editParams.email,
|
|
1903
|
+
firstName: editParams.firstName ? editParams.firstName : '',
|
|
1904
|
+
lastName: editParams.lastName ? editParams.lastName : '',
|
|
1438
1905
|
roles: editParams.roles,
|
|
1439
|
-
members: editParams.members
|
|
1440
1906
|
};
|
|
1441
|
-
this.#userManagementService
|
|
1442
|
-
.updateGroup(this.data.group.id, updateGroupRequest)
|
|
1443
|
-
.subscribe({
|
|
1907
|
+
this.#userManagementService.updateUser(this.data.user.id, updateUserRequest).subscribe({
|
|
1444
1908
|
error: (e) => {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
}
|
|
1449
|
-
this.#logger.error(`Failed to update group with id ${this.data.group.id}`, e);
|
|
1909
|
+
const errorKey = resolveErrorMessageKey('yuv.app.user-management.users.action.edit', e.status);
|
|
1910
|
+
this.error.set(this.#translateService.instant(errorKey));
|
|
1911
|
+
this.#logger.error(`Failed to update user with id ${this.data.user.id}`, e);
|
|
1450
1912
|
this.busy.set(false);
|
|
1451
1913
|
},
|
|
1452
1914
|
complete: () => {
|
|
1453
1915
|
this.busy.set(false);
|
|
1454
|
-
this.#dialogRef.close(this.data.
|
|
1916
|
+
this.#dialogRef.close(this.data.user.id);
|
|
1455
1917
|
},
|
|
1456
1918
|
});
|
|
1457
1919
|
}
|
|
1458
1920
|
close() {
|
|
1459
1921
|
this.#dialogRef.close();
|
|
1460
1922
|
}
|
|
1461
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.
|
|
1462
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.
|
|
1923
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: EditUserComponent, deps: [{ token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component });
|
|
1924
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: EditUserComponent, isStandalone: true, selector: "ymusrmgmt-edit-user", ngImport: i0, template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"\n 'yuv.app.user-management.users.action.edit.dialog.title'\n | translate : { username: data.user.username }\n \"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n @if (externallyManaged()) {\n <p class=\"hint\">\n {{ 'yuv.app.user-management.users.action.edit.identitySource.external.hint' | translate }}\n </p>\n }\n <mat-form-field class=\"yuv-form-field readonly-field\">\n <mat-label class=\"readonly-label\">{{\n 'yuv.app.user-management.users.property.username' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"username\"\n type=\"text\"\n [attr.tabindex]=\"-1\"\n readonly\n [attr.aria-label]=\"'yuv.app.user-management.users.property.username' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\n 'yuv.app.user-management.users.property.email' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"email\"\n type=\"text\"\n #emailField\n [readonly]=\"externallyManaged()\"\n [attr.tabindex]=\"externallyManaged() ? -1 : null\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.email' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\n 'yuv.app.user-management.users.property.firstname' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"firstName\"\n type=\"text\"\n [readonly]=\"externallyManaged()\"\n [attr.tabindex]=\"externallyManaged() ? -1 : null\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.firstname' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\n 'yuv.app.user-management.users.property.lastname' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"lastName\"\n type=\"text\"\n [readonly]=\"externallyManaged()\"\n [attr.tabindex]=\"externallyManaged() ? -1 : null\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.lastname' | translate\"\n />\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"userRoles()\"\n (rolesChange)=\"onRolesChange($event)\"\n >\n </ymusrmgmt-role-selection>\n </div>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.users.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"updateUser()\">\n {{ 'yuv.app.user-management.users.action.button.save' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}:host main .readonly-field{opacity:.5;pointer-events:none}:host main .readonly-label{pointer-events:none}:host main .roles-section{margin:var(--ymt-spacing-m) 0}:host main .roles-section.has-error ymusrmgmt-role-selection{border:var(--ymt-focus-indicator-size) solid var(--ymt-danger);border-radius:var(--ymt-corner-xs);display:block}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "component", type: RolesSelectionComponent, selector: "ymusrmgmt-role-selection", inputs: ["allRoles", "initialRoles"], outputs: ["rolesChange"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1463
1925
|
}
|
|
1464
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
1926
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: EditUserComponent, decorators: [{
|
|
1465
1927
|
type: Component,
|
|
1466
|
-
args: [{ selector: 'ymusrmgmt-edit-
|
|
1467
|
-
'[class.busy]': '!busy()',
|
|
1468
|
-
}, template: "<yuv-dialog [yuvBusyOverlay]=\"busy()\" [headertitel]=\"'yuv.app.user-management.groups.action.edit.label' | translate\">\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n <mat-form-field class=\"yuv-form-field readonly-field\">\n <mat-label class=\"readonly-label\">{{\"yuv.app.user-management.groups.object.property.name\" | translate }}</mat-label>\n <input matInput [formControl]=\"name\" type=\"text\" [attr.tabindex]=\"-1\" readonly/>\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"groupRoles()\"\n (rolesChange)=\"onRolesChange($event)\">\n </ymusrmgmt-role-selection>\n </div>\n <mat-form-field class=\"yuv-form-field\">\n <mat-label>{{'yuv.app.user-management.groups.object.property.members' | translate}}</mat-label>\n <mat-chip-grid #memberChipGrid>\n @for (member of groupMembers(); track member) {\n <mat-chip-row [editable]=\"false\"><div class=\"chip\">{{member}}</div></mat-chip-row>\n }\n <input [matChipInputFor]=\"memberChipGrid\" readonly/>\n </mat-chip-grid>\n </mat-form-field>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </form>\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ \"yuv.app.user-management.users.action.button.cancel\" | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"updateGroup()\">\n {{ 'yuv.app.user-management.users.action.button.save' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main form{padding:var(--ymt-spacing-m)}:host main form .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main form .readonly-field{opacity:.5;pointer-events:none}:host main form .readonly-label{pointer-events:none}:host main form .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs)}:host main form .roles-section{margin:var(--ymt-spacing-m) 0}:host main form .roles-section .roles-label{display:block;font-size:12px;font-weight:400;color:#0009;margin-bottom:var(--ymt-spacing-xs)}:host main form .roles-section.has-error .roles-label{color:var(--ymt-danger)}:host main form .roles-section.has-error ymusrmgmt-role-selection{border:2px solid var(--ymt-danger);border-radius:var(--ymt-corner-xs);display:block}:host main form .roles-section .roles-error{color:var(--ymt-danger);font-size:12px;margin-top:var(--ymt-spacing-xs);padding-left:var(--ymt-spacing-m)}\n"] }]
|
|
1928
|
+
args: [{ selector: 'ymusrmgmt-edit-user', standalone: true, imports: [...modules, ...components], template: "<yuv-dialog\n class=\"not-separated\"\n [yuvBusyOverlay]=\"busy()\"\n [headertitle]=\"\n 'yuv.app.user-management.users.action.edit.dialog.title'\n | translate : { username: data.user.username }\n \"\n>\n <main class=\"yuv-dialog-content\">\n <form [formGroup]=\"form\">\n @if (externallyManaged()) {\n <p class=\"hint\">\n {{ 'yuv.app.user-management.users.action.edit.identitySource.external.hint' | translate }}\n </p>\n }\n <mat-form-field class=\"yuv-form-field readonly-field\">\n <mat-label class=\"readonly-label\">{{\n 'yuv.app.user-management.users.property.username' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"username\"\n type=\"text\"\n [attr.tabindex]=\"-1\"\n readonly\n [attr.aria-label]=\"'yuv.app.user-management.users.property.username' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\n 'yuv.app.user-management.users.property.email' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"email\"\n type=\"text\"\n #emailField\n [readonly]=\"externallyManaged()\"\n [attr.tabindex]=\"externallyManaged() ? -1 : null\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.email' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\n 'yuv.app.user-management.users.property.firstname' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"firstName\"\n type=\"text\"\n [readonly]=\"externallyManaged()\"\n [attr.tabindex]=\"externallyManaged() ? -1 : null\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.firstname' | translate\"\n />\n </mat-form-field>\n <mat-form-field class=\"yuv-form-field\" [class.readonly-field]=\"externallyManaged()\">\n <mat-label [class.readonly-label]=\"externallyManaged()\">{{\n 'yuv.app.user-management.users.property.lastname' | translate\n }}</mat-label>\n <input\n matInput\n [formControl]=\"lastName\"\n type=\"text\"\n [readonly]=\"externallyManaged()\"\n [attr.tabindex]=\"externallyManaged() ? -1 : null\"\n [attr.aria-label]=\"'yuv.app.user-management.users.property.lastname' | translate\"\n />\n </mat-form-field>\n <div class=\"roles-section\" [class.has-error]=\"roles.invalid && roles.touched\">\n <ymusrmgmt-role-selection\n [allRoles]=\"allAvailableRoles()\"\n [initialRoles]=\"userRoles()\"\n (rolesChange)=\"onRolesChange($event)\"\n >\n </ymusrmgmt-role-selection>\n </div>\n </form>\n @if (error()) {\n <p class=\"error\" role=\"alert\">{{ error() }}</p>\n }\n </main>\n <footer>\n <button ymtButton=\"secondary\" [disabled]=\"busy()\" (click)=\"close()\">\n {{ 'yuv.app.user-management.users.action.button.cancel' | translate }}\n </button>\n <button ymtButton=\"primary\" [disabled]=\"disabledSubmit()\" (click)=\"updateUser()\">\n {{ 'yuv.app.user-management.users.action.button.save' | translate }}\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host{display:block;position:relative}:host main{background-color:var(--ymt-surface);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m)}:host main .yuv-form-field{width:100%;margin:var(--ymt-spacing-xs) 0}:host main .hint{color:var(--ymt-on-info-container, inherit);background-color:var(--ymt-info-container, var(--ymt-surface-container));padding:var(--ymt-spacing-m);border-radius:var(--ymt-corner-xs);margin:0 0 var(--ymt-spacing-m)}:host main .error{color:var(--ymt-on-danger-container);background-color:var(--ymt-danger-container);border-radius:var(--ymt-corner-m);padding:var(--ymt-spacing-m);margin:0}:host main .readonly-field{opacity:.5;pointer-events:none}:host main .readonly-label{pointer-events:none}:host main .roles-section{margin:var(--ymt-spacing-m) 0}:host main .roles-section.has-error ymusrmgmt-role-selection{border:var(--ymt-focus-indicator-size) solid var(--ymt-danger);border-radius:var(--ymt-corner-xs);display:block}\n"] }]
|
|
1469
1929
|
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
1470
1930
|
type: Inject,
|
|
1471
1931
|
args: [MAT_DIALOG_DATA]
|
|
1472
1932
|
}] }] });
|
|
1473
1933
|
|
|
1474
|
-
|
|
1934
|
+
/**
|
|
1935
|
+
* `MatPaginatorIntl` is provided app-wide as a single shared singleton (see `provideYmtMaterial`),
|
|
1936
|
+
* translated from the generic `ymt.paginator.*` keys ("Hits per page" etc.) that every paginator
|
|
1937
|
+
* in the whole shell reuses - overriding `itemsPerPageLabel` there would relabel every other
|
|
1938
|
+
* paginator too, not just this page's. Providing this class at the component level instead scopes
|
|
1939
|
+
* the override to just this page's `mat-paginator`, while still delegating everything else
|
|
1940
|
+
* (including re-translation on language change) to the shared instance.
|
|
1941
|
+
*/
|
|
1942
|
+
class UserListPaginatorIntl extends MatPaginatorIntl {
|
|
1943
|
+
/** `parent.changes` is the app-wide singleton's Subject - it outlives this instance, so the
|
|
1944
|
+
* subscription below has to be torn down explicitly on destroy. Without it, every time this
|
|
1945
|
+
* page's component is recreated (e.g. navigating away and back), another subscriber piles onto
|
|
1946
|
+
* the singleton forever, since nothing else ever unsubscribes it. */
|
|
1947
|
+
#parentSubscription;
|
|
1948
|
+
constructor() {
|
|
1949
|
+
super();
|
|
1950
|
+
const parent = inject(MatPaginatorIntl, { skipSelf: true });
|
|
1951
|
+
const translate = inject(TranslateService);
|
|
1952
|
+
// Our own `changes` (inherited from the base class, not `parent`'s) is what this page's
|
|
1953
|
+
// `mat-paginator` actually subscribes to, so re-translating on the parent's changes (e.g. a
|
|
1954
|
+
// language switch) still has to be forwarded explicitly via `this.changes.next()`.
|
|
1955
|
+
const update = () => {
|
|
1956
|
+
this.itemsPerPageLabel = translate.instant('yuv.app.user-management.users.label.navigation.pagination.itemsPerPage');
|
|
1957
|
+
this.nextPageLabel = parent.nextPageLabel;
|
|
1958
|
+
this.previousPageLabel = parent.previousPageLabel;
|
|
1959
|
+
this.firstPageLabel = parent.firstPageLabel;
|
|
1960
|
+
this.lastPageLabel = parent.lastPageLabel;
|
|
1961
|
+
this.getRangeLabel = parent.getRangeLabel;
|
|
1962
|
+
this.changes.next();
|
|
1963
|
+
};
|
|
1964
|
+
update();
|
|
1965
|
+
this.#parentSubscription = parent.changes.subscribe(update);
|
|
1966
|
+
}
|
|
1967
|
+
ngOnDestroy() {
|
|
1968
|
+
this.#parentSubscription.unsubscribe();
|
|
1969
|
+
}
|
|
1970
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserListPaginatorIntl, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1971
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserListPaginatorIntl });
|
|
1972
|
+
}
|
|
1973
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: UserListPaginatorIntl, decorators: [{
|
|
1974
|
+
type: Injectable
|
|
1975
|
+
}], ctorParameters: () => [] });
|
|
1976
|
+
class YuvUserManagementComponent {
|
|
1977
|
+
#shell = inject(ShellService);
|
|
1978
|
+
#titleService = inject(Title);
|
|
1979
|
+
#app = this.#shell.getApp(APP_ID);
|
|
1980
|
+
appName = this.#app?.title;
|
|
1475
1981
|
layoutSettingsID = APP_ID + '#root.layout.settings';
|
|
1982
|
+
/** Key the chosen page size is remembered under, across sessions - see `#loadStoredPageSize`. */
|
|
1983
|
+
#pageSizeSettingsKey = APP_ID + '#root.pageSize';
|
|
1984
|
+
pageSizeOptions = [10, 25, 50, 100];
|
|
1985
|
+
/** `yuv-split-view` area indices for the directory layout, so the top-bar hide/toggle buttons
|
|
1986
|
+
* and the collapsed-rail templates don't hardcode them repeatedly. */
|
|
1987
|
+
Area = { GROUPS: 0, DETAILS: 2 };
|
|
1476
1988
|
#logger = inject(Logger);
|
|
1477
1989
|
#userManagementService = inject(YuvUserManagementService);
|
|
1478
1990
|
#translateService = inject(TranslateService);
|
|
1479
1991
|
#dialog = inject(MatDialog);
|
|
1480
1992
|
#notificationService = inject(NotificationService);
|
|
1481
|
-
|
|
1482
|
-
|
|
1993
|
+
#userService = inject(UserService);
|
|
1994
|
+
#confirmService = inject(ConfirmService);
|
|
1995
|
+
#route = inject(ActivatedRoute);
|
|
1996
|
+
#router = inject(Router);
|
|
1997
|
+
#destroyRef = inject(DestroyRef);
|
|
1998
|
+
#injector = inject(Injector);
|
|
1999
|
+
#layoutSettings = inject(LayoutSettingsService);
|
|
2000
|
+
userDetailsComponent;
|
|
2001
|
+
groupsComponent;
|
|
2002
|
+
/** The search box's displayed value, bound to `<yuv-simple-search [query]>` in the template.
|
|
2003
|
+
* A signal rather than a `viewChild` + imperative assignment: the box lives inside an
|
|
2004
|
+
* `<ng-template yuvAppHeaderSlot="search">` that the shell renders into its own header component
|
|
2005
|
+
* on the shell's timing, but the bindings in that template are still evaluated in *this*
|
|
2006
|
+
* component's context, whenever and wherever it ends up being rendered - so there is nothing to
|
|
2007
|
+
* wait for. Always set to a fresh object: `query` is a plain setter input, so Angular only
|
|
2008
|
+
* re-invokes it when the bound reference changes. */
|
|
2009
|
+
searchBoxQuery = signal({ term: '' }, ...(ngDevMode ? [{ debugName: "searchBoxQuery" }] : /* istanbul ignore next */ []));
|
|
1483
2010
|
pagination = { total: 0, pages: 0, page: 0 };
|
|
1484
2011
|
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
2012
|
+
/** How many rows the user list and the group-membership list fetch/show per page - user-selectable
|
|
2013
|
+
* via the paginator's page-size control, remembered across sessions. */
|
|
2014
|
+
pageSize = signal(this.#loadStoredPageSize(), ...(ngDevMode ? [{ debugName: "pageSize" }] : /* istanbul ignore next */ []));
|
|
2015
|
+
displayedColumns = ['avatar', 'user', 'email', 'actions'];
|
|
2016
|
+
icons = APP_ICONS;
|
|
1489
2017
|
dataSource = signal([], ...(ngDevMode ? [{ debugName: "dataSource" }] : /* istanbul ignore next */ []));
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
2018
|
+
/** The selected row, or `undefined` when nothing is selected. Single-select and non-toggling
|
|
2019
|
+
* (see `select`), so a plain signal rather than a `SelectionModel` - and being a real signal, it
|
|
2020
|
+
* lets everything derived from it (`activeRowIndex`, `isSelected`) be a memoized `computed()`. */
|
|
2021
|
+
selectedUser = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedUser" }] : /* istanbul ignore next */ []));
|
|
2022
|
+
/** The rendered rows, in `dataSource()` order - see `FocusableRowDirective`. */
|
|
2023
|
+
rowItems = viewChildren(FocusableRowDirective, ...(ngDevMode ? [{ debugName: "rowItems" }] : /* istanbul ignore next */ []));
|
|
2024
|
+
/** Drives Up/Down over the rows, moving real DOM focus with the roving tabindex below. Built
|
|
2025
|
+
* from the `viewChildren` signal directly, so it follows the rows as the page's contents change
|
|
2026
|
+
* - hence the `Injector`, which is the signal overload's way of scoping that tracking.
|
|
2027
|
+
*
|
|
2028
|
+
* Selection follows focus here, matching the groups rail and a native single-select list: the
|
|
2029
|
+
* manager only moves focus, so `change` is what turns that into a selection. */
|
|
2030
|
+
#rowKeyManager = new FocusKeyManager(this.rowItems, this.#injector);
|
|
2031
|
+
constructor() {
|
|
2032
|
+
// Selection follows keyboard focus, the way a native single-select list behaves - and the way
|
|
2033
|
+
// this table behaved when it moved focus by hand.
|
|
2034
|
+
this.#rowKeyManager.change.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((index) => {
|
|
2035
|
+
const row = this.dataSource()[index];
|
|
2036
|
+
if (row)
|
|
2037
|
+
this.select(row);
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
/** Row index per user id, so the roving tabindex and the selected-row class below are O(1)
|
|
2041
|
+
* lookups rather than a `findIndex` per row per change-detection pass. */
|
|
2042
|
+
#rowIndexById = computed(() => new Map(this.dataSource().map((user, index) => [user.id, index])), ...(ngDevMode ? [{ debugName: "#rowIndexById" }] : /* istanbul ignore next */ []));
|
|
2043
|
+
/** Roving-tabindex position of the selected row within `dataSource()` - only this row (and its
|
|
2044
|
+
* actions button) is a real Tab stop, so Tab moves from the selected row straight to whatever
|
|
2045
|
+
* follows the table instead of stopping at every row in between, mirroring the groups rail.
|
|
2046
|
+
* `-1` (never a valid row index) when nothing is selected, so that state correctly makes every
|
|
2047
|
+
* row's button unreachable via Tab, rather than falling back to row 0 and making an arbitrary,
|
|
2048
|
+
* never-selected row's button reachable. */
|
|
2049
|
+
activeRowIndex = computed(() => {
|
|
2050
|
+
const selected = this.selectedUser();
|
|
2051
|
+
return selected ? this.#rowIndexById().get(selected.id) ?? -1 : -1;
|
|
2052
|
+
}, ...(ngDevMode ? [{ debugName: "activeRowIndex" }] : /* istanbul ignore next */ []));
|
|
2053
|
+
/** The id of the group currently scoping the user list, or `undefined` for "All users". This is
|
|
2054
|
+
* the scope *axis* - set straight from the route, so the list can be fetched for it immediately,
|
|
2055
|
+
* without waiting on the group's own record to arrive. `selectedGroupDetails` below is the same
|
|
2056
|
+
* group's content, for display. */
|
|
2057
|
+
selectedGroupId = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedGroupId" }] : /* istanbul ignore next */ []));
|
|
2058
|
+
/** The full record (name, roles, members) of `selectedGroupId`'s group, fetched alongside the
|
|
2059
|
+
* list rather than before it - see `#loadGroupDetails`. Briefly `undefined` after a group is
|
|
2060
|
+
* picked, and whenever no group is selected. */
|
|
2061
|
+
selectedGroupDetails = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedGroupDetails" }] : /* istanbul ignore next */ []));
|
|
2062
|
+
/** Roles granted by the scoping group, so the detail pane can highlight which of a member's inherited roles come from it specifically. Empty until `selectedGroupDetails` has loaded, or when no group is selected. */
|
|
2063
|
+
selectedGroupRoles = computed(() => this.selectedGroupDetails()?.roles ?? [], ...(ngDevMode ? [{ debugName: "selectedGroupRoles" }] : /* istanbul ignore next */ []));
|
|
2064
|
+
searchTerm = signal('', ...(ngDevMode ? [{ debugName: "searchTerm" }] : /* istanbul ignore next */ []));
|
|
2065
|
+
/** Which entry of the list pane's "All / Enabled / Disabled" filter menu is active - `undefined`
|
|
2066
|
+
* for "All users". Combines with `searchTerm` - both are sent to the users endpoint at once, so
|
|
2067
|
+
* a state filter narrows an active search. Only applies to "All users" though: the backend
|
|
2068
|
+
* doesn't support filtering group members by state. */
|
|
2069
|
+
enabledFilter = signal(undefined, ...(ngDevMode ? [{ debugName: "enabledFilter" }] : /* istanbul ignore next */ []));
|
|
2070
|
+
/** One-entry memo for `getRowActions`, keyed on everything those actions are derived from.
|
|
2071
|
+
*
|
|
2072
|
+
* The row menu's `matMenuContent` template re-evaluates its `@for` on every change-detection
|
|
2073
|
+
* pass for as long as the menu is open, and each rebuild costs a `getCurrentUser()` lookup, four
|
|
2074
|
+
* or five `translateService.instant()` lookups and four fresh objects. Only one row's menu can
|
|
2075
|
+
* be open at a time, so a single entry covers it.
|
|
2076
|
+
*
|
|
2077
|
+
* Keying on the row object rather than its id is deliberate: a list reload replaces the objects,
|
|
2078
|
+
* so a row whose `enabled` state changed can't keep stale actions. The scoping group is part of
|
|
2079
|
+
* the key for the same reason - it decides whether "Remove from group" is there at all, and
|
|
2080
|
+
* carries the name that action's confirmation uses.
|
|
2081
|
+
*
|
|
2082
|
+
* Building the array on the trigger's `(menuOpened)` instead - the obvious alternative - does
|
|
2083
|
+
* *not* work: `MatMenuTrigger.openMenu` attaches the menu content and focuses its first item
|
|
2084
|
+
* before emitting `menuOpened`, so the menu would render, size and position itself empty.
|
|
2085
|
+
*/
|
|
2086
|
+
#rowActionsMemo;
|
|
2087
|
+
/** The action menu for one row - see `#rowActionsMemo`. Bound by the row menu's template. */
|
|
2088
|
+
rowActionsFor(user) {
|
|
2089
|
+
const group = this.selectedGroupDetails();
|
|
2090
|
+
let memo = this.#rowActionsMemo;
|
|
2091
|
+
if (!memo || memo.user !== user || memo.group !== group) {
|
|
2092
|
+
memo = { user, group, actions: this.getRowActions(user) };
|
|
2093
|
+
this.#rowActionsMemo = memo;
|
|
1503
2094
|
}
|
|
2095
|
+
return memo.actions;
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Builds the edit/activate/deactivate/delete menu for a given user row - it doesn't depend on
|
|
2099
|
+
* the row being selected first. When a group is currently scoping the list, a "Remove from
|
|
2100
|
+
* group" action is appended too.
|
|
2101
|
+
*
|
|
2102
|
+
* Called through `rowActionsFor`, not directly from the template. (The detail pane has its own,
|
|
2103
|
+
* separate header action - a single edit button that emits `editUser` - and never used this.)
|
|
2104
|
+
*/
|
|
2105
|
+
getRowActions(user) {
|
|
2106
|
+
const isSelf = user.id === this.#userService.getCurrentUser().id;
|
|
2107
|
+
const group = this.selectedGroupDetails();
|
|
1504
2108
|
const editAction = {
|
|
1505
|
-
id: 'yuv.app.
|
|
1506
|
-
icon: APP_ICONS.
|
|
1507
|
-
label: this.#translateService.instant('yuv.app.user-management.
|
|
1508
|
-
callback: () => this.
|
|
2109
|
+
id: 'yuv.app.users.action.edit',
|
|
2110
|
+
icon: APP_ICONS.actionEditUser,
|
|
2111
|
+
label: this.#translateService.instant('yuv.app.user-management.users.action.edit.label'),
|
|
2112
|
+
callback: () => this.editUser(user),
|
|
2113
|
+
};
|
|
2114
|
+
const stateAction = user.enabled
|
|
2115
|
+
? {
|
|
2116
|
+
id: 'yuv.app.users.action.deactivate',
|
|
2117
|
+
icon: APP_ICONS.actionDeactivateUser,
|
|
2118
|
+
label: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.label'),
|
|
2119
|
+
disabled: isSelf,
|
|
2120
|
+
callback: () => this.deactivateUser(user),
|
|
2121
|
+
}
|
|
2122
|
+
: {
|
|
2123
|
+
id: 'yuv.app.users.action.activate',
|
|
2124
|
+
icon: APP_ICONS.actionActivateUser,
|
|
2125
|
+
label: this.#translateService.instant('yuv.app.user-management.users.action.activate.label'),
|
|
2126
|
+
disabled: isSelf,
|
|
2127
|
+
callback: () => this.activateUser(user),
|
|
2128
|
+
};
|
|
2129
|
+
const deleteAction = {
|
|
2130
|
+
id: 'yuv.app.users.action.delete',
|
|
2131
|
+
icon: APP_ICONS.actionDeleteUser,
|
|
2132
|
+
label: this.#translateService.instant('yuv.app.user-management.users.action.delete.label'),
|
|
2133
|
+
disabled: isSelf,
|
|
2134
|
+
callback: () => this.deleteUser(user),
|
|
1509
2135
|
};
|
|
1510
|
-
|
|
2136
|
+
const actions = [editAction, stateAction];
|
|
2137
|
+
if (group) {
|
|
2138
|
+
actions.push({
|
|
2139
|
+
id: 'yuv.app.users.action.removeGroupMember',
|
|
2140
|
+
icon: APP_ICONS.actionRemoveGroupMember,
|
|
2141
|
+
label: this.#translateService.instant('yuv.app.user-management.groups.action.removeMember.label'),
|
|
2142
|
+
callback: () => this.removeGroupMember(group, user),
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
actions.push(deleteAction);
|
|
2146
|
+
return actions;
|
|
2147
|
+
}
|
|
2148
|
+
createNewUser() {
|
|
2149
|
+
this.#logger.debug('Create new user ...');
|
|
2150
|
+
this.#dialog
|
|
2151
|
+
.open(CreateUserComponent, CreateUserComponent.DIALOG_CONFIG)
|
|
2152
|
+
.afterClosed()
|
|
2153
|
+
.subscribe((createdUser) => {
|
|
2154
|
+
if (createdUser) {
|
|
2155
|
+
this.#logger.debug('New user created with ID: ' + createdUser.id);
|
|
2156
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.users.action.create.success'));
|
|
2157
|
+
// The list is sorted alphabetically, so the new user could land on any page - searching
|
|
2158
|
+
// by its own username reliably surfaces it (as the only/first match) instead, letting the
|
|
2159
|
+
// usual auto-select-first-row fallback pick it: ready to assign roles/groups to right away.
|
|
2160
|
+
// Clears any active state filter along with it, which would otherwise be free to hide the
|
|
2161
|
+
// very user just created.
|
|
2162
|
+
this.#navigateToScope({ search: createdUser.username, enabled: null });
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
1511
2165
|
}
|
|
1512
2166
|
createNewGroup() {
|
|
1513
2167
|
this.#logger.debug('Create new group ...');
|
|
1514
|
-
this.#dialog
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
if (
|
|
1519
|
-
this.#logger.debug(
|
|
1520
|
-
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.
|
|
1521
|
-
this.refresh();
|
|
2168
|
+
this.#dialog
|
|
2169
|
+
.open(CreateGroupComponent, CreateGroupComponent.DIALOG_CONFIG)
|
|
2170
|
+
.afterClosed()
|
|
2171
|
+
.subscribe((createdGroup) => {
|
|
2172
|
+
if (createdGroup) {
|
|
2173
|
+
this.#logger.debug('New group created with ID: ' + createdGroup.id);
|
|
2174
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.create.success'));
|
|
2175
|
+
this.groupsComponent.refresh();
|
|
2176
|
+
// Selects it right away so the (still empty) group is scoping the table, ready for adding members.
|
|
2177
|
+
this.onGroupSelected(createdGroup);
|
|
1522
2178
|
}
|
|
1523
2179
|
});
|
|
1524
2180
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
2181
|
+
/** Opens a dialog to bulk-add users to `group` - the single owner of that flow, for both the
|
|
2182
|
+
* list pane's own button (where `group` is always the group scoping the list) and the rail's
|
|
2183
|
+
* per-row action (where it can be any group, see `GroupsComponent.addMembersRequested`). */
|
|
2184
|
+
openAddGroupMembersDialog(group) {
|
|
2185
|
+
this.#logger.debug(`Add members to group '${group.id}' ...`);
|
|
2186
|
+
this.#dialog
|
|
2187
|
+
.open(AddGroupMembersComponent, {
|
|
2188
|
+
...AddGroupMembersComponent.DIALOG_CONFIG,
|
|
2189
|
+
data: { group },
|
|
2190
|
+
})
|
|
2191
|
+
.afterClosed()
|
|
2192
|
+
.subscribe((result) => {
|
|
2193
|
+
if (!result)
|
|
2194
|
+
return;
|
|
2195
|
+
if (result.addedCount > 0) {
|
|
2196
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.addMembers.success', { count: result.addedCount, name: group.name }));
|
|
2197
|
+
// Only the list currently on screen needs reloading - adding members to some other
|
|
2198
|
+
// group from the rail's row menu doesn't change it.
|
|
2199
|
+
if (this.selectedGroupId() === group.id) {
|
|
2200
|
+
this.#reloadCurrentPage();
|
|
1540
2201
|
}
|
|
2202
|
+
}
|
|
2203
|
+
if (result.failedCount > 0) {
|
|
2204
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.groups.action.addMembers.partialError', { count: result.failedCount, name: group.name }));
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
/** Edit any row's user. */
|
|
2209
|
+
editUser(user) {
|
|
2210
|
+
this.#logger.debug(`Edit user with id '${user.id}'...`);
|
|
2211
|
+
this.#userManagementService.getUserDetails(user.id).subscribe({
|
|
2212
|
+
next: (userDetails) => {
|
|
2213
|
+
this.#dialog
|
|
2214
|
+
.open(EditUserComponent, {
|
|
2215
|
+
...EditUserComponent.DIALOG_CONFIG,
|
|
2216
|
+
data: { user: userDetails },
|
|
2217
|
+
})
|
|
2218
|
+
.afterClosed()
|
|
2219
|
+
.subscribe((editedUserId) => {
|
|
2220
|
+
if (editedUserId) {
|
|
2221
|
+
this.#logger.debug('User edited with ID: ' + editedUserId);
|
|
2222
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.users.action.edit.success', { username: user.username }));
|
|
2223
|
+
this.refresh(this.isSelected(user));
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
},
|
|
2227
|
+
error: (e) => {
|
|
2228
|
+
this.#logger.error(`Failed to load details for user with id '${user.id}'`, e);
|
|
2229
|
+
},
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
activateUser(user) {
|
|
2233
|
+
this.updateUserEnabledState(user, true);
|
|
2234
|
+
}
|
|
2235
|
+
deactivateUser(user) {
|
|
2236
|
+
this.updateUserEnabledState(user, false);
|
|
2237
|
+
}
|
|
2238
|
+
updateUserEnabledState(user, enabled) {
|
|
2239
|
+
const action = enabled ? 'activate' : 'deactivate';
|
|
2240
|
+
const updateUserRequest = { enabled };
|
|
2241
|
+
// Deactivating is the destructive direction, so only it asks for confirmation; activating goes
|
|
2242
|
+
// straight through.
|
|
2243
|
+
const confirmed$ = enabled
|
|
2244
|
+
? of(true)
|
|
2245
|
+
: this.#confirmService.confirm({
|
|
2246
|
+
title: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.label'),
|
|
2247
|
+
message: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.confirm.message', { username: user.username }),
|
|
2248
|
+
confirmLabel: this.#translateService.instant('yuv.app.user-management.users.action.deactivate.confirm.label'),
|
|
1541
2249
|
});
|
|
1542
|
-
|
|
2250
|
+
confirmed$
|
|
2251
|
+
.pipe(filter(Boolean), tap(() => this.busy.set(true)), switchMap(() => this.#userManagementService.updateUser(user.id, updateUserRequest)))
|
|
2252
|
+
.subscribe({
|
|
2253
|
+
error: (e) => {
|
|
2254
|
+
this.#notificationService.error(this.#translateService.instant(`yuv.app.user-management.users.action.${action}.error`, {
|
|
2255
|
+
username: user.username,
|
|
2256
|
+
}));
|
|
2257
|
+
this.#logger.error(`Failed to ${action} user with id ${user.id}`, e);
|
|
2258
|
+
this.busy.set(false);
|
|
2259
|
+
},
|
|
2260
|
+
complete: () => {
|
|
2261
|
+
this.#notificationService.success(this.#translateService.instant(`yuv.app.user-management.users.action.${action}.success`, { username: user.username }));
|
|
2262
|
+
this.busy.set(false);
|
|
2263
|
+
this.refresh(this.isSelected(user));
|
|
2264
|
+
},
|
|
2265
|
+
});
|
|
1543
2266
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
2267
|
+
deleteUser(user) {
|
|
2268
|
+
this.#logger.debug(`Delete user with id '${user.id}'...`);
|
|
2269
|
+
this.#userManagementService.deleteUser(user).subscribe({
|
|
2270
|
+
next: (result) => {
|
|
2271
|
+
if (result) {
|
|
2272
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.users.action.delete.success', {
|
|
2273
|
+
username: user.username,
|
|
2274
|
+
}));
|
|
2275
|
+
if (this.isSelected(user)) {
|
|
1552
2276
|
this.deselect();
|
|
1553
|
-
const currentPage = this.pagination?.page || 0;
|
|
1554
|
-
const targetPage = (currentPage > 0 && this.dataSource().length === 1) ? currentPage - 1 : currentPage;
|
|
1555
|
-
this.#loadGroupList(targetPage);
|
|
1556
2277
|
}
|
|
1557
|
-
|
|
1558
|
-
error: (e) => {
|
|
1559
|
-
this.#logger.error(`Failed to delete group with id '${selected.id}'`, e);
|
|
1560
|
-
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.groups.action.delete.error', { name: selected.name }));
|
|
2278
|
+
this.#reloadCurrentPage();
|
|
1561
2279
|
}
|
|
1562
|
-
}
|
|
1563
|
-
|
|
2280
|
+
},
|
|
2281
|
+
error: (e) => {
|
|
2282
|
+
this.#logger.error(`Failed to delete user with id '${user.id}'`, e);
|
|
2283
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.users.action.delete.error', {
|
|
2284
|
+
username: user.username,
|
|
2285
|
+
}));
|
|
2286
|
+
},
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
/** Removes a user from the group currently scoping the list - the user account itself is unaffected. */
|
|
2290
|
+
removeGroupMember(group, user) {
|
|
2291
|
+
this.#logger.debug(`Remove user with id '${user.id}' from group '${group.id}'...`);
|
|
2292
|
+
this.#userManagementService.removeGroupMember(group, user).subscribe({
|
|
2293
|
+
next: (result) => {
|
|
2294
|
+
if (result) {
|
|
2295
|
+
this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.removeMember.success', { username: user.username, name: group.name }));
|
|
2296
|
+
if (this.isSelected(user)) {
|
|
2297
|
+
this.deselect();
|
|
2298
|
+
}
|
|
2299
|
+
this.#reloadCurrentPage();
|
|
2300
|
+
}
|
|
2301
|
+
},
|
|
2302
|
+
error: (e) => {
|
|
2303
|
+
this.#logger.error(`Failed to remove user with id '${user.id}' from group '${group.id}'`, e);
|
|
2304
|
+
this.#notificationService.error(this.#translateService.instant('yuv.app.user-management.groups.action.removeMember.error', { username: user.username, name: group.name }));
|
|
2305
|
+
},
|
|
2306
|
+
});
|
|
1564
2307
|
}
|
|
2308
|
+
/** Non-toggling - a row click/Enter/Space always (re-)selects, never deselects, so the list
|
|
2309
|
+
* always has a selected row (and the detail pane never has to fall back to its empty state)
|
|
2310
|
+
* whenever `dataSource()` isn't itself empty. */
|
|
1565
2311
|
select(row) {
|
|
1566
|
-
|
|
1567
|
-
|
|
2312
|
+
this.selectedUser.set(row);
|
|
2313
|
+
}
|
|
2314
|
+
deselect() {
|
|
2315
|
+
this.selectedUser.set(undefined);
|
|
2316
|
+
}
|
|
2317
|
+
/** Arrow keys (and Home/End) for the row list - handed straight to the key manager, which moves
|
|
2318
|
+
* focus; `#rowKeyManager`'s `change` subscription turns that into a selection. */
|
|
2319
|
+
onTableKeydown(event) {
|
|
2320
|
+
// The selection can move without the key manager having done it - the auto-select after a
|
|
2321
|
+
// reload, or a row click - so bring its active index back in step first, or the next arrow
|
|
2322
|
+
// press would move relative to a row that is no longer the current one. `updateActiveItem`
|
|
2323
|
+
// does that without emitting `change` or moving focus.
|
|
2324
|
+
const selected = this.activeRowIndex();
|
|
2325
|
+
if (selected !== -1 && selected !== this.#rowKeyManager.activeItemIndex) {
|
|
2326
|
+
this.#rowKeyManager.updateActiveItem(selected);
|
|
1568
2327
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
2328
|
+
this.#rowKeyManager.onKeydown(event);
|
|
2329
|
+
}
|
|
2330
|
+
/** Moves real DOM focus onto the row at `index` - also called on a plain row click, see
|
|
2331
|
+
* `FocusableRowDirective.focus`. Going through the key manager (rather than the element) keeps
|
|
2332
|
+
* its active index in step, so the next arrow press moves from the clicked row. */
|
|
2333
|
+
focusRow(index) {
|
|
2334
|
+
this.#rowKeyManager.setActiveItem(index);
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Called by the groups rail - scopes the user list to that group's members, or back to every
|
|
2338
|
+
* user when `group` is undefined (the rail's pinned "All users" entry).
|
|
2339
|
+
*
|
|
2340
|
+
* Navigation only: like every other scope change, it is applied by `#applyScopeFromRoute` once
|
|
2341
|
+
* the router reports the new URL, never here.
|
|
2342
|
+
*/
|
|
2343
|
+
onGroupSelected(group) {
|
|
2344
|
+
this.#navigateToScope({ group: group?.id ?? null });
|
|
2345
|
+
}
|
|
2346
|
+
/** Called from the "All / enabled / disabled" filter menu in the list pane's header - only shown
|
|
2347
|
+
* while viewing "All users". Narrows an active search rather than replacing it: the state filter
|
|
2348
|
+
* and the search term combine (see `#navigateToScope`). */
|
|
2349
|
+
onEnabledFilterSelected(enabled) {
|
|
2350
|
+
this.#navigateToScope({ enabled: enabled ?? null });
|
|
2351
|
+
}
|
|
2352
|
+
/** Bound to `yuv-simple-search`'s `querySubmit` (Enter / the search button) and `clearInput`
|
|
2353
|
+
* (the X button) - deliberately not its `queryChange`, which fires debounced on every keystroke
|
|
2354
|
+
* and would otherwise refresh the list while the user is still typing.
|
|
2355
|
+
*
|
|
2356
|
+
* Leaves an active state filter in place (searching within "only disabled users" keeps showing
|
|
2357
|
+
* only disabled ones), and clearing the term leaves it in place too - see `#navigateToScope`. */
|
|
2358
|
+
onQueryChange(q) {
|
|
2359
|
+
this.#navigateToScope({ search: q.term || null });
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* Points the URL at a new scope, and stops there - `#applyScopeFromRoute` does the rest.
|
|
2363
|
+
*
|
|
2364
|
+
* Takes a patch of the scope axes and merges it into the current query string
|
|
2365
|
+
* (`queryParamsHandling: 'merge'`), so an axis the patch doesn't mention just keeps its current
|
|
2366
|
+
* value - the router carries that over itself, straight from the current URL. The one real
|
|
2367
|
+
* constraint the backend imposes needs an explicit override on top of that merge: `group` is
|
|
2368
|
+
* exclusive with the other two - group membership supports neither search nor state-filtering -
|
|
2369
|
+
* so touching the group axis at all (picking a group *or* going back to "All users") clears
|
|
2370
|
+
* `search`/`enabled` alongside it, even if the patch didn't otherwise mention them. `search` and
|
|
2371
|
+
* `enabled` themselves need no such override - the users endpoint takes both at once, so leaving
|
|
2372
|
+
* one out of a patch is exactly "carry it over", which `merge` already does.
|
|
2373
|
+
*
|
|
2374
|
+
* Query params rather than path segments specifically so this never causes a route-config
|
|
2375
|
+
* change - Angular only destroys/recreates a routed component across genuinely different route
|
|
2376
|
+
* configs, never for a query-param-only change, so switching between "All users" and any group
|
|
2377
|
+
* (or between two groups) always reuses this same instance.
|
|
2378
|
+
*/
|
|
2379
|
+
#navigateToScope(patch) {
|
|
2380
|
+
const isGroupChange = patch.group !== undefined;
|
|
2381
|
+
const queryParams = {};
|
|
2382
|
+
if (patch.group !== undefined)
|
|
2383
|
+
queryParams['group'] = patch.group;
|
|
2384
|
+
if (patch.search !== undefined)
|
|
2385
|
+
queryParams['search'] = patch.search;
|
|
2386
|
+
if (patch.enabled !== undefined) {
|
|
2387
|
+
queryParams['enabled'] = patch.enabled === null ? null : String(patch.enabled);
|
|
2388
|
+
}
|
|
2389
|
+
if (isGroupChange) {
|
|
2390
|
+
queryParams['search'] = null;
|
|
2391
|
+
queryParams['enabled'] = null;
|
|
1572
2392
|
}
|
|
1573
|
-
this.
|
|
2393
|
+
this.#router.navigate([], {
|
|
2394
|
+
relativeTo: this.#route,
|
|
2395
|
+
queryParamsHandling: 'merge',
|
|
2396
|
+
queryParams,
|
|
2397
|
+
});
|
|
1574
2398
|
}
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
this
|
|
2399
|
+
/** The groups rail edits/deletes rows itself; this only reconciles the page's own state when the group it's currently scoped to was affected. */
|
|
2400
|
+
onGroupUpdated(group) {
|
|
2401
|
+
if (this.selectedGroupId() === group.id) {
|
|
2402
|
+
this.selectedGroupDetails.set(group);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
/** Loads the scoping group's full record (name, roles) for display - see `selectedGroupDetails`.
|
|
2406
|
+
* Fired alongside the list request rather than before it, so the rows aren't waiting on a
|
|
2407
|
+
* response only the pane header needs. The guard covers the group being switched again while
|
|
2408
|
+
* this one was still in flight. */
|
|
2409
|
+
#loadGroupDetails(groupId) {
|
|
2410
|
+
this.#userManagementService.getGroupDetails(groupId).subscribe({
|
|
2411
|
+
next: (details) => {
|
|
2412
|
+
if (this.selectedGroupId() === groupId) {
|
|
2413
|
+
this.selectedGroupDetails.set(details);
|
|
2414
|
+
}
|
|
2415
|
+
},
|
|
2416
|
+
error: (e) => {
|
|
2417
|
+
this.#logger.error(`Failed to load details for group with id '${groupId}'`, e);
|
|
2418
|
+
},
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
onGroupDeleted(groupId) {
|
|
2422
|
+
if (this.selectedGroupId() === groupId) {
|
|
2423
|
+
this.onGroupSelected(undefined);
|
|
2424
|
+
}
|
|
1578
2425
|
}
|
|
1579
2426
|
isSelected(row) {
|
|
1580
|
-
return this.
|
|
2427
|
+
return this.selectedUser()?.id === row.id;
|
|
1581
2428
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
2429
|
+
/** Reloads whichever page the list is currently showing, leaving the scope/search untouched. */
|
|
2430
|
+
#reloadCurrentPage() {
|
|
2431
|
+
this.#loadUserList(this.pagination.page);
|
|
1584
2432
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
2433
|
+
refresh(refreshDetails = false) {
|
|
2434
|
+
this.#reloadCurrentPage();
|
|
2435
|
+
if (refreshDetails) {
|
|
2436
|
+
this.refreshUserDetails();
|
|
1588
2437
|
}
|
|
1589
2438
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
2439
|
+
refreshUserDetails() {
|
|
2440
|
+
if (this.userDetailsComponent) {
|
|
2441
|
+
this.userDetailsComponent.refresh();
|
|
2442
|
+
}
|
|
1592
2443
|
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2444
|
+
/**
|
|
2445
|
+
* Reads the remembered page size back, falling back to the default for anything unexpected (no
|
|
2446
|
+
* stored value yet, or a size no longer offered in `pageSizeOptions`).
|
|
2447
|
+
*
|
|
2448
|
+
* Persisted through `LayoutSettingsService` rather than the browser store directly - the same
|
|
2449
|
+
* mechanism the adjacent `yuv-split-view` uses for `[layoutSettingsID]`, so this page keeps one
|
|
2450
|
+
* persistence story instead of two, and the value lands under the framework's own `yuv.layout:`
|
|
2451
|
+
* namespace where `clearSettings()` can actually reach it. The service only accepts objects (it
|
|
2452
|
+
* returns `false` for anything else, storing nothing), hence the one-field wrapper.
|
|
2453
|
+
*/
|
|
2454
|
+
#loadStoredPageSize() {
|
|
2455
|
+
const stored = this.#layoutSettings.getSettings(this.#pageSizeSettingsKey);
|
|
2456
|
+
const pageSize = Number(stored?.pageSize);
|
|
2457
|
+
return this.pageSizeOptions.includes(pageSize)
|
|
2458
|
+
? pageSize
|
|
2459
|
+
: YuvUserManagementService.DEFAULT_PAGE_SIZE;
|
|
1596
2460
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
this
|
|
2461
|
+
onChangePage($event) {
|
|
2462
|
+
const { pageIndex, pageSize } = $event;
|
|
2463
|
+
if (pageSize !== this.pageSize()) {
|
|
2464
|
+
this.pageSize.set(pageSize);
|
|
2465
|
+
this.#layoutSettings.saveSettings(this.#pageSizeSettingsKey, { pageSize });
|
|
2466
|
+
// A different page size shifts what "page N" means - restart from the first page rather
|
|
2467
|
+
// than keeping an index that may now point past the end, or skip/duplicate rows.
|
|
2468
|
+
this.#loadUserList(0);
|
|
2469
|
+
}
|
|
2470
|
+
else {
|
|
2471
|
+
this.#loadUserList(pageIndex);
|
|
2472
|
+
}
|
|
1600
2473
|
}
|
|
1601
2474
|
ngOnInit() {
|
|
1602
|
-
this
|
|
2475
|
+
const translatedTitle = this.appName.startsWith('yuv.')
|
|
2476
|
+
? this.#translateService.instant(this.appName)
|
|
2477
|
+
: this.appName;
|
|
2478
|
+
this.#titleService.setTitle(translatedTitle);
|
|
2479
|
+
// Reactive rather than a one-time snapshot: this component is never destroyed/recreated for a
|
|
2480
|
+
// query-param-only change (see `#navigateToScope`), so a snapshot read would only ever see the
|
|
2481
|
+
// very first scope and miss every later one - picking a different group, typing/clearing a
|
|
2482
|
+
// search, switching the state filter, or browser Back/Forward through any of them.
|
|
2483
|
+
this.#route.queryParamMap
|
|
2484
|
+
.pipe(takeUntilDestroyed(this.#destroyRef))
|
|
2485
|
+
.subscribe((params) => this.#applyScopeFromRoute(params));
|
|
1603
2486
|
}
|
|
1604
|
-
|
|
2487
|
+
/**
|
|
2488
|
+
* The single writer of the page's scope state, and the only thing that loads the list for a
|
|
2489
|
+
* scope change. Every source arrives here the same way: the groups rail, the search box, the
|
|
2490
|
+
* filter menu, a deep link and browser Back/Forward all go through `#navigateToScope` (or the
|
|
2491
|
+
* address bar) and are read back off the route.
|
|
2492
|
+
*
|
|
2493
|
+
* That one-way flow is why there is no "is this the echo of my own navigation?" bookkeeping
|
|
2494
|
+
* here. Nothing applies a scope before navigating, so there is no echo to tell apart from a
|
|
2495
|
+
* genuine external change. And re-selecting what is already selected produces an identical URL,
|
|
2496
|
+
* which Angular's default `onSameUrlNavigation: 'ignore'` drops before it ever reaches this
|
|
2497
|
+
* subscription - so the duplicate reload that used to need a guard of its own can't happen.
|
|
2498
|
+
*/
|
|
2499
|
+
#applyScopeFromRoute(params) {
|
|
2500
|
+
const groupId = params.get('group');
|
|
2501
|
+
// A group scope wins over the other two axes. The backend supports neither search nor
|
|
2502
|
+
// state-filtering for group members, so a URL carrying both (only reachable by hand-editing
|
|
2503
|
+
// it - `#navigateToScope` never emits that combination) applies the group and drops the rest,
|
|
2504
|
+
// rather than leaving a filter showing as active that nothing can honour. `search` and
|
|
2505
|
+
// `enabled` do combine with each other, and are applied together below.
|
|
2506
|
+
const term = groupId ? '' : params.get('search') ?? '';
|
|
2507
|
+
const enabled = groupId ? undefined : this.#parseEnabledParam(params.get('enabled'));
|
|
2508
|
+
// Entering, leaving or switching a group scope always starts over at the first row; a changed
|
|
2509
|
+
// search or filter doesn't, so a row that survives into the new results stays selected (see
|
|
2510
|
+
// `#loadUserList`'s restore-by-id).
|
|
2511
|
+
if (groupId !== (this.selectedGroupId() ?? null)) {
|
|
2512
|
+
this.deselect();
|
|
2513
|
+
}
|
|
2514
|
+
this.searchTerm.set(term);
|
|
2515
|
+
this.searchBoxQuery.set({ term });
|
|
2516
|
+
this.enabledFilter.set(enabled);
|
|
2517
|
+
this.selectedGroupId.set(groupId ?? undefined);
|
|
2518
|
+
this.selectedGroupDetails.set(undefined);
|
|
2519
|
+
if (groupId) {
|
|
2520
|
+
// Fired alongside the list request below, not before it: only the pane header needs the
|
|
2521
|
+
// group's own record, so the rows shouldn't wait for it.
|
|
2522
|
+
this.#loadGroupDetails(groupId);
|
|
2523
|
+
}
|
|
2524
|
+
this.#loadUserList(0);
|
|
2525
|
+
}
|
|
2526
|
+
/** `?enabled=true|false` - anything else (including the param being absent) means "All users". */
|
|
2527
|
+
#parseEnabledParam(value) {
|
|
2528
|
+
if (value === 'true')
|
|
2529
|
+
return true;
|
|
2530
|
+
if (value === 'false')
|
|
2531
|
+
return false;
|
|
2532
|
+
return undefined;
|
|
2533
|
+
}
|
|
2534
|
+
#loadUserList(page) {
|
|
1605
2535
|
this.busy.set(true);
|
|
1606
|
-
|
|
2536
|
+
// Clears the previously shown rows immediately rather than leaving them up (stale, and
|
|
2537
|
+
// possibly for a different group) until the new response arrives - the busy indicator alone
|
|
2538
|
+
// (a subtle animated pane border) isn't enough to make clear what's currently shown is old.
|
|
2539
|
+
this.dataSource.set([]);
|
|
2540
|
+
const groupId = this.selectedGroupId();
|
|
2541
|
+
const term = this.searchTerm();
|
|
2542
|
+
const pageSize = this.pageSize();
|
|
2543
|
+
// Group membership search/state-filtering isn't supported by the backend - a group scope never
|
|
2544
|
+
// coexists with a non-empty `term` or a set `enabledFilter`, which `#applyScopeFromRoute`
|
|
2545
|
+
// guarantees for every scope, from every source. Outside a group the two are independent and
|
|
2546
|
+
// both go to the endpoint, which ANDs them.
|
|
2547
|
+
const request$ = groupId
|
|
2548
|
+
? this.#userManagementService.fetchGroupMembers(groupId, page, pageSize)
|
|
2549
|
+
: this.#userManagementService.fetchUsers(page, term, this.enabledFilter(), pageSize);
|
|
2550
|
+
request$.subscribe({
|
|
1607
2551
|
next: (response) => {
|
|
1608
|
-
console.log(`fetched groups: ${response}`);
|
|
1609
2552
|
this.dataSource.set(response.objects);
|
|
1610
2553
|
this.pagination = {
|
|
1611
2554
|
total: response.totalNumItems,
|
|
1612
|
-
pages: Math.ceil(response.totalNumItems /
|
|
1613
|
-
page: page
|
|
2555
|
+
pages: Math.ceil(response.totalNumItems / pageSize) || 1,
|
|
2556
|
+
page: page,
|
|
1614
2557
|
};
|
|
1615
2558
|
},
|
|
1616
2559
|
error: (e) => {
|
|
1617
|
-
this.#logger.error('Failed to fetch
|
|
2560
|
+
this.#logger.error('Failed to fetch users', e);
|
|
1618
2561
|
this.busy.set(false);
|
|
1619
2562
|
},
|
|
1620
2563
|
complete: () => {
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
2564
|
+
if (this.dataSource().length === 0 && page > 0) {
|
|
2565
|
+
this.#loadUserList(page - 1);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
// A plain name search matching nothing might actually be a user's exact id, pasted in by
|
|
2569
|
+
// someone who only has that to go on - falling back to an exact-id lookup lets them jump
|
|
2570
|
+
// straight to that user instead of hitting a dead end. Harmless no-op if it isn't one.
|
|
2571
|
+
if (this.dataSource().length === 0 && page === 0 && !groupId && term) {
|
|
2572
|
+
this.#tryFindByIdFallback(term);
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const selected = this.selectedUser();
|
|
2576
|
+
const updatedSelected = selected && this.dataSource().find((u) => u.id === selected.id);
|
|
2577
|
+
if (updatedSelected) {
|
|
1624
2578
|
this.select(updatedSelected);
|
|
1625
|
-
this.refreshGroupDetails();
|
|
1626
2579
|
}
|
|
1627
|
-
else {
|
|
2580
|
+
else if (this.dataSource().length > 0) {
|
|
2581
|
+
// Nothing selected (or the previous selection no longer exists in the freshly loaded
|
|
2582
|
+
// page) - always fall back to the first row rather than leaving the detail pane empty.
|
|
1628
2583
|
this.select(this.dataSource()[0]);
|
|
1629
2584
|
}
|
|
2585
|
+
else {
|
|
2586
|
+
this.deselect();
|
|
2587
|
+
}
|
|
1630
2588
|
this.busy.set(false);
|
|
1631
2589
|
},
|
|
1632
2590
|
});
|
|
1633
2591
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
2592
|
+
/** Called when a plain search matched no one - `term` might actually be a user's id rather than
|
|
2593
|
+
* part of their name (the backend's search endpoint doesn't match on id, which is why the plain
|
|
2594
|
+
* search above found nothing). If it resolves to a real user, show that one row directly rather
|
|
2595
|
+
* than rewriting the search box to their username and re-running a name search for it - the box
|
|
2596
|
+
* stays showing exactly the id that was typed. If not, this is just a genuine "no matches".
|
|
2597
|
+
*
|
|
2598
|
+
* An active state filter still applies: this lookup goes to the user endpoint directly, so a
|
|
2599
|
+
* match whose state the filter excludes is discarded rather than shown as the one row of a list
|
|
2600
|
+
* that claims to be filtered to the opposite state. */
|
|
2601
|
+
#tryFindByIdFallback(term) {
|
|
2602
|
+
const enabledFilter = this.enabledFilter();
|
|
2603
|
+
this.#userManagementService.getUserDetails(term).subscribe({
|
|
2604
|
+
next: (user) => {
|
|
2605
|
+
if (enabledFilter !== undefined && user.enabled !== enabledFilter) {
|
|
2606
|
+
this.busy.set(false);
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
this.dataSource.set([user]);
|
|
2610
|
+
this.pagination = { total: 1, pages: 1, page: 0 };
|
|
2611
|
+
this.select(user);
|
|
2612
|
+
this.busy.set(false);
|
|
2613
|
+
},
|
|
2614
|
+
error: () => {
|
|
2615
|
+
this.busy.set(false);
|
|
2616
|
+
},
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
openSettings() {
|
|
2620
|
+
this.#dialog.open(SettingsComponent, SettingsComponent.DIALOG_CONFIG);
|
|
2621
|
+
}
|
|
2622
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: YuvUserManagementComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2623
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: YuvUserManagementComponent, isStandalone: true, selector: "ymusrmgmt-root", providers: [
|
|
2624
|
+
{
|
|
2625
|
+
provide: MatPaginatorIntl,
|
|
2626
|
+
useClass: UserListPaginatorIntl,
|
|
2627
|
+
},
|
|
2628
|
+
], viewQueries: [{ propertyName: "rowItems", predicate: FocusableRowDirective, descendants: true, isSignal: true }, { propertyName: "userDetailsComponent", first: true, predicate: UserDetailsComponent, descendants: true }, { propertyName: "groupsComponent", first: true, predicate: GroupsComponent, descendants: true }], ngImport: i0, template: "<ng-template yuvAppHeaderSlot=\"actions\">\n <div class=\"usrmgmt-header-actions\">\n <ymusrmgmt-global-add-button (createUser)=\"createNewUser()\" (createGroup)=\"createNewGroup()\" />\n <button\n mat-icon-button\n class=\"usrmgmt-settings-button\"\n [matTooltip]=\"'yuv.app.user-management.navigation.action.settings.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.navigation.action.settings.label' | translate\"\n (click)=\"openSettings()\"\n >\n <mat-icon>{{ icons.actionSettings }}</mat-icon>\n </button>\n </div>\n</ng-template>\n\n<ng-template yuvAppHeaderSlot=\"search\">\n <!-- querySubmit (Enter / the search button), not queryChange - queryChange fires debounced on\n every keystroke, which used to refresh the list while still typing. clearInput (the X\n button) still needs its own binding since it doesn't also emit querySubmit. -->\n <yuv-simple-search\n [query]=\"searchBoxQuery()\"\n (querySubmit)=\"onQueryChange($event)\"\n (clearInput)=\"onQueryChange({ term: '' })\"\n ></yuv-simple-search>\n</ng-template>\n\n<main\n class=\"usrmgmt-main__content\"\n [attr.aria-label]=\"'yuv.app.user-management.content.label.aria' | translate\"\n>\n @if (!split.isAreaVisible(Area.GROUPS)) {\n <ymusrmgmt-pane-gutter\n icon=\"left_panel_open\"\n [flipLabel]=\"true\"\n [label]=\"'yuv.app.user-management.label.rail.title' | translate\"\n [tooltip]=\"'yuv.app.user-management.label.pane.showGroups.tooltip' | translate\"\n (expand)=\"split.showArea(Area.GROUPS)\"\n />\n }\n\n <yuv-split-view #split [layoutSettingsID]=\"layoutSettingsID\" class=\"content-split\">\n <ng-template yuvSplitArea [size]=\"20\">\n <ng-template #groupPaneTopBarActions>\n <ymusrmgmt-pane-toggle-button\n icon=\"left_panel_close\"\n [tooltip]=\"'yuv.app.user-management.label.pane.hideGroups.tooltip' | translate\"\n (toggled)=\"split.hideArea(Area.GROUPS)\"\n />\n </ng-template>\n\n <ymusrmgmt-groups\n [selectedGroupId]=\"selectedGroupId()\"\n [topBarActions]=\"groupPaneTopBarActions\"\n (groupSelected)=\"onGroupSelected($event)\"\n (groupUpdated)=\"onGroupUpdated($event)\"\n (groupDeleted)=\"onGroupDeleted($event)\"\n (addMembersRequested)=\"openAddGroupMembersDialog($event)\"\n >\n </ymusrmgmt-groups>\n </ng-template>\n\n <ng-template yuvSplitArea [size]=\"60\">\n <!-- `groupId` is the scope itself, known the moment a group is picked; `group` is that\n group's record, which arrives a beat later (see `selectedGroupDetails`). Anything\n structural keys off `groupId`, anything showing the group's own content off `group`. -->\n @let groupId = selectedGroupId(); @let group = selectedGroupDetails(); @let enabled =\n enabledFilter();\n <!-- The list pane's own title, shared by its `aria-label` and its visible header. -->\n @let listTitle = (groupId ? 'yuv.app.user-management.label.list.groupMembersTitle' :\n 'yuv.app.user-management.label.list.title' ) | translate;\n <ng-template #userListTopBarActions>\n @let groupsVisible = split.isAreaVisible(Area.GROUPS);\n <ymusrmgmt-pane-toggle-button\n align=\"start\"\n icon=\"left_panel_close\"\n [rotated]=\"!groupsVisible\"\n [tooltip]=\"\n (groupsVisible\n ? 'yuv.app.user-management.label.pane.hideGroups.tooltip'\n : 'yuv.app.user-management.label.pane.showGroups.tooltip'\n ) | translate\n \"\n (toggled)=\"split.toggleArea(Area.GROUPS)\"\n />\n @let detailsVisible = split.isAreaVisible(Area.DETAILS);\n <ymusrmgmt-pane-toggle-button\n icon=\"right_panel_close\"\n [rotated]=\"!detailsVisible\"\n [tooltip]=\"\n (detailsVisible\n ? 'yuv.app.user-management.label.pane.hideUserDetails.tooltip'\n : 'yuv.app.user-management.label.pane.showUserDetails.tooltip'\n ) | translate\n \"\n (toggled)=\"split.toggleArea(Area.DETAILS)\"\n />\n </ng-template>\n\n <!-- Enabled/disabled filtering isn't supported for group members - only offered while viewing \"All users\". -->\n <ng-template #userFilterActions>\n @if (!groupId) {\n <button\n mat-icon-button\n class=\"user-filter-trigger\"\n [class.active]=\"enabled !== undefined\"\n [matMenuTriggerFor]=\"userFilterMenu\"\n [matTooltip]=\"'yuv.app.user-management.label.filter.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.label.filter.tooltip' | translate\"\n >\n <mat-icon>filter_list</mat-icon>\n </button>\n } @else if (group) {\n <button\n mat-icon-button\n (click)=\"openAddGroupMembersDialog(group)\"\n [matTooltip]=\"'yuv.app.user-management.groups.action.addMembers.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.action.addMembers.label' | translate\"\n >\n <mat-icon>{{ icons.actionAddGroupMembers }}</mat-icon>\n </button>\n }\n </ng-template>\n\n <ymt-pane\n [busy]=\"busy()\"\n [yuvBusyOverlay]=\"busy()\"\n [topBarActions]=\"userListTopBarActions\"\n role=\"region\"\n [attr.aria-label]=\"listTitle\"\n >\n <ymt-pane-header\n [icon]=\"groupId ? 'group' : 'family_group'\"\n [title]=\"listTitle\"\n [actions]=\"userFilterActions\"\n >\n <ng-template #yuvPaneHeaderSubtitle>\n <span class=\"pane-subtitle\">\n <span>\n @if (groupId) {\n {{ group?.name }}\n } @else if (enabled === true) {\n {{ 'yuv.app.user-management.label.filter.enabled' | translate }}\n } @else if (enabled === false) {\n {{ 'yuv.app.user-management.label.filter.disabled' | translate }}\n } @else {\n {{ 'yuv.app.user-management.label.rail.allUsers' | translate }}\n }\n </span>\n @if (searchTerm(); as term) {\n <span class=\"search-hint\">{{\n 'yuv.app.user-management.label.search.filtered' | translate : { term }\n }}</span>\n }\n </span>\n </ng-template>\n </ymt-pane-header>\n <ymt-pane-body>\n <mat-table\n [dataSource]=\"dataSource()\"\n class=\"user-table mat-elevation-z8\"\n row\n (keydown)=\"onTableKeydown($event)\"\n >\n <ng-container matColumnDef=\"avatar\">\n <mat-header-cell *matHeaderCellDef />\n <mat-cell *matCellDef=\"let element\">\n <ymusrmgmt-user-avatar [user]=\"element\" />\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"user\">\n <mat-header-cell *matHeaderCellDef>{{\n 'yuv.app.user-management.users.property.user' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <div class=\"user-identity\">\n <span class=\"user-username-row\">\n <span class=\"user-username\">{{ element.username }}</span>\n </span>\n <span class=\"user-name-row\">\n <span class=\"user-name\">{{ element.firstName }} {{ element.lastName }}</span>\n </span>\n </div>\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"email\">\n <mat-header-cell *matHeaderCellDef>{{\n 'yuv.app.user-management.users.property.email' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\" class=\"user-email\">\n {{ element.email }}\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"actions\">\n <mat-header-cell *matHeaderCellDef />\n <mat-cell\n *matCellDef=\"let element; let i = index\"\n class=\"user-actions\"\n (click)=\"$event.stopPropagation()\"\n >\n <button\n mat-icon-button\n [matMenuTriggerFor]=\"rowMenu\"\n [matMenuTriggerData]=\"{ user: element }\"\n [matTooltip]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [tabIndex]=\"activeRowIndex() === i ? 0 : -1\"\n (click)=\"select(element)\"\n >\n <mat-icon>more_vert</mat-icon>\n </button>\n </mat-cell>\n </ng-container>\n <mat-header-row *matHeaderRowDef=\"displayedColumns\"></mat-header-row>\n <mat-row\n ymusrmgmt-focusable-row\n *matRowDef=\"let row; columns: displayedColumns; let i = index\"\n (click.single)=\"select(row); focusRow(i)\"\n [attr.tabindex]=\"activeRowIndex() === i ? 0 : -1\"\n (keydown.enter)=\"select(row)\"\n (keydown.space)=\"$event.preventDefault(); select(row)\"\n [class.selected-row]=\"activeRowIndex() === i\"\n [class.user-row--inactive]=\"!row.enabled\"\n class=\"user-row\"\n >\n </mat-row>\n <ng-template matNoDataRow>\n @if (!busy() && selectedGroupDetails(); as group) {\n <div class=\"empty-group-state\">\n <span class=\"empty-group-state-icon\"\n ><mat-icon aria-hidden=\"true\">group_off</mat-icon></span\n >\n <h3 class=\"empty-group-state-title\">\n {{ 'yuv.app.user-management.label.emptyGroup.title' | translate }}\n </h3>\n <p class=\"empty-group-state-description\">\n {{\n 'yuv.app.user-management.label.emptyGroup.description'\n | translate : { name: group.name }\n }}\n </p>\n <button ymtButton=\"primary\" (click)=\"openAddGroupMembersDialog(group)\">\n <mat-icon>{{ icons.actionAddGroupMembers }}</mat-icon>\n {{ 'yuv.app.user-management.groups.action.addMembers.label' | translate }}\n </button>\n </div>\n }\n </ng-template>\n </mat-table>\n </ymt-pane-body>\n <ymt-pane-footer>\n <mat-paginator\n class=\"paginator\"\n [length]=\"pagination.total\"\n [pageIndex]=\"pagination.page\"\n [pageSize]=\"pageSize()\"\n [pageSizeOptions]=\"pageSizeOptions\"\n [attr.aria-label]=\"\n 'yuv.app.user-management.users.label.navigation.pagination.aria' | translate\n \"\n (page)=\"onChangePage($event)\"\n ></mat-paginator>\n </ymt-pane-footer>\n </ymt-pane>\n </ng-template>\n\n <ng-template yuvSplitArea [size]=\"20\">\n <ng-template #userDetailsPaneTopBarActions>\n <ymusrmgmt-pane-toggle-button\n align=\"start\"\n icon=\"right_panel_close\"\n [tooltip]=\"'yuv.app.user-management.label.pane.hideUserDetails.tooltip' | translate\"\n (toggled)=\"split.hideArea(Area.DETAILS)\"\n />\n </ng-template>\n\n <ymusrmgmt-user-details\n [user]=\"selectedUser()\"\n [groupRoles]=\"selectedGroupRoles()\"\n [topBarActions]=\"userDetailsPaneTopBarActions\"\n (editUser)=\"editUser($event)\"\n />\n </ng-template>\n </yuv-split-view>\n\n @if (!split.isAreaVisible(Area.DETAILS)) {\n <ymusrmgmt-pane-gutter\n icon=\"right_panel_open\"\n [label]=\"'yuv.app.user-management.users.label.details.title' | translate\"\n [tooltip]=\"'yuv.app.user-management.label.pane.showUserDetails.tooltip' | translate\"\n (expand)=\"split.showArea(Area.DETAILS)\"\n />\n }\n</main>\n\n<mat-menu #rowMenu=\"matMenu\">\n <ng-template matMenuContent let-user=\"user\">\n @for (action of rowActionsFor(user); track action.id) {\n <button mat-menu-item [disabled]=\"action.disabled\" (click)=\"action.callback?.(action)\">\n <mat-icon>{{ action.icon }}</mat-icon>\n <span>{{ action.label }}</span>\n </button>\n }\n </ng-template>\n</mat-menu>\n\n<mat-menu #userFilterMenu=\"matMenu\">\n <button mat-menu-item (click)=\"onEnabledFilterSelected(undefined)\">\n <mat-icon>{{\n enabledFilter() === undefined ? 'radio_button_checked' : 'radio_button_unchecked'\n }}</mat-icon>\n <span>{{ 'yuv.app.user-management.label.rail.allUsers' | translate }}</span>\n </button>\n <button mat-menu-item (click)=\"onEnabledFilterSelected(true)\">\n <mat-icon>{{\n enabledFilter() === true ? 'radio_button_checked' : 'radio_button_unchecked'\n }}</mat-icon>\n <span>{{ 'yuv.app.user-management.label.filter.enabled' | translate }}</span>\n </button>\n <button mat-menu-item (click)=\"onEnabledFilterSelected(false)\">\n <mat-icon>{{\n enabledFilter() === false ? 'radio_button_checked' : 'radio_button_unchecked'\n }}</mat-icon>\n <span>{{ 'yuv.app.user-management.label.filter.disabled' | translate }}</span>\n </button>\n</mat-menu>\n", styles: [":host{--mat-table-row-item-outline-color: var(--ymt-outline-variant);--mat-table-header-headline-font: var(--ymt-font-headline-small-font);--mat-table-header-headline-size: var(--ymt-font-headline-small-size);--mat-table-header-headline-weight: var(--ymt-font-headline-small-weight);display:flex;flex-flow:column;height:100%;overflow:hidden}:host .usrmgmt-main__content{flex:1;min-height:0;display:flex;flex-flow:row;overflow:hidden;gap:var(--ymt-spacing-s);padding:var(--ymt-spacing-m)}:host .content-split{flex:1;min-width:0}:host ymt-pane-footer{--footer-block-padding: 0;--footer-inline-padding: 0}:host .paginator{flex:1;background:transparent}:host .pane-subtitle{display:inline-flex;align-items:baseline;gap:var(--ymt-spacing-2xs);min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}:host .user-filter-trigger.active{color:var(--ymt-primary)}:host .user-table{overflow:auto;height:100%}:host .user-row{height:3.75rem}:host .user-row:hover:not(.selected-row){background-color:var(--ymt-hover-background)}:host .user-row.user-row--inactive .user-username,:host .user-row.user-row--inactive .user-name,:host .user-row.user-row--inactive .user-email{opacity:.6}:host .mat-column-avatar{flex:0 0 var(--ymt-sizing-6xl)}:host .mat-column-user{flex:1.75 1 0;min-width:0}:host .mat-column-email{flex:1.25 1 0;min-width:0}:host .mat-column-actions{flex:0 0 4.5rem;justify-content:flex-end;padding-right:var(--ymt-sizing-xs)}:host .user-actions{display:flex}:host .user-identity{display:flex;flex-direction:column;gap:var(--ymt-spacing-3xs);line-height:1.25;min-width:0}:host .user-identity .user-name-row,:host .user-identity .user-username-row{display:flex;align-items:center;gap:.375rem;min-width:0}:host .user-identity .user-name{font:var(--ymt-font-title-smallest);color:var(--ymt-text-color);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}:host .user-identity .user-username{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host .user-email{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host .user-row.selected-row{background-color:var(--ymt-selection-background)}:host .user-table:focus-within .user-row.selected-row{background-color:var(--ymt-primary-container)}:host .user-table:focus-within .user-row.selected-row .user-name,:host .user-table:focus-within .user-row.selected-row .user-username,:host .user-table:focus-within .user-row.selected-row .user-email{color:var(--ymt-on-primary-container)}:host .user-table:focus-within .user-row.selected-row .user-actions button{--mat-icon-button-icon-color: var(--ymt-on-primary-container)}:host .empty-group-state{display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--ymt-spacing-m);width:100%;padding:var(--ymt-spacing-4xl) var(--ymt-spacing-l)}:host .empty-group-state-icon{width:var(--ymt-sizing-5xl);height:var(--ymt-sizing-5xl);border-radius:var(--ymt-corner-full);background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container);display:flex;align-items:center;justify-content:center;flex-shrink:0}:host .empty-group-state-title{font:var(--ymt-font-headline-small);color:var(--ymt-text-color);margin:0}:host .empty-group-state-description{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);max-width:32ch;margin:0}.usrmgmt-header-actions{display:flex;align-items:center;width:100%}.usrmgmt-header-actions .usrmgmt-settings-button{margin-inline-start:auto}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i3$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i3$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i3$2.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i3$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatPaginatorModule }, { kind: "component", type: i4$1.MatPaginator, selector: "mat-paginator", inputs: ["color", "pageIndex", "length", "pageSize", "pageSizeOptions", "hidePageSize", "showFirstLastButtons", "selectConfig", "disabled"], outputs: ["page"], exportAs: ["matPaginator"] }, { kind: "ngmodule", type: MatTableModule }, { kind: "component", type: i5.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i5.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i5.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i5.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i5.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i5.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i5.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i5.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i5.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i5.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "directive", type: i5.MatNoDataRow, selector: "ng-template[matNoDataRow]" }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SimpleSearchComponent, selector: "yuv-simple-search", inputs: ["query"], outputs: ["querySubmit", "clearInput", "targetSelectionChanged", "queryChange", "typeAggregation"] }, { kind: "directive", type: BusyOverlayDirective, selector: "[yuvBusyOverlay]", inputs: ["yuvBusyOverlay", "yuvBusyOverlayConfig", "yuvBusyError"], outputs: ["yuvBusyErrorDismiss"] }, { kind: "directive", type: ClickDoubleDirective, selector: "[click.single],[click.double]", inputs: ["debounceTime"], outputs: ["click.double", "click.single"] }, { kind: "component", type: UserDetailsComponent, selector: "ymusrmgmt-user-details", inputs: ["user", "groupRoles", "topBarActions"], outputs: ["editUser"] }, { kind: "component", type: GroupsComponent, selector: "ymusrmgmt-groups", inputs: ["selectedGroupId", "topBarActions"], outputs: ["groupSelected", "groupUpdated", "groupDeleted", "addMembersRequested"] }, { kind: "component", type: PaneToggleButtonComponent, selector: "ymusrmgmt-pane-toggle-button", inputs: ["icon", "tooltip", "rotated"], outputs: ["toggled"] }, { kind: "component", type: PaneGutterComponent, selector: "ymusrmgmt-pane-gutter", inputs: ["icon", "label", "tooltip", "flipLabel"], outputs: ["expand"] }, { kind: "component", type: UserAvatarComponent, selector: "ymusrmgmt-user-avatar", inputs: ["user", "showBadges"] }, { kind: "component", type: GlobalAddButtonComponent, selector: "ymusrmgmt-global-add-button", outputs: ["createUser", "createGroup"] }, { kind: "directive", type: FocusableRowDirective, selector: "[ymusrmgmt-focusable-row]" }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "ngmodule", type: YmtPanesModule }, { kind: "component", type: i7.YmtPaneComponent, selector: "ymt-pane", inputs: ["topBarActions", "busy", "noAnimation", "plain"] }, { kind: "component", type: i7.YmtPaneHeaderComponent, selector: "ymt-pane-header", inputs: ["title", "icon", "subtitle", "actions", "badges"] }, { kind: "component", type: i7.YmtPaneBodyComponent, selector: "ymt-pane-body" }, { kind: "component", type: i7.YmtPaneFooterComponent, selector: "ymt-pane-footer" }, { kind: "ngmodule", type: YuvClientShellModule }, { kind: "directive", type: i8$1.YuvAppHeaderSlotDirective, selector: "ng-template[yuvAppHeaderSlot]", inputs: ["yuvAppHeaderSlot"] }, { kind: "ngmodule", type: YuvSplitViewModule }, { kind: "directive", type: i9.SplitAreaDirective, selector: "[yuvSplitArea]", inputs: ["size", "minSize", "maxSize", "panelClass", "visible", "lockSize"] }, { kind: "component", type: i9.SplitViewComponent, selector: "yuv-split-view", inputs: ["direction", "gutterSize", "restrictMove", "disabled", "layoutSettingsID"], outputs: ["layoutSettingsChange", "dragStart", "dragEnd", "gutterClick", "gutterDblClick"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1636
2629
|
}
|
|
1637
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.
|
|
2630
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: YuvUserManagementComponent, decorators: [{
|
|
1638
2631
|
type: Component,
|
|
1639
|
-
args: [{ selector: 'ymusrmgmt-
|
|
1640
|
-
CommonModule,
|
|
2632
|
+
args: [{ selector: 'ymusrmgmt-root', imports: [
|
|
1641
2633
|
MatButtonModule,
|
|
1642
2634
|
MatIconModule,
|
|
1643
|
-
|
|
1644
|
-
MatTableModule,
|
|
2635
|
+
MatMenuModule,
|
|
1645
2636
|
MatPaginatorModule,
|
|
1646
|
-
|
|
1647
|
-
|
|
2637
|
+
MatTableModule,
|
|
2638
|
+
MatTooltipModule,
|
|
1648
2639
|
TranslatePipe,
|
|
2640
|
+
SimpleSearchComponent,
|
|
1649
2641
|
BusyOverlayDirective,
|
|
1650
2642
|
ClickDoubleDirective,
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2643
|
+
UserDetailsComponent,
|
|
2644
|
+
GroupsComponent,
|
|
2645
|
+
PaneToggleButtonComponent,
|
|
2646
|
+
PaneGutterComponent,
|
|
2647
|
+
UserAvatarComponent,
|
|
2648
|
+
GlobalAddButtonComponent,
|
|
2649
|
+
FocusableRowDirective,
|
|
2650
|
+
YmtButtonDirective,
|
|
1654
2651
|
YmtPanesModule,
|
|
1655
2652
|
YuvClientShellModule,
|
|
1656
|
-
|
|
1657
|
-
],
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2653
|
+
YuvSplitViewModule,
|
|
2654
|
+
], providers: [
|
|
2655
|
+
{
|
|
2656
|
+
provide: MatPaginatorIntl,
|
|
2657
|
+
useClass: UserListPaginatorIntl,
|
|
2658
|
+
},
|
|
2659
|
+
], template: "<ng-template yuvAppHeaderSlot=\"actions\">\n <div class=\"usrmgmt-header-actions\">\n <ymusrmgmt-global-add-button (createUser)=\"createNewUser()\" (createGroup)=\"createNewGroup()\" />\n <button\n mat-icon-button\n class=\"usrmgmt-settings-button\"\n [matTooltip]=\"'yuv.app.user-management.navigation.action.settings.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.navigation.action.settings.label' | translate\"\n (click)=\"openSettings()\"\n >\n <mat-icon>{{ icons.actionSettings }}</mat-icon>\n </button>\n </div>\n</ng-template>\n\n<ng-template yuvAppHeaderSlot=\"search\">\n <!-- querySubmit (Enter / the search button), not queryChange - queryChange fires debounced on\n every keystroke, which used to refresh the list while still typing. clearInput (the X\n button) still needs its own binding since it doesn't also emit querySubmit. -->\n <yuv-simple-search\n [query]=\"searchBoxQuery()\"\n (querySubmit)=\"onQueryChange($event)\"\n (clearInput)=\"onQueryChange({ term: '' })\"\n ></yuv-simple-search>\n</ng-template>\n\n<main\n class=\"usrmgmt-main__content\"\n [attr.aria-label]=\"'yuv.app.user-management.content.label.aria' | translate\"\n>\n @if (!split.isAreaVisible(Area.GROUPS)) {\n <ymusrmgmt-pane-gutter\n icon=\"left_panel_open\"\n [flipLabel]=\"true\"\n [label]=\"'yuv.app.user-management.label.rail.title' | translate\"\n [tooltip]=\"'yuv.app.user-management.label.pane.showGroups.tooltip' | translate\"\n (expand)=\"split.showArea(Area.GROUPS)\"\n />\n }\n\n <yuv-split-view #split [layoutSettingsID]=\"layoutSettingsID\" class=\"content-split\">\n <ng-template yuvSplitArea [size]=\"20\">\n <ng-template #groupPaneTopBarActions>\n <ymusrmgmt-pane-toggle-button\n icon=\"left_panel_close\"\n [tooltip]=\"'yuv.app.user-management.label.pane.hideGroups.tooltip' | translate\"\n (toggled)=\"split.hideArea(Area.GROUPS)\"\n />\n </ng-template>\n\n <ymusrmgmt-groups\n [selectedGroupId]=\"selectedGroupId()\"\n [topBarActions]=\"groupPaneTopBarActions\"\n (groupSelected)=\"onGroupSelected($event)\"\n (groupUpdated)=\"onGroupUpdated($event)\"\n (groupDeleted)=\"onGroupDeleted($event)\"\n (addMembersRequested)=\"openAddGroupMembersDialog($event)\"\n >\n </ymusrmgmt-groups>\n </ng-template>\n\n <ng-template yuvSplitArea [size]=\"60\">\n <!-- `groupId` is the scope itself, known the moment a group is picked; `group` is that\n group's record, which arrives a beat later (see `selectedGroupDetails`). Anything\n structural keys off `groupId`, anything showing the group's own content off `group`. -->\n @let groupId = selectedGroupId(); @let group = selectedGroupDetails(); @let enabled =\n enabledFilter();\n <!-- The list pane's own title, shared by its `aria-label` and its visible header. -->\n @let listTitle = (groupId ? 'yuv.app.user-management.label.list.groupMembersTitle' :\n 'yuv.app.user-management.label.list.title' ) | translate;\n <ng-template #userListTopBarActions>\n @let groupsVisible = split.isAreaVisible(Area.GROUPS);\n <ymusrmgmt-pane-toggle-button\n align=\"start\"\n icon=\"left_panel_close\"\n [rotated]=\"!groupsVisible\"\n [tooltip]=\"\n (groupsVisible\n ? 'yuv.app.user-management.label.pane.hideGroups.tooltip'\n : 'yuv.app.user-management.label.pane.showGroups.tooltip'\n ) | translate\n \"\n (toggled)=\"split.toggleArea(Area.GROUPS)\"\n />\n @let detailsVisible = split.isAreaVisible(Area.DETAILS);\n <ymusrmgmt-pane-toggle-button\n icon=\"right_panel_close\"\n [rotated]=\"!detailsVisible\"\n [tooltip]=\"\n (detailsVisible\n ? 'yuv.app.user-management.label.pane.hideUserDetails.tooltip'\n : 'yuv.app.user-management.label.pane.showUserDetails.tooltip'\n ) | translate\n \"\n (toggled)=\"split.toggleArea(Area.DETAILS)\"\n />\n </ng-template>\n\n <!-- Enabled/disabled filtering isn't supported for group members - only offered while viewing \"All users\". -->\n <ng-template #userFilterActions>\n @if (!groupId) {\n <button\n mat-icon-button\n class=\"user-filter-trigger\"\n [class.active]=\"enabled !== undefined\"\n [matMenuTriggerFor]=\"userFilterMenu\"\n [matTooltip]=\"'yuv.app.user-management.label.filter.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.label.filter.tooltip' | translate\"\n >\n <mat-icon>filter_list</mat-icon>\n </button>\n } @else if (group) {\n <button\n mat-icon-button\n (click)=\"openAddGroupMembersDialog(group)\"\n [matTooltip]=\"'yuv.app.user-management.groups.action.addMembers.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.groups.action.addMembers.label' | translate\"\n >\n <mat-icon>{{ icons.actionAddGroupMembers }}</mat-icon>\n </button>\n }\n </ng-template>\n\n <ymt-pane\n [busy]=\"busy()\"\n [yuvBusyOverlay]=\"busy()\"\n [topBarActions]=\"userListTopBarActions\"\n role=\"region\"\n [attr.aria-label]=\"listTitle\"\n >\n <ymt-pane-header\n [icon]=\"groupId ? 'group' : 'family_group'\"\n [title]=\"listTitle\"\n [actions]=\"userFilterActions\"\n >\n <ng-template #yuvPaneHeaderSubtitle>\n <span class=\"pane-subtitle\">\n <span>\n @if (groupId) {\n {{ group?.name }}\n } @else if (enabled === true) {\n {{ 'yuv.app.user-management.label.filter.enabled' | translate }}\n } @else if (enabled === false) {\n {{ 'yuv.app.user-management.label.filter.disabled' | translate }}\n } @else {\n {{ 'yuv.app.user-management.label.rail.allUsers' | translate }}\n }\n </span>\n @if (searchTerm(); as term) {\n <span class=\"search-hint\">{{\n 'yuv.app.user-management.label.search.filtered' | translate : { term }\n }}</span>\n }\n </span>\n </ng-template>\n </ymt-pane-header>\n <ymt-pane-body>\n <mat-table\n [dataSource]=\"dataSource()\"\n class=\"user-table mat-elevation-z8\"\n row\n (keydown)=\"onTableKeydown($event)\"\n >\n <ng-container matColumnDef=\"avatar\">\n <mat-header-cell *matHeaderCellDef />\n <mat-cell *matCellDef=\"let element\">\n <ymusrmgmt-user-avatar [user]=\"element\" />\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"user\">\n <mat-header-cell *matHeaderCellDef>{{\n 'yuv.app.user-management.users.property.user' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <div class=\"user-identity\">\n <span class=\"user-username-row\">\n <span class=\"user-username\">{{ element.username }}</span>\n </span>\n <span class=\"user-name-row\">\n <span class=\"user-name\">{{ element.firstName }} {{ element.lastName }}</span>\n </span>\n </div>\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"email\">\n <mat-header-cell *matHeaderCellDef>{{\n 'yuv.app.user-management.users.property.email' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\" class=\"user-email\">\n {{ element.email }}\n </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"actions\">\n <mat-header-cell *matHeaderCellDef />\n <mat-cell\n *matCellDef=\"let element; let i = index\"\n class=\"user-actions\"\n (click)=\"$event.stopPropagation()\"\n >\n <button\n mat-icon-button\n [matMenuTriggerFor]=\"rowMenu\"\n [matMenuTriggerData]=\"{ user: element }\"\n [matTooltip]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.common.action.more.label' | translate\"\n [tabIndex]=\"activeRowIndex() === i ? 0 : -1\"\n (click)=\"select(element)\"\n >\n <mat-icon>more_vert</mat-icon>\n </button>\n </mat-cell>\n </ng-container>\n <mat-header-row *matHeaderRowDef=\"displayedColumns\"></mat-header-row>\n <mat-row\n ymusrmgmt-focusable-row\n *matRowDef=\"let row; columns: displayedColumns; let i = index\"\n (click.single)=\"select(row); focusRow(i)\"\n [attr.tabindex]=\"activeRowIndex() === i ? 0 : -1\"\n (keydown.enter)=\"select(row)\"\n (keydown.space)=\"$event.preventDefault(); select(row)\"\n [class.selected-row]=\"activeRowIndex() === i\"\n [class.user-row--inactive]=\"!row.enabled\"\n class=\"user-row\"\n >\n </mat-row>\n <ng-template matNoDataRow>\n @if (!busy() && selectedGroupDetails(); as group) {\n <div class=\"empty-group-state\">\n <span class=\"empty-group-state-icon\"\n ><mat-icon aria-hidden=\"true\">group_off</mat-icon></span\n >\n <h3 class=\"empty-group-state-title\">\n {{ 'yuv.app.user-management.label.emptyGroup.title' | translate }}\n </h3>\n <p class=\"empty-group-state-description\">\n {{\n 'yuv.app.user-management.label.emptyGroup.description'\n | translate : { name: group.name }\n }}\n </p>\n <button ymtButton=\"primary\" (click)=\"openAddGroupMembersDialog(group)\">\n <mat-icon>{{ icons.actionAddGroupMembers }}</mat-icon>\n {{ 'yuv.app.user-management.groups.action.addMembers.label' | translate }}\n </button>\n </div>\n }\n </ng-template>\n </mat-table>\n </ymt-pane-body>\n <ymt-pane-footer>\n <mat-paginator\n class=\"paginator\"\n [length]=\"pagination.total\"\n [pageIndex]=\"pagination.page\"\n [pageSize]=\"pageSize()\"\n [pageSizeOptions]=\"pageSizeOptions\"\n [attr.aria-label]=\"\n 'yuv.app.user-management.users.label.navigation.pagination.aria' | translate\n \"\n (page)=\"onChangePage($event)\"\n ></mat-paginator>\n </ymt-pane-footer>\n </ymt-pane>\n </ng-template>\n\n <ng-template yuvSplitArea [size]=\"20\">\n <ng-template #userDetailsPaneTopBarActions>\n <ymusrmgmt-pane-toggle-button\n align=\"start\"\n icon=\"right_panel_close\"\n [tooltip]=\"'yuv.app.user-management.label.pane.hideUserDetails.tooltip' | translate\"\n (toggled)=\"split.hideArea(Area.DETAILS)\"\n />\n </ng-template>\n\n <ymusrmgmt-user-details\n [user]=\"selectedUser()\"\n [groupRoles]=\"selectedGroupRoles()\"\n [topBarActions]=\"userDetailsPaneTopBarActions\"\n (editUser)=\"editUser($event)\"\n />\n </ng-template>\n </yuv-split-view>\n\n @if (!split.isAreaVisible(Area.DETAILS)) {\n <ymusrmgmt-pane-gutter\n icon=\"right_panel_open\"\n [label]=\"'yuv.app.user-management.users.label.details.title' | translate\"\n [tooltip]=\"'yuv.app.user-management.label.pane.showUserDetails.tooltip' | translate\"\n (expand)=\"split.showArea(Area.DETAILS)\"\n />\n }\n</main>\n\n<mat-menu #rowMenu=\"matMenu\">\n <ng-template matMenuContent let-user=\"user\">\n @for (action of rowActionsFor(user); track action.id) {\n <button mat-menu-item [disabled]=\"action.disabled\" (click)=\"action.callback?.(action)\">\n <mat-icon>{{ action.icon }}</mat-icon>\n <span>{{ action.label }}</span>\n </button>\n }\n </ng-template>\n</mat-menu>\n\n<mat-menu #userFilterMenu=\"matMenu\">\n <button mat-menu-item (click)=\"onEnabledFilterSelected(undefined)\">\n <mat-icon>{{\n enabledFilter() === undefined ? 'radio_button_checked' : 'radio_button_unchecked'\n }}</mat-icon>\n <span>{{ 'yuv.app.user-management.label.rail.allUsers' | translate }}</span>\n </button>\n <button mat-menu-item (click)=\"onEnabledFilterSelected(true)\">\n <mat-icon>{{\n enabledFilter() === true ? 'radio_button_checked' : 'radio_button_unchecked'\n }}</mat-icon>\n <span>{{ 'yuv.app.user-management.label.filter.enabled' | translate }}</span>\n </button>\n <button mat-menu-item (click)=\"onEnabledFilterSelected(false)\">\n <mat-icon>{{\n enabledFilter() === false ? 'radio_button_checked' : 'radio_button_unchecked'\n }}</mat-icon>\n <span>{{ 'yuv.app.user-management.label.filter.disabled' | translate }}</span>\n </button>\n</mat-menu>\n", styles: [":host{--mat-table-row-item-outline-color: var(--ymt-outline-variant);--mat-table-header-headline-font: var(--ymt-font-headline-small-font);--mat-table-header-headline-size: var(--ymt-font-headline-small-size);--mat-table-header-headline-weight: var(--ymt-font-headline-small-weight);display:flex;flex-flow:column;height:100%;overflow:hidden}:host .usrmgmt-main__content{flex:1;min-height:0;display:flex;flex-flow:row;overflow:hidden;gap:var(--ymt-spacing-s);padding:var(--ymt-spacing-m)}:host .content-split{flex:1;min-width:0}:host ymt-pane-footer{--footer-block-padding: 0;--footer-inline-padding: 0}:host .paginator{flex:1;background:transparent}:host .pane-subtitle{display:inline-flex;align-items:baseline;gap:var(--ymt-spacing-2xs);min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}:host .user-filter-trigger.active{color:var(--ymt-primary)}:host .user-table{overflow:auto;height:100%}:host .user-row{height:3.75rem}:host .user-row:hover:not(.selected-row){background-color:var(--ymt-hover-background)}:host .user-row.user-row--inactive .user-username,:host .user-row.user-row--inactive .user-name,:host .user-row.user-row--inactive .user-email{opacity:.6}:host .mat-column-avatar{flex:0 0 var(--ymt-sizing-6xl)}:host .mat-column-user{flex:1.75 1 0;min-width:0}:host .mat-column-email{flex:1.25 1 0;min-width:0}:host .mat-column-actions{flex:0 0 4.5rem;justify-content:flex-end;padding-right:var(--ymt-sizing-xs)}:host .user-actions{display:flex}:host .user-identity{display:flex;flex-direction:column;gap:var(--ymt-spacing-3xs);line-height:1.25;min-width:0}:host .user-identity .user-name-row,:host .user-identity .user-username-row{display:flex;align-items:center;gap:.375rem;min-width:0}:host .user-identity .user-name{font:var(--ymt-font-title-smallest);color:var(--ymt-text-color);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}:host .user-identity .user-username{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host .user-email{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host .user-row.selected-row{background-color:var(--ymt-selection-background)}:host .user-table:focus-within .user-row.selected-row{background-color:var(--ymt-primary-container)}:host .user-table:focus-within .user-row.selected-row .user-name,:host .user-table:focus-within .user-row.selected-row .user-username,:host .user-table:focus-within .user-row.selected-row .user-email{color:var(--ymt-on-primary-container)}:host .user-table:focus-within .user-row.selected-row .user-actions button{--mat-icon-button-icon-color: var(--ymt-on-primary-container)}:host .empty-group-state{display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--ymt-spacing-m);width:100%;padding:var(--ymt-spacing-4xl) var(--ymt-spacing-l)}:host .empty-group-state-icon{width:var(--ymt-sizing-5xl);height:var(--ymt-sizing-5xl);border-radius:var(--ymt-corner-full);background-color:var(--ymt-primary-container);color:var(--ymt-on-primary-container);display:flex;align-items:center;justify-content:center;flex-shrink:0}:host .empty-group-state-title{font:var(--ymt-font-headline-small);color:var(--ymt-text-color);margin:0}:host .empty-group-state-description{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);max-width:32ch;margin:0}.usrmgmt-header-actions{display:flex;align-items:center;width:100%}.usrmgmt-header-actions .usrmgmt-settings-button{margin-inline-start:auto}\n"] }]
|
|
2660
|
+
}], ctorParameters: () => [], propDecorators: { userDetailsComponent: [{
|
|
1661
2661
|
type: ViewChild,
|
|
1662
|
-
args: [
|
|
1663
|
-
}],
|
|
1664
|
-
type:
|
|
1665
|
-
args: [
|
|
1666
|
-
}] } });
|
|
2662
|
+
args: [UserDetailsComponent]
|
|
2663
|
+
}], groupsComponent: [{
|
|
2664
|
+
type: ViewChild,
|
|
2665
|
+
args: [GroupsComponent]
|
|
2666
|
+
}], rowItems: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => FocusableRowDirective), { isSignal: true }] }] } });
|
|
1667
2667
|
|
|
1668
2668
|
const YuvUserManagementRoutes = [
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
}
|
|
2669
|
+
// The single directory view for both users and groups, bound directly at the app's own base
|
|
2670
|
+
// path - which group (if any) is currently scoping the list is a `?group=<id>` query param
|
|
2671
|
+
// rather than a path segment, so switching between "All users" and any group (or between two
|
|
2672
|
+
// different groups) never destroys/recreates this component: Angular only remounts across
|
|
2673
|
+
// genuinely different route configs, and query param changes alone never do that (see
|
|
2674
|
+
// YuvUserManagementComponent.ngOnInit). A `?search=<term>` query param is kept in sync with the
|
|
2675
|
+
// search box the same way, both read and written (see
|
|
2676
|
+
// YuvUserManagementComponent#syncRouteToSearch/#applySearchFromRoute).
|
|
2677
|
+
{ path: '', component: YuvUserManagementComponent },
|
|
1678
2678
|
];
|
|
1679
2679
|
|
|
1680
2680
|
/**
|
|
1681
2681
|
* Generated bundle index. Do not edit.
|
|
1682
2682
|
*/
|
|
1683
2683
|
|
|
1684
|
-
export { YuvUserManagementRoutes, YuvUserManagementService };
|
|
2684
|
+
export { YuvUserManagementRoutes, YuvUserManagementService, resolveErrorMessageKey };
|
|
1685
2685
|
//# sourceMappingURL=yuuvis-user-management-app.mjs.map
|