mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-12 17:15:15 +00:00
feat: rehauled console (#3525)
* new console * move npm ci to angular build * rel path for assets * local grpc copy * login policy, rm clear views, features rel path * lock Co-authored-by: Livio Amstutz <[email protected]>
This commit is contained in:
co-authored by
Livio Amstutz
parent
00f7dbe875
commit
08ae39ae19
File diff suppressed because it is too large
Load Diff
@@ -84,8 +84,8 @@ export class AssetService {
|
||||
.get('./assets/environment.json')
|
||||
.toPromise()
|
||||
.then((data: any) => {
|
||||
if (data && data.api) {
|
||||
return data.api;
|
||||
if (data && data.assetServiceUrl) {
|
||||
return data.assetServiceUrl;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
||||
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
|
||||
import { BehaviorSubject, from, Observable } from 'rxjs';
|
||||
|
||||
import { StatehandlerService } from './statehandler.service';
|
||||
import { StatehandlerService } from './statehandler/statehandler.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -10,14 +10,9 @@ import { StatehandlerService } from './statehandler.service';
|
||||
export class AuthenticationService {
|
||||
private authConfig!: AuthConfig;
|
||||
private _authenticated: boolean = false;
|
||||
private readonly _authenticationChanged: BehaviorSubject<
|
||||
boolean
|
||||
> = new BehaviorSubject(this.authenticated);
|
||||
private readonly _authenticationChanged: BehaviorSubject<boolean> = new BehaviorSubject(this.authenticated);
|
||||
|
||||
constructor(
|
||||
private oauthService: OAuthService,
|
||||
private statehandler: StatehandlerService,
|
||||
) { }
|
||||
constructor(private oauthService: OAuthService, private statehandler: StatehandlerService) {}
|
||||
|
||||
public initConfig(data: AuthConfig): void {
|
||||
this.authConfig = data;
|
||||
@@ -35,10 +30,7 @@ export class AuthenticationService {
|
||||
return from(this.oauthService.loadUserProfile());
|
||||
}
|
||||
|
||||
public async authenticate(
|
||||
partialConfig?: Partial<AuthConfig>,
|
||||
force: boolean = false,
|
||||
): Promise<boolean> {
|
||||
public async authenticate(partialConfig?: Partial<AuthConfig>, force: boolean = false): Promise<boolean> {
|
||||
if (partialConfig) {
|
||||
Object.assign(this.authConfig, partialConfig);
|
||||
}
|
||||
@@ -63,4 +55,3 @@ export class AuthenticationService {
|
||||
this._authenticationChanged.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
|
||||
import { ManagementService } from './mgmt.service';
|
||||
|
||||
export enum BreadcrumbType {
|
||||
IAM,
|
||||
ORG,
|
||||
PROJECT,
|
||||
GRANTEDPROJECT,
|
||||
PROJECTGRANT,
|
||||
APP,
|
||||
IDP,
|
||||
AUTHUSER,
|
||||
}
|
||||
|
||||
export class Breadcrumb {
|
||||
type: BreadcrumbType = BreadcrumbType.PROJECT;
|
||||
name?: string = '';
|
||||
param?: {
|
||||
key: 'projectid' | 'appid' | 'grantid' | 'id';
|
||||
value: string;
|
||||
} = {
|
||||
key: 'projectid',
|
||||
value: '',
|
||||
};
|
||||
routerLink: any[] = [];
|
||||
isZitadel?: boolean = false;
|
||||
hideNav?: boolean = false;
|
||||
|
||||
constructor(init: Partial<Breadcrumb>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BreadcrumbService {
|
||||
public readonly breadcrumbs$: BehaviorSubject<Breadcrumb[]> = new BehaviorSubject<Breadcrumb[]>([]);
|
||||
public readonly breadcrumbsExtended$ = combineLatest([
|
||||
this.breadcrumbs$,
|
||||
this.mgmtService.ownedProjects,
|
||||
this.mgmtService.grantedProjects,
|
||||
]).pipe(
|
||||
map(([breadcrumbs, projects, grantedProjects]) => {
|
||||
const newValues = breadcrumbs.map((b) => {
|
||||
if (!b.name && b.type === BreadcrumbType.PROJECT) {
|
||||
const project = projects.find((project) => b.param && project.id === b.param.value);
|
||||
b.name = project?.name ?? '';
|
||||
return b;
|
||||
} else if (!b.name && b.type === BreadcrumbType.GRANTEDPROJECT) {
|
||||
const grantedproject = grantedProjects.find(
|
||||
(grantedproject) => b.param && grantedproject.projectId === b.param.value,
|
||||
);
|
||||
b.name = grantedproject?.projectName ?? '';
|
||||
return b;
|
||||
} else {
|
||||
return b;
|
||||
}
|
||||
});
|
||||
return newValues;
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(private mgmtService: ManagementService) {}
|
||||
|
||||
public setBreadcrumb(breadcrumbs: Breadcrumb[]) {
|
||||
this.breadcrumbs$.next(breadcrumbs);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export class GrpcAuthService {
|
||||
} else {
|
||||
let orgs = this.cachedOrgs;
|
||||
if (orgs.length === 0) {
|
||||
orgs = (await this.listMyProjectOrgs(10, 0)).resultList;
|
||||
orgs = (await this.listMyProjectOrgs()).resultList;
|
||||
this.cachedOrgs = orgs;
|
||||
}
|
||||
|
||||
@@ -310,22 +310,24 @@ export class GrpcAuthService {
|
||||
}
|
||||
|
||||
public listMyProjectOrgs(
|
||||
limit: number,
|
||||
offset: number,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
queryList?: OrgQuery[],
|
||||
): Promise<ListMyProjectOrgsResponse.AsObject> {
|
||||
const req = new ListMyProjectOrgsRequest();
|
||||
const metadata = new ListQuery();
|
||||
const query = new ListQuery();
|
||||
if (offset) {
|
||||
metadata.setOffset(offset);
|
||||
query.setOffset(offset);
|
||||
}
|
||||
if (limit) {
|
||||
metadata.setLimit(limit);
|
||||
query.setLimit(limit);
|
||||
}
|
||||
if (queryList) {
|
||||
req.setQueriesList(queryList);
|
||||
}
|
||||
|
||||
req.setQuery(query);
|
||||
|
||||
return this.grpcService.auth.listMyProjectOrgs(req, null).then((resp) => resp.toObject());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable, OnDestroy, Renderer2, RendererFactory2 } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject, Observable, of, pairwise, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { KeyboardShortcutsComponent } from '../../modules/keyboard-shortcuts/keyboard-shortcuts.component';
|
||||
import { GrpcAuthService } from '../grpc-auth.service';
|
||||
import {
|
||||
ACTIONS,
|
||||
DOMAINS,
|
||||
HOME,
|
||||
KeyboardShortcut,
|
||||
ME,
|
||||
ORG,
|
||||
PROJECTS,
|
||||
SYSTEM,
|
||||
USERGRANTS,
|
||||
USERS,
|
||||
} from './keyboard-shortcuts';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class KeyboardShortcutsService implements OnDestroy {
|
||||
private renderer: Renderer2;
|
||||
private keyPressed: BehaviorSubject<any> = new BehaviorSubject<any>(null);
|
||||
private destroy$: Subject<void> = new Subject();
|
||||
|
||||
constructor(
|
||||
private rendererFactory2: RendererFactory2,
|
||||
private dialog: MatDialog,
|
||||
private router: Router,
|
||||
private authService: GrpcAuthService,
|
||||
) {
|
||||
this.renderer = this.rendererFactory2.createRenderer(null, null);
|
||||
this.renderer.listen('document', 'keydown', (event) => {
|
||||
this.keyPressed.next(event);
|
||||
});
|
||||
|
||||
this.keyPressed.pipe(pairwise(), takeUntil(this.destroy$)).subscribe(([firstKey, secondKey]) => {
|
||||
const firstTagname = firstKey?.target?.tagName;
|
||||
const secondTagname = secondKey?.target?.tagName;
|
||||
|
||||
const exclude = ['input', 'textarea'];
|
||||
|
||||
if (
|
||||
firstKey &&
|
||||
secondKey &&
|
||||
exclude.indexOf(firstTagname?.toLowerCase()) === -1 &&
|
||||
exclude.indexOf(secondTagname?.toLowerCase()) === -1
|
||||
) {
|
||||
if (secondKey.key === '?') {
|
||||
this.openOverviewDialog();
|
||||
}
|
||||
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyH') {
|
||||
if (this.hasPermission(HOME) && this.hasFeature(HOME)) {
|
||||
this.router.navigate(HOME.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyS') {
|
||||
if (this.hasPermission(SYSTEM) && this.hasFeature(SYSTEM)) {
|
||||
this.router.navigate(SYSTEM.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyO') {
|
||||
if (this.hasPermission(ORG) && this.hasFeature(ORG)) {
|
||||
this.router.navigate(ORG.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyM' && secondKey.code === 'KeyE') {
|
||||
if (this.hasPermission(ME) && this.hasFeature(ME)) {
|
||||
this.router.navigate(ME.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyP') {
|
||||
if (this.hasPermission(PROJECTS) && this.hasFeature(PROJECTS)) {
|
||||
this.router.navigate(PROJECTS.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyU') {
|
||||
if (this.hasPermission(USERS) && this.hasFeature(USERS)) {
|
||||
this.router.navigate(USERS.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyA') {
|
||||
if (this.hasPermission(USERGRANTS) && this.hasFeature(USERGRANTS)) {
|
||||
this.router.navigate(USERGRANTS.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyF') {
|
||||
if (this.hasPermission(ACTIONS) && this.hasFeature(ACTIONS)) {
|
||||
this.router.navigate(ACTIONS.link);
|
||||
}
|
||||
}
|
||||
if (firstKey.code === 'KeyG' && secondKey.code === 'KeyD') {
|
||||
if (this.hasPermission(DOMAINS) && this.hasFeature(DOMAINS)) {
|
||||
this.router.navigate(DOMAINS.link);
|
||||
}
|
||||
}
|
||||
|
||||
// if (secondKey.shiftKey && (secondKey.code === 'Digit7' || secondKey.key==='/')) {
|
||||
|
||||
// }
|
||||
|
||||
// SIDEWIDESHORTCUTS.f
|
||||
} else if (secondKey && exclude.indexOf(secondTagname?.toLowerCase()) === -1) {
|
||||
if (secondKey.key === '?') {
|
||||
this.openOverviewDialog();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
public openOverviewDialog(): void {
|
||||
const dialogRef = this.dialog.open(KeyboardShortcutsComponent, {
|
||||
data: {},
|
||||
width: '400px',
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((resp) => {
|
||||
if (resp) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasPermission(shortcut: KeyboardShortcut): Observable<boolean> {
|
||||
if (shortcut.permissions) {
|
||||
return this.authService.isAllowed(shortcut.permissions);
|
||||
} else {
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
private hasFeature(shortcut: KeyboardShortcut): Observable<boolean> {
|
||||
if (shortcut.features) {
|
||||
return this.authService.canUseFeature(shortcut.features);
|
||||
} else {
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface KeyboardShortcut {
|
||||
keyboardKeys: string[];
|
||||
link: any[];
|
||||
i18nKey: string;
|
||||
features?: string[] | RegExp[];
|
||||
permissions?: string[] | RegExp[];
|
||||
}
|
||||
|
||||
export const HOME: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.HOME',
|
||||
link: ['/'],
|
||||
keyboardKeys: ['g', 'h'],
|
||||
};
|
||||
|
||||
export const SYSTEM: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.SYSTEM',
|
||||
link: ['/system'],
|
||||
keyboardKeys: ['g', 's'],
|
||||
permissions: ['iam.read'],
|
||||
};
|
||||
|
||||
export const ORG: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.ORG',
|
||||
link: ['/org'],
|
||||
keyboardKeys: ['g', 'o'],
|
||||
permissions: ['org.read'],
|
||||
};
|
||||
|
||||
export const ME: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.ME',
|
||||
link: ['/users/me'],
|
||||
keyboardKeys: ['m', 'e'],
|
||||
};
|
||||
|
||||
export const PROJECTS: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.PROJECTS',
|
||||
link: ['/projects'],
|
||||
keyboardKeys: ['g', 'p'],
|
||||
permissions: ['project.read'],
|
||||
};
|
||||
|
||||
export const USERS: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.USERS',
|
||||
link: ['/users'],
|
||||
keyboardKeys: ['g', 'u'],
|
||||
permissions: ['user.read'],
|
||||
};
|
||||
|
||||
export const USERGRANTS: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.USERGRANTS',
|
||||
link: ['/grants'],
|
||||
keyboardKeys: ['g', 'a'],
|
||||
permissions: ['usergrant.read'],
|
||||
};
|
||||
|
||||
export const ACTIONS: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.ACTIONS',
|
||||
link: ['/actions'],
|
||||
keyboardKeys: ['g', 'f'],
|
||||
features: ['actions'],
|
||||
permissions: ['org.action.read'],
|
||||
};
|
||||
|
||||
export const DOMAINS: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.DOMAINS',
|
||||
link: ['/domains'],
|
||||
keyboardKeys: ['g', 'd'],
|
||||
permissions: ['org.read'],
|
||||
};
|
||||
|
||||
export const SIDEWIDESHORTCUTS = [ME, HOME, SYSTEM, ORG, PROJECTS, USERS, USERGRANTS, ACTIONS, DOMAINS];
|
||||
|
||||
export const ORGSWITCHER: KeyboardShortcut = {
|
||||
i18nKey: 'KEYBOARDSHORTCUTS.SHORTCUTS.ORGSWITCHER',
|
||||
link: ['/org'],
|
||||
keyboardKeys: ['/'],
|
||||
};
|
||||
|
||||
export const ORGSHORTCUTS = [ORGSWITCHER];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { SortDirection } from '@angular/material/sort';
|
||||
import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
|
||||
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
@@ -191,6 +192,8 @@ import {
|
||||
ListAppKeysResponse,
|
||||
ListAppsRequest,
|
||||
ListAppsResponse,
|
||||
ListGrantedProjectRolesRequest,
|
||||
ListGrantedProjectRolesResponse,
|
||||
ListGrantedProjectsRequest,
|
||||
ListGrantedProjectsResponse,
|
||||
ListHumanAuthFactorsRequest,
|
||||
@@ -432,7 +435,7 @@ import { MetadataQuery } from '../proto/generated/zitadel/metadata_pb';
|
||||
import { ListQuery } from '../proto/generated/zitadel/object_pb';
|
||||
import { DomainSearchQuery, DomainValidationType } from '../proto/generated/zitadel/org_pb';
|
||||
import { PasswordComplexityPolicy } from '../proto/generated/zitadel/policy_pb';
|
||||
import { ProjectQuery, RoleQuery } from '../proto/generated/zitadel/project_pb';
|
||||
import { GrantedProject, Project, ProjectQuery, RoleQuery } from '../proto/generated/zitadel/project_pb';
|
||||
import {
|
||||
Gender,
|
||||
MembershipQuery,
|
||||
@@ -448,7 +451,9 @@ export type ResponseMapper<TResp, TMappedResp> = (resp: TResp) => TMappedResp;
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ManagementService {
|
||||
public ownedProjects: BehaviorSubject<Project.AsObject[]> = new BehaviorSubject<Project.AsObject[]>([]);
|
||||
public ownedProjectsCount: BehaviorSubject<number> = new BehaviorSubject(0);
|
||||
public grantedProjects: BehaviorSubject<GrantedProject.AsObject[]> = new BehaviorSubject<GrantedProject.AsObject[]>([]);
|
||||
public grantedProjectsCount: BehaviorSubject<number> = new BehaviorSubject(0);
|
||||
|
||||
constructor(private readonly grpcService: GrpcService) {}
|
||||
@@ -634,14 +639,15 @@ export class ManagementService {
|
||||
|
||||
public listOrgIDPs(limit?: number, offset?: number, queryList?: IDPQuery[]): Promise<ListOrgIDPsResponse.AsObject> {
|
||||
const req = new ListOrgIDPsRequest();
|
||||
const metadata = new ListQuery();
|
||||
const query = new ListQuery();
|
||||
|
||||
if (limit) {
|
||||
metadata.setLimit(limit);
|
||||
query.setLimit(limit);
|
||||
}
|
||||
if (offset) {
|
||||
metadata.setOffset(offset);
|
||||
query.setOffset(offset);
|
||||
}
|
||||
req.setQuery(query);
|
||||
if (queryList) {
|
||||
req.setQueriesList(queryList);
|
||||
}
|
||||
@@ -1633,6 +1639,7 @@ export class ManagementService {
|
||||
offset: number,
|
||||
queriesList?: UserSearchQuery[],
|
||||
sortingColumn?: UserFieldName,
|
||||
sortingDirection?: SortDirection,
|
||||
): Promise<ListUsersResponse.AsObject> {
|
||||
const req = new ListUsersRequest();
|
||||
const query = new ListQuery();
|
||||
@@ -1642,10 +1649,14 @@ export class ManagementService {
|
||||
if (offset) {
|
||||
query.setOffset(offset);
|
||||
}
|
||||
if (sortingDirection) {
|
||||
query.setAsc(sortingDirection === 'asc');
|
||||
}
|
||||
req.setQuery(query);
|
||||
if (sortingColumn) {
|
||||
req.setSortingColumn(sortingColumn);
|
||||
}
|
||||
|
||||
if (queriesList) {
|
||||
req.setQueriesList(queriesList);
|
||||
}
|
||||
@@ -1811,6 +1822,7 @@ export class ManagementService {
|
||||
const obj = value.toObject();
|
||||
const count = obj.resultList.length;
|
||||
if (count >= 0) {
|
||||
this.ownedProjects.next(obj.resultList);
|
||||
this.ownedProjectsCount.next(count);
|
||||
}
|
||||
|
||||
@@ -1819,8 +1831,8 @@ export class ManagementService {
|
||||
}
|
||||
|
||||
public listGrantedProjects(
|
||||
limit: number,
|
||||
offset: number,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
queryList?: ProjectQuery[],
|
||||
): Promise<ListGrantedProjectsResponse.AsObject> {
|
||||
const req = new ListGrantedProjectsRequest();
|
||||
@@ -1839,6 +1851,7 @@ export class ManagementService {
|
||||
}
|
||||
return this.grpcService.mgmt.listGrantedProjects(req, null).then((value) => {
|
||||
const obj = value.toObject();
|
||||
this.grantedProjects.next(obj.resultList);
|
||||
this.grantedProjectsCount.next(obj.resultList.length);
|
||||
return obj;
|
||||
});
|
||||
@@ -2110,6 +2123,32 @@ export class ManagementService {
|
||||
return this.grpcService.mgmt.listProjectRoles(req, null).then((resp) => resp.toObject());
|
||||
}
|
||||
|
||||
public listGrantedProjectRoles(
|
||||
projectId: string,
|
||||
grantId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
queryList?: RoleQuery[],
|
||||
): Promise<ListGrantedProjectRolesResponse.AsObject> {
|
||||
const req = new ListGrantedProjectRolesRequest();
|
||||
req.setProjectId(projectId);
|
||||
req.setGrantId(grantId);
|
||||
|
||||
const query = new ListQuery();
|
||||
if (limit) {
|
||||
query.setLimit(limit);
|
||||
}
|
||||
if (offset) {
|
||||
query.setOffset(offset);
|
||||
}
|
||||
|
||||
req.setQuery(query);
|
||||
if (queryList) {
|
||||
req.setQueriesList(queryList);
|
||||
}
|
||||
return this.grpcService.mgmt.listGrantedProjectRoles(req, null).then((resp) => resp.toObject());
|
||||
}
|
||||
|
||||
public bulkAddProjectRoles(
|
||||
projectId: string,
|
||||
rolesList: BulkAddProjectRolesRequest.Role[],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NavigationService {
|
||||
private history: string[] = [];
|
||||
|
||||
constructor(private router: Router, private location: Location) {
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
this.history.push(event.urlAfterRedirects);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public back(): void {
|
||||
if (this.isBackPossible) {
|
||||
this.location.back();
|
||||
this.history.pop();
|
||||
} else {
|
||||
this.router.navigateByUrl('/');
|
||||
this.history.pop();
|
||||
}
|
||||
}
|
||||
|
||||
public get isBackPossible(): boolean {
|
||||
return this.history.length > 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { OverlayRef } from '@angular/cdk/overlay';
|
||||
|
||||
export class CnslOverlayRef {
|
||||
constructor(private overlayRef: OverlayRef) {}
|
||||
|
||||
close(): void {
|
||||
this.overlayRef.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { MediaMatcher } from '@angular/cdk/layout';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, of, Subject, switchMap, take, takeUntil, zip } from 'rxjs';
|
||||
|
||||
import { GrpcAuthService } from '../grpc-auth.service';
|
||||
import { CnslOverlayRef } from './overlay-ref';
|
||||
import { OverlayService } from './overlay.service';
|
||||
|
||||
export interface OverlayWorkflow {
|
||||
currentIndex: number;
|
||||
currentRef?: CnslOverlayRef;
|
||||
overlays: CnslOverlay[];
|
||||
}
|
||||
|
||||
export interface CnslOverlay {
|
||||
id: string;
|
||||
origin: string;
|
||||
toHighlight: string[];
|
||||
content: {
|
||||
count?: number;
|
||||
number?: number;
|
||||
i18nText: string;
|
||||
};
|
||||
requirements?: {
|
||||
media?: string;
|
||||
permission?: string[];
|
||||
feature?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OverlayWorkflowService implements OnDestroy {
|
||||
public readonly currentWorkflow$: BehaviorSubject<OverlayWorkflow | null> = new BehaviorSubject<OverlayWorkflow | null>(
|
||||
null,
|
||||
);
|
||||
public destroy$: Subject<void> = new Subject();
|
||||
|
||||
public openRef: CnslOverlayRef | null = null;
|
||||
public highlightedIds: { [id: string]: number } = {};
|
||||
public callback: Function | null = null;
|
||||
constructor(private mediaMatcher: MediaMatcher, overlayService: OverlayService, private authService: GrpcAuthService) {
|
||||
this.currentWorkflow$.pipe(takeUntil(this.destroy$)).subscribe((workflow) => {
|
||||
if (this.openRef) {
|
||||
this.openRef.close();
|
||||
this.openRef = null;
|
||||
}
|
||||
|
||||
Object.keys(this.highlightedIds).forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.style.removeProperty('z-index');
|
||||
delete this.highlightedIds[id];
|
||||
}
|
||||
});
|
||||
|
||||
const overlay = workflow?.overlays[workflow.currentIndex];
|
||||
if (overlay) {
|
||||
this.meetsRequirements(overlay)
|
||||
.pipe(take(1))
|
||||
.subscribe((can) => {
|
||||
if (can) {
|
||||
this.openRef = overlayService.open(overlay);
|
||||
|
||||
overlay.toHighlight.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
const oldZ = element.style.zIndex;
|
||||
this.highlightedIds[id] = Number(oldZ);
|
||||
element.style.zIndex = '1001';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.nextStep();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public meetsRequirements(overlay: CnslOverlay): Observable<boolean> {
|
||||
return zip([
|
||||
this.meetsMediaRequirements(overlay),
|
||||
this.meetsPermissionRequirements(overlay),
|
||||
this.meetsFeatureRequirements(overlay),
|
||||
] as Observable<boolean>[]).pipe(
|
||||
switchMap(([media, permission, feature]) => {
|
||||
return of(media && permission && feature);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public meetsMediaRequirements(overlay: CnslOverlay): Observable<boolean> {
|
||||
const media = overlay.requirements?.media;
|
||||
if (media) {
|
||||
const matches = this.mediaMatcher.matchMedia(media).matches;
|
||||
if (!matches) {
|
||||
return of(false);
|
||||
} else {
|
||||
return of(true);
|
||||
}
|
||||
} else {
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
public meetsPermissionRequirements(overlay: CnslOverlay): Observable<boolean> {
|
||||
const regexArray = overlay.requirements?.permission;
|
||||
if (regexArray && regexArray.length) {
|
||||
return this.authService.isAllowed(regexArray);
|
||||
} else {
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
public meetsFeatureRequirements(overlay: CnslOverlay): Observable<boolean> {
|
||||
const regexArray = overlay.requirements?.feature;
|
||||
if (regexArray && regexArray.length) {
|
||||
return this.authService.canUseFeature(regexArray);
|
||||
} else {
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.currentWorkflow$.next(null);
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
public startWorkflow(overlays: CnslOverlay[], cb: Function | null): void {
|
||||
if (cb) {
|
||||
this.callback = cb;
|
||||
}
|
||||
this.currentWorkflow$.next({ overlays, currentIndex: 0 });
|
||||
}
|
||||
|
||||
public nextStep(): void {
|
||||
const currentWorkflow = this.currentWorkflow$.value;
|
||||
if (this.nextPossible && currentWorkflow) {
|
||||
const nextIndex = currentWorkflow?.currentIndex + 1;
|
||||
this.currentWorkflow$.next({ ...currentWorkflow, currentIndex: nextIndex });
|
||||
} else {
|
||||
this.currentWorkflow$.next(null);
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public previousStep(): void {
|
||||
const currentWorkflow = this.currentWorkflow$.value;
|
||||
if (this.previousPossible && currentWorkflow) {
|
||||
const nextIndex = currentWorkflow?.currentIndex - 1;
|
||||
this.currentWorkflow$.next({ ...currentWorkflow, currentIndex: nextIndex });
|
||||
}
|
||||
}
|
||||
|
||||
public get nextPossible(): boolean {
|
||||
const currentWorkflow = this.currentWorkflow$.value;
|
||||
|
||||
if (currentWorkflow) {
|
||||
const nextIndex = currentWorkflow?.currentIndex + 1;
|
||||
if (nextIndex < currentWorkflow?.overlays.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public get isLast(): boolean {
|
||||
const currentWorkflow = this.currentWorkflow$.value;
|
||||
|
||||
if (currentWorkflow) {
|
||||
const currentIndex = currentWorkflow?.currentIndex;
|
||||
|
||||
if (currentIndex == currentWorkflow?.overlays.length - 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public get previousPossible(): boolean {
|
||||
const currentWorkflow = this.currentWorkflow$.value;
|
||||
|
||||
if (currentWorkflow && currentWorkflow.currentIndex > 0) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ConnectedPosition, ConnectionPositionPair, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal, PortalInjector } from '@angular/cdk/portal';
|
||||
import { ComponentRef, Injectable, Injector } from '@angular/core';
|
||||
|
||||
import { InfoOverlayComponent, OVERLAY_DATA } from '../../modules/info-overlay/info-overlay.component';
|
||||
import { CnslOverlayRef } from './overlay-ref';
|
||||
import { CnslOverlay } from './overlay-workflow.service';
|
||||
|
||||
interface InfoOverlayConfig extends CnslOverlay {
|
||||
backdropClass?: string;
|
||||
panelClass?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Partial<InfoOverlayConfig> = {
|
||||
backdropClass: 'cnsl-overlay-backdrop',
|
||||
panelClass: 'cnsl-overlay-panel',
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OverlayService {
|
||||
constructor(private overlay: Overlay, private injector: Injector) {}
|
||||
|
||||
public open(overlay: CnslOverlay) {
|
||||
const dialogConfig: InfoOverlayConfig = { ...DEFAULT_CONFIG, ...overlay };
|
||||
const overlayRef = this.createOverlay(dialogConfig);
|
||||
|
||||
const dialogRef = new CnslOverlayRef(overlayRef);
|
||||
|
||||
const overlayComponent = this.attachOverlayContainer(overlayRef, dialogConfig, overlayRef);
|
||||
|
||||
overlayRef.backdropClick().subscribe((_) => dialogRef.close());
|
||||
|
||||
return dialogRef;
|
||||
}
|
||||
|
||||
private attachOverlayContainer(overlayRef: OverlayRef, config: InfoOverlayConfig, dialogRef: OverlayRef) {
|
||||
const injector = this.createInjector(config, dialogRef);
|
||||
|
||||
const containerPortal = new ComponentPortal(InfoOverlayComponent, null, injector);
|
||||
const containerRef: ComponentRef<InfoOverlayComponent> = overlayRef.attach(containerPortal);
|
||||
|
||||
return containerRef.instance;
|
||||
}
|
||||
|
||||
private createInjector(config: InfoOverlayConfig, dialogRef: OverlayRef): PortalInjector {
|
||||
const injectionTokens = new WeakMap();
|
||||
|
||||
injectionTokens.set(OverlayRef, dialogRef);
|
||||
injectionTokens.set(OVERLAY_DATA, config.content);
|
||||
|
||||
return new PortalInjector(this.injector, injectionTokens);
|
||||
}
|
||||
|
||||
private createOverlay(config: InfoOverlayConfig): OverlayRef {
|
||||
const overlayConfig = this.getOverlayConfig(config);
|
||||
return this.overlay.create(overlayConfig);
|
||||
}
|
||||
|
||||
private getOverlayConfig(config: InfoOverlayConfig): OverlayConfig {
|
||||
// const positionStrategy = this.overlay.position().global().centerHorizontally().centerVertically();
|
||||
|
||||
const htmlOrigin: HTMLElement | null = document.getElementById(config.origin);
|
||||
|
||||
const positions: ConnectedPosition[] = [
|
||||
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10),
|
||||
new ConnectionPositionPair({ originX: 'end', originY: 'bottom' }, { overlayX: 'end', overlayY: 'top' }, 0, 10),
|
||||
];
|
||||
|
||||
let positionStrategy;
|
||||
if (htmlOrigin) {
|
||||
positionStrategy = this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(htmlOrigin)
|
||||
.withPositions(positions)
|
||||
.withFlexibleDimensions(true)
|
||||
.withPush(false);
|
||||
} else {
|
||||
positionStrategy = this.overlay.position().global().centerHorizontally().centerVertically();
|
||||
}
|
||||
|
||||
const overlayConfig = new OverlayConfig({
|
||||
hasBackdrop: true,
|
||||
backdropClass: config.backdropClass,
|
||||
panelClass: config.panelClass,
|
||||
scrollStrategy: this.overlay.scrollStrategies.block(),
|
||||
positionStrategy,
|
||||
});
|
||||
|
||||
return overlayConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CnslOverlay } from './overlay-workflow.service';
|
||||
|
||||
export const IntroWorkflowOverlays: CnslOverlay[] = [
|
||||
{
|
||||
id: 'orgswitcher',
|
||||
origin: 'orgswitchbutton',
|
||||
toHighlight: ['orgswitchbutton', 'orglink'],
|
||||
content: {
|
||||
number: 1,
|
||||
count: 4,
|
||||
i18nText: 'OVERLAYS.ORGSWITCHER.TEXT',
|
||||
},
|
||||
requirements: {
|
||||
permission: ['org.read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'systembutton',
|
||||
origin: 'systembutton',
|
||||
toHighlight: ['systembutton'],
|
||||
content: {
|
||||
number: 2,
|
||||
count: 4,
|
||||
i18nText: 'OVERLAYS.SYSTEM.TEXT',
|
||||
},
|
||||
requirements: {
|
||||
media: '(min-width: 600px)',
|
||||
permission: ['iam.read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'profilebutton',
|
||||
origin: 'avatartoggle',
|
||||
toHighlight: ['avatartoggle'],
|
||||
content: {
|
||||
number: 3,
|
||||
count: 4,
|
||||
i18nText: 'OVERLAYS.PROFILE.TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mainnav',
|
||||
origin: 'mainnav',
|
||||
toHighlight: ['mainnav'],
|
||||
content: {
|
||||
number: 4,
|
||||
count: 4,
|
||||
i18nText: 'OVERLAYS.NAV.TEXT',
|
||||
},
|
||||
requirements: {
|
||||
permission: ['org.read'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const OrgContextChangedWorkflowOverlays: CnslOverlay[] = [
|
||||
{
|
||||
id: 'orgswitcher',
|
||||
origin: 'orgswitchbutton',
|
||||
toHighlight: ['orgswitchbutton', 'orglink'],
|
||||
content: {
|
||||
i18nText: 'OVERLAYS.CONTEXTCHANGED.TEXT',
|
||||
},
|
||||
requirements: {
|
||||
permission: ['org.read'],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -20,24 +20,28 @@ export interface StripeCustomer {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SubscriptionService {
|
||||
constructor(private http: HttpClient, private storageService: StorageService) { }
|
||||
constructor(private http: HttpClient, private storageService: StorageService) {}
|
||||
|
||||
public getLink(orgId: string, redirectURI: string): Promise<any> {
|
||||
return this.http.get('./assets/environment.json')
|
||||
.toPromise().then((data: any) => {
|
||||
return this.http
|
||||
.get('./assets/environment.json')
|
||||
.toPromise()
|
||||
.then((data: any) => {
|
||||
if (data && data.subscriptionServiceUrl) {
|
||||
const serviceUrl = data.api;
|
||||
const serviceUrl = data.subscriptionServiceUrl;
|
||||
const accessToken = this.storageService.getItem(accessTokenStorageKey);
|
||||
return this.http.get(`${serviceUrl}/redirect`, {
|
||||
headers: {
|
||||
[authorizationKey]: `${bearerPrefix} ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
'org': orgId,
|
||||
'return_url': encodeURI(redirectURI),
|
||||
'country': 'ch',
|
||||
},
|
||||
}).toPromise();
|
||||
return this.http
|
||||
.get(`${serviceUrl}/redirect`, {
|
||||
headers: {
|
||||
[authorizationKey]: `${bearerPrefix} ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
org: orgId,
|
||||
return_url: encodeURI(redirectURI),
|
||||
country: 'ch',
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
} else {
|
||||
return Promise.reject('Could not load environment');
|
||||
}
|
||||
@@ -45,19 +49,23 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
public getCustomer(orgId: string): Promise<any> {
|
||||
return this.http.get('./assets/environment.json')
|
||||
.toPromise().then((data: any) => {
|
||||
return this.http
|
||||
.get('../../assets/environment.json')
|
||||
.toPromise()
|
||||
.then((data: any) => {
|
||||
if (data && data.subscriptionServiceUrl) {
|
||||
const serviceUrl = data.api;
|
||||
const serviceUrl = data.subscriptionServiceUrl;
|
||||
const accessToken = this.storageService.getItem(accessTokenStorageKey);
|
||||
return this.http.get(`${serviceUrl}/customer`, {
|
||||
headers: {
|
||||
[authorizationKey]: `${bearerPrefix} ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
'org': orgId,
|
||||
},
|
||||
}).toPromise();
|
||||
return this.http
|
||||
.get(`${serviceUrl}/customer`, {
|
||||
headers: {
|
||||
[authorizationKey]: `${bearerPrefix} ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
org: orgId,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
} else {
|
||||
return Promise.reject('Could not load environment');
|
||||
}
|
||||
@@ -65,19 +73,23 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
public setCustomer(orgId: string, body: StripeCustomer): Promise<any> {
|
||||
return this.http.get('./assets/environment.json')
|
||||
.toPromise().then((data: any) => {
|
||||
return this.http
|
||||
.get('./assets/environment.json')
|
||||
.toPromise()
|
||||
.then((data: any) => {
|
||||
if (data && data.subscriptionServiceUrl) {
|
||||
const serviceUrl = data.api;
|
||||
const serviceUrl = data.subscriptionServiceUrl;
|
||||
const accessToken = this.storageService.getItem(accessTokenStorageKey);
|
||||
return this.http.post(`${serviceUrl}/customer`, body, {
|
||||
headers: {
|
||||
[authorizationKey]: `${bearerPrefix} ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
'org': orgId,
|
||||
},
|
||||
}).toPromise();
|
||||
return this.http
|
||||
.post(`${serviceUrl}/customer`, body, {
|
||||
headers: {
|
||||
[authorizationKey]: `${bearerPrefix} ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
org: orgId,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
} else {
|
||||
return Promise.reject('Could not load environment');
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
declare const tinycolor: any;
|
||||
|
||||
export interface Color {
|
||||
name: string;
|
||||
hex: string;
|
||||
darkContrast: boolean;
|
||||
rgb: string;
|
||||
contrastColor: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ThemeService {
|
||||
private _darkTheme: Subject<boolean> = new Subject<boolean>();
|
||||
private _darkTheme: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
|
||||
public isDarkTheme: Observable<boolean> = this._darkTheme.asObservable();
|
||||
|
||||
private primaryColorPalette: Color[] = [];
|
||||
private warnColorPalette: Color[] = [];
|
||||
private backgroundColorPalette: Color[] = [];
|
||||
|
||||
constructor() {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme) {
|
||||
if (theme === 'light-theme') {
|
||||
this.setDarkTheme(false);
|
||||
} else {
|
||||
this.setDarkTheme(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDarkTheme(isDarkTheme: boolean): void {
|
||||
this._darkTheme.next(isDarkTheme);
|
||||
}
|
||||
@@ -25,10 +37,7 @@ export class ThemeService {
|
||||
public updateTheme(colors: Color[], type: string, theme: string): void {
|
||||
colors.forEach((color) => {
|
||||
document.documentElement.style.setProperty(`--theme-${theme}-${type}-${color.name}`, color.hex);
|
||||
document.documentElement.style.setProperty(
|
||||
`--theme-${theme}-${type}-contrast-${color.name}`,
|
||||
color.darkContrast ? 'hsla(0, 0%, 0%, 0.87)' : '#ffffff',
|
||||
);
|
||||
document.documentElement.style.setProperty(`--theme-${theme}-${type}-contrast-${color.name}`, color.contrastColor);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,7 +86,8 @@ export class ThemeService {
|
||||
return {
|
||||
name: name,
|
||||
hex: c.toHexString(),
|
||||
darkContrast: c.isLight(),
|
||||
rgb: c.toRgbString(),
|
||||
contrastColor: this.getContrast(c.toHexString()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,4 +100,14 @@ export class ThemeService {
|
||||
const color = tinycolor(hex);
|
||||
return color.isDark();
|
||||
}
|
||||
|
||||
public getContrast(color: string): string {
|
||||
const onBlack = tinycolor.readability('#000', color);
|
||||
const onWhite = tinycolor.readability('#fff', color);
|
||||
if (onBlack > onWhite) {
|
||||
return 'hsla(0, 0%, 0%, 0.87)';
|
||||
} else {
|
||||
return '#ffffff';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class ToastService {
|
||||
constructor(
|
||||
private snackBar: MatSnackBar,
|
||||
private translate: TranslateService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
public showInfo(message: string, i18nkey: boolean = false): void {
|
||||
if (i18nkey) {
|
||||
@@ -19,12 +19,12 @@ export class ToastService {
|
||||
.get(message)
|
||||
.subscribe(data => {
|
||||
this.translate.get('ACTIONS.CLOSE').pipe(take(1)).subscribe(value => {
|
||||
this.showMessage(data, value);
|
||||
this.showMessage(data, value, true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.translate.get('ACTIONS.CLOSE').pipe(take(1)).subscribe(value => {
|
||||
this.showMessage(message, value);
|
||||
this.showMessage(message, value, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,16 @@ export class ToastService {
|
||||
const { message, code, metadata } = grpcError;
|
||||
if (code !== 16) {
|
||||
this.translate.get('ACTIONS.CLOSE').pipe(take(1)).subscribe(value => {
|
||||
this.showMessage(decodeURI(message), value);
|
||||
this.showMessage(decodeURI(message), value, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(message: string, action: string): Observable<void> {
|
||||
const ref = this.snackBar.open(message, action, { duration: 4000 });
|
||||
private showMessage(message: string, action: string, success: boolean): Observable<void> {
|
||||
const ref = this.snackBar.open(message, action, {
|
||||
duration: 4000,
|
||||
panelClass: success ? "data-e2e-success" : "data-e2e-failure"
|
||||
});
|
||||
|
||||
return ref.onAction();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user