mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-30 19:12:16 +00:00
feat(console): rehauled navigation for instances including breadcrumb (#10283)
This PR significantly improves user navigation by introducing a new instance-level navigation bar. This new bar, positioned above the existing organization navigation, provides quick access to key sections: Home, Organizations, Actions, and Settings. Additionally, the breadcrumb component has been refined for more consistent behavior, reintroducing intuitive breadcrumb buttons to easily navigate up the hierarchy. These changes also include design improvements for a cleaner and more streamlined appearance across the interface. <img width="423" height="138" alt="Screenshot 2025-07-17 at 14 55 46" src="https://github.com/user-attachments/assets/ba9e40a1-1077-4cb6-8735-ac7ab637abe7" /> <img width="562" height="132" alt="Screenshot 2025-07-17 at 14 56 41" src="https://github.com/user-attachments/assets/d85dc673-0df8-4677-9d2b-dc031dde42c3" /> <img width="545" height="254" alt="Screenshot 2025-07-17 at 14 56 10" src="https://github.com/user-attachments/assets/eaf10117-079e-4181-8dbb-60c89b24556a" /> <img width="689" height="261" alt="Screenshot 2025-07-17 at 14 56 20" src="https://github.com/user-attachments/assets/510ad550-1d9a-4c6a-8af1-66cb0b23619c" /> --------- Co-authored-by: conblem <[email protected]> Co-authored-by: Stefan Benz <[email protected]> Co-authored-by: Florian Forster <[email protected]>
This commit is contained in:
co-authored by
conblem
Stefan Benz
Florian Forster
parent
cccba3f8f3
commit
f963ea9f86
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, NgIterable, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthConfig } from 'angular-oauth2-oidc';
|
||||
import { SessionState as V1SessionState, User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
|
||||
@@ -146,22 +146,22 @@ export class AccountsCardComponent {
|
||||
this.closedCard.emit();
|
||||
}
|
||||
|
||||
public selectAccount(loginHint: string): void {
|
||||
public async selectAccount(loginHint: string): Promise<void> {
|
||||
const configWithPrompt: Partial<AuthConfig> = {
|
||||
customQueryParams: {
|
||||
login_hint: loginHint,
|
||||
},
|
||||
};
|
||||
this.authService.authenticate(configWithPrompt).then();
|
||||
await this.authService.authenticate(configWithPrompt);
|
||||
}
|
||||
|
||||
public selectNewAccount(): void {
|
||||
public async selectNewAccount(): Promise<void> {
|
||||
const configWithPrompt: Partial<AuthConfig> = {
|
||||
customQueryParams: {
|
||||
prompt: 'login',
|
||||
} as any,
|
||||
},
|
||||
};
|
||||
this.authService.authenticate(configWithPrompt).then();
|
||||
await this.authService.authenticate(configWithPrompt);
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<h2>{{ 'ACTIONSTWO.EXECUTION.TITLE' | translate }}</h2>
|
||||
<cnsl-info-section [type]="InfoSectionType.ALERT">
|
||||
{{ 'ACTIONSTWO.BETA_NOTE' | translate }}
|
||||
</cnsl-info-section>
|
||||
|
||||
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}</p>
|
||||
|
||||
<cnsl-actions-two-actions-table
|
||||
|
||||
-1
@@ -21,7 +21,6 @@ import { ToastService } from 'src/app/services/toast.service';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { InputModule } from 'src/app/modules/input/input.module';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MessageInitShape } from '@bufbuild/protobuf';
|
||||
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module';
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<h2>{{ 'ACTIONSTWO.TARGET.TITLE' | translate }}</h2>
|
||||
<cnsl-info-section [type]="InfoSectionType.ALERT">
|
||||
{{ 'ACTIONSTWO.BETA_NOTE' | translate }}
|
||||
</cnsl-info-section>
|
||||
|
||||
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.TARGET.DESCRIPTION' | translate }}</p>
|
||||
|
||||
<cnsl-actions-two-targets-table
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { KeyValue } from '@angular/common';
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, DestroyRef, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
|
||||
import { BehaviorSubject, from, Observable, of, Subject } from 'rxjs';
|
||||
import { catchError, debounceTime, scan, take, takeUntil, tap } from 'rxjs/operators';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'src/app/proto/generated/zitadel/management_pb';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
export enum ChangeType {
|
||||
MYUSER = 'myuser',
|
||||
@@ -45,17 +46,18 @@ type ListChanges =
|
||||
| ListOrgChangesResponse.AsObject
|
||||
| ListAppChangesResponse.AsObject;
|
||||
|
||||
// todo: update this component to react to input changes
|
||||
@Component({
|
||||
selector: 'cnsl-changes',
|
||||
templateUrl: './changes.component.html',
|
||||
styleUrls: ['./changes.component.scss'],
|
||||
})
|
||||
export class ChangesComponent implements OnInit, OnDestroy {
|
||||
@Input() public changeType: ChangeType = ChangeType.USER;
|
||||
export class ChangesComponent implements OnInit {
|
||||
@Input({ required: true }) public changeType!: ChangeType;
|
||||
@Input() public id: string = '';
|
||||
@Input() public secId: string = '';
|
||||
@Input() public sortDirectionAsc: boolean = true;
|
||||
@Input() public refresh!: Observable<void>;
|
||||
@Input() public refresh?: Observable<void>;
|
||||
public bottom: boolean = false;
|
||||
|
||||
private _done: BehaviorSubject<any> = new BehaviorSubject(false);
|
||||
@@ -65,30 +67,26 @@ export class ChangesComponent implements OnInit, OnDestroy {
|
||||
loading: Observable<boolean> = this._loading.asObservable();
|
||||
public data: Observable<MappedChange[]> = this._data.asObservable().pipe(
|
||||
scan((acc, val) => {
|
||||
return false ? val.concat(acc) : acc.concat(val);
|
||||
return acc.concat(val);
|
||||
}),
|
||||
);
|
||||
public changes!: ListChanges;
|
||||
private destroyed$: Subject<void> = new Subject();
|
||||
constructor(
|
||||
private mgmtUserService: ManagementService,
|
||||
private authUserService: GrpcAuthService,
|
||||
private readonly mgmtUserService: ManagementService,
|
||||
private readonly authUserService: GrpcAuthService,
|
||||
private readonly destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.init();
|
||||
if (this.refresh) {
|
||||
this.refresh.pipe(takeUntil(this.destroyed$), debounceTime(2000)).subscribe(() => {
|
||||
this.refresh.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(() => {
|
||||
this._data = new BehaviorSubject([]);
|
||||
this.init();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed$.next();
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
let first: Promise<ListChanges>;
|
||||
switch (this.changeType) {
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
<mat-spinner [diameter]="20"></mat-spinner>
|
||||
</div>
|
||||
<ng-template #logo>
|
||||
<a class="title custom" [routerLink]="['/']" *ngIf="authService.labelpolicy$ | async as lP; else defaultHome">
|
||||
<a
|
||||
class="title custom"
|
||||
[routerLink]="(['iam.read', 'iam.policy.read'] | hasRole | async) ? ['/'] : ['/org']"
|
||||
*ngIf="authService.labelpolicy$ | async as lP; else defaultHome"
|
||||
>
|
||||
<img
|
||||
class="logo"
|
||||
alt="home logo"
|
||||
@@ -37,134 +41,7 @@
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngFor="let bread of breadcrumbService.breadcrumbs$ | async as bc; index as i">
|
||||
<ng-container *ngIf="bread.type === BreadcrumbType.INSTANCE">
|
||||
<ng-template cnslHasRole [hasRole]="['iam.read']">
|
||||
<svg
|
||||
class="slash hide-on-small"
|
||||
viewBox="0 0 24 24"
|
||||
width="32"
|
||||
height="32"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
shape-rendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16.88 3.549L7.12 20.451"></path>
|
||||
</svg>
|
||||
|
||||
<div class="breadcrumb-context hide-on-small">
|
||||
<a matRipple [matRippleUnbounded]="false" class="breadcrumb-link" [routerLink]="bread.routerLink">
|
||||
{{ 'MENU.INSTANCE' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="bread.type === BreadcrumbType.ORG">
|
||||
<svg
|
||||
class="slash"
|
||||
viewBox="0 0 24 24"
|
||||
width="32"
|
||||
height="32"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
shape-rendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16.88 3.549L7.12 20.451"></path>
|
||||
</svg>
|
||||
|
||||
<div class="org-context">
|
||||
<a *ngIf="org" matRipple [matRippleUnbounded]="false" class="org-link" id="orglink" [routerLink]="['/org']">
|
||||
{{ org.name ? org.name : 'NO NAME' }}</a
|
||||
>
|
||||
|
||||
<div class="org-context-wrapper" *ngIf="org">
|
||||
<button
|
||||
cdkOverlayOrigin
|
||||
#trigger="cdkOverlayOrigin"
|
||||
matRipple
|
||||
[matRippleUnbounded]="false"
|
||||
id="orgswitchbutton"
|
||||
class="org-switch-button"
|
||||
(click)="showOrgContext = !showOrgContext"
|
||||
>
|
||||
<span class="svgspan">
|
||||
<svg xmlns=" http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<cnsl-action-keys
|
||||
(actionTriggered)="showOrgContext = !showOrgContext"
|
||||
[type]="ActionKeysType.ORGSWITCHER"
|
||||
></cnsl-action-keys>
|
||||
</button>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="trigger"
|
||||
[cdkConnectedOverlayOffsetY]="10"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayPositions]="positions"
|
||||
cdkConnectedOverlayBackdropClass="transparent-backdrop"
|
||||
[cdkConnectedOverlayOpen]="showOrgContext"
|
||||
(backdropClick)="showOrgContext = false"
|
||||
(detach)="showOrgContext = false"
|
||||
>
|
||||
<cnsl-org-context
|
||||
class="context_card"
|
||||
*ngIf="org && showOrgContext"
|
||||
(closedCard)="showOrgContext = false"
|
||||
[org]="org"
|
||||
(setOrg)="setActiveOrg($event)"
|
||||
>
|
||||
</cnsl-org-context>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="bread.type !== BreadcrumbType.INSTANCE && bread.type !== BreadcrumbType.ORG">
|
||||
<svg
|
||||
class="slash"
|
||||
viewBox="0 0 24 24"
|
||||
width="32"
|
||||
height="32"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
shape-rendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16.88 3.549L7.12 20.451"></path>
|
||||
</svg>
|
||||
|
||||
<div class="breadcrumb-context">
|
||||
<a
|
||||
matRipple
|
||||
[matRippleUnbounded]="false"
|
||||
class="breadcrumb-link"
|
||||
[ngClass]="{ maxwidth: bc.length > 1 }"
|
||||
[routerLink]="bread.routerLink"
|
||||
>
|
||||
<ng-container *ngIf="i !== bc.length - 1; else defLabel">
|
||||
<span class="desk">{{ bread.name }}</span>
|
||||
<span class="mob">...</span>
|
||||
</ng-container>
|
||||
<ng-template #defLabel>
|
||||
<span>{{ bread.name }}</span>
|
||||
</ng-template>
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<cnsl-new-header></cnsl-new-header>
|
||||
|
||||
<span class="fill-space"></span>
|
||||
|
||||
@@ -178,33 +55,6 @@
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<div class="system-rel" *ngIf="!isOnMe">
|
||||
<a
|
||||
id="systembutton"
|
||||
*ngIf="!isOnInstance && (['iam.read$', 'iam.write$'] | hasRole | async)"
|
||||
[routerLink]="['/instance']"
|
||||
class="iam-settings"
|
||||
mat-stroked-button
|
||||
>
|
||||
<div class="cnsl-action-button">
|
||||
<span class="iam-label">{{ 'MENU.INSTANCE' | translate }}</span>
|
||||
<i class="las la-cog"></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
id="orgbutton"
|
||||
*ngIf="isOnInstance && (['org.read'] | hasRole | async)"
|
||||
[routerLink]="['/org']"
|
||||
class="org-settings"
|
||||
mat-stroked-button
|
||||
>
|
||||
<div class="cnsl-action-button">
|
||||
<span class="iam-label">{{ 'MENU.ORGANIZATION' | translate }}</span>
|
||||
<i class="las la-cog"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="user && user.id">
|
||||
<div class="account-card-wrapper">
|
||||
<button
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { User } from 'src/app/proto/generated/zitadel/user_pb';
|
||||
import { BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { ActionKeysType } from '../action-keys/action-keys.component';
|
||||
import { NewOrganizationService } from '../../services/new-organization.service';
|
||||
import { Organization } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-header',
|
||||
@@ -18,11 +19,11 @@ export class HeaderComponent {
|
||||
@Input({ required: true }) public user!: User.AsObject;
|
||||
public showOrgContext: boolean = false;
|
||||
|
||||
@Input() public org!: Org.AsObject;
|
||||
@Output() public changedActiveOrg: EventEmitter<Org.AsObject> = new EventEmitter();
|
||||
@Input() public org?: Organization | null;
|
||||
@Output() public changedActiveOrg = new EventEmitter<void>();
|
||||
public showAccount: boolean = false;
|
||||
public BreadcrumbType: any = BreadcrumbType;
|
||||
public ActionKeysType: any = ActionKeysType;
|
||||
protected readonly BreadcrumbType = BreadcrumbType;
|
||||
protected readonly ActionKeysType = ActionKeysType;
|
||||
|
||||
public positions: ConnectedPosition[] = [
|
||||
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10),
|
||||
@@ -38,12 +39,12 @@ export class HeaderComponent {
|
||||
public mgmtService: ManagementService,
|
||||
public breadcrumbService: BreadcrumbService,
|
||||
public router: Router,
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
) {}
|
||||
|
||||
public setActiveOrg(org: Org.AsObject): void {
|
||||
this.org = org;
|
||||
this.authService.setActiveOrg(org);
|
||||
this.changedActiveOrg.emit(org);
|
||||
public async setActiveOrg(orgId: string): Promise<void> {
|
||||
await this.newOrganizationService.setOrgId(orgId);
|
||||
this.changedActiveOrg.emit();
|
||||
}
|
||||
|
||||
public get isOnMe(): boolean {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ActionKeysModule } from '../action-keys/action-keys.module';
|
||||
import { AvatarModule } from '../avatar/avatar.module';
|
||||
import { OrgContextModule } from '../org-context/org-context.module';
|
||||
import { HeaderComponent } from './header.component';
|
||||
import { NewHeaderComponent } from '../new-header/new-header.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [HeaderComponent],
|
||||
@@ -38,6 +39,7 @@ import { HeaderComponent } from './header.component';
|
||||
AvatarModule,
|
||||
AccountsCardModule,
|
||||
HasRolePipeModule,
|
||||
NewHeaderComponent,
|
||||
],
|
||||
exports: [HeaderComponent],
|
||||
})
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
*ngIf="instance?.state"
|
||||
class="state"
|
||||
[ngClass]="{
|
||||
active: instance.state === State.INSTANCE_STATE_RUNNING,
|
||||
inactive: instance.state === State.INSTANCE_STATE_STOPPED || instance.state === State.INSTANCE_STATE_STOPPING,
|
||||
active: instance.state === State.STATE_RUNNING,
|
||||
inactive: instance.state === State.STATE_STOPPED || instance.state === State.STATE_STOPPING,
|
||||
}"
|
||||
>
|
||||
{{ 'IAM.STATE.' + instance.state | translate }}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
|
||||
import { User as UserV1 } from '@zitadel/proto/zitadel/user_pb';
|
||||
import { User as UserV2 } from '@zitadel/proto/zitadel/user/v2/user_pb';
|
||||
import { LoginPolicy as LoginPolicyV2 } from '@zitadel/proto/zitadel/policy_pb';
|
||||
import { Organization as OrgV2 } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-info-row',
|
||||
@@ -17,7 +18,7 @@ import { LoginPolicy as LoginPolicyV2 } from '@zitadel/proto/zitadel/policy_pb';
|
||||
})
|
||||
export class InfoRowComponent {
|
||||
@Input() public user?: User.AsObject | UserV2 | UserV1;
|
||||
@Input() public org!: Org.AsObject;
|
||||
@Input() public org!: Org.AsObject | OrgV2;
|
||||
@Input() public instance!: InstanceDetail.AsObject;
|
||||
@Input() public app!: App.AsObject;
|
||||
@Input() public idp!: IDP.AsObject;
|
||||
@@ -25,13 +26,13 @@ export class InfoRowComponent {
|
||||
@Input() public grantedProject!: GrantedProject.AsObject;
|
||||
@Input() public loginPolicy?: LoginPolicy.AsObject | LoginPolicyV2;
|
||||
|
||||
public UserState: any = UserState;
|
||||
public State: any = State;
|
||||
public OrgState: any = OrgState;
|
||||
public AppState: any = AppState;
|
||||
public IDPState: any = IDPState;
|
||||
public ProjectState: any = ProjectState;
|
||||
public ProjectGrantState: any = ProjectGrantState;
|
||||
public UserState = UserState;
|
||||
public State = State;
|
||||
public OrgState = OrgState;
|
||||
public AppState = AppState;
|
||||
public IDPState = IDPState;
|
||||
public ProjectState = ProjectState;
|
||||
public ProjectGrantState = ProjectGrantState;
|
||||
|
||||
public copied: string = '';
|
||||
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
|
||||
<table
|
||||
*ngIf="displayedColumns$ | async as displayedColumns"
|
||||
mat-table
|
||||
class="table"
|
||||
aria-label="Elements"
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<div class="selection">
|
||||
<mat-checkbox
|
||||
[disabled]="!canWrite"
|
||||
[disabled]="(canWrite$ | async) === false"
|
||||
color="primary"
|
||||
(change)="$event ? masterToggle() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
@@ -31,7 +37,7 @@
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="selection">
|
||||
<mat-checkbox
|
||||
[disabled]="!canWrite"
|
||||
[disabled]="(canWrite$ | async) === false"
|
||||
color="primary"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
@@ -95,7 +101,7 @@
|
||||
color="warn"
|
||||
(click)="$event.stopPropagation(); triggerDeleteMember(member)"
|
||||
mat-icon-button
|
||||
[disabled]="canDelete === false"
|
||||
[disabled]="(canDelete$ | async) === false"
|
||||
data-e2e="remove-member-button"
|
||||
>
|
||||
<i class="las la-trash"></i>
|
||||
@@ -116,14 +122,14 @@
|
||||
[selectable]="false"
|
||||
class="cnsl-chip"
|
||||
*ngFor="let role of member.rolesList"
|
||||
[removable]="canWrite"
|
||||
[removable]="canWrite$ | async"
|
||||
(removed)="removeRole(member, role)"
|
||||
data-e2e="role"
|
||||
>
|
||||
<div class="cnsl-chip-content">
|
||||
<div class="cnsl-chip-dot" [style.background]="getColor(role)"></div>
|
||||
<span>{{ role | roletransform }}</span>
|
||||
<button *ngIf="canWrite" matChipRemove data-e2e="remove-role-button">
|
||||
<button *ngIf="canWrite$ | async" matChipRemove data-e2e="remove-role-button">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,8 +141,8 @@
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
class="highlight"
|
||||
[ngClass]="{ pointer: canWrite }"
|
||||
(click)="canWrite ? addRole(member) : null"
|
||||
[ngClass]="{ pointer: canWrite$ | async }"
|
||||
(click)="addRole(member)"
|
||||
mat-row
|
||||
*matRowDef="let member; columns: displayedColumns"
|
||||
></tr>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatTable } from '@angular/material/table';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { combineLatestWith, firstValueFrom, Observable, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||
import { InstanceMembersDataSource } from 'src/app/pages/instance/instance-members/instance-members-datasource';
|
||||
import { OrgMembersDataSource } from 'src/app/pages/orgs/org-members/org-members-datasource';
|
||||
import { ProjectGrantMembersDataSource } from 'src/app/pages/projects/owned-projects/project-grant-detail/project-grant-members-datasource';
|
||||
@@ -29,8 +29,16 @@ type MemberDatasource =
|
||||
})
|
||||
export class MembersTableComponent implements OnInit, OnDestroy {
|
||||
public INITIALPAGESIZE: number = 25;
|
||||
@Input() public canDelete: boolean | null = false;
|
||||
@Input() public canWrite: boolean | null = false;
|
||||
@Input()
|
||||
public set canWrite(value: boolean | null) {
|
||||
this.canWrite$.next(!!value);
|
||||
}
|
||||
|
||||
@Input()
|
||||
public set canDelete(value: boolean | null) {
|
||||
this.canDelete$.next(!!value);
|
||||
}
|
||||
|
||||
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent;
|
||||
@ViewChild(MatTable) public table!: MatTable<Member.AsObject>;
|
||||
@Input() public dataSource?: MemberDatasource;
|
||||
@@ -42,24 +50,38 @@ export class MembersTableComponent implements OnInit, OnDestroy {
|
||||
@Output() public changedSelection: EventEmitter<any[]> = new EventEmitter();
|
||||
@Output() public deleteMember: EventEmitter<Member.AsObject> = new EventEmitter();
|
||||
|
||||
protected readonly displayedColumns$: Observable<string[]>;
|
||||
protected readonly canWrite$ = new ReplaySubject<boolean>(1);
|
||||
protected readonly canDelete$ = new ReplaySubject<boolean>(1);
|
||||
private destroyed: Subject<void> = new Subject();
|
||||
public displayedColumns: string[] = ['select', 'userId', 'displayName', 'loginname', 'email', 'roles'];
|
||||
public UserType: any = Type;
|
||||
|
||||
constructor(private dialog: MatDialog) {
|
||||
this.selection.changed.pipe(takeUntil(this.destroyed)).subscribe((_) => {
|
||||
this.changedSelection.emit(this.selection.selected);
|
||||
});
|
||||
|
||||
this.displayedColumns$ = this.getDisplayedColumns();
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.refreshTrigger.pipe(takeUntil(this.destroyed)).subscribe(() => {
|
||||
this.changePage(this.paginator);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canDelete || this.canWrite) {
|
||||
this.displayedColumns.push('actions');
|
||||
}
|
||||
private getDisplayedColumns() {
|
||||
const defaultColumns = ['select', 'userId', 'displayName', 'loginname', 'email', 'roles'];
|
||||
return this.canWrite$.pipe(
|
||||
combineLatestWith(this.canDelete$),
|
||||
map(([canWrite, canDelete]) => {
|
||||
if (canWrite || canDelete) {
|
||||
return [...defaultColumns, 'actions'];
|
||||
}
|
||||
return defaultColumns;
|
||||
}),
|
||||
startWith(defaultColumns),
|
||||
);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
@@ -99,7 +121,11 @@ export class MembersTableComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public addRole(member: Member.AsObject) {
|
||||
public async addRole(member: Member.AsObject) {
|
||||
if (!(await firstValueFrom(this.canWrite$))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(AddMemberRolesDialogComponent, {
|
||||
data: {
|
||||
user: member.displayName,
|
||||
|
||||
@@ -4,19 +4,19 @@ import { MatTable } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { Membership } from 'src/app/proto/generated/zitadel/user_pb';
|
||||
import { AdminService } from 'src/app/services/admin.service';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { OverlayWorkflowService } from 'src/app/services/overlay/overlay-workflow.service';
|
||||
import { OrgContextChangedWorkflowOverlays } from 'src/app/services/overlay/workflows';
|
||||
import { StorageLocation, StorageService } from 'src/app/services/storage.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { getMembershipColor } from 'src/app/utils/color';
|
||||
|
||||
import { PageEvent, PaginatorComponent } from '../paginator/paginator.component';
|
||||
import { MembershipsDataSource } from './memberships-datasource';
|
||||
import { NewOrganizationService } from '../../services/new-organization.service';
|
||||
import { Organization } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-memberships-table',
|
||||
@@ -49,7 +49,7 @@ export class MembershipsTableComponent implements OnInit, OnDestroy {
|
||||
private toast: ToastService,
|
||||
private router: Router,
|
||||
private workflowService: OverlayWorkflowService,
|
||||
private storageService: StorageService,
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
) {
|
||||
this.selection.changed.pipe(takeUntil(this.destroyed)).subscribe((_) => {
|
||||
this.changedSelection.emit(this.selection.selected);
|
||||
@@ -116,53 +116,44 @@ export class MembershipsTableComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public goto(membership: Membership.AsObject): void {
|
||||
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
|
||||
public async goto(membership: Membership.AsObject) {
|
||||
const orgId = this.newOrganizationService.orgId();
|
||||
|
||||
if (membership.orgId && !membership.projectId && !membership.projectGrantId) {
|
||||
// only shown on auth user, or if currentOrg === resourceOwner
|
||||
this.authService
|
||||
.getActiveOrg(membership.orgId)
|
||||
.then((membershipOrg) => {
|
||||
this.router.navigate(['/org/members']).then(() => {
|
||||
this.startOrgContextWorkflow(membershipOrg, org);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
|
||||
});
|
||||
try {
|
||||
const membershipOrg = await this.newOrganizationService.setOrgId(membership.orgId);
|
||||
await this.router.navigate(['/org/members']);
|
||||
this.startOrgContextWorkflow(membershipOrg, orgId);
|
||||
} catch (error) {
|
||||
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
|
||||
}
|
||||
} else if (membership.projectGrantId && membership.details?.resourceOwner) {
|
||||
// only shown on auth user
|
||||
this.authService
|
||||
.getActiveOrg(membership.details?.resourceOwner)
|
||||
.then((membershipOrg) => {
|
||||
this.router.navigate(['/granted-projects', membership.projectId, 'grants', membership.projectGrantId]).then(() => {
|
||||
this.startOrgContextWorkflow(membershipOrg, org);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
|
||||
});
|
||||
try {
|
||||
const membershipOrg = await this.newOrganizationService.setOrgId(membership.details?.resourceOwner);
|
||||
await this.router.navigate(['/granted-projects', membership.projectId, 'grants', membership.projectGrantId]);
|
||||
this.startOrgContextWorkflow(membershipOrg, orgId);
|
||||
} catch (error) {
|
||||
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
|
||||
}
|
||||
} else if (membership.projectId && membership.details?.resourceOwner) {
|
||||
// only shown on auth user, or if currentOrg === resourceOwner
|
||||
this.authService
|
||||
.getActiveOrg(membership.details?.resourceOwner)
|
||||
.then((membershipOrg) => {
|
||||
this.router.navigate(['/projects', membership.projectId, 'members']).then(() => {
|
||||
this.startOrgContextWorkflow(membershipOrg, org);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
|
||||
});
|
||||
try {
|
||||
const membershipOrg = await this.newOrganizationService.setOrgId(membership.details?.resourceOwner);
|
||||
await this.router.navigate(['/projects', membership.projectId, 'members']);
|
||||
this.startOrgContextWorkflow(membershipOrg, orgId);
|
||||
} catch (error) {
|
||||
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
|
||||
}
|
||||
} else if (membership.iam) {
|
||||
// only shown on auth user
|
||||
this.router.navigate(['/instance/members']);
|
||||
await this.router.navigate(['/instance/members']);
|
||||
}
|
||||
}
|
||||
|
||||
private startOrgContextWorkflow(membershipOrg: Org.AsObject, currentOrg?: Org.AsObject | null): void {
|
||||
if (!currentOrg || (membershipOrg.id && currentOrg.id && currentOrg.id !== membershipOrg.id)) {
|
||||
private startOrgContextWorkflow(membershipOrg: Organization, currentOrgId?: string | null): void {
|
||||
if (!currentOrgId || (membershipOrg.id && currentOrgId && currentOrgId !== membershipOrg.id)) {
|
||||
setTimeout(() => {
|
||||
this.workflowService.startWorkflow(OrgContextChangedWorkflowOverlays, null);
|
||||
}, 1000);
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<ng-container *ngIf="['iam.read$', 'iam.write$'] | hasRole as iamuser$">
|
||||
<div class="nav-col" [ngClass]="{ 'is-admin': (iamuser$ | async) }">
|
||||
<ng-container
|
||||
*ngIf="breadcrumbService.breadcrumbsExtended$ && (breadcrumbService.breadcrumbsExtended$ | async) as breadc"
|
||||
>
|
||||
<ng-container *ngIf="breadcrumbService.breadcrumbsExtended$ | async as breadc">
|
||||
<ng-container
|
||||
*ngIf="
|
||||
breadc[breadc.length - 1] &&
|
||||
!breadc[breadc.length - 1].hideNav &&
|
||||
breadc[breadc.length - 1].type !== BreadcrumbType.AUTHUSER &&
|
||||
breadc[breadc.length - 1].type !== BreadcrumbType.INSTANCE
|
||||
breadc[breadc.length - 1].type !== BreadcrumbType.AUTHUSER
|
||||
"
|
||||
[ngSwitch]="breadc[0].type"
|
||||
>
|
||||
<div class="nav-row" @navrow>
|
||||
<ng-container *ngSwitchCase="BreadcrumbType.ORG">
|
||||
<ng-container *ngSwitchCase="BreadcrumbType.INSTANCE">
|
||||
<div class="nav-row-abs" @navrowproject>
|
||||
<a
|
||||
class="nav-item"
|
||||
@@ -24,6 +21,48 @@
|
||||
<span class="label">{{ 'MENU.DASHBOARD' | translate }}</span>
|
||||
</a>
|
||||
|
||||
<ng-container class="org-list" *ngIf="org">
|
||||
<ng-template cnslHasRole [hasRole]="['org.read']">
|
||||
<a
|
||||
class="nav-item"
|
||||
[routerLinkActive]="['active']"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
[routerLink]="['/orgs']"
|
||||
>
|
||||
<span class="label">{{ 'MENU.ORGS' | translate }}</span>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<ng-template cnslHasRole [hasRole]="['org.action.read']">
|
||||
<a
|
||||
class="nav-item"
|
||||
[routerLinkActive]="['active']"
|
||||
[routerLink]="['/actions']"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>
|
||||
<span class="label">{{ 'MENU.ACTIONS' | translate }}</span>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<ng-template cnslHasRole [hasRole]="['org.read']">
|
||||
<a
|
||||
class="nav-item"
|
||||
[routerLinkActive]="['active']"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
[routerLink]="['/instance']"
|
||||
*ngIf="['iam.policy.read'] | hasRole | async"
|
||||
>
|
||||
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
|
||||
</a>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<template [ngTemplateOutlet]="shortcutKeyRef"></template>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="BreadcrumbType.ORG">
|
||||
<div class="nav-row-abs" @navrowproject>
|
||||
<ng-container class="org-list" *ngIf="org">
|
||||
<ng-template cnslHasRole [hasRole]="['org.read']">
|
||||
<a
|
||||
@@ -83,7 +122,7 @@
|
||||
<a
|
||||
class="nav-item"
|
||||
[routerLinkActive]="['active']"
|
||||
[routerLink]="['/actions']"
|
||||
[routerLink]="['/actions-v1']"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
>
|
||||
<span class="label">{{ 'MENU.ACTIONS' | translate }}</span>
|
||||
@@ -96,12 +135,7 @@
|
||||
[routerLinkActive]="['active']"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
[routerLink]="['/org-settings']"
|
||||
*ngIf="
|
||||
(['policy.read'] | hasRole | async) &&
|
||||
((['iam.read$', 'iam.write$'] | hasRole | async) === false ||
|
||||
(((authService.cachedOrgs | async)?.length ?? 1) > 1 &&
|
||||
(['iam.read$', 'iam.write$'] | hasRole | async)))
|
||||
"
|
||||
*ngIf="['policy.read'] | hasRole | async"
|
||||
>
|
||||
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
|
||||
</a>
|
||||
@@ -120,7 +154,6 @@
|
||||
<ng-template #shortcutKeyRef>
|
||||
<ng-container *ngIf="(isHandset$ | async) === false">
|
||||
<ng-template cnslHasRole [hasRole]="['iam.read']">
|
||||
<span class="fill-space"></span>
|
||||
<ng-container *ngIf="!adminService.hideOnboarding && (adminService.progressAllDone | async) === false">
|
||||
<div
|
||||
cdkOverlayOrigin
|
||||
@@ -160,6 +193,7 @@
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<span class="fill-space"></span>
|
||||
<div
|
||||
(click)="openHelp()"
|
||||
class="nav-shortcut-action-key"
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
.nav-row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
height: 40px; // Increased height to accommodate the line
|
||||
overflow: visible; // Allow the indicator line to show below
|
||||
|
||||
.nav-row-abs {
|
||||
padding: 0 2rem;
|
||||
@@ -32,7 +33,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overflow-y: hidden; // Allow the indicator line to show
|
||||
align-self: stretch;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -51,19 +52,45 @@
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
padding: 0.4rem 12px;
|
||||
color: map-get($foreground, text);
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
border-radius: 50vw;
|
||||
font-weight: 500;
|
||||
margin: 0.25rem 2px;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
height: 27px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
color: map-get($foreground, text);
|
||||
opacity: 0.8;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background-color: map-get($foreground, text);
|
||||
// transition: width 0.2s ease;
|
||||
border-radius: 2px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: map-get($foreground, text);
|
||||
background-color: if($is-dark-theme, #ffffff10, #00000010);
|
||||
}
|
||||
|
||||
.c_label {
|
||||
display: flex;
|
||||
@@ -97,13 +124,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: if($is-dark-theme, #ffffff40, #00000010);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $primary-color;
|
||||
color: map-get($primary, default-contrast);
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.c_label {
|
||||
.count {
|
||||
|
||||
@@ -5,16 +5,15 @@ import { Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/cor
|
||||
import { UntypedFormControl } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
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';
|
||||
import { StorageLocation, StorageService } from 'src/app/services/storage.service';
|
||||
import { Organization } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-nav',
|
||||
@@ -83,7 +82,7 @@ export class NavComponent implements OnDestroy {
|
||||
}),
|
||||
);
|
||||
|
||||
@Input() public org!: Org.AsObject;
|
||||
@Input() public org?: Organization | null;
|
||||
public filterControl: UntypedFormControl = new UntypedFormControl('');
|
||||
public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false);
|
||||
public showAccount: boolean = false;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<button class="header-button" matRipple [matRippleUnbounded]="false">
|
||||
<ng-icon size="1.3rem" name="heroChevronUpDown"></ng-icon>
|
||||
<span class="sr-only">{{ ariaLabel }}</span>
|
||||
</button>
|
||||
@@ -0,0 +1,45 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
gap: 0rem;
|
||||
padding-right: 0;
|
||||
height: 32px;
|
||||
max-height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@mixin header-button-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
|
||||
.header-button {
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
padding: 0 0.25rem;
|
||||
cursor: pointer;
|
||||
color: map-get($foreground, text);
|
||||
|
||||
&:hover {
|
||||
background-color: if($is-dark-theme, #ffffff10, #00000010);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { heroChevronUpDown } from '@ng-icons/heroicons/outline';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-header-button',
|
||||
templateUrl: './header-button.component.html',
|
||||
styleUrls: ['./header-button.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIconComponent, MatRippleModule],
|
||||
providers: [provideIcons({ heroChevronUpDown })],
|
||||
})
|
||||
export class HeaderButtonComponent {
|
||||
@Input() ariaLabel: string = '';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- the cdk overlay doesn't like it's properties being changed that's why we used the ng if to rerender it -->
|
||||
<ng-template
|
||||
*ngIf="isOpen$ | async as isOpen"
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="trigger"
|
||||
[cdkConnectedOverlayOpen]="isOpen"
|
||||
[cdkConnectedOverlayPositionStrategy]="positionStrategy()"
|
||||
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
|
||||
[cdkConnectedOverlayHasBackdrop]="isHandset()"
|
||||
(overlayOutsideClick)="closed.emit()"
|
||||
>
|
||||
<div class="dropdown-content">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,46 @@
|
||||
@mixin header-dropdown-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
$background: map-get($theme, background);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
$border-radius: 0.5rem;
|
||||
|
||||
.dropdown-content {
|
||||
max-height: 50vh;
|
||||
min-width: 300px;
|
||||
max-width: 80vw;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid rgba(#8795a1, 0.2);
|
||||
box-shadow: 0 0 15px 0 rgb(0 0 0 / 10%);
|
||||
background: map-get($background, cards);
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 599px) {
|
||||
.dropdown-content {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-content > :first-child > :first-child {
|
||||
border-top-left-radius: $border-radius;
|
||||
}
|
||||
|
||||
.dropdown-content > :last-child > :first-child {
|
||||
border-top-right-radius: $border-radius;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 599px) {
|
||||
.dropdown-content > :first-child > :last-child {
|
||||
border-bottom-left-radius: $border-radius;
|
||||
}
|
||||
|
||||
.dropdown-content > :last-child > :last-child {
|
||||
border-bottom-right-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
EventEmitter,
|
||||
Injector,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
runInInjectionContext,
|
||||
Signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { CdkConnectedOverlay, CdkOverlayOrigin, FlexibleConnectedPositionStrategy, Overlay } from '@angular/cdk/overlay';
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { AsyncPipe, NgIf } from '@angular/common';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-header-dropdown',
|
||||
templateUrl: './header-dropdown.component.html',
|
||||
styleUrls: ['./header-dropdown.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CdkConnectedOverlay, NgIf, AsyncPipe],
|
||||
})
|
||||
export class HeaderDropdownComponent implements OnInit {
|
||||
@Input({ required: true })
|
||||
public trigger!: CdkOverlayOrigin;
|
||||
|
||||
@Input({ required: true })
|
||||
public set isOpen(isOpen: boolean) {
|
||||
this.isOpen$.next(isOpen);
|
||||
}
|
||||
|
||||
@Output()
|
||||
public closed = new EventEmitter<void>();
|
||||
|
||||
protected readonly isOpen$ = new ReplaySubject<boolean>(1);
|
||||
protected readonly isHandset: Signal<boolean>;
|
||||
protected readonly positionStrategy: Signal<FlexibleConnectedPositionStrategy>;
|
||||
protected readonly scrollStrategy = this.overlay.scrollStrategies.block();
|
||||
|
||||
constructor(
|
||||
private readonly overlay: Overlay,
|
||||
private readonly breakpointObserver: BreakpointObserver,
|
||||
private readonly injector: Injector,
|
||||
) {
|
||||
this.isHandset = this.getIsHandset();
|
||||
this.positionStrategy = this.getPositionStrategy(this.isHandset);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// because closeWhenResized accesses the input properties, we need to run it in ngOnInit
|
||||
// this method is used to close the dropdown when the screen is resized
|
||||
// to make sure the dropdown will be rendered in the correct position
|
||||
runInInjectionContext(this.injector, () => {
|
||||
const isOpen = toSignal(this.isOpen$, { requireSync: true });
|
||||
effect(
|
||||
() => {
|
||||
this.isHandset();
|
||||
if (untracked(() => isOpen())) {
|
||||
this.closed.emit();
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getIsHandset() {
|
||||
const mediaQuery = '(max-width: 599px)';
|
||||
const isHandset$ = this.breakpointObserver.observe(mediaQuery).pipe(map(({ matches }) => matches));
|
||||
return toSignal(isHandset$, { initialValue: this.breakpointObserver.isMatched(mediaQuery) });
|
||||
}
|
||||
|
||||
private getPositionStrategy(isHandset: Signal<boolean>): Signal<FlexibleConnectedPositionStrategy> {
|
||||
return computed(() =>
|
||||
isHandset()
|
||||
? this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(document.body)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
])
|
||||
: this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(this.trigger.elementRef)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: 8, // 8px gap between trigger and overlay
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="instance-selector-container">
|
||||
<div class="upper-content">
|
||||
<span class="dropdown-label">{{ 'MENU.INSTANCEOVERVIEW' | translate }}</span>
|
||||
<a (click)="setInstance(instance)" mat-button class="dropdown-button"
|
||||
>{{ instance.name }}
|
||||
<ng-icon name="heroChevronRight"></ng-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
*ngIf="customerPortalLink$ | async as customerPortalLink"
|
||||
class="portal-link external-link"
|
||||
[href]="customerPortalLink"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div class="cnsl-action-button">
|
||||
<span class="portal-span">{{ 'MENU.CUSTOMERPORTAL' | translate }}</span>
|
||||
<i class="las la-external-link-alt"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
@mixin instance-selector-theme($theme) {
|
||||
$background: map-get($theme, background);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
|
||||
.instance-selector-container {
|
||||
background: map-get($background, footer);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.upper-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
color: if($is-dark-theme, #ffffff60, #00000060);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px;
|
||||
padding-top: 5px;
|
||||
border-bottom-left-radius: inherit;
|
||||
}
|
||||
|
||||
.portal-link {
|
||||
margin-right: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.portal-span {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Output, Input } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { InstanceDetail } from '@zitadel/proto/zitadel/instance_pb';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { heroCog8ToothSolid } from '@ng-icons/heroicons/solid';
|
||||
import { heroChevronRight } from '@ng-icons/heroicons/outline';
|
||||
import { EnvironmentService } from 'src/app/services/environment.service';
|
||||
import { map } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-instance-selector',
|
||||
templateUrl: './instance-selector.component.html',
|
||||
styleUrls: ['./instance-selector.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TranslateModule, MatButtonModule, RouterLink, NgIconComponent, CommonModule],
|
||||
providers: [provideIcons({ heroCog8ToothSolid, heroChevronRight })],
|
||||
})
|
||||
export class InstanceSelectorComponent {
|
||||
protected readonly customerPortalLink$ = this.envService.env.pipe(map((env) => env.customer_portal));
|
||||
@Output() public instanceChanged = new EventEmitter<string>();
|
||||
@Output() public settingsClicked = new EventEmitter<void>();
|
||||
|
||||
@Input({ required: true })
|
||||
public instance!: InstanceDetail;
|
||||
|
||||
constructor(private envService: EnvironmentService) {}
|
||||
|
||||
protected async setInstance({ id }: InstanceDetail) {
|
||||
this.instanceChanged.emit(id);
|
||||
// skip this for now
|
||||
// await this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class="new-header-wrapper">
|
||||
<ng-container *ngIf="myInstanceQuery.data()?.instance as instance">
|
||||
<ng-container *ngTemplateOutlet="slash"></ng-container>
|
||||
<a class="new-header-breadcrumb" matRipple [matRippleUnbounded]="false" [routerLink]="['/']">
|
||||
{{ instance.name }}
|
||||
</a>
|
||||
<cnsl-header-button
|
||||
cdkOverlayOrigin
|
||||
#instanceTrigger="cdkOverlayOrigin"
|
||||
(click)="isInstanceDropdownOpen.set(!isInstanceDropdownOpen())"
|
||||
ariaLabel="{{ instance.name }}"
|
||||
>
|
||||
</cnsl-header-button>
|
||||
|
||||
<cnsl-header-dropdown
|
||||
[trigger]="instanceTrigger"
|
||||
[isOpen]="isInstanceDropdownOpen()"
|
||||
(closed)="isInstanceDropdownOpen.set(false); instanceSelectorSecondStep.set(false)"
|
||||
>
|
||||
<cnsl-instance-selector
|
||||
*ngIf="!isHandset() || !instanceSelectorSecondStep()"
|
||||
[instance]="instance"
|
||||
(instanceChanged)="instanceSelectorSecondStep.set(true)"
|
||||
(settingsClicked)="isInstanceDropdownOpen.set(false)"
|
||||
></cnsl-instance-selector>
|
||||
<cnsl-organization-selector
|
||||
*ngIf="instanceSelectorSecondStep()"
|
||||
[backButton]="isHandset() ? instance.name : ''"
|
||||
(backButtonPressed)="instanceSelectorSecondStep.set(false)"
|
||||
(orgChanged)="isInstanceDropdownOpen.set(false); instanceSelectorSecondStep.set(false)"
|
||||
></cnsl-organization-selector>
|
||||
</cnsl-header-dropdown>
|
||||
</ng-container>
|
||||
<ng-container
|
||||
*ngIf="(['org.read'] | hasRole | async) === true && (!myInstanceQuery.data()?.instance || !onInstanceLevel())"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="slash"></ng-container>
|
||||
<a
|
||||
*ngIf="activeOrganizationQuery.data() as org"
|
||||
class="new-header-breadcrumb"
|
||||
matRipple
|
||||
[matRippleUnbounded]="false"
|
||||
[routerLink]="['/org']"
|
||||
>
|
||||
{{ org.name }}
|
||||
</a>
|
||||
<cnsl-header-button
|
||||
cdkOverlayOrigin
|
||||
#orgTrigger="cdkOverlayOrigin"
|
||||
(click)="isOrgDropdownOpen.set(!isOrgDropdownOpen())"
|
||||
ariaLabel="{{ activeOrganizationQuery.data()?.name }}"
|
||||
>
|
||||
</cnsl-header-button>
|
||||
<cnsl-header-dropdown [trigger]="orgTrigger" [isOpen]="isOrgDropdownOpen()" (closed)="isOrgDropdownOpen.set(false)">
|
||||
<cnsl-organization-selector (orgChanged)="isOrgDropdownOpen.set(false)"></cnsl-organization-selector>
|
||||
</cnsl-header-dropdown>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!isHandset()">
|
||||
<ng-container *ngFor="let bread of nestedBreadcrumbs(); index as i; let last = last">
|
||||
<ng-container *ngTemplateOutlet="slash"></ng-container>
|
||||
<a class="new-header-breadcrumb" matRipple [matRippleUnbounded]="false" [routerLink]="bread.routerLink">
|
||||
{{ bread.name }}
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #slash>
|
||||
<svg
|
||||
class="slash"
|
||||
viewBox="0 0 24 24"
|
||||
width="32"
|
||||
height="32"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
shape-rendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16.88 3.549L7.12 20.451"></path>
|
||||
</svg>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,35 @@
|
||||
@mixin new-header-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
|
||||
.new-header-wrapper {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.new-header-breadcrumb {
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
padding: 0 0.5rem;
|
||||
border: none;
|
||||
color: map-get($foreground, text);
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: all ease 0.2s;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: if($is-dark-theme, #ffffff10, #00000010);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, Signal, signal } from '@angular/core';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { NewOrganizationService } from '../../services/new-organization.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { AsyncPipe, NgForOf, NgIf, NgTemplateOutlet } from '@angular/common';
|
||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
||||
import { OrganizationSelectorComponent } from './organization-selector/organization-selector.component';
|
||||
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { InputModule } from '../input/input.module';
|
||||
import { HeaderButtonComponent } from './header-button/header-button.component';
|
||||
import { HeaderDropdownComponent } from './header-dropdown/header-dropdown.component';
|
||||
import { InstanceSelectorComponent } from './instance-selector/instance-selector.component';
|
||||
import { HasRolePipeModule } from '../../pipes/has-role-pipe/has-role-pipe.module';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
import { NewAdminService } from '../../services/new-admin.service';
|
||||
import { NewAuthService } from '../../services/new-auth.service';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from '../../services/breadcrumb.service';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-new-header',
|
||||
templateUrl: './new-header.component.html',
|
||||
styleUrls: ['./new-header.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatToolbarModule,
|
||||
OrganizationSelectorComponent,
|
||||
CdkOverlayOrigin,
|
||||
MatSelectModule,
|
||||
NgIf,
|
||||
InputModule,
|
||||
HeaderButtonComponent,
|
||||
HeaderDropdownComponent,
|
||||
InstanceSelectorComponent,
|
||||
NgTemplateOutlet,
|
||||
AsyncPipe,
|
||||
HasRolePipeModule,
|
||||
RouterLink,
|
||||
NgForOf,
|
||||
MatRippleModule,
|
||||
],
|
||||
})
|
||||
export class NewHeaderComponent {
|
||||
protected readonly listMyZitadelPermissionsQuery = this.newAuthService.listMyZitadelPermissionsQuery();
|
||||
protected readonly myInstanceQuery = this.adminService.getMyInstanceQuery();
|
||||
protected readonly organizationsQuery = injectQuery(() => ({
|
||||
...this.newOrganizationService.listOrganizationsQueryOptions(),
|
||||
enabled: (this.listMyZitadelPermissionsQuery.data() ?? []).includes('org.read'),
|
||||
}));
|
||||
protected readonly isInstanceDropdownOpen = signal(false);
|
||||
protected readonly isOrgDropdownOpen = signal(false);
|
||||
protected readonly instanceSelectorSecondStep = signal(false);
|
||||
protected readonly activeOrganizationQuery = this.newOrganizationService.activeOrganizationQuery();
|
||||
protected readonly isHandset: Signal<boolean>;
|
||||
protected readonly breadcrumbs: Signal<Breadcrumb[]> = toSignal(this.breadcrumbService.breadcrumbs$, { initialValue: [] });
|
||||
protected readonly nestedBreadcrumbs: Signal<Breadcrumb[]>;
|
||||
protected readonly onInstanceLevel: Signal<boolean>;
|
||||
|
||||
constructor(
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
private readonly toastService: ToastService,
|
||||
private readonly breakpointObserver: BreakpointObserver,
|
||||
private readonly adminService: NewAdminService,
|
||||
private readonly newAuthService: NewAuthService,
|
||||
private readonly breadcrumbService: BreadcrumbService,
|
||||
) {
|
||||
this.isHandset = this.getIsHandset();
|
||||
this.nestedBreadcrumbs = this.getBreadcrumbs();
|
||||
this.onInstanceLevel = this.isOnInstanceLevel();
|
||||
|
||||
effect(() => {
|
||||
if (this.listMyZitadelPermissionsQuery.isError()) {
|
||||
this.toastService.showError(this.listMyZitadelPermissionsQuery.error());
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.organizationsQuery.isError()) {
|
||||
this.toastService.showError(this.organizationsQuery.error());
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.myInstanceQuery.isError()) {
|
||||
this.toastService.showError(this.myInstanceQuery.error());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getIsHandset() {
|
||||
const mediaQuery = '(max-width: 599px)';
|
||||
const isHandset$ = this.breakpointObserver.observe(mediaQuery).pipe(map(({ matches }) => matches));
|
||||
return toSignal(isHandset$, { initialValue: this.breakpointObserver.isMatched(mediaQuery) });
|
||||
}
|
||||
|
||||
private getBreadcrumbs() {
|
||||
return computed(() =>
|
||||
this.breadcrumbs().filter(
|
||||
(breadcrumb) => breadcrumb.type === BreadcrumbType.PROJECT || breadcrumb.type === BreadcrumbType.APP,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private isOnInstanceLevel() {
|
||||
return computed(() => {
|
||||
return this.breadcrumbs().length === 1 && this.breadcrumbs()[0].type === BreadcrumbType.INSTANCE;
|
||||
});
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<div cdkTrapFocus class="focus-trapper">
|
||||
<div class="org-header">
|
||||
<button *ngIf="backButton" (click)="backButtonPressed.emit()" mat-button class="dropdown-button">
|
||||
<span class="back-button">
|
||||
<ng-icon name="heroArrowLeftCircleSolid"></ng-icon>
|
||||
<h3>Back to {{ backButton }}</h3>
|
||||
</span>
|
||||
</button>
|
||||
<span class="dropdown-label">{{ 'MENU.ORGANIZATION' | translate }}</span>
|
||||
<form [formGroup]="form" class="form">
|
||||
<ng-icon class="search-icon" name="heroMagnifyingGlass"></ng-icon>
|
||||
<input
|
||||
class="search-input"
|
||||
autocomplete="off"
|
||||
cnslInput
|
||||
[formControl]="form.controls.name"
|
||||
[placeholder]="'PROJECT.GRANT.CREATE.SEL_ORG' | translate"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="org-list">
|
||||
<!-- Make sure active org is always at the top -->
|
||||
<a *ngIf="activeOrgIfSearchMatches() as org" class="dropdown-button" mat-button (click)="changeOrg(org.id)">
|
||||
{{ org.name }}
|
||||
<ng-icon name="heroCheck"></ng-icon>
|
||||
</a>
|
||||
<ng-container *ngIf="organizationsQuery.data() as data">
|
||||
<ng-container *ngFor="let org of data.orgs; trackBy: trackOrgResponse">
|
||||
<a *ngIf="org.id !== activeOrg.data()?.id" class="dropdown-button" mat-button (click)="changeOrg(org.id)">
|
||||
{{ org.name }}
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data?.totalResult as totalResult">
|
||||
<button
|
||||
#moreButton
|
||||
class="dropdown-button"
|
||||
mat-stroked-button
|
||||
*ngIf="totalResult > QUERY_LIMIT"
|
||||
(click)="organizationsQuery.fetchNextPage()"
|
||||
[disabled]="!organizationsQuery.hasNextPage() || organizationsQuery.isFetchingNextPage()"
|
||||
>
|
||||
<ng-container *ngIf="['iam.read'] | hasRole | async">...{{ totalResult - data.orgs.length }} </ng-container>
|
||||
{{ 'PAGINATOR.MORE' | translate }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
:host {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@mixin organization-selector-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
$background: map-get($theme, background);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
|
||||
.dropdown-label {
|
||||
color: if($is-dark-theme, #ffffff60, #00000060);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.focus-trapper {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
max-height: calc(100% - 10px);
|
||||
// needed otherwise an unexpected scrollbar appears
|
||||
height: calc(100% - 10px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.org-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.org-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.dropdown-button > span:nth-child(2) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.form {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
// default input padding
|
||||
left: 10px;
|
||||
color: if($is-dark-theme, #ffffff60, #00000060);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-bottom: 0;
|
||||
height: 32px;
|
||||
// size of icon plus half of default padding of input
|
||||
padding-left: calc(1rem + 15px);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
signal,
|
||||
Signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { injectInfiniteQuery, injectMutation, keepPreviousData } from '@tanstack/angular-query-experimental';
|
||||
import { NewOrganizationService } from 'src/app/services/new-organization.service';
|
||||
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ListOrganizationsRequestSchema, ListOrganizationsResponse } from '@zitadel/proto/zitadel/org/v2/org_service_pb';
|
||||
import { MessageInitShape } from '@bufbuild/protobuf';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TextQueryMethod } from '@zitadel/proto/zitadel/object/v2/object_pb';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Organization } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { InputModule } from '../../input/input.module';
|
||||
import { MatOptionModule } from '@angular/material/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { heroCheck, heroMagnifyingGlass } from '@ng-icons/heroicons/outline';
|
||||
import { heroArrowLeftCircleSolid } from '@ng-icons/heroicons/solid';
|
||||
import { UserService } from 'src/app/services/user.service';
|
||||
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
|
||||
import { NewAuthService } from 'src/app/services/new-auth.service';
|
||||
|
||||
type NameQuery = Extract<
|
||||
NonNullable<MessageInitShape<typeof ListOrganizationsRequestSchema>['queries']>[number]['query'],
|
||||
{ case: 'nameQuery' }
|
||||
>;
|
||||
|
||||
const QUERY_LIMIT = 20;
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-organization-selector',
|
||||
templateUrl: './organization-selector.component.html',
|
||||
styleUrls: ['./organization-selector.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
NgForOf,
|
||||
NgIf,
|
||||
ReactiveFormsModule,
|
||||
A11yModule,
|
||||
MatButtonModule,
|
||||
TranslateModule,
|
||||
MatMenuModule,
|
||||
InputModule,
|
||||
MatOptionModule,
|
||||
NgIconComponent,
|
||||
HasRolePipeModule,
|
||||
AsyncPipe,
|
||||
],
|
||||
providers: [provideIcons({ heroCheck, heroMagnifyingGlass, heroArrowLeftCircleSolid })],
|
||||
})
|
||||
export class OrganizationSelectorComponent {
|
||||
@Input()
|
||||
public backButton = '';
|
||||
|
||||
@Output()
|
||||
public backButtonPressed = new EventEmitter<void>();
|
||||
|
||||
@Output()
|
||||
public orgChanged = new EventEmitter<Organization>();
|
||||
|
||||
@ViewChild('moreButton', { static: false, read: ElementRef })
|
||||
public set moreButton(button: ElementRef<HTMLButtonElement>) {
|
||||
this.moreButtonSignal.set(button);
|
||||
}
|
||||
|
||||
private moreButtonSignal = signal<ElementRef<HTMLButtonElement> | undefined>(undefined);
|
||||
|
||||
protected setOrgId = injectMutation(() => ({
|
||||
mutationFn: (orgId: string) => this.newOrganizationService.setOrgId(orgId),
|
||||
}));
|
||||
|
||||
protected readonly form: ReturnType<typeof this.buildForm>;
|
||||
private readonly nameQuery: Signal<NameQuery | undefined>;
|
||||
protected readonly organizationsQuery: ReturnType<typeof this.getOrganizationsQuery>;
|
||||
protected readonly activeOrg = this.newOrganizationService.activeOrganizationQuery();
|
||||
protected readonly activeOrgIfSearchMatches: Signal<Organization | undefined>;
|
||||
private readonly listMyZitadelPermissionsQuery = this.newAuthService.listMyZitadelPermissionsQuery();
|
||||
|
||||
constructor(
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
private readonly formBuilder: FormBuilder,
|
||||
private readonly router: Router,
|
||||
private readonly destroyRef: DestroyRef,
|
||||
private readonly userService: UserService,
|
||||
private readonly newAuthService: NewAuthService,
|
||||
toast: ToastService,
|
||||
) {
|
||||
this.form = this.buildForm();
|
||||
this.nameQuery = this.getNameQuery(this.form);
|
||||
this.organizationsQuery = this.getOrganizationsQuery(this.nameQuery);
|
||||
this.activeOrgIfSearchMatches = this.getActiveOrgIfSearchMatches(this.nameQuery);
|
||||
|
||||
effect(() => {
|
||||
if (this.organizationsQuery.isError()) {
|
||||
toast.showError(this.organizationsQuery.error());
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (this.setOrgId.isError()) {
|
||||
toast.showError(this.setOrgId.error());
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (this.activeOrg.isError()) {
|
||||
toast.showError(this.activeOrg.error());
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const orgId = newOrganizationService.orgId();
|
||||
const orgs = this.organizationsQuery.data()?.orgs;
|
||||
|
||||
// orgs not yet loaded or user has no orgs
|
||||
if (!orgs || orgs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// use has a selected org and it was found
|
||||
if (orgId && orgs.some((org) => org.id === orgId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// user has no org selected or the selected org is not in the org list
|
||||
newOrganizationService.setOrgId(orgs[0].id).then();
|
||||
});
|
||||
|
||||
this.infiniteScrollLoading();
|
||||
}
|
||||
|
||||
private infiniteScrollLoading() {
|
||||
const intersection = new IntersectionObserver(async (entries) => {
|
||||
if (!entries[0]?.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
await this.organizationsQuery.fetchNextPage();
|
||||
});
|
||||
this.destroyRef.onDestroy(() => {
|
||||
intersection.disconnect();
|
||||
});
|
||||
|
||||
effect((onCleanup) => {
|
||||
const moreButton = this.moreButtonSignal()?.nativeElement;
|
||||
const permissions = this.listMyZitadelPermissionsQuery.data();
|
||||
|
||||
if (!moreButton || !permissions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only do infinite scrolling when user has access to all orgs
|
||||
if (!permissions.includes('iam.read')) {
|
||||
return;
|
||||
}
|
||||
|
||||
intersection.observe(moreButton);
|
||||
onCleanup(() => {
|
||||
intersection.unobserve(moreButton);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private buildForm() {
|
||||
return this.formBuilder.group({
|
||||
name: new FormControl('', { nonNullable: true }),
|
||||
});
|
||||
}
|
||||
|
||||
private getNameQuery(form: ReturnType<typeof this.buildForm>): Signal<NameQuery | undefined> {
|
||||
const name$ = form.controls.name.valueChanges.pipe(debounceTime(125));
|
||||
const nameSignal = toSignal(name$, { initialValue: form.controls.name.value });
|
||||
|
||||
return computed(() => {
|
||||
const name = nameSignal();
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
const nameQuery: NameQuery = {
|
||||
case: 'nameQuery' as const,
|
||||
value: {
|
||||
name,
|
||||
method: TextQueryMethod.CONTAINS_IGNORE_CASE,
|
||||
},
|
||||
};
|
||||
return nameQuery;
|
||||
});
|
||||
}
|
||||
|
||||
private getOrganizationsQuery(nameQuery: Signal<NameQuery | undefined>) {
|
||||
return injectInfiniteQuery(() => {
|
||||
const query = nameQuery();
|
||||
const isExpired = this.userService.isExpired();
|
||||
return {
|
||||
queryKey: [this.userService.userId(), 'organization', 'listOrganizationsInfinite', query],
|
||||
queryFn: ({ pageParam, signal }) => this.newOrganizationService.listOrganizations(pageParam, signal),
|
||||
enabled: !isExpired,
|
||||
initialPageParam: {
|
||||
query: {
|
||||
limit: QUERY_LIMIT,
|
||||
offset: BigInt(0),
|
||||
},
|
||||
queries: query ? [{ query }] : undefined,
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage, pages, pageParam) =>
|
||||
this.countLoadedOrgs(pages) < (lastPage.details?.totalResult ?? BigInt(Number.MAX_SAFE_INTEGER))
|
||||
? {
|
||||
...pageParam,
|
||||
query: {
|
||||
...pageParam.query,
|
||||
offset: pageParam.query.offset + BigInt(lastPage.result.length),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
select: (data) => ({
|
||||
orgs: data.pages.flatMap((page) => page.result),
|
||||
totalResult: Number(data.pages[data.pages.length - 1]?.details?.totalResult ?? 0),
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private countLoadedOrgs(pages?: ListOrganizationsResponse[]) {
|
||||
if (!pages) {
|
||||
return BigInt(0);
|
||||
}
|
||||
return pages.reduce((acc, page) => acc + BigInt(page.result.length), BigInt(0));
|
||||
}
|
||||
|
||||
private getActiveOrgIfSearchMatches(nameQuery: Signal<NameQuery | undefined>) {
|
||||
return computed(() => {
|
||||
const activeOrg = this.activeOrg.data() ?? undefined;
|
||||
const query = nameQuery();
|
||||
if (!activeOrg || !query?.value?.name) {
|
||||
return activeOrg;
|
||||
}
|
||||
return activeOrg.name.toLowerCase().includes(query.value.name.toLowerCase()) ? activeOrg : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
protected async changeOrg(orgId: string) {
|
||||
const org = await this.setOrgId.mutateAsync(orgId);
|
||||
this.orgChanged.emit(org);
|
||||
await this.router.navigate(['/org']);
|
||||
}
|
||||
|
||||
protected trackOrgResponse(_: number, { id }: Organization): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
protected readonly QUERY_LIMIT = QUERY_LIMIT;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { UntypedFormControl } from '@angular/forms';
|
||||
import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, scan, take, tap } from 'rxjs';
|
||||
import { BehaviorSubject, catchError, debounceTime, from, map, Observable, of, pipe, take, tap } from 'rxjs';
|
||||
import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb';
|
||||
import { Org, OrgFieldName, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { AuthenticationService } from 'src/app/services/authentication.service';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { Organization } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
|
||||
const ORG_QUERY_LIMIT = 100;
|
||||
|
||||
@@ -44,7 +45,7 @@ export class OrgContextComponent implements OnInit {
|
||||
);
|
||||
|
||||
public filterControl: UntypedFormControl = new UntypedFormControl('');
|
||||
@Input() public org!: Org.AsObject;
|
||||
@Input({ required: true }) public org!: Organization;
|
||||
@ViewChild('input', { static: false }) input!: ElementRef;
|
||||
@Output() public closedCard: EventEmitter<void> = new EventEmitter();
|
||||
@Output() public setOrg: EventEmitter<Org.AsObject> = new EventEmitter();
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<cnsl-refresh-table [hideRefresh]="true" (refreshed)="refresh()" [loading]="loading$ | async">
|
||||
<cnsl-filter-org actions (filterChanged)="applySearchQuery($any($event))" (filterOpen)="filterOpen = $event">
|
||||
</cnsl-filter-org>
|
||||
<cnsl-refresh-table [hideRefresh]="true" [loading]="listOrganizationsQuery.isPending()">
|
||||
<cnsl-filter-org actions (filterChanged)="applySearchQuery($any($event), paginator)"> </cnsl-filter-org>
|
||||
|
||||
<ng-template actions cnslHasRole [hasRole]="['org.create', 'iam.write']">
|
||||
<a [routerLink]="['/orgs', 'create']" color="primary" mat-raised-button>
|
||||
<div class="cnsl-action-button">
|
||||
<mat-icon class="icon">add</mat-icon>
|
||||
<span>{{ 'ACTIONS.NEW' | translate }}</span>
|
||||
<cnsl-action-keys (actionTriggered)="gotoRouterLink(['/orgs', 'create'])"> </cnsl-action-keys>
|
||||
<cnsl-action-keys (actionTriggered)="router.navigate(['/orgs', 'create'])"> </cnsl-action-keys>
|
||||
</div>
|
||||
</a>
|
||||
</ng-template>
|
||||
@@ -22,12 +21,12 @@
|
||||
>
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'ORG.PAGES.ID' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">{{ org.id }}</td>
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" (click)="setAndNavigateToOrg(org)">{{ org.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="primaryDomain">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'ORG.PAGES.PRIMARYDOMAIN' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" (click)="setAndNavigateToOrg(org)">
|
||||
<div class="primary-domain-wrapper">
|
||||
<span>{{ org.primaryDomain }}</span>
|
||||
<button
|
||||
@@ -50,7 +49,7 @@
|
||||
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||
{{ 'ORG.PAGES.NAME' | translate }}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" (click)="setAndNavigateToOrg(org)">
|
||||
<span>{{ org.name }}</span
|
||||
><span *ngIf="defaultOrgId === org.id" class="state orgdefaultlabel">{{
|
||||
'ORG.PAGES.DEFAULTLABEL' | translate
|
||||
@@ -60,12 +59,12 @@
|
||||
|
||||
<ng-container matColumnDef="state">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'ORG.PAGES.STATE' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" (click)="setAndNavigateToOrg(org)">
|
||||
<span
|
||||
class="state"
|
||||
[ngClass]="{
|
||||
active: org.state === OrgState.ORG_STATE_ACTIVE,
|
||||
inactive: org.state === OrgState.ORG_STATE_INACTIVE,
|
||||
active: org.state === OrganizationState.ACTIVE,
|
||||
inactive: org.state === OrganizationState.INACTIVE,
|
||||
}"
|
||||
*ngIf="org.state"
|
||||
>{{ 'ORG.STATE.' + org.state | translate }}</span
|
||||
@@ -77,7 +76,7 @@
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
{{ 'ORG.PAGES.CREATIONDATE' | translate }}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" (click)="setAndNavigateToOrg(org)">
|
||||
{{ org.details?.creationDate | timestampToDate | localizedDate: 'fromNow' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
@@ -86,14 +85,14 @@
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
{{ 'ORG.PAGES.DATECHANGED' | translate }}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" (click)="setAndNavigateToOrg(org)">
|
||||
{{ org.details?.changeDate | timestampToDate | localizedDate: 'fromNow' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th mat-header-cell *matHeaderCellDef class="user-tr-actions"></th>
|
||||
<td mat-cell *matCellDef="let org" class="user-tr-actions">
|
||||
<td mat-cell *cnslCellDef="let org; dataSource: dataSource" class="user-tr-actions">
|
||||
<cnsl-table-actions [hasActions]="true">
|
||||
<button menuActions mat-menu-item (click)="setDefaultOrg(org)" data-e2e="set-default-button">
|
||||
{{ 'ORG.PAGES.SETASDEFAULT' | translate }}
|
||||
@@ -108,10 +107,10 @@
|
||||
<cnsl-paginator
|
||||
#paginator
|
||||
class="paginator"
|
||||
[timestamp]="timestamp"
|
||||
[length]="totalResult || 0"
|
||||
[pageSize]="initialLimit"
|
||||
[timestamp]="listOrganizationsQuery.data()?.details?.timestamp"
|
||||
[length]="Number(listOrganizationsQuery.data()?.details?.totalResult ?? 0)"
|
||||
[pageSize]="listQuery().limit"
|
||||
[pageSizeOptions]="[10, 20, 50, 100]"
|
||||
(page)="changePage()"
|
||||
(page)="pageChanged($event)"
|
||||
></cnsl-paginator>
|
||||
</cnsl-refresh-table>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||
import { Component, Input, ViewChild } from '@angular/core';
|
||||
import { MatSort, Sort } from '@angular/material/sort';
|
||||
import { Component, computed, effect, signal } from '@angular/core';
|
||||
import { Sort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
|
||||
import { BehaviorSubject, catchError, finalize, from, map, Observable, of, Subject, switchMap, takeUntil } from 'rxjs';
|
||||
import { Org, OrgFieldName, OrgQuery, OrgState } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { OrgQuery } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
import { AdminService } from 'src/app/services/admin.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { NewOrganizationService } from '../../services/new-organization.service';
|
||||
import { injectQuery, keepPreviousData } from '@tanstack/angular-query-experimental';
|
||||
import { MessageInitShape } from '@bufbuild/protobuf';
|
||||
import { ListOrganizationsRequestSchema } from '@zitadel/proto/zitadel/org/v2/org_service_pb';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { OrganizationFieldName } from '@zitadel/proto/zitadel/org/v2/query_pb';
|
||||
import { Organization, OrganizationState } from '@zitadel/proto/zitadel/org/v2/org_pb';
|
||||
import { PaginatorComponent } from '../paginator/paginator.component';
|
||||
|
||||
enum OrgListSearchKey {
|
||||
NAME = 'NAME',
|
||||
}
|
||||
|
||||
type Request = { limit: number; offset: number; queries: OrgQuery[] };
|
||||
type ListQuery = NonNullable<MessageInitShape<typeof ListOrganizationsRequestSchema>['query']>;
|
||||
type SearchQuery = NonNullable<MessageInitShape<typeof ListOrganizationsRequestSchema>['queries']>[number];
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-org-table',
|
||||
@@ -26,100 +28,80 @@ type Request = { limit: number; offset: number; queries: OrgQuery[] };
|
||||
styleUrls: ['./org-table.component.scss'],
|
||||
})
|
||||
export class OrgTableComponent {
|
||||
public orgSearchKey: OrgListSearchKey | undefined = undefined;
|
||||
|
||||
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent;
|
||||
@ViewChild('input') public filter!: Input;
|
||||
|
||||
public dataSource: MatTableDataSource<Org.AsObject> = new MatTableDataSource<Org.AsObject>([]);
|
||||
public displayedColumns: string[] = ['name', 'state', 'primaryDomain', 'creationDate', 'changeDate', 'actions'];
|
||||
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
|
||||
public activeOrg!: Org.AsObject;
|
||||
public initialLimit: number = 20;
|
||||
public timestamp: Timestamp.AsObject | undefined = undefined;
|
||||
public totalResult: number = 0;
|
||||
public filterOpen: boolean = false;
|
||||
public OrgState: any = OrgState;
|
||||
public copied: string = '';
|
||||
@ViewChild(MatSort) public sort!: MatSort;
|
||||
|
||||
private searchQueries: OrgQuery[] = [];
|
||||
private destroy$: Subject<void> = new Subject();
|
||||
private requestOrgs$: BehaviorSubject<Request> = new BehaviorSubject<Request>({
|
||||
limit: this.initialLimit,
|
||||
offset: 0,
|
||||
queries: [],
|
||||
});
|
||||
public defaultOrgId: string = '';
|
||||
private requestOrgsObservable$ = this.requestOrgs$.pipe(takeUntil(this.destroy$));
|
||||
|
||||
protected readonly listQuery = signal<ListQuery & { limit: number }>({ limit: 20, offset: BigInt(0) });
|
||||
private readonly searchQueries = signal<SearchQuery[]>([]);
|
||||
private readonly sortingColumn = signal<OrganizationFieldName | undefined>(undefined);
|
||||
|
||||
private readonly req = computed<MessageInitShape<typeof ListOrganizationsRequestSchema>>(() => ({
|
||||
query: this.listQuery(),
|
||||
queries: this.searchQueries().length ? this.searchQueries() : undefined,
|
||||
sortingColumn: this.sortingColumn(),
|
||||
}));
|
||||
|
||||
protected listOrganizationsQuery = injectQuery(() => ({
|
||||
...this.newOrganizationService.listOrganizationsQueryOptions(this.req()),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
protected readonly dataSource = this.getDataSource();
|
||||
|
||||
constructor(
|
||||
private authService: GrpcAuthService,
|
||||
private mgmtService: ManagementService,
|
||||
private adminService: AdminService,
|
||||
private router: Router,
|
||||
private toast: ToastService,
|
||||
private _liveAnnouncer: LiveAnnouncer,
|
||||
private translate: TranslateService,
|
||||
private readonly authService: GrpcAuthService,
|
||||
private readonly mgmtService: ManagementService,
|
||||
private readonly adminService: AdminService,
|
||||
protected readonly router: Router,
|
||||
private readonly toast: ToastService,
|
||||
private readonly liveAnnouncer: LiveAnnouncer,
|
||||
private readonly translate: TranslateService,
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
) {
|
||||
this.requestOrgs$.next({ limit: this.initialLimit, offset: 0, queries: this.searchQueries });
|
||||
this.authService.getActiveOrg().then((org) => (this.activeOrg = org));
|
||||
|
||||
this.requestOrgsObservable$.pipe(switchMap((req) => this.loadOrgs(req))).subscribe((orgs) => {
|
||||
this.dataSource = new MatTableDataSource<Org.AsObject>(orgs);
|
||||
});
|
||||
|
||||
this.mgmtService.getIAM().then((iam) => {
|
||||
this.defaultOrgId = iam.defaultOrgId;
|
||||
});
|
||||
}
|
||||
|
||||
public loadOrgs(request: Request): Observable<Org.AsObject[]> {
|
||||
this.loadingSubject.next(true);
|
||||
|
||||
let sortingField: OrgFieldName | undefined = undefined;
|
||||
if (this.sort?.active && this.sort?.direction)
|
||||
switch (this.sort.active) {
|
||||
case 'name':
|
||||
sortingField = OrgFieldName.ORG_FIELD_NAME_NAME;
|
||||
break;
|
||||
effect(() => {
|
||||
if (this.listOrganizationsQuery.isError()) {
|
||||
this.toast.showError(this.listOrganizationsQuery.error());
|
||||
}
|
||||
|
||||
return from(
|
||||
this.adminService.listOrgs(request.limit, request.offset, request.queries, sortingField, this.sort?.direction),
|
||||
).pipe(
|
||||
map((resp) => {
|
||||
this.timestamp = resp.details?.viewTimestamp;
|
||||
this.totalResult = resp.details?.totalResult ?? 0;
|
||||
return resp.resultList;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.toast.showError(error);
|
||||
return of([]);
|
||||
}),
|
||||
finalize(() => this.loadingSubject.next(false)),
|
||||
);
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.requestOrgs$.next({
|
||||
limit: this.paginator.pageSize,
|
||||
offset: this.paginator.pageSize * this.paginator.pageIndex,
|
||||
queries: this.searchQueries,
|
||||
});
|
||||
}
|
||||
|
||||
public sortChange(sortState: Sort) {
|
||||
if (sortState.direction && sortState.active) {
|
||||
this._liveAnnouncer.announce(`Sorted ${sortState.direction}ending`);
|
||||
this.refresh();
|
||||
private getDataSource() {
|
||||
const dataSource = new MatTableDataSource<Organization>();
|
||||
effect(() => {
|
||||
const organizations = this.listOrganizationsQuery.data()?.result ?? [];
|
||||
if (dataSource.data != organizations) {
|
||||
dataSource.data = organizations;
|
||||
}
|
||||
});
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
public async sortChange(sortState: Sort) {
|
||||
this.sortingColumn.set(sortState.active === 'name' ? OrganizationFieldName.NAME : undefined);
|
||||
|
||||
const listQuery = { ...this.listQuery() };
|
||||
if (sortState.direction === 'asc') {
|
||||
this.listQuery.set({ ...listQuery, asc: true });
|
||||
} else {
|
||||
this._liveAnnouncer.announce('Sorting cleared');
|
||||
delete listQuery.asc;
|
||||
this.listQuery.set(listQuery);
|
||||
}
|
||||
|
||||
if (sortState.direction && sortState.active) {
|
||||
await this.liveAnnouncer.announce(`Sorted ${sortState.direction}ending`);
|
||||
} else {
|
||||
await this.liveAnnouncer.announce('Sorting cleared');
|
||||
}
|
||||
}
|
||||
|
||||
public setDefaultOrg(org: Org.AsObject) {
|
||||
public setDefaultOrg(org: Organization) {
|
||||
this.adminService
|
||||
.setDefaultOrg(org.id)
|
||||
.then(() => {
|
||||
@@ -131,34 +113,56 @@ export class OrgTableComponent {
|
||||
});
|
||||
}
|
||||
|
||||
public applySearchQuery(searchQueries: OrgQuery[]): void {
|
||||
this.searchQueries = searchQueries;
|
||||
this.requestOrgs$.next({
|
||||
limit: this.paginator ? this.paginator.pageSize : this.initialLimit,
|
||||
offset: this.paginator ? this.paginator.pageSize * this.paginator.pageIndex : 0,
|
||||
queries: this.searchQueries,
|
||||
});
|
||||
}
|
||||
|
||||
public setFilter(key: OrgListSearchKey): void {
|
||||
setTimeout(() => {
|
||||
if (this.filter) {
|
||||
(this.filter as any).nativeElement.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
if (this.orgSearchKey !== key) {
|
||||
this.orgSearchKey = key;
|
||||
} else {
|
||||
this.orgSearchKey = undefined;
|
||||
this.refresh();
|
||||
public applySearchQuery(searchQueries: OrgQuery[], paginator: PaginatorComponent): void {
|
||||
if (this.searchQueries().length === 0 && searchQueries.length === 0) {
|
||||
return;
|
||||
}
|
||||
paginator.pageIndex = 0;
|
||||
this.searchQueries.set(searchQueries.map((q) => ({ query: this.oldQueryToNewQuery(q.toObject()) })));
|
||||
}
|
||||
|
||||
public setAndNavigateToOrg(org: Org.AsObject): void {
|
||||
if (org.state !== OrgState.ORG_STATE_REMOVED) {
|
||||
this.authService.setActiveOrg(org);
|
||||
this.router.navigate(['/org']);
|
||||
private oldQueryToNewQuery(query: OrgQuery.AsObject): SearchQuery['query'] {
|
||||
if (query.idQuery) {
|
||||
return {
|
||||
case: 'idQuery' as const,
|
||||
value: {
|
||||
id: query.idQuery.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.stateQuery) {
|
||||
return {
|
||||
case: 'stateQuery' as const,
|
||||
value: {
|
||||
state: query.stateQuery.state as unknown as any,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.domainQuery) {
|
||||
return {
|
||||
case: 'domainQuery' as const,
|
||||
value: {
|
||||
domain: query.domainQuery.domain,
|
||||
method: query.domainQuery.method as unknown as any,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.nameQuery) {
|
||||
return {
|
||||
case: 'nameQuery' as const,
|
||||
value: {
|
||||
name: query.nameQuery.name,
|
||||
method: query.nameQuery.method as unknown as any,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Invalid query');
|
||||
}
|
||||
|
||||
public async setAndNavigateToOrg(org: Organization): Promise<void> {
|
||||
if (org.state !== OrganizationState.REMOVED) {
|
||||
await this.newOrganizationService.setOrgId(org.id);
|
||||
await this.router.navigate(['/org']);
|
||||
} else {
|
||||
this.translate.get('ORG.TOAST.ORG_WAS_DELETED').subscribe((data) => {
|
||||
this.toast.showInfo(data);
|
||||
@@ -166,11 +170,13 @@ export class OrgTableComponent {
|
||||
}
|
||||
}
|
||||
|
||||
public changePage(): void {
|
||||
this.refresh();
|
||||
protected pageChanged(event: PageEvent) {
|
||||
this.listQuery.set({
|
||||
limit: event.pageSize,
|
||||
offset: BigInt(event.pageSize) * BigInt(event.pageIndex),
|
||||
});
|
||||
}
|
||||
|
||||
public gotoRouterLink(rL: any) {
|
||||
this.router.navigate(rL);
|
||||
}
|
||||
protected readonly Number = Number;
|
||||
protected readonly OrganizationState = OrganizationState;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PaginatorModule } from '../paginator/paginator.module';
|
||||
import { RefreshTableModule } from '../refresh-table/refresh-table.module';
|
||||
import { TableActionsModule } from '../table-actions/table-actions.module';
|
||||
import { OrgTableComponent } from './org-table.component';
|
||||
import { TypeSafeCellDefModule } from '../../directives/type-safe-cell-def/type-safe-cell-def.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [OrgTableComponent],
|
||||
@@ -45,6 +46,7 @@ import { OrgTableComponent } from './org-table.component';
|
||||
MatRadioModule,
|
||||
InputModule,
|
||||
FormsModule,
|
||||
TypeSafeCellDefModule,
|
||||
],
|
||||
exports: [OrgTableComponent],
|
||||
})
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
UpdateDomainPolicyRequest,
|
||||
} from 'src/app/proto/generated/zitadel/admin_pb';
|
||||
import { GetOrgIAMPolicyResponse } from 'src/app/proto/generated/zitadel/management_pb';
|
||||
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { DomainPolicy, OrgIAMPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
|
||||
import { AdminService } from 'src/app/services/admin.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { StorageLocation, StorageService } from 'src/app/services/storage.service';
|
||||
import { StorageService } from 'src/app/services/storage.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component';
|
||||
import { PolicyComponentServiceType } from '../policy-component-types.enum';
|
||||
import { NewOrganizationService } from '../../../services/new-organization.service';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-domain-policy',
|
||||
@@ -30,7 +30,7 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
|
||||
|
||||
public loading: boolean = false;
|
||||
private sub: Subscription = new Subscription();
|
||||
private org!: Org.AsObject;
|
||||
private orgId = this.newOrganizationService.getOrgId();
|
||||
|
||||
public PolicyComponentServiceType: any = PolicyComponentServiceType;
|
||||
|
||||
@@ -39,7 +39,7 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
|
||||
private toast: ToastService,
|
||||
private injector: Injector,
|
||||
private adminService: AdminService,
|
||||
private storageService: StorageService,
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -69,12 +69,6 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async getData(): Promise<GetCustomOrgIAMPolicyResponse.AsObject | GetOrgIAMPolicyResponse.AsObject | any> {
|
||||
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
|
||||
|
||||
if (org?.id) {
|
||||
this.org = org;
|
||||
}
|
||||
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
return this.managementService.getDomainPolicy();
|
||||
@@ -90,7 +84,7 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
if ((this.domainData as OrgIAMPolicy.AsObject).isDefault) {
|
||||
const req = new AddCustomDomainPolicyRequest();
|
||||
req.setOrgId(this.org.id);
|
||||
req.setOrgId(this.orgId());
|
||||
req.setUserLoginMustBeDomain(this.domainData.userLoginMustBeDomain);
|
||||
req.setValidateOrgDomains(this.domainData.validateOrgDomains);
|
||||
req.setSmtpSenderAddressMatchesInstanceDomain(this.domainData.smtpSenderAddressMatchesInstanceDomain);
|
||||
@@ -106,7 +100,7 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
|
||||
break;
|
||||
} else {
|
||||
const req = new AddCustomDomainPolicyRequest();
|
||||
req.setOrgId(this.org.id);
|
||||
req.setOrgId(this.orgId());
|
||||
req.setUserLoginMustBeDomain(this.domainData.userLoginMustBeDomain);
|
||||
req.setValidateOrgDomains(this.domainData.validateOrgDomains);
|
||||
req.setSmtpSenderAddressMatchesInstanceDomain(this.domainData.smtpSenderAddressMatchesInstanceDomain);
|
||||
@@ -154,7 +148,7 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
|
||||
dialogRef.afterClosed().subscribe((resp) => {
|
||||
if (resp) {
|
||||
this.adminService
|
||||
.resetCustomDomainPolicyToDefault(this.org.id)
|
||||
.resetCustomDomainPolicyToDefault(this.orgId())
|
||||
.then(() => {
|
||||
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
|
||||
setTimeout(() => {
|
||||
|
||||
+7
-7
@@ -85,7 +85,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
public fontName = '';
|
||||
|
||||
public refreshPreview: EventEmitter<void> = new EventEmitter();
|
||||
public org!: Org.AsObject;
|
||||
public org!: string;
|
||||
public InfoSectionType: any = InfoSectionType;
|
||||
private iconChanged: boolean = false;
|
||||
|
||||
@@ -152,7 +152,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
if (theme === Theme.DARK) {
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
return this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTDARKLOGO, formData, this.org.id));
|
||||
return this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTDARKLOGO, formData, this.org));
|
||||
case PolicyComponentServiceType.ADMIN:
|
||||
return this.handleUploadPromise(this.assetService.upload(AssetEndpoint.IAMDARKLOGO, formData));
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
if (theme === Theme.LIGHT) {
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
return this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTLOGO, formData, this.org.id));
|
||||
return this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTLOGO, formData, this.org));
|
||||
case PolicyComponentServiceType.ADMIN:
|
||||
return this.handleUploadPromise(this.assetService.upload(AssetEndpoint.IAMLOGO, formData));
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
this.service = this.injector.get(ManagementService as Type<ManagementService>);
|
||||
|
||||
const org: Org.AsObject | null = this.storageService.getItem(StorageKey.organization, StorageLocation.session);
|
||||
const org = this.storageService.getItem(StorageKey.organizationId, StorageLocation.session);
|
||||
|
||||
if (org) {
|
||||
this.org = org;
|
||||
@@ -209,7 +209,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
return this.handleFontUploadPromise(this.assetService.upload(AssetEndpoint.MGMTFONT, formData, this.org.id));
|
||||
return this.handleFontUploadPromise(this.assetService.upload(AssetEndpoint.MGMTFONT, formData, this.org));
|
||||
case PolicyComponentServiceType.ADMIN:
|
||||
return this.handleFontUploadPromise(this.assetService.upload(AssetEndpoint.IAMFONT, formData));
|
||||
}
|
||||
@@ -334,7 +334,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
if (theme === Theme.DARK) {
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTDARKICON, formData, this.org.id));
|
||||
this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTDARKICON, formData, this.org));
|
||||
break;
|
||||
case PolicyComponentServiceType.ADMIN:
|
||||
this.handleUploadPromise(this.assetService.upload(AssetEndpoint.IAMDARKICON, formData));
|
||||
@@ -344,7 +344,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
if (theme === Theme.LIGHT) {
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTICON, formData, this.org.id));
|
||||
this.handleUploadPromise(this.assetService.upload(AssetEndpoint.MGMTICON, formData, this.org));
|
||||
break;
|
||||
case PolicyComponentServiceType.ADMIN:
|
||||
this.handleUploadPromise(this.assetService.upload(AssetEndpoint.IAMICON, formData));
|
||||
|
||||
@@ -7,16 +7,16 @@ import {
|
||||
SAMLBinding,
|
||||
SAMLNameIDFormat,
|
||||
SAMLSignatureAlgorithm,
|
||||
} from '../../../proto/generated/zitadel/idp_pb';
|
||||
import { AbstractControl, FormControl, FormGroup } from '@angular/forms';
|
||||
} from 'src/app/proto/generated/zitadel/idp_pb';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { PolicyComponentServiceType } from '../../policies/policy-component-types.enum';
|
||||
import { ManagementService } from '../../../services/mgmt.service';
|
||||
import { AdminService } from '../../../services/admin.service';
|
||||
import { ToastService } from '../../../services/toast.service';
|
||||
import { GrpcAuthService } from '../../../services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { AdminService } from 'src/app/services/admin.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { BehaviorSubject, shareReplay, switchMap, take } from 'rxjs';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from '../../../services/breadcrumb.service';
|
||||
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
|
||||
import { atLeastOneIsFilled, requiredValidator } from '../../form-field/validators/validators';
|
||||
import {
|
||||
AddSAMLProviderRequest as AdminAddSAMLProviderRequest,
|
||||
@@ -28,10 +28,10 @@ import {
|
||||
GetProviderByIDRequest as MgmtGetProviderByIDRequest,
|
||||
UpdateSAMLProviderRequest as MgmtUpdateSAMLProviderRequest,
|
||||
} from 'src/app/proto/generated/zitadel/management_pb';
|
||||
import { Environment, EnvironmentService } from '../../../services/environment.service';
|
||||
import { Environment, EnvironmentService } from 'src/app/services/environment.service';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { ProviderNextService } from '../provider-next/provider-next.service';
|
||||
import { getEnumKeys, getEnumKeyFromValue, convertEnumValuesToKeys } from '../../../utils/enum.utils';
|
||||
import { getEnumKeys, getEnumKeyFromValue } from 'src/app/utils/enum.utils';
|
||||
|
||||
interface SAMLProviderForm {
|
||||
name: FormControl<string>;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
export interface SettingLinks {
|
||||
i18nTitle: string;
|
||||
i18nDesc: string;
|
||||
iamRouterLink: any;
|
||||
orgRouterLink?: any;
|
||||
queryParams: any;
|
||||
iamWithRole?: string[];
|
||||
orgWithRole?: string[];
|
||||
icon?: string;
|
||||
svgIcon?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const LOGIN_GROUP: SettingLinks = {
|
||||
i18nTitle: 'SETTINGS.GROUPS.LOGIN',
|
||||
i18nDesc: 'POLICY.LOGIN_POLICY.DESCRIPTION',
|
||||
iamRouterLink: ['/settings'],
|
||||
orgRouterLink: ['/org-settings'],
|
||||
queryParams: { id: 'login' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
orgWithRole: ['policy.read'],
|
||||
icon: 'las la-sign-in-alt',
|
||||
color: 'green',
|
||||
};
|
||||
|
||||
export const APPEARANCE_GROUP: SettingLinks = {
|
||||
i18nTitle: 'SETTINGS.GROUPS.APPEARANCE',
|
||||
i18nDesc: 'POLICY.PRIVATELABELING.DESCRIPTION',
|
||||
iamRouterLink: ['/settings'],
|
||||
orgRouterLink: ['/org-settings'],
|
||||
queryParams: { id: 'branding' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
orgWithRole: ['policy.read'],
|
||||
icon: 'las la-swatchbook',
|
||||
color: 'blue',
|
||||
};
|
||||
|
||||
export const PRIVACY_POLICY: SettingLinks = {
|
||||
i18nTitle: 'DESCRIPTIONS.SETTINGS.PRIVACY_POLICY.TITLE',
|
||||
i18nDesc: 'POLICY.PRIVACY_POLICY.DESCRIPTION',
|
||||
iamRouterLink: ['/settings'],
|
||||
orgRouterLink: ['/org-settings'],
|
||||
queryParams: { id: 'privacypolicy' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
orgWithRole: ['policy.read'],
|
||||
icon: 'las la-file-contract',
|
||||
color: 'black',
|
||||
};
|
||||
|
||||
export const NOTIFICATION_GROUP: SettingLinks = {
|
||||
i18nTitle: 'SETTINGS.GROUPS.NOTIFICATIONS',
|
||||
i18nDesc: 'SETTINGS.LIST.NOTIFICATIONS_DESC',
|
||||
iamRouterLink: ['/settings'],
|
||||
queryParams: { id: 'smtpprovider' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
icon: 'las la-bell',
|
||||
color: 'red',
|
||||
};
|
||||
|
||||
export const SETTINGLINKS: SettingLinks[] = [LOGIN_GROUP, APPEARANCE_GROUP, PRIVACY_POLICY, NOTIFICATION_GROUP];
|
||||
@@ -1,55 +0,0 @@
|
||||
<div class="org-title-row">
|
||||
<h2>{{ 'DESCRIPTIONS.ORG.TITLE' | translate }}</h2>
|
||||
<a mat-icon-button href="https://zitadel.com/docs/concepts/structure/organizations" rel="noreferrer" target="_blank">
|
||||
<mat-icon class="icon">info_outline</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
<p class="top-desc cnsl-secondary-text">
|
||||
{{ 'DESCRIPTIONS.ORG.DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<div class="row-lyt" [ngClass]="{ more: type === PolicyComponentServiceType.ADMIN }">
|
||||
<ng-container *ngFor="let setting of SETTINGS">
|
||||
<ng-template
|
||||
cnslHasRole
|
||||
[hasRole]="
|
||||
type === PolicyComponentServiceType.ADMIN
|
||||
? setting.iamWithRole
|
||||
: type === PolicyComponentServiceType.MGMT
|
||||
? setting.orgWithRole
|
||||
: []
|
||||
"
|
||||
>
|
||||
<div class="p-item card" @policy data-e2e="policy-card">
|
||||
<div class="avatar {{ setting.color }}">
|
||||
<mat-icon *ngIf="setting.svgIcon" class="mat-icon" [svgIcon]="setting.svgIcon"></mat-icon>
|
||||
<i *ngIf="setting.icon" class="icon {{ setting.icon }}"></i>
|
||||
</div>
|
||||
<div class="title">
|
||||
<span>{{ setting.i18nTitle | translate }}</span>
|
||||
</div>
|
||||
|
||||
<p class="desc cnsl-secondary-text">
|
||||
{{ setting.i18nDesc ? (setting.i18nDesc | translate) : '' }}
|
||||
</p>
|
||||
|
||||
<span class="fill-space"></span>
|
||||
|
||||
<div class="btn-wrapper">
|
||||
<a
|
||||
[routerLink]="
|
||||
type === PolicyComponentServiceType.ADMIN
|
||||
? setting.iamRouterLink
|
||||
: type === PolicyComponentServiceType.MGMT
|
||||
? setting.orgRouterLink
|
||||
: null
|
||||
"
|
||||
[queryParams]="setting.queryParams"
|
||||
mat-stroked-button
|
||||
>
|
||||
{{ 'POLICY.BTN_EDIT' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
@@ -1,156 +0,0 @@
|
||||
.org-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
height: 1.2rem;
|
||||
width: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-desc {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row-lyt {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
margin-top: 1.5rem;
|
||||
row-gap: 1rem;
|
||||
column-gap: 1rem;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
|
||||
@media only screen and (max-width: 1300px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&.more {
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
|
||||
@media only screen and (max-width: 1300px) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 850px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.p-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 250px;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media only screen and (max-width: 450px) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(40deg, rgb(129, 85, 185) 30%, #7b8ada);
|
||||
|
||||
&.purple {
|
||||
background: linear-gradient(40deg, #7c3aed 30%, #6d28d9);
|
||||
}
|
||||
|
||||
&.red {
|
||||
background: linear-gradient(40deg, #dc2626 30%, #db2777);
|
||||
}
|
||||
|
||||
&.green {
|
||||
background: linear-gradient(40deg, #059669 30%, #047857);
|
||||
}
|
||||
|
||||
&.blue {
|
||||
background: linear-gradient(40deg, #3b82f6 30%, #4f46e5);
|
||||
}
|
||||
|
||||
&.yellow {
|
||||
background: linear-gradient(40deg, #f59e0b 30%, #b45309);
|
||||
}
|
||||
|
||||
&.black {
|
||||
background: linear-gradient(40deg, #1f2937, #111827);
|
||||
}
|
||||
|
||||
.mat-icon {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon,
|
||||
i {
|
||||
font-size: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.icons {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fill-space {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { SettingsGridComponent } from './settings-grid.component';
|
||||
|
||||
describe('SettingsGridComponent', () => {
|
||||
let component: SettingsGridComponent;
|
||||
let fixture: ComponentFixture<SettingsGridComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [SettingsGridComponent],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SettingsGridComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum';
|
||||
|
||||
import { SETTINGLINKS, SettingLinks } from './settinglinks';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-settings-grid',
|
||||
templateUrl: './settings-grid.component.html',
|
||||
styleUrls: ['./settings-grid.component.scss'],
|
||||
animations: [
|
||||
trigger('policy', [
|
||||
transition(':enter', [
|
||||
style({
|
||||
opacity: 0.5,
|
||||
}),
|
||||
animate(
|
||||
'.15s ease-in-out',
|
||||
style({
|
||||
opacity: 1,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
transition(':leave', [
|
||||
style({
|
||||
opacity: 1,
|
||||
}),
|
||||
animate(
|
||||
'.15s ease-in-out',
|
||||
style({
|
||||
opacity: 0.5,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class SettingsGridComponent implements OnInit {
|
||||
@Input() public type!: PolicyComponentServiceType;
|
||||
@Input() public tag: string = '';
|
||||
public PolicyComponentServiceType: any = PolicyComponentServiceType;
|
||||
public SETTINGS: SettingLinks[] = SETTINGLINKS;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.SETTINGS = this.SETTINGS.filter((setting) =>
|
||||
this.type === PolicyComponentServiceType.MGMT ? !!setting.orgRouterLink : !!setting.iamRouterLink,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
|
||||
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
|
||||
|
||||
import { InfoSectionModule } from '../info-section/info-section.module';
|
||||
import { SettingsGridComponent } from './settings-grid.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [SettingsGridComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
HasRolePipeModule,
|
||||
HasRoleModule,
|
||||
TranslateModule,
|
||||
RouterModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
InfoSectionModule,
|
||||
],
|
||||
exports: [SettingsGridComponent],
|
||||
})
|
||||
export class SettingsGridModule {}
|
||||
@@ -1,10 +1,4 @@
|
||||
<cnsl-sidenav [indented]="true" [setting]="setting()" (settingChange)="setting.set($event)" [settingsList]="settingsList">
|
||||
<ng-container *ngIf="setting()?.id === 'organizations'">
|
||||
<h2>{{ 'ORG.PAGES.LIST' | translate }}</h2>
|
||||
<p class="org-desc cnsl-secondary-text">{{ 'ORG.PAGES.LISTDESCRIPTION' | translate }}</p>
|
||||
|
||||
<cnsl-org-table></cnsl-org-table>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="setting()?.id === 'features'">
|
||||
<cnsl-features></cnsl-features>
|
||||
</ng-container>
|
||||
@@ -74,12 +68,6 @@
|
||||
<ng-container *ngIf="setting()?.id === 'failedevents' && serviceType === PolicyComponentServiceType.ADMIN">
|
||||
<cnsl-iam-failed-events></cnsl-iam-failed-events>
|
||||
</ng-container>
|
||||
<!-- todo: figure out permissions -->
|
||||
<ng-container *ngIf="setting()?.id === 'actions'">
|
||||
<cnsl-actions-two-actions />
|
||||
</ng-container>
|
||||
<ng-container *ngIf="setting()?.id === 'actions_targets'">
|
||||
<cnsl-actions-two-targets />
|
||||
</ng-container>
|
||||
|
||||
<ng-content></ng-content>
|
||||
</cnsl-sidenav>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
|
||||
import { SidenavSetting } from '../sidenav/sidenav.component';
|
||||
|
||||
export const ORGANIZATIONS: SidenavSetting = {
|
||||
id: 'organizations',
|
||||
i18nKey: 'SETTINGS.LIST.ORGS',
|
||||
groupI18nKey: 'SETTINGS.GROUPS.GENERAL',
|
||||
requiredRoles: {
|
||||
[PolicyComponentServiceType.ADMIN]: ['iam.read'],
|
||||
},
|
||||
};
|
||||
|
||||
export const FEATURESETTINGS: SidenavSetting = {
|
||||
id: 'features',
|
||||
i18nKey: 'SETTINGS.LIST.FEATURESETTINGS',
|
||||
@@ -222,23 +213,3 @@ export const BRANDING: SidenavSetting = {
|
||||
[PolicyComponentServiceType.ADMIN]: ['iam.policy.read'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ACTIONS: SidenavSetting = {
|
||||
id: 'actions',
|
||||
i18nKey: 'SETTINGS.LIST.ACTIONS',
|
||||
groupI18nKey: 'SETTINGS.GROUPS.ACTIONS',
|
||||
requiredRoles: {
|
||||
[PolicyComponentServiceType.ADMIN]: ['action.execution.write', 'action.target.write'],
|
||||
},
|
||||
beta: true,
|
||||
};
|
||||
|
||||
export const ACTIONS_TARGETS: SidenavSetting = {
|
||||
id: 'actions_targets',
|
||||
i18nKey: 'SETTINGS.LIST.TARGETS',
|
||||
groupI18nKey: 'SETTINGS.GROUPS.ACTIONS',
|
||||
requiredRoles: {
|
||||
[PolicyComponentServiceType.ADMIN]: ['action.execution.write', 'action.target.write'],
|
||||
},
|
||||
beta: true,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,72 @@
|
||||
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { merge, Subject, takeUntil } from 'rxjs';
|
||||
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { Component, effect, OnDestroy } from '@angular/core';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { ProjectState } from 'src/app/proto/generated/zitadel/project_pb';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { StorageLocation, StorageService } from 'src/app/services/storage.service';
|
||||
|
||||
import { SETTINGLINKS } from '../settings-grid/settinglinks';
|
||||
import { NewOrganizationService } from '../../services/new-organization.service';
|
||||
|
||||
export interface SettingLinks {
|
||||
i18nTitle: string;
|
||||
i18nDesc: string;
|
||||
iamRouterLink: any;
|
||||
orgRouterLink?: any;
|
||||
queryParams: any;
|
||||
iamWithRole?: string[];
|
||||
orgWithRole?: string[];
|
||||
icon?: string;
|
||||
svgIcon?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const LOGIN_GROUP: SettingLinks = {
|
||||
i18nTitle: 'SETTINGS.GROUPS.LOGIN',
|
||||
i18nDesc: 'POLICY.LOGIN_POLICY.DESCRIPTION',
|
||||
iamRouterLink: ['/settings'],
|
||||
orgRouterLink: ['/org-settings'],
|
||||
queryParams: { id: 'login' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
orgWithRole: ['policy.read'],
|
||||
icon: 'las la-sign-in-alt',
|
||||
color: 'green',
|
||||
};
|
||||
|
||||
export const APPEARANCE_GROUP: SettingLinks = {
|
||||
i18nTitle: 'SETTINGS.GROUPS.APPEARANCE',
|
||||
i18nDesc: 'POLICY.PRIVATELABELING.DESCRIPTION',
|
||||
iamRouterLink: ['/settings'],
|
||||
orgRouterLink: ['/org-settings'],
|
||||
queryParams: { id: 'branding' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
orgWithRole: ['policy.read'],
|
||||
icon: 'las la-swatchbook',
|
||||
color: 'blue',
|
||||
};
|
||||
|
||||
export const PRIVACY_POLICY: SettingLinks = {
|
||||
i18nTitle: 'DESCRIPTIONS.SETTINGS.PRIVACY_POLICY.TITLE',
|
||||
i18nDesc: 'POLICY.PRIVACY_POLICY.DESCRIPTION',
|
||||
iamRouterLink: ['/settings'],
|
||||
orgRouterLink: ['/org-settings'],
|
||||
queryParams: { id: 'privacypolicy' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
orgWithRole: ['policy.read'],
|
||||
icon: 'las la-file-contract',
|
||||
color: 'black',
|
||||
};
|
||||
|
||||
export const NOTIFICATION_GROUP: SettingLinks = {
|
||||
i18nTitle: 'SETTINGS.GROUPS.NOTIFICATIONS',
|
||||
i18nDesc: 'SETTINGS.LIST.NOTIFICATIONS_DESC',
|
||||
iamRouterLink: ['/settings'],
|
||||
queryParams: { id: 'smtpprovider' },
|
||||
iamWithRole: ['iam.policy.read'],
|
||||
icon: 'las la-bell',
|
||||
color: 'red',
|
||||
};
|
||||
|
||||
export const SETTINGLINKS: SettingLinks[] = [LOGIN_GROUP, APPEARANCE_GROUP, PRIVACY_POLICY, NOTIFICATION_GROUP];
|
||||
|
||||
export interface ShortcutItem {
|
||||
id: string;
|
||||
@@ -80,7 +139,7 @@ const CREATE_USER: ShortcutItem = {
|
||||
styleUrls: ['./shortcuts.component.scss'],
|
||||
})
|
||||
export class ShortcutsComponent implements OnDestroy {
|
||||
public org!: Org.AsObject;
|
||||
public orgId!: string;
|
||||
|
||||
public main: ShortcutItem[] = [];
|
||||
public secondary: ShortcutItem[] = [];
|
||||
@@ -92,26 +151,19 @@ export class ShortcutsComponent implements OnDestroy {
|
||||
private destroy$: Subject<void> = new Subject();
|
||||
public editState: boolean = false;
|
||||
public ProjectState: any = ProjectState;
|
||||
|
||||
constructor(
|
||||
private storageService: StorageService,
|
||||
private auth: GrpcAuthService,
|
||||
private mgmtService: ManagementService,
|
||||
private newOrganizationService: NewOrganizationService,
|
||||
) {
|
||||
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
|
||||
if (org && org.id) {
|
||||
this.org = org;
|
||||
this.loadProjectShortcuts();
|
||||
}
|
||||
|
||||
merge(this.auth.activeOrgChanged, this.mgmtService.ownedProjects)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
|
||||
if (org && org.id) {
|
||||
this.org = org;
|
||||
this.loadProjectShortcuts();
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const orgId = this.newOrganizationService.orgId();
|
||||
if (orgId) {
|
||||
this.orgId = orgId;
|
||||
this.loadProjectShortcuts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public loadProjectShortcuts(): void {
|
||||
@@ -151,14 +203,14 @@ export class ShortcutsComponent implements OnDestroy {
|
||||
});
|
||||
|
||||
this.ALL_SHORTCUTS = [...routesShortcuts, ...settingsShortcuts, ...mapped];
|
||||
this.loadShortcuts(this.org);
|
||||
this.loadShortcuts(this.orgId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public loadShortcuts(org: Org.AsObject): void {
|
||||
public loadShortcuts(orgId: string): void {
|
||||
['main', 'secondary', 'third'].map((listName) => {
|
||||
const joinedShortcuts = this.storageService.getItem(`shortcuts:${listName}:${org.id}`, StorageLocation.local);
|
||||
const joinedShortcuts = this.storageService.getItem(`shortcuts:${listName}:${orgId}`, StorageLocation.local);
|
||||
if (joinedShortcuts) {
|
||||
const parsedIds: string[] = joinedShortcuts.split(',');
|
||||
if (parsedIds && parsedIds.length) {
|
||||
@@ -244,26 +296,26 @@ export class ShortcutsComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
public saveStateToStorage(): void {
|
||||
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
|
||||
if (org && org.id) {
|
||||
this.storageService.setItem(`shortcuts:main:${org.id}`, this.main.map((p) => p.id).join(','), StorageLocation.local);
|
||||
const orgId = this.newOrganizationService.orgId();
|
||||
if (orgId) {
|
||||
this.storageService.setItem(`shortcuts:main:${orgId}`, this.main.map((p) => p.id).join(','), StorageLocation.local);
|
||||
this.storageService.setItem(
|
||||
`shortcuts:secondary:${org.id}`,
|
||||
`shortcuts:secondary:${orgId}`,
|
||||
this.secondary.map((p) => p.id).join(','),
|
||||
StorageLocation.local,
|
||||
);
|
||||
this.storageService.setItem(`shortcuts:third:${org.id}`, this.third.map((p) => p.id).join(','), StorageLocation.local);
|
||||
this.storageService.setItem(`shortcuts:third:${orgId}`, this.third.map((p) => p.id).join(','), StorageLocation.local);
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
|
||||
if (org && org.id) {
|
||||
const orgId = this.newOrganizationService.orgId();
|
||||
if (orgId) {
|
||||
['main', 'secondary', 'third'].map((listName) => {
|
||||
this.storageService.removeItem(`shortcuts:${listName}:${org.id}`, StorageLocation.local);
|
||||
this.storageService.removeItem(`shortcuts:${listName}:${orgId}`, StorageLocation.local);
|
||||
});
|
||||
|
||||
this.loadShortcuts(org);
|
||||
this.loadShortcuts(orgId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="cnsl-action-button">
|
||||
<mat-icon class="icon">add</mat-icon>
|
||||
<span>{{ 'GRANTS.ADD_BTN' | translate }}</span>
|
||||
<cnsl-action-keys (actionTriggered)="gotoCreateLink(routerLink)" [type]="ActionKeysType.ADD"></cnsl-action-keys>
|
||||
<cnsl-action-keys (actionTriggered)="router.navigate(routerLink)" [type]="ActionKeysType.ADD"></cnsl-action-keys>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Router } from '@angular/router';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { enterAnimations } from 'src/app/animations';
|
||||
import { UserGrant as AuthUserGrant } from 'src/app/proto/generated/zitadel/auth_pb';
|
||||
import { Role } from 'src/app/proto/generated/zitadel/project_pb';
|
||||
import {
|
||||
Type,
|
||||
UserGrant as MgmtUserGrant,
|
||||
@@ -24,7 +23,9 @@ import { PageEvent, PaginatorComponent } from '../paginator/paginator.component'
|
||||
import { UserGrantRoleDialogComponent } from '../user-grant-role-dialog/user-grant-role-dialog.component';
|
||||
import { WarnDialogComponent } from '../warn-dialog/warn-dialog.component';
|
||||
import { UserGrantContext, UserGrantsDataSource } from './user-grants-datasource';
|
||||
import { Org, OrgIDQuery, OrgQuery, OrgState } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
||||
import { NewOrganizationService } from '../../services/new-organization.service';
|
||||
|
||||
export enum UserGrantListSearchKey {
|
||||
DISPLAY_NAME,
|
||||
@@ -43,7 +44,6 @@ type UserGrantAsObject = AuthUserGrant.AsObject | MgmtUserGrant.AsObject;
|
||||
})
|
||||
export class UserGrantsComponent implements OnInit, AfterViewInit {
|
||||
public userGrantListSearchKey: UserGrantListSearchKey | undefined = undefined;
|
||||
public UserGrantListSearchKey: any = UserGrantListSearchKey;
|
||||
|
||||
public INITIAL_PAGE_SIZE: number = 50;
|
||||
@Input() context: UserGrantContext = UserGrantContext.NONE;
|
||||
@@ -62,27 +62,24 @@ export class UserGrantsComponent implements OnInit, AfterViewInit {
|
||||
@Input() grantId: string = '';
|
||||
@ViewChild('input') public filter!: MatInput;
|
||||
|
||||
public projectRoleOptions: Role.AsObject[] = [];
|
||||
public routerLink: any = undefined;
|
||||
|
||||
public loadedId: string = '';
|
||||
public loadedProjectId: string = '';
|
||||
public grantToEdit: string = '';
|
||||
|
||||
public UserGrantContext: any = UserGrantContext;
|
||||
public Type: any = Type;
|
||||
public ActionKeysType: any = ActionKeysType;
|
||||
public UserGrantState: any = UserGrantState;
|
||||
public UserGrantContext = UserGrantContext;
|
||||
public Type = Type;
|
||||
public ActionKeysType = ActionKeysType;
|
||||
public UserGrantState = UserGrantState;
|
||||
@Input() public type: Type | undefined = undefined;
|
||||
|
||||
public filterOpen: boolean = false;
|
||||
public myOrgs: Array<Org.AsObject> = [];
|
||||
constructor(
|
||||
private authService: GrpcAuthService,
|
||||
private userService: ManagementService,
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
private router: Router,
|
||||
private readonly authService: GrpcAuthService,
|
||||
private readonly userService: ManagementService,
|
||||
private readonly toast: ToastService,
|
||||
private readonly dialog: MatDialog,
|
||||
private readonly queryClient: QueryClient,
|
||||
protected readonly router: Router,
|
||||
private readonly newOrganizationService: NewOrganizationService,
|
||||
) {}
|
||||
|
||||
@Input() public displayedColumns: string[] = [
|
||||
@@ -149,10 +146,6 @@ export class UserGrantsComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
public gotoCreateLink(rL: any): void {
|
||||
this.router.navigate(rL);
|
||||
}
|
||||
|
||||
private loadGrantsPage(type: Type | undefined, searchQueries?: UserGrantQuery[]): void {
|
||||
let queries: UserGrantQuery[] = [];
|
||||
|
||||
@@ -315,15 +308,12 @@ export class UserGrantsComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
public async showUser(grant: UserGrant.AsObject) {
|
||||
const orgQuery = new OrgQuery();
|
||||
const orgIdQuery = new OrgIDQuery();
|
||||
orgIdQuery.setId(grant.grantedOrgId);
|
||||
orgQuery.setIdQuery(orgIdQuery);
|
||||
|
||||
const orgs = (await this.authService.listMyProjectOrgs(1, 0, [orgQuery])).resultList;
|
||||
if (orgs.length === 1) {
|
||||
this.authService.setActiveOrg(orgs[0]);
|
||||
this.router.navigate(['/users', grant.userId]);
|
||||
const org = await this.queryClient.fetchQuery(
|
||||
this.newOrganizationService.organizationByIdQueryOptions(grant.grantedOrgId),
|
||||
);
|
||||
if (org) {
|
||||
this.newOrganizationService.setOrgId(grant.grantedOrgId);
|
||||
await this.router.navigate(['/users', grant.userId]);
|
||||
} else {
|
||||
this.toast.showInfo('GRANTS.TOAST.CANTSHOWINFO', true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user