fix: v2 user console errors (#9386)

# Which Problems Are Solved

- Fixed filtering in overview
- Only get users from current organization
- Use V2 api to get auth user

# How the Problems Are Solved

Added the organization filter to the List queries
Get current User ID from ID Token to get auth user by id

# Additional Changes

Refactored the UserList

# Additional Context
- Closes #9382
This commit is contained in:
Ramon 2025-02-21 14:57:09 +01:00 committed by GitHub
parent 9aad207ee4
commit 70234289cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 500 additions and 409 deletions

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs'; import { take } from 'rxjs';
@ -27,9 +27,10 @@ export class FilterOrgComponent extends FilterComponent implements OnInit {
constructor( constructor(
router: Router, router: Router,
destroyRef: DestroyRef,
protected override route: ActivatedRoute, protected override route: ActivatedRoute,
) { ) {
super(router, route); super(router, route, destroyRef);
} }
ngOnInit(): void { ngOnInit(): void {

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs'; import { take } from 'rxjs';
@ -23,8 +23,8 @@ export class FilterProjectComponent extends FilterComponent implements OnInit {
public searchQueries: ProjectQuery[] = []; public searchQueries: ProjectQuery[] = [];
public states: ProjectState[] = [ProjectState.PROJECT_STATE_ACTIVE, ProjectState.PROJECT_STATE_INACTIVE]; public states: ProjectState[] = [ProjectState.PROJECT_STATE_ACTIVE, ProjectState.PROJECT_STATE_INACTIVE];
constructor(router: Router, route: ActivatedRoute) { constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) {
super(router, route); super(router, route, destroyRef);
} }
ngOnInit(): void { ngOnInit(): void {

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs'; import { take } from 'rxjs';
@ -29,8 +29,8 @@ export class FilterUserGrantsComponent extends FilterComponent implements OnInit
public SubQuery: any = SubQuery; public SubQuery: any = SubQuery;
public searchQueries: UserGrantQuery[] = []; public searchQueries: UserGrantQuery[] = [];
constructor(router: Router, route: ActivatedRoute) { constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) {
super(router, route); super(router, route, destroyRef);
} }
ngOnInit(): void { ngOnInit(): void {

View File

@ -1,4 +1,4 @@
<cnsl-filter (resetted)="resetFilter()" (trigger)="emitFilter()" [queryCount]="searchQueries.length"> <cnsl-filter (resetted)="resetFilter()" (trigger)="emitFilter()" [queryCount]="(filterChanged | async)?.length ?? 0">
<div class="filter-row" id="filtercomp"> <div class="filter-row" id="filtercomp">
<div class="name-query"> <div class="name-query">
<mat-checkbox <mat-checkbox

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs'; import { take } from 'rxjs';
@ -13,6 +13,7 @@ import {
} from 'src/app/proto/generated/zitadel/user_pb'; } from 'src/app/proto/generated/zitadel/user_pb';
import { FilterComponent } from '../filter/filter.component'; import { FilterComponent } from '../filter/filter.component';
import { filter, map } from 'rxjs/operators';
enum SubQuery { enum SubQuery {
STATE, STATE,
@ -28,25 +29,27 @@ enum SubQuery {
}) })
export class FilterUserComponent extends FilterComponent implements OnInit { export class FilterUserComponent extends FilterComponent implements OnInit {
public SubQuery: any = SubQuery; public SubQuery: any = SubQuery;
public searchQueries: UserSearchQuery[] = []; private searchQueries: UserSearchQuery[] = [];
public states: UserState[] = [ public states: UserState[] = [
UserState.USER_STATE_ACTIVE, UserState.USER_STATE_ACTIVE,
UserState.USER_STATE_INACTIVE, UserState.USER_STATE_INACTIVE,
UserState.USER_STATE_DELETED, UserState.USER_STATE_DELETED,
UserState.USER_STATE_INITIAL,
UserState.USER_STATE_LOCKED, UserState.USER_STATE_LOCKED,
UserState.USER_STATE_SUSPEND, UserState.USER_STATE_INITIAL,
]; ];
constructor(router: Router, route: ActivatedRoute) { constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) {
super(router, route); super(router, route, destroyRef);
} }
ngOnInit(): void { ngOnInit(): void {
this.route.queryParams.pipe(take(1)).subscribe((params) => { this.route.queryParamMap
const { filter } = params; .pipe(
if (filter) { take(1),
const stringifiedFilters = filter as string; map((params) => params.get('filter')),
filter(Boolean),
)
.subscribe((stringifiedFilters) => {
const filters: UserSearchQuery.AsObject[] = JSON.parse(stringifiedFilters) as UserSearchQuery.AsObject[]; const filters: UserSearchQuery.AsObject[] = JSON.parse(stringifiedFilters) as UserSearchQuery.AsObject[];
const userQueries = filters.map((filter) => { const userQueries = filters.map((filter) => {
@ -94,7 +97,6 @@ export class FilterUserComponent extends FilterComponent implements OnInit {
this.filterChanged.emit(this.searchQueries ? this.searchQueries : []); this.filterChanged.emit(this.searchQueries ? this.searchQueries : []);
// this.showFilter = true; // this.showFilter = true;
// this.filterOpen.emit(true); // this.filterOpen.emit(true);
}
}); });
} }

View File

@ -1,7 +1,6 @@
import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay'; 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 { ActivatedRoute, Router } from '@angular/router';
import { Observable, Subject, takeUntil } from 'rxjs';
import { SearchQuery as MemberSearchQuery } from 'src/app/proto/generated/zitadel/member_pb'; import { SearchQuery as MemberSearchQuery } from 'src/app/proto/generated/zitadel/member_pb';
import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb'; import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb';
import { OrgQuery } from 'src/app/proto/generated/zitadel/org_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 { SearchQuery as UserSearchQuery, UserGrantQuery } from 'src/app/proto/generated/zitadel/user_pb';
import { ActionKeysType } from '../action-keys/action-keys.component'; import { ActionKeysType } from '../action-keys/action-keys.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
type FilterSearchQuery = UserSearchQuery | MemberSearchQuery | UserGrantQuery | ProjectQuery | OrgQuery; type FilterSearchQuery = UserSearchQuery | MemberSearchQuery | UserGrantQuery | ProjectQuery | OrgQuery;
type FilterSearchQueryAsObject = type FilterSearchQueryAsObject =
@ -23,7 +23,7 @@ type FilterSearchQueryAsObject =
templateUrl: './filter.component.html', templateUrl: './filter.component.html',
styleUrls: ['./filter.component.scss'], styleUrls: ['./filter.component.scss'],
}) })
export class FilterComponent implements OnDestroy { export class FilterComponent {
@Output() public filterChanged: EventEmitter<FilterSearchQuery[]> = new EventEmitter(); @Output() public filterChanged: EventEmitter<FilterSearchQuery[]> = new EventEmitter();
@Output() public filterOpen: EventEmitter<boolean> = new EventEmitter<boolean>(false); @Output() public filterOpen: EventEmitter<boolean> = new EventEmitter<boolean>(false);
@ -32,9 +32,6 @@ export class FilterComponent implements OnDestroy {
@Input() public queryCount: number = 0; @Input() public queryCount: number = 0;
private destroy$: Subject<void> = new Subject();
public filterChanged$: Observable<FilterSearchQuery[]> = this.filterChanged.asObservable();
public showFilter: boolean = false; public showFilter: boolean = false;
public methods: TextQueryMethod[] = [ public methods: TextQueryMethod[] = [
TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE,
@ -59,17 +56,13 @@ export class FilterComponent implements OnDestroy {
this.trigger.emit(); this.trigger.emit();
} }
public ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
constructor( constructor(
private router: Router, private router: Router,
protected route: ActivatedRoute, protected route: ActivatedRoute,
destroyRef: DestroyRef,
) { ) {
const changes$ = this.filterChanged.asObservable(); const changes$ = this.filterChanged.asObservable();
changes$.pipe(takeUntil(this.destroy$)).subscribe((queries) => { changes$.pipe(takeUntilDestroyed(destroyRef)).subscribe((queries) => {
const filters: Array<FilterSearchQueryAsObject | {}> | undefined = queries const filters: Array<FilterSearchQueryAsObject | {}> | undefined = queries
?.map((q) => q.toObject()) ?.map((q) => q.toObject())
.map((query) => .map((query) =>
@ -81,7 +74,8 @@ export class FilterComponent implements OnDestroy {
); );
if (filters && Object.keys(filters)) { if (filters && Object.keys(filters)) {
this.router.navigate([], { this.router
.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { queryParams: {
['filter']: JSON.stringify(filters), ['filter']: JSON.stringify(filters),
@ -89,7 +83,8 @@ export class FilterComponent implements OnDestroy {
replaceUrl: true, replaceUrl: true,
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
skipLocationChange: false, skipLocationChange: false,
}); })
.then();
} }
}); });
} }

View File

@ -2,12 +2,11 @@
<cnsl-top-view <cnsl-top-view
title="{{ userName$ | async }}" title="{{ userName$ | async }}"
sub="{{ user(userQuery)?.preferredLoginName }}" sub="{{ user(userQuery)?.preferredLoginName }}"
[isActive]="user(userQuery)?.state === UserState.USER_STATE_ACTIVE" [isActive]="user(userQuery)?.state === UserState.ACTIVE"
[isInactive]="user(userQuery)?.state === UserState.USER_STATE_INACTIVE" [isInactive]="user(userQuery)?.state === UserState.INACTIVE"
stateTooltip="{{ 'USER.STATE.' + user(userQuery)?.state | translate }}" stateTooltip="{{ 'USER.STATE.' + user(userQuery)?.state | translate }}"
[hasBackButton]="['org.read'] | hasRole | async" [hasBackButton]="['org.read'] | hasRole | async"
> >
<span *ngIf="userQuery.state === 'notfound'">{{ 'USER.PAGES.NOUSER' | translate }}</span>
<cnsl-info-row <cnsl-info-row
topContent topContent
*ngIf="user(userQuery) as user" *ngIf="user(userQuery) as user"
@ -42,7 +41,7 @@
[disabled]="false" [disabled]="false"
[genders]="genders" [genders]="genders"
[languages]="(langSvc.supported$ | async) || []" [languages]="(langSvc.supported$ | async) || []"
[username]="user.userName" [username]="user.username"
[profile]="profile" [profile]="profile"
[showEditImage]="true" [showEditImage]="true"
(changedLanguage)="changedLanguage($event)" (changedLanguage)="changedLanguage($event)"
@ -93,7 +92,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="currentSetting === 'idp'"> <ng-container *ngIf="currentSetting === 'idp'">
<cnsl-external-idps [userId]="user.id" [service]="grpcAuthService"></cnsl-external-idps> <cnsl-external-idps [userId]="user.userId" [service]="grpcAuthService"></cnsl-external-idps>
</ng-container> </ng-container>
<ng-container *ngIf="currentSetting === 'security'"> <ng-container *ngIf="currentSetting === 'security'">
@ -124,15 +123,14 @@
<cnsl-auth-passwordless #mfaComponent></cnsl-auth-passwordless> <cnsl-auth-passwordless #mfaComponent></cnsl-auth-passwordless>
<cnsl-auth-user-mfa <cnsl-auth-user-mfa
[phoneVerified]="humanUser(userQuery)?.type?.value?.phone?.isPhoneVerified ?? false" [phoneVerified]="humanUser(userQuery)?.type?.value?.phone?.isVerified ?? false"
#mfaComponent
></cnsl-auth-user-mfa> ></cnsl-auth-user-mfa>
</ng-container> </ng-container>
<ng-container *ngIf="currentSetting === 'grants'"> <ng-container *ngIf="currentSetting === 'grants'">
<cnsl-card title="{{ 'GRANTS.USER.TITLE' | translate }}" description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}"> <cnsl-card title="{{ 'GRANTS.USER.TITLE' | translate }}" description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}">
<cnsl-user-grants <cnsl-user-grants
[userId]="user.id" [userId]="user.userId"
[context]="USERGRANTCONTEXT" [context]="USERGRANTCONTEXT"
[displayedColumns]="[ [displayedColumns]="[
'org', 'org',
@ -165,7 +163,7 @@
*ngIf="metadataQuery.state !== 'error'" *ngIf="metadataQuery.state !== 'error'"
[metadata]="metadataQuery.value" [metadata]="metadataQuery.value"
[description]="'DESCRIPTIONS.USERS.SELF.METADATA' | translate" [description]="'DESCRIPTIONS.USERS.SELF.METADATA' | translate"
[disabled]="(['user.write:' + user.id, 'user.write'] | hasRole | async) === false" [disabled]="(['user.write:' + user.userId, 'user.write'] | hasRole | async) === false"
(editClicked)="editMetadata(user, metadataQuery.value)" (editClicked)="editMetadata(user, metadataQuery.value)"
(refresh)="refreshMetadata$.next(true)" (refresh)="refreshMetadata$.next(true)"
[loading]="metadataQuery.state === 'loading'" [loading]="metadataQuery.state === 'loading'"

View File

@ -36,11 +36,10 @@ import { ToastService } from 'src/app/services/toast.service';
import { formatPhone } from 'src/app/utils/formatPhone'; import { formatPhone } from 'src/app/utils/formatPhone';
import { EditDialogComponent, EditDialogData, EditDialogResult, EditDialogType } from './edit-dialog/edit-dialog.component'; import { EditDialogComponent, EditDialogData, EditDialogResult, EditDialogType } from './edit-dialog/edit-dialog.component';
import { LanguagesService } from 'src/app/services/languages.service'; import { LanguagesService } from 'src/app/services/languages.service';
import { Gender, HumanProfile } from '@zitadel/proto/zitadel/user/v2/user_pb'; import { Gender, HumanProfile, HumanUser, User, UserState } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { catchError, filter, map, startWith, tap, withLatestFrom } from 'rxjs/operators'; import { catchError, filter, map, startWith, tap, withLatestFrom } from 'rxjs/operators';
import { pairwiseStartWith } from 'src/app/utils/pairwiseStartWith'; import { pairwiseStartWith } from 'src/app/utils/pairwiseStartWith';
import { NewAuthService } from 'src/app/services/new-auth.service'; import { NewAuthService } from 'src/app/services/new-auth.service';
import { Human, User, UserState } from '@zitadel/proto/zitadel/user_pb';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NewMgmtService } from 'src/app/services/new-mgmt.service'; import { NewMgmtService } from 'src/app/services/new-mgmt.service';
import { Metadata } from '@zitadel/proto/zitadel/metadata_pb'; import { Metadata } from '@zitadel/proto/zitadel/metadata_pb';
@ -48,18 +47,14 @@ import { UserService } from 'src/app/services/user.service';
import { LoginPolicy } from '@zitadel/proto/zitadel/policy_pb'; import { LoginPolicy } from '@zitadel/proto/zitadel/policy_pb';
import { query } from '@angular/animations'; import { query } from '@angular/animations';
type UserQuery = type UserQuery = { state: 'success'; value: User } | { state: 'error'; value: string } | { state: 'loading'; value?: User };
| { state: 'success'; value: User }
| { state: 'error'; value: string }
| { state: 'loading'; value?: User }
| { state: 'notfound' };
type MetadataQuery = type MetadataQuery =
| { state: 'success'; value: Metadata[] } | { state: 'success'; value: Metadata[] }
| { state: 'loading'; value: Metadata[] } | { state: 'loading'; value: Metadata[] }
| { state: 'error'; value: string }; | { state: 'error'; value: string };
type UserWithHumanType = Omit<User, 'type'> & { type: { case: 'human'; value: Human } }; type UserWithHumanType = Omit<User, 'type'> & { type: { case: 'human'; value: HumanUser } };
@Component({ @Component({
selector: 'cnsl-auth-user-detail', selector: 'cnsl-auth-user-detail',
@ -67,17 +62,17 @@ type UserWithHumanType = Omit<User, 'type'> & { type: { case: 'human'; value: Hu
styleUrls: ['./auth-user-detail.component.scss'], styleUrls: ['./auth-user-detail.component.scss'],
}) })
export class AuthUserDetailComponent implements OnInit { 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 userLoginMustBeDomain: boolean = false;
public UserState: any = UserState; protected readonly UserState = UserState;
public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; protected USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER;
public refreshChanges$: EventEmitter<void> = new EventEmitter(); protected readonly refreshChanges$: EventEmitter<void> = new EventEmitter();
public refreshMetadata$ = new Subject<true>(); protected readonly refreshMetadata$ = new Subject<true>();
public settingsList: SidenavSetting[] = [ protected readonly settingsList: SidenavSetting[] = [
{ id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' }, { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' },
{ id: 'security', i18nKey: 'USER.SETTINGS.SECURITY' }, { id: 'security', i18nKey: 'USER.SETTINGS.SECURITY' },
{ id: 'idp', i18nKey: 'USER.SETTINGS.IDP' }, { id: 'idp', i18nKey: 'USER.SETTINGS.IDP' },
@ -92,9 +87,9 @@ export class AuthUserDetailComponent implements OnInit {
protected readonly user$: Observable<UserQuery>; protected readonly user$: Observable<UserQuery>;
protected readonly metadata$: Observable<MetadataQuery>; protected readonly metadata$: Observable<MetadataQuery>;
private readonly savedLanguage$: Observable<string>; private readonly savedLanguage$: Observable<string>;
protected currentSetting$: Observable<string | undefined>; protected readonly currentSetting$: Observable<string | undefined>;
public loginPolicy$: Observable<LoginPolicy>; protected readonly loginPolicy$: Observable<LoginPolicy>;
protected userName$: Observable<string>; protected readonly userName$: Observable<string>;
constructor( constructor(
public translate: TranslateService, public translate: TranslateService,
@ -209,13 +204,8 @@ export class AuthUserDetailComponent implements OnInit {
} }
private getMyUser(): Observable<UserQuery> { private getMyUser(): Observable<UserQuery> {
return defer(() => this.newAuthService.getMyUser()).pipe( return defer(() => this.userService.getMyUser()).pipe(
map(({ user }) => { map((user) => ({ state: 'success' as const, value: user })),
if (user) {
return { state: 'success', value: user } as const;
}
return { state: 'notfound' } as const;
}),
catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)), catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)),
startWith({ state: 'loading' } as const), startWith({ state: 'loading' } as const),
); );
@ -232,7 +222,7 @@ export class AuthUserDetailComponent implements OnInit {
if (!user.value) { if (!user.value) {
return EMPTY; return EMPTY;
} }
return this.getMetadataById(user.value.id); return this.getMetadataById(user.value.userId);
}), }),
pairwiseStartWith(undefined), pairwiseStartWith(undefined),
map(([prev, curr]) => { map(([prev, curr]) => {
@ -259,7 +249,7 @@ export class AuthUserDetailComponent implements OnInit {
labelKey: 'ACTIONS.NEWVALUE' as const, labelKey: 'ACTIONS.NEWVALUE' as const,
titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE' as const, titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE' as const,
descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC' as const, descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC' as const,
value: user.userName, value: user.username,
}; };
const dialogRef = this.dialog.open<EditDialogComponent, typeof data, EditDialogResult>(EditDialogComponent, { const dialogRef = this.dialog.open<EditDialogComponent, typeof data, EditDialogResult>(EditDialogComponent, {
data, data,
@ -271,8 +261,8 @@ export class AuthUserDetailComponent implements OnInit {
.pipe( .pipe(
map((value) => value?.value), map((value) => value?.value),
filter(Boolean), filter(Boolean),
filter((value) => user.userName != value), filter((value) => user.username != value),
switchMap((username) => this.userService.updateUser({ userId: user.id, username })), switchMap((username) => this.userService.updateUser({ userId: user.userId, username })),
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@ -288,7 +278,7 @@ export class AuthUserDetailComponent implements OnInit {
public saveProfile(user: User, profile: HumanProfile): void { public saveProfile(user: User, profile: HumanProfile): void {
this.userService this.userService
.updateUser({ .updateUser({
userId: user.id, userId: user.userId,
profile: { profile: {
givenName: profile.givenName, givenName: profile.givenName,
familyName: profile.familyName, familyName: profile.familyName,
@ -350,7 +340,7 @@ export class AuthUserDetailComponent implements OnInit {
public resendEmailVerification(user: User): void { public resendEmailVerification(user: User): void {
this.newMgmtService this.newMgmtService
.resendHumanEmailVerification(user.id) .resendHumanEmailVerification(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true); this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@ -362,7 +352,7 @@ export class AuthUserDetailComponent implements OnInit {
public resendPhoneVerification(user: User): void { public resendPhoneVerification(user: User): void {
this.newMgmtService this.newMgmtService
.resendHumanPhoneVerification(user.id) .resendHumanPhoneVerification(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true); this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@ -374,7 +364,7 @@ export class AuthUserDetailComponent implements OnInit {
public deletePhone(user: User): void { public deletePhone(user: User): void {
this.userService this.userService
.removePhone(user.id) .removePhone(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true); this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@ -417,7 +407,7 @@ export class AuthUserDetailComponent implements OnInit {
filter((resp): resp is Required<EditDialogResult> => !!resp?.value), filter((resp): resp is Required<EditDialogResult> => !!resp?.value),
switchMap(({ value, isVerified }) => switchMap(({ value, isVerified }) =>
this.userService.setEmail({ this.userService.setEmail({
userId: user.id, userId: user.userId,
email: value, email: value,
verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined }, verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined },
}), }),
@ -453,7 +443,7 @@ export class AuthUserDetailComponent implements OnInit {
.pipe( .pipe(
map((resp) => formatPhone(resp?.value)), map((resp) => formatPhone(resp?.value)),
filter(Boolean), filter(Boolean),
switchMap(({ phone }) => this.userService.setPhone({ userId: user.id, phone })), switchMap(({ phone }) => this.userService.setPhone({ userId: user.userId, phone })),
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@ -482,7 +472,7 @@ export class AuthUserDetailComponent implements OnInit {
.afterClosed() .afterClosed()
.pipe( .pipe(
filter(Boolean), filter(Boolean),
switchMap(() => this.userService.deleteUser(user.id)), switchMap(() => this.userService.deleteUser(user.userId)),
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@ -498,9 +488,9 @@ export class AuthUserDetailComponent implements OnInit {
this.newMgmtService.setUserMetadata({ this.newMgmtService.setUserMetadata({
key, key,
value: Buffer.from(value), value: Buffer.from(value),
id: user.id, id: user.userId,
}); });
const removeFcn = (key: string): Promise<any> => this.newMgmtService.removeUserMetadata({ key, id: user.id }); const removeFcn = (key: string): Promise<any> => this.newMgmtService.removeUserMetadata({ key, id: user.userId });
const dialogRef = this.dialog.open<MetadataDialogComponent, MetadataDialogData>(MetadataDialogComponent, { const dialogRef = this.dialog.open<MetadataDialogComponent, MetadataDialogData>(MetadataDialogComponent, {
data: { data: {

View File

@ -2,7 +2,7 @@
<div class="content"> <div class="content">
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.MACHINE.USERNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.MACHINE.USERNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="userName" required /> <input cnslInput formControlName="username" required />
</cnsl-form-field> </cnsl-form-field>
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.MACHINE.NAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.MACHINE.NAME' | translate }}</cnsl-label>

View File

@ -1,5 +1,5 @@
<div class="max-width-container"> <div class="max-width-container">
<div class="enlarged-container" [ngSwitch]="type"> <div class="enlarged-container">
<div class="users-title-row"> <div class="users-title-row">
<h1>{{ 'DESCRIPTIONS.USERS.TITLE' | translate }}</h1> <h1>{{ 'DESCRIPTIONS.USERS.TITLE' | translate }}</h1>
<a mat-icon-button href="https://zitadel.com/docs/concepts/structure/users" rel="noreferrer" target="_blank"> <a mat-icon-button href="https://zitadel.com/docs/concepts/structure/users" rel="noreferrer" target="_blank">
@ -7,21 +7,6 @@
</a> </a>
</div> </div>
<p class="user-list-sub cnsl-secondary-text">{{ 'DESCRIPTIONS.USERS.DESCRIPTION' | translate }}</p> <p class="user-list-sub cnsl-secondary-text">{{ 'DESCRIPTIONS.USERS.DESCRIPTION' | translate }}</p>
<ng-container *ngSwitchCase="Type.TYPE_HUMAN"> <cnsl-user-table [canWrite$]="['user.write$'] | hasRole" [canDelete$]="['user.delete$'] | hasRole"> </cnsl-user-table>
<cnsl-user-table
[type]="Type.TYPE_HUMAN"
[canWrite$]="['user.write$'] | hasRole"
[canDelete$]="['user.delete$'] | hasRole"
>
</cnsl-user-table>
</ng-container>
<ng-container *ngSwitchCase="Type.TYPE_MACHINE">
<cnsl-user-table
[type]="Type.TYPE_MACHINE"
[canWrite$]="['user.write$'] | hasRole"
[canDelete$]="['user.delete$'] | hasRole"
>
</cnsl-user-table>
</ng-container>
</div> </div>
</div> </div>

View File

@ -1,8 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; 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'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
@Component({ @Component({
@ -11,23 +8,10 @@ import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/
styleUrls: ['./user-list.component.scss'], styleUrls: ['./user-list.component.scss'],
}) })
export class UserListComponent { export class UserListComponent {
public Type: any = Type;
public type: Type = Type.TYPE_HUMAN;
constructor( constructor(
public translate: TranslateService, protected readonly translate: TranslateService,
activatedRoute: ActivatedRoute,
breadcrumbService: BreadcrumbService, 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 = { const bread: Breadcrumb = {
type: BreadcrumbType.ORG, type: BreadcrumbType.ORG,
routerLink: ['/org'], routerLink: ['/org'],

View File

@ -1,11 +1,11 @@
<cnsl-refresh-table <cnsl-refresh-table
[loading]="loading$ | async" *ngIf="type$ | async as type"
(refreshed)="refreshPage()" [loading]="loading()"
[dataSize]="totalResult" (refreshed)="this.refresh$.next(true)"
[dataSize]="dataSize()"
[hideRefresh]="true" [hideRefresh]="true"
[timestamp]="viewTimestamp" [timestamp]="(users$ | async)?.details?.timestamp"
[selection]="selection" [selection]="selection"
[emitRefreshOnPreviousRoutes]="refreshOnPreviousRoutes"
[showBorder]="true" [showBorder]="true"
> >
<div leftActions class="user-toggle-group"> <div leftActions class="user-toggle-group">
@ -60,12 +60,12 @@
<cnsl-filter-user <cnsl-filter-user
actions actions
*ngIf="!selection.hasValue()" *ngIf="!selection.hasValue()"
(filterChanged)="applySearchQuery($any($event))" (filterChanged)="this.searchQueries$.next($any($event))"
(filterOpen)="filterOpen = $event" (filterOpen)="filterOpen = $event"
></cnsl-filter-user> ></cnsl-filter-user>
<ng-template cnslHasRole [hasRole]="['user.write']" actions> <ng-template cnslHasRole [hasRole]="['user.write']" actions>
<button <button
(click)="gotoRouterLink(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])" (click)="router.navigate(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
color="primary" color="primary"
mat-raised-button mat-raised-button
[disabled]="(canWrite$ | async) === false" [disabled]="(canWrite$ | async) === false"
@ -77,7 +77,7 @@
<span>{{ 'ACTIONS.NEW' | translate }}</span> <span>{{ 'ACTIONS.NEW' | translate }}</span>
<cnsl-action-keys <cnsl-action-keys
*ngIf="!filterOpen" *ngIf="!filterOpen"
(actionTriggered)="gotoRouterLink(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])" (actionTriggered)="router.navigate(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
> >
</cnsl-action-keys> </cnsl-action-keys>
</div> </div>
@ -85,7 +85,7 @@
</ng-template> </ng-template>
<div class="table-wrapper"> <div class="table-wrapper">
<table class="table" mat-table [dataSource]="dataSource" matSort (matSortChange)="sortChange($event)"> <table class="table" mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="select"> <ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef> <th mat-header-cell *matHeaderCellDef>
<div class="selection"> <div class="selection">
@ -133,12 +133,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="displayName"> <ng-container matColumnDef="displayName">
<th <th mat-header-cell *matHeaderCellDef mat-sort-header>
mat-header-cell
*matHeaderCellDef
mat-sort-header
[ngClass]="{ 'search-active': this.userSearchKey === UserListSearchKey.DISPLAY_NAME }"
>
{{ 'USER.PROFILE.DISPLAYNAME' | translate }} {{ 'USER.PROFILE.DISPLAYNAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
@ -148,12 +143,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="preferredLoginName"> <ng-container matColumnDef="preferredLoginName">
<th <th mat-header-cell *matHeaderCellDef mat-sort-header>
mat-header-cell
*matHeaderCellDef
mat-sort-header
[ngClass]="{ 'search-active': this.userSearchKey === UserListSearchKey.DISPLAY_NAME }"
>
{{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }} {{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
@ -162,12 +152,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="username"> <ng-container matColumnDef="username">
<th <th mat-header-cell *matHeaderCellDef mat-sort-header>
mat-header-cell
*matHeaderCellDef
mat-sort-header
[ngClass]="{ 'search-active': this.userSearchKey === UserListSearchKey.USER_NAME }"
>
{{ 'USER.PROFILE.USERNAME' | translate }} {{ 'USER.PROFILE.USERNAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
@ -176,12 +161,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="email"> <ng-container matColumnDef="email">
<th <th mat-header-cell *matHeaderCellDef mat-sort-header>
mat-header-cell
*matHeaderCellDef
mat-sort-header
[ngClass]="{ 'search-active': this.UserListSearchKey === UserListSearchKey.EMAIL }"
>
{{ 'USER.EMAIL' | translate }} {{ 'USER.EMAIL' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
@ -250,17 +230,16 @@
</table> </table>
</div> </div>
<div *ngIf="(loading$ | async) === false && !dataSource?.data?.length" class="no-content-row"> <div *ngIf="!loading() && !dataSource?.data?.length" class="no-content-row">
<i class="las la-exclamation"></i> <i class="las la-exclamation"></i>
<span>{{ 'USER.TABLE.EMPTY' | translate }}</span> <span>{{ 'USER.TABLE.EMPTY' | translate }}</span>
</div> </div>
<cnsl-paginator <cnsl-paginator
#paginator
class="paginator" class="paginator"
[length]="totalResult || 0" [length]="dataSize()"
[pageSize]="INITIAL_PAGE_SIZE" [pageSize]="INITIAL_PAGE_SIZE"
[timestamp]="viewTimestamp" [timestamp]="(users$ | async)?.details?.timestamp"
[pageSizeOptions]="[10, 20, 50, 100]" [pageSizeOptions]="[10, 20, 50, 100]"
(page)="changePage($event)"
></cnsl-paginator> ></cnsl-paginator>
<!-- (page)="changePage($event)"-->
</cnsl-refresh-table> </cnsl-refresh-table>

View File

@ -1,34 +1,45 @@
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { SelectionModel } from '@angular/cdk/collections'; 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 { 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 { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Observable, of } from 'rxjs'; import {
import { take } from 'rxjs/operators'; 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 { enterAnimations } from 'src/app/animations';
import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component'; 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 { 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 { ToastService } from 'src/app/services/toast.service';
import { UserService } from 'src/app/services/user.service'; import { UserService } from 'src/app/services/user.service';
import { toSignal } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { User } from 'src/app/proto/generated/zitadel/user_pb'; import { SearchQuery as UserSearchQuery } from 'src/app/proto/generated/zitadel/user_pb';
import { SearchQuery, SearchQuerySchema, Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; import { Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb';
import { UserState, User as UserV2 } from '@zitadel/proto/zitadel/user/v2/user_pb'; import { UserState, User } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { create } from '@bufbuild/protobuf'; import { MessageInitShape } from '@bufbuild/protobuf';
import { Timestamp } from '@bufbuild/protobuf/wkt'; 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 { type Query = Exclude<
FIRST_NAME, Exclude<MessageInitShape<typeof ListUsersRequestSchema>['queries'], undefined>[number]['query'],
LAST_NAME, undefined
DISPLAY_NAME, >;
USER_NAME,
EMAIL,
}
@Component({ @Component({
selector: 'cnsl-user-table', selector: 'cnsl-user-table',
@ -37,25 +48,33 @@ enum UserListSearchKey {
animations: [enterAnimations], animations: [enterAnimations],
}) })
export class UserTableComponent implements OnInit { export class UserTableComponent implements OnInit {
public userSearchKey: UserListSearchKey | undefined = undefined; protected readonly Type = Type;
public Type = Type; protected readonly refresh$ = new ReplaySubject<true>(1);
@Input() public type: Type = Type.HUMAN;
@Input() refreshOnPreviousRoutes: string[] = [];
@Input() public canWrite$: Observable<boolean> = of(false); @Input() public canWrite$: Observable<boolean> = of(false);
@Input() public canDelete$: Observable<boolean> = of(false); @Input() public canDelete$: Observable<boolean> = of(false);
private user: Signal<User.AsObject | undefined> = toSignal(this.authService.user, { requireSync: true }); protected readonly dataSize: Signal<number>;
protected readonly loading = signal(false);
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent; private readonly paginator$ = new ReplaySubject<PaginatorComponent>(1);
@ViewChild(MatSort) public sort!: MatSort; @ViewChild(PaginatorComponent) public set paginator(paginator: PaginatorComponent) {
public INITIAL_PAGE_SIZE: number = 20; this.paginator$.next(paginator);
}
private readonly sort$ = new ReplaySubject<MatSort>(1);
@ViewChild(MatSort) public set sort(sort: MatSort) {
this.sort$.next(sort);
}
protected readonly INITIAL_PAGE_SIZE = 20;
protected readonly dataSource: MatTableDataSource<User> = new MatTableDataSource<User>();
protected readonly selection: SelectionModel<User> = new SelectionModel<User>(true, []);
protected readonly users$: Observable<ListUsersResponse>;
protected readonly type$: Observable<Type>;
protected readonly searchQueries$ = new ReplaySubject<UserSearchQuery[]>(1);
protected readonly myUser: Signal<User | undefined>;
public viewTimestamp!: Timestamp;
public totalResult: number = 0;
public dataSource: MatTableDataSource<UserV2> = new MatTableDataSource<UserV2>();
public selection: SelectionModel<UserV2> = new SelectionModel<UserV2>(true, []);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@Input() public displayedColumnsHuman: string[] = [ @Input() public displayedColumnsHuman: string[] = [
'select', 'select',
'displayName', 'displayName',
@ -76,46 +95,56 @@ export class UserTableComponent implements OnInit {
'actions', 'actions',
]; ];
@Output() public changedSelection: EventEmitter<Array<UserV2>> = new EventEmitter(); @Output() public changedSelection: EventEmitter<Array<User>> = new EventEmitter();
public UserState: any = UserState; protected readonly UserState = UserState;
public UserListSearchKey: any = UserListSearchKey;
public ActionKeysType: any = ActionKeysType; protected ActionKeysType = ActionKeysType;
public filterOpen: boolean = false; protected filterOpen: boolean = false;
private searchQueries: SearchQuery[] = [];
constructor( constructor(
private router: Router, protected readonly router: Router,
public translate: TranslateService, public readonly translate: TranslateService,
private authService: GrpcAuthService, private readonly userService: UserService,
private userService: UserService, private readonly toast: ToastService,
private toast: ToastService, private readonly dialog: MatDialog,
private dialog: MatDialog, private readonly route: ActivatedRoute,
private route: ActivatedRoute, private readonly destroyRef: DestroyRef,
private _liveAnnouncer: LiveAnnouncer, private readonly authenticationService: AuthenticationService,
private readonly authService: GrpcAuthService,
) { ) {
this.selection.changed.subscribe(() => { this.type$ = this.getType$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.changedSelection.emit(this.selection.selected); 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 { ngOnInit(): void {
this.route.queryParams.pipe(take(1)).subscribe((params) => { this.selection.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
if (!params['filter']) { this.changedSelection.emit(this.selection.selected);
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.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 { setType(type: Type) {
this.type = type;
this.router this.router
.navigate([], { .navigate([], {
relativeTo: this.route, relativeTo: this.route,
@ -127,12 +156,195 @@ export class UserTableComponent implements OnInit {
skipLocationChange: false, skipLocationChange: false,
}) })
.then(); .then();
this.getData( }
this.paginator.pageSize,
this.paginator.pageIndex * this.paginator.pageSize, private getMyUser() {
this.type, return defer(() => this.userService.getMyUser()).pipe(
this.searchQueries, catchError((error) => {
).then(); this.toast.showError(error);
return EMPTY;
}),
);
}
private getType$(): Observable<Type> {
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<Type>): Observable<Query[]> {
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<Type>) {
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 { 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)); this.isAllSelected() ? this.selection.clear() : this.dataSource.data.forEach((row) => this.selection.select(row));
} }
public changePage(event: PageEvent): void { public async deactivateSelectedUsers(): Promise<void> {
this.selection.clear();
this.getData(event.pageSize, event.pageIndex * event.pageSize, this.type, this.searchQueries).then();
}
public deactivateSelectedUsers(): void {
const usersToDeactivate = this.selection.selected const usersToDeactivate = this.selection.selected
.filter((u) => u.state === UserState.ACTIVE) .filter((u) => u.state === UserState.ACTIVE)
.map((value) => { .map((value) => {
return this.userService.deactivateUser(value.userId); return this.userService.deactivateUser(value.userId);
}); });
Promise.all(usersToDeactivate) try {
.then(() => { await Promise.all(usersToDeactivate);
} catch (error) {
this.toast.showError(error);
return;
}
this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true); this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true);
this.selection.clear(); this.selection.clear();
setTimeout(() => { setTimeout(() => {
this.refreshPage(); this.refresh$.next(true);
}, 1000); }, 1000);
})
.catch((error) => {
this.toast.showError(error);
});
} }
public reactivateSelectedUsers(): void { public async reactivateSelectedUsers(): Promise<void> {
const usersToReactivate = this.selection.selected const usersToReactivate = this.selection.selected
.filter((u) => u.state === UserState.INACTIVE) .filter((u) => u.state === UserState.INACTIVE)
.map((value) => { .map((value) => {
return this.userService.reactivateUser(value.userId); return this.userService.reactivateUser(value.userId);
}); });
Promise.all(usersToReactivate) try {
.then(() => { await Promise.all(usersToReactivate);
} catch (error) {
this.toast.showError(error);
return;
}
this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true); this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true);
this.selection.clear(); this.selection.clear();
setTimeout(() => { setTimeout(() => {
this.refreshPage(); this.refresh$.next(true);
}, 1000); }, 1000);
})
.catch((error) => {
this.toast.showError(error);
});
} }
public gotoRouterLink(rL: any): Promise<boolean> { public deleteUser(user: User): void {
return this.router.navigate(rL);
}
private async getData(limit: number, offset: number, type: Type, searchQueries?: SearchQuery[]): Promise<void> {
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();
}
}
public applySearchQuery(searchQueries: SearchQuery[]): void {
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();
}
public deleteUser(user: UserV2): void {
const authUserData = { const authUserData = {
confirmKey: 'ACTIONS.DELETE', confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL', cancelKey: 'ACTIONS.CANCEL',
@ -309,8 +423,9 @@ export class UserTableComponent implements OnInit {
}; };
if (user?.userId) { if (user?.userId) {
const authUser = this.user(); const authUser = this.myUser();
const isMe = authUser?.id === user.userId; console.log('my user', authUser);
const isMe = authUser?.userId === user.userId;
let dialogRef; let dialogRef;
@ -326,21 +441,21 @@ export class UserTableComponent implements OnInit {
}); });
} }
dialogRef.afterClosed().subscribe((resp) => { dialogRef
if (resp) { .afterClosed()
this.userService .pipe(
.deleteUser(user.userId) filter(Boolean),
.then(() => { switchMap(() => this.userService.deleteUser(user.userId)),
)
.subscribe({
next: () => {
setTimeout(() => { setTimeout(() => {
this.refreshPage(); this.refresh$.next(true);
}, 1000); }, 1000);
this.selection.clear(); this.selection.clear();
this.toast.showInfo('USER.TOAST.DELETED', true); this.toast.showInfo('USER.TOAST.DELETED', true);
}) },
.catch((error) => { error: (err) => this.toast.showError(err),
this.toast.showError(error);
});
}
}); });
} }
} }
@ -354,4 +469,20 @@ export class UserTableComponent implements OnInit {
const selected = this.selection.selected; const selected = this.selection.selected;
return selected ? selected.findIndex((user) => user.state !== UserState.INACTIVE) > -1 : false; 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),
);
}
} }

View File

@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { DestroyRef, Injectable } from '@angular/core';
import { GrpcService } from './grpc.service'; import { GrpcService } from './grpc.service';
import { import {
AddHumanUserRequestSchema, AddHumanUserRequestSchema,
@ -11,7 +11,6 @@ import {
DeactivateUserResponse, DeactivateUserResponse,
DeleteUserRequestSchema, DeleteUserRequestSchema,
DeleteUserResponse, DeleteUserResponse,
GetUserByIDRequestSchema,
GetUserByIDResponse, GetUserByIDResponse,
ListAuthenticationFactorsRequestSchema, ListAuthenticationFactorsRequestSchema,
ListAuthenticationFactorsResponse, ListAuthenticationFactorsResponse,
@ -65,60 +64,87 @@ import {
} from '@zitadel/proto/zitadel/user/v2/user_pb'; } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Timestamp as TimestampV2, TimestampSchema } from '@bufbuild/protobuf/wkt'; import { Timestamp as TimestampV2, TimestampSchema } from '@bufbuild/protobuf/wkt';
import { Details, DetailsSchema, ListQuerySchema } from '@zitadel/proto/zitadel/object/v2/object_pb'; import { Details, DetailsSchema } 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 { Human, Machine, Phone, Profile, User } from '../proto/generated/zitadel/user_pb'; import { Human, Machine, Phone, Profile, User } from '../proto/generated/zitadel/user_pb';
import { ObjectDetails } from '../proto/generated/zitadel/object_pb'; import { ObjectDetails } from '../proto/generated/zitadel/object_pb';
import { Timestamp } from '../proto/generated/google/protobuf/timestamp_pb'; import { Timestamp } from '../proto/generated/google/protobuf/timestamp_pb';
import { HumanPhone, HumanPhoneSchema } from '@zitadel/proto/zitadel/user/v2/phone_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({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class UserService { export class UserService {
constructor(private readonly grpcService: GrpcService) {} private readonly userId$: Observable<string>;
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<typeof AddHumanUserRequestSchema>): Promise<AddHumanUserResponse> { public addHumanUser(req: MessageInitShape<typeof AddHumanUserRequestSchema>): Promise<AddHumanUserResponse> {
return this.grpcService.userNew.addHumanUser(create(AddHumanUserRequestSchema, req)); return this.grpcService.userNew.addHumanUser(create(AddHumanUserRequestSchema, req));
} }
public listUsers( public listUsers(req: MessageInitShape<typeof ListUsersRequestSchema>): Promise<ListUsersResponse> {
limit: number,
offset: number,
queriesList?: SearchQuery[],
sortingColumn?: UserFieldName,
sortingDirection?: SortDirection,
): Promise<ListUsersResponse> {
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;
}
return this.grpcService.userNew.listUsers(req); return this.grpcService.userNew.listUsers(req);
} }
public async getMyUser(): Promise<UserV2> {
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<GetUserByIDResponse> { public getUserById(userId: string): Promise<GetUserByIDResponse> {
return this.grpcService.userNew.getUserByID(create(GetUserByIDRequestSchema, { userId })); return this.grpcService.userNew.getUserByID({ userId });
} }
public deactivateUser(userId: string): Promise<DeactivateUserResponse> { public deactivateUser(userId: string): Promise<DeactivateUserResponse> {