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:
Elio Bischof 2023-05-19 12:48:39 +02:00 committed by GitHub
parent 2e86c44aa5
commit b475068140
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 336 additions and 155 deletions

View File

@ -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/.

View File

@ -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",

View 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);
});

View File

@ -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,

View File

@ -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;
}

View File

@ -83,7 +83,7 @@
</a>
<a
*ngIf="customerPortalLink"
*ngIf="customerPortalLink$ | async as customerPortalLink"
class="nav-item external-link"
[href]="customerPortalLink"
target="_blank"

View File

@ -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();

View File

@ -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">

View File

@ -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 {

View File

@ -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();
}
}

View File

@ -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());

View 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),
);
}
}

View 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),
);
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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;
});
}
}

View File

@ -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);
}),
);
}
}

View File

@ -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;
}
}

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -253,6 +253,10 @@
"TITLE": "トークンが期限切れになりました。",
"DESCRIPTION": "下のボタンをクリックして、もう一度ログインする。"
},
"EXHAUSTED": {
"TITLE": "認証されたリクエストのクォータを使い果たしました",
"DESCRIPTION": "このZITADELインスタンスの制限を削除または増加させる"
},
"INVALID_FORMAT": "不正なフォーマットです",
"NOTANEMAIL": "入力された値がメールアドレスではありません。",
"MINLENGTH": "{{requiredLength}} 文字以上の文字列が必要です。",

View File

@ -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.",

View File

@ -252,6 +252,10 @@
"TITLE": "您的授权令牌已过期。",
"DESCRIPTION": "点击下方按钮再次登录。"
},
"EXHAUSTED": {
"TITLE": "你的认证请求配额已用完.",
"DESCRIPTION": "删除或增加这个ZITADEL实例的限制。"
},
"INVALID_FORMAT": "格式是无效的。",
"NOTANEMAIL": "给定的值不是合法电子邮件地址。",
"MINLENGTH": "长度必须至少是{{requiredLength}}字符。",