diff --git a/console/src/app/modules/filter-org/filter-org.component.ts b/console/src/app/modules/filter-org/filter-org.component.ts index 8e100971d0..220b219358 100644 --- a/console/src/app/modules/filter-org/filter-org.component.ts +++ b/console/src/app/modules/filter-org/filter-org.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -27,9 +27,10 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { constructor( router: Router, + destroyRef: DestroyRef, protected override route: ActivatedRoute, ) { - super(router, route); + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-project/filter-project.component.ts b/console/src/app/modules/filter-project/filter-project.component.ts index b884024c2c..92556d311d 100644 --- a/console/src/app/modules/filter-project/filter-project.component.ts +++ b/console/src/app/modules/filter-project/filter-project.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -23,8 +23,8 @@ export class FilterProjectComponent extends FilterComponent implements OnInit { public searchQueries: ProjectQuery[] = []; public states: ProjectState[] = [ProjectState.PROJECT_STATE_ACTIVE, ProjectState.PROJECT_STATE_INACTIVE]; - constructor(router: Router, route: ActivatedRoute) { - super(router, route); + constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) { + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts b/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts index 3c17e8c208..dccaed13e5 100644 --- a/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts +++ b/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -29,8 +29,8 @@ export class FilterUserGrantsComponent extends FilterComponent implements OnInit public SubQuery: any = SubQuery; public searchQueries: UserGrantQuery[] = []; - constructor(router: Router, route: ActivatedRoute) { - super(router, route); + constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) { + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-user/filter-user.component.html b/console/src/app/modules/filter-user/filter-user.component.html index c5d3d9a820..907ea6d18d 100644 --- a/console/src/app/modules/filter-user/filter-user.component.html +++ b/console/src/app/modules/filter-user/filter-user.component.html @@ -1,4 +1,4 @@ - +
{ - const { filter } = params; - if (filter) { - const stringifiedFilters = filter as string; + this.route.queryParamMap + .pipe( + take(1), + map((params) => params.get('filter')), + filter(Boolean), + ) + .subscribe((stringifiedFilters) => { const filters: UserSearchQuery.AsObject[] = JSON.parse(stringifiedFilters) as UserSearchQuery.AsObject[]; const userQueries = filters.map((filter) => { @@ -94,8 +97,7 @@ export class FilterUserComponent extends FilterComponent implements OnInit { this.filterChanged.emit(this.searchQueries ? this.searchQueries : []); // this.showFilter = true; // this.filterOpen.emit(true); - } - }); + }); } public changeCheckbox(subquery: SubQuery, event: MatCheckboxChange) { diff --git a/console/src/app/modules/filter/filter.component.ts b/console/src/app/modules/filter/filter.component.ts index ce2cc15c08..dac94525d9 100644 --- a/console/src/app/modules/filter/filter.component.ts +++ b/console/src/app/modules/filter/filter.component.ts @@ -1,7 +1,6 @@ import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay'; -import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, Output } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Observable, Subject, takeUntil } from 'rxjs'; import { SearchQuery as MemberSearchQuery } from 'src/app/proto/generated/zitadel/member_pb'; import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb'; import { OrgQuery } from 'src/app/proto/generated/zitadel/org_pb'; @@ -9,6 +8,7 @@ import { ProjectQuery } from 'src/app/proto/generated/zitadel/project_pb'; import { SearchQuery as UserSearchQuery, UserGrantQuery } from 'src/app/proto/generated/zitadel/user_pb'; import { ActionKeysType } from '../action-keys/action-keys.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; type FilterSearchQuery = UserSearchQuery | MemberSearchQuery | UserGrantQuery | ProjectQuery | OrgQuery; type FilterSearchQueryAsObject = @@ -23,7 +23,7 @@ type FilterSearchQueryAsObject = templateUrl: './filter.component.html', styleUrls: ['./filter.component.scss'], }) -export class FilterComponent implements OnDestroy { +export class FilterComponent { @Output() public filterChanged: EventEmitter = new EventEmitter(); @Output() public filterOpen: EventEmitter = new EventEmitter(false); @@ -32,9 +32,6 @@ export class FilterComponent implements OnDestroy { @Input() public queryCount: number = 0; - private destroy$: Subject = new Subject(); - public filterChanged$: Observable = this.filterChanged.asObservable(); - public showFilter: boolean = false; public methods: TextQueryMethod[] = [ TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, @@ -59,17 +56,13 @@ export class FilterComponent implements OnDestroy { this.trigger.emit(); } - public ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - constructor( private router: Router, protected route: ActivatedRoute, + destroyRef: DestroyRef, ) { const changes$ = this.filterChanged.asObservable(); - changes$.pipe(takeUntil(this.destroy$)).subscribe((queries) => { + changes$.pipe(takeUntilDestroyed(destroyRef)).subscribe((queries) => { const filters: Array | undefined = queries ?.map((q) => q.toObject()) .map((query) => @@ -81,15 +74,17 @@ export class FilterComponent implements OnDestroy { ); if (filters && Object.keys(filters)) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { - ['filter']: JSON.stringify(filters), - }, - replaceUrl: true, - queryParamsHandling: 'merge', - skipLocationChange: false, - }); + this.router + .navigate([], { + relativeTo: this.route, + queryParams: { + ['filter']: JSON.stringify(filters), + }, + replaceUrl: true, + queryParamsHandling: 'merge', + skipLocationChange: false, + }) + .then(); } }); } diff --git a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html index e244ee886e..80f985eb24 100644 --- a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html +++ b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html @@ -2,12 +2,11 @@ - {{ 'USER.PAGES.NOUSER' | translate }} - + @@ -124,15 +123,14 @@ & { type: { case: 'human'; value: Human } }; +type UserWithHumanType = Omit & { type: { case: 'human'; value: HumanUser } }; @Component({ selector: 'cnsl-auth-user-detail', @@ -67,17 +62,17 @@ type UserWithHumanType = Omit & { type: { case: 'human'; value: Hu styleUrls: ['./auth-user-detail.component.scss'], }) export class AuthUserDetailComponent implements OnInit { - public genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE]; + protected readonly genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE]; - public ChangeType: any = ChangeType; + protected readonly ChangeType = ChangeType; public userLoginMustBeDomain: boolean = false; - public UserState: any = UserState; + protected readonly UserState = UserState; - public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; - public refreshChanges$: EventEmitter = new EventEmitter(); - public refreshMetadata$ = new Subject(); + protected USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; + protected readonly refreshChanges$: EventEmitter = new EventEmitter(); + protected readonly refreshMetadata$ = new Subject(); - public settingsList: SidenavSetting[] = [ + protected readonly settingsList: SidenavSetting[] = [ { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' }, { id: 'security', i18nKey: 'USER.SETTINGS.SECURITY' }, { id: 'idp', i18nKey: 'USER.SETTINGS.IDP' }, @@ -92,9 +87,9 @@ export class AuthUserDetailComponent implements OnInit { protected readonly user$: Observable; protected readonly metadata$: Observable; private readonly savedLanguage$: Observable; - protected currentSetting$: Observable; - public loginPolicy$: Observable; - protected userName$: Observable; + protected readonly currentSetting$: Observable; + protected readonly loginPolicy$: Observable; + protected readonly userName$: Observable; constructor( public translate: TranslateService, @@ -209,13 +204,8 @@ export class AuthUserDetailComponent implements OnInit { } private getMyUser(): Observable { - return defer(() => this.newAuthService.getMyUser()).pipe( - map(({ user }) => { - if (user) { - return { state: 'success', value: user } as const; - } - return { state: 'notfound' } as const; - }), + return defer(() => this.userService.getMyUser()).pipe( + map((user) => ({ state: 'success' as const, value: user })), catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)), startWith({ state: 'loading' } as const), ); @@ -232,7 +222,7 @@ export class AuthUserDetailComponent implements OnInit { if (!user.value) { return EMPTY; } - return this.getMetadataById(user.value.id); + return this.getMetadataById(user.value.userId); }), pairwiseStartWith(undefined), map(([prev, curr]) => { @@ -259,7 +249,7 @@ export class AuthUserDetailComponent implements OnInit { labelKey: 'ACTIONS.NEWVALUE' as const, titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE' as const, descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC' as const, - value: user.userName, + value: user.username, }; const dialogRef = this.dialog.open(EditDialogComponent, { data, @@ -271,8 +261,8 @@ export class AuthUserDetailComponent implements OnInit { .pipe( map((value) => value?.value), filter(Boolean), - filter((value) => user.userName != value), - switchMap((username) => this.userService.updateUser({ userId: user.id, username })), + filter((value) => user.username != value), + switchMap((username) => this.userService.updateUser({ userId: user.userId, username })), ) .subscribe({ next: () => { @@ -288,7 +278,7 @@ export class AuthUserDetailComponent implements OnInit { public saveProfile(user: User, profile: HumanProfile): void { this.userService .updateUser({ - userId: user.id, + userId: user.userId, profile: { givenName: profile.givenName, familyName: profile.familyName, @@ -350,7 +340,7 @@ export class AuthUserDetailComponent implements OnInit { public resendEmailVerification(user: User): void { this.newMgmtService - .resendHumanEmailVerification(user.id) + .resendHumanEmailVerification(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true); this.refreshChanges$.emit(); @@ -362,7 +352,7 @@ export class AuthUserDetailComponent implements OnInit { public resendPhoneVerification(user: User): void { this.newMgmtService - .resendHumanPhoneVerification(user.id) + .resendHumanPhoneVerification(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true); this.refreshChanges$.emit(); @@ -374,7 +364,7 @@ export class AuthUserDetailComponent implements OnInit { public deletePhone(user: User): void { this.userService - .removePhone(user.id) + .removePhone(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.PHONEREMOVED', true); this.refreshChanges$.emit(); @@ -417,7 +407,7 @@ export class AuthUserDetailComponent implements OnInit { filter((resp): resp is Required => !!resp?.value), switchMap(({ value, isVerified }) => this.userService.setEmail({ - userId: user.id, + userId: user.userId, email: value, verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined }, }), @@ -453,7 +443,7 @@ export class AuthUserDetailComponent implements OnInit { .pipe( map((resp) => formatPhone(resp?.value)), filter(Boolean), - switchMap(({ phone }) => this.userService.setPhone({ userId: user.id, phone })), + switchMap(({ phone }) => this.userService.setPhone({ userId: user.userId, phone })), ) .subscribe({ next: () => { @@ -482,7 +472,7 @@ export class AuthUserDetailComponent implements OnInit { .afterClosed() .pipe( filter(Boolean), - switchMap(() => this.userService.deleteUser(user.id)), + switchMap(() => this.userService.deleteUser(user.userId)), ) .subscribe({ next: () => { @@ -498,9 +488,9 @@ export class AuthUserDetailComponent implements OnInit { this.newMgmtService.setUserMetadata({ key, value: Buffer.from(value), - id: user.id, + id: user.userId, }); - const removeFcn = (key: string): Promise => this.newMgmtService.removeUserMetadata({ key, id: user.id }); + const removeFcn = (key: string): Promise => this.newMgmtService.removeUserMetadata({ key, id: user.userId }); const dialogRef = this.dialog.open(MetadataDialogComponent, { data: { diff --git a/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html b/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html index 8f52fc3b98..4c5fa510de 100644 --- a/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html +++ b/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html @@ -2,7 +2,7 @@
{{ 'USER.MACHINE.USERNAME' | translate }} - + {{ 'USER.MACHINE.NAME' | translate }} diff --git a/console/src/app/pages/users/user-list/user-list.component.html b/console/src/app/pages/users/user-list/user-list.component.html index b7d2110245..132b5b7942 100644 --- a/console/src/app/pages/users/user-list/user-list.component.html +++ b/console/src/app/pages/users/user-list/user-list.component.html @@ -1,5 +1,5 @@
-
+

{{ 'DESCRIPTIONS.USERS.TITLE' | translate }}

@@ -7,21 +7,6 @@

{{ 'DESCRIPTIONS.USERS.DESCRIPTION' | translate }}

- - - - - - - - +
diff --git a/console/src/app/pages/users/user-list/user-list.component.ts b/console/src/app/pages/users/user-list/user-list.component.ts index 9773f89dd6..ffb0b96b64 100644 --- a/console/src/app/pages/users/user-list/user-list.component.ts +++ b/console/src/app/pages/users/user-list/user-list.component.ts @@ -1,8 +1,5 @@ import { Component } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { take } from 'rxjs/operators'; -import { Type } from 'src/app/proto/generated/zitadel/user_pb'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; @Component({ @@ -11,23 +8,10 @@ import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/ styleUrls: ['./user-list.component.scss'], }) export class UserListComponent { - public Type: any = Type; - public type: Type = Type.TYPE_HUMAN; - constructor( - public translate: TranslateService, - activatedRoute: ActivatedRoute, + protected readonly translate: TranslateService, breadcrumbService: BreadcrumbService, ) { - activatedRoute.queryParams.pipe(take(1)).subscribe((params: Params) => { - const { type } = params; - if (type && type === 'human') { - this.type = Type.TYPE_HUMAN; - } else if (type && type === 'machine') { - this.type = Type.TYPE_MACHINE; - } - }); - const bread: Breadcrumb = { type: BreadcrumbType.ORG, routerLink: ['/org'], diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.html b/console/src/app/pages/users/user-list/user-table/user-table.component.html index c40127dcc2..07583b5626 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.html +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.html @@ -1,11 +1,11 @@
@@ -60,12 +60,12 @@
@@ -85,7 +85,7 @@
- +
@@ -133,12 +133,7 @@ -
+ {{ 'USER.PROFILE.DISPLAYNAME' | translate }} @@ -148,12 +143,7 @@ - + {{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }} @@ -162,12 +152,7 @@ - + {{ 'USER.PROFILE.USERNAME' | translate }} @@ -176,12 +161,7 @@ - + {{ 'USER.EMAIL' | translate }} @@ -250,17 +230,16 @@
-
+
{{ 'USER.TABLE.EMPTY' | translate }}
+ diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.ts b/console/src/app/pages/users/user-list/user-table/user-table.component.ts index e894472d74..79481de63d 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.ts +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.ts @@ -1,34 +1,45 @@ -import { LiveAnnouncer } from '@angular/cdk/a11y'; import { SelectionModel } from '@angular/cdk/collections'; -import { Component, EventEmitter, Input, OnInit, Output, Signal, ViewChild } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, signal, Signal, ViewChild } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { MatSort, Sort } from '@angular/material/sort'; +import { MatSort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { + combineLatestWith, + defer, + delay, + distinctUntilChanged, + EMPTY, + from, + Observable, + of, + ReplaySubject, + shareReplay, + switchMap, + toArray, +} from 'rxjs'; +import { catchError, filter, finalize, map, startWith, take } from 'rxjs/operators'; import { enterAnimations } from 'src/app/animations'; import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component'; -import { PageEvent, PaginatorComponent } from 'src/app/modules/paginator/paginator.component'; +import { PaginatorComponent } from 'src/app/modules/paginator/paginator.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; -import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ToastService } from 'src/app/services/toast.service'; import { UserService } from 'src/app/services/user.service'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { User } from 'src/app/proto/generated/zitadel/user_pb'; -import { SearchQuery, SearchQuerySchema, Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; -import { UserState, User as UserV2 } from '@zitadel/proto/zitadel/user/v2/user_pb'; -import { create } from '@bufbuild/protobuf'; -import { Timestamp } from '@bufbuild/protobuf/wkt'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { SearchQuery as UserSearchQuery } from 'src/app/proto/generated/zitadel/user_pb'; +import { Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; +import { UserState, User } from '@zitadel/proto/zitadel/user/v2/user_pb'; +import { MessageInitShape } from '@bufbuild/protobuf'; +import { ListUsersRequestSchema, ListUsersResponse } from '@zitadel/proto/zitadel/user/v2/user_service_pb'; +import { AuthenticationService } from 'src/app/services/authentication.service'; +import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { UserState as UserStateV1 } from 'src/app/proto/generated/zitadel/user_pb'; -enum UserListSearchKey { - FIRST_NAME, - LAST_NAME, - DISPLAY_NAME, - USER_NAME, - EMAIL, -} +type Query = Exclude< + Exclude['queries'], undefined>[number]['query'], + undefined +>; @Component({ selector: 'cnsl-user-table', @@ -37,25 +48,33 @@ enum UserListSearchKey { animations: [enterAnimations], }) export class UserTableComponent implements OnInit { - public userSearchKey: UserListSearchKey | undefined = undefined; - public Type = Type; - @Input() public type: Type = Type.HUMAN; - @Input() refreshOnPreviousRoutes: string[] = []; + protected readonly Type = Type; + protected readonly refresh$ = new ReplaySubject(1); + @Input() public canWrite$: Observable = of(false); @Input() public canDelete$: Observable = of(false); - private user: Signal = toSignal(this.authService.user, { requireSync: true }); + protected readonly dataSize: Signal; + protected readonly loading = signal(false); - @ViewChild(PaginatorComponent) public paginator!: PaginatorComponent; - @ViewChild(MatSort) public sort!: MatSort; - public INITIAL_PAGE_SIZE: number = 20; + private readonly paginator$ = new ReplaySubject(1); + @ViewChild(PaginatorComponent) public set paginator(paginator: PaginatorComponent) { + this.paginator$.next(paginator); + } + private readonly sort$ = new ReplaySubject(1); + @ViewChild(MatSort) public set sort(sort: MatSort) { + this.sort$.next(sort); + } + + protected readonly INITIAL_PAGE_SIZE = 20; + + protected readonly dataSource: MatTableDataSource = new MatTableDataSource(); + protected readonly selection: SelectionModel = new SelectionModel(true, []); + protected readonly users$: Observable; + protected readonly type$: Observable; + protected readonly searchQueries$ = new ReplaySubject(1); + protected readonly myUser: Signal; - public viewTimestamp!: Timestamp; - public totalResult: number = 0; - public dataSource: MatTableDataSource = new MatTableDataSource(); - public selection: SelectionModel = new SelectionModel(true, []); - private loadingSubject: BehaviorSubject = new BehaviorSubject(false); - public loading$: Observable = this.loadingSubject.asObservable(); @Input() public displayedColumnsHuman: string[] = [ 'select', 'displayName', @@ -76,46 +95,56 @@ export class UserTableComponent implements OnInit { 'actions', ]; - @Output() public changedSelection: EventEmitter> = new EventEmitter(); + @Output() public changedSelection: EventEmitter> = new EventEmitter(); - public UserState: any = UserState; - public UserListSearchKey: any = UserListSearchKey; + protected readonly UserState = UserState; - public ActionKeysType: any = ActionKeysType; - public filterOpen: boolean = false; + protected ActionKeysType = ActionKeysType; + protected filterOpen: boolean = false; - private searchQueries: SearchQuery[] = []; constructor( - private router: Router, - public translate: TranslateService, - private authService: GrpcAuthService, - private userService: UserService, - private toast: ToastService, - private dialog: MatDialog, - private route: ActivatedRoute, - private _liveAnnouncer: LiveAnnouncer, + protected readonly router: Router, + public readonly translate: TranslateService, + private readonly userService: UserService, + private readonly toast: ToastService, + private readonly dialog: MatDialog, + private readonly route: ActivatedRoute, + private readonly destroyRef: DestroyRef, + private readonly authenticationService: AuthenticationService, + private readonly authService: GrpcAuthService, ) { - this.selection.changed.subscribe(() => { - this.changedSelection.emit(this.selection.selected); - }); + this.type$ = this.getType$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); + this.users$ = this.getUsers(this.type$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); + this.myUser = toSignal(this.getMyUser()); + + this.dataSize = toSignal( + this.users$.pipe( + map((users) => users.result.length), + distinctUntilChanged(), + ), + { initialValue: 0 }, + ); } ngOnInit(): void { - this.route.queryParams.pipe(take(1)).subscribe((params) => { - if (!params['filter']) { - this.getData(this.INITIAL_PAGE_SIZE, 0, this.type, this.searchQueries).then(); - } - - if (params['deferredReload']) { - setTimeout(() => { - this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize, this.type).then(); - }, 2000); - } + this.selection.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.changedSelection.emit(this.selection.selected); }); + + this.users$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((users) => (this.dataSource.data = users.result)); + + this.route.queryParamMap + .pipe( + map((params) => params.get('deferredReload')), + filter(Boolean), + take(1), + delay(2000), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => this.refresh$.next(true)); } - public setType(type: Type): void { - this.type = type; + setType(type: Type) { this.router .navigate([], { relativeTo: this.route, @@ -127,12 +156,195 @@ export class UserTableComponent implements OnInit { skipLocationChange: false, }) .then(); - this.getData( - this.paginator.pageSize, - this.paginator.pageIndex * this.paginator.pageSize, - this.type, - this.searchQueries, - ).then(); + } + + private getMyUser() { + return defer(() => this.userService.getMyUser()).pipe( + catchError((error) => { + this.toast.showError(error); + return EMPTY; + }), + ); + } + + private getType$(): Observable { + return this.route.queryParamMap.pipe( + map((params) => params.get('type')), + filter(Boolean), + map((type) => (type === 'machine' ? Type.MACHINE : Type.HUMAN)), + startWith(Type.HUMAN), + distinctUntilChanged(), + ); + } + + private getDirection$() { + return this.sort$.pipe( + switchMap((sort) => + sort.sortChange.pipe( + map(({ direction }) => direction), + startWith(sort.direction), + ), + ), + distinctUntilChanged(), + ); + } + + private getSortingColumn$() { + return this.sort$.pipe( + switchMap((sort) => + sort.sortChange.pipe( + map(({ active }) => active), + startWith(sort.active), + ), + ), + map((active) => { + switch (active) { + case 'displayName': + return UserFieldName.DISPLAY_NAME; + case 'username': + return UserFieldName.USER_NAME; + case 'preferredLoginName': + // TODO: replace with preferred username sorting once implemented + return UserFieldName.USER_NAME; + case 'email': + return UserFieldName.EMAIL; + case 'state': + return UserFieldName.STATE; + case 'creationDate': + return UserFieldName.CREATION_DATE; + default: + return undefined; + } + }), + distinctUntilChanged(), + ); + } + + private getQueries(type$: Observable): Observable { + const activeOrgId$ = this.getActiveOrgId(); + + return this.searchQueries$.pipe( + startWith([]), + combineLatestWith(type$, activeOrgId$), + switchMap(([queries, type, organizationId]) => + from(queries).pipe( + map((query) => this.searchQueryToV2(query.toObject())), + startWith({ case: 'typeQuery' as const, value: { type } }), + startWith(organizationId ? { case: 'organizationIdQuery' as const, value: { organizationId } } : undefined), + filter(Boolean), + toArray(), + ), + ), + ); + } + + private searchQueryToV2(query: UserSearchQuery.AsObject): Query | undefined { + if (query.userNameQuery) { + return { + case: 'userNameQuery' as const, + value: { + userName: query.userNameQuery.userName, + method: query.userNameQuery.method as unknown as any, + }, + }; + } else if (query.displayNameQuery) { + return { + case: 'displayNameQuery' as const, + value: { + displayName: query.displayNameQuery.displayName, + method: query.displayNameQuery.method as unknown as any, + }, + }; + } else if (query.emailQuery) { + return { + case: 'emailQuery' as const, + value: { + emailAddress: query.emailQuery.emailAddress, + method: query.emailQuery.method as unknown as any, + }, + }; + } else if (query.stateQuery) { + return { + case: 'stateQuery' as const, + value: { + state: this.toV2State(query.stateQuery.state), + }, + }; + } else { + return undefined; + } + } + + private toV2State(state: UserStateV1) { + switch (state) { + case UserStateV1.USER_STATE_ACTIVE: + return UserState.ACTIVE; + case UserStateV1.USER_STATE_INACTIVE: + return UserState.INACTIVE; + case UserStateV1.USER_STATE_DELETED: + return UserState.DELETED; + case UserStateV1.USER_STATE_LOCKED: + return UserState.LOCKED; + case UserStateV1.USER_STATE_INITIAL: + return UserState.INITIAL; + default: + throw new Error(`Invalid UserState ${state}`); + } + } + + private getUsers(type$: Observable) { + const queries$ = this.getQueries(type$); + const direction$ = this.getDirection$(); + const sortingColumn$ = this.getSortingColumn$(); + + const page$ = this.paginator$.pipe(switchMap((paginator) => paginator.page)); + const pageSize$ = page$.pipe( + map(({ pageSize }) => pageSize), + startWith(this.INITIAL_PAGE_SIZE), + distinctUntilChanged(), + ); + const pageIndex$ = page$.pipe( + map(({ pageIndex }) => pageIndex), + startWith(0), + distinctUntilChanged(), + ); + + return this.refresh$.pipe( + startWith(true), + combineLatestWith(queries$, direction$, sortingColumn$, pageSize$, pageIndex$), + switchMap(([_, queries, direction, sortingColumn, pageSize, pageIndex]) => { + return this.fetchUsers(queries, direction, sortingColumn, pageSize, pageIndex); + }), + ); + } + + private fetchUsers( + queries: Query[], + direction: SortDirection, + sortingColumn: UserFieldName | undefined, + pageSize: number, + pageIndex: number, + ) { + return defer(() => { + const req = { + query: { + limit: pageSize, + offset: BigInt(pageIndex * pageSize), + asc: direction === 'asc', + }, + sortingColumn, + queries: queries.map((query) => ({ query })), + }; + + this.loading.set(true); + return this.userService.listUsers(req); + }).pipe( + catchError((error) => { + this.toast.showError(error); + return EMPTY; + }), + finalize(() => this.loading.set(false)), + ); } public isAllSelected(): boolean { @@ -145,147 +357,49 @@ export class UserTableComponent implements OnInit { this.isAllSelected() ? this.selection.clear() : this.dataSource.data.forEach((row) => this.selection.select(row)); } - public changePage(event: PageEvent): void { - this.selection.clear(); - this.getData(event.pageSize, event.pageIndex * event.pageSize, this.type, this.searchQueries).then(); - } - - public deactivateSelectedUsers(): void { + public async deactivateSelectedUsers(): Promise { const usersToDeactivate = this.selection.selected .filter((u) => u.state === UserState.ACTIVE) .map((value) => { return this.userService.deactivateUser(value.userId); }); - Promise.all(usersToDeactivate) - .then(() => { - this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true); - this.selection.clear(); - setTimeout(() => { - this.refreshPage(); - }, 1000); - }) - .catch((error) => { - this.toast.showError(error); - }); + try { + await Promise.all(usersToDeactivate); + } catch (error) { + this.toast.showError(error); + return; + } + + this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true); + this.selection.clear(); + setTimeout(() => { + this.refresh$.next(true); + }, 1000); } - public reactivateSelectedUsers(): void { + public async reactivateSelectedUsers(): Promise { const usersToReactivate = this.selection.selected .filter((u) => u.state === UserState.INACTIVE) .map((value) => { return this.userService.reactivateUser(value.userId); }); - Promise.all(usersToReactivate) - .then(() => { - this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true); - this.selection.clear(); - setTimeout(() => { - this.refreshPage(); - }, 1000); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - - public gotoRouterLink(rL: any): Promise { - return this.router.navigate(rL); - } - - private async getData(limit: number, offset: number, type: Type, searchQueries?: SearchQuery[]): Promise { - this.loadingSubject.next(true); - - let queryT = create(SearchQuerySchema, { - query: { - case: 'typeQuery', - value: { - type, - }, - }, - }); - - let sortingField: UserFieldName | undefined = undefined; - if (this.sort?.active && this.sort?.direction) - switch (this.sort.active) { - case 'displayName': - sortingField = UserFieldName.DISPLAY_NAME; - break; - case 'username': - sortingField = UserFieldName.USER_NAME; - break; - case 'preferredLoginName': - // TODO: replace with preferred username sorting once implemented - sortingField = UserFieldName.USER_NAME; - break; - case 'email': - sortingField = UserFieldName.EMAIL; - break; - case 'state': - sortingField = UserFieldName.STATE; - break; - case 'creationDate': - sortingField = UserFieldName.CREATION_DATE; - break; - } - - this.userService - .listUsers( - limit, - offset, - searchQueries?.length ? [queryT, ...searchQueries] : [queryT], - sortingField, - this.sort?.direction, - ) - .then((resp) => { - if (resp.details?.totalResult) { - this.totalResult = Number(resp.details.totalResult); - } else { - this.totalResult = 0; - } - if (resp.details?.timestamp) { - this.viewTimestamp = resp.details?.timestamp; - } - this.dataSource.data = resp.result; - this.loadingSubject.next(false); - }) - .catch((error) => { - this.toast.showError(error); - this.loadingSubject.next(false); - }); - } - - public refreshPage(): void { - this.getData( - this.paginator.pageSize, - this.paginator.pageIndex * this.paginator.pageSize, - this.type, - this.searchQueries, - ).then(); - } - - public sortChange(sortState: Sort) { - if (sortState.direction && sortState.active) { - this._liveAnnouncer.announce(`Sorted ${sortState.direction} ending`).then(); - this.refreshPage(); - } else { - this._liveAnnouncer.announce('Sorting cleared').then(); + try { + await Promise.all(usersToReactivate); + } catch (error) { + this.toast.showError(error); + return; } - } - public applySearchQuery(searchQueries: SearchQuery[]): void { + this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true); this.selection.clear(); - this.searchQueries = searchQueries; - this.getData( - this.paginator ? this.paginator.pageSize : this.INITIAL_PAGE_SIZE, - this.paginator ? this.paginator.pageIndex * this.paginator.pageSize : 0, - this.type, - searchQueries, - ).then(); + setTimeout(() => { + this.refresh$.next(true); + }, 1000); } - public deleteUser(user: UserV2): void { + public deleteUser(user: User): void { const authUserData = { confirmKey: 'ACTIONS.DELETE', cancelKey: 'ACTIONS.CANCEL', @@ -309,8 +423,9 @@ export class UserTableComponent implements OnInit { }; if (user?.userId) { - const authUser = this.user(); - const isMe = authUser?.id === user.userId; + const authUser = this.myUser(); + console.log('my user', authUser); + const isMe = authUser?.userId === user.userId; let dialogRef; @@ -326,22 +441,22 @@ export class UserTableComponent implements OnInit { }); } - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.userService - .deleteUser(user.userId) - .then(() => { - setTimeout(() => { - this.refreshPage(); - }, 1000); - this.selection.clear(); - this.toast.showInfo('USER.TOAST.DELETED', true); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); + dialogRef + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.userService.deleteUser(user.userId)), + ) + .subscribe({ + next: () => { + setTimeout(() => { + this.refresh$.next(true); + }, 1000); + this.selection.clear(); + this.toast.showInfo('USER.TOAST.DELETED', true); + }, + error: (err) => this.toast.showError(err), + }); } } @@ -354,4 +469,20 @@ export class UserTableComponent implements OnInit { const selected = this.selection.selected; return selected ? selected.findIndex((user) => user.state !== UserState.INACTIVE) > -1 : false; } + + private getActiveOrgId() { + return this.authenticationService.authenticationChanged.pipe( + startWith(true), + filter(Boolean), + switchMap(() => + from(this.authService.getActiveOrg()).pipe( + catchError((err) => { + this.toast.showError(err); + return of(undefined); + }), + ), + ), + map((org) => org?.id), + ); + } } diff --git a/console/src/app/services/user.service.ts b/console/src/app/services/user.service.ts index 328d3c7e0f..d0388571d3 100644 --- a/console/src/app/services/user.service.ts +++ b/console/src/app/services/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { GrpcService } from './grpc.service'; import { AddHumanUserRequestSchema, @@ -11,7 +11,6 @@ import { DeactivateUserResponse, DeleteUserRequestSchema, DeleteUserResponse, - GetUserByIDRequestSchema, GetUserByIDResponse, ListAuthenticationFactorsRequestSchema, ListAuthenticationFactorsResponse, @@ -65,60 +64,87 @@ import { } from '@zitadel/proto/zitadel/user/v2/user_pb'; import { create } from '@bufbuild/protobuf'; import { Timestamp as TimestampV2, TimestampSchema } from '@bufbuild/protobuf/wkt'; -import { Details, DetailsSchema, ListQuerySchema } from '@zitadel/proto/zitadel/object/v2/object_pb'; -import { SearchQuery, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; -import { SortDirection } from '@angular/material/sort'; +import { Details, DetailsSchema } from '@zitadel/proto/zitadel/object/v2/object_pb'; import { Human, Machine, Phone, Profile, User } from '../proto/generated/zitadel/user_pb'; import { ObjectDetails } from '../proto/generated/zitadel/object_pb'; import { Timestamp } from '../proto/generated/google/protobuf/timestamp_pb'; import { HumanPhone, HumanPhoneSchema } from '@zitadel/proto/zitadel/user/v2/phone_pb'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { firstValueFrom, Observable, shareReplay } from 'rxjs'; +import { filter, map, startWith, tap, timeout } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Injectable({ providedIn: 'root', }) export class UserService { - constructor(private readonly grpcService: GrpcService) {} + private readonly userId$: Observable; + private user: UserV2 | undefined; + + constructor( + private readonly grpcService: GrpcService, + private readonly oauthService: OAuthService, + destroyRef: DestroyRef, + ) { + this.userId$ = this.getUserId().pipe(shareReplay({ refCount: true, bufferSize: 1 })); + + // this preloads the userId and deletes the cache everytime the userId changes + this.userId$.pipe(takeUntilDestroyed(destroyRef)).subscribe(async () => { + this.user = undefined; + try { + await this.getMyUser(); + } catch (error) { + console.warn(error); + } + }); + } + + private getUserId() { + return this.oauthService.events.pipe( + filter((event) => event.type === 'token_received'), + startWith(this.oauthService.getIdToken), + map(() => this.oauthService.getIdToken()), + filter(Boolean), + // split jwt and get base64 encoded payload + map((token) => token.split('.')[1]), + // decode payload + map(atob), + // parse payload + map((payload) => JSON.parse(payload)), + map((payload: unknown) => { + // check if sub is in payload and is a string + if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') { + return payload.sub; + } + throw new Error('Invalid payload'); + }), + ); + } public addHumanUser(req: MessageInitShape): Promise { return this.grpcService.userNew.addHumanUser(create(AddHumanUserRequestSchema, req)); } - public listUsers( - limit: number, - offset: number, - queriesList?: SearchQuery[], - sortingColumn?: UserFieldName, - sortingDirection?: SortDirection, - ): Promise { - const query = create(ListQuerySchema); - - if (limit) { - query.limit = limit; - } - if (offset) { - query.offset = BigInt(offset); - } - if (sortingDirection) { - query.asc = sortingDirection === 'asc'; - } - - const req = create(ListUsersRequestSchema, { - query, - }); - - if (sortingColumn) { - req.sortingColumn = sortingColumn; - } - - if (queriesList) { - req.queries = queriesList; - } - + public listUsers(req: MessageInitShape): Promise { return this.grpcService.userNew.listUsers(req); } + public async getMyUser(): Promise { + const userId = await firstValueFrom(this.userId$.pipe(timeout(2000))); + if (this.user) { + return this.user; + } + const resp = await this.getUserById(userId); + if (!resp.user) { + throw new Error("Couldn't find user"); + } + + this.user = resp.user; + return resp.user; + } + public getUserById(userId: string): Promise { - return this.grpcService.userNew.getUserByID(create(GetUserByIDRequestSchema, { userId })); + return this.grpcService.userNew.getUserByID({ userId }); } public deactivateUser(userId: string): Promise {