mirror of
				https://github.com/zitadel/zitadel.git
				synced 2025-10-31 18:19:11 +00:00 
			
		
		
		
	feat(console): friendly quota exhausted screen (#5790)
* fix: remove access interceptor for console * feat: show dialog on exhausted requests * fix exhausted cookie handling * fix quota exhausted screen * read instance mgmt from environment.json * fix interceptors * don't check cookie on environment.json * fix environment.json path * exclude environment.json from cookie check * remove cookie before loading env * use UTC to delete the cookie * delete cookie before fetching env * simplify cookie handling * lint * review cleanup * use exhausted property from env json * fix bootstrapping * lint * always open mgmt link if present * chore: fetch env json before ng serve * wait for cookie to be removed * fix typo * don't wait for cookie to be set
This commit is contained in:
		| @@ -231,7 +231,6 @@ The commands in this section are tested against the following software versions: | ||||
| - [Node version v16.17.0](https://nodejs.org/en/download/) | ||||
| - [npm version 8.18.0](https://docs.npmjs.com/try-the-latest-stable-version-of-npm) | ||||
| - [Cypress runtime dependencies](https://docs.cypress.io/guides/continuous-integration/introduction#Dependencies) | ||||
| - [curl version 7.58.0](https://curl.se/download.html) | ||||
|  | ||||
| <details> | ||||
|   <summary>Note for WSL2 on Windows 10</summary> | ||||
| @@ -269,18 +268,17 @@ To allow console access via http://localhost:4200, you have to configure the ZIT | ||||
| You can run the local console development server now. | ||||
|  | ||||
| ```bash | ||||
| # Console loads its target environment from the file console/src/assets/environment.json. | ||||
| # Load it from the backend. | ||||
| curl http://localhost:8080/ui/console/assets/environment.json > ./src/assets/environment.json | ||||
| # Install npm dependencies | ||||
| npm install | ||||
|  | ||||
| # Generate source files from Protos | ||||
| npm run generate | ||||
|  | ||||
| # Install npm dependencies | ||||
| npm install | ||||
|  | ||||
| # Start the server | ||||
| npm start | ||||
|  | ||||
| # If you don't want to develop against http://localhost:8080, you can use another environment | ||||
| ENVIRONMENT_JSON_URL=https://my-cloud-instance-abcdef.zitadel.cloud/ui/console/assets/environment.json npm start | ||||
| ``` | ||||
|  | ||||
| Navigate to http://localhost:4200/. | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|   "version": "0.0.0", | ||||
|   "scripts": { | ||||
|     "ng": "ng", | ||||
|     "start": "ng serve", | ||||
|     "start": "node prebuild.development.js && ng serve", | ||||
|     "build": "ng build --configuration production --base-href=/ui/console/", | ||||
|     "prelint": "npm run generate", | ||||
|     "lint": "ng lint && prettier --check src", | ||||
|   | ||||
							
								
								
									
										23
									
								
								console/prebuild.development.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								console/prebuild.development.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| var fs = require('fs'); | ||||
| var path = require('path') | ||||
| var https = require('https'); | ||||
|  | ||||
| var defaultEnvironmentJsonURL = 'http://localhost:8080/ui/console/assets/environment.json' | ||||
| var devEnvFile = path.join(__dirname, "src", "assets", "environment.json") | ||||
| var url = process.env["ENVIRONMENT_JSON_URL"] || defaultEnvironmentJsonURL; | ||||
|  | ||||
| https.get(url, function (res) { | ||||
|     var body = ''; | ||||
|  | ||||
|     res.on('data', function (chunk) { | ||||
|         body += chunk; | ||||
|     }); | ||||
|  | ||||
|     res.on('end', function () { | ||||
|         fs.writeFileSync(devEnvFile, body); | ||||
|         console.log("Developing against the following environment") | ||||
|         console.log(JSON.stringify(JSON.parse(body), null, 4)) | ||||
|     }); | ||||
| }).on('error', function (e) { | ||||
|     console.error("Got an error: ", e); | ||||
| }); | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { CommonModule, registerLocaleData } from '@angular/common'; | ||||
| import { HttpClientModule } from '@angular/common/http'; | ||||
| import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; | ||||
| import localeDe from '@angular/common/locales/de'; | ||||
| import localeEn from '@angular/common/locales/en'; | ||||
| import localeEs from '@angular/common/locales/es'; | ||||
| @@ -27,7 +27,6 @@ import { RoleGuard } from 'src/app/guards/role.guard'; | ||||
| import { UserGuard } from 'src/app/guards/user.guard'; | ||||
| import { InfoOverlayModule } from 'src/app/modules/info-overlay/info-overlay.module'; | ||||
| import { AssetService } from 'src/app/services/asset.service'; | ||||
|  | ||||
| import { AppRoutingModule } from './app-routing.module'; | ||||
| import { AppComponent } from './app.component'; | ||||
| import { HasRoleModule } from './directives/has-role/has-role.module'; | ||||
| @@ -40,9 +39,13 @@ import { HasRolePipeModule } from './pipes/has-role-pipe/has-role-pipe.module'; | ||||
| import { AdminService } from './services/admin.service'; | ||||
| import { AuthenticationService } from './services/authentication.service'; | ||||
| import { BreadcrumbService } from './services/breadcrumb.service'; | ||||
| import { EnvironmentService } from './services/environment.service'; | ||||
| import { ExhaustedService } from './services/exhausted.service'; | ||||
| import { GrpcAuthService } from './services/grpc-auth.service'; | ||||
| import { GrpcService } from './services/grpc.service'; | ||||
| import { AuthInterceptor } from './services/interceptors/auth.interceptor'; | ||||
| import { ExhaustedGrpcInterceptor } from './services/interceptors/exhausted.grpc.interceptor'; | ||||
| import { ExhaustedHttpInterceptor } from './services/interceptors/exhausted.http.interceptor'; | ||||
| import { GRPC_INTERCEPTORS } from './services/interceptors/grpc-interceptor'; | ||||
| import { I18nInterceptor } from './services/interceptors/i18n.interceptor'; | ||||
| import { OrgInterceptor } from './services/interceptors/org.interceptor'; | ||||
| @@ -84,9 +87,9 @@ export class WebpackTranslateLoader implements TranslateLoader { | ||||
|   } | ||||
| } | ||||
|  | ||||
| const appInitializerFn = (grpcServ: GrpcService) => { | ||||
| const appInitializerFn = (grpcSvc: GrpcService) => { | ||||
|   return () => { | ||||
|     return grpcServ.loadAppEnvironment(); | ||||
|     return grpcSvc.loadAppEnvironment(); | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @@ -139,6 +142,8 @@ const authConfig: AuthConfig = { | ||||
|     RoleGuard, | ||||
|     UserGuard, | ||||
|     ThemeService, | ||||
|     EnvironmentService, | ||||
|     ExhaustedService, | ||||
|     { | ||||
|       provide: APP_INITIALIZER, | ||||
|       useFactory: appInitializerFn, | ||||
| @@ -167,6 +172,16 @@ const authConfig: AuthConfig = { | ||||
|       provide: OAuthStorage, | ||||
|       useClass: StorageService, | ||||
|     }, | ||||
|     { | ||||
|       provide: HTTP_INTERCEPTORS, | ||||
|       multi: true, | ||||
|       useClass: ExhaustedHttpInterceptor, | ||||
|     }, | ||||
|     { | ||||
|       provide: GRPC_INTERCEPTORS, | ||||
|       multi: true, | ||||
|       useClass: ExhaustedGrpcInterceptor, | ||||
|     }, | ||||
|     { | ||||
|       provide: GRPC_INTERCEPTORS, | ||||
|       multi: true, | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { PrivacyPolicy } from 'src/app/proto/generated/zitadel/policy_pb'; | ||||
| import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; | ||||
|  | ||||
| @@ -7,10 +7,12 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; | ||||
|   templateUrl: './footer.component.html', | ||||
|   styleUrls: ['./footer.component.scss'], | ||||
| }) | ||||
| export class FooterComponent { | ||||
| export class FooterComponent implements OnInit { | ||||
|   public policy?: PrivacyPolicy.AsObject; | ||||
|   constructor(public authService: GrpcAuthService) { | ||||
|     authService.getMyPrivacyPolicy().then((policyResp) => { | ||||
|   constructor(public authService: GrpcAuthService) {} | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.authService.getMyPrivacyPolicy().then((policyResp) => { | ||||
|       if (policyResp.policy) { | ||||
|         this.policy = policyResp.policy; | ||||
|       } | ||||
|   | ||||
| @@ -83,7 +83,7 @@ | ||||
|                 </a> | ||||
|  | ||||
|                 <a | ||||
|                   *ngIf="customerPortalLink" | ||||
|                   *ngIf="customerPortalLink$ | async as customerPortalLink" | ||||
|                   class="nav-item external-link" | ||||
|                   [href]="customerPortalLink" | ||||
|                   target="_blank" | ||||
|   | ||||
| @@ -1,16 +1,16 @@ | ||||
| import { animate, keyframes, style, transition, trigger } from '@angular/animations'; | ||||
| import { BreakpointObserver } from '@angular/cdk/layout'; | ||||
| import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay'; | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core'; | ||||
| import { UntypedFormControl } from '@angular/forms'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { BehaviorSubject, combineLatest, map, Observable, Subject, take } from 'rxjs'; | ||||
| import { BehaviorSubject, combineLatest, map, Observable, Subject } from 'rxjs'; | ||||
| import { Org } from 'src/app/proto/generated/zitadel/org_pb'; | ||||
| import { User } from 'src/app/proto/generated/zitadel/user_pb'; | ||||
| import { AdminService } from 'src/app/services/admin.service'; | ||||
| import { AuthenticationService } from 'src/app/services/authentication.service'; | ||||
| import { BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; | ||||
| import { EnvironmentService } from 'src/app/services/environment.service'; | ||||
| import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; | ||||
| import { KeyboardShortcutsService } from 'src/app/services/keyboard-shortcuts/keyboard-shortcuts.service'; | ||||
| import { ManagementService } from 'src/app/services/mgmt.service'; | ||||
| @@ -90,7 +90,7 @@ export class NavComponent implements OnDestroy { | ||||
|   private destroy$: Subject<void> = new Subject(); | ||||
|  | ||||
|   public BreadcrumbType: any = BreadcrumbType; | ||||
|   public customerPortalLink: string = ''; | ||||
|   public customerPortalLink$ = this.envService.env.pipe(map((env) => env.customer_portal)); | ||||
|  | ||||
|   public positions: ConnectedPosition[] = [ | ||||
|     new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10), | ||||
| @@ -98,6 +98,7 @@ export class NavComponent implements OnDestroy { | ||||
|   ]; | ||||
|  | ||||
|   constructor( | ||||
|     private envService: EnvironmentService, | ||||
|     public authService: GrpcAuthService, | ||||
|     public adminService: AdminService, | ||||
|     public authenticationService: AuthenticationService, | ||||
| @@ -105,23 +106,9 @@ export class NavComponent implements OnDestroy { | ||||
|     public mgmtService: ManagementService, | ||||
|     private router: Router, | ||||
|     private breakpointObserver: BreakpointObserver, | ||||
|     private http: HttpClient, | ||||
|     private shortcutService: KeyboardShortcutsService, | ||||
|     private storageService: StorageService, | ||||
|   ) { | ||||
|     this.loadEnvironment(); | ||||
|   } | ||||
|  | ||||
|   public loadEnvironment(): void { | ||||
|     this.http | ||||
|       .get('./assets/environment.json') | ||||
|       .pipe(take(1)) | ||||
|       .subscribe((data: any) => { | ||||
|         if (data && data.customer_portal) { | ||||
|           this.customerPortalLink = data.customer_portal; | ||||
|         } | ||||
|       }); | ||||
|   } | ||||
|   ) {} | ||||
|  | ||||
|   public ngOnDestroy() { | ||||
|     this.destroy$.next(); | ||||
|   | ||||
| @@ -397,8 +397,8 @@ | ||||
|         </ng-container> | ||||
|  | ||||
|         <ng-container *ngIf="currentSetting === 'urls'"> | ||||
|           <cnsl-card title=" {{ 'APP.URLS' | translate }}"> | ||||
|             <cnsl-info-section *ngIf="environmentMap['issuer']"> | ||||
|           <cnsl-card title=" {{ 'APP.URLS' | translate }}" *ngIf="environmentMap$ | async as environmentMap"> | ||||
|             <cnsl-info-section *ngIf="environmentMap.issuer"> | ||||
|               <div | ||||
|                 [innerHtml]=" | ||||
|                   'APP.OIDC.WELLKNOWN' | translate : { url: environmentMap['issuer'] + '/.well-known/openid-configuration' } | ||||
| @@ -426,7 +426,7 @@ | ||||
|             </div> | ||||
|  | ||||
|             <div class="app-info-row"> | ||||
|               <div class="app-info-wrapper" *ngFor="let wellKnownV of wellKnownMap | keyvalue"> | ||||
|               <div class="app-info-wrapper" *ngFor="let wellKnownV of wellKnownMap$ | async | keyvalue"> | ||||
|                 <p class="app-info-row-title cnsl-secondary-text">{{ wellKnownV.key }}</p> | ||||
|                 <div class="app-copy-row"> | ||||
|                   <div *ngIf="wellKnownV.value" class="environment"> | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; | ||||
| import { Location } from '@angular/common'; | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; | ||||
| import { MatLegacyCheckboxChange as MatCheckboxChange } from '@angular/material/legacy-checkbox'; | ||||
| @@ -10,7 +9,7 @@ import { TranslateService } from '@ngx-translate/core'; | ||||
| import { Buffer } from 'buffer'; | ||||
| import { Duration } from 'google-protobuf/google/protobuf/duration_pb'; | ||||
| import { Subject, Subscription } from 'rxjs'; | ||||
| import { take } from 'rxjs/operators'; | ||||
| import { map, take } from 'rxjs/operators'; | ||||
| import { RadioItemAuthType } from 'src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component'; | ||||
| import { ChangeType } from 'src/app/modules/changes/changes.component'; | ||||
| import { InfoSectionType } from 'src/app/modules/info-section/info-section.component'; | ||||
| @@ -41,6 +40,7 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; | ||||
| import { ManagementService } from 'src/app/services/mgmt.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
|  | ||||
| import { EnvironmentService } from 'src/app/services/environment.service'; | ||||
| import { AppSecretDialogComponent } from '../app-secret-dialog/app-secret-dialog.component'; | ||||
| import { | ||||
|   BASIC_AUTH_METHOD, | ||||
| @@ -79,8 +79,17 @@ export class AppDetailComponent implements OnInit, OnDestroy { | ||||
|   public projectId: string = ''; | ||||
|   public app?: App.AsObject; | ||||
|  | ||||
|   public environmentMap: { [key: string]: string } = {}; | ||||
|   public wellKnownMap: { [key: string]: string } = {}; | ||||
|   public environmentMap$ = this.envSvc.env.pipe( | ||||
|     map((env) => { | ||||
|       return { | ||||
|         issuer: env.issuer, | ||||
|         adminServiceUrl: `${env.api}/admin/v1`, | ||||
|         mgmtServiceUrl: `${env.api}/management/v1`, | ||||
|         authServiceUrl: `${env.api}/auth/v1`, | ||||
|       }; | ||||
|     }), | ||||
|   ); | ||||
|   public wellKnownMap$ = this.envSvc.wellKnown; | ||||
|  | ||||
|   public oidcResponseTypes: OIDCResponseType[] = [ | ||||
|     OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, | ||||
| @@ -138,6 +147,7 @@ export class AppDetailComponent implements OnInit, OnDestroy { | ||||
|   public currentSetting: string | undefined = this.settingsList[0].id; | ||||
|  | ||||
|   constructor( | ||||
|     private envSvc: EnvironmentService, | ||||
|     public translate: TranslateService, | ||||
|     private route: ActivatedRoute, | ||||
|     private toast: ToastService, | ||||
| @@ -148,7 +158,6 @@ export class AppDetailComponent implements OnInit, OnDestroy { | ||||
|     private authService: GrpcAuthService, | ||||
|     private router: Router, | ||||
|     private breadcrumbService: BreadcrumbService, | ||||
|     private http: HttpClient, | ||||
|   ) { | ||||
|     this.oidcForm = this.fb.group({ | ||||
|       devMode: [{ value: false, disabled: true }], | ||||
| @@ -176,25 +185,6 @@ export class AppDetailComponent implements OnInit, OnDestroy { | ||||
|       metadataUrl: [{ value: '', disabled: true }], | ||||
|       metadataXml: [{ value: '', disabled: true }], | ||||
|     }); | ||||
|  | ||||
|     this.http.get('./assets/environment.json').subscribe((env: any) => { | ||||
|       this.environmentMap = { | ||||
|         issuer: env.issuer, | ||||
|         adminServiceUrl: `${env.api}/admin/v1`, | ||||
|         mgmtServiceUrl: `${env.api}/management/v1`, | ||||
|         authServiceUrl: `${env.api}/auth/v1`, | ||||
|       }; | ||||
|  | ||||
|       this.http.get(`${env.issuer}/.well-known/openid-configuration`).subscribe((wellKnown: any) => { | ||||
|         this.wellKnownMap = { | ||||
|           authorization_endpoint: wellKnown.authorization_endpoint, | ||||
|           end_session_endpoint: wellKnown.end_session_endpoint, | ||||
|           introspection_endpoint: wellKnown.introspection_endpoint, | ||||
|           token_endpoint: wellKnown.token_endpoint, | ||||
|           userinfo_endpoint: wellKnown.userinfo_endpoint, | ||||
|         }; | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   public formatClockSkewLabel(seconds: number): string { | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { lastValueFrom } from 'rxjs'; | ||||
| import { switchMap } from 'rxjs'; | ||||
|  | ||||
| import { PolicyComponentServiceType } from '../modules/policies/policy-component-types.enum'; | ||||
| import { Theme } from '../modules/policies/private-labeling-policy/private-labeling-policy.component'; | ||||
| import { EnvironmentService } from './environment.service'; | ||||
| import { StorageService } from './storage.service'; | ||||
|  | ||||
| const authorizationKey = 'Authorization'; | ||||
| @@ -69,46 +70,29 @@ export const ENDPOINT = { | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class AssetService { | ||||
|   private serviceUrl!: Promise<string>; | ||||
|   private accessToken: string = ''; | ||||
|   constructor(private http: HttpClient, private storageService: StorageService) { | ||||
|   constructor(private envService: EnvironmentService, private http: HttpClient, private storageService: StorageService) { | ||||
|     const aT = this.storageService.getItem(accessTokenStorageKey); | ||||
|  | ||||
|     if (aT) { | ||||
|       this.accessToken = aT; | ||||
|     } | ||||
|     this.serviceUrl = this.getServiceUrl(); | ||||
|   } | ||||
|  | ||||
|   private async getServiceUrl(): Promise<string> { | ||||
|     const url = await lastValueFrom(this.http.get('./assets/environment.json')) | ||||
|       .then((data: any) => { | ||||
|         if (data && data.api) { | ||||
|           return data.api; | ||||
|         } | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error(error); | ||||
|       }); | ||||
|  | ||||
|     return url; | ||||
|   } | ||||
|  | ||||
|   public upload(endpoint: AssetEndpoint | string, body: any, orgId?: string): Promise<any> { | ||||
|     const headers: any = { | ||||
|       [authorizationKey]: `${bearerPrefix} ${this.accessToken}`, | ||||
|     }; | ||||
|  | ||||
|     if (orgId) { | ||||
|       headers[orgKey] = `${orgId}`; | ||||
|     } | ||||
|  | ||||
|     return this.serviceUrl.then((url) => | ||||
|       this.http | ||||
|         .post(`${url}/assets/v1/${endpoint}`, body, { | ||||
|           headers: headers, | ||||
|         }) | ||||
|         .toPromise(), | ||||
|     ); | ||||
|     return this.envService.env | ||||
|       .pipe( | ||||
|         switchMap((env) => | ||||
|           this.http.post(`${env.api}/assets/v1/${endpoint}`, body, { | ||||
|             headers: headers, | ||||
|           }), | ||||
|         ), | ||||
|       ) | ||||
|       .toPromise(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -35,10 +35,8 @@ export class AuthenticationService { | ||||
|       Object.assign(this.authConfig, partialConfig); | ||||
|     } | ||||
|     this.oauthService.configure(this.authConfig); | ||||
|  | ||||
|     this.oauthService.strictDiscoveryDocumentValidation = false; | ||||
|     await this.oauthService.loadDiscoveryDocumentAndTryLogin(); | ||||
|  | ||||
|     this._authenticated = this.oauthService.hasValidAccessToken(); | ||||
|     if (!this.oauthService.hasValidIdToken() || !this.authenticated || partialConfig || force) { | ||||
|       const newState = await lastValueFrom(this.statehandler.createState()); | ||||
|   | ||||
							
								
								
									
										88
									
								
								console/src/app/services/environment.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								console/src/app/services/environment.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { catchError, map, Observable, of, shareReplay, switchMap, throwError } from 'rxjs'; | ||||
|  | ||||
| import { AdminServiceClient } from '../proto/generated/zitadel/AdminServiceClientPb'; | ||||
| import { AuthServiceClient } from '../proto/generated/zitadel/AuthServiceClientPb'; | ||||
| import { ManagementServiceClient } from '../proto/generated/zitadel/ManagementServiceClientPb'; | ||||
| import { ExhaustedService } from './exhausted.service'; | ||||
|  | ||||
| export interface Environment { | ||||
|   api: string; | ||||
|   clientid: string; | ||||
|   issuer: string; | ||||
|   customer_portal?: string; | ||||
|   instance_management_url?: string; | ||||
|   exhausted?: boolean; | ||||
| } | ||||
|  | ||||
| interface WellKnown { | ||||
|   authorization_endpoint: string; | ||||
|   end_session_endpoint: string; | ||||
|   introspection_endpoint: string; | ||||
|   token_endpoint: string; | ||||
|   userinfo_endpoint: string; | ||||
| } | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class EnvironmentService { | ||||
|   private environmentJsonPath = './assets/environment.json'; | ||||
|   private wellknownPath = '/.well-known/openid-configuration`'; | ||||
|   public auth!: AuthServiceClient; | ||||
|   public mgmt!: ManagementServiceClient; | ||||
|   public admin!: AdminServiceClient; | ||||
|  | ||||
|   private environment$: Observable<Environment>; | ||||
|   private wellKnown$: Observable<WellKnown>; | ||||
|  | ||||
|   constructor(private http: HttpClient, private exhaustedSvc: ExhaustedService) { | ||||
|     this.environment$ = this.createEnvironment(); | ||||
|     this.wellKnown$ = this.createWellKnown(this.environment$); | ||||
|   } | ||||
|  | ||||
|   // env returns an `Observable<Environment>` that can be subscribed to whenever needed. | ||||
|   // It makes the HTTP call exactly once and replays the cached result. | ||||
|   // If the responses exhausted property is true, the exhaused dialog is shown. | ||||
|   get env() { | ||||
|     return this.environment$; | ||||
|   } | ||||
|  | ||||
|   // wellKnown returns an `Observable<Environment>` that can be subscribed to whenever needed. | ||||
|   // It makes the HTTP call exactly once and replays the cached result. | ||||
|   get wellKnown() { | ||||
|     return this.wellKnown$; | ||||
|   } | ||||
|  | ||||
|   private createEnvironment() { | ||||
|     return this.http.get<Environment>(this.environmentJsonPath).pipe( | ||||
|       catchError((err) => { | ||||
|         console.error('Getting environment.json failed', err); | ||||
|         return throwError(() => err); | ||||
|       }), | ||||
|       switchMap((env) => { | ||||
|         const env$ = of(env); | ||||
|         if (env.exhausted) { | ||||
|           return this.exhaustedSvc.showExhaustedDialog(env$).pipe(map(() => env)); | ||||
|         } | ||||
|         return env$; | ||||
|       }), | ||||
|       // Cache the first response, then replay it | ||||
|       shareReplay(1), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   private createWellKnown(environment$: Observable<Environment>) { | ||||
|     return environment$.pipe( | ||||
|       catchError((err) => { | ||||
|         console.error('Getting well-known OIDC configuration failed', err); | ||||
|         return throwError(() => err); | ||||
|       }), | ||||
|       switchMap((env) => { | ||||
|         return this.http.get<WellKnown>(`${env.issuer}${this.wellknownPath}`); | ||||
|       }), | ||||
|       // Cache the first response, then replay it | ||||
|       shareReplay(1), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										41
									
								
								console/src/app/services/exhausted.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								console/src/app/services/exhausted.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; | ||||
| import { map, Observable, of, switchMap, tap } from 'rxjs'; | ||||
| import { WarnDialogComponent } from '../modules/warn-dialog/warn-dialog.component'; | ||||
| import { Environment } from './environment.service'; | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class ExhaustedService { | ||||
|   private isClosed = true; | ||||
|  | ||||
|   constructor(private dialog: MatDialog) {} | ||||
|  | ||||
|   public showExhaustedDialog(env$: Observable<Environment>) { | ||||
|     if (!this.isClosed) { | ||||
|       return of(undefined); | ||||
|     } | ||||
|     this.isClosed = false; | ||||
|     return this.dialog | ||||
|       .open(WarnDialogComponent, { | ||||
|         data: { | ||||
|           confirmKey: 'ACTIONS.CONTINUE', | ||||
|           titleKey: 'ERRORS.EXHAUSTED.TITLE', | ||||
|           descriptionKey: 'ERRORS.EXHAUSTED.DESCRIPTION', | ||||
|         }, | ||||
|         disableClose: true, | ||||
|         width: '400px', | ||||
|         id: 'authenticated-requests-exhausted-dialog', | ||||
|       }) | ||||
|       .afterClosed() | ||||
|       .pipe( | ||||
|         switchMap(() => env$), | ||||
|         tap((env) => { | ||||
|           // Just reload if there is no instance management url | ||||
|           location.href = env.instance_management_url || location.href; | ||||
|         }), | ||||
|         map(() => undefined), | ||||
|       ); | ||||
|   } | ||||
| } | ||||
| @@ -1,18 +1,22 @@ | ||||
| import { PlatformLocation } from '@angular/common'; | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; | ||||
| import { TranslateService } from '@ngx-translate/core'; | ||||
| import { AuthConfig } from 'angular-oauth2-oidc'; | ||||
| import { catchError, switchMap, tap, throwError } from 'rxjs'; | ||||
|  | ||||
| import { AdminServiceClient } from '../proto/generated/zitadel/AdminServiceClientPb'; | ||||
| import { AuthServiceClient } from '../proto/generated/zitadel/AuthServiceClientPb'; | ||||
| import { ManagementServiceClient } from '../proto/generated/zitadel/ManagementServiceClientPb'; | ||||
| import { AuthenticationService } from './authentication.service'; | ||||
| import { EnvironmentService } from './environment.service'; | ||||
| import { ExhaustedService } from './exhausted.service'; | ||||
| import { AuthInterceptor } from './interceptors/auth.interceptor'; | ||||
| import { ExhaustedGrpcInterceptor } from './interceptors/exhausted.grpc.interceptor'; | ||||
| import { I18nInterceptor } from './interceptors/i18n.interceptor'; | ||||
| import { OrgInterceptor } from './interceptors/org.interceptor'; | ||||
| import { StorageService } from './storage.service'; | ||||
| import { ThemeService } from './theme.service'; | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| @@ -23,22 +27,30 @@ export class GrpcService { | ||||
|   public admin!: AdminServiceClient; | ||||
|  | ||||
|   constructor( | ||||
|     private http: HttpClient, | ||||
|     private envService: EnvironmentService, | ||||
|     private platformLocation: PlatformLocation, | ||||
|     private authenticationService: AuthenticationService, | ||||
|     private storageService: StorageService, | ||||
|     private dialog: MatDialog, | ||||
|     private translate: TranslateService, | ||||
|     private exhaustedService: ExhaustedService, | ||||
|     private themeService: ThemeService, | ||||
|   ) {} | ||||
|  | ||||
|   public async loadAppEnvironment(): Promise<any> { | ||||
|     return this.http | ||||
|       .get('./assets/environment.json') | ||||
|       .toPromise() | ||||
|       .then((data: any) => { | ||||
|         if (data && data.api && data.issuer) { | ||||
|   public loadAppEnvironment(): Promise<any> { | ||||
|     this.themeService.applyLabelPolicy(); | ||||
|     // We use the browser language until we can make API requests to get the users configured language. | ||||
|     return this.translate | ||||
|       .use(this.translate.getBrowserLang() || this.translate.defaultLang) | ||||
|       .pipe( | ||||
|         switchMap(() => this.envService.env), | ||||
|         tap((env) => { | ||||
|           if (!env?.api || !env?.issuer) { | ||||
|             return; | ||||
|           } | ||||
|           const interceptors = { | ||||
|             unaryInterceptors: [ | ||||
|               new ExhaustedGrpcInterceptor(this.exhaustedService, this.envService), | ||||
|               new OrgInterceptor(this.storageService), | ||||
|               new AuthInterceptor(this.authenticationService, this.storageService, this.dialog), | ||||
|               new I18nInterceptor(this.translate), | ||||
| @@ -46,19 +58,19 @@ export class GrpcService { | ||||
|           }; | ||||
|  | ||||
|           this.auth = new AuthServiceClient( | ||||
|             data.api, | ||||
|             env.api, | ||||
|             null, | ||||
|             // @ts-ignore | ||||
|             interceptors, | ||||
|           ); | ||||
|           this.mgmt = new ManagementServiceClient( | ||||
|             data.api, | ||||
|             env.api, | ||||
|             null, | ||||
|             // @ts-ignore | ||||
|             interceptors, | ||||
|           ); | ||||
|           this.admin = new AdminServiceClient( | ||||
|             data.api, | ||||
|             env.api, | ||||
|             null, | ||||
|             // @ts-ignore | ||||
|             interceptors, | ||||
| @@ -68,19 +80,20 @@ export class GrpcService { | ||||
|             scope: 'openid profile email', | ||||
|             responseType: 'code', | ||||
|             oidc: true, | ||||
|             clientId: data.clientid, | ||||
|             issuer: data.issuer, | ||||
|             clientId: env.clientid, | ||||
|             issuer: env.issuer, | ||||
|             redirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'auth/callback', | ||||
|             postLogoutRedirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'signedout', | ||||
|             requireHttps: false, | ||||
|           }; | ||||
|  | ||||
|           this.authenticationService.initConfig(authConfig); | ||||
|         } | ||||
|         return Promise.resolve(data); | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         console.error('Failed to load environment from assets'); | ||||
|       }); | ||||
|         }), | ||||
|         catchError((err) => { | ||||
|           console.error('Failed to load environment from assets', err); | ||||
|           return throwError(() => err); | ||||
|         }), | ||||
|       ) | ||||
|       .toPromise(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -43,7 +43,7 @@ export class AuthInterceptor<TReq = unknown, TResp = unknown> implements UnaryIn | ||||
|       .then((response: any) => { | ||||
|         return response; | ||||
|       }) | ||||
|       .catch((error: any) => { | ||||
|       .catch(async (error: any) => { | ||||
|         if (error.code === 16) { | ||||
|           this.triggerDialog.next(true); | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,29 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { Request, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web'; | ||||
| import { EnvironmentService } from '../environment.service'; | ||||
| import { ExhaustedService } from '../exhausted.service'; | ||||
|  | ||||
| /** | ||||
|  * ExhaustedGrpcInterceptor shows the exhausted dialog after receiving a gRPC response status 8. | ||||
|  */ | ||||
| @Injectable({ providedIn: 'root' }) | ||||
| export class ExhaustedGrpcInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> { | ||||
|   constructor(private exhaustedSvc: ExhaustedService, private envSvc: EnvironmentService) {} | ||||
|  | ||||
|   public async intercept( | ||||
|     request: Request<TReq, TResp>, | ||||
|     invoker: (request: Request<TReq, TResp>) => Promise<UnaryResponse<TReq, TResp>>, | ||||
|   ): Promise<UnaryResponse<TReq, TResp>> { | ||||
|     return invoker(request).catch((error: any) => { | ||||
|       if (error.code === StatusCode.RESOURCE_EXHAUSTED) { | ||||
|         return this.exhaustedSvc | ||||
|           .showExhaustedDialog(this.envSvc.env) | ||||
|           .toPromise() | ||||
|           .then(() => { | ||||
|             throw error; | ||||
|           }); | ||||
|       } | ||||
|       throw error; | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { catchError, Observable, switchMap, throwError } from 'rxjs'; | ||||
| import { EnvironmentService } from '../environment.service'; | ||||
| import { ExhaustedService } from '../exhausted.service'; | ||||
|  | ||||
| /** | ||||
|  * ExhaustedHttpInterceptor shows the exhausted dialog after receiving an HTTP response status 429. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class ExhaustedHttpInterceptor implements HttpInterceptor { | ||||
|   constructor(private exhaustedSvc: ExhaustedService, private envSvc: EnvironmentService) {} | ||||
|  | ||||
|   intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { | ||||
|     return next.handle(req).pipe( | ||||
|       catchError((error: HttpErrorResponse) => { | ||||
|         if (error.status === 429) { | ||||
|           return this.exhaustedSvc.showExhaustedDialog(this.envSvc.env).pipe(switchMap(() => throwError(() => error))); | ||||
|         } | ||||
|         return throwError(() => error); | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,43 +0,0 @@ | ||||
| import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; | ||||
| import { OAuthModuleConfig } from 'angular-oauth2-oidc'; | ||||
| import { Observable } from 'rxjs'; | ||||
|  | ||||
| import { Org } from '../../proto/generated/zitadel/org_pb'; | ||||
| import { StorageKey, StorageLocation, StorageService } from '../storage.service'; | ||||
|  | ||||
| const orgKey = 'x-zitadel-orgid'; | ||||
| export abstract class HttpOrgInterceptor implements HttpInterceptor { | ||||
|   private org!: Org.AsObject; | ||||
|  | ||||
|   protected get validUrls(): string[] { | ||||
|     return this.oauthModuleConfig.resourceServer.allowedUrls || []; | ||||
|   } | ||||
|  | ||||
|   constructor(private storageService: StorageService, protected oauthModuleConfig: OAuthModuleConfig) { | ||||
|     const org: Org.AsObject | null = this.storageService.getItem(StorageKey.organization, StorageLocation.session); | ||||
|  | ||||
|     if (org) { | ||||
|       this.org = org; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { | ||||
|     if (!this.urlValidation(req.url)) { | ||||
|       return next.handle(req); | ||||
|     } | ||||
|  | ||||
|     return next.handle( | ||||
|       req.clone({ | ||||
|         setHeaders: { | ||||
|           [orgKey]: this.org.id, | ||||
|         }, | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   private urlValidation(toIntercept: string): boolean { | ||||
|     const URLS = ['https://api.zitadel.dev/assets', 'https://api.zitadel.ch/assets']; | ||||
|  | ||||
|     return URLS.findIndex((url) => toIntercept.startsWith(url)) > -1; | ||||
|   } | ||||
| } | ||||
| @@ -252,6 +252,10 @@ | ||||
|       "TITLE": "Du bist abgemeldet", | ||||
|       "DESCRIPTION": "Klicke auf \"Einloggen\", um Dich erneut anzumelden." | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "Dein Kontingent an authentifizierten Anfragen is erschöpft.", | ||||
|       "DESCRIPTION": "Lösche oder erhöhe die Grenze für diese ZITADEL Instanz." | ||||
|     }, | ||||
|     "INVALID_FORMAT": "Das Format is ungültig.", | ||||
|     "NOTANEMAIL": "Der eingegebene Wert ist keine E-Mail Adresse.", | ||||
|     "MINLENGTH": "Muss mindestens {{requiredLength}} Zeichen lang sein.", | ||||
|   | ||||
| @@ -253,6 +253,10 @@ | ||||
|       "TITLE": "Your authorization token has expired.", | ||||
|       "DESCRIPTION": "Click the button below to log in again." | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "Your quota for authenticated requests is exhausted.", | ||||
|       "DESCRIPTION": "Remove or increase the quota limit for this ZITADEL instance." | ||||
|     }, | ||||
|     "INVALID_FORMAT": "The formatting is invalid.", | ||||
|     "NOTANEMAIL": "The given value is not an e-mail address.", | ||||
|     "MINLENGTH": "Must be at least {{requiredLength}} characters long.", | ||||
|   | ||||
| @@ -253,6 +253,10 @@ | ||||
|       "TITLE": "Tu token de autorización token ha caducado.", | ||||
|       "DESCRIPTION": "Haz clic en el botón más abajo para iniciar sesión otra vez." | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "Su cuota de solicitudes autenticadas se ha agotado.", | ||||
|       "DESCRIPTION": "Borrar o aumentar el límite de esta instancia de ZITADEL." | ||||
|     }, | ||||
|     "INVALID_FORMAT": "El formato no es valido.", | ||||
|     "NOTANEMAIL": "El valor proporcionado no es una dirección de email.", | ||||
|     "MINLENGTH": "Debe tener al menos {{requiredLength}} caracteres de longitud.", | ||||
|   | ||||
| @@ -252,6 +252,10 @@ | ||||
|       "TITLE": "Votre jeton d'autorisation a expiré.", | ||||
|       "DESCRIPTION": "Cliquez sur le bouton ci-dessous pour vous reconnecter." | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "Ton quota de demandes authentifiées est épuisé.", | ||||
|       "DESCRIPTION": "Supprimez ou augmentez la limite de cette instance ZITADEL." | ||||
|     }, | ||||
|     "INVALID_FORMAT": "Le format n'est pas valide", | ||||
|     "NOTANEMAIL": "La valeur donnée n'est pas une adresse e-mail", | ||||
|     "MINLENGTH": "Doit comporter au moins {{length}} caractères.", | ||||
|   | ||||
| @@ -252,6 +252,10 @@ | ||||
|       "TITLE": "Il tuo Access Token \u00e8 scaduto.", | ||||
|       "DESCRIPTION": "Clicca il pulsante per richiedere una nuova sessione." | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "La quota di richieste autenticate è esaurita.", | ||||
|       "DESCRIPTION": "Cancellare o aumentare il limite per questa istanza ZITADEL." | ||||
|     }, | ||||
|     "INVALID_FORMAT": "Il formato non è valido.", | ||||
|     "NOTANEMAIL": "Il valore dato non \u00e8 un indirizzo e-mail.", | ||||
|     "MINLENGTH": "Deve essere lunga almeno {{requiredLength}} caratteri.", | ||||
|   | ||||
| @@ -253,6 +253,10 @@ | ||||
|       "TITLE": "トークンが期限切れになりました。", | ||||
|       "DESCRIPTION": "下のボタンをクリックして、もう一度ログインする。" | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "認証されたリクエストのクォータを使い果たしました", | ||||
|       "DESCRIPTION": "このZITADELインスタンスの制限を削除または増加させる" | ||||
|     }, | ||||
|     "INVALID_FORMAT": "不正なフォーマットです", | ||||
|     "NOTANEMAIL": "入力された値がメールアドレスではありません。", | ||||
|     "MINLENGTH": "{{requiredLength}} 文字以上の文字列が必要です。", | ||||
|   | ||||
| @@ -252,6 +252,10 @@ | ||||
|       "TITLE": "Twój token autoryzacji wygasł.", | ||||
|       "DESCRIPTION": "Kliknij przycisk poniżej, aby ponownie się zalogować." | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "Twój limit uwierzytelnionych wniosków został wyczerpany.", | ||||
|       "DESCRIPTION": "Usuń lub zwiększ limit dla tej instancji ZITADEL." | ||||
|     }, | ||||
|     "INVALID_FORMAT": "Format jest nieprawidłowy.", | ||||
|     "NOTANEMAIL": "Podana wartość nie jest adresem e-mail.", | ||||
|     "MINLENGTH": "Musi mieć co najmniej {{requiredLength}} znaków.", | ||||
|   | ||||
| @@ -252,6 +252,10 @@ | ||||
|       "TITLE": "您的授权令牌已过期。", | ||||
|       "DESCRIPTION": "点击下方按钮再次登录。" | ||||
|     }, | ||||
|     "EXHAUSTED": { | ||||
|       "TITLE": "你的认证请求配额已用完.", | ||||
|       "DESCRIPTION": "删除或增加这个ZITADEL实例的限制。" | ||||
|     }, | ||||
|     "INVALID_FORMAT": "格式是无效的。", | ||||
|     "NOTANEMAIL": "给定的值不是合法电子邮件地址。", | ||||
|     "MINLENGTH": "长度必须至少是{{requiredLength}}字符。", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Elio Bischof
					Elio Bischof