fix: load metadata using user service (#9429)

# Which Problems Are Solved
- #9382 "When I log in and get to my user profile page, I get an empty
error message at the top:"

# How the Problems Are Solved
load metadata using user service

# Additional Changes
- The roles observable returns an empty array instead of never emiting
- Small refactorings in app.component.ts because at first I thought the
errors stems from there.
- Added withLatestFromSynchronousFix RXJS operator because
withLatestFrom has confusing behavior when used in synchronous contexts.
Why this operator is needed is described here:
https://github.com/ReactiveX/rxjs/issues/7068

# Additional Context
- Closes #9382
This commit is contained in:
Ramon 2025-03-03 09:24:55 +01:00 committed by GitHub
parent 4df3b6492c
commit b0f70626c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 74 additions and 94 deletions

View File

@ -1,14 +1,14 @@
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay'; import { OverlayContainer } from '@angular/cdk/overlay';
import { DOCUMENT, ViewportScroller } from '@angular/common'; import { DOCUMENT, ViewportScroller } from '@angular/common';
import { Component, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core'; import { Component, DestroyRef, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon'; import { MatIconRegistry } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav'; import { MatDrawer } from '@angular/material/sidenav';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { LangChangeEvent, TranslateService } from '@ngx-translate/core'; import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
import { Observable, of, Subject } from 'rxjs'; import { Observable, of, Subject, switchMap } from 'rxjs';
import { filter, map, startWith, takeUntil } from 'rxjs/operators'; import { filter, map, startWith, takeUntil, tap } from 'rxjs/operators';
import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations'; import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations';
import { Org } from './proto/generated/zitadel/org_pb'; import { Org } from './proto/generated/zitadel/org_pb';
@ -21,6 +21,7 @@ import { ThemeService } from './services/theme.service';
import { UpdateService } from './services/update.service'; import { UpdateService } from './services/update.service';
import { fallbackLanguage, supportedLanguages, supportedLanguagesRegexp } from './utils/language'; import { fallbackLanguage, supportedLanguages, supportedLanguagesRegexp } from './utils/language';
import { PosthogService } from './services/posthog.service'; import { PosthogService } from './services/posthog.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'cnsl-root', selector: 'cnsl-root',
@ -28,7 +29,7 @@ import { PosthogService } from './services/posthog.service';
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation], animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation],
}) })
export class AppComponent implements OnDestroy { export class AppComponent {
@ViewChild('drawer') public drawer!: MatDrawer; @ViewChild('drawer') public drawer!: MatDrawer;
public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe( public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe(
map((result) => { map((result) => {
@ -48,8 +49,6 @@ export class AppComponent implements OnDestroy {
public showProjectSection: boolean = false; public showProjectSection: boolean = false;
private destroy$: Subject<void> = new Subject();
public language: string = 'en'; public language: string = 'en';
public privacyPolicy!: PrivacyPolicy.AsObject; public privacyPolicy!: PrivacyPolicy.AsObject;
constructor( constructor(
@ -70,6 +69,7 @@ export class AppComponent implements OnDestroy {
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private posthog: PosthogService, private posthog: PosthogService,
private readonly destroyRef: DestroyRef,
) { ) {
console.log( console.log(
'%cWait!', '%cWait!',
@ -199,42 +199,43 @@ export class AppComponent implements OnDestroy {
this.getProjectCount(); this.getProjectCount();
this.authService.activeOrgChanged.pipe(takeUntil(this.destroy$)).subscribe((org) => { this.authService.activeOrgChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((org) => {
if (org) { if (org) {
this.org = org; this.org = org;
this.getProjectCount(); this.getProjectCount();
} }
}); });
this.activatedRoute.queryParams.pipe(filter((params) => !!params['org'])).subscribe((params) => { this.activatedRoute.queryParamMap
const { org } = params; .pipe(
this.authService.getActiveOrg(org); map((params) => params.get('org')),
}); filter(Boolean),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((org) => this.authService.getActiveOrg(org));
this.authenticationService.authenticationChanged.pipe(takeUntil(this.destroy$)).subscribe((authenticated) => { this.authenticationService.authenticationChanged
if (authenticated) { .pipe(
this.authService filter(Boolean),
.getActiveOrg() switchMap(() => this.authService.getActiveOrg()),
.then(async (org) => { takeUntilDestroyed(this.destroyRef),
this.org = org; )
// TODO add when console storage is implemented .subscribe({
// this.startIntroWorkflow(); next: (org) => (this.org = org),
}) error: async (err) => {
.catch((error) => { console.error(err);
console.error(error); return this.router.navigate(['/users/me']);
this.router.navigate(['/users/me']); },
}); });
}
});
this.isDarkTheme = this.themeService.isDarkTheme; this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme.pipe(takeUntil(this.destroy$)).subscribe((dark) => { this.isDarkTheme.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((dark) => {
const theme = dark ? 'dark-theme' : 'light-theme'; const theme = dark ? 'dark-theme' : 'light-theme';
this.onSetTheme(theme); this.onSetTheme(theme);
this.setFavicon(theme); this.setFavicon(theme);
}); });
this.translate.onLangChange.pipe(takeUntil(this.destroy$)).subscribe((language: LangChangeEvent) => { this.translate.onLangChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((language: LangChangeEvent) => {
this.document.documentElement.lang = language.lang; this.document.documentElement.lang = language.lang;
this.language = language.lang; this.language = language.lang;
}); });
@ -254,11 +255,6 @@ export class AppComponent implements OnDestroy {
// }, 1000); // }, 1000);
// } // }
public ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public prepareRoute(outlet: RouterOutlet): boolean { public prepareRoute(outlet: RouterOutlet): boolean {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation']; return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];
} }
@ -283,7 +279,7 @@ export class AppComponent implements OnDestroy {
this.translate.addLangs(supportedLanguages); this.translate.addLangs(supportedLanguages);
this.translate.setDefaultLang(fallbackLanguage); this.translate.setDefaultLang(fallbackLanguage);
this.authService.user.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((userprofile) => { this.authService.user.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)).subscribe((userprofile) => {
const cropped = navigator.language.split('-')[0] ?? fallbackLanguage; const cropped = navigator.language.split('-')[0] ?? fallbackLanguage;
const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage; const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage;
@ -306,7 +302,7 @@ export class AppComponent implements OnDestroy {
} }
private setFavicon(theme: string): void { private setFavicon(theme: string): void {
this.authService.labelpolicy$.pipe(startWith(undefined), takeUntil(this.destroy$)).subscribe((lP) => { this.authService.labelpolicy$.pipe(startWith(undefined), takeUntilDestroyed(this.destroyRef)).subscribe((lP) => {
if (theme === 'dark-theme' && lP?.iconUrlDark) { if (theme === 'dark-theme' && lP?.iconUrlDark) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied // Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrlDark).then((response) => { fetch(lP.iconUrlDark).then((response) => {

View File

@ -5,19 +5,7 @@ import { MatDialog } from '@angular/material/dialog';
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 { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { import { defer, EMPTY, fromEvent, mergeWith, Observable, of, shareReplay, Subject, switchMap, take } from 'rxjs';
combineLatestWith,
defer,
EMPTY,
fromEvent,
mergeWith,
Observable,
of,
shareReplay,
Subject,
switchMap,
take,
} from 'rxjs';
import { ChangeType } from 'src/app/modules/changes/changes.component'; import { ChangeType } from 'src/app/modules/changes/changes.component';
import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { InfoDialogComponent } from 'src/app/modules/info-dialog/info-dialog.component'; import { InfoDialogComponent } from 'src/app/modules/info-dialog/info-dialog.component';
@ -37,7 +25,7 @@ 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, HumanUser, User, UserState } 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, 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -47,12 +35,12 @@ 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 = { state: 'success'; value: User } | { state: 'error'; value: string } | { state: 'loading'; value?: User }; type UserQuery = { state: 'success'; value: User } | { state: 'error'; error: any } | { state: 'loading'; value?: User };
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'; error: any };
type UserWithHumanType = Omit<User, 'type'> & { type: { case: 'human'; value: HumanUser } }; type UserWithHumanType = Omit<User, 'type'> & { type: { case: 'human'; value: HumanUser } };
@ -92,9 +80,9 @@ export class AuthUserDetailComponent implements OnInit {
protected readonly userName$: Observable<string>; protected readonly userName$: Observable<string>;
constructor( constructor(
public translate: TranslateService, private translate: TranslateService,
private toast: ToastService, private toast: ToastService,
public grpcAuthService: GrpcAuthService, protected grpcAuthService: GrpcAuthService,
private dialog: MatDialog, private dialog: MatDialog,
private auth: AuthenticationService, private auth: AuthenticationService,
private breadcrumbService: BreadcrumbService, private breadcrumbService: BreadcrumbService,
@ -111,7 +99,7 @@ export class AuthUserDetailComponent implements OnInit {
this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.userName$ = this.getUserName(this.user$); this.userName$ = this.getUserName(this.user$);
this.savedLanguage$ = this.getSavedLanguage$(this.user$); this.savedLanguage$ = this.getSavedLanguage$(this.user$);
this.metadata$ = this.getMetadata$(this.user$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); this.metadata$ = this.getMetadata$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe( this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe(
catchError(() => EMPTY), catchError(() => EMPTY),
@ -164,7 +152,7 @@ export class AuthUserDetailComponent implements OnInit {
}); });
this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => { this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if (query.state == 'error') { if (query.state == 'error') {
this.toast.showError(query.value); this.toast.showError(query.error);
} }
}); });
@ -206,24 +194,15 @@ export class AuthUserDetailComponent implements OnInit {
private getMyUser(): Observable<UserQuery> { private getMyUser(): Observable<UserQuery> {
return defer(() => this.userService.getMyUser()).pipe( return defer(() => this.userService.getMyUser()).pipe(
map((user) => ({ state: 'success' as const, value: user })), map((user) => ({ state: 'success' as const, value: user })),
catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)), catchError((error) => of({ state: 'error', error } as const)),
startWith({ state: 'loading' } as const), startWith({ state: 'loading' } as const),
); );
} }
getMetadata$(user$: Observable<UserQuery>): Observable<MetadataQuery> { getMetadata$(): Observable<MetadataQuery> {
return this.refreshMetadata$.pipe( return this.refreshMetadata$.pipe(
startWith(true), startWith(true),
combineLatestWith(user$), switchMap(() => this.getMetadata()),
switchMap(([_, user]) => {
if (!(user.state === 'success' || user.state === 'loading')) {
return EMPTY;
}
if (!user.value) {
return EMPTY;
}
return this.getMetadataById(user.value.userId);
}),
pairwiseStartWith(undefined), pairwiseStartWith(undefined),
map(([prev, curr]) => { map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') { if (prev?.state === 'success' && curr.state === 'loading') {
@ -234,11 +213,11 @@ export class AuthUserDetailComponent implements OnInit {
); );
} }
private getMetadataById(userId: string): Observable<MetadataQuery> { private getMetadata(): Observable<MetadataQuery> {
return defer(() => this.newMgmtService.listUserMetadata(userId)).pipe( return defer(() => this.newAuthService.listMyMetadata()).pipe(
map((metadata) => ({ state: 'success', value: metadata.result }) as const), map((metadata) => ({ state: 'success', value: metadata.result }) as const),
startWith({ state: 'loading', value: [] as Metadata[] } as const), startWith({ state: 'loading', value: [] as Metadata[] } as const),
catchError((err) => of({ state: 'error', value: err.message ?? '' } as const)), catchError((error) => of({ state: 'error', error } as const)),
); );
} }

View File

@ -445,6 +445,7 @@ export class UserDetailComponent implements OnInit {
}; };
const dialogRef = this.dialog.open<WarnDialogComponent, typeof data, boolean>(WarnDialogComponent, { const dialogRef = this.dialog.open<WarnDialogComponent, typeof data, boolean>(WarnDialogComponent, {
data,
width: '400px', width: '400px',
}); });

View File

@ -1,21 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { OAuthService } from 'angular-oauth2-oidc'; import { OAuthService } from 'angular-oauth2-oidc';
import { import { BehaviorSubject, combineLatestWith, EMPTY, mergeWith, NEVER, Observable, of, shareReplay, Subject } from 'rxjs';
BehaviorSubject,
combineLatestWith,
defer,
distinctUntilKeyChanged,
EMPTY,
forkJoin,
mergeWith,
NEVER,
Observable,
of,
shareReplay,
Subject,
TimeoutError,
} from 'rxjs';
import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap, timeout } from 'rxjs/operators'; import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap, timeout } from 'rxjs/operators';
import { import {
@ -186,7 +172,6 @@ export class GrpcAuthService {
.then((resp) => resp.resultList) .then((resp) => resp.resultList)
.catch(() => <string[]>[]), .catch(() => <string[]>[]),
), ),
filter((roles) => !!roles.length),
distinctUntilChanged((a, b) => { distinctUntilChanged((a, b) => {
return JSON.stringify(a.sort()) === JSON.stringify(b.sort()); return JSON.stringify(a.sort()) === JSON.stringify(b.sort());
}), }),
@ -302,7 +287,6 @@ export class GrpcAuthService {
} }
return this.zitadelPermissions.pipe( return this.zitadelPermissions.pipe(
filter((permissions) => !!permissions.length),
map((permissions) => this.hasRoles(permissions, roles, requiresAll)), map((permissions) => this.hasRoles(permissions, roles, requiresAll)),
distinctUntilChanged(), distinctUntilChanged(),
); );

View File

@ -1,12 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service'; import { GrpcService } from './grpc.service';
import { create } from '@bufbuild/protobuf';
import { import {
AddMyAuthFactorOTPSMSRequestSchema,
AddMyAuthFactorOTPSMSResponse, AddMyAuthFactorOTPSMSResponse,
GetMyUserRequestSchema,
GetMyUserResponse, GetMyUserResponse,
VerifyMyPhoneRequestSchema, ListMyMetadataResponse,
VerifyMyPhoneResponse, VerifyMyPhoneResponse,
} from '@zitadel/proto/zitadel/auth_pb'; } from '@zitadel/proto/zitadel/auth_pb';
@ -17,14 +14,18 @@ export class NewAuthService {
constructor(private readonly grpcService: GrpcService) {} constructor(private readonly grpcService: GrpcService) {}
public getMyUser(): Promise<GetMyUserResponse> { public getMyUser(): Promise<GetMyUserResponse> {
return this.grpcService.authNew.getMyUser(create(GetMyUserRequestSchema)); return this.grpcService.authNew.getMyUser({});
} }
public verifyMyPhone(code: string): Promise<VerifyMyPhoneResponse> { public verifyMyPhone(code: string): Promise<VerifyMyPhoneResponse> {
return this.grpcService.authNew.verifyMyPhone(create(VerifyMyPhoneRequestSchema, { code })); return this.grpcService.authNew.verifyMyPhone({});
} }
public addMyAuthFactorOTPSMS(): Promise<AddMyAuthFactorOTPSMSResponse> { public addMyAuthFactorOTPSMS(): Promise<AddMyAuthFactorOTPSMSResponse> {
return this.grpcService.authNew.addMyAuthFactorOTPSMS(create(AddMyAuthFactorOTPSMSRequestSchema)); return this.grpcService.authNew.addMyAuthFactorOTPSMS({});
}
public listMyMetadata(): Promise<ListMyMetadataResponse> {
return this.grpcService.authNew.listMyMetadata({});
} }
} }

View File

@ -0,0 +1,19 @@
import { combineLatestWith, distinctUntilChanged, Observable, ObservableInput, ObservableInputTuple } from 'rxjs';
import { map } from 'rxjs/operators';
// withLatestFrom does not work in this case, so we use
// combineLatestWith + distinctUntilChanged
// here the problem is described in more detail
// https://github.com/ReactiveX/rxjs/issues/7068
export const withLatestFromSynchronousFix =
<T, A extends readonly unknown[]>(...secondaries$: [...ObservableInputTuple<A>]) =>
(primary$: Observable<T>) =>
primary$.pipe(
// we add the index, so we can distinguish
// primary submissions from each other,
// and then we can only emit when primary changes
map((primary, i) => <const>[primary, i]),
combineLatestWith(...secondaries$),
distinctUntilChanged(undefined!, ([[_, i]]) => i),
map(([[primary], ...secondaries]) => <const>[primary, ...secondaries]),
);