diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35c27ef3b..2e0055fa5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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)
Note for WSL2 on Windows 10 @@ -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/. diff --git a/console/package.json b/console/package.json index bee613d6b..4e4fb35db 100644 --- a/console/package.json +++ b/console/package.json @@ -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", diff --git a/console/prebuild.development.js b/console/prebuild.development.js new file mode 100644 index 000000000..01cd1135e --- /dev/null +++ b/console/prebuild.development.js @@ -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); +}); diff --git a/console/src/app/app.module.ts b/console/src/app/app.module.ts index 2adbbbd8e..adb60c8af 100644 --- a/console/src/app/app.module.ts +++ b/console/src/app/app.module.ts @@ -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, diff --git a/console/src/app/modules/footer/footer.component.ts b/console/src/app/modules/footer/footer.component.ts index 270128bdc..b0fa971b0 100644 --- a/console/src/app/modules/footer/footer.component.ts +++ b/console/src/app/modules/footer/footer.component.ts @@ -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; } diff --git a/console/src/app/modules/nav/nav.component.html b/console/src/app/modules/nav/nav.component.html index 38e77ed8a..8f8aea7e5 100644 --- a/console/src/app/modules/nav/nav.component.html +++ b/console/src/app/modules/nav/nav.component.html @@ -83,7 +83,7 @@ = 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(); diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.html b/console/src/app/pages/projects/apps/app-detail/app-detail.component.html index 0d69f370d..67ea50c6e 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.html +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.html @@ -397,8 +397,8 @@ - - + +
-
+

{{ wellKnownV.key }}

diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts b/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts index d02eaa776..2f743acb7 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts @@ -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 { diff --git a/console/src/app/services/asset.service.ts b/console/src/app/services/asset.service.ts index 25e6ba1df..320150ff4 100644 --- a/console/src/app/services/asset.service.ts +++ b/console/src/app/services/asset.service.ts @@ -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; 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 { - 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 { 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(); } } diff --git a/console/src/app/services/authentication.service.ts b/console/src/app/services/authentication.service.ts index b4b24c30f..c5de101e6 100644 --- a/console/src/app/services/authentication.service.ts +++ b/console/src/app/services/authentication.service.ts @@ -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()); diff --git a/console/src/app/services/environment.service.ts b/console/src/app/services/environment.service.ts new file mode 100644 index 000000000..343cd424b --- /dev/null +++ b/console/src/app/services/environment.service.ts @@ -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; + private wellKnown$: Observable; + + constructor(private http: HttpClient, private exhaustedSvc: ExhaustedService) { + this.environment$ = this.createEnvironment(); + this.wellKnown$ = this.createWellKnown(this.environment$); + } + + // env returns an `Observable` 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` 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(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) { + return environment$.pipe( + catchError((err) => { + console.error('Getting well-known OIDC configuration failed', err); + return throwError(() => err); + }), + switchMap((env) => { + return this.http.get(`${env.issuer}${this.wellknownPath}`); + }), + // Cache the first response, then replay it + shareReplay(1), + ); + } +} diff --git a/console/src/app/services/exhausted.service.ts b/console/src/app/services/exhausted.service.ts new file mode 100644 index 000000000..135e30e5f --- /dev/null +++ b/console/src/app/services/exhausted.service.ts @@ -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) { + 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), + ); + } +} diff --git a/console/src/app/services/grpc.service.ts b/console/src/app/services/grpc.service.ts index 9dc4a8480..a842edfb2 100644 --- a/console/src/app/services/grpc.service.ts +++ b/console/src/app/services/grpc.service.ts @@ -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 { - return this.http - .get('./assets/environment.json') - .toPromise() - .then((data: any) => { - if (data && data.api && data.issuer) { + public loadAppEnvironment(): Promise { + 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(); } } diff --git a/console/src/app/services/interceptors/auth.interceptor.ts b/console/src/app/services/interceptors/auth.interceptor.ts index 18f85fbb0..5ff77dcad 100644 --- a/console/src/app/services/interceptors/auth.interceptor.ts +++ b/console/src/app/services/interceptors/auth.interceptor.ts @@ -43,7 +43,7 @@ export class AuthInterceptor implements UnaryIn .then((response: any) => { return response; }) - .catch((error: any) => { + .catch(async (error: any) => { if (error.code === 16) { this.triggerDialog.next(true); } diff --git a/console/src/app/services/interceptors/exhausted.grpc.interceptor.ts b/console/src/app/services/interceptors/exhausted.grpc.interceptor.ts new file mode 100644 index 000000000..7486b525c --- /dev/null +++ b/console/src/app/services/interceptors/exhausted.grpc.interceptor.ts @@ -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 implements UnaryInterceptor { + constructor(private exhaustedSvc: ExhaustedService, private envSvc: EnvironmentService) {} + + public async intercept( + request: Request, + invoker: (request: Request) => Promise>, + ): Promise> { + 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; + }); + } +} diff --git a/console/src/app/services/interceptors/exhausted.http.interceptor.ts b/console/src/app/services/interceptors/exhausted.http.interceptor.ts new file mode 100644 index 000000000..898f9ccd9 --- /dev/null +++ b/console/src/app/services/interceptors/exhausted.http.interceptor.ts @@ -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, next: HttpHandler): Observable> { + 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); + }), + ); + } +} diff --git a/console/src/app/services/interceptors/http-org.interceptor.ts b/console/src/app/services/interceptors/http-org.interceptor.ts deleted file mode 100644 index 9531e1471..000000000 --- a/console/src/app/services/interceptors/http-org.interceptor.ts +++ /dev/null @@ -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, next: HttpHandler): Observable> { - 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; - } -} diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index b4fbe919e..68d81ac77 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -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.", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 586d21db0..2d27939ee 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -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.", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 80ce39bc6..0fea6748a 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -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.", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index a95736564..e24116fd8 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -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.", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 113252814..4d18722af 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -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.", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 8c3e33d22..2ba3863ad 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -253,6 +253,10 @@ "TITLE": "トークンが期限切れになりました。", "DESCRIPTION": "下のボタンをクリックして、もう一度ログインする。" }, + "EXHAUSTED": { + "TITLE": "認証されたリクエストのクォータを使い果たしました", + "DESCRIPTION": "このZITADELインスタンスの制限を削除または増加させる" + }, "INVALID_FORMAT": "不正なフォーマットです", "NOTANEMAIL": "入力された値がメールアドレスではありません。", "MINLENGTH": "{{requiredLength}} 文字以上の文字列が必要です。", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index fc480d285..0dd538ba3 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -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.", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index bfd30edfa..dbc8dcc19 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -252,6 +252,10 @@ "TITLE": "您的授权令牌已过期。", "DESCRIPTION": "点击下方按钮再次登录。" }, + "EXHAUSTED": { + "TITLE": "你的认证请求配额已用完.", + "DESCRIPTION": "删除或增加这个ZITADEL实例的限制。" + }, "INVALID_FORMAT": "格式是无效的。", "NOTANEMAIL": "给定的值不是合法电子邮件地址。", "MINLENGTH": "长度必须至少是{{requiredLength}}字符。",