mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-04 23:45:07 +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:
parent
2e86c44aa5
commit
b475068140
@ -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}}字符。",
|
||||
|
Loading…
Reference in New Issue
Block a user