From 142effe00c1ab04a86af62156273e1cfc9b1fdee Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 27 Mar 2025 22:43:41 +0100 Subject: [PATCH] feat(console): list v2 sessions (#9539) # Which Problems Are Solved With the new login comes a new API. Sessions must be listed using the new sessions api. This PR checks for the new login feature flag to be enabled and lists the users sessions using the new API. --------- Co-authored-by: conblem --- .../accounts-card.component.html | 9 +- .../accounts-card/accounts-card.component.ts | 166 ++++++++++++++---- .../app/modules/header/header.component.ts | 2 +- console/src/app/services/grpc.service.ts | 5 +- console/src/app/services/session.service.ts | 15 ++ 5 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 console/src/app/services/session.service.ts diff --git a/console/src/app/modules/accounts-card/accounts-card.component.html b/console/src/app/modules/accounts-card/accounts-card.component.html index 1ed1649545..24fe098331 100644 --- a/console/src/app/modules/accounts-card/accounts-card.component.html +++ b/console/src/app/modules/accounts-card/accounts-card.component.html @@ -1,7 +1,6 @@ -
+
{{ 'USER.EDITACCOUNT' | translate }} diff --git a/console/src/app/modules/accounts-card/accounts-card.component.ts b/console/src/app/modules/accounts-card/accounts-card.component.ts index 2676a5bcf5..d91148e862 100644 --- a/console/src/app/modules/accounts-card/accounts-card.component.ts +++ b/console/src/app/modules/accounts-card/accounts-card.component.ts @@ -1,64 +1,156 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Router } from '@angular/router'; import { AuthConfig } from 'angular-oauth2-oidc'; -import { Session, User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; +import { SessionState as V1SessionState, User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; import { AuthenticationService } from 'src/app/services/authentication.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { toSignal } from '@angular/core/rxjs-interop'; +import { SessionService } from 'src/app/services/session.service'; +import { + catchError, + defer, + from, + map, + mergeMap, + Observable, + of, + ReplaySubject, + shareReplay, + switchMap, + timeout, + TimeoutError, + toArray, +} from 'rxjs'; +import { NewFeatureService } from 'src/app/services/new-feature.service'; +import { ToastService } from 'src/app/services/toast.service'; +import { SessionState as V2SessionState } from '@zitadel/proto/zitadel/user_pb'; +import { filter, withLatestFrom } from 'rxjs/operators'; + +interface V1AndV2Session { + displayName: string; + avatarUrl: string; + loginName: string; + userName: string; + authState: V1SessionState | V2SessionState; +} @Component({ selector: 'cnsl-accounts-card', templateUrl: './accounts-card.component.html', styleUrls: ['./accounts-card.component.scss'], }) -export class AccountsCardComponent implements OnInit { - @Input() public user?: User.AsObject; - @Input() public iamuser: boolean | null = false; - - @Output() public closedCard: EventEmitter = new EventEmitter(); - public sessions: Session.AsObject[] = []; - public loadingUsers: boolean = false; - public UserState: any = UserState; - private labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined }); - - constructor( - public authService: AuthenticationService, - private router: Router, - private userService: GrpcAuthService, - ) { - this.userService - .listMyUserSessions() - .then((sessions) => { - this.sessions = sessions.resultList.filter((user) => user.loginName !== this.user?.preferredLoginName); - this.loadingUsers = false; - }) - .catch(() => { - this.loadingUsers = false; - }); +export class AccountsCardComponent { + @Input({ required: true }) + public set user(user: User.AsObject) { + this.user$.next(user); } - ngOnInit(): void { - this.loadingUsers = true; + @Input() public iamuser: boolean | null = false; + + @Output() public closedCard = new EventEmitter(); + + protected readonly user$ = new ReplaySubject(1); + protected readonly UserState = UserState; + private readonly labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined }); + protected readonly sessions$: Observable; + + constructor( + protected readonly authService: AuthenticationService, + private readonly router: Router, + private readonly userService: GrpcAuthService, + private readonly sessionService: SessionService, + private readonly featureService: NewFeatureService, + private readonly toast: ToastService, + ) { + this.sessions$ = this.getSessions().pipe(shareReplay({ refCount: true, bufferSize: 1 })); + } + + private getUseLoginV2() { + return defer(() => this.featureService.getInstanceFeatures()).pipe( + map(({ loginV2 }) => loginV2?.required ?? false), + timeout(1000), + catchError((err) => { + if (!(err instanceof TimeoutError)) { + this.toast.showError(err); + } + return of(false); + }), + ); + } + + private getSessions(): Observable { + const useLoginV2$ = this.getUseLoginV2(); + + return useLoginV2$.pipe( + switchMap((useLoginV2) => { + if (useLoginV2) { + return this.getV2Sessions(); + } else { + return this.getV1Sessions(); + } + }), + catchError((err) => { + this.toast.showError(err); + return of([]); + }), + ); + } + + private getV1Sessions(): Observable { + return defer(() => this.userService.listMyUserSessions()).pipe( + mergeMap(({ resultList }) => from(resultList)), + withLatestFrom(this.user$), + filter(([{ loginName }, user]) => loginName !== user.preferredLoginName), + map(([s]) => ({ + displayName: s.displayName, + avatarUrl: s.avatarUrl, + loginName: s.loginName, + authState: s.authState, + userName: s.userName, + })), + toArray(), + ); + } + + private getV2Sessions(): Observable { + return defer(() => + this.sessionService.listSessions({ + queries: [ + { + query: { + case: 'userAgentQuery', + value: {}, + }, + }, + ], + }), + ).pipe( + mergeMap(({ sessions }) => from(sessions)), + withLatestFrom(this.user$), + filter(([s, user]) => s.factors?.user?.loginName !== user.preferredLoginName), + map(([s]) => ({ + displayName: s.factors?.user?.displayName ?? '', + avatarUrl: '', + loginName: s.factors?.user?.loginName ?? '', + authState: V2SessionState.ACTIVE, + userName: s.factors?.user?.loginName ?? '', + })), + toArray(), + ); } public editUserProfile(): void { - this.router.navigate(['users/me']); + this.router.navigate(['users/me']).then(); this.closedCard.emit(); } - public closeCard(element: HTMLElement): void { - if (!element.classList.contains('dontcloseonclick')) { - this.closedCard.emit(); - } - } - public selectAccount(loginHint: string): void { const configWithPrompt: Partial = { customQueryParams: { login_hint: loginHint, }, }; - this.authService.authenticate(configWithPrompt); + this.authService.authenticate(configWithPrompt).then(); } public selectNewAccount(): void { @@ -67,7 +159,7 @@ export class AccountsCardComponent implements OnInit { prompt: 'login', } as any, }; - this.authService.authenticate(configWithPrompt); + this.authService.authenticate(configWithPrompt).then(); } public logout(): void { diff --git a/console/src/app/modules/header/header.component.ts b/console/src/app/modules/header/header.component.ts index e560237343..c12c38e43a 100644 --- a/console/src/app/modules/header/header.component.ts +++ b/console/src/app/modules/header/header.component.ts @@ -15,7 +15,7 @@ import { ActionKeysType } from '../action-keys/action-keys.component'; }) export class HeaderComponent { @Input() public isDarkTheme: boolean = true; - @Input() public user?: User.AsObject; + @Input({ required: true }) public user!: User.AsObject; public showOrgContext: boolean = false; @Input() public org!: Org.AsObject; diff --git a/console/src/app/services/grpc.service.ts b/console/src/app/services/grpc.service.ts index baec234d6a..6ec04975f6 100644 --- a/console/src/app/services/grpc.service.ts +++ b/console/src/app/services/grpc.service.ts @@ -18,7 +18,7 @@ import { NewConnectWebOrgInterceptor, OrgInterceptor, OrgInterceptorProvider } f import { StorageService } from './storage.service'; import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb'; //@ts-ignore -import { createFeatureServiceClient, createUserServiceClient } from '@zitadel/client/v2'; +import { createFeatureServiceClient, createUserServiceClient, createSessionServiceClient } from '@zitadel/client/v2'; //@ts-ignore import { createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1'; import { createGrpcWebTransport } from '@connectrpc/connect-web'; @@ -38,6 +38,7 @@ export class GrpcService { public admin!: AdminServiceClient; public user!: UserServiceClient; public userNew!: ReturnType; + public session!: ReturnType; public mgmtNew!: ReturnType; public authNew!: ReturnType; public featureNew!: ReturnType; @@ -47,7 +48,6 @@ export class GrpcService { private readonly envService: EnvironmentService, private readonly platformLocation: PlatformLocation, private readonly authenticationService: AuthenticationService, - private readonly storageService: StorageService, private readonly translate: TranslateService, private readonly exhaustedService: ExhaustedService, private readonly authInterceptor: AuthInterceptor, @@ -112,6 +112,7 @@ export class GrpcService { ], }); this.userNew = createUserServiceClient(transport); + this.session = createSessionServiceClient(transport); this.mgmtNew = createManagementServiceClient(transportOldAPIs); this.authNew = createAuthServiceClient(transport); this.featureNew = createFeatureServiceClient(transport); diff --git a/console/src/app/services/session.service.ts b/console/src/app/services/session.service.ts new file mode 100644 index 0000000000..12e07049b4 --- /dev/null +++ b/console/src/app/services/session.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { GrpcService } from './grpc.service'; +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { ListSessionsRequestSchema, ListSessionsResponse } from '@zitadel/proto/zitadel/session/v2/session_service_pb'; + +@Injectable({ + providedIn: 'root', +}) +export class SessionService { + constructor(private readonly grpcService: GrpcService) {} + + public listSessions(req: MessageInitShape): Promise { + return this.grpcService.session.listSessions(req); + } +}