@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.
@@ -1,59 +1,51 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Injectable, signal, computed, Component, Pipe, Input, linkedSignal, input, output, effect, Inject, HostBinding, ViewChild } from '@angular/core';
3
- import * as i5 from '@angular/router';
4
- import { Router, ActivatedRoute, NavigationEnd, RouterModule } from '@angular/router';
5
- import * as i3 from '@angular/material/button';
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 * as i2$1 from '@angular/material/button-toggle';
8
- import { MatButtonToggleModule } from '@angular/material/button-toggle';
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 i4$1 from '@angular/material/tooltip';
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 { filter } from 'rxjs/operators';
15
- import { Title } from '@angular/platform-browser';
16
- import { BackendService, TranslateService, ApiBase, TranslatePipe, NotificationService, Logger, LocalizationService, UserService } from '@yuuvis/client-core';
17
- import * as i6$1 from '@yuuvis/material/panes';
18
- import { YmtPanesModule } from '@yuuvis/material/panes';
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 i1$2 from '@angular/common';
23
- import { CommonModule } from '@angular/common';
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 { ConfirmService, BusyOverlayDirective, DialogComponent, ClickDoubleDirective } from '@yuuvis/client-components/common';
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 i2$2 from '@angular/material/paginator';
37
- import { MatPaginatorModule } from '@angular/material/paginator';
38
- import { MatSortModule } from '@angular/material/sort';
39
- import * as i3$4 from '@angular/material/table';
40
- import { MatTableModule } from '@angular/material/table';
41
- import { MatTabsModule } from '@angular/material/tabs';
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 i5$1 from '@yuuvis/material/layout';
48
- import { YmtLayoutModule } from '@yuuvis/material/layout';
49
- import { MatCheckboxModule } from '@angular/material/checkbox';
50
- import * as i3$2 from '@angular/material/chips';
51
- import { MatChipsModule } from '@angular/material/chips';
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 { MatCardModule } from '@angular/material/card';
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: 'account_circle_off',
60
+ stateInactive: 'block',
69
61
  sourceExternal: 'cloud',
70
- actionCreateNewUser: 'person_add',
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
- actionSettings: 'settings',
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
- static DEFAULT_PAGE_SIZE = 15;
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
- fetchUsers(page = 0, search) {
85
- const filterParams = new HttpParams()
86
- .set('page', page)
87
- .set('size', YuvUserManagementService.DEFAULT_PAGE_SIZE)
88
- .set('sort', 'username%2Casc')
89
- .set('search', search || '');
90
- return this.#backendService
91
- .get(`/tenant-management/api/idm/users?${filterParams}`, ApiBase.none);
92
- }
93
- fetchGroups(page = 0, search) {
94
- const filterParams = new HttpParams()
95
- .set('page', page)
96
- .set('size', YuvUserManagementService.DEFAULT_PAGE_SIZE)
97
- .set('sort', 'name%2Casc')
98
- .set('search', search || '');
99
- return this.#backendService
100
- .get(`/tenant-management/api/idm/groups?${filterParams}`, ApiBase.none);
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, { responseType: 'text' })
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, { responseType: 'text' })
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}?${queryParams}`, ApiBase.none)
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
- .get(`/tenant-management/api/admin/identity-provider/oidc`, ApiBase.none);
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.delete.label'),
207
- message: this.#translateService.instant('yuv.app.user-management.settings.oidc.action.delete.confirm.message'),
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
- const queryParams = new HttpParams()
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.14", ngImport: i0, type: YuvUserManagementService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
226
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: YuvUserManagementService, providedIn: 'root' });
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
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: YuvUserManagementService, decorators: [{
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
- const components$4 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent];
236
- const modules$4 = [CommonModule, ReactiveFormsModule, TranslatePipe, ClipboardModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule];
237
- /** Placeholder shown in the client secret field of an already configured provider; the backend never returns the real secret. */
238
- const FAKE_CLIENT_SECRET = '••••••••••••••••';
239
- class SettingsComponent {
240
- #fb = inject(FormBuilder);
241
- #userManagementService = inject(YuvUserManagementService);
242
- #dialogRef = inject((MatDialogRef));
243
- #translateService = inject(TranslateService);
244
- #notificationService = inject(NotificationService);
245
- #logger = inject(Logger);
246
- busy = signal(true, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
247
- error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
248
- /** True if the tenant has no OIDC identity provider configured (backend responds with 404). */
249
- notConfigured = signal(false, ...(ngDevMode ? [{ debugName: "notConfigured" }] : /* istanbul ignore next */ []));
250
- provider = signal(undefined, ...(ngDevMode ? [{ debugName: "provider" }] : /* istanbul ignore next */ []));
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
- * SCIM tenant URL. Uses the backend-provided value if present; otherwise falls back to a
253
- * client-side computation from the redirect URI: `{redirectUri up to and including /realms/{tenant}}/scim/v2`.
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
- tenantUrl = computed(() => {
256
- const scimTenantUrl = this.provider()?.scimProvisioning?.tenantUrl;
257
- if (scimTenantUrl)
258
- return scimTenantUrl;
259
- const redirectUri = this.provider()?.redirectUri;
260
- const match = redirectUri?.match(/^(.*\/realms\/[^/]+)\//);
261
- return match ? `${match[1]}/scim/v2` : undefined;
262
- }, ...(ngDevMode ? [{ debugName: "tenantUrl" }] : /* istanbul ignore next */ []));
263
- displayName = new FormControl('Microsoft Entra ID', [Validators.required]);
264
- discoveryUrl = new FormControl('', [Validators.required]);
265
- clientId = new FormControl('', [Validators.required]);
266
- clientSecret = new FormControl('', [Validators.required]);
267
- form = this.#fb.group({
268
- displayName: this.displayName,
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
- this.#router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe((e) => {
427
- const view = this.views.find(v => e.urlAfterRedirects.includes('/' + v.link));
428
- if (view)
429
- this.activeLink = view.link;
430
- });
431
- }
432
- ngOnInit() {
433
- const translatedTitle = this.appName.startsWith('yuv.')
434
- ? this.#translateService.instant(this.appName)
435
- : this.appName;
436
- this.#titleService.setTitle(translatedTitle);
437
- }
438
- navigate(link) {
439
- this.#router.navigate([link], { relativeTo: this.#activatedRoute });
440
- }
441
- openSettings() {
442
- this.#dialog.open(SettingsComponent, {
443
- width: '600px',
444
- maxWidth: '90vw',
445
- });
446
- }
447
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: YuvUserManagementComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
448
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: YuvUserManagementComponent, isStandalone: true, selector: "ymusrmgmt-root", ngImport: i0, template: "<ng-template yuvAppHeaderSlot=\"actions\">\n <div class=\"usrmgmt-header-actions\">\n <mat-button-toggle-group [value]=\"activeLink\" (change)=\"navigate($event.value)\" hideSingleSelectionIndicator>\n @for (view of views; track view.id) {\n <mat-button-toggle [value]=\"view.link\" [matTooltip]=\"view.tooltip | translate\">{{ view.title | translate }}</mat-button-toggle>\n }\n </mat-button-toggle-group>\n <button\n mat-icon-button\n class=\"usrmgmt-settings-button\"\n [matTooltip]=\"'yuv.app.user-management.navigation.settings.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.navigation.settings.label' | translate\"\n (click)=\"openSettings()\">\n <mat-icon>{{ icons.actionSettings }}</mat-icon>\n </button>\n </div>\n</ng-template>\n\n<main class=\"usrmgmt-main__content\" [attr.aria-label]=\"'yuv.app.user-management.content.aria.label' | translate\">\n <router-outlet></router-outlet>\n</main>\n\n<router-outlet name=\"modal\"></router-outlet>\n", styles: [":host{display:flex;flex-flow:column;height:100%}:host .usrmgmt-main__content{flex:1;overflow-y:hidden}.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: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatButtonToggleModule }, { kind: "directive", type: i2$1.MatButtonToggleGroup, selector: "mat-button-toggle-group", inputs: ["appearance", "name", "vertical", "value", "multiple", "disabled", "disabledInteractive", "hideSingleSelectionIndicator", "hideMultipleSelectionIndicator"], outputs: ["valueChange", "change"], exportAs: ["matButtonToggleGroup"] }, { kind: "component", type: i2$1.MatButtonToggle, selector: "mat-button-toggle", inputs: ["aria-label", "aria-labelledby", "id", "name", "value", "tabIndex", "disableRipple", "appearance", "checked", "disabled", "disabledInteractive"], outputs: ["change"], exportAs: ["matButtonToggle"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { 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: "directive", type: i5.RouterOutlet, selector: "router-outlet", inputs: ["name", "routerOutletData"], outputs: ["activate", "deactivate", "attach", "detach"], exportAs: ["outlet"] }, { kind: "ngmodule", type: YmtPanesModule }, { kind: "ngmodule", type: YuvClientShellModule }, { kind: "directive", type: i6.YuvAppHeaderSlotDirective, selector: "ng-template[yuvAppHeaderSlot]", inputs: ["yuvAppHeaderSlot"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
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.14", ngImport: i0, type: YuvUserManagementComponent, decorators: [{
402
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ListFieldComponent, decorators: [{
451
403
  type: Component,
452
- args: [{ selector: 'ymusrmgmt-root', standalone: true, imports: [
453
- MatButtonModule,
454
- MatButtonToggleModule,
455
- MatIconModule,
456
- MatTooltipModule,
457
- RouterModule,
458
- TranslatePipe,
459
- YmtPanesModule,
460
- YuvClientShellModule,
461
- ], template: "<ng-template yuvAppHeaderSlot=\"actions\">\n <div class=\"usrmgmt-header-actions\">\n <mat-button-toggle-group [value]=\"activeLink\" (change)=\"navigate($event.value)\" hideSingleSelectionIndicator>\n @for (view of views; track view.id) {\n <mat-button-toggle [value]=\"view.link\" [matTooltip]=\"view.tooltip | translate\">{{ view.title | translate }}</mat-button-toggle>\n }\n </mat-button-toggle-group>\n <button\n mat-icon-button\n class=\"usrmgmt-settings-button\"\n [matTooltip]=\"'yuv.app.user-management.navigation.settings.tooltip' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.navigation.settings.label' | translate\"\n (click)=\"openSettings()\">\n <mat-icon>{{ icons.actionSettings }}</mat-icon>\n </button>\n </div>\n</ng-template>\n\n<main class=\"usrmgmt-main__content\" [attr.aria-label]=\"'yuv.app.user-management.content.aria.label' | translate\">\n <router-outlet></router-outlet>\n</main>\n\n<router-outlet name=\"modal\"></router-outlet>\n", styles: [":host{display:flex;flex-flow:column;height:100%}:host .usrmgmt-main__content{flex:1;overflow-y:hidden}.usrmgmt-header-actions{display:flex;align-items:center;width:100%}.usrmgmt-header-actions .usrmgmt-settings-button{margin-inline-start:auto}\n"] }]
462
- }], ctorParameters: () => [] });
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.14", ngImport: i0, type: RoleLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
488
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.14", ngImport: i0, type: RoleLabelPipe, isStandalone: true, name: "roleLabel" });
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.14", ngImport: i0, type: RoleLabelPipe, decorators: [{
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.14", ngImport: i0, type: RoleTooltipPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
500
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.14", ngImport: i0, type: RoleTooltipPipe, isStandalone: true, name: "roleTooltip" });
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.14", ngImport: i0, type: RoleTooltipPipe, decorators: [{
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.14", ngImport: i0, type: SortRolesByLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
514
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.14", ngImport: i0, type: SortRolesByLabelPipe, isStandalone: true, name: "sortRolesByLabel" });
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.14", ngImport: i0, type: SortRolesByLabelPipe, decorators: [{
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
- formBuilder;
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
- userDetailsForm;
526
- currentUserId;
527
- set userId(value) {
528
- this.currentUserId = value;
529
- this.loadUserDetails();
530
- }
531
- loadUserDetails() {
532
- if (this.currentUserId) {
533
- this.userService.getUserDetails(this.currentUserId).subscribe(user => {
534
- this.userDetails.set(user);
535
- this.userDetailsForm.patchValue({
536
- id: user.id,
537
- username: user.username,
538
- firstName: user.firstName,
539
- lastName: user.lastName,
540
- email: user.email,
541
- enabled: user.enabled
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
- constructor(userService, formBuilder) {
556
- this.userService = userService;
557
- this.formBuilder = formBuilder;
558
- this.userDetailsForm = this.formBuilder.group({
559
- id: [{ value: '' }],
560
- username: [{ value: '' }],
561
- firstName: [{ value: '' }],
562
- lastName: [{ value: '' }],
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.14", ngImport: i0, type: UserDetailsComponent, deps: [{ token: YuvUserManagementService }, { token: i1.FormBuilder }], target: i0.ɵɵFactoryTarget.Component });
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.14", ngImport: i0, type: UserDetailsComponent, decorators: [{
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
- MatChipsModule,
577
+ BusyOverlayDirective,
578
+ ClipboardModule,
579
+ ListFieldComponent,
580
+ MatButtonModule,
574
581
  MatFormFieldModule,
575
- MatInputModule,
576
582
  MatIconModule,
577
- MatCheckboxModule,
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 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"] }]
586
- }], ctorParameters: () => [{ type: YuvUserManagementService }, { type: i1.FormBuilder }], propDecorators: { userId: [{
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
- #compareByLabel = (a, b) => resolveRoleLabel(this.#localization, a).localeCompare(resolveRoleLabel(this.#localization, b));
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
- this.assignedRoles.set([...initial].sort(this.#compareByLabel));
706
- this.availableRoles.set(all.filter((role) => !initial.includes(role)).sort(this.#compareByLabel));
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
- this.availableRoles.update((roles) => roles.filter((r) => !toAssign.includes(r)));
723
- this.assignedRoles.update((roles) => [...roles, ...toAssign].sort(this.#compareByLabel));
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
- this.assignedRoles.update((roles) => roles.filter((r) => !toRemove.includes(r)));
732
- this.availableRoles.update((roles) => [...roles, ...toRemove].sort(this.#compareByLabel));
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].sort(this.#compareByLabel));
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].sort(this.#compareByLabel));
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.selectedAvailable().includes(role);
687
+ return this.#selectedAvailableSet().has(role);
756
688
  }
757
689
  isAssignedSelected(role) {
758
- return this.selectedAssigned().includes(role);
690
+ return this.#selectedAssignedSet().has(role);
759
691
  }
760
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: RolesSelectionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
761
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", 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.users.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.users.roles.available' | translate\">\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 {{ role | roleLabel }}\n </mat-list-option>\n }\n @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.users.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.users.roles.assignAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.assignAll' | translate\">\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.users.roles.assign' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.assign' | translate\">\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.users.roles.remove' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.remove' | translate\">\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.users.roles.removeAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.removeAll' | translate\">\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.users.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.users.roles.assigned' | translate\">\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 {{ role | roleLabel }}\n </mat-list-option>\n }\n @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.users.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;color:var(--ymt-text-color-subtle);font:var(--ymt-font-body-subtle)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { 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: MatListModule }, { kind: "component", type: i3$3.MatSelectionList, selector: "mat-selection-list", inputs: ["color", "compareWith", "multiple", "hideSingleSelectionIndicator", "disabled"], outputs: ["selectionChange"], exportAs: ["matSelectionList"] }, { kind: "component", type: i3$3.MatListOption, selector: "mat-list-option", inputs: ["togglePosition", "checkboxPosition", "color", "value", "selected"], outputs: ["selectedChange"], exportAs: ["matListOption"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$1.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" }] });
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.14", ngImport: i0, type: RolesSelectionComponent, decorators: [{
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.users.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.users.roles.available' | translate\">\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 {{ role | roleLabel }}\n </mat-list-option>\n }\n @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.users.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.users.roles.assignAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.assignAll' | translate\">\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.users.roles.assign' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.assign' | translate\">\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.users.roles.remove' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.remove' | translate\">\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.users.roles.removeAll' | translate\"\n [attr.aria-label]=\"'yuv.app.user-management.users.roles.removeAll' | translate\">\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.users.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.users.roles.assigned' | translate\">\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 {{ role | roleLabel }}\n </mat-list-option>\n }\n @empty {\n <div class=\"empty-message\">\n {{ 'yuv.app.user-management.users.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;color:var(--ymt-text-color-subtle);font:var(--ymt-font-body-subtle)}\n"] }]
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$2 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent, RolesSelectionComponent];
778
- const modules$2 = [
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 EditUserComponent {
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
- userRoles = signal([], ...(ngDevMode ? [{ debugName: "userRoles" }] : /* istanbul ignore next */ []));
796
- /** Externally managed users (e.g. provisioned via SCIM) may only have roles assigned; their personal data is owned by the external IdP. */
797
- externallyManaged = signal(false, ...(ngDevMode ? [{ debugName: "externallyManaged" }] : /* istanbul ignore next */ []));
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
- username: this.username,
805
- email: this.email,
806
- firstName: this.firstName,
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
- username: this.data.user.username,
819
- firstName: this.data.user.firstName,
820
- lastName: this.data.user.lastName,
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
- // Initialize userRoles from the data
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.user?.[key];
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.userRoles.set(roles);
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
- updateUser() {
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 updateUserRequest = {
868
- email: editParams.email,
869
- firstName: editParams.firstName ? editParams.firstName : "",
870
- lastName: editParams.lastName ? editParams.lastName : "",
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
- switch (e.status) {
878
- case 409: {
879
- this.error.set(this.#translateService.instant("yuv.app.user-management.users.action.create.error.emailExists"));
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
- this.#dialogRef.close(this.data.user.id);
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.14", ngImport: i0, type: EditUserComponent, deps: [{ token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component });
898
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: EditUserComponent, isStandalone: true, selector: "ymusrmgmt-edit-user", host: { properties: { "class.busy": "!busy()" } }, ngImport: i0, 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"], 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: 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: 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: "component", type: RolesSelectionComponent, selector: "ymusrmgmt-role-selection", inputs: ["allRoles", "initialRoles"], outputs: ["rolesChange"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
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.14", ngImport: i0, type: EditUserComponent, decorators: [{
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-user', standalone: true, imports: [...modules$2, ...components$2], host: {
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
- class YuvUsersPageComponent {
911
- layoutSettingsID = APP_ID + '#root.layout.settings';
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
- #userService = inject(UserService);
918
- #confirmService = inject(ConfirmService);
919
- userDetailsComponent;
920
- pagination = { total: 0, pages: 0, page: 0 };
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
- actions = [];
923
- displayedColumns = ['state', 'name', 'firstName', 'lastName', 'email', 'id'];
924
- icons = {
925
- ...APP_ICONS,
926
- };
927
- dataSource = signal([], ...(ngDevMode ? [{ debugName: "dataSource" }] : /* istanbul ignore next */ []));
928
- selection = signal(new SelectionModel(false, undefined), ...(ngDevMode ? [{ debugName: "selection" }] : /* istanbul ignore next */ []));
929
- detailsActive = true;
930
- layoutOptions = {
931
- resizable: true,
932
- detailsSize: 25,
933
- detailsMinSize: 25,
934
- detailsMaxSize: 75
935
- };
936
- #query = { term: '' };
937
- #updateActions(selectedUser) {
938
- if (!selectedUser) {
939
- this.actions = [];
940
- this.detailsActive = false;
941
- return;
942
- }
943
- const currentUser = this.#userService.getCurrentUser();
944
- const editAction = {
945
- id: 'yuv.app.users.action.edit',
946
- icon: APP_ICONS.actionEditUser,
947
- label: this.#translateService.instant('yuv.app.user-management.users.action.edit.label'),
948
- callback: () => this.editSelectedUser(),
949
- };
950
- const activateAction = {
951
- id: 'yuv.app.users.action.activate',
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
- createNewUser() {
975
- this.#logger.debug('Create new user ...');
976
- this.#dialog.open(CreateUserComponent, {
977
- width: '400px',
978
- maxWidth: '90vw',
979
- }).afterClosed().subscribe((createdUserId) => {
980
- if (createdUserId) {
981
- this.#logger.debug("New user created with ID: " + createdUserId);
982
- this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.users.action.create.success'));
983
- this.refresh();
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
- editSelectedUser() {
988
- const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
989
- if (selected) {
990
- this.#logger.debug(`Edit selected user with id '${selected.id}'...`);
991
- this.#dialog.open(EditUserComponent, {
992
- width: '800px',
993
- maxWidth: '90vw',
994
- data: {
995
- user: this.userDetailsComponent.userDetails()
996
- },
997
- }).afterClosed().subscribe((editedUserId) => {
998
- if (editedUserId) {
999
- this.#logger.debug("User edited with ID: " + editedUserId);
1000
- this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.users.action.edit.success', { username: selected.username }));
1001
- this.refresh(true);
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
- activateSelectedUser() {
1007
- this.updateUserEnabledState(true);
1008
- }
1009
- deactivateSelectedUser() {
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
- performUpdate();
957
+ const group = this.groups()[index - 1];
958
+ if (group)
959
+ this.selectGroup(group);
1050
960
  }
1051
961
  }
1052
- deleteSelectedUser() {
1053
- const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
1054
- if (selected) {
1055
- this.#logger.debug(`Delete selected user with id '${selected.id}'...`);
1056
- this.#userManagementService.deleteUser(selected).subscribe({
1057
- next: (result) => {
1058
- if (result) {
1059
- this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.users.action.delete.success', { username: selected.username }));
1060
- this.deselect();
1061
- this.#loadUserList(this.pagination?.page || 0);
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('Failed to fetch users', e);
1126
- this.busy.set(false);
1005
+ this.#logger.error(`Failed to load details for group with id '${group.id}'`, e);
1127
1006
  },
1128
- complete: () => {
1129
- if (this.dataSource().length === 0 && page > 0) {
1130
- this.#loadUserList(page - 1);
1131
- return;
1132
- }
1133
- const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
1134
- if (selected && this.dataSource().some(u => u.id === selected.id)) {
1135
- const updatedSelected = this.dataSource().find(u => u.id === selected.id);
1136
- this.select(updatedSelected);
1137
- }
1138
- else {
1139
- this.select(this.dataSource()[0]);
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
- this.busy.set(false);
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.14", ngImport: i0, type: YuvUsersPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
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.14", ngImport: i0, type: YuvUsersPageComponent, decorators: [{
1032
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: GroupsComponent, decorators: [{
1149
1033
  type: Component,
1150
- args: [{ selector: 'ymusrmgmt-users', standalone: true, imports: [
1151
- CommonModule,
1034
+ args: [{ selector: 'ymusrmgmt-groups', standalone: true, imports: [
1152
1035
  MatButtonModule,
1153
1036
  MatIconModule,
1154
- MatPaginatorModule,
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
- YuvClientShellModule,
1171
- YuvOverflowMenuModule,
1172
- ], host: {
1173
- '[class.busy]': '!busy()',
1174
- }, 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"] }]
1175
- }], propDecorators: { userDetailsComponent: [{
1176
- type: ViewChild,
1177
- args: [UserDetailsComponent]
1178
- }], pagination: [{
1179
- type: HostBinding,
1180
- args: ['class.pagination']
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
- const components$1 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent, RolesSelectionComponent];
1184
- const modules$1 = [
1185
- CommonModule,
1186
- ReactiveFormsModule,
1187
- TranslatePipe,
1188
- MatFormFieldModule,
1189
- MatSlideToggleModule,
1190
- MatInputModule
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
- groupMembers = signal([], ...(ngDevMode ? [{ debugName: "groupMembers" }] : /* istanbul ignore next */ []));
1204
- name = new FormControl('', [Validators.required, Validators.minLength(3), Validators.maxLength(255), Validators.pattern('^[a-zA-Z0-9._-]*$')]);
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
- switch (e.status) {
1240
- case 409: {
1241
- const errorMsg = JSON.parse(e.error).message;
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: (userId) => {
1257
- createdGroupId = userId;
1349
+ next: (groupId) => {
1350
+ createdGroupId = groupId;
1258
1351
  },
1259
1352
  complete: () => {
1260
1353
  this.busy.set(false);
1261
- this.#dialogRef.close(createdGroupId);
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.14", ngImport: i0, type: CreateGroupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1279
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: CreateGroupComponent, isStandalone: true, selector: "ymusrmgmt-create-group", host: { properties: { "class.busy": "!busy()" } }, ngImport: i0, 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"], 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: 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: "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: "component", type: RolesSelectionComponent, selector: "ymusrmgmt-role-selection", inputs: ["allRoles", "initialRoles"], outputs: ["rolesChange"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
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.14", ngImport: i0, type: CreateGroupComponent, decorators: [{
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$1, ...components$1], host: {
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
- class GroupDetailsComponent {
1289
- userService;
1290
- formBuilder;
1291
- groupDetails = signal(undefined, ...(ngDevMode ? [{ debugName: "groupDetails" }] : /* istanbul ignore next */ []));
1292
- groupDetailsForm;
1293
- currentGroupId;
1294
- set groupId(value) {
1295
- this.currentGroupId = value;
1296
- this.loadGroupDetails();
1297
- }
1298
- loadGroupDetails() {
1299
- if (this.currentGroupId) {
1300
- this.userService.getGroupDetails(this.currentGroupId).subscribe(group => {
1301
- this.groupDetails.set(group);
1302
- this.groupDetailsForm.patchValue({
1303
- id: group.id,
1304
- name: group.name
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.groupDetails.set(undefined);
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
- * Refresh the user details by reloading the data from the server
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
- refresh() {
1316
- this.loadGroupDetails();
1317
- }
1318
- constructor(userService, formBuilder) {
1319
- this.userService = userService;
1320
- this.formBuilder = formBuilder;
1321
- this.groupDetailsForm = this.formBuilder.group({
1322
- id: [{ value: '' }],
1323
- name: [{ value: '' }]
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
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: GroupDetailsComponent, deps: [{ token: YuvUserManagementService }, { token: i1.FormBuilder }], target: i0.ɵɵFactoryTarget.Component });
1327
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: GroupDetailsComponent, isStandalone: true, selector: "ymusrmgmt-group-details", inputs: { groupId: "groupId" }, ngImport: i0, template: " \n@let currentGroupDetails = groupDetails();\n@if (currentGroupDetails) {\n <form [formGroup]=\"groupDetailsForm\" class=\"group-details-form\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{('yuv.app.user-management.groups.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.groups.object.property.name' | translate)}}</mat-label>\n <input matInput formControlName=\"name\" readonly>\n </mat-form-field>\n <mat-form-field>\n <mat-label>{{('yuv.app.user-management.groups.object.property.roles' | translate)}}</mat-label>\n <mat-chip-grid #roleChipGrid>\n @if (currentGroupDetails.roles) {\n @for (role of currentGroupDetails.roles | sortRolesByLabel; track role) {\n <mat-chip-row [editable]=\"false\" [disabled]=\"currentGroupDetails.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-form-field>\n <mat-label>{{('yuv.app.user-management.groups.object.property.members' | translate)}}</mat-label>\n <mat-chip-grid #memberChipGrid>\n @if (currentGroupDetails.members) {\n @for (member of currentGroupDetails.members.sort(); track member) {\n <mat-chip-row [editable]=\"false\"><div class=\"chip\">{{member}}</div></mat-chip-row>\n }\n }\n <input [matChipInputFor]=\"memberChipGrid\" readonly/>\n </mat-chip-grid>\n </mat-form-field> \n </form>\n} @else {\n <p>{{('yuv.app.user-management.groups.details.empty' | translate)}}</p>\n}\n", styles: [":host{height:100%;overflow:hidden;display:flex;flex-flow:column}:host .group-details-form{flex:1;overflow-y:auto;display:grid;align-items:start;align-content:start;gap:var(--ymt-spacing-m);padding:1rem}:host .group-details-form mat-chip-row{overflow:hidden}:host .group-details-form .roles-field mat-chip-grid{max-height:300px;overflow-y:auto;display:block}\n"], dependencies: [{ kind: "ngmodule", type: MatCardModule }, { 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: "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: "ngmodule", type: MatCheckboxModule }, { kind: "ngmodule", type: MatSlideToggleModule }, { 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: 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" }] });
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.14", ngImport: i0, type: GroupDetailsComponent, decorators: [{
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
- MatCardModule,
1333
- MatChipsModule,
1334
- MatFormFieldModule,
1335
- MatInputModule,
1336
- MatIconModule,
1337
- MatCheckboxModule,
1338
- MatSlideToggleModule,
1339
- MatTabsModule,
1340
- MatTooltipModule,
1341
- ReactiveFormsModule,
1342
- TranslatePipe,
1343
- RoleLabelPipe,
1344
- RoleTooltipPipe,
1345
- SortRolesByLabelPipe
1346
- ], template: " \n@let currentGroupDetails = groupDetails();\n@if (currentGroupDetails) {\n <form [formGroup]=\"groupDetailsForm\" class=\"group-details-form\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{('yuv.app.user-management.groups.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.groups.object.property.name' | translate)}}</mat-label>\n <input matInput formControlName=\"name\" readonly>\n </mat-form-field>\n <mat-form-field>\n <mat-label>{{('yuv.app.user-management.groups.object.property.roles' | translate)}}</mat-label>\n <mat-chip-grid #roleChipGrid>\n @if (currentGroupDetails.roles) {\n @for (role of currentGroupDetails.roles | sortRolesByLabel; track role) {\n <mat-chip-row [editable]=\"false\" [disabled]=\"currentGroupDetails.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-form-field>\n <mat-label>{{('yuv.app.user-management.groups.object.property.members' | translate)}}</mat-label>\n <mat-chip-grid #memberChipGrid>\n @if (currentGroupDetails.members) {\n @for (member of currentGroupDetails.members.sort(); track member) {\n <mat-chip-row [editable]=\"false\"><div class=\"chip\">{{member}}</div></mat-chip-row>\n }\n }\n <input [matChipInputFor]=\"memberChipGrid\" readonly/>\n </mat-chip-grid>\n </mat-form-field> \n </form>\n} @else {\n <p>{{('yuv.app.user-management.groups.details.empty' | translate)}}</p>\n}\n", styles: [":host{height:100%;overflow:hidden;display:flex;flex-flow:column}:host .group-details-form{flex:1;overflow-y:auto;display:grid;align-items:start;align-content:start;gap:var(--ymt-spacing-m);padding:1rem}:host .group-details-form mat-chip-row{overflow:hidden}:host .group-details-form .roles-field mat-chip-grid{max-height:300px;overflow-y:auto;display:block}\n"] }]
1347
- }], ctorParameters: () => [{ type: YuvUserManagementService }, { type: i1.FormBuilder }], propDecorators: { groupId: [{
1348
- type: Input
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 = [BusyOverlayDirective, YmtButtonDirective, DialogComponent, RolesSelectionComponent];
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 EditGroupComponent {
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
- allUsers = signal([], ...(ngDevMode ? [{ debugName: "allUsers" }] : /* istanbul ignore next */ []));
1371
- groupRoles = signal([], ...(ngDevMode ? [{ debugName: "groupRoles" }] : /* istanbul ignore next */ []));
1372
- groupMembers = signal([], ...(ngDevMode ? [{ debugName: "groupMembers" }] : /* istanbul ignore next */ []));
1373
- name = new FormControl('');
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
- name: this.name,
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
- name: this.data.group.name,
1391
- roles: this.data.group.roles,
1392
- members: this.data.group.members
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
- this.members.valueChanges.subscribe((v) => { this.#logger.info("members changed: " + v); });
1395
- this.groupRoles.set(this.data.group.roles || []);
1396
- this.groupMembers.set(this.data.group.members || []);
1397
- this.members.disable();
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.sort());
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.group?.[key];
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.groupRoles.set(roles);
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
- onMembersChange(members) {
1428
- this.groupMembers.set(members);
1429
- this.form.patchValue({ members: members });
1430
- this.roles.markAsTouched(); // Mark as touched to trigger validation display
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 updateGroupRequest = {
1437
- name: editParams.name,
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
- switch (e.status) {
1446
- default:
1447
- this.error.set(this.#translateService.instant("yuv.app.user-management.groups.action.edit.error.unknown"));
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.group.id);
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.14", ngImport: i0, type: EditGroupComponent, deps: [{ token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component });
1462
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: EditGroupComponent, isStandalone: true, selector: "ymusrmgmt-edit-group", host: { properties: { "class.busy": "!busy()" } }, ngImport: i0, 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"], 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: 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: 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: 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: "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" }] });
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.14", ngImport: i0, type: EditGroupComponent, decorators: [{
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-group', standalone: true, imports: [...modules, ...components], host: {
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
- class YuvGroupsPageComponent {
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
- groupDetailsComponent;
1482
- actions = [];
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
- displayedColumns = ['name', 'description', 'id'];
1486
- icons = {
1487
- ...APP_ICONS,
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
- selection = signal(new SelectionModel(false, undefined), ...(ngDevMode ? [{ debugName: "selection" }] : /* istanbul ignore next */ []));
1491
- enableDetails = signal(false, ...(ngDevMode ? [{ debugName: "enableDetails" }] : /* istanbul ignore next */ []));
1492
- layoutOptions = {
1493
- resizable: true,
1494
- detailsSize: 25,
1495
- detailsMinSize: 25,
1496
- detailsMaxSize: 75
1497
- };
1498
- #query = { term: '' };
1499
- #updateActions(selectedGroup) {
1500
- if (!selectedGroup) {
1501
- this.actions = [];
1502
- return;
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.groups.action.edit',
1506
- icon: APP_ICONS.actionEditGroup,
1507
- label: this.#translateService.instant('yuv.app.user-management.groups.action.edit.label'),
1508
- callback: () => this.editSelectedGroup(),
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
- this.actions = [editAction];
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.open(CreateGroupComponent, {
1515
- width: '400px',
1516
- maxWidth: '90vw',
1517
- }).afterClosed().subscribe((createdGroupId) => {
1518
- if (createdGroupId) {
1519
- this.#logger.debug("New group created with ID: " + createdGroupId);
1520
- this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.group.action.create.success'));
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
- editSelectedGroup() {
1526
- const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
1527
- if (selected) {
1528
- this.#logger.debug(`Edit selected user with id '${selected.id}'...`);
1529
- this.#dialog.open(EditGroupComponent, {
1530
- width: '800px',
1531
- maxWidth: '90vw',
1532
- data: {
1533
- group: this.groupDetailsComponent.groupDetails()
1534
- },
1535
- }).afterClosed().subscribe((editedGroupId) => {
1536
- if (editedGroupId) {
1537
- this.#logger.debug("Group edited with ID: " + editedGroupId);
1538
- this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.edit.success', { name: selected.name }));
1539
- this.refresh();
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
- deleteSelectedGroup() {
1545
- const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
1546
- if (selected) {
1547
- this.#logger.debug(`Delete selected group with id '${selected.id}'...`);
1548
- this.#userManagementService.deleteGroup(selected).subscribe({
1549
- next: (result) => {
1550
- if (result) {
1551
- this.#notificationService.success(this.#translateService.instant('yuv.app.user-management.groups.action.delete.success', { name: selected.name }));
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
- if (this.selection().isSelected(row)) {
1567
- this.deselect();
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
- else {
1570
- this.selection().select(row);
1571
- this.#updateActions(row);
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.enableDetails.set(true);
2393
+ this.#router.navigate([], {
2394
+ relativeTo: this.#route,
2395
+ queryParamsHandling: 'merge',
2396
+ queryParams,
2397
+ });
1574
2398
  }
1575
- deselect() {
1576
- this.selection().clear();
1577
- this.#updateActions(undefined);
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.selection().selected?.some(item => item.id === row.id);
2427
+ return this.selectedUser()?.id === row.id;
1581
2428
  }
1582
- refresh() {
1583
- this.#loadGroupList(this.pagination?.page || 0);
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
- refreshGroupDetails() {
1586
- if (this.groupDetailsComponent) {
1587
- this.groupDetailsComponent.refresh();
2433
+ refresh(refreshDetails = false) {
2434
+ this.#reloadCurrentPage();
2435
+ if (refreshDetails) {
2436
+ this.refreshUserDetails();
1588
2437
  }
1589
2438
  }
1590
- getPageSize() {
1591
- return YuvUserManagementService.DEFAULT_PAGE_SIZE;
2439
+ refreshUserDetails() {
2440
+ if (this.userDetailsComponent) {
2441
+ this.userDetailsComponent.refresh();
2442
+ }
1592
2443
  }
1593
- onChangePage($event) {
1594
- const { pageIndex } = $event;
1595
- this.#loadGroupList(pageIndex);
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
- onQueryChange(q) {
1598
- this.#query.term = q.term || '';
1599
- this.#loadGroupList(0);
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.#loadGroupList(0);
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
- #loadGroupList(page) {
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
- this.#userManagementService.fetchGroups(page, this.#query.term).subscribe({
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 / YuvUserManagementService.DEFAULT_PAGE_SIZE),
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 groups', e);
2560
+ this.#logger.error('Failed to fetch users', e);
1618
2561
  this.busy.set(false);
1619
2562
  },
1620
2563
  complete: () => {
1621
- const selected = this.selection().hasValue() ? this.selection().selected[0] : undefined;
1622
- if (selected && this.dataSource().some(g => g.id === selected.id)) {
1623
- const updatedSelected = this.dataSource().find(g => g.id === selected.id);
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
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: YuvGroupsPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1635
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.14", type: YuvGroupsPageComponent, isStandalone: true, selector: "ymusrmgmt-groups", host: { properties: { "class.busy": "!busy()", "class.pagination": "this.pagination" } }, viewQueries: [{ propertyName: "groupDetailsComponent", first: true, predicate: GroupDetailsComponent, descendants: true }], ngImport: i0, template: "<ng-template yuvAppHeaderSlot=\"search\">\n</ng-template>\n\n<ymt-master-details-layout [yuvBusyOverlay]=\"busy()\"\n [gutterSize]=\"16\"\n [options]=\"layoutOptions\"\n [layoutSettingsID]=\"layoutSettingsID\">\n <ng-template ymtLayoutPane role=\"master\" [topBarActions]=\"masterPaneActions\">\n <ng-container *ngTemplateOutlet=\"masterPaneContent\"></ng-container>\n </ng-template>\n <ng-template ymtLayoutPane role=\"details\">\n <ng-container *ngTemplateOutlet=\"detailsPaneContent\"></ng-container>\n </ng-template>\n</ymt-master-details-layout>\n\n<ng-template #masterPaneActions>\n <!--h2>{{('yuv.app.user-management.groups.title' | translate)}}</h2-->\n <mat-paginator class=\"paginator\" [length]=\"pagination.total\" [pageIndex]=\"pagination.page\" [pageSize]=\"getPageSize()\" [attr.aria-label]=\"'yuv.app.user-management.groups.navigation.pagination.aria' | translate\" (page)=\"onChangePage($event)\" hidePageSize></mat-paginator>\n <button ymt-icon-button (click)=\"refresh()\" [matTooltip]=\"'yuv.app.user-management.groups.navigation.refresh.tooltip' | translate\" [attr.aria-label]=\"'yuv.app.user-management.groups.navigation.refresh.tooltip' | translate\"><mat-icon>{{icons.refresh}}</mat-icon></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 #masterPaneContent>\n <ymt-pane-body>\n <mat-table [dataSource]=\"dataSource()\" class=\"group-table mat-elevation-z8\" row>\n <ng-container matColumnDef=\"state\">\n <mat-header-cell *matHeaderCellDef />\n <mat-cell *matCellDef=\"let element\"><mat-icon>{{ element.enabled ? icons.stateActive : icons.stateInactive\n }}</mat-icon></mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.groups.object.property.name' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"description\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.groups.object.property.description' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.description}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.groups.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=\"group-row\">\n </mat-row>\n </mat-table>\n </ymt-pane-body>\n</ng-template>\n\n<ng-template #detailsPaneContent>\n <ymt-pane-header [icon]=\"'group'\" [title]=\"'yuv.app.user-management.groups.details.title' | translate\" [subtitle]=\"selection().selected.at(0)?.id || ('yuv.app.user-management.groups.details.empty' | translate)\" [actions]=\"listActions\"></ymt-pane-header>\n <ymt-pane-body>\n <ymusrmgmt-group-details #groupDetails [groupId]=\"selection().selected.at(0)?.id\" />\n </ymt-pane-body>\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 .group-table{overflow:auto;height:100%}:host .group-row:hover{background-color:var(--ymt-hover-background)}:host .selected-row{background-color:var(--ymt-selection-background)!important}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { 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: MatTooltipModule }, { kind: "directive", type: i4$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { 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: 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: RouterModule }, { 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: GroupDetailsComponent, selector: "ymusrmgmt-group-details", inputs: ["groupId"] }, { 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" }] });
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.14", ngImport: i0, type: YuvGroupsPageComponent, decorators: [{
2630
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: YuvUserManagementComponent, decorators: [{
1638
2631
  type: Component,
1639
- args: [{ selector: 'ymusrmgmt-groups', standalone: true, imports: [
1640
- CommonModule,
2632
+ args: [{ selector: 'ymusrmgmt-root', imports: [
1641
2633
  MatButtonModule,
1642
2634
  MatIconModule,
1643
- MatTooltipModule,
1644
- MatTableModule,
2635
+ MatMenuModule,
1645
2636
  MatPaginatorModule,
1646
- MatSortModule,
1647
- RouterModule,
2637
+ MatTableModule,
2638
+ MatTooltipModule,
1648
2639
  TranslatePipe,
2640
+ SimpleSearchComponent,
1649
2641
  BusyOverlayDirective,
1650
2642
  ClickDoubleDirective,
1651
- GroupDetailsComponent,
1652
- YmtIconButtonDirective,
1653
- YmtLayoutModule,
2643
+ UserDetailsComponent,
2644
+ GroupsComponent,
2645
+ PaneToggleButtonComponent,
2646
+ PaneGutterComponent,
2647
+ UserAvatarComponent,
2648
+ GlobalAddButtonComponent,
2649
+ FocusableRowDirective,
2650
+ YmtButtonDirective,
1654
2651
  YmtPanesModule,
1655
2652
  YuvClientShellModule,
1656
- YuvOverflowMenuModule,
1657
- ], host: {
1658
- '[class.busy]': '!busy()',
1659
- }, template: "<ng-template yuvAppHeaderSlot=\"search\">\n</ng-template>\n\n<ymt-master-details-layout [yuvBusyOverlay]=\"busy()\"\n [gutterSize]=\"16\"\n [options]=\"layoutOptions\"\n [layoutSettingsID]=\"layoutSettingsID\">\n <ng-template ymtLayoutPane role=\"master\" [topBarActions]=\"masterPaneActions\">\n <ng-container *ngTemplateOutlet=\"masterPaneContent\"></ng-container>\n </ng-template>\n <ng-template ymtLayoutPane role=\"details\">\n <ng-container *ngTemplateOutlet=\"detailsPaneContent\"></ng-container>\n </ng-template>\n</ymt-master-details-layout>\n\n<ng-template #masterPaneActions>\n <!--h2>{{('yuv.app.user-management.groups.title' | translate)}}</h2-->\n <mat-paginator class=\"paginator\" [length]=\"pagination.total\" [pageIndex]=\"pagination.page\" [pageSize]=\"getPageSize()\" [attr.aria-label]=\"'yuv.app.user-management.groups.navigation.pagination.aria' | translate\" (page)=\"onChangePage($event)\" hidePageSize></mat-paginator>\n <button ymt-icon-button (click)=\"refresh()\" [matTooltip]=\"'yuv.app.user-management.groups.navigation.refresh.tooltip' | translate\" [attr.aria-label]=\"'yuv.app.user-management.groups.navigation.refresh.tooltip' | translate\"><mat-icon>{{icons.refresh}}</mat-icon></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 #masterPaneContent>\n <ymt-pane-body>\n <mat-table [dataSource]=\"dataSource()\" class=\"group-table mat-elevation-z8\" row>\n <ng-container matColumnDef=\"state\">\n <mat-header-cell *matHeaderCellDef />\n <mat-cell *matCellDef=\"let element\"><mat-icon>{{ element.enabled ? icons.stateActive : icons.stateInactive\n }}</mat-icon></mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.groups.object.property.name' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"description\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.groups.object.property.description' | translate\n }}</mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.description}} </mat-cell>\n </ng-container>\n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef>{{'yuv.app.user-management.groups.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=\"group-row\">\n </mat-row>\n </mat-table>\n </ymt-pane-body>\n</ng-template>\n\n<ng-template #detailsPaneContent>\n <ymt-pane-header [icon]=\"'group'\" [title]=\"'yuv.app.user-management.groups.details.title' | translate\" [subtitle]=\"selection().selected.at(0)?.id || ('yuv.app.user-management.groups.details.empty' | translate)\" [actions]=\"listActions\"></ymt-pane-header>\n <ymt-pane-body>\n <ymusrmgmt-group-details #groupDetails [groupId]=\"selection().selected.at(0)?.id\" />\n </ymt-pane-body>\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 .group-table{overflow:auto;height:100%}:host .group-row:hover{background-color:var(--ymt-hover-background)}:host .selected-row{background-color:var(--ymt-selection-background)!important}\n"] }]
1660
- }], propDecorators: { groupDetailsComponent: [{
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: [GroupDetailsComponent]
1663
- }], pagination: [{
1664
- type: HostBinding,
1665
- args: ['class.pagination']
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
- path: '',
1671
- component: YuvUserManagementComponent,
1672
- children: [
1673
- { path: 'users', component: YuvUsersPageComponent },
1674
- { path: 'groups', component: YuvGroupsPageComponent },
1675
- { path: '', redirectTo: 'users', pathMatch: 'full' }
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